From 9a1e33bfbbfdca7eb293c2c60e3b6039d801f284 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 17:21:45 +0000 Subject: [PATCH 1/6] Use public identifiers for directory pages and publishing --- README.md | 4 + openapi/extensions-v2.json | 125 ++++++++++++++---- src/components/ExtensionCard.astro | 2 +- src/components/ExtensionForm.astro | 19 +-- src/components/ExtensionHeader.astro | 4 +- src/components/OwnerExtensionView.astro | 2 +- src/components/admin/ClaimCard.astro | 6 +- .../admin/DecidedRevisionsTable.astro | 13 +- .../admin/DeveloperProfilesTable.astro | 8 +- .../admin/ExtensionAdminTable.astro | 12 +- .../admin/PendingRevisionsTable.astro | 16 ++- src/lib/api/client.ts | 14 +- .../api/generated/extensions-v2/types.gen.ts | 30 +++-- src/lib/developer-form.ts | 9 +- src/lib/directory-filters.ts | 4 +- src/lib/directory-pagination.ts | 2 +- src/lib/extension-form.ts | 14 +- src/lib/extension-view.ts | 4 +- src/lib/extensions-data.ts | 12 +- .../account/admin/developers/index.astro | 4 +- src/pages/account/admin/extensions/[id].astro | 4 +- .../account/admin/extensions/[id]/edit.astro | 18 +-- src/pages/account/developer/index.astro | 46 ++++--- .../account/developer/transfer/revoke.ts | 2 +- src/pages/account/extensions/new.astro | 2 +- src/pages/account/index.astro | 18 ++- src/pages/api/admin/revision-detail.ts | 2 +- src/pages/developer/[id].astro | 10 +- src/pages/extension/[id].astro | 4 +- src/pages/index.astro | 6 +- src/scripts/extension-directory.ts | 6 +- src/types/index.ts | 4 +- test/directory-pagination.test.ts | 2 +- test/extension-directory.test.ts | 4 +- test/extension-form.test.ts | 21 +-- test/extension-view.test.ts | 14 +- test/extensions-api.test.ts | 39 +++--- test/extensions-data.test.ts | 19 ++- test/helpers/directory-fixtures.ts | 2 + 39 files changed, 336 insertions(+), 191 deletions(-) diff --git a/README.md b/README.md index b49020cc..e643f1f0 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,7 @@ The generated v2 client follows the API's manifest v1 contract: `module`, string Package icons are paths relative to the distribution root. For GitHub release assets matching the declared source repository, the site resolves them against the release tag. `FOSSBilling/modules` uses its `Ucfirst(id)/` package directory; other GitHub sources use the repository root. Unsupported sources omit relative icons. Legacy HTTP(S) icons continue to work. API mutations and site mutations both purge the `extensions` and `developers` tags. The `developers` tag is currently a no-op reserved for future cached surfaces; developer pages are uncached. Deploy this site with the API's manifest migration and cache-tag change before enabling the updated modules publisher. Existing cached pages expire through their cache windows; deployment must coordinate the new contract and tag. Old pending revisions require resubmission using the manifest fields before approval. + +## Directory Identity + +Extension and developer records use UUIDv7 internal IDs. Public routes, profile handles and manifest IDs use their separate `identifier` fields. Keep UUIDs for record identity and use identifiers when constructing public links or package manifests. diff --git a/openapi/extensions-v2.json b/openapi/extensions-v2.json index aa69be9c..0e8e61f4 100644 --- a/openapi/extensions-v2.json +++ b/openapi/extensions-v2.json @@ -115,6 +115,10 @@ "description": "Null when a legacy URL exceeds the card length bound" }, "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "developer": { @@ -132,6 +136,7 @@ "version", "download_url", "id", + "identifier", "developer" ] }, @@ -154,7 +159,7 @@ "PublicDeveloper": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, @@ -177,6 +182,10 @@ "maxLength": 2048, "format": "uri" }, + "id": { + "type": "string", + "format": "uuid" + }, "approved": { "type": "boolean" }, @@ -184,12 +193,23 @@ "type": "boolean" } }, - "required": ["id", "type", "name", "approved", "unclaimed"] + "required": [ + "identifier", + "type", + "name", + "id", + "approved", + "unclaimed" + ] }, "OwnedExtensionListItem": { "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "developer": { @@ -300,6 +320,7 @@ }, "required": [ "id", + "identifier", "developer", "published", "pending_revision", @@ -450,13 +471,17 @@ "minLength": 1 }, "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "developer": { "$ref": "#/components/schemas/PublicDeveloper" } }, - "required": ["id", "developer"] + "required": ["id", "identifier", "developer"] } ] }, @@ -965,7 +990,7 @@ "maxLength": 2048, "format": "uri" }, - "id": { + "identifier": { "type": "string", "minLength": 1, "maxLength": 64, @@ -985,7 +1010,7 @@ "source", "version", "download_url", - "id" + "identifier" ], "additionalProperties": false }, @@ -1104,6 +1129,10 @@ "type": "string" }, "extension_id": { + "type": "string", + "format": "uuid" + }, + "extension_identifier": { "type": "string" }, "developer_id": { @@ -1157,6 +1186,7 @@ "required": [ "id", "extension_id", + "extension_identifier", "developer_id", "submitted_by", "status", @@ -1180,6 +1210,10 @@ "type": "string" }, "extension_id": { + "type": "string", + "format": "uuid" + }, + "extension_identifier": { "type": "string" }, "developer_id": { @@ -1243,6 +1277,7 @@ "required": [ "id", "extension_id", + "extension_identifier", "developer_id", "submitted_by", "status", @@ -1343,7 +1378,8 @@ "type": "string" }, "developer_id": { - "type": "string" + "type": "string", + "format": "uuid" }, "claimant_id": { "type": "string" @@ -1400,6 +1436,9 @@ { "type": "object", "properties": { + "developer_identifier": { + "type": "string" + }, "developer_name": { "type": "string" }, @@ -1415,6 +1454,7 @@ } }, "required": [ + "developer_identifier", "developer_name", "developer_type", "claimant_name", @@ -1431,6 +1471,10 @@ { "type": "object", "properties": { + "id": { + "type": "string", + "format": "uuid" + }, "approved": { "type": "boolean" }, @@ -1464,14 +1508,19 @@ "type": ["string", "null"] } }, - "required": ["approved", "content_revision", "profile_generation"] + "required": [ + "id", + "approved", + "content_revision", + "profile_generation" + ] } ] }, "Developer": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, @@ -1500,7 +1549,7 @@ "format": "email" } }, - "required": ["id", "type", "name"] + "required": ["identifier", "type", "name"] }, "ReviewNoteRequired": { "type": "object", @@ -1733,7 +1782,8 @@ "type": "string" }, "developer_id": { - "type": "string" + "type": "string", + "format": "uuid" }, "can_publish": { "type": "boolean" @@ -1758,8 +1808,7 @@ "properties": { "developer_id": { "type": "string", - "minLength": 1, - "maxLength": 200 + "format": "uuid" }, "can_publish": { "type": "boolean", @@ -1795,7 +1844,7 @@ "DeveloperInput": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, @@ -1824,7 +1873,7 @@ "format": "email" } }, - "required": ["id", "type", "name"], + "required": ["identifier", "type", "name"], "additionalProperties": false }, "DeveloperDetailResponse": { @@ -1883,7 +1932,7 @@ }, "required": false, "description": "Public scope only", - "name": "developer_id", + "name": "developer_identifier", "in": "query" }, { @@ -2026,7 +2075,7 @@ "result": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string" }, "revision_id": { @@ -2035,9 +2084,13 @@ "status": { "type": "string", "enum": ["pending"] + }, + "id": { + "type": "string", + "format": "uuid" } }, - "required": ["id", "revision_id", "status"] + "required": ["identifier", "revision_id", "status", "id"] } }, "required": ["result"] @@ -2250,7 +2303,7 @@ "result": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string" }, "revision_id": { @@ -2261,7 +2314,7 @@ "enum": ["pending"] } }, - "required": ["id", "revision_id", "status"] + "required": ["identifier", "revision_id", "status"] } }, "required": ["result"] @@ -2392,6 +2445,10 @@ "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "deleted": { @@ -2399,7 +2456,7 @@ "enum": [true] } }, - "required": ["id", "deleted"] + "required": ["id", "identifier", "deleted"] } }, "required": ["result"] @@ -2601,7 +2658,7 @@ "result": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string" }, "revision_id": { @@ -2612,7 +2669,7 @@ "enum": ["pending"] } }, - "required": ["id", "revision_id", "status"] + "required": ["identifier", "revision_id", "status"] } }, "required": ["result"] @@ -4505,6 +4562,10 @@ "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "status": { @@ -4517,7 +4578,7 @@ "example": true } }, - "required": ["id", "status", "notified"] + "required": ["id", "identifier", "status", "notified"] } }, "required": ["result"] @@ -4640,6 +4701,10 @@ "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "status": { @@ -4652,7 +4717,7 @@ "example": true } }, - "required": ["id", "status", "notified"] + "required": ["id", "identifier", "status", "notified"] } }, "required": ["result"] @@ -4764,6 +4829,10 @@ "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "revision_id": { @@ -4774,7 +4843,7 @@ "enum": ["approved"] } }, - "required": ["id", "revision_id", "status"] + "required": ["id", "identifier", "revision_id", "status"] } }, "required": ["result"] @@ -4937,6 +5006,10 @@ "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "approved": { @@ -4949,7 +5022,7 @@ "example": true } }, - "required": ["id", "approved", "notified"] + "required": ["id", "identifier", "approved", "notified"] } }, "required": ["result"] diff --git a/src/components/ExtensionCard.astro b/src/components/ExtensionCard.astro index d1e67bf1..fc99eae7 100644 --- a/src/components/ExtensionCard.astro +++ b/src/components/ExtensionCard.astro @@ -19,7 +19,7 @@ const iconUrl = await getSignedImageUrl( requests to Worker routes with an empty 503 (cf-speculation-refused), so any prefetch strategy only produces console errors, never speed. --> !popularSpdxIds.has(id)); Publisher

