From dc837c25f28e737ce0eace7357eda59827f853af Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:07:04 +0200 Subject: [PATCH] feat: let an install link name the extension version to offer in the marketplace --- app/(main)/admin/login/page.tsx | 7 ++++-- lib/__tests__/connector-registry.test.ts | 32 ++++++++++++++++++++++++ lib/connector/admin-next.ts | 14 +++++++---- lib/connector/registry.ts | 15 ++++++++--- lib/release-version.ts | 10 ++++++++ 5 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 lib/release-version.ts diff --git a/app/(main)/admin/login/page.tsx b/app/(main)/admin/login/page.tsx index 3e870475b..e00ce26c4 100644 --- a/app/(main)/admin/login/page.tsx +++ b/app/(main)/admin/login/page.tsx @@ -44,9 +44,12 @@ export default function AdminLoginPage() { const next = safeAdminNext(new URLSearchParams(window.location.search).get('next')); if (next) { const [path, query] = next.split('?', 2); - const tab = new URLSearchParams(query ?? '').get('tab'); + const search = new URLSearchParams(query ?? ''); + const tab = search.get('tab'); if (tab && isAdminTab(tab)) useAdminTabStore.getState().setActiveTab(tab); - router.push(path); + // The tab rides in the store; a version pick (an install link to one + // version of an extension) stays in the URL for the marketplace page. + router.push(search.has('version') ? next : path); return; } router.push('/admin'); diff --git a/lib/__tests__/connector-registry.test.ts b/lib/__tests__/connector-registry.test.ts index fd8a334f7..dfef636d0 100644 --- a/lib/__tests__/connector-registry.test.ts +++ b/lib/__tests__/connector-registry.test.ts @@ -82,6 +82,24 @@ describe('resolving to app paths', () => { } }); + it('carries the version an install link asked for into the marketplace page', () => { + expect(path('admin_extension', { slug: 'quick-reply', version: '1.2.0' })).toBe( + '/admin/marketplace/quick-reply?version=1.2.0', + ); + expect(path('admin_extension', { slug: 'quick-reply', version: '2.0.0-beta.1' })).toBe( + '/admin/marketplace/quick-reply?version=2.0.0-beta.1', + ); + }); + + it('survives the admin login round-trip as a whole', () => { + // The admin layout parks the resolved path in ?next=; the gate there must + // let exactly what the registry produces through, or a versioned install + // link would drop its version (or the whole path) on the way. + const versioned = path('admin_extension', { slug: 'quick-reply', version: '1.2.0' }); + expect(safeAdminNext(versioned)).toBe(versioned); + expect(safeAdminNext(encodeURIComponent(versioned))).toBe(versioned); + }); + it('defaults the admin tab when the link named none', () => { const admin = resolve('admin'); if (admin.kind === 'ok') expect(admin.resolution.adminTab).toBe('dashboard'); @@ -125,6 +143,15 @@ describe('parameter validation', () => { }, ); + it.each(['1.2', 'v1.2.3', '1.2.3+build.5', 'latest', '1.2.3/..', '1.2.3?x', '1.2.3-'])( + 'refuses the extension version %j', + (version) => { + const result = resolve('admin_extension', { slug: 'quick-reply', version }); + expect(result.kind).toBe('bad-param'); + if (result.kind === 'bad-param') expect(result.param).toBe('version'); + }, + ); + it.each(['2026-02-31', '2026-13-01', '20260920', 'tomorrow'])( 'refuses the date %j', (date) => { @@ -167,6 +194,8 @@ describe('safeAdminNext', () => { ['/admin/', '/admin/'], ['/admin/marketplace/quick-reply', '/admin/marketplace/quick-reply'], ['/admin?tab=policy', '/admin?tab=policy'], + ['/admin/marketplace/quick-reply?version=1.2.0', '/admin/marketplace/quick-reply?version=1.2.0'], + ['/admin/marketplace/quick-reply?version=2.0.0-beta.1', '/admin/marketplace/quick-reply?version=2.0.0-beta.1'], ['%2Fadmin%2Fplugins%2Fx', '/admin/plugins/x'], ])('accepts %j', (raw, expected) => { expect(safeAdminNext(raw)).toBe(expected); @@ -185,6 +214,9 @@ describe('safeAdminNext', () => { '/admin/../mail', '/adminfoo', '/admin?tab=policy&x=1', + '/admin/marketplace/x?version=1.2.0&tab=policy', + '/admin/marketplace/x?version=../../etc', + '/admin/marketplace/x?version=' + '1'.repeat(70), '/admin?next=https://evil.example', '%2f%2fevil.example', '/admin/' + 'a'.repeat(600), diff --git a/lib/connector/admin-next.ts b/lib/connector/admin-next.ts index 3f580b050..a669a4b06 100644 --- a/lib/connector/admin-next.ts +++ b/lib/connector/admin-next.ts @@ -20,9 +20,11 @@ const SAFE = /^\/admin(?:\/[A-Za-z0-9._~-]+)*\/?$/; * Refuses anything that could leave this origin or this area: a scheme, a * protocol-relative `//host`, a backslash (which some browsers normalise to * `/`), an encoded separator, a `..` segment, or a path outside `/admin`. - * A `?tab=` query is allowed, and validated by the caller against the real - * tab list - the admin shell keeps its tab in a store, so that is how a link - * reaches a specific panel. + * Two queries are allowed. `?tab=` is validated by the caller against the + * real tab list - the admin shell keeps its tab in a store, so that is how a + * link reaches a specific panel. `?version=` is the version a connector + * install link asked for (`/admin/marketplace/?version=1.2.0`); the + * marketplace page checks it again against the directory before offering it. */ export function safeAdminNext(raw: string | null | undefined): string | null { if (!raw) return null; @@ -44,8 +46,10 @@ export function safeAdminNext(raw: string | null | undefined): string | null { const [path, query = ''] = value.split('?', 2); if (!SAFE.test(path)) return null; if (path.split('/').includes('..')) return null; - // Only the tab hand-off, and only as a plain word. - if (query && !/^tab=[a-z_]{1,32}$/.test(query)) return null; + // Only the tab hand-off or the version pick, each as one plain value. + if (query && !/^tab=[a-z_]{1,32}$/.test(query) && !/^version=[0-9A-Za-z.-]{1,64}$/.test(query)) { + return null; + } return query ? `${path}?${query}` : path; } diff --git a/lib/connector/registry.ts b/lib/connector/registry.ts index 9b3b02a59..6fbdb1156 100644 --- a/lib/connector/registry.ts +++ b/lib/connector/registry.ts @@ -30,6 +30,7 @@ import { ADMIN_TABS, type AdminTabId } from '@/stores/admin-tab-store'; import type { SettingsSearchTab } from '@/lib/settings-search'; import { buildSettingsPath, buildFilesPath } from '@/lib/deep-links'; +import { RELEASE_VERSION } from '@/lib/release-version'; /** Mirrors `SettingsSearchTab`. Listed so an unknown tab is refused here. */ const SETTINGS_TABS: readonly SettingsSearchTab[] = [ @@ -48,7 +49,7 @@ const CALENDAR_VIEWS = ['month', 'week', 'day', 'agenda', 'tasks'] as const; // JMAP id. The connector carries the alias list purely as suggestions for its // link generator. -export type ParamType = 'enum' | 'slug' | 'date' | 'id' | 'path'; +export type ParamType = 'enum' | 'slug' | 'date' | 'id' | 'path' | 'version'; export interface ParamSpec { readonly type: ParamType; @@ -137,9 +138,15 @@ export const TARGETS: readonly Target[] = [ { name: 'admin_extension', label: 'an extension in the marketplace', - params: { slug: { type: 'slug', required: true } }, + // `version` picks the version the page offers to install; without it the + // page offers the newest one this Bulwark can run. The marketplace page + // reads it from the query string, and `safeAdminNext` lets exactly this + // query through the admin login. + params: { slug: { type: 'slug', required: true }, version: { type: 'version' } }, resolve: (p) => ({ - path: `/admin/marketplace/${encodeURIComponent(p.slug)}`, + path: p.version + ? `/admin/marketplace/${encodeURIComponent(p.slug)}?version=${encodeURIComponent(p.version)}` + : `/admin/marketplace/${encodeURIComponent(p.slug)}`, admin: true, }), }, @@ -195,6 +202,8 @@ function checkParam(spec: ParamSpec, value: string): boolean { return SLUG.test(value); case 'date': return isRealDate(value); + case 'version': + return RELEASE_VERSION.test(value); case 'id': // Becomes one path segment, so it must not be able to climb out of it. // `%` is refused along with the separators because the value is decoded diff --git a/lib/release-version.ts b/lib/release-version.ts new file mode 100644 index 000000000..47f84f0ff --- /dev/null +++ b/lib/release-version.ts @@ -0,0 +1,10 @@ +/** + * The shape of a version the extension directory publishes: semver core with + * an optional pre-release (`1.2.3`, `2.0.0-beta.1`). Build metadata is not + * part of a published version and is refused with everything else. + * + * Shared by the connector registry (the `version` link parameter), the admin + * login's `?next=` gate and the marketplace routes, so a version that passes + * one passes all of them. + */ +export const RELEASE_VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;