- Publishing as {developer.name} ({developer.id}). + Publishing as {developer.name} ( + {developer.identifier}). !popularSpdxIds.has(id)); />

-

diff --git a/src/components/OwnerExtensionView.astro b/src/components/OwnerExtensionView.astro index ab82d526..70a43abd 100644 --- a/src/components/OwnerExtensionView.astro +++ b/src/components/OwnerExtensionView.astro @@ -12,7 +12,7 @@ interface Props { userId: string; } const { owned, userId } = Astro.props; -const id = owned.id; +const id = owned.identifier; const env = Astro.locals.env; const api = createApiClient(env, userId); // A review decision and publication are independent: rejecting an update diff --git a/src/components/admin/ClaimCard.astro b/src/components/admin/ClaimCard.astro index 0881a864..9cdb06d6 100644 --- a/src/components/admin/ClaimCard.astro +++ b/src/components/admin/ClaimCard.astro @@ -21,7 +21,7 @@ const Title = titleTag;

  • - {c.developer_name} ({c.developer_id}) + {c.developer_name} ({c.developer_identifier}) {c.github_org_verified && <span class="badge">GitHub Verified</span>}

    @@ -61,7 +61,7 @@ const Title = titleTag; { return ( <> - +

    - {r.name ?? r.extension_id} + {r.name ?? r.extension_identifier}

    -

    {r.extension_id}

    +

    + {r.extension_identifier} +

    {!showNote && r.review_note && (

    {d.name}

    -

    {d.id}

    +

    {d.identifier}

    {d.URL && (

    - - {ext.published?.name ?? ext.id} + + {ext.published?.name ?? ext.identifier}

    -

    {ext.id}

    +

    {ext.identifier}

    {ext.developer.name} @@ -66,7 +66,7 @@ function statusOf(ext: OwnedExtensionListItem) {
    Delist diff --git a/src/components/admin/PendingRevisionsTable.astro b/src/components/admin/PendingRevisionsTable.astro index 666c8135..f881c1d2 100644 --- a/src/components/admin/PendingRevisionsTable.astro +++ b/src/components/admin/PendingRevisionsTable.astro @@ -31,23 +31,27 @@ const DETAIL_COLUMNS = 4; {revisions.map((r) => { - const title = r.name ?? r.extension_id; + const title = r.name ?? r.extension_identifier; const version = r.version?.trim() || null; const description = r.description?.trim() || null; return ( <>

    - + {title}

    -

    {r.extension_id}

    +

    + {r.extension_identifier} +

    {version ?? '—'} @@ -79,7 +83,7 @@ const DETAIL_COLUMNS = 4; data-size="sm" disabled={!r.content_available} data-open-approve={r.id} - data-extension-id={r.extension_id} + data-extension-id={r.extension_identifier} data-revision-id={r.id} data-title={title} > @@ -91,7 +95,7 @@ const DETAIL_COLUMNS = 4; data-size="sm" data-variant="destructive" data-open-reject={r.id} - data-extension-id={r.extension_id} + data-extension-id={r.extension_identifier} data-revision-id={r.id} data-title={title} > diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 9ded9687..326868fe 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -74,12 +74,12 @@ type ExtensionListQuery = NonNullable; type RevisionQueueQuery = NonNullable; type ListFilters = Pick< ExtensionListQuery, - 'type' | 'developer_id' | 'status' | 'q' | 'limit' | 'cursor' + 'type' | 'developer_identifier' | 'status' | 'q' | 'limit' | 'cursor' >; export type ExtensionDirectoryFilters = Pick< ExtensionListQuery, - 'type' | 'developer_id' | 'limit' | 'cursor' + 'type' | 'developer_identifier' | 'limit' | 'cursor' >; type ExtensionMineFilters = Pick; @@ -382,8 +382,8 @@ function listQuery( if (filters.type !== undefined) { query.type = filters.type; } - if (filters.developer_id !== undefined) { - query.developer_id = filters.developer_id; + if (filters.developer_identifier !== undefined) { + query.developer_identifier = filters.developer_identifier; } if (filters.status !== undefined) { query.status = filters.status; @@ -410,7 +410,7 @@ export async function listExtensions( return cachedEdgeRead( dataCacheKey('extensions', { cursor: query.cursor, - developer_id: query.developer_id, + developer_identifier: query.developer_identifier, limit: query.limit, type: query.type, }), @@ -429,7 +429,7 @@ export async function listExtensions( ); } -export async function getExtensionById( +export async function getExtensionByIdentifier( env: ApplicationEnv, id: string, ): Promise { @@ -445,7 +445,7 @@ export async function getExtensionById( ); } -export async function getDeveloperById( +export async function getDeveloperByIdentifier( env: ApplicationEnv, id: string, ): Promise { diff --git a/src/lib/api/generated/extensions-v2/types.gen.ts b/src/lib/api/generated/extensions-v2/types.gen.ts index ac2a3c67..ac39c5da 100644 --- a/src/lib/api/generated/extensions-v2/types.gen.ts +++ b/src/lib/api/generated/extensions-v2/types.gen.ts @@ -37,6 +37,7 @@ export type ExtensionListItem = { */ download_url: string | null; id: string; + identifier: string; developer: PublicDeveloper; }; @@ -46,17 +47,19 @@ export type Repository = { }; export type PublicDeveloper = { - id: string; + identifier: string; type: 'user' | 'organization'; name: string; URL?: string; avatar_url?: string; + id: string; approved: boolean; unclaimed: boolean; }; export type OwnedExtensionListItem = { id: string; + identifier: string; developer: PublicDeveloper; published: { manifest_version: 1; @@ -145,6 +148,7 @@ export type Extension = ExtensionContent & { version?: string; download_url?: string; id: string; + identifier: string; developer: PublicDeveloper; }; @@ -286,7 +290,7 @@ export type ExtensionCreate = { source: Repository; version: string; download_url: string; - id: string; + identifier: string; }; export type ExtensionUpdate = { @@ -318,6 +322,7 @@ export type ExtensionUpdate = { export type ExtensionRevisionSummary = { id: string; extension_id: string; + extension_identifier: string; developer_id: string; submitted_by: string; status: 'pending' | 'approved' | 'rejected'; @@ -337,6 +342,7 @@ export type ExtensionRevisionSummary = { export type ExtensionRevision = { id: string; extension_id: string; + extension_identifier: string; developer_id: string; submitted_by: string; status: 'pending' | 'approved' | 'rejected'; @@ -397,6 +403,7 @@ export type ClaimNote = { }; export type PendingDeveloperClaim = DeveloperClaim & { + developer_identifier: string; developer_name: string; developer_type: 'user' | 'organization'; claimant_name: string | null; @@ -404,6 +411,7 @@ export type PendingDeveloperClaim = DeveloperClaim & { }; export type DeveloperProfile = Developer & { + id: string; approved: boolean; content_revision: number; profile_generation: string; @@ -417,7 +425,7 @@ export type DeveloperProfile = Developer & { }; export type Developer = { - id: string; + identifier: string; type: 'user' | 'organization'; name: string; URL?: string; @@ -508,7 +516,7 @@ export type OwnedDeveloperProfile = | null; export type DeveloperInput = { - id: string; + identifier: string; type: 'user' | 'organization'; name: string; URL?: string; @@ -534,7 +542,7 @@ export type GetExtensionsData = { /** * Public scope only */ - developer_id?: string; + developer_identifier?: string; limit?: number; /** * Opaque cursor returned by the previous page @@ -638,9 +646,10 @@ export type PostExtensionsResponses = { */ 201: { result: { - id: string; + identifier: string; revision_id: string; status: 'pending'; + id: string; }; }; }; @@ -690,6 +699,7 @@ export type DeleteExtensionsByIdResponses = { 200: { result: { id: string; + identifier: string; deleted: true; }; }; @@ -800,7 +810,7 @@ export type PutExtensionsByIdResponses = { */ 202: { result: { - id: string; + identifier: string; revision_id: string; status: 'pending'; }; @@ -967,7 +977,7 @@ export type PutExtensionsByIdRevisionsByRevisionIdResponses = { */ 202: { result: { - id: string; + identifier: string; revision_id: string; status: 'pending'; }; @@ -1814,6 +1824,7 @@ export type PostExtensionsByIdDelistResponses = { 200: { result: { id: string; + identifier: string; status: 'delisted'; /** * Whether a notification email was dispatched - delivery itself is asynchronous @@ -1877,6 +1888,7 @@ export type PostExtensionsByIdRelistResponses = { 200: { result: { id: string; + identifier: string; status: 'relisted'; /** * Whether a notification email was dispatched - delivery itself is asynchronous @@ -1951,6 +1963,7 @@ export type PostExtensionsByIdModeratorCorrectResponses = { 200: { result: { id: string; + identifier: string; revision_id: string; status: 'approved'; }; @@ -2011,6 +2024,7 @@ export type PostDevelopersByIdApproveResponses = { 200: { result: { id: string; + identifier: string; approved: true; /** * Whether a notification email was dispatched - delivery itself is asynchronous diff --git a/src/lib/developer-form.ts b/src/lib/developer-form.ts index 561eff8a..bb5366fd 100644 --- a/src/lib/developer-form.ts +++ b/src/lib/developer-form.ts @@ -18,13 +18,14 @@ export function buildDeveloperProfile( ): DeveloperProfileInput { const str = (name: string) => formString(form, name); const typeInput = str('type'); - const id = existingDeveloper?.id ?? str('id').toLowerCase(); - if (!id) { - throw new DeveloperValidationError('Publisher ID is required.'); + const identifier = + existingDeveloper?.identifier ?? str('identifier').toLowerCase(); + if (!identifier) { + throw new DeveloperValidationError('Publisher Identifier is required.'); } return { - id, + identifier, type: isDeveloperType(typeInput) ? typeInput : 'user', name: str('name'), URL: withHttpsScheme(str('url')), diff --git a/src/lib/directory-filters.ts b/src/lib/directory-filters.ts index 5dc5c754..4c65b1b7 100644 --- a/src/lib/directory-filters.ts +++ b/src/lib/directory-filters.ts @@ -21,9 +21,9 @@ export function parseDirectoryFilters(params: URLSearchParams): if (type) { filters.type = type; } - const developerId = params.get('developer_id'); + const developerId = params.get('developer_identifier'); if (developerId !== null) { - filters.developer_id = developerId; + filters.developer_identifier = developerId; } const limit = params.get('limit'); if (limit !== null) { diff --git a/src/lib/directory-pagination.ts b/src/lib/directory-pagination.ts index 9d54ae9a..d47c4e95 100644 --- a/src/lib/directory-pagination.ts +++ b/src/lib/directory-pagination.ts @@ -11,7 +11,7 @@ type DirectoryPage = { type DirectoryPageFilters = { type?: string; - developer_id?: string; + developer_identifier?: string; limit?: number; }; diff --git a/src/lib/extension-form.ts b/src/lib/extension-form.ts index 7bd472ad..c3cf92e4 100644 --- a/src/lib/extension-form.ts +++ b/src/lib/extension-form.ts @@ -7,7 +7,9 @@ import { repositoryFromFields, repositoryInputUrl } from './repository'; import { formString } from './form'; import { withHttpsScheme } from './url-prefix'; -export type ExtensionFormEcho = Extension & { moduleRequirementsInput: string }; +export type ExtensionFormEcho = Omit & { + moduleRequirementsInput: string; +}; // Thrown for form-level validation failures that should be shown to the // user, distinct from ApiRequestError (the api rejecting an otherwise @@ -191,7 +193,7 @@ function buildContent( // PUT can't rename, since the id comes from the path and is immutable. export function buildExtensionCreatePayload(form: FormData): ExtensionCreate { return { - id: formString(form, 'extension_id').toLowerCase(), + identifier: formString(form, 'extension_identifier').toLowerCase(), ...buildContent( form, { releases: [], version: '', download_url: '' }, @@ -223,7 +225,7 @@ export function buildExtensionUpdatePayload( // page so a new form field only needs adding here. export function echoExtensionFromForm( form: FormData, - base: Extension, + base: Omit, ): ExtensionFormEcho { const selectedLicenseId = formString(form, 'license_spdx_id'); const isRecognizedLicense = @@ -281,7 +283,7 @@ export function echoReleaseDraftFromForm(form: FormData): ReleaseDraft { } // Best-effort echo for the create page: there is no existing extension to -// spread over, so the base is a blank, submission-shaped extension — the id +// spread over, so the base is a blank, submission-shaped extension — the identifier // comes from the form (lowercased, matching what a successful submit would // store) and the developer is the submitter's own profile. Returns both the // field echo and the release draft for ExtensionForm's redisplay props. @@ -289,11 +291,11 @@ export function echoNewExtensionFromForm( form: FormData, developer: Extension['developer'], ): { extension: ExtensionFormEcho; releaseDraft: ReleaseDraft } { - const base: Extension = { + const base: Omit = { manifest_version: 1, author: { name: developer.name, url: developer.URL }, requires: { fossbilling: '', php: '' }, - id: formString(form, 'extension_id').toLowerCase(), + identifier: formString(form, 'extension_identifier').toLowerCase(), type: 'module', name: '', description: '', diff --git a/src/lib/extension-view.ts b/src/lib/extension-view.ts index 759314a1..58a69038 100644 --- a/src/lib/extension-view.ts +++ b/src/lib/extension-view.ts @@ -1,7 +1,7 @@ import { ApiRequestError, createApiClient, - getExtensionById, + getExtensionByIdentifier, type OwnedExtension, } from './api/client'; import type { Extension } from '@/types'; @@ -39,5 +39,5 @@ export async function loadExtensionView( throw error; } } - return { kind: 'public', extension: await getExtensionById(env, id) }; + return { kind: 'public', extension: await getExtensionByIdentifier(env, id) }; } diff --git a/src/lib/extensions-data.ts b/src/lib/extensions-data.ts index efef291e..04a45791 100644 --- a/src/lib/extensions-data.ts +++ b/src/lib/extensions-data.ts @@ -3,7 +3,7 @@ // generated Extensions v2 client. import { createApiClient, - getDeveloperById as getDeveloperByIdFromApi, + getDeveloperByIdentifier as getDeveloperByIdentifierFromApi, paginateAll, type DeveloperProfile as ApiDeveloperProfile, type Extension as ApiExtension, @@ -52,6 +52,7 @@ export interface LastReview { // must not collapse them into a single derived status. export interface OwnedExtensionDetail { id: string; + identifier: string; developer: PublicDeveloperProfile; published: Extension | null; pendingRevision: PendingRevision | null; @@ -64,6 +65,7 @@ function toDeveloperProfile( ): DeveloperProfile & { has_pending_transfer?: boolean } { return { id: developer.id, + identifier: developer.identifier, type: developer.type, name: developer.name, URL: developer.URL, @@ -86,6 +88,7 @@ function toDeveloperProfile( function toPublicDeveloper(developer: PublicDeveloper): PublicDeveloperProfile { return { id: developer.id, + identifier: developer.identifier, type: developer.type, name: developer.name, URL: developer.URL, @@ -101,6 +104,7 @@ function toExtension(extension: ApiExtension): Extension { author: extension.author, requires: extension.requires, id: extension.id, + identifier: extension.identifier, type: extension.type, name: extension.name, description: extension.description, @@ -126,11 +130,13 @@ export function toOwnedExtensionDetail( ): OwnedExtensionDetail { return { id: owned.id, + identifier: owned.identifier, developer: toPublicDeveloper(owned.developer), published: owned.published ? toExtension({ ...owned.published, id: owned.id, + identifier: owned.identifier, developer: owned.developer, }) : null, @@ -215,12 +221,12 @@ export async function getDeveloperByOwner( } } -export async function getDeveloperById( +export async function getDeveloperByIdentifier( env: ApplicationEnv, id: string, ): Promise { try { - return toPublicDeveloper(await getDeveloperByIdFromApi(env, id)); + return toPublicDeveloper(await getDeveloperByIdentifierFromApi(env, id)); } catch { // Public pages historically treated any domain read failure as a missing // profile. Keep that fail-soft behavior at the API adapter boundary. diff --git a/src/pages/account/admin/developers/index.astro b/src/pages/account/admin/developers/index.astro index 30ba18ab..dc2de63b 100644 --- a/src/pages/account/admin/developers/index.astro +++ b/src/pages/account/admin/developers/index.astro @@ -72,7 +72,7 @@ if (query) { claims = claims.filter((c) => [ c.developer_name, - c.developer_id, + c.developer_identifier, c.claimant_name ?? '', c.claimant_id, c.claimant_github_login ?? '', @@ -80,7 +80,7 @@ if (query) { ); } else { developers = developers.filter((d) => - [d.name, d.id, d.owner_name ?? ''].some((field) => + [d.name, d.identifier, d.owner_name ?? ''].some((field) => field.toLowerCase().includes(needle), ), ); diff --git a/src/pages/account/admin/extensions/[id].astro b/src/pages/account/admin/extensions/[id].astro index 7bd8d2cb..db1285f7 100644 --- a/src/pages/account/admin/extensions/[id].astro +++ b/src/pages/account/admin/extensions/[id].astro @@ -70,7 +70,7 @@ const correctable = Boolean(
    {ext && correctable && ( {ext.developer.name} - ({ext.developer.id}) + ({ext.developer.identifier})

    diff --git a/src/pages/account/admin/extensions/[id]/edit.astro b/src/pages/account/admin/extensions/[id]/edit.astro index f02fc4ad..afc3ba82 100644 --- a/src/pages/account/admin/extensions/[id]/edit.astro +++ b/src/pages/account/admin/extensions/[id]/edit.astro @@ -88,15 +88,15 @@ if ( throw new ExtensionValidationError('A correction note is required.'); } const payload = buildExtensionUpdatePayload(form, base); - await api.correctExtension(owned.id, payload, submittedNote); + await api.correctExtension(owned.identifier, payload, submittedNote); purgeDirectory(Astro); await setFlash(Astro, env.sessionSecret, { category: 'success', - title: `"${owned.id}" corrected.`, + title: `"${owned.identifier}" corrected.`, description: 'Recorded as a moderator revision — the author sees it in history; no email was sent.', }); - return Astro.redirect(`/account/admin/extensions/${owned.id}`); + return Astro.redirect(`/account/admin/extensions/${owned.identifier}`); } catch (e) { error = e instanceof ApiRequestError @@ -119,7 +119,7 @@ const title = base?.name || owned?.id || id;

    - Correct {owned.published?.name ?? owned.id} + Correct {owned.published?.name ?? owned.identifier}

    {!owned.published ? ( @@ -178,8 +178,10 @@ const title = base?.name || owned?.id || id; An edit submitted {formatDate(owned.pendingRevision.createdAt)} is still awaiting review. Approve or reject it first — a correction cannot supersede a pending edit. See the{' '} -
    detail page or - the revision queue. + + detail page + {' '} + or the revision queue.
    ) : ( @@ -196,7 +198,7 @@ const title = base?.name || owned?.id || id; releaseDraft={submittedReleaseDraft ?? undefined} error={error} submitLabel="Correct Live Listing" - cancelHref={`/account/admin/extensions/${owned.id}`} + cancelHref={`/account/admin/extensions/${owned.identifier}`} correctionNote={submittedNote} /> diff --git a/src/pages/account/developer/index.astro b/src/pages/account/developer/index.astro index 41d85514..41ad15aa 100644 --- a/src/pages/account/developer/index.astro +++ b/src/pages/account/developer/index.astro @@ -8,7 +8,7 @@ import { } from '@/lib/api/client'; import { getDeveloperByOwner, - getDeveloperById, + getDeveloperByIdentifier, getExtensionsByOwner, } from '@/lib/extensions-data'; import { @@ -98,7 +98,7 @@ if (Astro.request.method === 'POST') { // route (transfer/revoke.ts), matching the rest of the app. if (form.get('intent') === 'transfer-initiate' && developer) { try { - transferResult = await api.initiateTransfer(developer.id); + transferResult = await api.initiateTransfer(developer.identifier); pendingTransfer = true; } catch (e) { error = @@ -153,9 +153,12 @@ if (Astro.request.method === 'POST') { !isEdit && e instanceof ApiRequestError && e.code === 'DEVELOPER_ID_TAKEN' && - submitted?.id + submitted?.identifier ) { - const conflicting = await getDeveloperById(env, submitted.id); + const conflicting = await getDeveloperByIdentifier( + env, + submitted.identifier, + ); idTaken = conflicting?.unclaimed === true; } if (e instanceof ApiRequestError && e.code === 'GITHUB_MISMATCH') { @@ -205,7 +208,7 @@ const reconnectUrl = buildGithubReconnectUrl(
      {myClaims.map((c) => (
    • - {c.developer_id} + {c.developer_identifier}
      {c.status === 'pending' && ( @@ -248,12 +251,12 @@ const reconnectUrl = buildGithubReconnectUrl(

      {error}

      - {idTaken && submitted?.id && ( + {idTaken && submitted?.identifier && (

      If this is your existing extension author profile, you can request to claim it instead of creating a new one — visit{' '} - - the public profile page for "{submitted.id}" + + the public profile page for "{submitted.identifier}" {' '} and use the "Claim this profile" option.

      @@ -285,9 +288,9 @@ const reconnectUrl = buildGithubReconnectUrl( ) : !isEdit && pendingClaim ? (

      You have a pending claim on{' '} - {pendingClaim.developer_id}. Cancel it above before - creating a separate profile — a claim can be approved at any time, - and you can only ever own one. + {pendingClaim.developer_identifier}. Cancel it + above before creating a separate profile — a claim can be approved + at any time, and you can only ever own one.

      ) : (
      @@ -299,18 +302,18 @@ const reconnectUrl = buildGithubReconnectUrl(
      Publisher Identity
      -
      Shown on your public developer page at {developer - ? ` /developer/${developer.id}` + ? ` /developer/${developer.identifier}` : ' /developer/'} .

      @@ -624,7 +628,7 @@ const reconnectUrl = buildGithubReconnectUrl( id="delete-developer-profile" title="Delete developer profile" description="Permanently deletes your publisher identity and its public page. This can't be undone, and the ID may be used by someone else afterward." - confirmText={developer!.id} + confirmText={developer!.identifier} intentField={{ name: 'intent', value: 'delete' }} submitLabel="Delete Developer Profile" /> diff --git a/src/pages/account/developer/transfer/revoke.ts b/src/pages/account/developer/transfer/revoke.ts index df9c71f6..49c9a728 100644 --- a/src/pages/account/developer/transfer/revoke.ts +++ b/src/pages/account/developer/transfer/revoke.ts @@ -13,7 +13,7 @@ export const POST: APIRoute = formAction({ const developer = await getDeveloperByOwner(env, user.sub); if (!developer) return '/account/developer'; - await createApiClient(env, user.sub).revokeTransfer(developer.id); + await createApiClient(env, user.sub).revokeTransfer(developer.identifier); await setFlash(context, env.sessionSecret, { title: 'Pending transfer link revoked.', }); diff --git a/src/pages/account/extensions/new.astro b/src/pages/account/extensions/new.astro index 07838d7a..e79b8b26 100644 --- a/src/pages/account/extensions/new.astro +++ b/src/pages/account/extensions/new.astro @@ -40,7 +40,7 @@ if (Astro.request.method === 'POST') { await setFlash(Astro, Astro.locals.env.sessionSecret, { title: 'Submitted for Review.', }); - return Astro.redirect(`/extension/${payload.id}`); + return Astro.redirect(`/extension/${payload.identifier}`); } catch (e) { error = e instanceof ApiRequestError diff --git a/src/pages/account/index.astro b/src/pages/account/index.astro index a7ec52b4..0304c701 100644 --- a/src/pages/account/index.astro +++ b/src/pages/account/index.astro @@ -232,12 +232,14 @@ const reconnectUrl = buildGithubReconnectUrl(Astro.url.origin, '/account'); githubVerifiedAt={developer.github_verified_at} />

      -

      {developer.id}

      +

      + {developer.identifier} +

    - Claim on {displayClaim.developer_id} + Claim on {displayClaim.developer_identifier} {displayClaim.status === 'pending' && ( Pending Review @@ -366,13 +368,15 @@ const reconnectUrl = buildGithubReconnectUrl(Astro.url.origin, '/account');

  • - {ext.published?.name ?? ext.id} + {ext.published?.name ?? ext.identifier}

    - {ext.published ? `v${ext.published.version}` : ext.id} + {ext.published + ? `v${ext.published.version}` + : ext.identifier}

    {rejectedEdit && ext.last_review?.review_note && (

    ) : null} { : null; return Response.json({ result: { - extension_id: revision.extension_id, + extension_identifier: revision.extension_identifier, revision: revision.content, published: ext?.published ?? null, }, diff --git a/src/pages/developer/[id].astro b/src/pages/developer/[id].astro index 6c6c2160..a37ba296 100644 --- a/src/pages/developer/[id].astro +++ b/src/pages/developer/[id].astro @@ -1,7 +1,7 @@ --- import Base from '../../layouts/Base.astro'; import DirectorySection from '@/components/DirectorySection.astro'; -import { getDeveloperById } from '@/lib/extensions-data'; +import { getDeveloperByIdentifier } from '@/lib/extensions-data'; import { ApiRequestError, createApiClient, @@ -33,10 +33,10 @@ if (!id) { // on an invalid id, but keeps listExtensions off the critical path for every // valid request — the far more common case — and repeat misses hit the // per-colo data cache. -const extensionsPromise = listExtensions(env, { developer_id: id }); +const extensionsPromise = listExtensions(env, { developer_identifier: id }); extensionsPromise.catch(() => {}); -const developer = await getDeveloperById(env, id); +const developer = await getDeveloperByIdentifier(env, id); if (!developer) { return Astro.rewrite('/404'); @@ -58,7 +58,7 @@ if (Astro.request.method === 'POST' && developer.unclaimed) { const api = createApiClient(env, user.sub); try { - await api.claimDeveloper(developer.id, submittedNote || undefined); + await api.claimDeveloper(developer.identifier, submittedNote || undefined); claimSubmitted = true; } catch (e) { claimError = @@ -189,7 +189,7 @@ const extensions = extensionsPage?.result ?? []; data-extension-directory data-api-url="/api/extensions/cards" data-limit="50" - data-developer-id={developer.id} + data-developer-id={developer.identifier} data-has-more={ extensionsPage ? hasNextPage(extensionsPage.pagination) : false } diff --git a/src/pages/extension/[id].astro b/src/pages/extension/[id].astro index d6006b00..2b17025f 100644 --- a/src/pages/extension/[id].astro +++ b/src/pages/extension/[id].astro @@ -94,12 +94,12 @@ const releasesView = Astro.url.searchParams.get('view') === 'releases'; ) : ( <> Developer ID ( diff --git a/src/types/index.ts b/src/types/index.ts index 6a6021ca..62bcf2c9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -66,6 +66,7 @@ export type License = string; export type Developer = { id: string; + identifier: string; type: DeveloperType; name: string; URL?: string; @@ -74,6 +75,7 @@ export type Developer = { }; export type Extension = { + identifier: string; manifest_version: 1; author: PackageAuthor; requires: Requirements; @@ -104,7 +106,7 @@ export type DeveloperProfile = Developer & { unclaimed?: boolean; }; -// What getDeveloperById (the public /developer/[id] read) returns — private +// What getDeveloperByIdentifier (the public /developer/[id] read) returns — private // owner and moderation fields are excluded from the local public view. export type PublicDeveloperProfile = Omit< DeveloperProfile, diff --git a/test/directory-pagination.test.ts b/test/directory-pagination.test.ts index 2f788bdf..b3bff248 100644 --- a/test/directory-pagination.test.ts +++ b/test/directory-pagination.test.ts @@ -12,7 +12,7 @@ import { item, page } from './helpers/directory-fixtures'; describe('directory page accumulation', () => { const filters = { type: 'module' as const, - developer_id: 'developer-id', + developer_identifier: 'developer-id', limit: 100, }; diff --git a/test/extension-directory.test.ts b/test/extension-directory.test.ts index de378200..224b9d88 100644 --- a/test/extension-directory.test.ts +++ b/test/extension-directory.test.ts @@ -16,12 +16,12 @@ describe('application boundary validation', () => { describe('directory filter parsing', () => { it('parses every supported filter and ignores unknown params', () => { const params = new URLSearchParams( - 'type=module&developer_id=dev-1&limit=24&cursor=opaque&q=ignored', + 'type=module&developer_identifier=dev-1&limit=24&cursor=opaque&q=ignored', ); expect(parseDirectoryFilters(params)).toEqual({ filters: { type: 'module', - developer_id: 'dev-1', + developer_identifier: 'dev-1', limit: 24, cursor: 'opaque', }, diff --git a/test/extension-form.test.ts b/test/extension-form.test.ts index ca66cb39..cb0d9c17 100644 --- a/test/extension-form.test.ts +++ b/test/extension-form.test.ts @@ -12,7 +12,7 @@ import type { Extension } from '@/types'; function extensionForm(overrides: Record = {}) { const form = new FormData(); const values = { - extension_id: 'example_module', + extension_identifier: 'example_module', type: 'module', name: 'Example', description: 'Description', @@ -36,7 +36,8 @@ function extensionForm(overrides: Record = {}) { return form; } const developer = { - id: 'owner', + id: '019a0000-0000-7000-8000-000000000001', + identifier: 'owner', type: 'organization' as const, name: 'Developer', }; @@ -69,7 +70,8 @@ const published: Extension = { }, }, ], - id: 'example_module', + id: '019a0000-0000-7000-8000-000000000002', + identifier: 'example_module', developer, }; @@ -77,7 +79,7 @@ describe('manifest publishing forms', () => { it('sends the new manifest fields without transforming constraints or attribution', () => { const body = buildExtensionCreatePayload(extensionForm()); expect(body).toMatchObject({ - id: 'example_module', + identifier: 'example_module', manifest_version: 1, type: 'module', license: 'MIT', @@ -97,9 +99,9 @@ describe('manifest publishing forms', () => { }); it('lowercases new ids and accepts an omitted homepage', () => { const body = buildExtensionCreatePayload( - extensionForm({ extension_id: 'My_Module', homepage_url: '' }), + extensionForm({ extension_identifier: 'My_Module', homepage_url: '' }), ); - expect(body.id).toBe('my_module'); + expect(body.identifier).toBe('my_module'); expect(body.homepage_url).toBeUndefined(); }); it('carries published releases unchanged when editing metadata', () => { @@ -237,7 +239,10 @@ describe('manifest publishing forms', () => { expect(echo.moduleRequirementsInput).toBe('unfinished dependency'); expect(echo.license).toBe('MIT OR BSD-3-Clause'); expect(echoReleaseDraftFromForm(form).version_tag).toBe('1.0.0'); - expect(echoNewExtensionFromForm(form, developer).extension.id).toBe( + expect( + echoNewExtensionFromForm(form, developer).extension, + ).not.toHaveProperty('id'); + expect(echoNewExtensionFromForm(form, developer).extension.identifier).toBe( 'example_module', ); }); @@ -273,7 +278,7 @@ it('safely echoes blank new-extension and release submissions', () => { }); const echo = echoNewExtensionFromForm(form, developer); expect(echo.extension).toMatchObject({ - id: '', + identifier: '', name: '', license: '', moduleRequirementsInput: '', diff --git a/test/extension-view.test.ts b/test/extension-view.test.ts index 24b43476..1e44fdac 100644 --- a/test/extension-view.test.ts +++ b/test/extension-view.test.ts @@ -2,13 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ getOwnDeveloper: vi.fn(), getMyExtension: vi.fn(), - getExtensionById: vi.fn(), + getExtensionByIdentifier: vi.fn(), createApiClient: vi.fn(), })); vi.mock('@/lib/api/client', async (original) => ({ ...(await original()), createApiClient: mocks.createApiClient, - getExtensionById: mocks.getExtensionById, + getExtensionByIdentifier: mocks.getExtensionByIdentifier, })); import { loadExtensionView } from '@/lib/extension-view'; import { ApiRequestError } from '@/lib/api/client'; @@ -27,7 +27,7 @@ beforeEach(() => { mocks.createApiClient.mockReturnValue(mocks); mocks.getOwnDeveloper.mockResolvedValue({ id: 'my-profile' }); mocks.getMyExtension.mockResolvedValue(owned); - mocks.getExtensionById.mockResolvedValue(published); + mocks.getExtensionByIdentifier.mockResolvedValue(published); }); describe('permission-aware extension view', () => { it('uses only the public read for anonymous visitors', async () => { @@ -44,12 +44,12 @@ describe('permission-aware extension view', () => { userId: user.sub, }); expect(mocks.createApiClient).toHaveBeenCalledWith(env, user.sub); - expect(mocks.getExtensionById).not.toHaveBeenCalled(); + expect(mocks.getExtensionByIdentifier).not.toHaveBeenCalled(); }); it('allows an owner to view a first submission without a public listing', async () => { mocks.getMyExtension.mockResolvedValue({ ...owned, published: null }); expect((await loadExtensionView(env, 'sample', user)).kind).toBe('owner'); - expect(mocks.getExtensionById).not.toHaveBeenCalled(); + expect(mocks.getExtensionByIdentifier).not.toHaveBeenCalled(); }); it('does not grant editing from a moderator cookie flag', async () => { mocks.getOwnDeveloper.mockResolvedValue(null); @@ -90,7 +90,7 @@ describe('permission-aware extension view', () => { }); it('keeps unpublished content inaccessible to unrelated visitors', async () => { mocks.getOwnDeveloper.mockResolvedValue(null); - mocks.getExtensionById.mockRejectedValue( + mocks.getExtensionByIdentifier.mockRejectedValue( new ApiRequestError(404, 'NOT_FOUND', 'Not published'), ); await expect(loadExtensionView(env, 'sample', user)).rejects.toMatchObject({ @@ -106,7 +106,7 @@ describe('permission-aware extension view', () => { await expect( loadExtensionView(env, 'sample', user), ).rejects.toMatchObject({ status: 503 }); - expect(mocks.getExtensionById).not.toHaveBeenCalled(); + expect(mocks.getExtensionByIdentifier).not.toHaveBeenCalled(); }, ); }); diff --git a/test/extensions-api.test.ts b/test/extensions-api.test.ts index a18bdf35..8fd03c98 100644 --- a/test/extensions-api.test.ts +++ b/test/extensions-api.test.ts @@ -19,7 +19,7 @@ import { clampApiPageLimit, createApiClient, getApiErrorMessage, - getExtensionById, + getExtensionByIdentifier, listExtensions, type Extension, type ExtensionCreate, @@ -144,7 +144,7 @@ describe('generated Extensions v2 façade', () => { .mockResolvedValue(apiResponse(page([], null, false))); const request = { type: 'theme' as const, - developer_id: 'developer-id', + developer_identifier: 'developer-id', limit: 25, cursor: 'opaque-cursor', }; @@ -257,14 +257,14 @@ describe('generated Extensions v2 façade', () => { await listExtensions(publicEnv, { type: 'theme', - developer_id: 'developer-id', + developer_identifier: 'developer-id', limit: 100, cursor: opaqueCursor, }); const url = requestUrl(fetchMock); expect(url.searchParams.get('type')).toBe('theme'); - expect(url.searchParams.get('developer_id')).toBe('developer-id'); + expect(url.searchParams.get('developer_identifier')).toBe('developer-id'); expect(url.searchParams.get('cursor')).toBe(opaqueCursor); }); @@ -296,7 +296,10 @@ describe('generated Extensions v2 façade', () => { .mockResolvedValue(apiResponse({ result: detail })); vi.stubGlobal('fetch', fetchMock); - const response = await getExtensionById(publicEnv, 'full-extension'); + const response = await getExtensionByIdentifier( + publicEnv, + 'full-extension', + ); expect(requestFrom(fetchMock).url).toBe( 'https://api.example.test/extensions/v2/extensions/full-extension', @@ -352,17 +355,17 @@ describe('generated Extensions v2 façade', () => { 'opaque cursor', ); // The mine scope forwards a type filter when asked, and never carries - // the public directory's developer_id filter. + // the public directory's developer_identifier filter. expect(requestUrl(fetchMock, 0).searchParams.get('type')).toBe('theme'); - expect(requestUrl(fetchMock, 0).searchParams.has('developer_id')).toBe( - false, - ); + expect( + requestUrl(fetchMock, 0).searchParams.has('developer_identifier'), + ).toBe(false); expect(requestUrl(fetchMock, 1).searchParams.has('cursor')).toBe(false); }); it('serializes a create payload with no developer field and reports the new revision', async () => { const payload = { - id: 'body-extension', + identifier: 'body-extension', manifest_version: 1 as const, author: { name: 'Package Author' }, requires: { fossbilling: '^0.8.9', php: '>=8.3' }, @@ -381,7 +384,7 @@ describe('generated Extensions v2 façade', () => { apiResponse( { result: { - id: 'body-extension', + identifier: 'body-extension', revision_id: 'revision-1', status: 'pending', }, @@ -402,7 +405,7 @@ describe('generated Extensions v2 façade', () => { expect(request.headers.get('content-type')).toBe('application/json'); expect(await request.json()).toEqual(payload); expect(result).toEqual({ - id: 'body-extension', + identifier: 'body-extension', revision_id: 'revision-1', status: 'pending', }); @@ -428,7 +431,7 @@ describe('generated Extensions v2 façade', () => { apiResponse( { result: { - id: 'body-extension', + identifier: 'body-extension', revision_id: 'revision-2', status: 'pending', }, @@ -934,6 +937,7 @@ describe('cursor-paginated moderator lists', () => { function developerProfile(id: string): DeveloperProfile { return { id, + identifier: id, type: 'user', name: id, approved: true, @@ -945,7 +949,8 @@ describe('cursor-paginated moderator lists', () => { function myClaim(id: string): PendingDeveloperClaim { return { id, - developer_id: 'dev-1', + developer_id: '019a0000-0000-7000-8000-000000000001', + developer_identifier: 'dev-1', claimant_id: 'user-1', status: 'pending', created_at: '2026-01-01T00:00:00Z', @@ -1000,7 +1005,7 @@ describe('cursor-paginated moderator lists', () => { it('walks the claims queue and profile history with explicit pages', async () => { const claim = myClaim('claim-1'); const entry: DeveloperHistoryEntry = { - developer_id: 'dev-1', + developer_id: '019a0000-0000-7000-8000-000000000001', type: 'user', name: 'Dev One', changed_by: 'user-1', @@ -1029,7 +1034,9 @@ describe('cursor-paginated moderator lists', () => { ]); expect(claims.map((c) => c.id)).toEqual(['claim-1']); - expect(history.map((h) => h.developer_id)).toEqual(['dev-1']); + expect(history.map((h) => h.developer_id)).toEqual([ + '019a0000-0000-7000-8000-000000000001', + ]); const urls = fetchMock.mock.calls.map( (call) => new URL((call[0] as Request).url), ); diff --git a/test/extensions-data.test.ts b/test/extensions-data.test.ts index dcf9c897..33b3eba9 100644 --- a/test/extensions-data.test.ts +++ b/test/extensions-data.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ createApiClient: vi.fn(), - getDeveloperById: vi.fn(), + getDeveloperByIdentifier: vi.fn(), })); // paginateAll stays real: the adapters under test route their whole-list @@ -10,11 +10,11 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/api/client', async (importOriginal) => ({ ...(await importOriginal()), createApiClient: mocks.createApiClient, - getDeveloperById: mocks.getDeveloperById, + getDeveloperByIdentifier: mocks.getDeveloperByIdentifier, })); import { - getDeveloperById, + getDeveloperByIdentifier, getDeveloperByOwner, getExtensionsByOwner, getOwnedExtension, @@ -26,6 +26,7 @@ const env = makeEnv(); const developer = { id: 'developer', + identifier: 'developer', type: 'organization' as const, name: 'Example developer', approved: true, @@ -34,7 +35,7 @@ const developer = { beforeEach(() => { mocks.createApiClient.mockReset(); - mocks.getDeveloperById.mockReset(); + mocks.getDeveloperByIdentifier.mockReset(); }); describe('API-backed extension data adapters', () => { @@ -52,6 +53,7 @@ describe('API-backed extension data adapters', () => { mocks.createApiClient.mockReturnValue({ getMyExtension: vi.fn().mockResolvedValue({ id: 'extension-id', + identifier: 'example', developer, published: { manifest_version: 1 as const, @@ -115,6 +117,7 @@ describe('API-backed extension data adapters', () => { it('maps either detail view to the same shape, delisted state included', () => { const detail = toOwnedExtensionDetail({ id: 'extension-id', + identifier: 'example', developer, published: { manifest_version: 1 as const, @@ -150,9 +153,13 @@ describe('API-backed extension data adapters', () => { }); it('treats failed public developer reads as missing profiles', async () => { - mocks.getDeveloperById.mockRejectedValue(new Error('API unavailable')); + mocks.getDeveloperByIdentifier.mockRejectedValue( + new Error('API unavailable'), + ); - await expect(getDeveloperById(env, 'developer-id')).resolves.toBeNull(); + await expect( + getDeveloperByIdentifier(env, 'developer-id'), + ).resolves.toBeNull(); }); it('keeps owner pages usable when the API cannot load the profile or list', async () => { diff --git a/test/helpers/directory-fixtures.ts b/test/helpers/directory-fixtures.ts index ee8ece24..e3285287 100644 --- a/test/helpers/directory-fixtures.ts +++ b/test/helpers/directory-fixtures.ts @@ -5,6 +5,7 @@ import type { ExtensionListItem } from '@/lib/api/client'; export function item(id: string, name = id): ExtensionListItem { return { id, + identifier: id, manifest_version: 1 as const, author: { name: 'Package Author' }, type: 'module', @@ -17,6 +18,7 @@ export function item(id: string, name = id): ExtensionListItem { download_url: `https://example.test/${id}.zip`, developer: { id: 'fossbilling', + identifier: 'fossbilling', type: 'organization', name: 'FOSSBilling', approved: true, From 9971ec313f44f42f319e487ddad2044919c4fdba Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 17:38:15 +0000 Subject: [PATCH 2/6] Use stable developer IDs for audit history --- openapi/extensions-v2.json | 8 +++++--- src/components/admin/DeveloperProfilesTable.astro | 2 +- src/components/admin/ExtensionAdminTable.astro | 2 +- src/lib/api/generated/extensions-v2/types.gen.ts | 5 ++++- src/pages/account/admin/developers/[id]/history.astro | 4 ++-- test/extensions-api.test.ts | 5 ++++- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/openapi/extensions-v2.json b/openapi/extensions-v2.json index 0e8e61f4..cd61364c 100644 --- a/openapi/extensions-v2.json +++ b/openapi/extensions-v2.json @@ -1986,10 +1986,10 @@ "type": "string", "minLength": 1, "maxLength": 200, - "description": "All scope only: case-insensitive substring match on the extension id" + "description": "All scope only: case-insensitive substring match on the extension identifier" }, "required": false, - "description": "All scope only: case-insensitive substring match on the extension id", + "description": "All scope only: case-insensitive substring match on the extension identifier", "name": "q", "in": "query" } @@ -5106,9 +5106,11 @@ { "schema": { "type": "string", - "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + "format": "uuid", + "description": "Internal developer UUID" }, "required": true, + "description": "Internal developer UUID", "name": "id", "in": "path" }, diff --git a/src/components/admin/DeveloperProfilesTable.astro b/src/components/admin/DeveloperProfilesTable.astro index 28b7c4e1..82cf06ab 100644 --- a/src/components/admin/DeveloperProfilesTable.astro +++ b/src/components/admin/DeveloperProfilesTable.astro @@ -26,7 +26,7 @@ const { developers } = Astro.props;

    {d.name} diff --git a/src/components/admin/ExtensionAdminTable.astro b/src/components/admin/ExtensionAdminTable.astro index 2dcd8317..acdead26 100644 --- a/src/components/admin/ExtensionAdminTable.astro +++ b/src/components/admin/ExtensionAdminTable.astro @@ -40,7 +40,7 @@ function statusOf(ext: OwnedExtensionListItem) { {ext.developer.name} diff --git a/src/lib/api/generated/extensions-v2/types.gen.ts b/src/lib/api/generated/extensions-v2/types.gen.ts index ac39c5da..e65a0670 100644 --- a/src/lib/api/generated/extensions-v2/types.gen.ts +++ b/src/lib/api/generated/extensions-v2/types.gen.ts @@ -557,7 +557,7 @@ export type GetExtensionsData = { */ status?: 'published' | 'delisted' | 'unpublished'; /** - * All scope only: case-insensitive substring match on the extension id + * All scope only: case-insensitive substring match on the extension identifier */ q?: string; }; @@ -2040,6 +2040,9 @@ export type PostDevelopersByIdApproveResponse = export type GetDevelopersByIdHistoryData = { body?: never; path: { + /** + * Internal developer UUID + */ id: string; }; query?: { diff --git a/src/pages/account/admin/developers/[id]/history.astro b/src/pages/account/admin/developers/[id]/history.astro index a6024342..0b5fe923 100644 --- a/src/pages/account/admin/developers/[id]/history.astro +++ b/src/pages/account/admin/developers/[id]/history.astro @@ -53,9 +53,9 @@ if (historyQuery) { ---

    { const api = createApiClient(authenticatedEnv, 'moderator-sub'); const [claims, history] = await Promise.all([ api.listPendingClaims(), - api.listDeveloperHistory('dev-1'), + api.listDeveloperHistory('019a0000-0000-7000-8000-000000000001'), ]); expect(claims.map((c) => c.id)).toEqual(['claim-1']); @@ -1044,6 +1044,9 @@ describe('cursor-paginated moderator lists', () => { u.pathname.endsWith('/developers/claims'), ); const historyUrl = urls.find((u) => u.pathname.endsWith('/history')); + expect(historyUrl?.pathname).toBe( + '/extensions/v2/developers/019a0000-0000-7000-8000-000000000001/history', + ); expect(claimsUrl?.searchParams.get('scope')).toBe('pending'); expect(claimsUrl?.searchParams.get('limit')).toBe('100'); expect(claimsUrl?.searchParams.get('cursor')).toBe(null); From 7e1e4cd5a85fbee32db5dad62cf3f713f5d4134b Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 18:04:40 +0000 Subject: [PATCH 3/6] Address directory identity review feedback --- README.md | 2 +- .../admin/DeveloperProfilesTable.astro | 2 +- src/lib/developer-form.ts | 4 +-- src/lib/extension-form.ts | 4 +-- .../admin/developers/[id]/history.astro | 6 ++-- src/pages/index.astro | 20 ++++++----- test/directory-pagination.test.ts | 24 +++++++------ test/extension-view.test.ts | 34 ++++++++++++++++--- test/extensions-api.test.ts | 5 ++- test/helpers/directory-fixtures.ts | 22 +++++++----- 10 files changed, 81 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index e643f1f0..bb21f642 100644 --- a/README.md +++ b/README.md @@ -113,4 +113,4 @@ API mutations and site mutations both purge the `extensions` and `developers` ta ## Directory Identity -Extension and developer records use UUIDv7 internal IDs. Public routes, profile handles and manifest IDs use their separate `identifier` fields. Keep UUIDs for record identity and use identifiers when constructing public links or package manifests. +Extension and developer records use UUIDv7 internal IDs. Public routes, profile handles and publishing requests use the separate `identifier` fields. Keep UUIDs for record identity and audit-history links. A package manifest keeps its `id` field, whose value is the public extension identifier. diff --git a/src/components/admin/DeveloperProfilesTable.astro b/src/components/admin/DeveloperProfilesTable.astro index 82cf06ab..8b1ea641 100644 --- a/src/components/admin/DeveloperProfilesTable.astro +++ b/src/components/admin/DeveloperProfilesTable.astro @@ -68,7 +68,7 @@ const { developers } = Astro.props;
    @@ -124,7 +126,7 @@ const activeFilterCount = - {activeType || activeDeveloperId ? ( + {activeType || activeDeveloperIdentifier ? ( Clear Filters diff --git a/test/directory-pagination.test.ts b/test/directory-pagination.test.ts index b3bff248..f13e6c7e 100644 --- a/test/directory-pagination.test.ts +++ b/test/directory-pagination.test.ts @@ -36,7 +36,7 @@ describe('directory page accumulation', () => { ...filters, cursor: 'cursor-2', } satisfies DirectoryPageRequest); - expect(state.items.map((extension) => extension.id)).toEqual([ + expect(state.items.map((extension) => extension.identifier)).toEqual([ 'zeta', 'alpha', 'beta', @@ -50,7 +50,7 @@ describe('directory page accumulation', () => { .fn() .mockResolvedValue(page([item('ALPHA'), item('second')], null, false)); const pager = createDirectoryPagerFromIds( - ['alpha'], + [item('alpha').id], { next_cursor: 'cursor-2', has_more: true }, filters, loadPage, @@ -58,7 +58,9 @@ describe('directory page accumulation', () => { const state = await pager.loadNextPage(); - expect(state.items.map((extension) => extension.id)).toEqual(['second']); + expect(state.items.map((extension) => extension.identifier)).toEqual([ + 'second', + ]); }); it('does not duplicate concurrent next-page requests', async () => { @@ -73,7 +75,7 @@ describe('directory page accumulation', () => { }), ); const pager = createDirectoryPagerFromIds( - ['first'], + [item('first').id], { next_cursor: 'cursor-2', has_more: true }, filters, loadPage, @@ -87,9 +89,9 @@ describe('directory page accumulation', () => { resolvePage?.(page([item('first'), item('second')], null, false)); await Promise.all([firstRequest, duplicateRequest]); - expect(pager.getState().items.map((extension) => extension.id)).toEqual([ - 'second', - ]); + expect( + pager.getState().items.map((extension) => extension.identifier), + ).toEqual(['second']); }); it('appends a final page and offers no further request', async () => { @@ -105,7 +107,7 @@ describe('directory page accumulation', () => { const state = await pager.loadNextPage(); await pager.loadNextPage(); - expect(state.items.map((extension) => extension.id)).toEqual([ + expect(state.items.map((extension) => extension.identifier)).toEqual([ 'first', 'last', ]); @@ -137,7 +139,7 @@ describe('directory page accumulation', () => { .mockResolvedValueOnce(page([item('second')], 'invalid-cursor', true)) .mockRejectedValueOnce(invalidCursor); const pager = createDirectoryPagerFromIds( - ['first'], + [item('first').id], { next_cursor: 'cursor-2', has_more: true }, filters, loadPage, @@ -146,7 +148,9 @@ describe('directory page accumulation', () => { await pager.loadNextPage(); const state = await pager.loadNextPage(); - expect(state.items.map((extension) => extension.id)).toEqual(['second']); + expect(state.items.map((extension) => extension.identifier)).toEqual([ + 'second', + ]); expect(state.nextCursor).toBe('invalid-cursor'); expect(state.hasMore).toBe(true); expect(state.error).toBe(invalidCursor); diff --git a/test/extension-view.test.ts b/test/extension-view.test.ts index 1e44fdac..5d14e199 100644 --- a/test/extension-view.test.ts +++ b/test/extension-view.test.ts @@ -15,17 +15,28 @@ import { ApiRequestError } from '@/lib/api/client'; import { makeEnv } from './helpers/env'; const env = makeEnv(); const user = { sub: 'owner', name: 'Owner', email: 'owner@example.test' }; -const published = { id: 'sample', name: 'Published' }; +const published = { + id: '019a0000-0000-7000-8000-000000000001', + identifier: 'sample', + name: 'Published', +}; const owned = { - id: 'sample', - developer: { id: 'my-profile' }, + id: published.id, + identifier: 'sample', + developer: { + id: '019a0000-0000-7000-8000-000000000002', + identifier: 'my-profile', + }, published, pending_revision: { content: { name: 'Private pending' } }, }; beforeEach(() => { vi.resetAllMocks(); mocks.createApiClient.mockReturnValue(mocks); - mocks.getOwnDeveloper.mockResolvedValue({ id: 'my-profile' }); + mocks.getOwnDeveloper.mockResolvedValue({ + id: owned.developer.id, + identifier: 'my-profile', + }); mocks.getMyExtension.mockResolvedValue(owned); mocks.getExtensionByIdentifier.mockResolvedValue(published); }); @@ -35,6 +46,7 @@ describe('permission-aware extension view', () => { kind: 'public', extension: published, }); + expect(mocks.getExtensionByIdentifier).toHaveBeenCalledWith(env, 'sample'); expect(mocks.createApiClient).not.toHaveBeenCalled(); }); it('enables editing only for the API-verified owner', async () => { @@ -44,6 +56,7 @@ describe('permission-aware extension view', () => { userId: user.sub, }); expect(mocks.createApiClient).toHaveBeenCalledWith(env, user.sub); + expect(mocks.getMyExtension).toHaveBeenCalledWith('sample'); expect(mocks.getExtensionByIdentifier).not.toHaveBeenCalled(); }); it('allows an owner to view a first submission without a public listing', async () => { @@ -56,17 +69,22 @@ describe('permission-aware extension view', () => { expect( await loadExtensionView(env, 'sample', { ...user, is_moderator: true }), ).toEqual({ kind: 'public', extension: published }); + expect(mocks.getExtensionByIdentifier).toHaveBeenCalledWith(env, 'sample'); expect(mocks.getMyExtension).not.toHaveBeenCalled(); }); it('does not mistake a moderator-readable record for an owned extension', async () => { mocks.getMyExtension.mockResolvedValue({ ...owned, - developer: { id: 'someone-else' }, + developer: { + id: '019a0000-0000-7000-8000-000000000003', + identifier: 'someone-else', + }, }); expect(await loadExtensionView(env, 'sample', user)).toEqual({ kind: 'public', extension: published, }); + expect(mocks.getExtensionByIdentifier).toHaveBeenCalledWith(env, 'sample'); }); it.each([403, 404])( 'falls back to public content when the account/profile is unavailable (%s)', @@ -77,6 +95,10 @@ describe('permission-aware extension view', () => { expect((await loadExtensionView(env, 'sample', user)).kind).toBe( 'public', ); + expect(mocks.getExtensionByIdentifier).toHaveBeenCalledWith( + env, + 'sample', + ); }, ); it('falls back to public content for another developer’s extension', async () => { @@ -87,6 +109,7 @@ describe('permission-aware extension view', () => { kind: 'public', extension: published, }); + expect(mocks.getExtensionByIdentifier).toHaveBeenCalledWith(env, 'sample'); }); it('keeps unpublished content inaccessible to unrelated visitors', async () => { mocks.getOwnDeveloper.mockResolvedValue(null); @@ -96,6 +119,7 @@ describe('permission-aware extension view', () => { await expect(loadExtensionView(env, 'sample', user)).rejects.toMatchObject({ status: 404, }); + expect(mocks.getExtensionByIdentifier).toHaveBeenCalledWith(env, 'sample'); }); it.each(['getOwnDeveloper', 'getMyExtension'] as const)( 'keeps %s outages retryable rather than hiding owner controls', diff --git a/test/extensions-api.test.ts b/test/extensions-api.test.ts index ef8dd9ce..9d684125 100644 --- a/test/extensions-api.test.ts +++ b/test/extensions-api.test.ts @@ -314,7 +314,10 @@ describe('generated Extensions v2 façade', () => { expect(response.source.repo).toBe('fossbilling/full-extension'); expect(response.version).toBe('1.0.0'); expect(response.download_url).toContain('full-extension.zip'); - expect(response.developer.id).toBe('fossbilling'); + expect(response.id).toBe(detail.id); + expect(response.identifier).toBe('full-extension'); + expect(response.developer.id).toBe(detail.developer.id); + expect(response.developer.identifier).toBe('fossbilling'); }); it('uses generated serialization and a fresh bearer callback for authenticated requests', async () => { diff --git a/test/helpers/directory-fixtures.ts b/test/helpers/directory-fixtures.ts index e3285287..e051e5fb 100644 --- a/test/helpers/directory-fixtures.ts +++ b/test/helpers/directory-fixtures.ts @@ -1,23 +1,27 @@ +import { createHash } from 'node:crypto'; import type { ExtensionListItem } from '@/lib/api/client'; -// Full ExtensionListItem fixture: the generated DTO requires every field, -// even though most tests only assert on ids. -export function item(id: string, name = id): ExtensionListItem { +// Keep record UUIDs distinct from public identifiers in API fixtures. +export function item(identifier: string, name = identifier): ExtensionListItem { + const suffix = createHash('sha256') + .update(identifier.toLowerCase()) + .digest('hex') + .slice(0, 12); return { - id, - identifier: id, + id: `019a0000-0000-7000-8000-${suffix}`, + identifier, manifest_version: 1 as const, author: { name: 'Package Author' }, type: 'module', name, description: `${name} description`, - homepage_url: `https://example.test/${id}`, + homepage_url: `https://example.test/${identifier}`, license: 'MIT', - source: { type: 'github', repo: `fossbilling/${id}` }, + source: { type: 'github', repo: `fossbilling/${identifier}` }, version: '1.0.0', - download_url: `https://example.test/${id}.zip`, + download_url: `https://example.test/${identifier}.zip`, developer: { - id: 'fossbilling', + id: '019a0000-0000-7000-8000-000000000001', identifier: 'fossbilling', type: 'organization', name: 'FOSSBilling', From c7cffb71dbdbbaa3899419daed63943d89a69140 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 18:09:24 +0000 Subject: [PATCH 4/6] Verify ownership uses the developer UUID --- test/extension-view.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/extension-view.test.ts b/test/extension-view.test.ts index 5d14e199..3803c9d5 100644 --- a/test/extension-view.test.ts +++ b/test/extension-view.test.ts @@ -72,12 +72,14 @@ describe('permission-aware extension view', () => { expect(mocks.getExtensionByIdentifier).toHaveBeenCalledWith(env, 'sample'); expect(mocks.getMyExtension).not.toHaveBeenCalled(); }); - it('does not mistake a moderator-readable record for an owned extension', async () => { + it('does not grant editing when a profile identifier has been reused', async () => { + // The profile can be deleted and its identifier reused between the two + // API reads. A moderator can then read the replacement's extension. mocks.getMyExtension.mockResolvedValue({ ...owned, developer: { id: '019a0000-0000-7000-8000-000000000003', - identifier: 'someone-else', + identifier: 'my-profile', }, }); expect(await loadExtensionView(env, 'sample', user)).toEqual({ From 4ee17e7bc2eb0daa1fe6242e8ac48c45c461dbb7 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 18:17:05 +0000 Subject: [PATCH 5/6] Simplify identity views and test fixtures --- src/components/ExtensionForm.astro | 24 ++---- .../account/admin/extensions/[id]/edit.astro | 2 +- src/pages/account/developer/index.astro | 8 +- src/pages/api/admin/revision-detail.ts | 1 - test/directory-pagination.test.ts | 25 ------ test/extension-form.test.ts | 11 +-- test/extensions-api.test.ts | 85 +++++++++++++++---- test/extensions-data.test.ts | 19 +++-- test/revision-detail.test.ts | 4 +- 9 files changed, 103 insertions(+), 76 deletions(-) diff --git a/src/components/ExtensionForm.astro b/src/components/ExtensionForm.astro index 8fab17ed..2c3bb2b2 100644 --- a/src/components/ExtensionForm.astro +++ b/src/components/ExtensionForm.astro @@ -577,33 +577,27 @@ const otherSpdxIds = SPDX_LICENSE_IDS.filter((id) => !popularSpdxIds.has(id));