From 91911618a5290d079ec10b266e8db0af7298f09f Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 17:21:34 +0000 Subject: [PATCH 1/4] Separate directory record IDs from public identifiers --- package-lock.json | 14 + package.json | 1 + src/services/extensions/v1/database.ts | 13 +- src/services/extensions/v2/README.md | 6 +- src/services/extensions/v2/db/badges.ts | 4 +- .../extensions/v2/db/developer-claims.ts | 180 +-- .../extensions/v2/db/developer-profiles.ts | 87 +- .../extensions/v2/db/developer-transfers.ts | 16 +- src/services/extensions/v2/db/extensions.ts | 155 +- .../db/migrations/0030_directory_identity.sql | 670 ++++++++ .../v2/db/migrations/meta/0030_snapshot.json | 1395 +++++++++++++++++ .../v2/db/migrations/meta/_journal.json | 7 + src/services/extensions/v2/db/revisions.ts | 44 +- src/services/extensions/v2/db/schema.ts | 20 +- src/services/extensions/v2/email/notify.ts | 4 +- .../extensions/v2/email/recipients.ts | 21 +- src/services/extensions/v2/routes/badges.ts | 2 +- .../v2/routes/developer-profiles.ts | 6 +- .../extensions/v2/routes/moderation.ts | 35 +- .../extensions/v2/routes/owner-extensions.ts | 64 +- .../extensions/v2/routes/public-extensions.ts | 10 +- .../extensions/v2/schemas/developers.ts | 4 +- .../extensions/v2/schemas/extensions.ts | 23 +- src/services/extensions/v2/schemas/m2m.ts | 4 +- .../extensions/v2/schemas/ownership.ts | 3 +- .../extensions/v2/schemas/revisions.ts | 3 +- test/services/extensions/v1/index.test.ts | 22 +- test/services/extensions/v2/account.test.ts | 50 +- test/services/extensions/v2/badges.test.ts | 24 +- test/services/extensions/v2/contract.test.ts | 24 +- test/services/extensions/v2/db-fixtures.ts | 89 +- .../extensions/v2/developer-approval.test.ts | 16 +- .../extensions/v2/developer-profiles.test.ts | 175 ++- .../extensions/v2/directory-identity.test.ts | 95 ++ .../extensions/v2/extension-writes.test.ts | 93 +- test/services/extensions/v2/harness.ts | 14 +- test/services/extensions/v2/manifest.test.ts | 18 +- .../services/extensions/v2/migrations.test.ts | 65 + .../extensions/v2/moderation-correct.test.ts | 53 +- .../extensions/v2/moderation-notify.test.ts | 70 +- .../v2/moderation-revalidate.test.ts | 33 +- .../v2/moderation-revocation.test.ts | 14 +- .../services/extensions/v2/moderation.test.ts | 262 ++-- test/services/extensions/v2/ownership.test.ts | 53 +- .../extensions/v2/pending-edits.test.ts | 22 +- .../extensions/v2/public-extensions.test.ts | 42 +- test/services/extensions/v2/publisher.test.ts | 50 +- .../extensions/v2/resource-bounds.test.ts | 124 +- 48 files changed, 3441 insertions(+), 758 deletions(-) create mode 100644 src/services/extensions/v2/db/migrations/0030_directory_identity.sql create mode 100644 src/services/extensions/v2/db/migrations/meta/0030_snapshot.json create mode 100644 test/services/extensions/v2/directory-identity.test.ts diff --git a/package-lock.json b/package-lock.json index 4f652633..d9c21ebc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "drizzle-orm": "0.45.4", "hono": "4.13.16", "semver": "7.8.6", + "uuid": "14.0.3", "zod": "4.6.7" }, "devDependencies": { @@ -6171,6 +6172,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz", + "integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/verkit": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", diff --git a/package.json b/package.json index 01cde373..78585c3f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "drizzle-orm": "0.45.4", "hono": "4.13.16", "semver": "7.8.6", + "uuid": "14.0.3", "zod": "4.6.7" }, "devDependencies": { diff --git a/src/services/extensions/v1/database.ts b/src/services/extensions/v1/database.ts index 95a4d8b2..a7f66e79 100644 --- a/src/services/extensions/v1/database.ts +++ b/src/services/extensions/v1/database.ts @@ -9,9 +9,9 @@ import type { Release as ManifestRelease } from "../v2/schemas/extensions"; import { Extension, Release, Author, Repository } from "./interfaces"; const EXTENSION_COLUMNS = { - id: extensions.id, + id: extensions.identifier, type: extensions.type, - developerId: extensions.developerId, + developerId: developers.identifier, authorType: developers.type, authorName: developers.name, authorUrl: developers.url, @@ -120,7 +120,10 @@ export class ExtensionsDatabase { // the published-extension index and is unique. rows = page ? ((await base - .orderBy(sql`LOWER(${extensions.id})`, extensions.id) + .orderBy( + sql`LOWER(${extensions.identifier})`, + extensions.identifier + ) .offset(page.offset) .limit(page.limit + 1)) as ExtensionRow[]) : ((await base) as ExtensionRow[]); @@ -150,7 +153,7 @@ export class ExtensionsDatabase { .innerJoin(developers, eq(extensions.developerId, developers.id)) .where( and( - sql`LOWER(${extensions.id}) = LOWER(${id})`, + sql`LOWER(${extensions.identifier}) = LOWER(${id})`, isNotNull(extensions.publishedAt), isNull(extensions.delistedAt) ) @@ -181,7 +184,7 @@ export class ExtensionsDatabase { .from(extensions) .where( and( - sql`LOWER(${extensions.id}) = LOWER(${id})`, + sql`LOWER(${extensions.identifier}) = LOWER(${id})`, isNotNull(extensions.publishedAt), isNull(extensions.delistedAt) ) diff --git a/src/services/extensions/v2/README.md b/src/services/extensions/v2/README.md index d5f36f4f..ce2c2efb 100644 --- a/src/services/extensions/v2/README.md +++ b/src/services/extensions/v2/README.md @@ -6,6 +6,10 @@ Self-service extension publishing, developer-profile ownership, moderation, and This service owns the complete Extensions domain and its `DB_EXTENSIONS` schema. The separate Extensions site keeps OIDC/session state but reaches this domain through the generated HTTPS API client; it must not bind or migrate `DB_EXTENSIONS`. +## Directory Identity + +Extensions and developers have generated UUIDv7 `id` primary keys and separate, globally unique `identifier` fields. Database relationships and machine publisher bindings use UUIDs. Public profile/extension paths use identifiers; `name` is the editable display name. Creation requests supply `identifier`, never the internal ID. The package manifest's `id` remains the public extension identifier. + ## Endpoints Endpoints are not listed here. The service publishes its own contract: @@ -37,7 +41,7 @@ For example, after checking an affected package's actual requirements, an operat UPDATE extensions SET releases = json_set(releases, '$[0].requires', json('{"fossbilling":">=0.8.9","php":">=8.3"}')) -WHERE id = 'affected-extension'; +WHERE identifier = 'affected-extension'; ``` Use the ID, index, and verified constraints from the preflight investigation; the values above are examples. Re-run the query until it returns no rows. diff --git a/src/services/extensions/v2/db/badges.ts b/src/services/extensions/v2/db/badges.ts index 7bb151a0..222ee366 100644 --- a/src/services/extensions/v2/db/badges.ts +++ b/src/services/extensions/v2/db/badges.ts @@ -16,7 +16,7 @@ type BadgeData = { export class ExtensionBadgesDatabase { constructor(private db: ExtensionsDb) {} - async getById(id: string): Promise> { + async getByIdentifier(id: string): Promise> { try { // Select only badge fields so large README content does not affect badges. const [row] = await this.db @@ -31,7 +31,7 @@ export class ExtensionBadgesDatabase { .from(extensions) .where( and( - sql`LOWER(${extensions.id}) = LOWER(${id})`, + sql`LOWER(${extensions.identifier}) = LOWER(${id})`, isNotNull(extensions.publishedAt), isNull(extensions.delistedAt) ) diff --git a/src/services/extensions/v2/db/developer-claims.ts b/src/services/extensions/v2/db/developer-claims.ts index 0b9ba896..d9926c4b 100644 --- a/src/services/extensions/v2/db/developer-claims.ts +++ b/src/services/extensions/v2/db/developer-claims.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gt, isNull, lt, or, sql, SQL } from "drizzle-orm"; +import { and, asc, desc, eq, gt, lt, or, sql, SQL } from "drizzle-orm"; import { DatabaseResult } from "../../../../lib/interfaces"; import { ExtensionsDb } from "../../../../lib/db"; import { createCursorCodec } from "./cursor"; @@ -117,123 +117,99 @@ export class DeveloperClaimsDatabase { } async claim( - developerId: string, + identifier: string, claimantId: string, note?: string, githubToken?: string ): Promise> { try { - let githubOrgVerified: number | null = null; - let githubVerificationNote: string | null = null; - const [developer] = await this.db - .select({ type: developers.type }) + .select({ + id: developers.id, + identifier: developers.identifier, + owner: developers.ownerUserId, + type: developers.type + }) .from(developers) + .where(sql`lower(${developers.identifier}) = lower(${identifier})`); + + if (!developer) + return { + data: null, + error: { code: "NOT_FOUND", message: "Developer not found" } + }; + const developerId = developer.id; + if (developer.owner !== null) + return { + data: null, + error: await this.claimIneligibilityError(developerId, claimantId) + }; + // Reject duplicates before spending the GitHub verification budget. + // The insert below rechecks ownership and claimant eligibility. + const [hasPendingClaim] = await this.db + .select({ one: sql`1` }) + .from(developerClaims) .where( - and(eq(developers.id, developerId), isNull(developers.ownerUserId)) + and( + eq(developerClaims.developerId, developerId), + eq(developerClaims.claimantId, claimantId), + eq(developerClaims.status, "pending") + ) ); - if (developer) { - // Cheap short-circuit ahead of the GitHub lookup below: a claimant - // replaying an already-pending claim on this id would otherwise - // trigger a fresh GitHub API call every time, purely to be told the - // INSERT's own guard rejects it as a duplicate — letting one caller - // burn through the shared service-level GitHub quota for free. This - // is safe precisely because it only ever *returns* here when the - // read observes `pending` — it never falls through to verification - // or the INSERT in that case, so it can't itself create an - // unverified claim. Anything else (no claim yet, or one already - // resolved to approved/rejected) always continues through full - // verification below. A pending claim that resolves between this - // read and the response going out can make the message stale - // relative to that instant, but never lets a row get created - // without verification. - const [hasPendingClaim] = await this.db - .select({ one: sql`1` }) - .from(developerClaims) - .where( - and( - eq(developerClaims.developerId, developerId), - eq(developerClaims.claimantId, claimantId), - eq(developerClaims.status, "pending") - ) - ); - - if (hasPendingClaim) { - return { - data: null, - error: { - code: "CONFLICT", - message: "You already have a pending claim on this profile" - } - }; - } - - if ( - !(await reserveClaimVerification(this.db, claimantId, developerId)) - ) { - return { - data: null, - error: { - code: "RATE_LIMITED", - message: "Claim verification allowance exhausted; try again later" - } - }; - } + if (hasPendingClaim) { + return { + data: null, + error: { + code: "CONFLICT", + message: "You already have a pending claim on this profile" + } + }; + } - const check = await verifyGithubOwnership( - this.db, - developerId, - developer.type as Developer["type"], - claimantId, - githubToken - ); + if (!(await reserveClaimVerification(this.db, claimantId, developerId))) { + return { + data: null, + error: { + code: "RATE_LIMITED", + message: "Claim verification allowance exhausted; try again later" + } + }; + } - if ("error" in check) { - return { data: null, error: check.error }; - } + const check = await verifyGithubOwnership( + this.db, + developer.identifier, + developer.type as Developer["type"], + claimantId, + githubToken + ); - if (check.mismatch) { - return { - data: null, - error: { - code: "GITHUB_MISMATCH", - message: - "Your linked GitHub account doesn't match this developer's GitHub organization or username, so it can't be claimed automatically. Make sure you're signed in with the right GitHub account, then try again." - } - }; - } + if ("error" in check) { + return { data: null, error: check.error }; + } - githubOrgVerified = check.githubOrgVerified; - githubVerificationNote = check.note; + if (check.mismatch) { + return { + data: null, + error: { + code: "GITHUB_MISMATCH", + message: + "Your linked GitHub account doesn't match this developer's GitHub organization or username, so it can't be claimed automatically. Make sure you're signed in with the right GitHub account, then try again." + } + }; } const id = crypto.randomUUID(); - let result; - try { - // Both eligibility checks are folded into the INSERT itself, rather - // than a separate SELECT beforehand — a caller who loses eligibility - // (developer gets claimed/transferred, or the caller picks up a - // different profile) between an up-front check and the write could - // otherwise still slip a stale claim through. (The SELECT above is - // only used to decide the GitHub verification signal, and is always - // re-checked here — it can't itself grant eligibility.) Kept as raw - // sql: an INSERT...SELECT...WHERE EXISTS isn't expressible via - // .insert().values(). - // ON CONFLICT DO NOTHING turns a lost race against the pending-claim - // partial unique index into changes = 0, which the ineligibility - // diagnosis below already has to explain. - result = await this.db.run(sql` - INSERT INTO ${developerClaims} (id, developer_id, claimant_id, note, github_org_verified, github_verification_note) - SELECT ${id}, ${developerId}, ${claimantId}, ${note ?? null}, ${githubOrgVerified}, ${githubVerificationNote} - WHERE EXISTS (SELECT 1 FROM ${developers} WHERE id = ${developerId} AND owner_user_id IS NULL) - AND NOT EXISTS (SELECT 1 FROM ${developers} WHERE owner_user_id = ${claimantId}) - AND EXISTS (SELECT 1 FROM ${users} WHERE ${users.id} = ${claimantId} AND ${users.deletedAt} IS NULL) - ON CONFLICT DO NOTHING - `); - } catch (error) { - return databaseError("claim", error); - } + // Ownership can change during verification; keep eligibility checks in the write. + const result = await this.db.run(sql` + INSERT INTO ${developerClaims} (id, developer_id, claimant_id, note, github_org_verified, github_verification_note) + SELECT ${id}, ${developerId}, ${claimantId}, ${note ?? null}, ${check.githubOrgVerified}, ${check.note} + WHERE EXISTS (SELECT 1 FROM ${developers} WHERE id = ${developerId} AND owner_user_id IS NULL) + AND NOT EXISTS (SELECT 1 FROM ${developers} WHERE owner_user_id = ${claimantId}) + AND EXISTS (SELECT 1 FROM ${users} WHERE ${users.id} = ${claimantId} AND ${users.deletedAt} IS NULL) + ON CONFLICT DO NOTHING + `); if (!result.meta?.changes) { return { @@ -379,6 +355,7 @@ export class DeveloperClaimsDatabase { const rows = await this.db .select({ claim: developerClaims, + developerIdentifier: developers.identifier, developerName: developers.name, developerType: developers.type, claimantName: users.name, @@ -406,6 +383,7 @@ export class DeveloperClaimsDatabase { data: { items: pageRows.map((row) => ({ ...parseClaimRow(row.claim), + developer_identifier: row.developerIdentifier, developer_name: row.developerName, developer_type: row.developerType as PendingDeveloperClaim["developer_type"], diff --git a/src/services/extensions/v2/db/developer-profiles.ts b/src/services/extensions/v2/db/developer-profiles.ts index ac4c3ecf..336a4c0b 100644 --- a/src/services/extensions/v2/db/developer-profiles.ts +++ b/src/services/extensions/v2/db/developer-profiles.ts @@ -1,3 +1,4 @@ +import { v7 as uuidv7 } from "uuid"; import { and, asc, desc, eq, gt, isNull, lt, or, sql, SQL } from "drizzle-orm"; import { DatabaseResult } from "../../../../lib/interfaces"; import { ExtensionsDb } from "../../../../lib/db"; @@ -60,6 +61,7 @@ type DeveloperRow = typeof developers.$inferSelect; function parseDeveloperRow(row: DeveloperRow): DeveloperProfile { return { id: row.id, + identifier: row.identifier, type: row.type as DeveloperProfile["type"], name: row.name, URL: row.url ?? undefined, @@ -157,7 +159,7 @@ export class DeveloperProfilesDatabase { } // githubToken — see the comment on verifyGithubOwnership(). Only consulted - // when creating a brand-new profile (developer.id is immutable once + // when creating a brand-new profile (the identifier is immutable once // owned, so an update can't need re-verifying); guards against squatting // on an id that matches a real GitHub org/user the caller doesn't control, // the one gap claim() alone can't close since it only ever applies to @@ -177,9 +179,15 @@ export class DeveloperProfilesDatabase { .select() .from(developers) .where(eq(developers.ownerUserId, userId)), - this.db.select().from(developers).where(eq(developers.id, developer.id)) + this.db + .select() + .from(developers) + .where( + sql`lower(${developers.identifier}) = lower(${developer.identifier})` + ) ]); const isCreating = !existingOwn; + const developerId = existingOwn?.id ?? uuidv7(); let githubOrgVerified: number | null = null; let githubUrlVerified: number | null = null; @@ -219,7 +227,7 @@ export class DeveloperProfilesDatabase { const check = await verifyGithubOwnership( this.db, - developer.id, + developer.identifier, developer.type, userId, githubToken, @@ -250,13 +258,13 @@ export class DeveloperProfilesDatabase { // a deletion can win between that check and this statement. mainStmt = toD1Statement(this.db.$client, { sql: `INSERT INTO developers ( - id, type, name, url, avatar_url, contact_email, + id, identifier, type, name, url, avatar_url, contact_email, owner_user_id, approved_at, created_at, updated_at, github_org_verified, github_verification_note, github_verified_at, github_url_verified ) - SELECT ?, ?, ?, ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP, + SELECT ?, ?, ?, ?, ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?, CASE WHEN ? IS NULL THEN NULL ELSE CURRENT_TIMESTAMP END, ? @@ -271,7 +279,8 @@ export class DeveloperProfilesDatabase { ) ON CONFLICT DO NOTHING`, params: [ - developer.id, + developerId, + developer.identifier, developer.type, developer.name, developer.URL ?? null, @@ -288,7 +297,7 @@ export class DeveloperProfilesDatabase { ] }); } else { - if (developer.id !== existingOwn.id) { + if (developer.identifier !== existingOwn.identifier) { return { data: null, error: { @@ -336,7 +345,7 @@ export class DeveloperProfilesDatabase { }) .where( and( - eq(developers.id, developer.id), + eq(developers.id, developerId), eq(developers.ownerUserId, userId), // Pin approval/verification decisions to the version read above. eq(developers.contentRevision, existingOwn.contentRevision), @@ -373,7 +382,7 @@ export class DeveloperProfilesDatabase { WHERE changes() = 1`, params: [ crypto.randomUUID(), - developer.id, + developerId, developer.type, developer.name, developer.URL ?? null, @@ -396,7 +405,7 @@ export class DeveloperProfilesDatabase { .from(developers) .where( and( - eq(developers.id, developer.id), + eq(developers.id, developerId), eq(developers.ownerUserId, userId), sql`EXISTS (SELECT 1 FROM ${users} WHERE ${users.id} = ${userId} AND ${users.deletedAt} IS NULL)` @@ -411,7 +420,11 @@ export class DeveloperProfilesDatabase { } return { data: null, - error: await this.upsertBlockedError(userId, developer.id, isCreating) + error: await this.upsertBlockedError( + userId, + developer.identifier, + isCreating + ) }; } @@ -420,7 +433,7 @@ export class DeveloperProfilesDatabase { .from(developers) .where( and( - eq(developers.id, developer.id), + eq(developers.id, developerId), eq(developers.ownerUserId, userId) ) ); @@ -462,7 +475,7 @@ export class DeveloperProfilesDatabase { this.db .select({ id: developers.id }) .from(developers) - .where(eq(developers.id, developerId)), + .where(sql`lower(${developers.identifier}) = lower(${developerId})`), this.db .select({ id: developers.id }) .from(developers) @@ -669,19 +682,26 @@ export class DeveloperProfilesDatabase { } } - async getById( - id: string + getById(id: string) { + return this.getProfile(eq(developers.id, id)); + } + + getByIdentifier(identifier: string) { + return this.getProfile( + sql`lower(${developers.identifier}) = lower(${identifier})` + ); + } + + private async getProfile( + condition: SQL ): Promise> { try { - const [row] = await this.db - .select() - .from(developers) - .where(eq(developers.id, id)); + const [row] = await this.db.select().from(developers).where(condition); if (!row) { return { data: null, error: { - message: `Cannot find developer by id: ${id}`, + message: "Developer not found", code: "NOT_FOUND" } }; @@ -828,7 +848,9 @@ export class DeveloperProfilesDatabase { expectedRevision: number, expectedGeneration: string, reviewerId: string - ): Promise> { + ): Promise< + DatabaseResult<{ id: string; identifier: string; approved: true }> + > { let result; try { result = await this.db @@ -840,7 +862,7 @@ export class DeveloperProfilesDatabase { }) .where( and( - eq(developers.id, id), + sql`lower(${developers.identifier}) = lower(${id})`, eq(developers.contentRevision, expectedRevision), eq(developers.profileGeneration, expectedGeneration), sql`EXISTS ( @@ -849,16 +871,17 @@ export class DeveloperProfilesDatabase { AND ${users.isModerator} = 1 )` ) - ); + ) + .returning({ id: developers.id, identifier: developers.identifier }); } catch (error) { return databaseError("approve", error); } - if (!result.meta?.changes) { + if (!result[0]) { const actorError = await moderatorActorError(this.db, reviewerId); if (actorError) return { data: null, error: actorError }; - const existing = await this.getById(id); + const existing = await this.getByIdentifier(id); if (existing.error) { // A failed diagnostic lookup is not evidence that the developer is // missing. Preserve database errors (and any other lookup error) so @@ -876,7 +899,7 @@ export class DeveloperProfilesDatabase { }; } - return { data: { id, approved: true }, error: null }; + return { data: { ...result[0], approved: true }, error: null }; } // Newest-first keyset pages of one developer's audit history. @@ -919,7 +942,9 @@ export class DeveloperProfilesDatabase { let rows; try { - const conditions = [eq(developerHistory.developerId, developerId)]; + const conditions = [ + sql`${developerHistory.developerId} = (SELECT id FROM developers WHERE identifier=${developerId})` + ]; if (decoded) { conditions.push( or( @@ -1006,6 +1031,7 @@ export class DeveloperProfilesDatabase { const [row] = await this.db .select({ id: developers.id, + identifier: developers.identifier, type: developers.type, url: developers.url }) @@ -1102,7 +1128,7 @@ export class DeveloperProfilesDatabase { const matches = matchesClaimant( row.type as Developer["type"], - row.id, + row.identifier, identity.data ); @@ -1124,7 +1150,10 @@ export class DeveloperProfilesDatabase { if (!matches) { writeUrlVerified = true; } else if (checkUrl) { - const entity = await checkGithubEntity(row.id, githubToken ?? ""); + const entity = await checkGithubEntity( + row.identifier, + githubToken ?? "" + ); // An unavailable lookup is explicitly inconclusive, not a disproof, // so it leaves the stored URL verification signal untouched rather // than clearing a real prior verification over a transient failure. diff --git a/src/services/extensions/v2/db/developer-transfers.ts b/src/services/extensions/v2/db/developer-transfers.ts index df042404..b8d9457b 100644 --- a/src/services/extensions/v2/db/developer-transfers.ts +++ b/src/services/extensions/v2/db/developer-transfers.ts @@ -58,9 +58,15 @@ export class DeveloperTransfersDatabase { } async initiateTransfer( - developerId: string, + identifier: string, userId: string ): Promise> { + const profile = await new DeveloperProfilesDatabase( + this.db + ).getByIdentifier(identifier); + if (profile.error || !profile.data) + return { data: null, error: profile.error }; + const developerId = profile.data.id; try { const token = crypto.randomUUID().replace(/-/g, "") + @@ -140,9 +146,15 @@ export class DeveloperTransfersDatabase { } async revokeTransfer( - developerId: string, + identifier: string, userId: string ): Promise> { + const profile = await new DeveloperProfilesDatabase( + this.db + ).getByIdentifier(identifier); + if (profile.error || !profile.data) + return { data: null, error: profile.error }; + const developerId = profile.data.id; try { const result = await this.db.run(sql` UPDATE ${developerTransfers} SET revoked_at = CURRENT_TIMESTAMP diff --git a/src/services/extensions/v2/db/extensions.ts b/src/services/extensions/v2/db/extensions.ts index feadbb55..435c9d15 100644 --- a/src/services/extensions/v2/db/extensions.ts +++ b/src/services/extensions/v2/db/extensions.ts @@ -1,3 +1,4 @@ +import { v7 as uuidv7 } from "uuid"; import { MAX_CONTENT_BYTES } from "../resource-limits"; import { and, asc, eq, isNotNull, isNull, or, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/sqlite-core"; @@ -37,6 +38,7 @@ export const MAX_PENDING_REVISIONS_PER_USER = 10; // carry a dangling one through its rebuild. const DEVELOPER_COLUMNS = { developerId: developers.id, + developerIdentifier: developers.identifier, developerType: developers.type, developerName: developers.name, developerUrl: developers.url, @@ -135,6 +137,7 @@ const { const EXTENSION_COLUMNS = { id: extensions.id, + identifier: extensions.identifier, ...DETAIL_CONTENT_COLUMNS, publishedBytes, ...DEVELOPER_COLUMNS @@ -142,6 +145,7 @@ const EXTENSION_COLUMNS = { const EXTENSION_LIST_COLUMNS = { id: extensions.id, + identifier: extensions.identifier, ...CARD_CONTENT_COLUMNS, publishedBytes, ...DEVELOPER_COLUMNS @@ -189,6 +193,7 @@ const REVIEW_COLUMNS = { // and the pending revision's stored content (up to 256 KiB per row) with it. const OWNED_LIST_COLUMNS = { id: extensions.id, + identifier: extensions.identifier, publishedAt: extensions.publishedAt, delistedAt: extensions.delistedAt, delistReason: sql`substr(${extensions.delistReason}, 1, 2000)`, @@ -234,6 +239,7 @@ type OwnedRow = Awaited>[number]; interface DeveloperRow { developerId: string; + developerIdentifier: string; developerType: string; developerName: string; developerUrl: string | null; @@ -252,6 +258,7 @@ interface DeveloperRow { interface PublishedRow extends DeveloperRow { manifest: string | null; id: string; + identifier: string; type: string; name: string; description: string; @@ -273,7 +280,7 @@ type PublishedListRow = Omit< export interface ExtensionListFilters { type?: string; - developerId?: string; + developerIdentifier?: string; limit?: number; cursor?: string; } @@ -291,22 +298,18 @@ export interface OwnedExtensionListPage { } type ExtensionCursor = { - normalizedId: string; + identifier: string; id: string; }; -// normalizedId is checked against id rather than trusted: it drives the -// keyset comparison, so a tampered cursor could otherwise seek from a -// position the id itself doesn't correspond to. +// Public identifiers order the directory; the UUID breaks ties. const extensionCursor = createCursorCodec( (parsed): parsed is ExtensionCursor => - typeof parsed.id === "string" && - typeof parsed.normalizedId === "string" && - parsed.normalizedId === parsed.id.toLowerCase() + typeof parsed.id === "string" && typeof parsed.identifier === "string" ); export interface CreateExtensionInput { - extensionId: string; + identifier: string; developerId: string; ownershipEpoch: number; submittedBy: string; @@ -318,7 +321,7 @@ export interface CreateExtensionInput { // route's mapping lookup is only advisory; the insert re-derives it), and // submittedBy is the client's synthetic users row. export interface CreateExtensionAsPublisherInput { - extensionId: string; + identifier: string; clientId: string; developerId: string; submittedBy: string; @@ -338,8 +341,10 @@ export class ExtensionsDatabase { isNull(extensions.delistedAt) ]; if (filters.type) conditions.push(eq(extensions.type, filters.type)); - if (filters.developerId) - conditions.push(eq(extensions.developerId, filters.developerId)); + if (filters.developerIdentifier) + conditions.push( + sql`lower(${developers.identifier}) = lower(${filters.developerIdentifier})` + ); if (filters.cursor) { const cursor = extensionCursor.decode(filters.cursor); @@ -354,7 +359,7 @@ export class ExtensionsDatabase { .from(extensions) .innerJoin(developers, eq(extensions.developerId, developers.id)) .where(and(...conditions)) - .orderBy(asc(sql`LOWER(${extensions.id})`), asc(extensions.id)) + .orderBy(asc(sql`LOWER(${extensions.identifier})`), asc(extensions.id)) .limit(limit + 1)) as PublishedListRow[]; } catch (error) { return databaseError("list", error); @@ -371,7 +376,7 @@ export class ExtensionsDatabase { }; } - async getById(id: string): Promise> { + async getByIdentifier(id: string): Promise> { let rows: PublishedRow[]; try { rows = (await this.db @@ -380,7 +385,7 @@ export class ExtensionsDatabase { .innerJoin(developers, eq(extensions.developerId, developers.id)) .where( and( - sql`LOWER(${extensions.id}) = LOWER(${id})`, + sql`LOWER(${extensions.identifier}) = LOWER(${id})`, isNotNull(extensions.publishedAt), isNull(extensions.delistedAt) ) @@ -431,7 +436,7 @@ export class ExtensionsDatabase { try { rows = await ownedListQuery(this.db) .where(and(...conditions)) - .orderBy(asc(sql`LOWER(${extensions.id})`), asc(extensions.id)) + .orderBy(asc(sql`LOWER(${extensions.identifier})`), asc(extensions.id)) .limit(limit + 1); } catch (error) { return databaseError("listOwned", error); @@ -481,7 +486,7 @@ export class ExtensionsDatabase { // produces for a non-ASCII id. const pattern = `%${escapeLikePattern(filters.q)}%`; conditions.push( - sql`LOWER(${extensions.id}) LIKE LOWER(${pattern}) ESCAPE '\\'` + sql`LOWER(${extensions.identifier}) LIKE LOWER(${pattern}) ESCAPE '\\'` ); } if (filters.type) { @@ -503,7 +508,7 @@ export class ExtensionsDatabase { try { const query = ownedListQuery(this.db); rows = await (conditions.length ? query.where(and(...conditions)) : query) - .orderBy(asc(sql`LOWER(${extensions.id})`), asc(extensions.id)) + .orderBy(asc(sql`LOWER(${extensions.identifier})`), asc(extensions.id)) .limit(limit + 1); } catch (error) { return databaseError("listForModeration", error); @@ -574,7 +579,7 @@ export class ExtensionsDatabase { }) .from(extensions) .innerJoin(developers, eq(extensions.developerId, developers.id)) - .where(sql`LOWER(${extensions.id}) = LOWER(${id})`); + .where(sql`LOWER(${extensions.identifier}) = LOWER(${id})`); const row = rows[0]; if (!row) return notFound(id); @@ -597,7 +602,7 @@ export class ExtensionsDatabase { let rows: OwnedRow[]; try { rows = await ownedQuery(this.db).where( - sql`LOWER(${extensions.id}) = LOWER(${id})` + sql`LOWER(${extensions.identifier}) = LOWER(${id})` ); } catch (error) { return databaseError("getOwned", error); @@ -631,14 +636,17 @@ export class ExtensionsDatabase { // toD1Statement for why this is raw sql rather than two builder calls. async create( input: CreateExtensionInput - ): Promise> { + ): Promise< + DatabaseResult<{ id: string; identifier: string; revisionId: string }> + > { const revisionId = crypto.randomUUID(); + const extensionId = uuidv7(); let results; try { const extensionStmt = toD1Statement(this.db.$client, { - sql: `INSERT INTO extensions (id, developer_id, created_by, created_at, updated_at) - SELECT ?, d.id, d.owner_user_id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + sql: `INSERT INTO extensions (id, identifier, developer_id, created_by, created_at, updated_at) + SELECT ?, ?, d.id, d.owner_user_id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM developers d WHERE d.id = ? AND d.owner_user_id = ? AND d.ownership_epoch = ? AND EXISTS ( @@ -650,7 +658,8 @@ export class ExtensionsDatabase { ) < ? ON CONFLICT DO NOTHING`, params: [ - input.extensionId, + extensionId, + input.identifier, input.developerId, input.submittedBy, input.ownershipEpoch, @@ -667,7 +676,7 @@ export class ExtensionsDatabase { WHERE changes() = 1`, params: [ revisionId, - input.extensionId, + extensionId, input.developerId, input.submittedBy, JSON.stringify(input.content), @@ -688,7 +697,10 @@ export class ExtensionsDatabase { } } - return { data: { id: input.extensionId, revisionId }, error: null }; + return { + data: { id: extensionId, identifier: input.identifier, revisionId }, + error: null + }; } // The insert affected no rows: either ON CONFLICT DO NOTHING swallowed an id @@ -703,7 +715,7 @@ export class ExtensionsDatabase { const [taken] = await this.db .select({ one: sql`1` }) .from(extensions) - .where(sql`LOWER(${extensions.id}) = LOWER(${input.extensionId})`); + .where(sql`LOWER(${extensions.identifier}) = LOWER(${input.identifier})`); if (taken) { return { message: "An extension with this id already exists", @@ -723,14 +735,17 @@ export class ExtensionsDatabase { // users row); the mapped developer's human owner must still be active. async createAsPublisher( input: CreateExtensionAsPublisherInput - ): Promise> { + ): Promise< + DatabaseResult<{ id: string; identifier: string; revisionId: string }> + > { const revisionId = crypto.randomUUID(); + const extensionId = uuidv7(); let results; try { const extensionStmt = toD1Statement(this.db.$client, { - sql: `INSERT INTO extensions (id, developer_id, created_by, created_at, updated_at) - SELECT ?, d.id, d.owner_user_id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + sql: `INSERT INTO extensions (id, identifier, developer_id, created_by, created_at, updated_at) + SELECT ?, ?, d.id, d.owner_user_id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM developers d WHERE d.id = ? AND EXISTS ( @@ -747,7 +762,8 @@ export class ExtensionsDatabase { ) < ? ON CONFLICT DO NOTHING`, params: [ - input.extensionId, + extensionId, + input.identifier, input.developerId, input.clientId, input.submittedBy, @@ -764,7 +780,7 @@ export class ExtensionsDatabase { AND changes() = 1`, params: [ revisionId, - input.extensionId, + extensionId, input.submittedBy, JSON.stringify(input.content), input.developerId @@ -787,7 +803,10 @@ export class ExtensionsDatabase { } } - return { data: { id: input.extensionId, revisionId }, error: null }; + return { + data: { id: extensionId, identifier: input.identifier, revisionId }, + error: null + }; } private async createAsPublisherBlockedError( @@ -847,7 +866,7 @@ export class ExtensionsDatabase { const [taken] = await this.db .select({ one: sql`1` }) .from(extensions) - .where(sql`LOWER(${extensions.id}) = LOWER(${input.extensionId})`); + .where(sql`LOWER(${extensions.identifier}) = LOWER(${input.identifier})`); if (taken) { return { message: "An extension with this id already exists", @@ -867,12 +886,12 @@ export class ExtensionsDatabase { async withdraw( id: string, ownerUserId: string - ): Promise> { + ): Promise> { let result; try { - result = await this.db.run(sql` + result = await this.db.all<{ id: string; identifier: string }>(sql` DELETE FROM ${extensions} - WHERE LOWER(id) = LOWER(${id}) + WHERE LOWER(identifier) = LOWER(${id}) AND published_at IS NULL AND developer_id IN ( SELECT d.id FROM ${developers} d WHERE d.owner_user_id = ${ownerUserId} @@ -881,16 +900,17 @@ export class ExtensionsDatabase { SELECT 1 FROM ${users} u WHERE u.id = ${ownerUserId} AND u.deleted_at IS NULL ) + RETURNING id,identifier `); } catch (error) { return databaseError("withdraw", error); } - if (!result.meta?.changes) { + if (!result[0]) { return this.withdrawBlockedError(id, ownerUserId); } - return { data: { id }, error: null }; + return { data: result[0], error: null }; } // Separates the four ways the delete can affect no rows, so the route can @@ -912,7 +932,7 @@ export class ExtensionsDatabase { }) .from(extensions) .innerJoin(developers, eq(extensions.developerId, developers.id)) - .where(sql`LOWER(${extensions.id}) = LOWER(${id})`); + .where(sql`LOWER(${extensions.identifier}) = LOWER(${id})`); if (!existing) return notFound(id); if (existing.publishedAt) { return { @@ -950,7 +970,7 @@ export class ExtensionsDatabase { id: string, moderatorId: string, reason: string - ): Promise> { + ): Promise> { let result; try { result = await this.db @@ -962,7 +982,7 @@ export class ExtensionsDatabase { }) .where( and( - sql`LOWER(${extensions.id}) = LOWER(${id})`, + sql`LOWER(${extensions.identifier}) = LOWER(${id})`, isNotNull(extensions.publishedAt), isNull(extensions.delistedAt), sql`EXISTS ( @@ -971,16 +991,17 @@ export class ExtensionsDatabase { AND ${users.isModerator} = 1 )` ) - ); + ) + .returning({ id: extensions.id, identifier: extensions.identifier }); } catch (error) { return databaseError("delist", error); } - if (!result.meta?.changes) { + if (!result[0]) { return this.delistBlockedError(id, moderatorId); } - return { data: { id }, error: null }; + return { data: result[0], error: null }; } // Separates the three ways delist()'s guard can affect no rows, so the @@ -1001,7 +1022,7 @@ export class ExtensionsDatabase { delistedAt: extensions.delistedAt }) .from(extensions) - .where(sql`LOWER(${extensions.id}) = LOWER(${id})`); + .where(sql`LOWER(${extensions.identifier}) = LOWER(${id})`); } catch (error) { return databaseError("delist", error); } @@ -1041,7 +1062,7 @@ export class ExtensionsDatabase { async relist( id: string, moderatorId: string - ): Promise> { + ): Promise> { let rows; try { rows = await this.db @@ -1053,7 +1074,7 @@ export class ExtensionsDatabase { }) .where( and( - sql`LOWER(${extensions.id}) = LOWER(${id})`, + sql`LOWER(${extensions.identifier}) = LOWER(${id})`, isNotNull(extensions.publishedAt), isNotNull(extensions.delistedAt), sql`EXISTS ( @@ -1064,7 +1085,7 @@ export class ExtensionsDatabase { )` ) ) - .returning({ id: extensions.id }); + .returning({ id: extensions.id, identifier: extensions.identifier }); } catch (error) { return databaseError("relist", error); } @@ -1074,7 +1095,7 @@ export class ExtensionsDatabase { return this.relistBlockedError(id, moderatorId); } - return { data: { id: row.id }, error: null }; + return { data: row, error: null }; } private async relistBlockedError( @@ -1097,7 +1118,7 @@ export class ExtensionsDatabase { delistedAt: extensions.delistedAt }) .from(extensions) - .where(sql`LOWER(${extensions.id}) = LOWER(${id})`); + .where(sql`LOWER(${extensions.identifier}) = LOWER(${id})`); } catch (error) { return databaseError("relist", error); } @@ -1139,7 +1160,9 @@ export class ExtensionsDatabase { moderatorId: string, content: ExtensionContent, correctionNote: string - ): Promise> { + ): Promise< + DatabaseResult<{ id: string; identifier: string; revisionId: string }> + > { const parsed = ExtensionContentSchema.safeParse(content); if (!parsed.success) { return { @@ -1162,7 +1185,7 @@ export class ExtensionsDatabase { SELECT ?, e.id, e.developer_id, ?, 'approved', ?, ?, ?, CURRENT_TIMESTAMP, d.ownership_epoch FROM extensions e JOIN developers d ON d.id = e.developer_id - WHERE LOWER(e.id) = LOWER(?) + WHERE LOWER(e.identifier) = LOWER(?) AND e.published_at IS NOT NULL AND e.delisted_at IS NULL AND NOT EXISTS ( @@ -1173,7 +1196,7 @@ export class ExtensionsDatabase { SELECT 1 FROM users u WHERE u.id = ? AND u.deleted_at IS NULL AND u.is_moderator = 1 ) - RETURNING extension_id`, + RETURNING extension_id, (SELECT identifier FROM extensions WHERE id=extension_id) AS identifier`, params: [ revisionId, moderatorId, @@ -1197,7 +1220,7 @@ export class ExtensionsDatabase { download_url = ?, manifest = ?, published_revision_id = ?, updated_at = CURRENT_TIMESTAMP - WHERE changes() = 1 AND LOWER(id) = LOWER(?)`, + WHERE changes() = 1 AND LOWER(identifier) = LOWER(?)`, params: [ valid.type, valid.name, @@ -1228,6 +1251,8 @@ export class ExtensionsDatabase { return { data: { id: (results[0].results[0] as { extension_id: string }).extension_id, + identifier: (results[0].results[0] as { identifier: string }) + .identifier, revisionId }, error: null @@ -1254,7 +1279,7 @@ export class ExtensionsDatabase { delistedAt: extensions.delistedAt }) .from(extensions) - .where(sql`LOWER(${extensions.id}) = LOWER(${id})`); + .where(sql`LOWER(${extensions.identifier}) = LOWER(${id})`); } catch (error) { return databaseError("moderatorCorrect", error); } @@ -1284,7 +1309,7 @@ export class ExtensionsDatabase { .from(extensionRevisions) .where( and( - sql`LOWER(${extensionRevisions.extensionId}) = LOWER(${id})`, + sql`${extensionRevisions.extensionId} = (SELECT id FROM extensions WHERE lower(identifier)=lower(${id}))`, eq(extensionRevisions.status, "pending") ) ); @@ -1330,9 +1355,9 @@ function notFound(id: string): DatabaseResult { function keysetAfter(cursor: ExtensionCursor) { return or( - sql`LOWER(${extensions.id}) > ${cursor.normalizedId}`, + sql`LOWER(${extensions.identifier}) > ${cursor.identifier}`, and( - sql`LOWER(${extensions.id}) = ${cursor.normalizedId}`, + sql`LOWER(${extensions.identifier}) = ${cursor.identifier}`, sql`${extensions.id} > ${cursor.id}` ) )!; @@ -1345,6 +1370,7 @@ export function isValidExtensionCursor(value: string): boolean { function parseDeveloper(row: DeveloperRow): PublicDeveloper { return { id: row.developerId, + identifier: row.developerIdentifier, type: row.developerType as "user" | "organization", name: row.developerName, URL: row.developerUrl ?? undefined, @@ -1403,7 +1429,7 @@ interface ListContentFields { function listContent( row: ListContentFields, card: true -): Omit; +): Omit; function listContent( row: ListContentFields, card: false @@ -1412,7 +1438,7 @@ function listContent( row: ListContentFields, card: boolean ): - | Omit + | Omit | Omit { const metadata = parseManifestMetadata(row.manifest); return { @@ -1446,7 +1472,7 @@ function listContent( // Shared keyset-pagination tail: each listing over-fetches one row to detect // hasMore, then encodes the next cursor from the final returned row. -function keysetPage( +function keysetPage( rows: T[], limit: number ): { pageRows: T[]; hasMore: boolean; nextCursor: string | null } { @@ -1459,7 +1485,7 @@ function keysetPage( nextCursor: hasMore && last ? extensionCursor.encode({ - normalizedId: last.id.toLowerCase(), + identifier: last.identifier.toLowerCase(), id: last.id }) : null @@ -1469,6 +1495,7 @@ function keysetPage( function parseListRow(row: PublishedListRow): ExtensionListItem { return { id: row.id, + identifier: row.identifier, ...listContent(row, true), developer: parseDeveloper(row) }; @@ -1479,6 +1506,7 @@ function parseListRow(row: PublishedListRow): ExtensionListItem { function parseRow(row: PublishedRow): Extension { return { id: row.id, + identifier: row.identifier, developer: parseDeveloper(row), ...listContent(row, false), homepage_url: row.website || undefined, @@ -1491,6 +1519,7 @@ function parseRow(row: PublishedRow): Extension { function parseOwnedListRow(row: OwnedListRow): OwnedExtensionListItem { return { id: row.id, + identifier: row.identifier, developer: parseDeveloper(row), published: row.publishedAt ? listContent(row, true) : null, pending_revision: diff --git a/src/services/extensions/v2/db/migrations/0030_directory_identity.sql b/src/services/extensions/v2/db/migrations/0030_directory_identity.sql new file mode 100644 index 00000000..785b32b3 --- /dev/null +++ b/src/services/extensions/v2/db/migrations/0030_directory_identity.sql @@ -0,0 +1,670 @@ +-- Keep directory identities independent from public identifiers. +-- Rebuild children before parents so D1 can enforce foreign keys throughout. +CREATE TABLE _developers_ids AS SELECT id AS identifier, + substr(timestamp,1,8)||'-'||substr(timestamp,9,4)||'-7'||substr(entropy,1,3)||'-'||substr('89ab',1+(abs(random() % 4)),1)||substr(entropy,4,3)||'-'||substr(entropy,7,12) AS id + FROM (SELECT id,printf('%012x',CAST(unixepoch(created_at)*1000 AS INTEGER)) AS timestamp,lower(hex(randomblob(9))) AS entropy FROM developers); +--> statement-breakpoint +CREATE TABLE _extensions_ids AS SELECT id AS identifier, + substr(timestamp,1,8)||'-'||substr(timestamp,9,4)||'-7'||substr(entropy,1,3)||'-'||substr('89ab',1+(abs(random() % 4)),1)||substr(entropy,4,3)||'-'||substr(entropy,7,12) AS id + FROM (SELECT id,printf('%012x',CAST(unixepoch(created_at)*1000 AS INTEGER)) AS timestamp,lower(hex(randomblob(9))) AS entropy FROM extensions); +--> statement-breakpoint +CREATE TABLE _developers_backup AS SELECT rowid AS original_rowid,* FROM developers; +--> statement-breakpoint +CREATE TABLE _extensions_backup AS SELECT rowid AS original_rowid,* FROM extensions; +--> statement-breakpoint +CREATE TABLE _developer_transfers_backup AS SELECT rowid AS original_rowid,* FROM developer_transfers; +--> statement-breakpoint +CREATE TABLE _developer_claims_backup AS SELECT rowid AS original_rowid,* FROM developer_claims; +--> statement-breakpoint +CREATE TABLE _developer_history_backup AS SELECT rowid AS original_rowid,* FROM developer_history; +--> statement-breakpoint +CREATE TABLE _extension_write_events_backup AS SELECT rowid AS original_rowid,* FROM extension_write_events; +--> statement-breakpoint +CREATE TABLE _m2m_publisher_clients_backup AS SELECT rowid AS original_rowid,* FROM m2m_publisher_clients; +--> statement-breakpoint +CREATE TABLE _extension_revisions_backup AS SELECT rowid AS original_rowid,* FROM extension_revisions; +--> statement-breakpoint +DROP TRIGGER trg_developer_history_no_update; +--> statement-breakpoint +DROP TRIGGER trg_developer_history_no_delete; +--> statement-breakpoint +DROP TRIGGER resource_extension_revisions_insert; +--> statement-breakpoint +DROP TRIGGER resource_extension_revisions_delete; +--> statement-breakpoint +DROP TRIGGER resource_extension_revisions_update; +--> statement-breakpoint +DROP TRIGGER resource_extension_revisions_identity_update; +--> statement-breakpoint +DROP TRIGGER extension_revision_admission; +--> statement-breakpoint +DROP TRIGGER extension_revision_write_event; +--> statement-breakpoint +DROP TRIGGER extension_revision_content_bound; +--> statement-breakpoint +DROP TRIGGER extension_revision_identity; +--> statement-breakpoint +DROP TRIGGER extension_resource_identity; +--> statement-breakpoint +DROP TRIGGER sync_extension_revisions_bytes_insert; +--> statement-breakpoint +DROP TRIGGER sync_extension_revisions_bytes_update; +--> statement-breakpoint +DROP TRIGGER sync_revision_summary_insert; +--> statement-breakpoint +DROP TRIGGER sync_revision_summary_update; +--> statement-breakpoint +DROP TRIGGER developers_generate_profile_instance; +--> statement-breakpoint +DROP TRIGGER resource_extensions_insert; +--> statement-breakpoint +DROP TRIGGER sync_extensions_bytes_insert; +--> statement-breakpoint +DROP TRIGGER sync_extensions_bytes_update; +--> statement-breakpoint +DROP TRIGGER resource_extensions_delete; +--> statement-breakpoint +DROP TRIGGER resource_extensions_update; +--> statement-breakpoint +DROP TRIGGER extensions_published_manifest_insert; +--> statement-breakpoint +DROP TRIGGER extensions_published_manifest_update; +--> statement-breakpoint +DROP TABLE extension_revisions; +--> statement-breakpoint +DROP TABLE m2m_publisher_clients; +--> statement-breakpoint +DROP TABLE extension_write_events; +--> statement-breakpoint +DROP TABLE developer_history; +--> statement-breakpoint +DROP TABLE developer_claims; +--> statement-breakpoint +DROP TABLE developer_transfers; +--> statement-breakpoint +DROP TABLE extensions; +--> statement-breakpoint +DROP TABLE developers; +--> statement-breakpoint +CREATE TABLE "developers" ( + id TEXT PRIMARY KEY NOT NULL, + identifier text NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + url TEXT +, owner_user_id TEXT REFERENCES users(id), approved_at TEXT, created_at TEXT NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', updated_at TEXT NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', avatar_url TEXT, contact_email TEXT, ownership_epoch INTEGER NOT NULL DEFAULT 1 CHECK (ownership_epoch >= 1), content_revision INTEGER NOT NULL DEFAULT 1 CHECK (content_revision >= 1), approved_revision INTEGER, approved_by TEXT, github_org_verified INTEGER CHECK (github_org_verified IN (0, 1)), github_verification_note TEXT, `github_verified_at` text, github_url_verified INTEGER CHECK (github_url_verified = 1), `url_check_cooldown_until` text, `profile_generation` text DEFAULT '' NOT NULL); +--> statement-breakpoint +CREATE TABLE "extensions" ( + `id` text PRIMARY KEY NOT NULL, + identifier text NOT NULL, + `developer_id` text NOT NULL, + `published_at` text, + `published_revision_id` text, + `type` text, + `name` text, + `description` text, + `releases` text, + `website` text, + `license` text, + `icon_url` text, + `readme` text, + `source` text, + `version` text, + `download_url` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, `delisted_at` text, `delist_reason` text, created_by TEXT, published_bytes INTEGER NOT NULL DEFAULT 0, manifest TEXT, + FOREIGN KEY (`developer_id`) REFERENCES `developers`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "extensions_published_content_check" CHECK("extensions"."published_at" IS NULL OR ( + "extensions"."type" IS NOT NULL AND "extensions"."name" IS NOT NULL AND + "extensions"."description" IS NOT NULL AND "extensions"."releases" IS NOT NULL AND + "extensions"."website" IS NOT NULL AND "extensions"."license" IS NOT NULL AND + "extensions"."readme" IS NOT NULL AND "extensions"."source" IS NOT NULL AND + "extensions"."version" IS NOT NULL AND "extensions"."download_url" IS NOT NULL + )) +); +--> statement-breakpoint +CREATE TABLE "developer_transfers" ( + id TEXT PRIMARY KEY NOT NULL, + developer_id TEXT NOT NULL REFERENCES "developers"(id), + token_hash TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + accepted_by TEXT REFERENCES users(id), + accepted_at TEXT, + revoked_at TEXT +); +--> statement-breakpoint +CREATE TABLE "developer_claims" ( + id TEXT PRIMARY KEY NOT NULL, + developer_id TEXT NOT NULL REFERENCES "developers"(id), + claimant_id TEXT NOT NULL REFERENCES users(id), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), + note TEXT, + review_note TEXT, + reviewer_id TEXT REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + reviewed_at TEXT +, github_org_verified INTEGER CHECK (github_org_verified IN (0, 1)), github_verification_note TEXT); +--> statement-breakpoint +CREATE TABLE "developer_history" ( + id TEXT PRIMARY KEY NOT NULL, + developer_id TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + url TEXT, + changed_by TEXT NOT NULL REFERENCES users(id), + changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +--> statement-breakpoint +CREATE TABLE extension_write_events ( + id TEXT PRIMARY KEY NOT NULL, account_id TEXT NOT NULL, + developer_id TEXT NOT NULL, occurred_at INTEGER NOT NULL +); +--> statement-breakpoint +CREATE TABLE `m2m_publisher_clients` ( + `client_id` text PRIMARY KEY NOT NULL, + `developer_id` text NOT NULL, + `can_publish` integer DEFAULT 0 NOT NULL, + `ownership_epoch` integer DEFAULT 1 NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `revoked_at` text, + FOREIGN KEY (`developer_id`) REFERENCES `developers`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "m2m_publisher_clients_can_publish_check" CHECK("m2m_publisher_clients"."can_publish" IN (0, 1)), + CONSTRAINT "m2m_publisher_clients_ownership_epoch_check" CHECK("m2m_publisher_clients"."ownership_epoch" >= 1) +); +--> statement-breakpoint +CREATE TABLE `extension_revisions` ( + `id` text PRIMARY KEY NOT NULL, + `extension_id` text NOT NULL, + `developer_id` text NOT NULL, + `submitted_by` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `content` text NOT NULL, + `reviewer_id` text, + `review_note` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `reviewed_at` text, + `ownership_epoch` integer DEFAULT 1 NOT NULL, content_bytes INTEGER NOT NULL DEFAULT 0, content_hash TEXT, compacted_at TEXT, summary_name TEXT, summary_version TEXT, summary_description TEXT, content_readable INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (`extension_id`) REFERENCES `extensions`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`submitted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`reviewer_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "extension_revisions_status_check" CHECK("extension_revisions"."status" IN ('pending', 'approved', 'rejected')), + CONSTRAINT "extension_revisions_ownership_epoch_check" CHECK("extension_revisions"."ownership_epoch" >= 1) +); +--> statement-breakpoint +INSERT INTO developers (id,type,name,url,owner_user_id,approved_at,created_at,updated_at,avatar_url,contact_email,ownership_epoch,content_revision,approved_revision,approved_by,github_org_verified,github_verification_note,github_verified_at,github_url_verified,url_check_cooldown_until,profile_generation,identifier) + SELECT (SELECT id FROM _developers_ids WHERE identifier=b.id),b.type,b.name,b.url,b.owner_user_id,b.approved_at,b.created_at,b.updated_at,b.avatar_url,b.contact_email,b.ownership_epoch,b.content_revision,b.approved_revision,b.approved_by,b.github_org_verified,b.github_verification_note,b.github_verified_at,b.github_url_verified,b.url_check_cooldown_until,b.profile_generation,b.id + FROM _developers_backup b ORDER BY original_rowid; +--> statement-breakpoint +INSERT INTO extensions (id,developer_id,published_at,published_revision_id,type,name,description,releases,website,license,icon_url,readme,source,version,download_url,created_at,updated_at,delisted_at,delist_reason,created_by,published_bytes,manifest,identifier) + SELECT (SELECT id FROM _extensions_ids WHERE identifier=b.id),coalesce((SELECT id FROM _developers_ids WHERE identifier=b.developer_id),b.developer_id),b.published_at,b.published_revision_id,b.type,b.name,b.description,b.releases,b.website,b.license,b.icon_url,b.readme,b.source,b.version,b.download_url,b.created_at,b.updated_at,b.delisted_at,b.delist_reason,b.created_by,b.published_bytes,b.manifest,b.id + FROM _extensions_backup b ORDER BY original_rowid; +--> statement-breakpoint +INSERT INTO developer_transfers (id,developer_id,token_hash,created_by,created_at,expires_at,accepted_by,accepted_at,revoked_at) + SELECT b.id,coalesce((SELECT id FROM _developers_ids WHERE identifier=b.developer_id),b.developer_id),b.token_hash,b.created_by,b.created_at,b.expires_at,b.accepted_by,b.accepted_at,b.revoked_at + FROM _developer_transfers_backup b ORDER BY original_rowid; +--> statement-breakpoint +INSERT INTO developer_claims (id,developer_id,claimant_id,status,note,review_note,reviewer_id,created_at,reviewed_at,github_org_verified,github_verification_note) + SELECT b.id,coalesce((SELECT id FROM _developers_ids WHERE identifier=b.developer_id),b.developer_id),b.claimant_id,b.status,b.note,b.review_note,b.reviewer_id,b.created_at,b.reviewed_at,b.github_org_verified,b.github_verification_note + FROM _developer_claims_backup b ORDER BY original_rowid; +--> statement-breakpoint +INSERT INTO developer_history (id,developer_id,type,name,url,changed_by,changed_at) + SELECT b.id,coalesce((SELECT id FROM _developers_ids WHERE identifier=b.developer_id),b.developer_id),b.type,b.name,b.url,b.changed_by,b.changed_at + FROM _developer_history_backup b ORDER BY original_rowid; +--> statement-breakpoint +INSERT INTO extension_write_events (id,account_id,developer_id,occurred_at) + SELECT b.id,b.account_id,coalesce((SELECT id FROM _developers_ids WHERE identifier=b.developer_id),b.developer_id),b.occurred_at + FROM _extension_write_events_backup b ORDER BY original_rowid; +--> statement-breakpoint +INSERT INTO m2m_publisher_clients (client_id,developer_id,can_publish,ownership_epoch,created_at,revoked_at) + SELECT b.client_id,coalesce((SELECT id FROM _developers_ids WHERE identifier=b.developer_id),b.developer_id),b.can_publish,b.ownership_epoch,b.created_at,b.revoked_at + FROM _m2m_publisher_clients_backup b ORDER BY original_rowid; +--> statement-breakpoint +INSERT INTO extension_revisions (id,extension_id,developer_id,submitted_by,status,content,reviewer_id,review_note,created_at,reviewed_at,ownership_epoch,content_bytes,content_hash,compacted_at,summary_name,summary_version,summary_description,content_readable) + SELECT b.id,(SELECT id FROM _extensions_ids WHERE identifier=b.extension_id),coalesce((SELECT id FROM _developers_ids WHERE identifier=b.developer_id),b.developer_id),b.submitted_by,b.status,b.content,b.reviewer_id,b.review_note,b.created_at,b.reviewed_at,b.ownership_epoch,b.content_bytes,b.content_hash,b.compacted_at,b.summary_name,b.summary_version,b.summary_description,b.content_readable + FROM _extension_revisions_backup b ORDER BY original_rowid; +--> statement-breakpoint +CREATE UNIQUE INDEX idx_developers_owner_unique ON developers(owner_user_id); +--> statement-breakpoint +CREATE INDEX idx_developers_approved ON developers(approved_at); +--> statement-breakpoint +CREATE UNIQUE INDEX idx_developer_transfers_token ON developer_transfers(token_hash); +--> statement-breakpoint +CREATE UNIQUE INDEX idx_developer_transfers_pending + ON developer_transfers(developer_id) + WHERE accepted_at IS NULL AND revoked_at IS NULL; +--> statement-breakpoint +CREATE INDEX idx_developer_claims_developer ON developer_claims(developer_id); +--> statement-breakpoint +CREATE INDEX idx_developer_claims_claimant ON developer_claims(claimant_id); +--> statement-breakpoint +CREATE UNIQUE INDEX idx_developer_claims_pending_unique + ON developer_claims(developer_id, claimant_id) + WHERE status = 'pending'; +--> statement-breakpoint +CREATE INDEX idx_developer_claims_pending_queue + ON developer_claims(created_at) + WHERE status = 'pending'; +--> statement-breakpoint +CREATE INDEX idx_developer_history_developer_changed_at + ON developer_history(developer_id, changed_at); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_extensions_identifier_nocase` ON `extensions` (lower("identifier")); +--> statement-breakpoint +CREATE INDEX `idx_extensions_developer_order` ON `extensions` (`developer_id`,lower("identifier"),`id`); +--> statement-breakpoint +CREATE INDEX `idx_extension_revisions_submitted_by` ON `extension_revisions` (`submitted_by`); +--> statement-breakpoint +CREATE INDEX `idx_extension_revisions_developer` ON `extension_revisions` (`developer_id`); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_extension_revisions_pending` ON `extension_revisions` (`extension_id`) WHERE "extension_revisions"."status" = 'pending'; +--> statement-breakpoint +CREATE INDEX `idx_extension_revisions_extension_page` ON `extension_revisions` (`extension_id`,"created_at" desc,"id" desc); +--> statement-breakpoint +CREATE INDEX `idx_extension_revisions_submitter_page` ON `extension_revisions` (`submitted_by`,"created_at" desc,"id" desc); +--> statement-breakpoint +CREATE INDEX `idx_extension_revisions_queue_page` ON `extension_revisions` (`status`,`created_at`,`id`); +--> statement-breakpoint +CREATE INDEX `idx_extension_revisions_reviewed` ON `extension_revisions` (`extension_id`,`reviewed_at`) WHERE "extension_revisions"."status" IN ('approved', 'rejected'); +--> statement-breakpoint +CREATE INDEX `idx_extension_revisions_submitter_pending` ON `extension_revisions` (`submitted_by`) WHERE "extension_revisions"."status" = 'pending'; +--> statement-breakpoint +CREATE INDEX `idx_developer_history_account_changed_at` ON `developer_history` (`changed_by`,`changed_at`); +--> statement-breakpoint +CREATE INDEX idx_extension_write_events_account ON extension_write_events(account_id,occurred_at); +--> statement-breakpoint +CREATE INDEX idx_extension_write_events_developer ON extension_write_events(developer_id,occurred_at); +--> statement-breakpoint +CREATE INDEX idx_extension_write_events_time ON extension_write_events(occurred_at); +--> statement-breakpoint +CREATE INDEX idx_extensions_published_revision ON extensions(published_revision_id); +--> statement-breakpoint +CREATE INDEX idx_extension_revisions_retention ON extension_revisions(compacted_at,reviewed_at,id,status); +--> statement-breakpoint +CREATE INDEX `idx_m2m_publisher_clients_developer` ON `m2m_publisher_clients` (`developer_id`); +--> statement-breakpoint +CREATE INDEX idx_extensions_public_order ON extensions (lower(id),id) WHERE published_at IS NOT NULL AND delisted_at IS NULL; +--> statement-breakpoint +CREATE INDEX idx_extensions_type_public_order ON extensions (type,lower(id),id) WHERE published_at IS NOT NULL AND delisted_at IS NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX idx_developers_identifier_nocase ON developers(lower(identifier)); +--> statement-breakpoint +CREATE TRIGGER trg_developer_history_no_update +BEFORE UPDATE ON developer_history +BEGIN + SELECT RAISE(ABORT, 'developer_history is append-only: rows cannot be updated'); +END; +--> statement-breakpoint +CREATE TRIGGER trg_developer_history_no_delete +BEFORE DELETE ON developer_history +BEGIN + SELECT RAISE(ABORT, 'developer_history is append-only: rows cannot be deleted'); +END; +--> statement-breakpoint +CREATE TRIGGER resource_extension_revisions_insert AFTER INSERT ON extension_revisions BEGIN + INSERT INTO extension_resource_usage(scope,subject,bytes,extensions,revisions) + SELECT scope, subject, length(CAST(NEW.content AS BLOB)), 0, 1 + FROM ( + SELECT 'global' AS scope, 'all' AS subject + UNION ALL SELECT 'account', NEW.submitted_by + UNION ALL SELECT 'developer', NEW.developer_id + ) WHERE true + ON CONFLICT(scope,subject) DO UPDATE SET + bytes=bytes+excluded.bytes, + extensions=extensions+excluded.extensions, + revisions=revisions+excluded.revisions; + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM extension_resource_usage + WHERE ((scope='account' AND subject=NEW.submitted_by) + OR (scope='developer' AND subject=NEW.developer_id)) + AND (bytes>26214400 OR extensions>100 OR revisions>1000) + ) THEN RAISE(ABORT,'extension_resource_quota') END; +END; +--> statement-breakpoint +CREATE TRIGGER resource_extension_revisions_delete AFTER DELETE ON extension_revisions BEGIN + UPDATE extension_resource_usage + SET bytes=bytes-(length(CAST(OLD.content AS BLOB))), revisions=revisions-1 + WHERE (scope='global' AND subject='all') + OR (scope='account' AND subject=OLD.submitted_by) + OR (scope='developer' AND subject=OLD.developer_id); +END; +--> statement-breakpoint +CREATE TRIGGER resource_extension_revisions_update AFTER UPDATE OF content ON extension_revisions +WHEN NEW.submitted_by=OLD.submitted_by AND NEW.developer_id=OLD.developer_id BEGIN + UPDATE extension_resource_usage + SET bytes = bytes + length(CAST(NEW.content AS BLOB)) - length(CAST(OLD.content AS BLOB)) + WHERE (scope='global' AND subject='all') + OR (scope='account' AND subject=NEW.submitted_by) + OR (scope='developer' AND subject=NEW.developer_id); + SELECT CASE WHEN length(CAST(NEW.content AS BLOB)) > length(CAST(OLD.content AS BLOB)) AND EXISTS ( + SELECT 1 FROM extension_resource_usage + WHERE ((scope='account' AND subject=NEW.submitted_by) + OR (scope='developer' AND subject=NEW.developer_id)) + AND (bytes>26214400 OR extensions>100 OR revisions>1000) + ) + THEN RAISE(ABORT,'extension_resource_quota') END; +END; +--> statement-breakpoint +CREATE TRIGGER resource_extension_revisions_identity_update AFTER UPDATE OF submitted_by,developer_id ON extension_revisions +WHEN NEW.submitted_by IS NOT OLD.submitted_by OR NEW.developer_id IS NOT OLD.developer_id BEGIN +UPDATE extension_resource_usage SET bytes=bytes+((length(CAST(OLD.content AS BLOB))) * -1), revisions=revisions+(-1) + WHERE scope='global' AND subject='all'; +UPDATE extension_resource_usage SET bytes=bytes+((length(CAST(OLD.content AS BLOB))) * -1), revisions=revisions+(-1) + WHERE scope='account' AND subject=OLD.submitted_by; +UPDATE extension_resource_usage SET bytes=bytes+((length(CAST(OLD.content AS BLOB))) * -1), revisions=revisions+(-1) + WHERE scope='developer' AND subject=OLD.developer_id; +INSERT INTO extension_resource_usage(scope,subject,bytes,extensions,revisions) VALUES ('global','all',(length(CAST(NEW.content AS BLOB))),0,1) + ON CONFLICT(scope,subject) DO UPDATE SET + bytes=bytes+excluded.bytes,extensions=extensions+excluded.extensions,revisions=revisions+excluded.revisions; +INSERT INTO extension_resource_usage(scope,subject,bytes,extensions,revisions) VALUES ('account',NEW.submitted_by,(length(CAST(NEW.content AS BLOB))),0,1) + ON CONFLICT(scope,subject) DO UPDATE SET + bytes=bytes+excluded.bytes,extensions=extensions+excluded.extensions,revisions=revisions+excluded.revisions; +SELECT CASE WHEN ((length(CAST(NEW.content AS BLOB))) > (length(CAST(OLD.content AS BLOB)))) AND EXISTS (SELECT 1 FROM extension_resource_usage + WHERE scope='account' AND subject=NEW.submitted_by AND (bytes>26214400 OR extensions>100 OR revisions>1000)) + THEN RAISE(ABORT,'extension_resource_quota') END; +INSERT INTO extension_resource_usage(scope,subject,bytes,extensions,revisions) VALUES ('developer',NEW.developer_id,(length(CAST(NEW.content AS BLOB))),0,1) + ON CONFLICT(scope,subject) DO UPDATE SET + bytes=bytes+excluded.bytes,extensions=extensions+excluded.extensions,revisions=revisions+excluded.revisions; +SELECT CASE WHEN ((length(CAST(NEW.content AS BLOB))) > (length(CAST(OLD.content AS BLOB)))) AND EXISTS (SELECT 1 FROM extension_resource_usage + WHERE scope='developer' AND subject=NEW.developer_id AND (bytes>26214400 OR extensions>100 OR revisions>1000)) + THEN RAISE(ABORT,'extension_resource_quota') END; +END; +--> statement-breakpoint +CREATE TRIGGER extension_revision_admission BEFORE INSERT ON extension_revisions BEGIN + -- Cleanup scales with accepted writes; idle cleanup still runs hourly. + DELETE FROM extension_write_events WHERE id IN ( + SELECT id FROM extension_write_events WHERE occurred_at <= unixepoch()-86400 + ORDER BY occurred_at LIMIT 50 + ); + SELECT CASE WHEN length(CAST(NEW.content AS BLOB)) > 262144 + + THEN RAISE(ABORT,'extension_content_size') END; + SELECT CASE WHEN (SELECT COUNT(*) FROM extension_write_events WHERE account_id=NEW.submitted_by AND occurred_at>unixepoch()-60)>=5 + OR (SELECT COUNT(*) FROM extension_write_events WHERE developer_id=NEW.developer_id AND occurred_at>unixepoch()-60)>=5 + + THEN RAISE(ABORT,'extension_write_rate_minute') END; + SELECT CASE WHEN (SELECT COUNT(*) FROM extension_write_events WHERE account_id=NEW.submitted_by AND occurred_at>unixepoch()-86400)>=50 + OR (SELECT COUNT(*) FROM extension_write_events WHERE developer_id=NEW.developer_id AND occurred_at>unixepoch()-86400)>=50 + + THEN RAISE(ABORT,'extension_write_rate_day') END; +END; +--> statement-breakpoint +CREATE TRIGGER extension_revision_write_event AFTER INSERT ON extension_revisions BEGIN + INSERT INTO extension_write_events(id,account_id,developer_id,occurred_at) + VALUES (NEW.id,NEW.submitted_by,NEW.developer_id,unixepoch(NEW.created_at)); +END; +--> statement-breakpoint +CREATE TRIGGER extension_revision_content_bound BEFORE UPDATE OF content ON extension_revisions +WHEN length(CAST(NEW.content AS BLOB)) > 262144 AND length(CAST(NEW.content AS BLOB)) > length(CAST(OLD.content AS BLOB)) +BEGIN SELECT RAISE(ABORT,'extension_content_size'); END; +--> statement-breakpoint +CREATE TRIGGER extension_revision_identity BEFORE UPDATE OF id,extension_id,developer_id,submitted_by ON extension_revisions +WHEN NEW.id IS NOT OLD.id OR NEW.extension_id IS NOT OLD.extension_id + OR NEW.developer_id IS NOT OLD.developer_id OR NEW.submitted_by IS NOT OLD.submitted_by +BEGIN SELECT RAISE(ABORT,'extension_resource_identity'); END; +--> statement-breakpoint +CREATE TRIGGER extension_resource_identity BEFORE UPDATE OF created_by,developer_id ON extensions +WHEN NEW.created_by IS NOT OLD.created_by OR NEW.developer_id IS NOT OLD.developer_id +BEGIN SELECT RAISE(ABORT,'extension_resource_identity'); END; +--> statement-breakpoint +CREATE TRIGGER sync_extension_revisions_bytes_insert AFTER INSERT ON extension_revisions +WHEN NEW.content_bytes != (length(CAST(NEW.content AS BLOB))) BEGIN + UPDATE extension_revisions SET content_bytes=(length(CAST(NEW.content AS BLOB))) + WHERE id=NEW.id; +END; +--> statement-breakpoint +CREATE TRIGGER sync_extension_revisions_bytes_update AFTER UPDATE OF content,content_bytes ON extension_revisions +WHEN NEW.content_bytes != (length(CAST(NEW.content AS BLOB))) BEGIN + UPDATE extension_revisions SET content_bytes=(length(CAST(NEW.content AS BLOB))) + WHERE id=NEW.id; +END; +--> statement-breakpoint +CREATE TRIGGER sync_revision_summary_insert AFTER INSERT ON extension_revisions +WHEN NEW.compacted_at IS NULL BEGIN + UPDATE extension_revisions SET content_readable = CASE WHEN length(CAST(NEW.content AS BLOB)) <= 262144 AND json_valid(NEW.content) THEN + CASE WHEN json_type(NEW.content) != 'object' THEN 0 + WHEN json_type(NEW.content, '$.releases') IS NULL OR json_type(NEW.content, '$.releases') = 'null' THEN 1 + WHEN json_type(NEW.content, '$.releases') != 'array' THEN 0 + WHEN json_array_length(NEW.content, '$.releases') > 100 THEN 0 + ELSE NOT EXISTS (SELECT 1 FROM json_each(NEW.content, '$.releases') r + WHERE CASE WHEN r.type = 'object' THEN + json_type(r.value, '$.tag') IS NOT 'text' OR length(json_extract(r.value, '$.tag')) > 100 + ELSE 1 END) + END ELSE 0 END, + summary_name = CASE WHEN length(CAST(NEW.content AS BLOB)) <= 262144 AND json_valid(NEW.content) + THEN CASE WHEN json_type(NEW.content,'$.name')='text' THEN substr(json_extract(NEW.content,'$.name'),1,120) ELSE NULL END + ELSE NULL END, + summary_version = CASE WHEN length(CAST(NEW.content AS BLOB)) <= 262144 AND json_valid(NEW.content) + THEN CASE WHEN json_type(NEW.content,'$.version')='text' THEN substr(json_extract(NEW.content,'$.version'),1,100) ELSE NULL END + ELSE NULL END, + summary_description = CASE WHEN length(CAST(NEW.content AS BLOB)) <= 262144 AND json_valid(NEW.content) + THEN CASE WHEN json_type(NEW.content,'$.description')='text' THEN substr(json_extract(NEW.content,'$.description'),1,4000) ELSE NULL END + ELSE NULL END + WHERE id=NEW.id; +END; +--> statement-breakpoint +CREATE TRIGGER sync_revision_summary_update AFTER UPDATE OF content ON extension_revisions +WHEN NEW.compacted_at IS NULL BEGIN + UPDATE extension_revisions SET content_readable = CASE WHEN length(CAST(NEW.content AS BLOB)) <= 262144 AND json_valid(NEW.content) THEN + CASE WHEN json_type(NEW.content) != 'object' THEN 0 + WHEN json_type(NEW.content, '$.releases') IS NULL OR json_type(NEW.content, '$.releases') = 'null' THEN 1 + WHEN json_type(NEW.content, '$.releases') != 'array' THEN 0 + WHEN json_array_length(NEW.content, '$.releases') > 100 THEN 0 + ELSE NOT EXISTS (SELECT 1 FROM json_each(NEW.content, '$.releases') r + WHERE CASE WHEN r.type = 'object' THEN + json_type(r.value, '$.tag') IS NOT 'text' OR length(json_extract(r.value, '$.tag')) > 100 + ELSE 1 END) + END ELSE 0 END, + summary_name = CASE WHEN length(CAST(NEW.content AS BLOB)) <= 262144 AND json_valid(NEW.content) + THEN CASE WHEN json_type(NEW.content,'$.name')='text' THEN substr(json_extract(NEW.content,'$.name'),1,120) ELSE NULL END + ELSE NULL END, + summary_version = CASE WHEN length(CAST(NEW.content AS BLOB)) <= 262144 AND json_valid(NEW.content) + THEN CASE WHEN json_type(NEW.content,'$.version')='text' THEN substr(json_extract(NEW.content,'$.version'),1,100) ELSE NULL END + ELSE NULL END, + summary_description = CASE WHEN length(CAST(NEW.content AS BLOB)) <= 262144 AND json_valid(NEW.content) + THEN CASE WHEN json_type(NEW.content,'$.description')='text' THEN substr(json_extract(NEW.content,'$.description'),1,4000) ELSE NULL END + ELSE NULL END + WHERE id=NEW.id; +END; +--> statement-breakpoint +CREATE TRIGGER developers_generate_profile_instance +AFTER INSERT ON developers +BEGIN + UPDATE developers SET profile_generation = lower(hex(randomblob(16))) + WHERE id = NEW.id; +END; +--> statement-breakpoint +CREATE TRIGGER resource_extensions_insert AFTER INSERT ON extensions BEGIN + INSERT INTO extension_resource_usage(scope,subject,bytes,extensions,revisions) + SELECT scope, subject, COALESCE(length(CAST(NEW.manifest AS BLOB)),0) + + COALESCE(length(CAST(NEW.type AS BLOB)),0) + + COALESCE(length(CAST(NEW.name AS BLOB)),0) + + COALESCE(length(CAST(NEW.description AS BLOB)),0) + + COALESCE(length(CAST(NEW.releases AS BLOB)),0) + + COALESCE(length(CAST(NEW.website AS BLOB)),0) + + COALESCE(length(CAST(NEW.license AS BLOB)),0) + + COALESCE(length(CAST(NEW.icon_url AS BLOB)),0) + + COALESCE(length(CAST(NEW.readme AS BLOB)),0) + + COALESCE(length(CAST(NEW.source AS BLOB)),0) + + COALESCE(length(CAST(NEW.version AS BLOB)),0) + + COALESCE(length(CAST(NEW.download_url AS BLOB)),0), 1, 0 + FROM ( + SELECT 'global' AS scope, 'all' AS subject + UNION ALL SELECT 'account', COALESCE(NEW.created_by,'legacy') + UNION ALL SELECT 'developer', NEW.developer_id + ) WHERE true + ON CONFLICT(scope,subject) DO UPDATE SET + bytes=bytes+excluded.bytes, + extensions=extensions+excluded.extensions, + revisions=revisions+excluded.revisions; + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM extension_resource_usage + WHERE ((scope='account' AND subject=COALESCE(NEW.created_by,'legacy')) + OR (scope='developer' AND subject=NEW.developer_id)) + AND (bytes>26214400 OR extensions>100 OR revisions>1000) + ) THEN RAISE(ABORT,'extension_resource_quota') END; +END; +--> statement-breakpoint +CREATE TRIGGER sync_extensions_bytes_insert AFTER INSERT ON extensions +WHEN NEW.published_bytes != (COALESCE(length(CAST(NEW.manifest AS BLOB)),0) + + COALESCE(length(CAST(NEW.type AS BLOB)),0) + + COALESCE(length(CAST(NEW.name AS BLOB)),0) + + COALESCE(length(CAST(NEW.description AS BLOB)),0) + + COALESCE(length(CAST(NEW.releases AS BLOB)),0) + + COALESCE(length(CAST(NEW.website AS BLOB)),0) + + COALESCE(length(CAST(NEW.license AS BLOB)),0) + + COALESCE(length(CAST(NEW.icon_url AS BLOB)),0) + + COALESCE(length(CAST(NEW.readme AS BLOB)),0) + + COALESCE(length(CAST(NEW.source AS BLOB)),0) + + COALESCE(length(CAST(NEW.version AS BLOB)),0) + + COALESCE(length(CAST(NEW.download_url AS BLOB)),0)) BEGIN + UPDATE extensions SET published_bytes=(COALESCE(length(CAST(NEW.manifest AS BLOB)),0) + + COALESCE(length(CAST(NEW.type AS BLOB)),0) + + COALESCE(length(CAST(NEW.name AS BLOB)),0) + + COALESCE(length(CAST(NEW.description AS BLOB)),0) + + COALESCE(length(CAST(NEW.releases AS BLOB)),0) + + COALESCE(length(CAST(NEW.website AS BLOB)),0) + + COALESCE(length(CAST(NEW.license AS BLOB)),0) + + COALESCE(length(CAST(NEW.icon_url AS BLOB)),0) + + COALESCE(length(CAST(NEW.readme AS BLOB)),0) + + COALESCE(length(CAST(NEW.source AS BLOB)),0) + + COALESCE(length(CAST(NEW.version AS BLOB)),0) + + COALESCE(length(CAST(NEW.download_url AS BLOB)),0)) + WHERE id=NEW.id; +END; +--> statement-breakpoint +CREATE TRIGGER sync_extensions_bytes_update AFTER UPDATE OF type,name,description,releases,website,license,icon_url,readme,source,version,download_url,manifest,published_bytes ON extensions +WHEN NEW.published_bytes != (COALESCE(length(CAST(NEW.manifest AS BLOB)),0) + + COALESCE(length(CAST(NEW.type AS BLOB)),0) + + COALESCE(length(CAST(NEW.name AS BLOB)),0) + + COALESCE(length(CAST(NEW.description AS BLOB)),0) + + COALESCE(length(CAST(NEW.releases AS BLOB)),0) + + COALESCE(length(CAST(NEW.website AS BLOB)),0) + + COALESCE(length(CAST(NEW.license AS BLOB)),0) + + COALESCE(length(CAST(NEW.icon_url AS BLOB)),0) + + COALESCE(length(CAST(NEW.readme AS BLOB)),0) + + COALESCE(length(CAST(NEW.source AS BLOB)),0) + + COALESCE(length(CAST(NEW.version AS BLOB)),0) + + COALESCE(length(CAST(NEW.download_url AS BLOB)),0)) BEGIN + UPDATE extensions SET published_bytes=(COALESCE(length(CAST(NEW.manifest AS BLOB)),0) + + COALESCE(length(CAST(NEW.type AS BLOB)),0) + + COALESCE(length(CAST(NEW.name AS BLOB)),0) + + COALESCE(length(CAST(NEW.description AS BLOB)),0) + + COALESCE(length(CAST(NEW.releases AS BLOB)),0) + + COALESCE(length(CAST(NEW.website AS BLOB)),0) + + COALESCE(length(CAST(NEW.license AS BLOB)),0) + + COALESCE(length(CAST(NEW.icon_url AS BLOB)),0) + + COALESCE(length(CAST(NEW.readme AS BLOB)),0) + + COALESCE(length(CAST(NEW.source AS BLOB)),0) + + COALESCE(length(CAST(NEW.version AS BLOB)),0) + + COALESCE(length(CAST(NEW.download_url AS BLOB)),0)) + WHERE id=NEW.id; +END; +--> statement-breakpoint +CREATE TRIGGER resource_extensions_delete AFTER DELETE ON extensions BEGIN + UPDATE extension_resource_usage + SET bytes=bytes-(COALESCE(length(CAST(OLD.manifest AS BLOB)),0) + COALESCE(length(CAST(OLD.type AS BLOB)),0) + + COALESCE(length(CAST(OLD.name AS BLOB)),0) + + COALESCE(length(CAST(OLD.description AS BLOB)),0) + + COALESCE(length(CAST(OLD.releases AS BLOB)),0) + + COALESCE(length(CAST(OLD.website AS BLOB)),0) + + COALESCE(length(CAST(OLD.license AS BLOB)),0) + + COALESCE(length(CAST(OLD.icon_url AS BLOB)),0) + + COALESCE(length(CAST(OLD.readme AS BLOB)),0) + + COALESCE(length(CAST(OLD.source AS BLOB)),0) + + COALESCE(length(CAST(OLD.version AS BLOB)),0) + + COALESCE(length(CAST(OLD.download_url AS BLOB)),0)), extensions=extensions-1 + WHERE (scope='global' AND subject='all') + OR (scope='account' AND subject=COALESCE(OLD.created_by,'legacy')) + OR (scope='developer' AND subject=OLD.developer_id); +END; +--> statement-breakpoint +CREATE TRIGGER resource_extensions_update AFTER UPDATE OF type,name,description,releases,website,license,icon_url,readme,source,version,download_url,manifest ON extensions BEGIN + UPDATE extension_resource_usage + SET bytes = bytes + (COALESCE(length(CAST(NEW.manifest AS BLOB)),0) + + COALESCE(length(CAST(NEW.type AS BLOB)),0) + + COALESCE(length(CAST(NEW.name AS BLOB)),0) + + COALESCE(length(CAST(NEW.description AS BLOB)),0) + + COALESCE(length(CAST(NEW.releases AS BLOB)),0) + + COALESCE(length(CAST(NEW.website AS BLOB)),0) + + COALESCE(length(CAST(NEW.license AS BLOB)),0) + + COALESCE(length(CAST(NEW.icon_url AS BLOB)),0) + + COALESCE(length(CAST(NEW.readme AS BLOB)),0) + + COALESCE(length(CAST(NEW.source AS BLOB)),0) + + COALESCE(length(CAST(NEW.version AS BLOB)),0) + + COALESCE(length(CAST(NEW.download_url AS BLOB)),0)) - (COALESCE(length(CAST(OLD.manifest AS BLOB)),0) + + COALESCE(length(CAST(OLD.type AS BLOB)),0) + + COALESCE(length(CAST(OLD.name AS BLOB)),0) + + COALESCE(length(CAST(OLD.description AS BLOB)),0) + + COALESCE(length(CAST(OLD.releases AS BLOB)),0) + + COALESCE(length(CAST(OLD.website AS BLOB)),0) + + COALESCE(length(CAST(OLD.license AS BLOB)),0) + + COALESCE(length(CAST(OLD.icon_url AS BLOB)),0) + + COALESCE(length(CAST(OLD.readme AS BLOB)),0) + + COALESCE(length(CAST(OLD.source AS BLOB)),0) + + COALESCE(length(CAST(OLD.version AS BLOB)),0) + + COALESCE(length(CAST(OLD.download_url AS BLOB)),0)) + WHERE (scope='global' AND subject='all') + OR (scope='account' AND subject=COALESCE(NEW.created_by,'legacy')) + OR (scope='developer' AND subject=NEW.developer_id); + SELECT CASE WHEN (COALESCE(length(CAST(NEW.manifest AS BLOB)),0) + + COALESCE(length(CAST(NEW.type AS BLOB)),0) + + COALESCE(length(CAST(NEW.name AS BLOB)),0) + + COALESCE(length(CAST(NEW.description AS BLOB)),0) + + COALESCE(length(CAST(NEW.releases AS BLOB)),0) + + COALESCE(length(CAST(NEW.website AS BLOB)),0) + + COALESCE(length(CAST(NEW.license AS BLOB)),0) + + COALESCE(length(CAST(NEW.icon_url AS BLOB)),0) + + COALESCE(length(CAST(NEW.readme AS BLOB)),0) + + COALESCE(length(CAST(NEW.source AS BLOB)),0) + + COALESCE(length(CAST(NEW.version AS BLOB)),0) + + COALESCE(length(CAST(NEW.download_url AS BLOB)),0)) > (COALESCE(length(CAST(OLD.manifest AS BLOB)),0) + + COALESCE(length(CAST(OLD.type AS BLOB)),0) + + COALESCE(length(CAST(OLD.name AS BLOB)),0) + + COALESCE(length(CAST(OLD.description AS BLOB)),0) + + COALESCE(length(CAST(OLD.releases AS BLOB)),0) + + COALESCE(length(CAST(OLD.website AS BLOB)),0) + + COALESCE(length(CAST(OLD.license AS BLOB)),0) + + COALESCE(length(CAST(OLD.icon_url AS BLOB)),0) + + COALESCE(length(CAST(OLD.readme AS BLOB)),0) + + COALESCE(length(CAST(OLD.source AS BLOB)),0) + + COALESCE(length(CAST(OLD.version AS BLOB)),0) + + COALESCE(length(CAST(OLD.download_url AS BLOB)),0)) AND EXISTS ( + SELECT 1 FROM extension_resource_usage + WHERE ((scope='account' AND subject=COALESCE(NEW.created_by,'legacy')) + OR (scope='developer' AND subject=NEW.developer_id)) + AND (bytes>26214400 OR extensions>100 OR revisions>1000) + ) + THEN RAISE(ABORT,'extension_resource_quota') END; +END; +--> statement-breakpoint +CREATE TRIGGER extensions_published_manifest_insert BEFORE INSERT ON extensions +WHEN NEW.published_at IS NOT NULL AND NEW.manifest IS NULL +BEGIN SELECT RAISE(ABORT,'extensions_published_manifest_required'); END; +--> statement-breakpoint +CREATE TRIGGER extensions_published_manifest_update BEFORE UPDATE OF published_at,manifest ON extensions +WHEN NEW.published_at IS NOT NULL AND NEW.manifest IS NULL +BEGIN SELECT RAISE(ABORT,'extensions_published_manifest_required'); END; +--> statement-breakpoint +UPDATE extension_resource_usage SET subject=(SELECT id FROM _developers_ids WHERE identifier=subject) WHERE scope='developer' AND subject IN (SELECT identifier FROM _developers_ids); +--> statement-breakpoint +DROP TABLE _developers_backup; +--> statement-breakpoint +DROP TABLE _extensions_backup; +--> statement-breakpoint +DROP TABLE _developer_transfers_backup; +--> statement-breakpoint +DROP TABLE _developer_claims_backup; +--> statement-breakpoint +DROP TABLE _developer_history_backup; +--> statement-breakpoint +DROP TABLE _extension_write_events_backup; +--> statement-breakpoint +DROP TABLE _m2m_publisher_clients_backup; +--> statement-breakpoint +DROP TABLE _extension_revisions_backup; +--> statement-breakpoint +DROP TABLE _developers_ids; +--> statement-breakpoint +DROP TABLE _extensions_ids; diff --git a/src/services/extensions/v2/db/migrations/meta/0030_snapshot.json b/src/services/extensions/v2/db/migrations/meta/0030_snapshot.json new file mode 100644 index 00000000..d5b156e0 --- /dev/null +++ b/src/services/extensions/v2/db/migrations/meta/0030_snapshot.json @@ -0,0 +1,1395 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "93fb9a02-8924-42e3-a428-55fd316dbfc9", + "prevId": "522dcadb-834f-4249-9e4e-611e233a50d2", + "tables": { + "claim_verification_budgets": { + "name": "claim_verification_budgets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_claim_verification_budgets_expiry": { + "name": "idx_claim_verification_budgets_expiry", + "columns": ["expires_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "developer_claims": { + "name": "developer_claims", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimant_id": { + "name": "claimant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_note": { + "name": "review_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewer_id": { + "name": "reviewer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_org_verified": { + "name": "github_org_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_verification_note": { + "name": "github_verification_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_developer_claims_developer": { + "name": "idx_developer_claims_developer", + "columns": ["developer_id"], + "isUnique": false + }, + "idx_developer_claims_claimant": { + "name": "idx_developer_claims_claimant", + "columns": ["claimant_id"], + "isUnique": false + }, + "idx_developer_claims_pending_unique": { + "name": "idx_developer_claims_pending_unique", + "columns": ["developer_id", "claimant_id"], + "isUnique": true, + "where": "\"developer_claims\".\"status\" = 'pending'" + }, + "idx_developer_claims_pending_queue": { + "name": "idx_developer_claims_pending_queue", + "columns": ["created_at"], + "isUnique": false, + "where": "\"developer_claims\".\"status\" = 'pending'" + } + }, + "foreignKeys": { + "developer_claims_developer_id_developers_id_fk": { + "name": "developer_claims_developer_id_developers_id_fk", + "tableFrom": "developer_claims", + "tableTo": "developers", + "columnsFrom": ["developer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "developer_claims_claimant_id_users_id_fk": { + "name": "developer_claims_claimant_id_users_id_fk", + "tableFrom": "developer_claims", + "tableTo": "users", + "columnsFrom": ["claimant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "developer_claims_reviewer_id_users_id_fk": { + "name": "developer_claims_reviewer_id_users_id_fk", + "tableFrom": "developer_claims", + "tableTo": "users", + "columnsFrom": ["reviewer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "developer_claims_status_check": { + "name": "developer_claims_status_check", + "value": "\"developer_claims\".\"status\" IN ('pending', 'approved', 'rejected')" + }, + "developer_claims_github_org_verified_check": { + "name": "developer_claims_github_org_verified_check", + "value": "\"developer_claims\".\"github_org_verified\" IN (0, 1)" + } + } + }, + "developer_history": { + "name": "developer_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_developer_history_developer_changed_at": { + "name": "idx_developer_history_developer_changed_at", + "columns": ["developer_id", "changed_at"], + "isUnique": false + }, + "idx_developer_history_account_changed_at": { + "name": "idx_developer_history_account_changed_at", + "columns": ["changed_by", "changed_at"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_history_changed_by_users_id_fk": { + "name": "developer_history_changed_by_users_id_fk", + "tableFrom": "developer_history", + "tableTo": "users", + "columnsFrom": ["changed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "developer_transfers": { + "name": "developer_transfers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_developer_transfers_token": { + "name": "idx_developer_transfers_token", + "columns": ["token_hash"], + "isUnique": true + }, + "idx_developer_transfers_pending": { + "name": "idx_developer_transfers_pending", + "columns": ["developer_id"], + "isUnique": true, + "where": "\"developer_transfers\".\"accepted_at\" IS NULL AND \"developer_transfers\".\"revoked_at\" IS NULL" + } + }, + "foreignKeys": { + "developer_transfers_developer_id_developers_id_fk": { + "name": "developer_transfers_developer_id_developers_id_fk", + "tableFrom": "developer_transfers", + "tableTo": "developers", + "columnsFrom": ["developer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "developer_transfers_created_by_users_id_fk": { + "name": "developer_transfers_created_by_users_id_fk", + "tableFrom": "developer_transfers", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "developer_transfers_accepted_by_users_id_fk": { + "name": "developer_transfers_accepted_by_users_id_fk", + "tableFrom": "developer_transfers", + "tableTo": "users", + "columnsFrom": ["accepted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "developers": { + "name": "developers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01-01T00:00:00.000Z'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01-01T00:00:00.000Z'" + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownership_epoch": { + "name": "ownership_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "content_revision": { + "name": "content_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "profile_generation": { + "name": "profile_generation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "approved_revision": { + "name": "approved_revision", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_org_verified": { + "name": "github_org_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_verification_note": { + "name": "github_verification_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_verified_at": { + "name": "github_verified_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_url_verified": { + "name": "github_url_verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url_check_cooldown_until": { + "name": "url_check_cooldown_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_developers_identifier_nocase": { + "name": "idx_developers_identifier_nocase", + "columns": ["lower(\"identifier\")"], + "isUnique": true + }, + "idx_developers_owner_unique": { + "name": "idx_developers_owner_unique", + "columns": ["owner_user_id"], + "isUnique": true + }, + "idx_developers_approved": { + "name": "idx_developers_approved", + "columns": ["approved_at"], + "isUnique": false + } + }, + "foreignKeys": { + "developers_owner_user_id_users_id_fk": { + "name": "developers_owner_user_id_users_id_fk", + "tableFrom": "developers", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "developers_ownership_epoch_check": { + "name": "developers_ownership_epoch_check", + "value": "\"developers\".\"ownership_epoch\" >= 1" + }, + "developers_content_revision_check": { + "name": "developers_content_revision_check", + "value": "\"developers\".\"content_revision\" >= 1" + }, + "developers_github_org_verified_check": { + "name": "developers_github_org_verified_check", + "value": "\"developers\".\"github_org_verified\" IN (0, 1)" + }, + "developers_github_url_verified_check": { + "name": "developers_github_url_verified_check", + "value": "\"developers\".\"github_url_verified\" = 1" + } + } + }, + "extension_resource_usage": { + "name": "extension_resource_usage", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "extensions": { + "name": "extensions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revisions": { + "name": "revisions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_extension_resource_usage_subject": { + "name": "idx_extension_resource_usage_subject", + "columns": ["scope", "subject"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "extension_revisions": { + "name": "extension_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submitted_by": { + "name": "submitted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_bytes": { + "name": "content_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "summary_name": { + "name": "summary_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary_version": { + "name": "summary_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary_description": { + "name": "summary_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_readable": { + "name": "content_readable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compacted_at": { + "name": "compacted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewer_id": { + "name": "reviewer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_note": { + "name": "review_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownership_epoch": { + "name": "ownership_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "idx_extension_revisions_retention": { + "name": "idx_extension_revisions_retention", + "columns": ["compacted_at", "reviewed_at", "id", "status"], + "isUnique": false + }, + "idx_extension_revisions_submitted_by": { + "name": "idx_extension_revisions_submitted_by", + "columns": ["submitted_by"], + "isUnique": false + }, + "idx_extension_revisions_developer": { + "name": "idx_extension_revisions_developer", + "columns": ["developer_id"], + "isUnique": false + }, + "idx_extension_revisions_pending": { + "name": "idx_extension_revisions_pending", + "columns": ["extension_id"], + "isUnique": true, + "where": "\"extension_revisions\".\"status\" = 'pending'" + }, + "idx_extension_revisions_extension_page": { + "name": "idx_extension_revisions_extension_page", + "columns": ["extension_id", "\"created_at\" desc", "\"id\" desc"], + "isUnique": false + }, + "idx_extension_revisions_submitter_page": { + "name": "idx_extension_revisions_submitter_page", + "columns": ["submitted_by", "\"created_at\" desc", "\"id\" desc"], + "isUnique": false + }, + "idx_extension_revisions_queue_page": { + "name": "idx_extension_revisions_queue_page", + "columns": ["status", "created_at", "id"], + "isUnique": false + }, + "idx_extension_revisions_reviewed": { + "name": "idx_extension_revisions_reviewed", + "columns": ["extension_id", "reviewed_at"], + "isUnique": false, + "where": "\"extension_revisions\".\"status\" IN ('approved', 'rejected')" + }, + "idx_extension_revisions_submitter_pending": { + "name": "idx_extension_revisions_submitter_pending", + "columns": ["submitted_by"], + "isUnique": false, + "where": "\"extension_revisions\".\"status\" = 'pending'" + } + }, + "foreignKeys": { + "extension_revisions_extension_id_extensions_id_fk": { + "name": "extension_revisions_extension_id_extensions_id_fk", + "tableFrom": "extension_revisions", + "tableTo": "extensions", + "columnsFrom": ["extension_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "extension_revisions_submitted_by_users_id_fk": { + "name": "extension_revisions_submitted_by_users_id_fk", + "tableFrom": "extension_revisions", + "tableTo": "users", + "columnsFrom": ["submitted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "extension_revisions_reviewer_id_users_id_fk": { + "name": "extension_revisions_reviewer_id_users_id_fk", + "tableFrom": "extension_revisions", + "tableTo": "users", + "columnsFrom": ["reviewer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "extension_revisions_status_check": { + "name": "extension_revisions_status_check", + "value": "\"extension_revisions\".\"status\" IN ('pending', 'approved', 'rejected')" + }, + "extension_revisions_ownership_epoch_check": { + "name": "extension_revisions_ownership_epoch_check", + "value": "\"extension_revisions\".\"ownership_epoch\" >= 1" + } + } + }, + "extension_write_events": { + "name": "extension_write_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_extension_write_events_account": { + "name": "idx_extension_write_events_account", + "columns": ["account_id", "occurred_at"], + "isUnique": false + }, + "idx_extension_write_events_developer": { + "name": "idx_extension_write_events_developer", + "columns": ["developer_id", "occurred_at"], + "isUnique": false + }, + "idx_extension_write_events_time": { + "name": "idx_extension_write_events_time", + "columns": ["occurred_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "extensions": { + "name": "extensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_bytes": { + "name": "published_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "published_at": { + "name": "published_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_revision_id": { + "name": "published_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "releases": { + "name": "releases", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_url": { + "name": "download_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "delisted_at": { + "name": "delisted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delist_reason": { + "name": "delist_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_extensions_published_revision": { + "name": "idx_extensions_published_revision", + "columns": ["published_revision_id"], + "isUnique": false + }, + "idx_extensions_identifier_nocase": { + "name": "idx_extensions_identifier_nocase", + "columns": ["lower(\"identifier\")"], + "isUnique": true + }, + "idx_extensions_developer_order": { + "name": "idx_extensions_developer_order", + "columns": ["developer_id", "lower(\"identifier\")", "id"], + "isUnique": false + }, + "idx_extensions_public_order": { + "name": "idx_extensions_public_order", + "columns": ["lower(\"identifier\")", "id"], + "isUnique": false, + "where": "\"extensions\".\"published_at\" IS NOT NULL AND \"extensions\".\"delisted_at\" IS NULL" + }, + "idx_extensions_type_public_order": { + "name": "idx_extensions_type_public_order", + "columns": ["type", "lower(\"identifier\")", "id"], + "isUnique": false, + "where": "\"extensions\".\"published_at\" IS NOT NULL AND \"extensions\".\"delisted_at\" IS NULL" + } + }, + "foreignKeys": { + "extensions_developer_id_developers_id_fk": { + "name": "extensions_developer_id_developers_id_fk", + "tableFrom": "extensions", + "tableTo": "developers", + "columnsFrom": ["developer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "extensions_published_content_check": { + "name": "extensions_published_content_check", + "value": "\"extensions\".\"published_at\" IS NULL OR (\n \"extensions\".\"type\" IS NOT NULL AND \"extensions\".\"name\" IS NOT NULL AND\n \"extensions\".\"description\" IS NOT NULL AND \"extensions\".\"releases\" IS NOT NULL AND\n \"extensions\".\"website\" IS NOT NULL AND \"extensions\".\"license\" IS NOT NULL AND\n \"extensions\".\"readme\" IS NOT NULL AND \"extensions\".\"source\" IS NOT NULL AND\n \"extensions\".\"version\" IS NOT NULL AND \"extensions\".\"download_url\" IS NOT NULL\n )" + } + } + }, + "m2m_publisher_clients": { + "name": "m2m_publisher_clients", + "columns": { + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "developer_id": { + "name": "developer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "can_publish": { + "name": "can_publish", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ownership_epoch": { + "name": "ownership_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_m2m_publisher_clients_developer": { + "name": "idx_m2m_publisher_clients_developer", + "columns": ["developer_id"], + "isUnique": false + } + }, + "foreignKeys": { + "m2m_publisher_clients_developer_id_developers_id_fk": { + "name": "m2m_publisher_clients_developer_id_developers_id_fk", + "tableFrom": "m2m_publisher_clients", + "tableTo": "developers", + "columnsFrom": ["developer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "m2m_publisher_clients_can_publish_check": { + "name": "m2m_publisher_clients_can_publish_check", + "value": "\"m2m_publisher_clients\".\"can_publish\" IN (0, 1)" + }, + "m2m_publisher_clients_ownership_epoch_check": { + "name": "m2m_publisher_clients_ownership_epoch_check", + "value": "\"m2m_publisher_clients\".\"ownership_epoch\" >= 1" + } + } + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "picture": { + "name": "picture", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_moderator": { + "name": "is_moderator", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_orgs": { + "name": "github_orgs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_orgs_expires_at": { + "name": "github_orgs_expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "idx_developers_identifier_nocase": { + "columns": { + "lower(\"identifier\")": { + "isExpression": true + } + } + }, + "idx_extension_revisions_extension_page": { + "columns": { + "\"created_at\" desc": { + "isExpression": true + }, + "\"id\" desc": { + "isExpression": true + } + } + }, + "idx_extension_revisions_submitter_page": { + "columns": { + "\"created_at\" desc": { + "isExpression": true + }, + "\"id\" desc": { + "isExpression": true + } + } + }, + "idx_extensions_identifier_nocase": { + "columns": { + "lower(\"identifier\")": { + "isExpression": true + } + } + }, + "idx_extensions_developer_order": { + "columns": { + "lower(\"identifier\")": { + "isExpression": true + } + } + }, + "idx_extensions_public_order": { + "columns": { + "lower(\"identifier\")": { + "isExpression": true + } + } + }, + "idx_extensions_type_public_order": { + "columns": { + "lower(\"identifier\")": { + "isExpression": true + } + } + } + } + } +} diff --git a/src/services/extensions/v2/db/migrations/meta/_journal.json b/src/services/extensions/v2/db/migrations/meta/_journal.json index 456cece5..651ad1a8 100644 --- a/src/services/extensions/v2/db/migrations/meta/_journal.json +++ b/src/services/extensions/v2/db/migrations/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1793793600000, "tag": "0029_align_manifest", "breakpoints": true + }, + { + "idx": 30, + "version": "6", + "when": 1794071864002, + "tag": "0030_directory_identity", + "breakpoints": true } ] } diff --git a/src/services/extensions/v2/db/revisions.ts b/src/services/extensions/v2/db/revisions.ts index bf9af00c..01aff795 100644 --- a/src/services/extensions/v2/db/revisions.ts +++ b/src/services/extensions/v2/db/revisions.ts @@ -51,6 +51,7 @@ const revisionCursor = createCursorCodec( interface RevisionRow { id: string; extensionId: string; + extensionIdentifier: string; developerId: string; submittedBy: string; status: string; @@ -84,6 +85,7 @@ function parseRevisionRow(row: RevisionRow): StoredRevision { const REVISION_COLUMNS = { id: extensionRevisions.id, extensionId: extensionRevisions.extensionId, + extensionIdentifier: sql`(SELECT identifier FROM extensions WHERE id=${extensionRevisions.extensionId})`, developerId: extensionRevisions.developerId, submittedBy: extensionRevisions.submittedBy, status: extensionRevisions.status, @@ -112,6 +114,7 @@ function parseSummaryRow(row: SummaryRow): ExtensionRevisionSummary { return { id: row.id, extension_id: row.extensionId, + extension_identifier: row.extensionIdentifier, developer_id: row.developerId, submitted_by: row.submittedBy, status: row.status as RevisionStatus, @@ -154,7 +157,7 @@ export class ExtensionRevisionsDatabase { SELECT ${id}, e.id, d.id, ${input.callerId}, 'pending', ${JSON.stringify(input.content)}, d.ownership_epoch FROM ${extensions} e JOIN ${developers} d ON d.id = e.developer_id - WHERE LOWER(e.id) = LOWER(${input.extensionId}) + WHERE LOWER(e.identifier) = LOWER(${input.extensionId}) AND d.owner_user_id = ${input.callerId} AND EXISTS ( SELECT 1 FROM ${users} u @@ -189,10 +192,10 @@ export class ExtensionRevisionsDatabase { revisionId: string; callerId: string; content: ExtensionContent; - }): Promise> { + }): Promise> { const id = crypto.randomUUID(); try { - const results = await this.db.$client.batch<{ extensionId: string }>([ + const results = await this.db.$client.batch<{ identifier: string }>([ this.db.$client .prepare( ` @@ -201,7 +204,7 @@ export class ExtensionRevisionsDatabase { AND EXISTS ( SELECT 1 FROM extensions e JOIN developers d ON d.id = e.developer_id JOIN users u ON u.id = d.owner_user_id - WHERE LOWER(e.id) = LOWER(?) AND e.id = extension_revisions.extension_id + WHERE LOWER(e.identifier) = LOWER(?) AND e.id = extension_revisions.extension_id AND d.id = extension_revisions.developer_id AND d.ownership_epoch = extension_revisions.ownership_epoch AND d.owner_user_id = ? AND u.deleted_at IS NULL @@ -226,8 +229,8 @@ export class ExtensionRevisionsDatabase { (id, extension_id, developer_id, submitted_by, status, content, ownership_epoch) SELECT ?, e.id, d.id, ?, 'pending', ?, d.ownership_epoch FROM extensions e JOIN developers d ON d.id = e.developer_id - WHERE LOWER(e.id) = LOWER(?) AND changes() = 1 - RETURNING extension_id AS extensionId + WHERE LOWER(e.identifier) = LOWER(?) AND changes() = 1 + RETURNING (SELECT identifier FROM extensions WHERE id=extension_id) AS identifier ` ) .bind( @@ -240,7 +243,10 @@ export class ExtensionRevisionsDatabase { const [replacement] = results[1].results; if (replacement) return { - data: { id, extensionId: replacement.extensionId }, + data: { + id, + identifier: replacement.identifier + }, error: null }; const error = await this.proposeBlockedError(input); @@ -273,7 +279,9 @@ export class ExtensionRevisionsDatabase { .select({ ownerUserId: developers.ownerUserId }) .from(extensions) .innerJoin(developers, eq(extensions.developerId, developers.id)) - .where(sql`LOWER(${extensions.id}) = LOWER(${input.extensionId})`); + .where( + sql`LOWER(${extensions.identifier}) = LOWER(${input.extensionId})` + ); if (!existing) { return { @@ -293,7 +301,7 @@ export class ExtensionRevisionsDatabase { .from(extensionRevisions) .where( and( - sql`LOWER(${extensionRevisions.extensionId}) = LOWER(${input.extensionId})`, + sql`${extensionRevisions.extensionId} = (SELECT id FROM extensions WHERE lower(identifier)=lower(${input.extensionId}))`, eq(extensionRevisions.status, "pending") ) ); @@ -332,7 +340,7 @@ export class ExtensionRevisionsDatabase { SELECT ${id}, e.id, d.id, ${input.submittedBy}, 'pending', ${JSON.stringify(input.content)}, d.ownership_epoch FROM ${extensions} e JOIN ${developers} d ON d.id = e.developer_id - WHERE LOWER(e.id) = LOWER(${input.extensionId}) + WHERE LOWER(e.identifier) = LOWER(${input.extensionId}) AND d.id = ${input.developerId} AND EXISTS ( SELECT 1 FROM ${users} u @@ -427,7 +435,9 @@ export class ExtensionRevisionsDatabase { const [existing] = await this.db .select({ one: sql`1` }) .from(extensions) - .where(sql`LOWER(${extensions.id}) = LOWER(${input.extensionId})`); + .where( + sql`LOWER(${extensions.identifier}) = LOWER(${input.extensionId})` + ); if (!existing) { return { @@ -441,7 +451,7 @@ export class ExtensionRevisionsDatabase { .from(extensionRevisions) .where( and( - sql`LOWER(${extensionRevisions.extensionId}) = LOWER(${input.extensionId})`, + sql`${extensionRevisions.extensionId} = (SELECT id FROM extensions WHERE lower(identifier)=lower(${input.extensionId}))`, eq(extensionRevisions.status, "pending") ) ); @@ -491,7 +501,7 @@ export class ExtensionRevisionsDatabase { SELECT 1 FROM users u WHERE u.id=${readerId} AND u.deleted_at IS NULL AND (u.is_moderator=1 OR (${extensionId ?? null} IS NOT NULL AND EXISTS ( SELECT 1 FROM extensions e JOIN developers d ON d.id=e.developer_id - WHERE e.id=${extensionId ?? null} AND d.owner_user_id=${readerId} + WHERE lower(e.identifier)=lower(${extensionId ?? null}) AND d.owner_user_id=${readerId} ))) )`); if (decoded) { @@ -559,7 +569,9 @@ export class ExtensionRevisionsDatabase { : "asc"; const conditions: SQL[] = []; if (filters.extensionId) { - conditions.push(eq(extensionRevisions.extensionId, filters.extensionId)); + conditions.push( + sql`${extensionRevisions.extensionId} = (SELECT id FROM extensions WHERE lower(identifier)=lower(${filters.extensionId}))` + ); } if (filters.status) { conditions.push(eq(extensionRevisions.status, filters.status)); @@ -625,7 +637,7 @@ export class ExtensionRevisionsDatabase { .where( and( eq(extensionRevisions.id, id), - sql`LOWER(${extensionRevisions.extensionId}) = LOWER(${extensionId})`, + sql`${extensionRevisions.extensionId} = (SELECT id FROM extensions WHERE lower(identifier)=lower(${extensionId}))`, readerId ? sql`EXISTS ( SELECT 1 FROM users u WHERE u.id=${readerId} AND u.deleted_at IS NULL @@ -704,7 +716,7 @@ export class ExtensionRevisionsDatabase { .where( and( eq(extensionRevisions.id, id), - sql`LOWER(${extensionRevisions.extensionId}) = LOWER(${extensionId})`, + sql`${extensionRevisions.extensionId} = (SELECT id FROM extensions WHERE lower(identifier)=lower(${extensionId}))`, eq(extensionRevisions.status, "pending"), sql`EXISTS ( SELECT 1 FROM ${users} diff --git a/src/services/extensions/v2/db/schema.ts b/src/services/extensions/v2/db/schema.ts index 8a1464e9..ea15aaa2 100644 --- a/src/services/extensions/v2/db/schema.ts +++ b/src/services/extensions/v2/db/schema.ts @@ -42,6 +42,7 @@ export const extensions = sqliteTable( "extensions", { id: text("id").primaryKey(), + identifier: text("identifier").notNull(), developerId: text("developer_id") .notNull() .references(() => developers.id), @@ -86,11 +87,10 @@ export const extensions = sqliteTable( }, (table) => [ index("idx_extensions_published_revision").on(table.publishedRevisionId), - // Case-insensitive id uniqueness. The id is a lowercase slug by schema, - // but adopted rows predate that, and this is what stops two developers - // racing for ids that differ only in case — the job migration 0011's - // extension_submissions.target_key index used to do from the other side. - uniqueIndex("idx_extensions_id_nocase").on(sql`lower(${table.id})`), + // Public identifiers are globally unique, independently of UUID identity. + uniqueIndex("idx_extensions_identifier_nocase").on( + sql`lower(${table.identifier})` + ), // Not partial, unlike the two below: this one serves both the public // developer_id filter and GET /extensions/mine, which pages every owned // extension and so cannot filter on published_at. A partial index would @@ -99,19 +99,19 @@ export const extensions = sqliteTable( // same index and checks published_at per row. index("idx_extensions_developer_order").on( table.developerId, - sql`lower(${table.id})`, + sql`lower(${table.identifier})`, table.id ), // These two are partial: every read that uses them filters on // published_at IS NOT NULL AND delisted_at IS NULL, so an unpublished or // delisted row would only bloat the index the extension directory scans. index("idx_extensions_public_order") - .on(sql`lower(${table.id})`, table.id) + .on(sql`lower(${table.identifier})`, table.id) .where( sql`${table.publishedAt} IS NOT NULL AND ${table.delistedAt} IS NULL` ), index("idx_extensions_type_public_order") - .on(table.type, sql`lower(${table.id})`, table.id) + .on(table.type, sql`lower(${table.identifier})`, table.id) .where( sql`${table.publishedAt} IS NOT NULL AND ${table.delistedAt} IS NULL` ), @@ -144,6 +144,7 @@ export const developers = sqliteTable( "developers", { id: text("id").primaryKey(), + identifier: text("identifier").notNull(), type: text("type").notNull(), name: text("name").notNull(), url: text("url"), @@ -189,6 +190,9 @@ export const developers = sqliteTable( urlCheckCooldownUntil: text("url_check_cooldown_until") }, (table) => [ + uniqueIndex("idx_developers_identifier_nocase").on( + sql`lower(${table.identifier})` + ), uniqueIndex("idx_developers_owner_unique").on(table.ownerUserId), index("idx_developers_approved").on(table.approvedAt), check( diff --git a/src/services/extensions/v2/email/notify.ts b/src/services/extensions/v2/email/notify.ts index c67cd5e1..94126cac 100644 --- a/src/services/extensions/v2/email/notify.ts +++ b/src/services/extensions/v2/email/notify.ts @@ -62,6 +62,7 @@ export async function sendModerationNotification( return false; } developerName = developerEmail?.developerName; + developerId = developerEmail?.developerIdentifier; } else if (input.extensionId) { const recipient = await resolveExtensionEmail(db, input.extensionId); if (!recipient) { @@ -73,7 +74,7 @@ export async function sendModerationNotification( } to = recipient.to; extensionName = recipient.extensionName; - developerId = recipient.developerId; + developerId = recipient.developerIdentifier; developerName = recipient.developerName; } else if (input.developerId) { const recipient = await resolveDeveloperProfileEmail( @@ -89,6 +90,7 @@ export async function sendModerationNotification( } to = recipient.to; developerName = recipient.developerName; + developerId = recipient.developerIdentifier; } else { logError("email", "Notification missing extension and developer", { kind: input.kind diff --git a/src/services/extensions/v2/email/recipients.ts b/src/services/extensions/v2/email/recipients.ts index c92d3243..d50f9d8e 100644 --- a/src/services/extensions/v2/email/recipients.ts +++ b/src/services/extensions/v2/email/recipients.ts @@ -32,14 +32,14 @@ export async function resolveExtensionEmail( ): Promise<{ to: string; extensionName?: string; - developerId: string; + developerIdentifier: string; developerName?: string; } | null> { try { const [row] = await db .select({ extensionName: extensions.name, - developerId: extensions.developerId, + developerIdentifier: developers.identifier, developerName: developers.name, contactEmail: developers.contactEmail, accountEmail: users.email @@ -50,7 +50,7 @@ export async function resolveExtensionEmail( // Moderation routes pass the raw URL param, and ids are matched // case-insensitively everywhere else (LOWER(id) = LOWER(id)) - mixed // case is a supported input, not a miss. - .where(sql`LOWER(${extensions.id}) = LOWER(${extensionId})`); + .where(sql`LOWER(${extensions.identifier}) = LOWER(${extensionId})`); if (!row) return null; @@ -60,7 +60,7 @@ export async function resolveExtensionEmail( return { to, extensionName: row.extensionName ?? undefined, - developerId: row.developerId, + developerIdentifier: row.developerIdentifier, developerName: row.developerName }; } catch { @@ -71,10 +71,15 @@ export async function resolveExtensionEmail( export async function resolveDeveloperProfileEmail( db: ExtensionsDb, developerId: string -): Promise<{ to: string; developerName?: string } | null> { +): Promise<{ + to: string; + developerIdentifier: string; + developerName?: string; +} | null> { try { const [row] = await db .select({ + developerIdentifier: developers.identifier, developerName: developers.name, contactEmail: developers.contactEmail, accountEmail: users.email @@ -87,7 +92,11 @@ export async function resolveDeveloperProfileEmail( const to = pickEmail(row.contactEmail, row.accountEmail); if (!to) return null; - return { to, developerName: row.developerName }; + return { + to, + developerIdentifier: row.developerIdentifier, + developerName: row.developerName + }; } catch { return null; } diff --git a/src/services/extensions/v2/routes/badges.ts b/src/services/extensions/v2/routes/badges.ts index 773af42d..b6a6b828 100644 --- a/src/services/extensions/v2/routes/badges.ts +++ b/src/services/extensions/v2/routes/badges.ts @@ -34,7 +34,7 @@ export function registerBadgeRoutes(app: ExtensionsV2App): void { const db = new ExtensionBadgesDatabase( getExtensionsDb(c.env.DB_EXTENSIONS) ); - const { data, error } = await db.getById(id); + const { data, error } = await db.getByIdentifier(id); if (error || !data) return c.json( errorBody(error, "Unable to load badge"), diff --git a/src/services/extensions/v2/routes/developer-profiles.ts b/src/services/extensions/v2/routes/developer-profiles.ts index 5c70b874..4a76dbad 100644 --- a/src/services/extensions/v2/routes/developer-profiles.ts +++ b/src/services/extensions/v2/routes/developer-profiles.ts @@ -387,7 +387,7 @@ export function registerDeveloperProfileRoutes(app: ExtensionsV2App): void { if ( !own.error && own.data && - own.data.id.toLowerCase() === id.toLowerCase() + own.data.identifier.toLowerCase() === id.toLowerCase() ) { // The full owned view carries contact_email, verification signals, // and transfer state, so it stays behind the active-account check @@ -411,7 +411,7 @@ export function registerDeveloperProfileRoutes(app: ExtensionsV2App): void { return c.json(errorBody(access.error, "Unable to check access"), 500); } if (access.data?.moderator) { - const { data, error } = await db.getById(id); + const { data, error } = await db.getByIdentifier(id); if (error || !data) { const status = statusFromErrorCode(error?.code, false); return c.json(errorBody(error, "Developer not found"), status); @@ -421,7 +421,7 @@ export function registerDeveloperProfileRoutes(app: ExtensionsV2App): void { return res; } } - const { data, error } = await db.getById(id); + const { data, error } = await db.getByIdentifier(id); if (error || !data) { const status = statusFromErrorCode(error?.code, false); return c.json(errorBody(error, "Developer not found"), status); diff --git a/src/services/extensions/v2/routes/moderation.ts b/src/services/extensions/v2/routes/moderation.ts index 55f71f2b..a86de375 100644 --- a/src/services/extensions/v2/routes/moderation.ts +++ b/src/services/extensions/v2/routes/moderation.ts @@ -215,7 +215,8 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { "application/json": { schema: z.object({ result: z.object({ - id: z.string(), + id: z.uuidv7(), + identifier: z.string(), status: z.literal("delisted"), notified: NotifiedSchema }) @@ -254,7 +255,14 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { reason }); return c.json( - { result: { id: data.id, status: "delisted" as const, notified } }, + { + result: { + id: data.id, + identifier: data.identifier, + status: "delisted" as const, + notified + } + }, 200 ); }); @@ -279,7 +287,8 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { "application/json": { schema: z.object({ result: z.object({ - id: z.string(), + id: z.uuidv7(), + identifier: z.string(), status: z.literal("relisted"), notified: NotifiedSchema }) @@ -313,11 +322,18 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { } const notified = await notifyAuthor(c, extDb, query, { kind: "extension-relisted", - extensionId: data.id, + extensionId: id, reason: review_note?.trim() || undefined }); return c.json( - { result: { id: data.id, status: "relisted" as const, notified } }, + { + result: { + id: data.id, + identifier: data.identifier, + status: "relisted" as const, + notified + } + }, 200 ); }); @@ -354,7 +370,8 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { "application/json": { schema: z.object({ result: z.object({ - id: z.string(), + id: z.uuidv7(), + identifier: z.string(), revision_id: z.string(), status: z.literal("approved") }) @@ -404,6 +421,7 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { { result: { id: data.id, + identifier: data.identifier, revision_id: data.revisionId, status: "approved" as const } @@ -432,7 +450,8 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { "application/json": { schema: z.object({ result: z.object({ - id: z.string(), + id: z.uuidv7(), + identifier: z.string(), approved: z.literal(true), notified: NotifiedSchema }) @@ -470,7 +489,7 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { } const notified = await notifyAuthor(c, extDb, query, { kind: "developer-approved", - developerId: id + developerId: data.id }); return c.json({ result: { ...data, notified } }, 200); }); diff --git a/src/services/extensions/v2/routes/owner-extensions.ts b/src/services/extensions/v2/routes/owner-extensions.ts index 3421e00a..53768b9b 100644 --- a/src/services/extensions/v2/routes/owner-extensions.ts +++ b/src/services/extensions/v2/routes/owner-extensions.ts @@ -72,12 +72,13 @@ async function revisionReadAccess( return { data: { extensionId: ownership.data.extensionId }, error: null }; } +const AcceptedRevisionResultSchema = z.object({ + identifier: z.string(), + revision_id: z.string(), + status: z.literal("pending") +}); const AcceptedRevisionSchema = z.object({ - result: z.object({ - id: z.string(), - revision_id: z.string(), - status: z.literal("pending") - }) + result: AcceptedRevisionResultSchema }); export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { @@ -95,7 +96,15 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { }, responses: { 201: { - content: { "application/json": { schema: AcceptedRevisionSchema } }, + content: { + "application/json": { + schema: z.object({ + result: AcceptedRevisionResultSchema.extend({ + id: z.uuidv7() + }) + }) + } + }, description: "Extension created. It holds the id immediately but stays out of the public extension listing until a moderator approves the revision." }, @@ -120,14 +129,14 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { app.openapi(createRouteDefinition, async (c) => { const auth = getAuth(c); - const { id, ...content } = c.req.valid("json"); + const { identifier, ...content } = c.req.valid("json"); const publisher = getPublisher(c); // A publisher client writes under its mapped developer; the mapping is // re-verified inside the insert. Humans resolve their own profile. if (publisher) { const input: CreateExtensionAsPublisherInput = { - extensionId: id, + identifier, clientId: publisher.clientId, developerId: publisher.developerId, submittedBy: auth.userId, @@ -146,6 +155,7 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { { result: { id: data.id, + identifier, revision_id: data.revisionId, status: "pending" as const } @@ -175,7 +185,7 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.create({ - extensionId: id, + identifier, developerId: owner.data.id, ownershipEpoch: owner.data.ownershipEpoch, submittedBy: auth.userId, @@ -192,6 +202,7 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { { result: { id: data.id, + identifier, revision_id: data.revisionId, status: "pending" as const } @@ -268,7 +279,13 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { ); } return c.json( - { result: { id, revision_id: data.id, status: "pending" as const } }, + { + result: { + identifier: id, + revision_id: data.id, + status: "pending" as const + } + }, 202 ); }); @@ -337,7 +354,7 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { return c.json( { result: { - id: data.extensionId, + identifier: data.identifier, revision_id: data.id, status: "pending" as const } @@ -359,7 +376,11 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { content: { "application/json": { schema: z.object({ - result: z.object({ id: z.string(), deleted: z.literal(true) }) + result: z.object({ + id: z.uuidv7(), + identifier: z.string(), + deleted: z.literal(true) + }) }) } }, @@ -389,7 +410,16 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { ); } revalidateExtensions(c); - return c.json({ result: { id: data.id, deleted: true as const } }, 200); + return c.json( + { + result: { + id: data.id, + identifier: data.identifier, + deleted: true as const + } + }, + 200 + ); }); const revisionsRoute = createRoute({ @@ -445,7 +475,7 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.listScoped({ - extensionId: access.data.extensionId, + extensionId: id, readerId: auth.userId, sort: "newest", limit, @@ -514,11 +544,7 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { const db = new ExtensionRevisionsDatabase( getExtensionsDb(c.env.DB_EXTENSIONS) ); - const { data, error } = await db.getById( - access.data.extensionId, - revisionId, - auth.userId - ); + const { data, error } = await db.getById(id, revisionId, auth.userId); if (error || !data) { return c.json( errorBody(error, "Unable to load revisions"), diff --git a/src/services/extensions/v2/routes/public-extensions.ts b/src/services/extensions/v2/routes/public-extensions.ts index 39796fdb..ca36e1d4 100644 --- a/src/services/extensions/v2/routes/public-extensions.ts +++ b/src/services/extensions/v2/routes/public-extensions.ts @@ -48,18 +48,18 @@ export function registerPublicExtensionsRoutes(app: ExtensionsV2App): void { }); app.openapi(listExtensionsRoute, async (c) => { - const { scope, type, developer_id, status, q, limit, cursor } = + const { scope, type, developer_identifier, status, q, limit, cursor } = c.req.valid("query"); const auth = getOptionalAuth(c); const extDb = getExtensionsDb(c.env.DB_EXTENSIONS); // Filter/scope combinations are rejected rather than silently ignored so // a caller cannot mistake one projection for another. - if (scope !== "public" && developer_id !== undefined) { + if (scope !== "public" && developer_identifier !== undefined) { return c.json( { error: { - message: "developer_id is only valid with scope=public", + message: "developer_identifier is only valid with scope=public", code: "VALIDATION_ERROR" } }, @@ -82,7 +82,7 @@ export function registerPublicExtensionsRoutes(app: ExtensionsV2App): void { const db = new ExtensionsDatabase(extDb); const { data, error } = await db.list({ type, - developerId: developer_id, + developerIdentifier: developer_identifier, limit, cursor }); @@ -322,7 +322,7 @@ export function registerPublicExtensionsRoutes(app: ExtensionsV2App): void { } } - const { data, error } = await db.getById(id); + const { data, error } = await db.getByIdentifier(id); if (error || !data) { const status = statusFromContentReadError(error?.code); return c.json(errorBody(error, "Extension not found"), status); diff --git a/src/services/extensions/v2/schemas/developers.ts b/src/services/extensions/v2/schemas/developers.ts index 413d9d47..bd4af04a 100644 --- a/src/services/extensions/v2/schemas/developers.ts +++ b/src/services/extensions/v2/schemas/developers.ts @@ -20,7 +20,7 @@ const developerId = () => export const DeveloperSchema = z .object({ - id: developerId(), + identifier: developerId(), type: z.enum(["user", "organization"]), name: z.string().min(1).max(120), URL: httpUrl().optional(), @@ -43,6 +43,7 @@ export const DeveloperInputSchema = DeveloperSchema.strict().openapi("DeveloperInput"); export const DeveloperProfileSchema = DeveloperSchema.extend({ + id: z.uuidv7(), approved: z.boolean(), content_revision: z.int().positive(), profile_generation: ProfileGenerationSchema, @@ -107,6 +108,7 @@ export function toPublicDeveloper( ): PublicDeveloper { return { id: profile.id, + identifier: profile.identifier, type: profile.type, name: profile.name, URL: profile.URL, diff --git a/src/services/extensions/v2/schemas/extensions.ts b/src/services/extensions/v2/schemas/extensions.ts index f40db85b..2da222ff 100644 --- a/src/services/extensions/v2/schemas/extensions.ts +++ b/src/services/extensions/v2/schemas/extensions.ts @@ -122,16 +122,16 @@ function refineContentSize(content: unknown, ctx: z.RefinementCtx): void { } } -// POST /extensions. The id is chosen once here and is immutable afterwards. +// POST /extensions. The public identifier is chosen once; the API generates its UUID. // No developer field: a user owns at most one profile, so the server knows it. export const ExtensionCreateSchema = ExtensionContentSchema.extend({ - id: ManifestSchema.shape.id + identifier: ManifestSchema.shape.id }) .strict() .superRefine(refineContentSize) .openapi("ExtensionCreate"); -// PUT /extensions/{id}. Same content, no id — that comes from the path. +// PUT /extensions/{id}. The public identifier comes from the path. // .strict() belongs here and not on ExtensionContentSchema, which is also a // branch of the responses below; see DeveloperInputSchema for why. export const ExtensionUpdateSchema = ExtensionContentSchema.strict() @@ -152,7 +152,8 @@ const PublishedContentSchema = ExtensionContentSchema.extend({ // The public projection: published package attribution plus its current owner. export const ExtensionSchema = PublishedContentSchema.extend({ - id: z.string(), + id: z.uuidv7(), + identifier: z.string(), developer: PublicDeveloperSchema }).openapi("Extension"); @@ -180,7 +181,8 @@ const ExtensionCardContentSchema = ExtensionContentSchema.omit({ }); export const ExtensionListItemSchema = ExtensionCardContentSchema.extend({ - id: z.string(), + id: z.uuidv7(), + identifier: z.string(), developer: PublicDeveloperSchema }).openapi("ExtensionListItem"); @@ -233,7 +235,8 @@ export const DelistedInfoSchema = z // no derived `status` field on top; see the README for how they map to a UI. export const OwnedExtensionListItemSchema = z .object({ - id: z.string(), + id: z.uuidv7(), + identifier: z.string(), developer: PublicDeveloperSchema, published: ExtensionCardContentSchema.nullable(), pending_revision: PendingRevisionRefSchema.nullable(), @@ -266,11 +269,11 @@ export const ExtensionListQuerySchema = z.object({ .openapi({ param: { name: "type", in: "query" } }), - developer_id: z + developer_identifier: z .string() .optional() .openapi({ - param: { name: "developer_id", in: "query" } + param: { name: "developer_identifier", in: "query" } }), ...cursorPaginationFields }); @@ -295,11 +298,11 @@ export const UnifiedExtensionListQuerySchema = ExtensionListQuerySchema.extend({ description: "public: published extension directory (anonymous allowed). mine: caller's own extensions. all: every extension (moderator only)." }), - developer_id: z + developer_identifier: z .string() .optional() .openapi({ - param: { name: "developer_id", in: "query" }, + param: { name: "developer_identifier", in: "query" }, description: "Public scope only" }), status: ExtensionStatusSchema.optional().openapi({ diff --git a/src/services/extensions/v2/schemas/m2m.ts b/src/services/extensions/v2/schemas/m2m.ts index ba3266eb..1b9d8325 100644 --- a/src/services/extensions/v2/schemas/m2m.ts +++ b/src/services/extensions/v2/schemas/m2m.ts @@ -19,7 +19,7 @@ export const PublisherClientIdParamSchema = z.object({ // developer's pending revisions); without it the client may only propose. export const PublisherRegisterSchema = z .object({ - developer_id: z.string().min(1).max(200), + developer_id: z.uuidv7(), can_publish: z.boolean().default(false) }) .strict() @@ -28,7 +28,7 @@ export const PublisherRegisterSchema = z export const PublisherClientSchema = z .object({ client_id: z.string(), - developer_id: z.string(), + developer_id: z.uuidv7(), can_publish: z.boolean(), created_at: z.string(), revoked_at: z.string().nullable() diff --git a/src/services/extensions/v2/schemas/ownership.ts b/src/services/extensions/v2/schemas/ownership.ts index 0d49efcb..0722c88b 100644 --- a/src/services/extensions/v2/schemas/ownership.ts +++ b/src/services/extensions/v2/schemas/ownership.ts @@ -18,7 +18,7 @@ export type DeveloperTransfer = z.infer; export const DeveloperClaimSchema = z .object({ id: z.string(), - developer_id: z.string(), + developer_id: z.uuidv7(), claimant_id: z.string(), status: z.enum(["pending", "approved", "rejected"]), note: z.string().optional(), @@ -39,6 +39,7 @@ export const DeveloperClaimSchema = z export type DeveloperClaim = z.infer; export const PendingDeveloperClaimSchema = DeveloperClaimSchema.extend({ + developer_identifier: z.string(), developer_name: z.string(), developer_type: z.enum(["user", "organization"]), // The claimant's own account name/GitHub handle, so the moderator sees diff --git a/src/services/extensions/v2/schemas/revisions.ts b/src/services/extensions/v2/schemas/revisions.ts index 99f03b3d..6db200d5 100644 --- a/src/services/extensions/v2/schemas/revisions.ts +++ b/src/services/extensions/v2/schemas/revisions.ts @@ -11,7 +11,8 @@ export type RevisionStatus = z.infer; export const ExtensionRevisionSchema = z .object({ id: z.string(), - extension_id: z.string(), + extension_id: z.uuidv7(), + extension_identifier: z.string(), developer_id: z.string(), submitted_by: z.string(), status: RevisionStatusSchema, diff --git a/test/services/extensions/v1/index.test.ts b/test/services/extensions/v1/index.test.ts index 94071a4a..ca3834db 100644 --- a/test/services/extensions/v1/index.test.ts +++ b/test/services/extensions/v1/index.test.ts @@ -1,3 +1,4 @@ +import { v7 as uuidv7 } from "uuid"; import { describe, it, expect, beforeAll, beforeEach } from "vitest"; import { eq } from "drizzle-orm"; import { @@ -15,7 +16,8 @@ import { applyTestMigrations } from "../../../utils/apply-migrations"; const testExtensionRows = [ { - id: "Example", + id: uuidv7(), + identifier: "Example", manifest: JSON.stringify({ manifest_version: 1, author: { name: "FOSSBilling" }, @@ -59,7 +61,8 @@ const testExtensionRows = [ "https://github.com/FOSSBilling/example-module/releases/download/0.0.5/Example.zip" }, { - id: "TestTheme", + id: uuidv7(), + identifier: "TestTheme", manifest: JSON.stringify({ manifest_version: 1, author: { name: "FOSSBilling" }, @@ -104,6 +107,7 @@ describe("Extensions API v1", () => { await db.insert(developers).values({ id: "fossbilling", + identifier: "fossbilling", type: "organization", name: "fossbilling", url: "https://fossbilling.org" @@ -199,7 +203,8 @@ describe("Extensions API v1", () => { const db = getExtensionsDb(env.DB_EXTENSIONS); await db.insert(extensions).values({ ...testExtensionRows[0], - id: "alpha-mod", + id: uuidv7(), + identifier: "alpha-mod", name: "Alpha Module", publishedAt: "2026-01-01T00:00:00.000Z" }); @@ -275,7 +280,7 @@ describe("Extensions API v1", () => { await db .update(extensions) .set({ delistedAt: "2026-01-01T00:00:00.000Z" }) - .where(eq(extensions.id, "Example")); + .where(eq(extensions.identifier, "Example")); const ctx = createExecutionContext(); const res = await app.request("/extensions/v1/list", {}, env, ctx); @@ -309,13 +314,15 @@ describe("Extensions API v1", () => { const db = getExtensionsDb(env.DB_EXTENSIONS); await db.insert(developers).values({ id: "nourl", + identifier: "nourl", type: "user", name: "No URL", url: null }); await db.insert(extensions).values({ ...testExtensionRows[1], - id: "NoIcon", + id: uuidv7(), + identifier: "NoIcon", developerId: "nourl", iconUrl: null, publishedAt: "2026-01-01T00:00:00.000Z" @@ -382,7 +389,7 @@ describe("Extensions API v1", () => { await db .update(extensions) .set({ delistedAt: "2026-01-01T00:00:00.000Z" }) - .where(eq(extensions.id, "Example")); + .where(eq(extensions.identifier, "Example")); const ctx = createExecutionContext(); const res = await app.request("/extensions/v1/Example", {}, env, ctx); @@ -458,7 +465,7 @@ describe("Extensions API v1", () => { } ]) }) - .where(eq(extensions.id, "Example")); + .where(eq(extensions.identifier, "Example")); const ctx = createExecutionContext(); const response = await app.request( "/extensions/v1/Example/version", @@ -482,6 +489,7 @@ describe("v1 projection failures", () => { await db.delete(developers); await db.insert(developers).values({ id: "projection-owner", + identifier: "projection-owner", type: "organization", name: "Owner" }); diff --git a/test/services/extensions/v2/account.test.ts b/test/services/extensions/v2/account.test.ts index 8adb66d9..749359c8 100644 --- a/test/services/extensions/v2/account.test.ts +++ b/test/services/extensions/v2/account.test.ts @@ -1,3 +1,4 @@ +import { UUID_V7 } from "./db-fixtures"; import app from "../../../../src/app"; import { env } from "cloudflare:workers"; import { @@ -24,6 +25,7 @@ import { } from "./harness"; import { insertUser, + getDeveloper, insertDeveloper, insertExtension, insertUnpublishedExtension, @@ -241,15 +243,15 @@ describe("Extensions API v2", () => { expect(await developer.json()).toEqual({ result: null }); await insertDeveloper(db, { - id: "account-developer", + identifier: "account-developer", type: "user", name: "Account Developer", owner_user_id: "account-1" }); await insertExtension(db, { - id: "account-extension", + identifier: "account-extension", type: "module", - developer_id: "account-developer", + developer_identifier: "account-developer", name: "Account Extension", description: "description", releases: "[]", @@ -265,12 +267,17 @@ describe("Extensions API v2", () => { const owned = await get("/extensions/v2/extensions?scope=mine", headers); expect(owned.status).toBe(200); expect(await owned.json()).toMatchObject({ - result: [{ id: "account-extension" }], + result: [ + { + id: expect.stringMatching(UUID_V7), + identifier: "account-extension" + } + ], pagination: { has_more: false, next_cursor: null } }); const filtered = await get( - "/extensions/v2/extensions?scope=mine&developer_id=someone-else", + "/extensions/v2/extensions?scope=mine&developer_identifier=someone-else", headers ); expect(filtered.status).toBe(422); @@ -422,7 +429,10 @@ describe("Extensions API v2", () => { .prepare( "INSERT INTO m2m_publisher_clients (client_id, developer_id, can_publish) VALUES (?, ?, 1)" ) - .bind("modules-publisher", "mapped-developer") + .bind( + "modules-publisher", + (await getDeveloper(db, "mapped-developer"))!.id + ) .run(); const before = await db .prepare("SELECT developer_id FROM m2m_publisher_clients") @@ -456,13 +466,13 @@ describe("Extensions API v2", () => { it("blocks deletion while an unpublished extension is still owned", async () => { await seedDeveloper("pending-developer", "pending-owner"); await insertUnpublishedExtension(db, { - id: "pending-ext", - developer_id: "pending-developer" + identifier: "pending-ext", + developer_identifier: "pending-developer" }); await insertRevision(db, { id: "pending-revision", - extension_id: "pending-ext", - developer_id: "pending-developer", + extension_identifier: "pending-ext", + developer_identifier: "pending-developer", submitted_by: "pending-owner", content: JSON.stringify(sampleContent()) }); @@ -487,37 +497,37 @@ describe("Extensions API v2", () => { await seedUnownedDeveloper("claim-target"); await insertDeveloperTransfer(db, { id: "cleanup-transfer", - developer_id: "cleanup-developer", + developer_identifier: "cleanup-developer", token_hash: "cleanup-token-hash", created_by: "cleanup-user", expires_at: "2099-01-01 00:00:00" }); await insertDeveloperClaim(db, { id: "cleanup-owned-claim", - developer_id: "cleanup-developer", + developer_identifier: "cleanup-developer", claimant_id: "cleanup-user" }); await insertDeveloperClaim(db, { id: "cleanup-pending-claim", - developer_id: "claim-target", + developer_identifier: "claim-target", claimant_id: "cleanup-user" }); // Under claim-target, a developer this user does not own - which is the // only way a pending revision survives the "no owned extensions" guard. await insertUnpublishedExtension(db, { - id: "cleanup-pending-ext", - developer_id: "claim-target" + identifier: "cleanup-pending-ext", + developer_identifier: "claim-target" }); await insertRevision(db, { id: "cleanup-pending-revision", - extension_id: "cleanup-pending-ext", - developer_id: "claim-target", + extension_identifier: "cleanup-pending-ext", + developer_identifier: "claim-target", submitted_by: "cleanup-user", content: JSON.stringify(sampleContent()) }); await insertDeveloperHistory(db, { id: "cleanup-history", - developer_id: "cleanup-developer", + developer_identifier: "cleanup-developer", type: "user", name: "Before deletion", changed_by: "cleanup-user" @@ -569,7 +579,7 @@ describe("Extensions API v2", () => { expect(await listDeveloperHistory(db)).toEqual([ expect.objectContaining({ id: "cleanup-history", - developer_id: "cleanup-developer", + developer_id: expect.stringMatching(UUID_V7), changed_by: "cleanup-user" }) ]); @@ -601,7 +611,7 @@ describe("Extensions API v2", () => { await seedDeveloper("rollback-developer", "rollback-user"); await insertDeveloperTransfer(db, { id: "rollback-transfer", - developer_id: "rollback-developer", + developer_identifier: "rollback-developer", token_hash: "rollback-token-hash", created_by: "rollback-user", expires_at: "2099-01-01 00:00:00" diff --git a/test/services/extensions/v2/badges.test.ts b/test/services/extensions/v2/badges.test.ts index 28454c3d..2adac488 100644 --- a/test/services/extensions/v2/badges.test.ts +++ b/test/services/extensions/v2/badges.test.ts @@ -21,13 +21,13 @@ const badge = (type: string) => beforeEach(async () => { await insertDeveloper(db, { - id: "badge-dev", + identifier: "badge-dev", type: "user", name: "Developer" }); await insertExtension(db, { - id: "example", - developer_id: "badge-dev", + identifier: "example", + developer_identifier: "badge-dev", license: '"MIT OR Apache-2.0"', // Deliberately unordered; requirements belong to each release, not to the // root manifest or to the first element of a stored array. @@ -91,15 +91,19 @@ describe("v2 public badges", () => { "hides %s extensions", async (kind) => { if (kind === "missing") - await db.prepare("DELETE FROM extensions WHERE id='example'").run(); + await db + .prepare("DELETE FROM extensions WHERE identifier='example'") + .run(); if (kind === "unpublished") await db - .prepare("UPDATE extensions SET published_at=NULL WHERE id='example'") + .prepare( + "UPDATE extensions SET published_at=NULL WHERE identifier='example'" + ) .run(); if (kind === "delisted") await db .prepare( - "UPDATE extensions SET delisted_at=CURRENT_TIMESTAMP, delist_reason='Removed' WHERE id='example'" + "UPDATE extensions SET delisted_at=CURRENT_TIMESTAMP, delist_reason='Removed' WHERE identifier='example'" ) .run(); const response = await badge("fossbilling"); @@ -119,7 +123,7 @@ describe("v2 public badges", () => { it("handles empty release histories explicitly", async () => { await db - .prepare("UPDATE extensions SET releases='[]' WHERE id='example'") + .prepare("UPDATE extensions SET releases='[]' WHERE identifier='example'") .run(); expect(await (await badge("version")).text()).toContain( "Latest version: unknown" @@ -134,7 +138,7 @@ describe("v2 public badges", () => { it("escapes requirement text in the generated SVG", async () => { await db - .prepare("UPDATE extensions SET releases=? WHERE id='example'") + .prepare("UPDATE extensions SET releases=? WHERE identifier='example'") .bind( JSON.stringify([release("1.0.0", '')]) ) @@ -146,7 +150,7 @@ describe("v2 public badges", () => { it("renders badges when README content exceeds detail limits", async () => { await db - .prepare("UPDATE extensions SET readme=? WHERE id='example'") + .prepare("UPDATE extensions SET readme=? WHERE identifier='example'") .bind("x".repeat(300_000)) .run(); const queries: string[] = []; @@ -159,7 +163,7 @@ describe("v2 public badges", () => { it("refuses oversized stored badge fields without truncation", async () => { await db - .prepare("UPDATE extensions SET license=? WHERE id='example'") + .prepare("UPDATE extensions SET license=? WHERE identifier='example'") .bind(JSON.stringify("A".repeat(1001))) .run(); const response = await badge("license"); diff --git a/test/services/extensions/v2/contract.test.ts b/test/services/extensions/v2/contract.test.ts index b031b82c..b66ea1b9 100644 --- a/test/services/extensions/v2/contract.test.ts +++ b/test/services/extensions/v2/contract.test.ts @@ -1,3 +1,4 @@ +import { UUID_V7 } from "./db-fixtures"; import { describe, it, expect, vi } from "vitest"; import { setupExtensionsV2Tests, @@ -36,7 +37,10 @@ describe("Extensions API v2 contract", () => { ); expect(me.status).toBe(200); await expect(me.json()).resolves.toMatchObject({ - result: { id: "dev-developer" } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "dev-developer" + } }); }); @@ -57,7 +61,7 @@ describe("Extensions API v2 contract", () => { // developer_id is public-scope only. const scoped = await get( - "/extensions/v2/extensions?scope=mine&developer_id=dev-developer", + "/extensions/v2/extensions?scope=mine&developer_identifier=dev-developer", mod ); expect(scoped.status).toBe(422); @@ -89,14 +93,14 @@ describe("Extensions API v2 contract", () => { it("walks past the first page when rows exceed the limit", async () => { await insertDeveloper(db, { - id: "aaa-developer", + identifier: "aaa-developer", type: "user", name: "Aaa Developer", url: null, owner_user_id: "user-1" }); await insertDeveloper(db, { - id: "zzz-developer", + identifier: "zzz-developer", type: "user", name: "Zzz Developer", url: null, @@ -108,10 +112,12 @@ describe("Extensions API v2 contract", () => { const first = await get("/extensions/v2/developers?limit=1", mod); expect(first.status).toBe(200); const firstBody = (await first.json()) as { - result: Array<{ id: string }>; + result: Array<{ id: string; identifier: string }>; pagination: { next_cursor: string | null; has_more: boolean }; }; - expect(firstBody.result.map((d) => d.id)).toEqual(["aaa-developer"]); + expect(firstBody.result.map((d) => d.identifier)).toEqual([ + "aaa-developer" + ]); expect(firstBody.pagination.has_more).toBe(true); expect(firstBody.pagination.next_cursor).toBeTruthy(); @@ -121,10 +127,12 @@ describe("Extensions API v2 contract", () => { ); expect(second.status).toBe(200); const secondBody = (await second.json()) as { - result: Array<{ id: string }>; + result: Array<{ id: string; identifier: string }>; pagination: { next_cursor: string | null; has_more: boolean }; }; - expect(secondBody.result.map((d) => d.id)).toEqual(["zzz-developer"]); + expect(secondBody.result.map((d) => d.identifier)).toEqual([ + "zzz-developer" + ]); expect(secondBody.pagination).toEqual({ next_cursor: null, has_more: false diff --git a/test/services/extensions/v2/db-fixtures.ts b/test/services/extensions/v2/db-fixtures.ts index 9505f9b2..628883fe 100644 --- a/test/services/extensions/v2/db-fixtures.ts +++ b/test/services/extensions/v2/db-fixtures.ts @@ -1,3 +1,7 @@ +import { v7 as uuidv7 } from "uuid"; + +export const UUID_V7 = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; // Raw-SQL test fixtures against the real local D1 (see // test/utils/apply-migrations.ts) - deliberately not going through Drizzle, // since these exist to seed/read rows for assertions, not to exercise the @@ -6,6 +10,7 @@ export interface DeveloperRow { id: string; + identifier: string; type: string; name: string; url: string | null; @@ -28,6 +33,7 @@ export interface DeveloperRow { export interface ExtensionRow { id: string; + identifier: string; developer_id: string; published_at: string | null; published_revision_id: string | null; @@ -212,7 +218,11 @@ export async function ensureUser(db: D1Database, id: string): Promise { export async function insertDeveloper( db: D1Database, - row: Partial & { id: string; type: string; name: string } + row: Omit, "id"> & { + identifier: string; + type: string; + name: string; + } ): Promise { if (row.owner_user_id) { await ensureUser(db, row.owner_user_id); @@ -221,14 +231,15 @@ export async function insertDeveloper( await db .prepare( `INSERT INTO developers - (id, type, name, url, owner_user_id, approved_at, created_at, updated_at, + (id, identifier, type, name, url, owner_user_id, approved_at, created_at, updated_at, avatar_url, contact_email, ownership_epoch, content_revision, approved_revision, approved_by, github_org_verified, github_verification_note, github_verified_at, github_url_verified) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( - row.id, + uuidv7(), + row.identifier, row.type, row.name, row.url ?? null, @@ -258,21 +269,25 @@ export async function insertDeveloper( // together, which is what extensions_published_content_check requires. export async function insertExtension( db: D1Database, - row: Partial & { id: string; developer_id: string } + row: Omit, "id" | "developer_id"> & { + identifier: string; + developer_identifier: string; + } ): Promise { const now = new Date().toISOString(); await db .prepare( `INSERT INTO extensions - (id, developer_id, published_at, published_revision_id, type, name, + (id, identifier, developer_id, published_at, published_revision_id, type, name, description, releases, website, license, icon_url, readme, source, version, download_url, created_at, updated_at, delisted_at, delist_reason, manifest) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( - row.id, - row.developer_id, + uuidv7(), + row.identifier, + (await getDeveloper(db, row.developer_identifier))!.id, row.published_at !== undefined ? row.published_at : now, row.published_revision_id ?? null, row.type ?? "module", @@ -304,24 +319,30 @@ export async function insertExtension( // published_at. This is the state a POST /extensions leaves behind. export async function insertUnpublishedExtension( db: D1Database, - row: { id: string; developer_id: string; created_at?: string } + row: { identifier: string; developer_identifier: string; created_at?: string } ): Promise { const now = new Date().toISOString(); await db .prepare( - `INSERT INTO extensions (id, developer_id, created_at, updated_at) - VALUES (?, ?, ?, ?)` + `INSERT INTO extensions (id, identifier, developer_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)` + ) + .bind( + uuidv7(), + row.identifier, + (await getDeveloper(db, row.developer_identifier))!.id, + row.created_at ?? now, + now ) - .bind(row.id, row.developer_id, row.created_at ?? now, now) .run(); } export async function insertRevision( db: D1Database, - row: Partial & { + row: Omit, "developer_id" | "extension_id"> & { id: string; - extension_id: string; - developer_id: string; + extension_identifier: string; + developer_identifier: string; submitted_by: string; content: string; } @@ -339,8 +360,8 @@ export async function insertRevision( ) .bind( row.id, - row.extension_id, - row.developer_id, + (await getExtension(db, row.extension_identifier))!.id, + (await getDeveloper(db, row.developer_identifier))!.id, row.submitted_by, row.status ?? "pending", row.content, @@ -355,9 +376,9 @@ export async function insertRevision( export async function insertDeveloperClaim( db: D1Database, - row: Partial & { + row: Omit, "developer_id"> & { id: string; - developer_id: string; + developer_identifier: string; claimant_id: string; } ): Promise { @@ -374,7 +395,7 @@ export async function insertDeveloperClaim( ) .bind( row.id, - row.developer_id, + (await getDeveloper(db, row.developer_identifier))!.id, row.claimant_id, row.status ?? "pending", row.note ?? null, @@ -390,9 +411,9 @@ export async function insertDeveloperClaim( export async function insertDeveloperTransfer( db: D1Database, - row: Partial & { + row: Omit, "developer_id"> & { id: string; - developer_id: string; + developer_identifier: string; created_by: string; token_hash: string; expires_at: string; @@ -411,7 +432,7 @@ export async function insertDeveloperTransfer( ) .bind( row.id, - row.developer_id, + (await getDeveloper(db, row.developer_identifier))!.id, row.token_hash, row.created_by, row.created_at ?? new Date().toISOString(), @@ -425,9 +446,9 @@ export async function insertDeveloperTransfer( export async function insertDeveloperHistory( db: D1Database, - row: Partial & { + row: Omit, "developer_id"> & { id: string; - developer_id: string; + developer_identifier: string; type: string; name: string; changed_by: string; @@ -442,7 +463,7 @@ export async function insertDeveloperHistory( ) .bind( row.id, - row.developer_id, + (await getDeveloper(db, row.developer_identifier))!.id, row.type, row.name, row.url ?? null, @@ -454,11 +475,11 @@ export async function insertDeveloperHistory( export async function getDeveloper( db: D1Database, - id: string + identifier: string ): Promise { return db - .prepare("SELECT * FROM developers WHERE id = ?") - .bind(id) + .prepare("SELECT * FROM developers WHERE lower(identifier) = lower(?)") + .bind(identifier) .first(); } @@ -485,11 +506,11 @@ export async function countExtensions(db: D1Database): Promise { export async function getExtension( db: D1Database, - id: string + identifier: string ): Promise { return db - .prepare("SELECT * FROM extensions WHERE id = ?") - .bind(id) + .prepare("SELECT * FROM extensions WHERE lower(identifier) = lower(?)") + .bind(identifier) .first(); } @@ -585,7 +606,7 @@ export async function bumpDeveloperOwnership( await ensureUser(db, newOwnerUserId); await db .prepare( - "UPDATE developers SET owner_user_id = ?, ownership_epoch = ownership_epoch + 1 WHERE id = ?" + "UPDATE developers SET owner_user_id = ?, ownership_epoch = ownership_epoch + 1 WHERE identifier = ?" ) .bind(newOwnerUserId, developerId) .run(); diff --git a/test/services/extensions/v2/developer-approval.test.ts b/test/services/extensions/v2/developer-approval.test.ts index 1f29bcb0..988e3f9c 100644 --- a/test/services/extensions/v2/developer-approval.test.ts +++ b/test/services/extensions/v2/developer-approval.test.ts @@ -214,7 +214,7 @@ describe("Developer approval binds to the reviewed profile instance and content" ).toBe(200); // Model second-granular timestamps deterministically without timing a test. await db - .prepare("UPDATE developers SET updated_at = ? WHERE id = ?") + .prepare("UPDATE developers SET updated_at = ? WHERE identifier = ?") .bind("2026-10-07 00:00:00", "dev-developer") .run(); } @@ -246,7 +246,7 @@ describe("Developer approval binds to the reviewed profile instance and content" await insertUser(db, { id: "moderator", is_moderator: 1 }); const moderator = await authHeaders("moderator"); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "user", name: "Developer", approved_at: "2026-10-07 00:00:00", @@ -254,8 +254,8 @@ describe("Developer approval binds to the reviewed profile instance and content" approved_revision: approvedRevision }); await insertExtension(db, { - id: "live-ext", - developer_id: "dev-developer" + identifier: "live-ext", + developer_identifier: "dev-developer" }); const expected = approvedRevision === 2; const profile = (await (await get(profilePath, {})).json()) as { @@ -273,10 +273,10 @@ describe("Developer approval binds to the reviewed profile instance and content" for (const query of ["scope", "status"]) { const queue = (await ( await get(`/extensions/v2/developers?${query}=unapproved`, moderator) - ).json()) as { result: Array<{ id: string }> }; - expect(queue.result.some((item) => item.id === "dev-developer")).toBe( - !expected - ); + ).json()) as { result: Array<{ id: string; identifier: string }> }; + expect( + queue.result.some((item) => item.identifier === "dev-developer") + ).toBe(!expected); } } ); diff --git a/test/services/extensions/v2/developer-profiles.test.ts b/test/services/extensions/v2/developer-profiles.test.ts index a9dbaa4d..7dda4758 100644 --- a/test/services/extensions/v2/developer-profiles.test.ts +++ b/test/services/extensions/v2/developer-profiles.test.ts @@ -1,3 +1,5 @@ +import { v7 as uuidv7 } from "uuid"; +import { UUID_V7 } from "./db-fixtures"; import { describe, it, expect, vi } from "vitest"; import { env } from "cloudflare:workers"; import { request as ghRequest } from "@octokit/request"; @@ -51,18 +53,18 @@ describe("Extensions API v2", () => { ).toBe(200); await db .prepare( - "UPDATE developers SET approved_at = CURRENT_TIMESTAMP, approved_revision = content_revision WHERE id = ?" + "UPDATE developers SET approved_at = CURRENT_TIMESTAMP, approved_revision = content_revision WHERE identifier = ?" ) - .bind(profile.id) + .bind(profile.identifier) .run(); - const before = await getDeveloper(db, profile.id); + const before = await getDeveloper(db, profile.identifier); const responses = await Promise.all( Array.from({ length: 6 }, () => put("/extensions/v2/developers/me", headers, profile) ) ); expect(responses.map((r) => r.status)).toEqual(Array(6).fill(200)); - expect(await getDeveloper(db, profile.id)).toEqual(before); + expect(await getDeveloper(db, profile.identifier)).toEqual(before); expect(await listDeveloperHistory(db)).toHaveLength(1); }); @@ -72,12 +74,13 @@ describe("Extensions API v2", () => { expect( (await put("/extensions/v2/developers/me", headers, profile)).status ).toBe(200); + const developerId = (await getDeveloper(db, profile.identifier))!.id; for (let i = 0; i < 18; i++) { await db .prepare( "INSERT INTO developer_history (id, developer_id, type, name, changed_by) VALUES (?, ?, 'user', 'Budget', ?)" ) - .bind(`budget-${i}`, profile.id, "budget-owner") + .bind(`budget-${i}`, developerId, "budget-owner") .run(); } const raced = await Promise.all( @@ -93,7 +96,7 @@ describe("Extensions API v2", () => { error: { code: "PROFILE_MUTATION_RATE_LIMITED" } }); expect(await listDeveloperHistory(db)).toHaveLength(20); - const current = await getDeveloper(db, profile.id); + const current = await getDeveloper(db, profile.identifier); expect( ( await put("/extensions/v2/developers/me", headers, { @@ -132,12 +135,13 @@ describe("Extensions API v2", () => { it("allows writes after old history leaves the window and audits all editable fields", async () => { const headers = await authHeaders("expired-budget-owner"); let profile = sampleDeveloper({ id: "expired-profile" }); + const formerDeveloperId = uuidv7(); for (let i = 0; i < 20; i++) { await db .prepare( "INSERT INTO developer_history (id, developer_id, type, name, changed_by, changed_at) VALUES (?, ?, 'user', 'Old', ?, datetime('now', '-2 days'))" ) - .bind(`old-${i}`, profile.id, "expired-budget-owner") + .bind(`old-${i}`, formerDeveloperId, "expired-budget-owner") .run(); } expect( @@ -156,13 +160,15 @@ describe("Extensions API v2", () => { ).toBe(200); } expect(await listDeveloperHistory(db)).toHaveLength(26); - expect((await getDeveloper(db, profile.id))?.content_revision).toBe(6); + expect( + (await getDeveloper(db, profile.identifier))?.content_revision + ).toBe(6); }); it("treats omitted optional fields as null and audits clearing them once", async () => { const headers = await authHeaders("null-profile-owner"); const profile = { - id: "null-profile", + identifier: "null-profile", type: "user", name: "Null profile" }; @@ -190,7 +196,7 @@ describe("Extensions API v2", () => { (await put("/extensions/v2/developers/me", headers, profile)).status ).toBe(200); expect(await listDeveloperHistory(db)).toHaveLength(3); - const current = await getDeveloper(db, profile.id); + const current = await getDeveloper(db, profile.identifier); expect(current?.url).toBeNull(); expect(current?.avatar_url).toBeNull(); expect(current?.contact_email).toBeNull(); @@ -204,13 +210,13 @@ describe("Extensions API v2", () => { const allowed = await put( "/extensions/v2/developers/me", firstHeaders, - { id, type: "user", name: id } + { identifier: id, type: "user", name: id } ); expect(allowed.status).toBe(403); } const denied = await put("/extensions/v2/developers/me", firstHeaders, { - id: "attempt-four", + identifier: "attempt-four", type: "user", name: "Attempt four" }); @@ -228,7 +234,7 @@ describe("Extensions API v2", () => { const otherAccount = await put( "/extensions/v2/developers/me", await authHeaders("independent-rate-limit-account"), - { id: "other-attempt", type: "user", name: "Other attempt" } + { identifier: "other-attempt", type: "user", name: "Other attempt" } ); expect(otherAccount.status).toBe(403); expect(ghRequest).toHaveBeenCalledTimes(4); @@ -260,14 +266,14 @@ describe("Extensions API v2", () => { for (const id of ["remaining-one", "remaining-two"]) { const allowed = await put("/extensions/v2/developers/me", headers, { - id, + identifier: id, type: "user", name: id }); expect(allowed.status).toBe(403); } const denied = await put("/extensions/v2/developers/me", headers, { - id: "no-allowance", + identifier: "no-allowance", type: "user", name: "No allowance" }); @@ -365,7 +371,7 @@ describe("Extensions API v2", () => { if (!raced && sql.includes("INSERT INTO developers")) { raced = true; await insertDeveloper(db, { - id: "raced-developer", + identifier: "raced-developer", type: "user", name: "Concurrent Creator", owner_user_id: "user-2" @@ -417,7 +423,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } + { identifier: "acme-org", type: "organization", name: "Acme Org" } ); expect(res.status).toBe(200); @@ -467,7 +473,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } + { identifier: "acme-org", type: "organization", name: "Acme Org" } ); expect(res.status).toBe(200); @@ -490,7 +496,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } + { identifier: "acme-org", type: "organization", name: "Acme Org" } ); expect(res.status).toBe(200); @@ -512,7 +518,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } + { identifier: "acme-org", type: "organization", name: "Acme Org" } ); expect(res.status).toBe(403); @@ -532,7 +538,7 @@ describe("Extensions API v2", () => { "/extensions/v2/developers/me", await authHeaders("user-1"), { - id: "acme-org", + identifier: "acme-org", type: "organization", name: "Acme Org", URL: "https://acme.example" @@ -564,7 +570,7 @@ describe("Extensions API v2", () => { "/extensions/v2/developers/me", await authHeaders("user-1"), { - id: "acme-org", + identifier: "acme-org", type: "organization", name: "Acme Org", URL: "https://acme.example" @@ -592,7 +598,7 @@ describe("Extensions API v2", () => { "/extensions/v2/developers/me", await authHeaders("user-1"), { - id: "acme-org", + identifier: "acme-org", type: "organization", name: "Acme Org", URL: "http://acme.example/" @@ -617,7 +623,7 @@ describe("Extensions API v2", () => { "/extensions/v2/developers/me", await authHeaders("user-1"), { - id: "acme-org", + identifier: "acme-org", type: "organization", name: "Acme Org", URL: "https://acme.example" @@ -642,7 +648,7 @@ describe("Extensions API v2", () => { "/extensions/v2/developers/me", await authHeaders("user-1"), { - id: "acme-org", + identifier: "acme-org", type: "organization", name: "Acme Org", URL: "https://acme.example/docs" @@ -666,7 +672,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } + { identifier: "acme-org", type: "organization", name: "Acme Org" } ); expect(res.status).toBe(403); @@ -686,7 +692,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "acme-org", type: "user", name: "Acme Org" } + { identifier: "acme-org", type: "user", name: "Acme Org" } ); expect(res.status).toBe(403); @@ -715,7 +721,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "unavailable-dev", type: "user", name: "Unavailable" } + { identifier: "unavailable-dev", type: "user", name: "Unavailable" } ); expect(res.status).toBe(expectedStatus); @@ -739,7 +745,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "unavailable-dev", type: "user", name: "Unavailable" } + { identifier: "unavailable-dev", type: "user", name: "Unavailable" } ); expect(res.status).toBe(503); @@ -761,7 +767,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "invalid-response", type: "user", name: "Invalid" } + { identifier: "invalid-response", type: "user", name: "Invalid" } ); expect(res.status).toBe(503); @@ -779,7 +785,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "unsupported-entity", type: "user", name: "Unsupported" } + { identifier: "unsupported-entity", type: "user", name: "Unsupported" } ); expect(res.status).toBe(422); @@ -795,7 +801,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } + { identifier: "acme-org", type: "organization", name: "Acme Org" } ); expect(res.status).toBe(200); @@ -816,7 +822,7 @@ describe("Extensions API v2", () => { const res = await put( "/extensions/v2/developers/me", await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } + { identifier: "acme-org", type: "organization", name: "Acme Org" } ); expect(res.status).toBe(500); @@ -1024,7 +1030,7 @@ describe("Extensions API v2", () => { ); expect( (await listDeveloperHistory(db)).filter( - (row) => row.developer_id === "dev-developer" + (row) => row.name === "Dev Developer" ) ).toHaveLength(1); }); @@ -1204,7 +1210,10 @@ describe("Extensions API v2", () => { .prepare( "INSERT INTO m2m_publisher_clients (client_id, developer_id, can_publish) VALUES (?, ?, 1)" ) - .bind("modules-publisher", "dev-developer") + .bind( + "modules-publisher", + (await getDeveloper(db, "dev-developer"))!.id + ) .run(); const res = await del( @@ -1213,9 +1222,12 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(200); const body = (await res.json()) as { - result: { id: string; deleted: boolean }; + result: { identifier: string; deleted: boolean }; }; - expect(body.result).toEqual({ id: "dev-developer", deleted: true }); + expect(body.result).toMatchObject({ + id: expect.stringMatching(/^[a-f0-9-]{36}$/), + deleted: true + }); const getRes = await get("/extensions/v2/developers/dev-developer", {}); expect(getRes.status).toBe(404); @@ -1245,7 +1257,9 @@ describe("Extensions API v2", () => { if (!raced && sql.includes("DELETE FROM developers")) { raced = true; await db - .prepare("UPDATE developers SET owner_user_id = ? WHERE id = ?") + .prepare( + "UPDATE developers SET owner_user_id = ? WHERE identifier = ?" + ) .bind("owner-after", "raced-delete") .run(); } @@ -1277,7 +1291,7 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "owner-after" }); await insertDeveloperTransfer(db, { id: "transfer-1", - developer_id: "raced-delete", + developer_identifier: "raced-delete", created_by: "owner-before", token_hash: "hash-1", expires_at: "2099-01-01T00:00:00.000Z" @@ -1288,7 +1302,9 @@ describe("Extensions API v2", () => { if (!raced && sql.includes("DELETE FROM developers")) { raced = true; await db - .prepare("UPDATE developers SET owner_user_id = ? WHERE id = ?") + .prepare( + "UPDATE developers SET owner_user_id = ? WHERE identifier = ?" + ) .bind("owner-after", "raced-delete") .run(); } @@ -1368,7 +1384,7 @@ describe("Extensions API v2", () => { ); await insertDeveloperClaim(db, { id: "claim-1", - developer_id: "dev-developer", + developer_identifier: "dev-developer", claimant_id: "user-2", status: "rejected", review_note: "no", @@ -1376,6 +1392,7 @@ describe("Extensions API v2", () => { reviewed_at: new Date().toISOString() }); + const originalId = (await getDeveloper(db, "dev-developer"))!.id; const res = await del( "/extensions/v2/developers/me", await authHeaders("user-1") @@ -1384,17 +1401,17 @@ describe("Extensions API v2", () => { expect( (await listDeveloperTransfers(db)).filter( - (r) => r.developer_id === "dev-developer" + (r) => r.developer_id === originalId ) ).toHaveLength(0); expect( (await listDeveloperClaims(db)).filter( - (r) => r.developer_id === "dev-developer" + (r) => r.developer_id === originalId ) ).toHaveLength(0); expect( (await listDeveloperHistory(db)).filter( - (r) => r.developer_id === "dev-developer" + (r) => r.developer_id === originalId ).length ).toBeGreaterThan(0); }); @@ -1432,8 +1449,10 @@ describe("Extensions API v2", () => { {} ); expect(stillThere.status).toBe(200); - const body = (await stillThere.json()) as { result: { id: string } }; - expect(body.result.id).toBe("dev-developer"); + const body = (await stillThere.json()) as { + result: { identifier: string }; + }; + expect(body.result.identifier).toBe("dev-developer"); }); it("reports an inactive owner when the account is deactivated during deletion", async () => { @@ -1473,7 +1492,7 @@ describe("Extensions API v2", () => { it("reports an inactive account when deactivated during a URL cooldown reservation", async () => { await insertUser(db, { id: "user-1" }); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -1511,7 +1530,7 @@ describe("Extensions API v2", () => { it("re-verifies and refreshes the timestamp when the owner's GitHub org still matches", async () => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: null, @@ -1552,7 +1571,7 @@ describe("Extensions API v2", () => { "preserves verification when the owner's organization evidence is %s", async (_state, github_orgs, github_orgs_expires_at) => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: null, @@ -1587,7 +1606,7 @@ describe("Extensions API v2", () => { it("preserves verification when the owner's GitHub login is whitespace-only", async () => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: null, @@ -1618,7 +1637,7 @@ describe("Extensions API v2", () => { it("flips to unverified when the owner's GitHub org membership no longer matches", async () => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: null, @@ -1654,7 +1673,7 @@ describe("Extensions API v2", () => { it("verifies for the first time on re-check when the caller now has a matching linked GitHub identity", async () => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: null, @@ -1682,7 +1701,7 @@ describe("Extensions API v2", () => { it("doesn't check the Publisher URL without ?check_url=true", async () => { mockGithubEntity("Organization", "https://acme.example"); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -1709,7 +1728,7 @@ describe("Extensions API v2", () => { it("checks the Publisher URL when re-verified with ?check_url=true", async () => { mockGithubEntity("Organization", "https://acme.example"); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -1739,7 +1758,7 @@ describe("Extensions API v2", () => { "returns an error and retains the cooldown when GitHub responds with %s", async (upstreamStatus, message, expectedStatus, expectedCode) => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -1784,7 +1803,7 @@ describe("Extensions API v2", () => { it("rate-limits repeated ?check_url=true calls from the same caller", async () => { mockGithubEntity("Organization", "https://acme.example"); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -1816,7 +1835,7 @@ describe("Extensions API v2", () => { it("doesn't rate-limit reverify calls that don't use ?check_url", async () => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -1843,14 +1862,14 @@ describe("Extensions API v2", () => { it("rate-limits ?check_url=true per caller, not globally", async () => { mockGithubEntity("Organization", "https://acme.example"); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", owner_user_id: "user-1" }); await insertDeveloper(db, { - id: "other-developer", + identifier: "other-developer", type: "organization", name: "Other", url: "https://acme.example", @@ -1882,7 +1901,7 @@ describe("Extensions API v2", () => { it("only lets one of two concurrent ?check_url=true requests through", async () => { mockGithubEntity("Organization", "https://acme.example"); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -1907,7 +1926,7 @@ describe("Extensions API v2", () => { it("does not persist a URL verification computed against a stale URL", async () => { mockGithubEntity("Organization", "https://acme.example"); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -1926,7 +1945,7 @@ describe("Extensions API v2", () => { env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { if (sql.includes("developers") && sql.includes("github_verified_at")) { await db - .prepare("UPDATE developers SET url = ? WHERE id = ?") + .prepare("UPDATE developers SET url = ? WHERE identifier = ?") .bind("https://different.example", "dev-developer") .run(); } @@ -1950,7 +1969,7 @@ describe("Extensions API v2", () => { it("clears a previously-verified Publisher URL when identity no longer matches", async () => { mockGithubEntity("Organization", "https://acme.example"); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -1982,7 +2001,7 @@ describe("Extensions API v2", () => { it("clears a previously-verified Publisher URL when identity no longer matches, even without ?check_url", async () => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -2022,7 +2041,7 @@ describe("Extensions API v2", () => { // caught separately before trusting the entity's blog field. mockGithubEntity("Organization", "https://acme.example"); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "user", name: "Dev", url: "https://acme.example", @@ -2059,7 +2078,7 @@ describe("Extensions API v2", () => { it("preserves an existing Publisher URL verification when the GitHub lookup fails", async () => { mockGithubEntityNotFound(); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -2087,7 +2106,7 @@ describe("Extensions API v2", () => { it("treats ?check_url=false the same as omitting it", async () => { mockGithubEntity("Organization", "https://acme.example"); await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: "https://acme.example", @@ -2121,7 +2140,7 @@ describe("Extensions API v2", () => { it("refuses to overwrite verification if ownership moves away between the lookup and the write", async () => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: null, @@ -2157,7 +2176,7 @@ describe("Extensions API v2", () => { it("refuses to overwrite verification if the profile type changes during the check", async () => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: null, @@ -2178,7 +2197,7 @@ describe("Extensions API v2", () => { if (!changed && sql.includes("github_verified_at")) { changed = true; await db - .prepare("UPDATE developers SET type = ? WHERE id = ?") + .prepare("UPDATE developers SET type = ? WHERE identifier = ?") .bind("user", "dev-developer") .run(); } @@ -2200,7 +2219,7 @@ describe("Extensions API v2", () => { it("refuses to overwrite verification if GitHub identity sync wins the race", async () => { await insertDeveloper(db, { - id: "dev-developer", + identifier: "dev-developer", type: "organization", name: "Dev", url: null, @@ -2256,7 +2275,7 @@ describe("Extensions API v2", () => { describe("GET /developers/{id}", () => { it("returns a developer's public profile without contact_email, unauthenticated", async () => { await insertDeveloper(db, { - id: "public-dev", + identifier: "public-dev", type: "organization", name: "Public Dev", url: "https://example.com", @@ -2270,7 +2289,8 @@ describe("Extensions API v2", () => { expect(res.status).toBe(200); const body = (await res.json()) as { result: Record }; expect(body.result).toEqual({ - id: "public-dev", + id: expect.stringMatching(UUID_V7), + identifier: "public-dev", type: "organization", name: "Public Dev", URL: "https://example.com", @@ -2292,13 +2312,13 @@ describe("Extensions API v2", () => { const res = await get("/extensions/v2/developers/legacy-public", {}); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ - result: { id: "legacy-public", unclaimed: true } + result: { identifier: "legacy-public", unclaimed: true } }); }); it("falls back to the public view for a deactivated owner", async () => { await insertDeveloper(db, { - id: "owned-dev", + identifier: "owned-dev", type: "user", name: "Owned Dev", contact_email: "private@example.com", @@ -2317,7 +2337,10 @@ describe("Extensions API v2", () => { expect(res.status).toBe(200); const body = (await res.json()) as { result: Record }; expect(body.result.contact_email).toBeUndefined(); - expect(body.result).toMatchObject({ id: "owned-dev", unclaimed: false }); + expect(body.result).toMatchObject({ + identifier: "owned-dev", + unclaimed: false + }); expect(res.headers.get("Vary")).toBe("Authorization"); }); }); diff --git a/test/services/extensions/v2/directory-identity.test.ts b/test/services/extensions/v2/directory-identity.test.ts new file mode 100644 index 00000000..3f8bb8ad --- /dev/null +++ b/test/services/extensions/v2/directory-identity.test.ts @@ -0,0 +1,95 @@ +import { UUID_V7 } from "./db-fixtures"; +import { describe, expect, it, vi } from "vitest"; +import { + authHeaders, + db, + get, + post, + put, + sampleCreate, + sampleDeveloper, + setupExtensionsV2Tests +} from "./harness"; + +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); +setupExtensionsV2Tests(); + +describe("directory identity", () => { + it("generates UUIDv7 keys and keeps public identifiers out of foreign keys", async () => { + const headers = await authHeaders("owner"); + const profile = await put( + "/extensions/v2/developers/me", + headers, + sampleDeveloper() + ); + expect(profile.status).toBe(200); + const developer = ( + (await profile.json()) as { result: { id: string; identifier: string } } + ).result; + expect(developer.id).toMatch(UUID_V7); + expect(developer.identifier).toBe("dev-developer"); + const created = await post( + "/extensions/v2/extensions", + headers, + sampleCreate() + ); + expect(created.status).toBe(201); + const extension = ( + (await created.json()) as { + result: { id: string; identifier: string; revision_id: string }; + } + ).result; + expect(extension.id).toMatch(UUID_V7); + expect(extension.identifier).toBe("new-ext"); + expect( + await db + .prepare("SELECT developer_id FROM extensions WHERE id=?") + .bind(extension.id) + .first() + ).toEqual({ developer_id: developer.id }); + expect( + await db + .prepare( + "SELECT extension_id,developer_id FROM extension_revisions WHERE id=?" + ) + .bind(extension.revision_id) + .first() + ).toEqual({ extension_id: extension.id, developer_id: developer.id }); + expect( + (await get("/extensions/v2/extensions/new-ext", headers)).status + ).toBe(200); + expect( + (await get(`/extensions/v2/extensions/${extension.id}`, headers)).status + ).toBe(404); + expect( + (await get("/extensions/v2/developers/dev-developer", headers)).status + ).toBe(200); + }); + + it("preserves both identities when display names change", async () => { + const headers = await authHeaders("owner"); + const response = await put( + "/extensions/v2/developers/me", + headers, + sampleDeveloper() + ); + const before = ( + (await response.json()) as { result: { id: string; identifier: string } } + ).result; + const updated = await put( + "/extensions/v2/developers/me", + headers, + sampleDeveloper({ name: "New Display Name" }) + ); + expect(updated.status).toBe(200); + expect( + ((await updated.json()) as { result: unknown }).result + ).toMatchObject({ + id: before.id, + identifier: before.identifier, + name: "New Display Name" + }); + }); +}); diff --git a/test/services/extensions/v2/extension-writes.test.ts b/test/services/extensions/v2/extension-writes.test.ts index 73103c82..5fccf96c 100644 --- a/test/services/extensions/v2/extension-writes.test.ts +++ b/test/services/extensions/v2/extension-writes.test.ts @@ -20,6 +20,7 @@ import { countRevisions, insertUser, getExtension, + getDeveloper, getRevision, insertDeveloper, insertExtension, @@ -38,14 +39,14 @@ setupExtensionsV2Tests(); // lowercaseId() rejects them at validation. async function seedMixedCaseExtension() { await insertDeveloper(db, { - id: "owner-developer", + identifier: "owner-developer", type: "user", name: "Owner", owner_user_id: "owner-1" }); await insertExtension(db, { - id: "Existing-Ext", - developer_id: "owner-developer", + identifier: "Existing-Ext", + developer_identifier: "owner-developer", name: "Existing" }); } @@ -156,15 +157,23 @@ describe("Extensions API v2 writes", () => { expect(res.status).toBe(201); const data = (await res.json()) as { - result: { id: string; revision_id: string; status: string }; + result: { + id: string; + identifier: string; + revision_id: string; + status: string; + }; }; - expect(data.result).toMatchObject({ id: "new-ext", status: "pending" }); + expect(data.result).toMatchObject({ + identifier: "new-ext", + status: "pending" + }); // Holds its id immediately, but stays unpublished until a moderator // approves. const stored = await getExtension(db, "new-ext"); expect(stored).toMatchObject({ - developer_id: "new-developer", + developer_id: (await getDeveloper(db, "new-developer"))!.id, published_at: null, published_revision_id: null, name: null @@ -172,7 +181,7 @@ describe("Extensions API v2 writes", () => { const revision = await getRevision(db, data.result.revision_id); expect(revision).toMatchObject({ - extension_id: "new-ext", + extension_id: (await getExtension(db, "new-ext"))!.id, submitted_by: "user-1", status: "pending" }); @@ -315,7 +324,7 @@ describe("Extensions API v2 writes", () => { // The edit body carries the same guard. await seedOwnedExtension(); - const { id: _id, ...content } = body; + const { identifier: _identifier, ...content } = body; const edited = await put( "/extensions/v2/extensions/existing-ext", await authHeaders("owner-1"), @@ -400,7 +409,7 @@ describe("Extensions API v2 writes", () => { expect(res.status).toBe(202); const [revision] = await listRevisions(db); expect(revision).toMatchObject({ - extension_id: "existing-ext", + extension_id: (await getExtension(db, "existing-ext"))!.id, status: "pending" }); expect(JSON.parse(revision.content).name).toBe("Renamed"); @@ -516,7 +525,9 @@ describe("Extensions API v2 writes", () => { expect(res.status).toBe(202); // The revision must hang off the stored spelling, not the requested one. const [revision] = await listRevisions(db); - expect(revision.extension_id).toBe("Existing-Ext"); + expect(revision.extension_id).toBe( + (await getExtension(db, "Existing-Ext"))!.id + ); }); it("lists revisions addressed in lower case", async () => { @@ -730,13 +741,14 @@ describe("Extensions API v2 writes", () => { const data = (await res.json()) as { result: Array<{ id: string; + identifier: string; published: { name: string } | null; - pending_revision: { id: string } | null; + pending_revision: { id: string; identifier: string } | null; last_review: unknown; }>; }; - expect(data.result.map((item) => item.id)).toEqual([ + expect(data.result.map((item) => item.identifier)).toEqual([ "draft-ext", "existing-ext" ]); @@ -761,7 +773,7 @@ describe("Extensions API v2 writes", () => { const data = (await res.json()) as { result: Array<{ published: { name: string } | null; - pending_revision: { id: string } | null; + pending_revision: { id: string; identifier: string } | null; }>; }; expect(data.result[0].published).toMatchObject({ name: "Existing" }); @@ -833,8 +845,12 @@ describe("Extensions API v2 writes", () => { "/extensions/v2/extensions?scope=mine", await authHeaders("owner-1") ); - const data = (await res.json()) as { result: Array<{ id: string }> }; - expect(data.result.map((item) => item.id)).toEqual(["existing-ext"]); + const data = (await res.json()) as { + result: Array<{ id: string; identifier: string }>; + }; + expect(data.result.map((item) => item.identifier)).toEqual([ + "existing-ext" + ]); }); // extensions.type is NULL until a first approval, so filtering the column @@ -876,8 +892,10 @@ describe("Extensions API v2 writes", () => { headers ); expect(res.status).toBe(200); - const data = (await res.json()) as { result: Array<{ id: string }> }; - expect(data.result.map((item) => item.id)).toEqual([ + const data = (await res.json()) as { + result: Array<{ id: string; identifier: string }>; + }; + expect(data.result.map((item) => item.identifier)).toEqual([ "draft-ext", "live-ext", "rejected-ext" @@ -920,10 +938,10 @@ describe("Extensions API v2 writes", () => { headers ); const firstBody = (await first.json()) as { - result: Array<{ id: string }>; + result: Array<{ id: string; identifier: string }>; pagination: { has_more: boolean; next_cursor: string }; }; - expect(firstBody.result.map((item) => item.id)).toEqual([ + expect(firstBody.result.map((item) => item.identifier)).toEqual([ "page-a", "page-b" ]); @@ -934,10 +952,12 @@ describe("Extensions API v2 writes", () => { headers ); const secondBody = (await second.json()) as { - result: Array<{ id: string }>; + result: Array<{ id: string; identifier: string }>; pagination: { has_more: boolean; next_cursor: null }; }; - expect(secondBody.result.map((item) => item.id)).toEqual(["page-c"]); + expect(secondBody.result.map((item) => item.identifier)).toEqual([ + "page-c" + ]); expect(secondBody.pagination).toEqual({ has_more: false, next_cursor: null @@ -973,14 +993,14 @@ describe("Extensions API v2 writes", () => { // advertised schema catches the disagreement. it("serves an adopted extension with no releases against its own schema", async () => { await insertDeveloper(db, { - id: "owner-developer", + identifier: "owner-developer", type: "user", name: "Owner", owner_user_id: "owner-1" }); await insertExtension(db, { - id: "adopted-ext", - developer_id: "owner-developer", + identifier: "adopted-ext", + developer_identifier: "owner-developer", name: "Adopted", releases: "[]" }); @@ -1006,9 +1026,14 @@ describe("Extensions API v2 writes", () => { ); expect(res.status).toBe(200); const data = (await res.json()) as { - result: { id: string; readme: string; pending_revision?: unknown }; + result: { + id: string; + identifier: string; + readme: string; + pending_revision?: unknown; + }; }; - expect(data.result.id).toBe("existing-ext"); + expect(data.result.identifier).toBe("existing-ext"); expect(data.result.readme).toBeDefined(); expect("pending_revision" in data.result).toBe(false); }); @@ -1043,10 +1068,14 @@ describe("Extensions API v2 writes", () => { ); expect(res.status).toBe(200); const data = (await res.json()) as { - result: Array<{ extension_id: string }>; + result: Array<{ + extension_id: string; + identifier: string; + extension_identifier: string; + }>; }; expect(data.result).toHaveLength(1); - expect(data.result[0].extension_id).toBe("mine"); + expect(data.result[0].extension_identifier).toBe("mine"); }); }); @@ -1062,11 +1091,15 @@ describe("Extensions API v2 writes", () => { ); expect(res.status).toBe(200); const data = (await res.json()) as { - result: Array<{ extension_id: string; status: string }>; + result: Array<{ + extension_id: string; + identifier: string; + status: string; + }>; }; expect(data.result).toHaveLength(1); expect(data.result[0]).toMatchObject({ - extension_id: "new-ext", + extension_id: (await getExtension(db, "new-ext"))!.id, status: "pending" }); }); diff --git a/test/services/extensions/v2/harness.ts b/test/services/extensions/v2/harness.ts index a66e0012..c7e77ff6 100644 --- a/test/services/extensions/v2/harness.ts +++ b/test/services/extensions/v2/harness.ts @@ -158,14 +158,14 @@ export function sampleCreate(overrides?: { name?: string; }) { return { - id: overrides?.extensionId ?? "new-ext", + identifier: overrides?.extensionId ?? "new-ext", ...sampleContent(overrides) }; } export function sampleDeveloper(overrides?: { id?: string; name?: string }) { return { - id: overrides?.id ?? "dev-developer", + identifier: overrides?.id ?? "dev-developer", type: "user", name: overrides?.name ?? "Dev Developer", URL: "https://example.com" @@ -179,7 +179,7 @@ export async function seedDeveloper( ownerUserId: string ): Promise { await insertDeveloper(db, { - id, + identifier: id, type: "user", name: "Developer", url: null, @@ -192,7 +192,7 @@ export async function seedUnownedDeveloper( name = "Legacy Developer" ): Promise { await insertDeveloper(db, { - id, + identifier: id, type: "user", name, url: null, @@ -202,15 +202,15 @@ export async function seedUnownedDeveloper( export async function seedOwnedExtension(): Promise { await insertDeveloper(db, { - id: "owner-developer", + identifier: "owner-developer", type: "user", name: "Owner", url: null, owner_user_id: "owner-1" }); await insertExtension(db, { - id: "existing-ext", - developer_id: "owner-developer", + identifier: "existing-ext", + developer_identifier: "owner-developer", name: "Existing", source: '{"type":"github","repo":"example/existing"}' }); diff --git a/test/services/extensions/v2/manifest.test.ts b/test/services/extensions/v2/manifest.test.ts index cfc35fa8..efd8a7f8 100644 --- a/test/services/extensions/v2/manifest.test.ts +++ b/test/services/extensions/v2/manifest.test.ts @@ -55,7 +55,10 @@ describe("manifest v1 publishing", () => { const { result: extension } = (await detail.json()) as { result: Record; }; - expect(extension).toMatchObject({ ...content, developer: { id: "owner" } }); + expect(extension).toMatchObject({ + ...content, + developer: { identifier: "owner" } + }); expect(extension).not.toHaveProperty("website"); const list = await get("/extensions/v2/extensions?type=module", {}); const { result: items } = (await list.json()) as { @@ -127,18 +130,20 @@ describe("manifest v1 publishing", () => { expect( ( await post( - `/extensions/v2/extensions/${content.id}/revisions/${result.revision_id}/approve`, + `/extensions/v2/extensions/${content.identifier}/revisions/${result.revision_id}/approve`, await authHeaders("moderator"), {} ) ).status ).toBe(200); const published = (await ( - await get(`/extensions/v2/extensions/${content.id}`, {}) + await get(`/extensions/v2/extensions/${content.identifier}`, {}) ).json()) as { result: typeof content }; expect(published.result.requires).toEqual(requires); expect(published.result.releases[0].requires).toEqual(requires); - expect((await get(`/extensions/v1/${content.id}`, {})).status).toBe(200); + expect((await get(`/extensions/v1/${content.identifier}`, {})).status).toBe( + 200 + ); }); it("keeps the historical minimum-version field bounded in revision schemas", () => { @@ -189,7 +194,10 @@ describe("manifest v1 publishing", () => { it("allows existing long identities to be edited without relaxing new manifest IDs", async () => { await seedDeveloper("owner", "user-1"); const id = "e".repeat(120); - await insertExtension(db, { id, developer_id: "owner" }); + await insertExtension(db, { + identifier: id, + developer_identifier: "owner" + }); expect( ( await put( diff --git a/test/services/extensions/v2/migrations.test.ts b/test/services/extensions/v2/migrations.test.ts index 9b8f5a47..ee791835 100644 --- a/test/services/extensions/v2/migrations.test.ts +++ b/test/services/extensions/v2/migrations.test.ts @@ -1374,3 +1374,68 @@ it.each(["", "v"])( } } ); + +describe("directory UUID identities", () => { + it("preserves populated relationships and resource accounting in a D1 transaction", () => { + const db = new DatabaseSync(":memory:"); + try { + db.exec("PRAGMA foreign_keys=ON"); + for (const name of migrationNames.filter((name) => name < "0030")) + db.exec(migration(name)); + db.exec(` + INSERT INTO users(id,created_at,updated_at) VALUES ('owner',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP); + INSERT INTO developers(id,type,name,owner_user_id,created_at) VALUES ('studio','user','Studio','owner','2026-11-01 00:00:00'); + INSERT INTO extensions(id,developer_id,created_by) VALUES ('example','studio','owner'); + INSERT INTO extension_revisions(id,extension_id,developer_id,submitted_by,content) VALUES ('revision','example','studio','owner','{}'); + INSERT INTO developer_claims(id,developer_id,claimant_id) VALUES ('claim','studio','owner'); + INSERT INTO m2m_publisher_clients(client_id,developer_id,can_publish) VALUES ('publisher','studio',1); + `); + const before = db + .prepare( + "SELECT bytes,extensions,revisions FROM extension_resource_usage WHERE scope='developer' AND subject='studio'" + ) + .get(); + db.exec("BEGIN"); + db.exec(migration("0030_directory_identity.sql")); + db.exec("COMMIT"); + const developer = db + .prepare("SELECT id,identifier FROM developers") + .get() as { id: string; identifier: string }; + const extension = db + .prepare("SELECT id,identifier,developer_id FROM extensions") + .get() as { id: string; identifier: string; developer_id: string }; + const pattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + expect(developer).toEqual({ + id: expect.stringMatching(pattern), + identifier: "studio" + }); + expect(extension).toEqual({ + id: expect.stringMatching(pattern), + identifier: "example", + developer_id: developer.id + }); + expect( + db + .prepare("SELECT extension_id,developer_id FROM extension_revisions") + .get() + ).toEqual({ extension_id: extension.id, developer_id: developer.id }); + expect( + db.prepare("SELECT developer_id FROM developer_claims").get() + ).toEqual({ developer_id: developer.id }); + expect( + db.prepare("SELECT developer_id FROM m2m_publisher_clients").get() + ).toEqual({ developer_id: developer.id }); + expect( + db + .prepare( + "SELECT bytes,extensions,revisions FROM extension_resource_usage WHERE scope='developer' AND subject=?" + ) + .get(developer.id) + ).toEqual(before); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + } finally { + db.close(); + } + }); +}); diff --git a/test/services/extensions/v2/moderation-correct.test.ts b/test/services/extensions/v2/moderation-correct.test.ts index 79f74c81..420ee2ac 100644 --- a/test/services/extensions/v2/moderation-correct.test.ts +++ b/test/services/extensions/v2/moderation-correct.test.ts @@ -1,3 +1,4 @@ +import { UUID_V7 } from "./db-fixtures"; import { describe, it, expect, vi } from "vitest"; import { env } from "cloudflare:workers"; import { wrapD1WithHook } from "./db-interceptor"; @@ -44,8 +45,8 @@ async function seedPublished(): Promise { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer", + identifier: "live-ext", + developer_identifier: "new-developer", readme: "old readme" }); } @@ -64,7 +65,7 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { ); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ - result: { id: "live-ext" } + result: { id: expect.stringMatching(UUID_V7), identifier: "live-ext" } }); expect(await countRevisions(db)).toBe(1); }); @@ -169,8 +170,8 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertUnpublishedExtension(db, { - id: "draft-ext", - developer_id: "new-developer" + identifier: "draft-ext", + developer_identifier: "new-developer" }); const res = await post( "/extensions/v2/extensions/draft-ext/moderator-correct", @@ -188,7 +189,7 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { await seedPublished(); await db .prepare( - "UPDATE extensions SET delisted_at = ?, delist_reason = ? WHERE id = ?" + "UPDATE extensions SET delisted_at = ?, delist_reason = ? WHERE identifier= ?" ) .bind(new Date().toISOString(), "cause", "live-ext") .run(); @@ -212,7 +213,11 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { const res = await post(PATH, await authHeaders("mod-1"), correctBody()); expect(res.status).toBe(409); await expect(res.json()).resolves.toMatchObject({ - error: { code: "CONFLICT" } + error: { + code: "CONFLICT", + message: + "An edit to this extension is already awaiting review; approve or reject it first" + } }); // Neither the live content nor the pending edit moves. expect((await getExtension(db, "live-ext"))?.readme).toBe("old readme"); @@ -264,9 +269,18 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { ); expect(res.status).toBe(200); const body = (await res.json()) as { - result: { id: string; revision_id: string; status: string }; + result: { + id: string; + identifier: string; + revision_id: string; + status: string; + }; }; - expect(body.result).toMatchObject({ id: "live-ext", status: "approved" }); + expect(body.result).toMatchObject({ + id: expect.stringMatching(UUID_V7), + identifier: "live-ext", + status: "approved" + }); expect(typeof body.result.revision_id).toBe("string"); // No notification envelope on this route (api#251: history only). expect("notified" in body.result).toBe(false); @@ -279,7 +293,7 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { const revision = await getRevision(db, body.result.revision_id); expect(revision).toMatchObject({ - extension_id: "live-ext", + extension_id: expect.stringMatching(UUID_V7), status: "approved", submitted_by: "mod-1", reviewer_id: "mod-1", @@ -292,7 +306,8 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { // Ownership is untouched: developer, owner, and epoch survive. expect(await getDeveloper(db, "new-developer")).toMatchObject({ - id: "new-developer", + id: expect.stringMatching(UUID_V7), + identifier: "new-developer", owner_user_id: "user-1", ownership_epoch: 1 }); @@ -305,8 +320,8 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "LIVE-ext", - developer_id: "new-developer" + identifier: "LIVE-ext", + developer_identifier: "new-developer" }); const res = await post( "/extensions/v2/extensions/live-EXT/moderator-correct", @@ -315,7 +330,11 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { ); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ - result: { id: "LIVE-ext", status: "approved" } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "LIVE-ext", + status: "approved" + } }); }); @@ -329,12 +348,12 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { ); expect(created.status).toBe(201); const { result: pending } = (await created.json()) as { - result: { id: string; revision_id: string }; + result: { id: string; identifier: string; revision_id: string }; }; expect( ( await post( - `/extensions/v2/extensions/${pending.id}/revisions/${pending.revision_id}/approve?notify=false`, + `/extensions/v2/extensions/${pending.identifier}/revisions/${pending.revision_id}/approve?notify=false`, await authHeaders("mod-1"), {} ) @@ -353,7 +372,7 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { const revisions = await listRevisions(db); expect( revisions - .filter((r) => r.extension_id === "history-ext") + .filter((r) => r.extension_id === pending.id) .map((r) => r.status) .sort() ).toEqual(["approved", "approved"]); diff --git a/test/services/extensions/v2/moderation-notify.test.ts b/test/services/extensions/v2/moderation-notify.test.ts index aa99770b..52253ca3 100644 --- a/test/services/extensions/v2/moderation-notify.test.ts +++ b/test/services/extensions/v2/moderation-notify.test.ts @@ -1,3 +1,4 @@ +import { UUID_V7 } from "./db-fixtures"; import { describe, it, expect, vi, afterEach } from "vitest"; import { env } from "cloudflare:workers"; import { @@ -58,21 +59,24 @@ function stubSmtpApi(payload: unknown = { success: true, message: "sent" }) { async function seedLiveExtension(): Promise { await insertDeveloper(db, { - id: "new-developer", + identifier: "new-developer", type: "user", name: "New Developer", url: null, owner_user_id: "user-1", contact_email: "author@example.com" }); - await insertExtension(db, { id: "live-ext", developer_id: "new-developer" }); + await insertExtension(db, { + identifier: "live-ext", + developer_identifier: "new-developer" + }); } async function seedPendingClaim(): Promise { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await insertUser(db, { id: "claimant-1", email: "claimant@example.com" }); await insertDeveloper(db, { - id: "legacy-dev", + identifier: "legacy-dev", type: "user", name: "Legacy", url: null, @@ -80,7 +84,7 @@ async function seedPendingClaim(): Promise { }); await insertDeveloperClaim(db, { id: "claim-1", - developer_id: "legacy-dev", + developer_identifier: "legacy-dev", claimant_id: "claimant-1" }); } @@ -99,7 +103,12 @@ describe("moderation notification emails", () => { ); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ - result: { id: "live-ext", status: "delisted", notified: true } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "live-ext", + status: "delisted", + notified: true + } }); expect(calls).toHaveLength(1); @@ -132,7 +141,12 @@ describe("moderation notification emails", () => { ); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ - result: { id: "live-ext", status: "relisted", notified: true } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "live-ext", + status: "relisted", + notified: true + } }); expect(calls).toHaveLength(1); @@ -207,7 +221,12 @@ describe("moderation notification emails", () => { ); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ - result: { id: "live-ext", status: "delisted", notified: false } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "live-ext", + status: "delisted", + notified: false + } }); expect(calls).toHaveLength(0); }); @@ -216,15 +235,15 @@ describe("moderation notification emails", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await insertUser(db, { id: "user-1", email: "owner@example.com" }); await insertDeveloper(db, { - id: "new-developer", + identifier: "new-developer", type: "user", name: "New Developer", url: null, owner_user_id: "user-1" }); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); setEmailEnv(); const calls = stubSmtpApi(); @@ -262,7 +281,12 @@ describe("moderation notification emails", () => { // it. The provider failure is visible only in logs; the delist itself // must still succeed. await expect(res.json()).resolves.toEqual({ - result: { id: "live-ext", status: "delisted", notified: true } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "live-ext", + status: "delisted", + notified: true + } }); expect(calls).toHaveLength(1); expect((await getExtension(db, "live-ext"))?.delist_reason).toBe( @@ -277,7 +301,7 @@ describe("moderation notification emails", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await insertUser(db, { id: "user-1", email: "original@example.com" }); await insertDeveloper(db, { - id: "new-developer", + identifier: "new-developer", type: "user", name: "New Developer", url: null, @@ -290,7 +314,7 @@ describe("moderation notification emails", () => { ); expect(created.status).toBe(201); const { result } = (await created.json()) as { - result: { id: string; revision_id: string }; + result: { id: string; identifier: string; revision_id: string }; }; await bumpDeveloperOwnership(db, "new-developer", "user-2"); await insertUser(db, { id: "user-2", email: "current@example.com" }); @@ -298,7 +322,7 @@ describe("moderation notification emails", () => { const calls = stubSmtpApi(); const res = await post( - `/extensions/v2/extensions/${result.id}/revisions/${result.revision_id}/reject`, + `/extensions/v2/extensions/${result.identifier}/revisions/${result.revision_id}/reject`, await authHeaders("mod-1"), { review_note: "Needs a valid license URL" } ); @@ -350,15 +374,15 @@ describe("moderation notification emails", () => { it("reports notified:false when no address exists anywhere", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await insertDeveloper(db, { - id: "new-developer", + identifier: "new-developer", type: "user", name: "New Developer", url: null, owner_user_id: null }); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); setEmailEnv(); const calls = stubSmtpApi(); @@ -403,7 +427,7 @@ describe("moderation notification emails", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await insertUser(db, { id: "user-1", email: "owner@example.com" }); await insertDeveloper(db, { - id: "new-developer", + identifier: "new-developer", type: "user", name: "New Developer", url: null, @@ -411,8 +435,8 @@ describe("moderation notification emails", () => { contact_email: "The Owner " }); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); setEmailEnv(); const calls = stubSmtpApi(); @@ -434,7 +458,7 @@ describe("moderation notification emails", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await insertUser(db, { id: "user-1", email: "owner@example.com" }); await insertDeveloper(db, { - id: "new-developer", + identifier: "new-developer", type: "user", name: "New Developer", url: null, @@ -447,13 +471,13 @@ describe("moderation notification emails", () => { ); expect(created.status).toBe(201); const { result } = (await created.json()) as { - result: { id: string; revision_id: string }; + result: { id: string; identifier: string; revision_id: string }; }; setEmailEnv(); const calls = stubSmtpApi(); const res = await post( - `/extensions/v2/extensions/${result.id}/revisions/${result.revision_id}/approve`, + `/extensions/v2/extensions/${result.identifier}/revisions/${result.revision_id}/approve`, await authHeaders("mod-1"), { review_note: " " } ); diff --git a/test/services/extensions/v2/moderation-revalidate.test.ts b/test/services/extensions/v2/moderation-revalidate.test.ts index fdbad7ee..f2b3fb7f 100644 --- a/test/services/extensions/v2/moderation-revalidate.test.ts +++ b/test/services/extensions/v2/moderation-revalidate.test.ts @@ -1,3 +1,4 @@ +import { UUID_V7 } from "./db-fixtures"; import { describe, it, expect, vi, afterEach } from "vitest"; import { env } from "cloudflare:workers"; import { listCacheKey } from "../../../../src/services/extensions/v1/list-cache"; @@ -61,15 +62,15 @@ async function seedModAndExtension(): Promise { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await insertUser(db, { id: "user-1", email: "owner@example.com" }); await insertDeveloper(db, { - id: "new-developer", + identifier: "new-developer", type: "user", name: "New Developer", url: null, owner_user_id: "user-1" }); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); } @@ -181,12 +182,12 @@ describe("CDN cache revalidation on extension mutations", () => { ); expect(created.status).toBe(201); const { result } = (await created.json()) as { - result: { id: string; revision_id: string }; + result: { id: string; identifier: string; revision_id: string }; }; const fetcher = stubFrontend(); const res = await post( - `/extensions/v2/extensions/${result.id}/revisions/${result.revision_id}/approve?notify=false`, + `/extensions/v2/extensions/${result.identifier}/revisions/${result.revision_id}/approve?notify=false`, await authHeaders("mod-1"), {} ); @@ -216,12 +217,12 @@ describe("CDN cache revalidation on extension mutations", () => { ); expect(created.status).toBe(201); const { result } = (await created.json()) as { - result: { id: string; revision_id: string }; + result: { id: string; identifier: string; revision_id: string }; }; const fetcher = stubFrontend(); const res = await post( - `/extensions/v2/extensions/${result.id}/revisions/${result.revision_id}/reject?notify=false`, + `/extensions/v2/extensions/${result.identifier}/revisions/${result.revision_id}/reject?notify=false`, await authHeaders("mod-1"), { review_note: "Needs work" } ); @@ -233,7 +234,7 @@ describe("CDN cache revalidation on extension mutations", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await insertUser(db, { id: "claimant-1", email: "claimant@example.com" }); await insertDeveloper(db, { - id: "legacy-dev", + identifier: "legacy-dev", type: "user", name: "Legacy", url: null, @@ -241,7 +242,7 @@ describe("CDN cache revalidation on extension mutations", () => { }); await insertDeveloperClaim(db, { id: "claim-1", - developer_id: "legacy-dev", + developer_identifier: "legacy-dev", claimant_id: "claimant-1" }); const fetcher = stubFrontend(); @@ -257,15 +258,15 @@ describe("CDN cache revalidation on extension mutations", () => { it("purges after an owner withdraws an unpublished extension", async () => { await insertUser(db, { id: "user-1", email: "owner@example.com" }); await insertDeveloper(db, { - id: "new-developer", + identifier: "new-developer", type: "user", name: "New Developer", url: null, owner_user_id: "user-1" }); await insertUnpublishedExtension(db, { - id: "draft-ext", - developer_id: "new-developer" + identifier: "draft-ext", + developer_identifier: "new-developer" }); const fetcher = stubFrontend(); @@ -294,7 +295,7 @@ describe("CDN cache revalidation on extension mutations", () => { // still public content, so the delete path must purge too. await insertUser(db, { id: "user-1", email: "owner@example.com" }); await insertDeveloper(db, { - id: "doomed-dev", + identifier: "doomed-dev", type: "user", name: "Doomed", url: null, @@ -329,7 +330,11 @@ describe("CDN cache revalidation on extension mutations", () => { const res = await delist("mod-1"); expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ - result: { id: "live-ext", status: "delisted" } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "live-ext", + status: "delisted" + } }); }); diff --git a/test/services/extensions/v2/moderation-revocation.test.ts b/test/services/extensions/v2/moderation-revocation.test.ts index 4c45ff86..4d11500b 100644 --- a/test/services/extensions/v2/moderation-revocation.test.ts +++ b/test/services/extensions/v2/moderation-revocation.test.ts @@ -52,30 +52,30 @@ describe("commit-time moderator authority", () => { await insertUser(db, { id: "reviewer", is_moderator: 1 }); const claimAction = action.startsWith("claim"); await insertDeveloper(db, { - id: "review-developer", + identifier: "review-developer", type: "individual", name: "Developer", owner_user_id: claimAction ? null : "owner" }); await insertExtension(db, { - id: "review-extension", - developer_id: "review-developer" + identifier: "review-extension", + developer_identifier: "review-developer" }); await insertRevision(db, { id: "review-revision", - extension_id: "review-extension", - developer_id: "review-developer", + extension_identifier: "review-extension", + developer_identifier: "review-developer", submitted_by: "owner", content: JSON.stringify(sampleContent({ name: "Reviewed content" })) }); await insertDeveloperClaim(db, { id: "review-claim", - developer_id: "review-developer", + developer_identifier: "review-developer", claimant_id: "claimant" }); await insertDeveloperClaim(db, { id: "competing-claim", - developer_id: "review-developer", + developer_identifier: "review-developer", claimant_id: "competitor" }); const headers = await authHeaders("reviewer"); diff --git a/test/services/extensions/v2/moderation.test.ts b/test/services/extensions/v2/moderation.test.ts index e9f0a303..6d60e6ea 100644 --- a/test/services/extensions/v2/moderation.test.ts +++ b/test/services/extensions/v2/moderation.test.ts @@ -1,3 +1,4 @@ +import { UUID_V7 } from "./db-fixtures"; import { describe, it, expect, vi } from "vitest"; import { env } from "cloudflare:workers"; import { wrapD1WithHook } from "./db-interceptor"; @@ -48,9 +49,9 @@ async function createPending( ); expect(res.status).toBe(201); const { result } = (await res.json()) as { - result: { id: string; revision_id: string }; + result: { id: string; identifier: string; revision_id: string }; }; - return { id: result.id, revisionId: result.revision_id }; + return { id: result.identifier, revisionId: result.revision_id }; } function reviewPath( @@ -100,7 +101,7 @@ describe("Extensions API v2", () => { expect(data.result).toHaveLength(1); expect(data.result[0]).toMatchObject({ status: "pending", - extension_id: "new-ext" + extension_id: expect.stringMatching(UUID_V7) }); }); }); @@ -109,8 +110,8 @@ describe("Extensions API v2", () => { it("serves the public projection to unrelated callers for a published extension", async () => { await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); const res = await get( @@ -119,9 +120,9 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(200); const data = (await res.json()) as { - result: { id: string; pending_revision?: unknown }; + result: { id: string; identifier: string; pending_revision?: unknown }; }; - expect(data.result.id).toBe("live-ext"); + expect(data.result.identifier).toBe("live-ext"); expect("pending_revision" in data.result).toBe(false); }); @@ -139,8 +140,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "LIVE-ext", - developer_id: "new-developer" + identifier: "LIVE-ext", + developer_identifier: "new-developer" }); const res = await get( @@ -149,9 +150,13 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(200); const data = (await res.json()) as { - result: { id: string; published: { name: string } | null }; + result: { + id: string; + identifier: string; + published: { name: string } | null; + }; }; - expect(data.result.id).toBe("LIVE-ext"); + expect(data.result.identifier).toBe("LIVE-ext"); expect(data.result.published?.name).toBe("Extension"); }); @@ -159,8 +164,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); await post( "/extensions/v2/extensions/live-ext/delist", @@ -193,12 +198,12 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); await insertUnpublishedExtension(db, { - id: "draft-ext", - developer_id: "new-developer" + identifier: "draft-ext", + developer_identifier: "new-developer" }); await post( "/extensions/v2/extensions/live-ext/delist", @@ -207,8 +212,8 @@ describe("Extensions API v2", () => { ); await seedDeveloper("other-developer", "user-2"); await insertExtension(db, { - id: "other-ext", - developer_id: "other-developer" + identifier: "other-ext", + developer_identifier: "other-developer" }); const res = await get( @@ -216,8 +221,10 @@ describe("Extensions API v2", () => { await authHeaders("mod-1") ); expect(res.status).toBe(200); - const body = (await res.json()) as { result: Array<{ id: string }> }; - expect(body.result.map((r) => r.id)).toEqual([ + const body = (await res.json()) as { + result: Array<{ id: string; identifier: string }>; + }; + expect(body.result.map((r) => r.identifier)).toEqual([ "draft-ext", "live-ext", "other-ext" @@ -228,16 +235,16 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); await insertExtension(db, { - id: "delisted-ext", - developer_id: "new-developer" + identifier: "delisted-ext", + developer_identifier: "new-developer" }); await insertUnpublishedExtension(db, { - id: "draft-ext", - developer_id: "new-developer" + identifier: "draft-ext", + developer_identifier: "new-developer" }); await post( "/extensions/v2/extensions/delisted-ext/delist", @@ -250,7 +257,7 @@ describe("Extensions API v2", () => { await authHeaders("mod-1") ); expect(await published.json()).toMatchObject({ - result: [{ id: "live-ext" }] + result: [{ id: expect.stringMatching(UUID_V7), identifier: "live-ext" }] }); const delisted = await get( @@ -258,7 +265,9 @@ describe("Extensions API v2", () => { await authHeaders("mod-1") ); expect(await delisted.json()).toMatchObject({ - result: [{ id: "delisted-ext" }] + result: [ + { id: expect.stringMatching(UUID_V7), identifier: "delisted-ext" } + ] }); const unpublished = await get( @@ -266,7 +275,9 @@ describe("Extensions API v2", () => { await authHeaders("mod-1") ); expect(await unpublished.json()).toMatchObject({ - result: [{ id: "draft-ext" }] + result: [ + { id: expect.stringMatching(UUID_V7), identifier: "draft-ext" } + ] }); }); @@ -274,16 +285,16 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "paygate", - developer_id: "new-developer" + identifier: "paygate", + developer_identifier: "new-developer" }); await insertExtension(db, { - id: "other-gateway", - developer_id: "new-developer" + identifier: "other-gateway", + developer_identifier: "new-developer" }); await insertExtension(db, { - id: "unrelated", - developer_id: "new-developer" + identifier: "unrelated", + developer_identifier: "new-developer" }); const res = await get( @@ -291,8 +302,10 @@ describe("Extensions API v2", () => { await authHeaders("mod-1") ); expect(res.status).toBe(200); - const body = (await res.json()) as { result: Array<{ id: string }> }; - expect(body.result.map((r) => r.id).sort()).toEqual([ + const body = (await res.json()) as { + result: Array<{ id: string; identifier: string }>; + }; + expect(body.result.map((r) => r.identifier).sort()).toEqual([ "other-gateway", "paygate" ]); @@ -302,8 +315,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "pay-gate", - developer_id: "new-developer" + identifier: "pay-gate", + developer_identifier: "new-developer" }); const percentRes = await get( @@ -432,7 +445,11 @@ describe("Extensions API v2", () => { const v1Res = await get("/extensions/v1/new-ext", {}); expect(v1Res.status).toBe(200); const v1Body = (await v1Res.json()) as { - result: { id: string; author: { id: string } }; + result: { + id: string; + identifier: string; + author: { id: string; identifier: string }; + }; }; expect(v1Body.result.id).toBe("new-ext"); expect(v1Body.result.author.id).toBe("new-developer"); @@ -538,13 +555,13 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertUnpublishedExtension(db, { - id: "legacy-ext", - developer_id: "new-developer" + identifier: "legacy-ext", + developer_identifier: "new-developer" }); await insertRevision(db, { id: "legacy-revision", - extension_id: "legacy-ext", - developer_id: "new-developer", + extension_identifier: "legacy-ext", + developer_identifier: "new-developer", submitted_by: "user-1", // Carried through by migration 0021 from a submission written under // rules that required far less than today's. @@ -689,15 +706,15 @@ describe("Extensions API v2", () => { // cannot fork a second, lowercase one. it("edits a mixed-case legacy extension in place", async () => { await insertDeveloper(db, { - id: "owner-developer", + identifier: "owner-developer", type: "user", name: "Owner", url: null, owner_user_id: "owner-1" }); await insertExtension(db, { - id: "Existing-Ext", - developer_id: "owner-developer", + identifier: "Existing-Ext", + developer_identifier: "owner-developer", name: "Existing" }); await insertUser(db, { id: "mod-1", is_moderator: 1 }); @@ -840,8 +857,8 @@ describe("Extensions API v2", () => { it("requires moderator access", async () => { await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); const res = await post( @@ -867,8 +884,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertUnpublishedExtension(db, { - id: "draft-ext", - developer_id: "new-developer" + identifier: "draft-ext", + developer_identifier: "new-developer" }); const res = await post( @@ -883,8 +900,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); const res = await post( @@ -899,8 +916,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); const res = await post( @@ -915,8 +932,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "LIVE-ext", - developer_id: "new-developer", + identifier: "LIVE-ext", + developer_identifier: "new-developer", updated_at: "2020-01-01T00:00:00.000Z" }); @@ -927,7 +944,12 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ - result: { id: "live-ext", status: "delisted", notified: false } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "LIVE-ext", + status: "delisted", + notified: false + } }); // Case-insensitively addressed, like every other extension route. @@ -963,8 +985,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); const mod = await authHeaders("mod-1"); await post("/extensions/v2/extensions/live-ext/delist", mod, { @@ -989,8 +1011,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); await post( "/extensions/v2/extensions/live-ext/delist", @@ -1041,8 +1063,8 @@ describe("Extensions API v2", () => { it("requires moderator access", async () => { await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); const res = await post( @@ -1068,8 +1090,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); const res = await post( @@ -1089,8 +1111,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); const mod = await authHeaders("mod-1"); await post("/extensions/v2/extensions/live-ext/delist", mod, { @@ -1104,7 +1126,12 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ - result: { id: "live-ext", status: "relisted", notified: false } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "live-ext", + status: "relisted", + notified: false + } }); }); @@ -1112,8 +1139,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); const mod = await authHeaders("mod-1"); await post("/extensions/v2/extensions/live-ext/delist", mod, { @@ -1129,7 +1156,12 @@ describe("Extensions API v2", () => { }); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ - result: { id: "live-ext", status: "relisted", notified: false } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "live-ext", + status: "relisted", + notified: false + } }); expect(await getExtension(db, "live-ext")).toMatchObject({ @@ -1137,15 +1169,17 @@ describe("Extensions API v2", () => { }); await expect( (await get("/extensions/v2/extensions", {})).json() - ).resolves.toMatchObject({ result: [{ id: "live-ext" }] }); + ).resolves.toMatchObject({ + result: [{ id: expect.stringMatching(UUID_V7), identifier: "live-ext" }] + }); }); it("answers with the stored canonical id for a mixed-case path", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "LIVE-ext", - developer_id: "new-developer" + identifier: "LIVE-ext", + developer_identifier: "new-developer" }); const mod = await authHeaders("mod-1"); await post("/extensions/v2/extensions/live-ext/delist", mod, { @@ -1159,7 +1193,12 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ - result: { id: "LIVE-ext", status: "relisted", notified: false } + result: { + id: expect.stringMatching(UUID_V7), + identifier: "LIVE-ext", + status: "relisted", + notified: false + } }); }); @@ -1167,8 +1206,8 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); const mod = await authHeaders("mod-1"); await post("/extensions/v2/extensions/live-ext/delist", mod, { @@ -1301,10 +1340,11 @@ describe("Extensions API v2", () => { ); expect(approve.status).toBe(200); const approveBody = (await approve.json()) as { - result: { id: string; approved: boolean }; + result: { id: string; identifier: string; approved: boolean }; }; expect(approveBody.result).toEqual({ - id: "dev-developer", + id: expect.stringMatching(UUID_V7), + identifier: "dev-developer", approved: true, notified: false }); @@ -1315,9 +1355,9 @@ describe("Extensions API v2", () => { ); expect(unapproved.status).toBe(200); const unapprovedBody = (await unapproved.json()) as { - result: Array<{ id: string }>; + result: Array<{ id: string; identifier: string }>; }; - expect(unapprovedBody.result.map((a) => a.id)).not.toContain( + expect(unapprovedBody.result.map((a) => a.identifier)).not.toContain( "dev-developer" ); }); @@ -1365,17 +1405,17 @@ describe("Extensions API v2", () => { ); expect(res.status).toBe(200); const body = (await res.json()) as { - result: Array<{ id: string; approved: boolean }>; + result: Array<{ id: string; identifier: string; approved: boolean }>; }; - expect(body.result.map((a) => a.id).sort()).toEqual([ + expect(body.result.map((a) => a.identifier).sort()).toEqual([ "dev-developer", "other-developer" ]); - expect(body.result.find((a) => a.id === "dev-developer")?.approved).toBe( - true - ); expect( - body.result.find((a) => a.id === "other-developer")?.approved + body.result.find((a) => a.identifier === "dev-developer")?.approved + ).toBe(true); + expect( + body.result.find((a) => a.identifier === "other-developer")?.approved ).toBe(false); }); @@ -1402,7 +1442,9 @@ describe("Extensions API v2", () => { ); expect(scoped.status).toBe(200); await expect(scoped.json()).resolves.toMatchObject({ - result: [{ id: "dev-developer" }], + result: [ + { id: expect.stringMatching(UUID_V7), identifier: "dev-developer" } + ], pagination: { next_cursor: null, has_more: false } }); @@ -1430,10 +1472,12 @@ describe("Extensions API v2", () => { const first = await get("/extensions/v2/developers?limit=1", mod); expect(first.status).toBe(200); const firstBody = (await first.json()) as { - result: Array<{ id: string }>; + result: Array<{ id: string; identifier: string }>; pagination: { next_cursor: string | null; has_more: boolean }; }; - expect(firstBody.result.map((d) => d.id)).toEqual(["aaa-developer"]); + expect(firstBody.result.map((d) => d.identifier)).toEqual([ + "aaa-developer" + ]); expect(firstBody.pagination.has_more).toBe(true); expect(firstBody.pagination.next_cursor).toBeTruthy(); @@ -1443,10 +1487,12 @@ describe("Extensions API v2", () => { ); expect(second.status).toBe(200); const secondBody = (await second.json()) as { - result: Array<{ id: string }>; + result: Array<{ id: string; identifier: string }>; pagination: { next_cursor: string | null; has_more: boolean }; }; - expect(secondBody.result.map((d) => d.id)).toEqual(["zzz-developer"]); + expect(secondBody.result.map((d) => d.identifier)).toEqual([ + "zzz-developer" + ]); expect(secondBody.pagination).toEqual({ next_cursor: null, has_more: false @@ -1498,7 +1544,9 @@ describe("Extensions API v2", () => { }>; }; expect(data.result).toHaveLength(1); - expect(data.result[0].developer_id).toBe("dev-developer"); + expect(data.result[0].developer_id).toBe( + (await getDeveloper(db, "dev-developer"))!.id + ); expect(data.result[0].name).toBe("Dev Developer"); expect(data.result[0].changed_by).toBe("user-1"); }); @@ -1671,31 +1719,31 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { - id: "live-ext", - developer_id: "new-developer" + identifier: "live-ext", + developer_identifier: "new-developer" }); await insertExtension(db, { - id: "gone-ext", - developer_id: "new-developer", + identifier: "gone-ext", + developer_identifier: "new-developer", delisted_at: new Date().toISOString(), delist_reason: "Upstream source removed" }); await insertUnpublishedExtension(db, { - id: "draft-ext", - developer_id: "new-developer" + identifier: "draft-ext", + developer_identifier: "new-developer" }); const content = JSON.stringify(sampleContent()); await insertRevision(db, { id: "pending-revision", - extension_id: "live-ext", - developer_id: "new-developer", + extension_identifier: "live-ext", + developer_identifier: "new-developer", submitted_by: "user-1", content }); await insertRevision(db, { id: "approved-revision", - extension_id: "live-ext", - developer_id: "new-developer", + extension_identifier: "live-ext", + developer_identifier: "new-developer", submitted_by: "user-1", status: "approved", reviewer_id: "mod-1", @@ -1703,8 +1751,8 @@ describe("Extensions API v2", () => { }); await insertRevision(db, { id: "rejected-revision", - extension_id: "live-ext", - developer_id: "new-developer", + extension_identifier: "live-ext", + developer_identifier: "new-developer", submitted_by: "user-1", status: "rejected", reviewer_id: "mod-1", diff --git a/test/services/extensions/v2/ownership.test.ts b/test/services/extensions/v2/ownership.test.ts index a7ee40ea..85bcb915 100644 --- a/test/services/extensions/v2/ownership.test.ts +++ b/test/services/extensions/v2/ownership.test.ts @@ -105,9 +105,9 @@ describe("Extensions API v2", () => { ); expect(accept.status).toBe(200); const accepted = (await accept.json()) as { - result: { id: string; approved: boolean }; + result: { id: string; identifier: string; approved: boolean }; }; - expect(accepted.result.id).toBe("dev-developer"); + expect(accepted.result.identifier).toBe("dev-developer"); expect(accepted.result.approved).toBe(false); expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( "user-2" @@ -146,11 +146,13 @@ describe("Extensions API v2", () => { ) { deleted = true; await db - .prepare("DELETE FROM developer_transfers WHERE developer_id = ?") + .prepare( + "DELETE FROM developer_transfers WHERE developer_id = (SELECT id FROM developers WHERE identifier=?)" + ) .bind("dev-developer") .run(); await db - .prepare("DELETE FROM developers WHERE id = ?") + .prepare("DELETE FROM developers WHERE identifier = ?") .bind("dev-developer") .run(); } @@ -178,17 +180,17 @@ describe("Extensions API v2", () => { ); await insertDeveloperClaim(db, { id: "transfer-pending-claim", - developer_id: "dev-developer", + developer_identifier: "dev-developer", claimant_id: "user-3" }); await insertUnpublishedExtension(db, { - id: "transfer-pending-ext", - developer_id: "dev-developer" + identifier: "transfer-pending-ext", + developer_identifier: "dev-developer" }); await insertRevision(db, { id: "transfer-pending-revision", - extension_id: "transfer-pending-ext", - developer_id: "dev-developer", + extension_identifier: "transfer-pending-ext", + developer_identifier: "dev-developer", submitted_by: "user-3", content: JSON.stringify(sampleContent()) }); @@ -573,6 +575,19 @@ describe("Extensions API v2", () => { }); describe("developer claims", () => { + it("does not treat an internal UUID as a public identifier", async () => { + await seedUnownedDeveloper("legacy-developer"); + const developer = (await getDeveloper(db, "legacy-developer"))!; + const response = await post( + `/extensions/v2/developers/${developer.id}/claim`, + await authHeaders("claimant"), + {} + ); + expect(response.status).toBe(404); + expect(await countDeveloperClaims(db)).toBe(0); + expect(ghRequest).not.toHaveBeenCalled(); + }); + // The claim body is strict so the server-computed verification fields on // DeveloperClaimSchema can never be supplied by the caller. Unknown keys // are reported at the root path, since the body has no nesting. @@ -695,7 +710,7 @@ describe("Extensions API v2", () => { github_orgs_expires_at }); await insertDeveloper(db, { - id: "acme-org", + identifier: "acme-org", type: "organization", name: "Acme Org", url: null, @@ -886,7 +901,7 @@ describe("Extensions API v2", () => { // transition therefore executes first and this NOT NULL violation // proves D1 rolls it back. return db.prepare( - "UPDATE developers SET name = NULL WHERE id = 'legacy-developer'" + "UPDATE developers SET name = NULL WHERE identifier = 'legacy-developer'" ); } }); @@ -931,9 +946,9 @@ describe("Extensions API v2", () => { ); expect(approve.status).toBe(200); const approved = (await approve.json()) as { - result: { id: string; approved: boolean }; + result: { id: string; identifier: string; approved: boolean }; }; - expect(approved.result.id).toBe("legacy-developer"); + expect(approved.result.identifier).toBe("legacy-developer"); expect(approved.result.approved).toBe(false); expect((await getDeveloper(db, "legacy-developer"))?.owner_user_id).toBe( "user-1" @@ -983,7 +998,7 @@ describe("Extensions API v2", () => { it("copies the claim's GitHub verification onto the developer row it transfers ownership to", async () => { await insertDeveloper(db, { - id: "legacy-developer", + identifier: "legacy-developer", type: "organization", name: "Legacy Developer", url: null, @@ -1073,7 +1088,7 @@ describe("Extensions API v2", () => { it("verifies a claim when the claimant's linked GitHub org matches the developer id", async () => { await insertDeveloper(db, { - id: "legacy-developer", + identifier: "legacy-developer", type: "organization", name: "Legacy Developer", url: null, @@ -1100,7 +1115,7 @@ describe("Extensions API v2", () => { it("verifies a claim when the claimant's linked GitHub login matches a user-type developer id", async () => { await insertDeveloper(db, { - id: "legacy-user", + identifier: "legacy-user", type: "user", name: "Legacy User", url: null, @@ -1127,7 +1142,7 @@ describe("Extensions API v2", () => { it("blocks a claim outright when the claimant's linked GitHub identity doesn't match", async () => { await insertDeveloper(db, { - id: "legacy-developer", + identifier: "legacy-developer", type: "organization", name: "Legacy Developer", url: null, @@ -1170,7 +1185,7 @@ describe("Extensions API v2", () => { it("falls back to unverified manual review when organization membership evidence is stale", async () => { await insertDeveloper(db, { - id: "legacy-developer", + identifier: "legacy-developer", type: "organization", name: "Legacy Developer", url: null, @@ -1198,7 +1213,7 @@ describe("Extensions API v2", () => { it("does not verify an organization claim for a whitespace-only GitHub login", async () => { await insertDeveloper(db, { - id: "legacy-developer", + identifier: "legacy-developer", type: "organization", name: "Legacy Developer", url: null, diff --git a/test/services/extensions/v2/pending-edits.test.ts b/test/services/extensions/v2/pending-edits.test.ts index 41c53f6c..59e89aa7 100644 --- a/test/services/extensions/v2/pending-edits.test.ts +++ b/test/services/extensions/v2/pending-edits.test.ts @@ -75,13 +75,13 @@ describe("pending submission replacement", () => { it("lets the owner replace a publisher submission and attributes the replacement to the owner", async () => { await seedDeveloper("dev", "owner"); await insertUnpublishedExtension(db, { - id: "new-ext", - developer_id: "dev" + identifier: "new-ext", + developer_identifier: "dev" }); await insertRevision(db, { id: "publisher-revision", - extension_id: "new-ext", - developer_id: "dev", + extension_identifier: "new-ext", + developer_identifier: "dev", submitted_by: "publisher-client", content: JSON.stringify(sampleContent()) }); @@ -107,13 +107,13 @@ describe("pending submission replacement", () => { async (extensionId) => { await seedDeveloper("dev", "owner"); await insertUnpublishedExtension(db, { - id: extensionId, - developer_id: "dev" + identifier: extensionId, + developer_identifier: "dev" }); await insertRevision(db, { id: "canonical-revision", - extension_id: extensionId, - developer_id: "dev", + extension_identifier: extensionId, + developer_identifier: "dev", submitted_by: "owner", content: JSON.stringify(sampleContent()) }); @@ -124,11 +124,11 @@ describe("pending submission replacement", () => { ); expect(response.status).toBe(202); const { result } = (await response.json()) as { - result: { id: string; revision_id: string }; + result: { id: string; identifier: string; revision_id: string }; }; - expect(result.id).toBe(extensionId); + expect(result.identifier).toBe(extensionId); expect((await getRevision(db, result.revision_id))!.extension_id).toBe( - result.id + (await getExtension(db, extensionId))!.id ); } ); diff --git a/test/services/extensions/v2/public-extensions.test.ts b/test/services/extensions/v2/public-extensions.test.ts index 0c3e4302..7f784b70 100644 --- a/test/services/extensions/v2/public-extensions.test.ts +++ b/test/services/extensions/v2/public-extensions.test.ts @@ -1,3 +1,4 @@ +import { UUID_V7 } from "./db-fixtures"; import { describe, it, expect, vi } from "vitest"; import { setupExtensionsV2Tests, db, get, seedOwnedExtension } from "./harness"; import { insertDeveloper, insertExtension } from "./db-fixtures"; @@ -14,7 +15,7 @@ describe("Extensions API v2", () => { describe("GET /extensions", () => { async function seedExtensions(ids: string[]): Promise { await insertDeveloper(db, { - id: "listing-developer", + identifier: "listing-developer", type: "user", name: "Listing Developer", url: null, @@ -22,9 +23,9 @@ describe("Extensions API v2", () => { }); for (const id of ids) { await insertExtension(db, { - id, + identifier: id, type: "module", - developer_id: "listing-developer", + developer_identifier: "listing-developer", name: id, description: `Description for ${id}`, releases: '[{"tag":"1.0.0"}]', @@ -47,12 +48,13 @@ describe("Extensions API v2", () => { const body = (await res.json()) as { result: Array<{ id: string; - developer: { id: string; unclaimed: boolean }; + identifier: string; + developer: { id: string; identifier: string; unclaimed: boolean }; }>; }; expect(body.result).toHaveLength(1); - expect(body.result[0].id).toBe("existing-ext"); - expect(body.result[0].developer.id).toBe("owner-developer"); + expect(body.result[0].identifier).toBe("existing-ext"); + expect(body.result[0].developer.identifier).toBe("owner-developer"); expect(body.result[0].developer.unclaimed).toBe(false); expect(body.result[0]).not.toHaveProperty("readme"); expect(body.result[0]).not.toHaveProperty("releases"); @@ -94,14 +96,14 @@ describe("Extensions API v2", () => { await seedOwnedExtension(); const matching = await get( - "/extensions/v2/extensions?developer_id=owner-developer", + "/extensions/v2/extensions?developer_identifier=owner-developer", {} ); const matchingBody = (await matching.json()) as { result: unknown[] }; expect(matchingBody.result).toHaveLength(1); const nonMatching = await get( - "/extensions/v2/extensions?developer_id=someone-else", + "/extensions/v2/extensions?developer_identifier=someone-else", {} ); const nonMatchingBody = (await nonMatching.json()) as { @@ -115,10 +117,13 @@ describe("Extensions API v2", () => { const first = await get("/extensions/v2/extensions?limit=2", {}); const firstBody = (await first.json()) as { - result: Array<{ id: string }>; + result: Array<{ id: string; identifier: string }>; pagination: { next_cursor: string | null; has_more: boolean }; }; - expect(firstBody.result.map(({ id }) => id)).toEqual(["Alpha", "bravo"]); + expect(firstBody.result.map(({ identifier }) => identifier)).toEqual([ + "Alpha", + "bravo" + ]); expect(firstBody.pagination.has_more).toBe(true); const middle = await get( @@ -126,7 +131,7 @@ describe("Extensions API v2", () => { {} ); const middleBody = (await middle.json()) as typeof firstBody; - expect(middleBody.result.map(({ id }) => id)).toEqual([ + expect(middleBody.result.map(({ identifier }) => identifier)).toEqual([ "charlie", "delta" ]); @@ -137,7 +142,9 @@ describe("Extensions API v2", () => { {} ); const finalBody = (await final.json()) as typeof firstBody; - expect(finalBody.result.map(({ id }) => id)).toEqual(["echo"]); + expect(finalBody.result.map(({ identifier }) => identifier)).toEqual([ + "echo" + ]); expect(finalBody.pagination).toEqual({ next_cursor: null, has_more: false @@ -174,7 +181,7 @@ describe("Extensions API v2", () => { ); expect(second.status).toBe(200); expect(await second.json()).toMatchObject({ - result: [{ id: "😀" }], + result: [{ id: expect.stringMatching(UUID_V7), identifier: "😀" }], pagination: { next_cursor: null, has_more: false } }); }); @@ -199,10 +206,11 @@ describe("Extensions API v2", () => { const body = (await res.json()) as { result: { id: string; + identifier: string; developer: { name: string; approved: boolean; unclaimed: boolean }; }; }; - expect(body.result.id).toBe("existing-ext"); + expect(body.result.identifier).toBe("existing-ext"); expect(body.result.developer.name).toBe("Owner"); expect(body.result.developer.approved).toBe(false); expect(body.result.developer.unclaimed).toBe(false); @@ -224,15 +232,15 @@ describe("Extensions API v2", () => { it("404s for a delisted extension, even though it is still published", async () => { await insertDeveloper(db, { - id: "listing-developer", + identifier: "listing-developer", type: "user", name: "Listing Developer", url: null, owner_user_id: null }); await insertExtension(db, { - id: "delisted-ext", - developer_id: "listing-developer", + identifier: "delisted-ext", + developer_identifier: "listing-developer", delisted_at: "2026-01-01T00:00:00.000Z", delist_reason: "Upstream source removed" }); diff --git a/test/services/extensions/v2/publisher.test.ts b/test/services/extensions/v2/publisher.test.ts index 51c4b565..72baaec4 100644 --- a/test/services/extensions/v2/publisher.test.ts +++ b/test/services/extensions/v2/publisher.test.ts @@ -15,6 +15,7 @@ import { } from "./harness"; import { insertUser, + getDeveloper, insertExtension, bumpDeveloperOwnership } from "./db-fixtures"; @@ -54,7 +55,10 @@ async function registerPublisher(canPublish = true): Promise { return put( `/extensions/v2/m2m/clients/${CLIENT}`, await authHeaders(MODERATOR), - { developer_id: DEVELOPER, can_publish: canPublish } + { + developer_id: (await getDeveloper(db, DEVELOPER))!.id, + can_publish: canPublish + } ); } @@ -80,7 +84,12 @@ describe("m2m publisher registration", () => { const res = await put( `/extensions/v2/m2m/clients/${CLIENT}`, await authHeaders(OWNER), - { developer_id: DEVELOPER, can_publish: true } + { + developer_id: + (await getDeveloper(db, DEVELOPER))?.id ?? + "019a0000-0000-7000-8000-000000000001", + can_publish: true + } ); expect(res.status).toBe(403); }); @@ -100,7 +109,7 @@ describe("m2m publisher registration", () => { }; expect(body.result).toMatchObject({ client_id: CLIENT, - developer_id: DEVELOPER, + developer_id: (await getDeveloper(db, DEVELOPER))!.id, can_publish: true, revoked_at: null }); @@ -117,13 +126,19 @@ describe("m2m publisher registration", () => { const missing = await put( `/extensions/v2/m2m/clients/${CLIENT}`, await authHeaders(MODERATOR), - { developer_id: "no-such-developer", can_publish: true } + { + developer_id: "019a0000-0000-7000-8000-000000000001", + can_publish: true + } ); expect(missing.status).toBe(404); const unclaimed = await put( `/extensions/v2/m2m/clients/${CLIENT}`, await authHeaders(MODERATOR), - { developer_id: "unclaimed-dev", can_publish: true } + { + developer_id: (await getDeveloper(db, "unclaimed-dev"))!.id, + can_publish: true + } ); expect(unclaimed.status).toBe(404); }); @@ -168,9 +183,11 @@ describe("m2m publisher writes", () => { }; const revision = await revisionRow(body.result.revision_id); expect(revision?.submitted_by).toBe(`m2m:${CLIENT}`); - expect(revision?.developer_id).toBe(DEVELOPER); + expect(revision?.developer_id).toBe( + (await getDeveloper(db, DEVELOPER))!.id + ); const ext = await db - .prepare("SELECT created_by FROM extensions WHERE id = ?") + .prepare("SELECT created_by FROM extensions WHERE identifier= ?") .bind("published-ext") .first<{ created_by: string }>(); expect(ext?.created_by).toBe(OWNER); @@ -179,8 +196,8 @@ describe("m2m publisher writes", () => { it("proposes an edit to an extension of the mapped developer", async () => { await registerPublisher(); await insertExtension(db, { - id: "existing-ext", - developer_id: DEVELOPER, + identifier: "existing-ext", + developer_identifier: DEVELOPER, name: "Existing", source: '{"type":"github","repo":"example/existing"}' }); @@ -195,12 +212,21 @@ describe("m2m publisher writes", () => { }; const revision = await revisionRow(body.result.revision_id); expect(revision?.submitted_by).toBe(`m2m:${CLIENT}`); + const repeated = await put( + "/extensions/v2/extensions/existing-ext", + await m2mHeaders(["extensions:push"]), + sampleContent({ name: "Another Edit" }) + ); + expect(repeated.status).toBe(409); + await expect(repeated.json()).resolves.toMatchObject({ + error: { message: "An edit to this extension is already awaiting review" } + }); }); it("rejects writes for an unregistered client", async () => { await insertExtension(db, { - id: "existing-ext", - developer_id: DEVELOPER, + identifier: "existing-ext", + developer_identifier: DEVELOPER, name: "Existing", source: '{"type":"github","repo":"example/existing"}' }); @@ -338,7 +364,7 @@ describe("m2m publisher auto-approval", () => { ); const ext = await db .prepare( - "SELECT published_at, published_revision_id FROM extensions WHERE id = ?" + "SELECT published_at, published_revision_id FROM extensions WHERE identifier= ?" ) .bind("auto-published") .first<{ published_at: string | null; published_revision_id: string }>(); diff --git a/test/services/extensions/v2/resource-bounds.test.ts b/test/services/extensions/v2/resource-bounds.test.ts index 6d031ced..b8ab4732 100644 --- a/test/services/extensions/v2/resource-bounds.test.ts +++ b/test/services/extensions/v2/resource-bounds.test.ts @@ -1,3 +1,4 @@ +import { UUID_V7 } from "./db-fixtures"; import { describe, expect, it, vi } from "vitest"; import { createExecutionContext, @@ -39,6 +40,7 @@ import { } from "./harness"; import { insertExtension, + getDeveloper, insertRevision, insertUser, countExtensions, @@ -63,12 +65,15 @@ async function withdraw(id: string, user = "owner") { async function create(id: string) { return post("/extensions/v2/extensions", await authHeaders("owner"), { ...sampleCreate(), - id + identifier: id }); } async function owned() { await seedDeveloper("developer", "owner"); - await insertExtension(db, { id: "live", developer_id: "developer" }); + await insertExtension(db, { + identifier: "live", + developer_identifier: "developer" + }); } async function ageEvents(seconds = 120) { await db @@ -79,8 +84,8 @@ async function ageEvents(seconds = 120) { async function insertHistory(id: string, status = "rejected") { await insertRevision(db, { id, - extension_id: "live", - developer_id: "developer", + extension_identifier: "live", + developer_identifier: "developer", submitted_by: "owner", content: JSON.stringify(sampleContent()), status, @@ -130,11 +135,13 @@ describe("Extension resource admission", () => { it("keeps oversized published extensions visible as published cards", async () => { await owned(); await db - .prepare("UPDATE extensions SET readme=? WHERE id='live'") + .prepare("UPDATE extensions SET readme=? WHERE identifier='live'") .bind("x".repeat(MAX_CONTENT_BYTES + 1)) .run(); const extensions = new ExtensionsDatabase(getExtensionsDb(db)); - const list = await extensions.listOwned({ developerId: "developer" }); + const list = await extensions.listOwned({ + developerId: (await getDeveloper(db, "developer"))!.id + }); expect(list.error).toBeNull(); expect(list.data?.items[0].published).not.toBeNull(); expect( @@ -174,8 +181,8 @@ describe("Extension resource admission", () => { .first() ).toEqual({ submitted_by: "owner", - developer_id: "developer", - extension_id: "live", + developer_id: expect.stringMatching(UUID_V7), + extension_id: expect.stringMatching(UUID_V7), id: "fixed-attribution" }); }); @@ -256,7 +263,9 @@ describe("Extension resource admission", () => { ]; for (let i = 0; i < columns.length; i++) { await db - .prepare(`UPDATE extensions SET ${columns[i]}=? WHERE id='live'`) + .prepare( + `UPDATE extensions SET ${columns[i]}=? WHERE identifier='live'` + ) .bind(values[i]) .run(); await reconcile(); @@ -270,7 +279,7 @@ describe("Extension resource admission", () => { .bind(JSON.stringify({ ...content, readme: "😀" })) .run(); await reconcile(); - await db.prepare("DELETE FROM extensions WHERE id='live'").run(); + await db.prepare("DELETE FROM extensions WHERE identifier='live'").run(); await reconcile(); }); it("preserves overflow status when stream cancellation rejects", async () => { @@ -295,7 +304,7 @@ describe("Extension resource admission", () => { it("accepts a valid JSON body delivered in small chunks", async () => { await owned(); const bytes = new TextEncoder().encode( - JSON.stringify({ ...sampleCreate(), id: "chunked" }) + JSON.stringify({ ...sampleCreate(), identifier: "chunked" }) ); let position = 0; const stream = new ReadableStream({ @@ -320,7 +329,10 @@ describe("Extension resource admission", () => { await owned(); const id = "e".repeat(201); expect((await create(id)).status).toBe(422); - await insertExtension(db, { id, developer_id: "developer" }); + await insertExtension(db, { + identifier: id, + developer_identifier: "developer" + }); expect((await get(`/extensions/v2/extensions/${id}`, {})).status).toBe(200); expect( ( @@ -380,7 +392,7 @@ describe("Extension resource admission", () => { }); it("accepts the exact raw-byte cap and rejects one byte more without Content-Length", async () => { await seedDeveloper("developer", "owner"); - const body = JSON.stringify({ ...sampleCreate(), id: "exact" }); + const body = JSON.stringify({ ...sampleCreate(), identifier: "exact" }); const padded = body + " ".repeat( @@ -526,7 +538,9 @@ describe("Extension resource admission", () => { expect((await create(`churn-${i}`)).status).toBe(201); expect((await withdraw(`churn-${i}`)).status).toBe(200); } - await db.prepare("DELETE FROM developers WHERE id='developer'").run(); + await db + .prepare("DELETE FROM developers WHERE identifier = 'developer'") + .run(); await seedDeveloper("replacement", "owner"); await db .prepare("UPDATE users SET deleted_at=CURRENT_TIMESTAMP WHERE id='owner'") @@ -605,14 +619,25 @@ describe("Extension resource admission", () => { .prepare( `SELECT ${column} AS value FROM extension_resource_usage WHERE scope=? AND subject=?` ) - .bind(scope, subject) + .bind( + scope, + scope === "developer" + ? (await getDeveloper(db, subject))!.id + : subject + ) .first("value"); try { await db .prepare( `UPDATE extension_resource_usage SET ${column}=? WHERE scope=? AND subject=?` ) - .bind(limit, scope, subject) + .bind( + limit, + scope, + scope === "developer" + ? (await getDeveloper(db, subject))!.id + : subject + ) .run(); expect((await create("blocked")).status).toBe(409); expect(await countExtensions(db)).toBe(1); @@ -624,7 +649,13 @@ describe("Extension resource admission", () => { .prepare( `UPDATE extension_resource_usage SET ${column}=? WHERE scope=? AND subject=?` ) - .bind(original, scope, subject) + .bind( + original, + scope, + scope === "developer" + ? (await getDeveloper(db, subject))!.id + : subject + ) .run(); } } @@ -635,7 +666,7 @@ describe("Extension resource admission", () => { expect((await create("publication")).status).toBe(201); const revision = await db .prepare( - "SELECT id FROM extension_revisions WHERE extension_id='publication'" + "SELECT id FROM extension_revisions WHERE extension_id=(SELECT id FROM extensions WHERE identifier='publication')" ) .first("id"); await db @@ -661,14 +692,20 @@ describe("Extension resource admission", () => { ).toBe("pending"); expect( await db - .prepare("SELECT published_at FROM extensions WHERE id='publication'") + .prepare( + "SELECT published_at FROM extensions WHERE identifier='publication'" + ) .first("published_at") ).toBeNull(); }); it("accounts UTF-8 bytes exactly and releases them transactionally without shifting account charges", async () => { await seedDeveloper("developer", "owner"); - const content = { ...sampleCreate(), id: "multibyte", readme: "😀é" }; - const { id: _id, ...stored } = content; + const content = { + ...sampleCreate(), + identifier: "multibyte", + readme: "😀é" + }; + const { identifier: _identifier, ...stored } = content; expect( ( await post( @@ -689,7 +726,7 @@ describe("Extension resource admission", () => { await insertUser(db, { id: "recipient" }); await db .prepare( - "UPDATE developers SET owner_user_id='recipient' WHERE id='developer'" + "UPDATE developers SET owner_user_id='recipient' WHERE identifier = 'developer'" ) .run(); expect( @@ -774,8 +811,8 @@ describe("Bounded revision reads and maintenance", () => { ] as const) { await insertRevision(db, { id, - extension_id: "live", - developer_id: "developer", + extension_identifier: "live", + developer_identifier: "developer", submitted_by: "owner", content: JSON.stringify({ ...sampleContent(), releases }), status: "rejected", @@ -805,8 +842,8 @@ describe("Bounded revision reads and maintenance", () => { await owned(); await insertRevision(db, { id: "unicode-tag", - extension_id: "live", - developer_id: "developer", + extension_identifier: "live", + developer_identifier: "developer", submitted_by: "owner", content: JSON.stringify({ releases: [{ ...sampleContent().releases[0], tag: "😀".repeat(100) }] @@ -827,21 +864,21 @@ describe("Bounded revision reads and maintenance", () => { for (const id of ["old", "published"]) await insertHistory(id, "approved"); await db .prepare( - "UPDATE extensions SET published_revision_id='published' WHERE id='live'" + "UPDATE extensions SET published_revision_id='published' WHERE identifier='live'" ) .run(); await insertRevision(db, { id: "pending", - extension_id: "live", - developer_id: "developer", + extension_identifier: "live", + developer_identifier: "developer", submitted_by: "owner", content: JSON.stringify(sampleContent()), created_at: "2000-01-01 00:00:00" }); await insertRevision(db, { id: "recent", - extension_id: "live", - developer_id: "developer", + extension_identifier: "live", + developer_identifier: "developer", submitted_by: "owner", content: JSON.stringify(sampleContent()), status: "rejected", @@ -931,7 +968,10 @@ describe("Bounded revision reads and maintenance", () => { it("bounds response amplification for 100 maximum-sized bodies", async () => { await owned(); await seedDeveloper("second", "second-owner"); - await insertExtension(db, { id: "second-live", developer_id: "second" }); + await insertExtension(db, { + identifier: "second-live", + developer_identifier: "second" + }); await insertUser(db, { id: "mod", is_moderator: 1 }); const content = { ...sampleContent(), @@ -947,8 +987,8 @@ describe("Bounded revision reads and maintenance", () => { for (let i = 0; i < 100; i++) await insertRevision(db, { id: `max-${i}`, - extension_id: i < 50 ? "live" : "second-live", - developer_id: i < 50 ? "developer" : "second", + extension_identifier: i < 50 ? "live" : "second-live", + developer_identifier: i < 50 ? "developer" : "second", submitted_by: i < 50 ? "owner" : "second-owner", content: JSON.stringify(content), status: "rejected", @@ -1085,7 +1125,7 @@ describe("Bounded revision reads and maintenance", () => { async (source, type) => { await owned(); await db - .prepare("UPDATE extensions SET source=? WHERE id='live'") + .prepare("UPDATE extensions SET source=? WHERE identifier='live'") .bind(JSON.stringify(source)) .run(); const extensions = new ExtensionsDatabase(getExtensionsDb(db)); @@ -1095,7 +1135,7 @@ describe("Bounded revision reads and maintenance", () => { expect(publicCard?.source).toEqual({ type, repo: "example/repo" }); expect(ExtensionListItemSchema.safeParse(publicCard).success).toBe(true); const ownerCards = await extensions.listOwned({ - developerId: "developer" + developerId: (await getDeveloper(db, "developer"))!.id }); expect(ownerCards.error).toBeNull(); const ownerCard = ownerCards.data?.items[0]; @@ -1106,7 +1146,9 @@ describe("Bounded revision reads and maintenance", () => { expect(OwnedExtensionListItemSchema.safeParse(ownerCard).success).toBe( true ); - expect((await extensions.getById("live")).data?.source).toEqual(source); + expect((await extensions.getByIdentifier("live")).data?.source).toEqual( + source + ); } ); it("preserves under-limit legacy fields in public and owner details", async () => { @@ -1116,7 +1158,7 @@ describe("Bounded revision reads and maintenance", () => { const source = { type: "custom", repo: "R".repeat(5000) }; await db .prepare( - "UPDATE extensions SET website=?, license=?, source=? WHERE id='live'" + "UPDATE extensions SET website=?, license=?, source=? WHERE identifier='live'" ) .bind(website, JSON.stringify(license), JSON.stringify(source)) .run(); @@ -1130,7 +1172,7 @@ describe("Bounded revision reads and maintenance", () => { license: "L".repeat(1000), source: { type: "custom", repo: "R".repeat(500) } }); - const publicDetail = await extensions.getById("live"); + const publicDetail = await extensions.getByIdentifier("live"); expect(publicDetail.error).toBeNull(); expect(publicDetail.data).toMatchObject({ homepage_url: website, @@ -1151,12 +1193,12 @@ describe("Bounded revision reads and maintenance", () => { const download = "https://example.test/" + "y".repeat(3000); await db .prepare( - "UPDATE extensions SET website=?,download_url=?,releases=? WHERE id='live'" + "UPDATE extensions SET website=?,download_url=?,releases=? WHERE identifier='live'" ) .bind(homepage, download, JSON.stringify(sampleContent().releases)) .run(); const extensions = new ExtensionsDatabase(getExtensionsDb(db)); - const detail = await extensions.getById("live"); + const detail = await extensions.getByIdentifier("live"); expect(detail.error).toBeNull(); expect(ExtensionSchema.safeParse(detail.data).success).toBe(true); expect(detail.data).toMatchObject({ From 2cf872ede3c0246115169265c975d34622fe2223 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 17:38:28 +0000 Subject: [PATCH 2/4] Address directory identity review findings --- src/services/extensions/v2/README.md | 12 ++-- .../extensions/v2/db/developer-profiles.ts | 11 +--- src/services/extensions/v2/db/extensions.ts | 4 +- .../db/migrations/0030_directory_identity.sql | 11 +++- src/services/extensions/v2/db/revisions.ts | 18 +++--- src/services/extensions/v2/email/notify.ts | 2 +- .../extensions/v2/email/recipients.ts | 7 +-- .../extensions/v2/routes/moderation.ts | 10 ++- .../extensions/v2/routes/owner-extensions.ts | 2 +- .../extensions/v2/schemas/extensions.ts | 6 +- test/services/extensions/v2/contract.test.ts | 21 +++++-- test/services/extensions/v2/db-fixtures.ts | 6 +- .../extensions/v2/developer-profiles.test.ts | 4 +- .../extensions/v2/directory-identity.test.ts | 1 + .../extensions/v2/extension-writes.test.ts | 3 + .../services/extensions/v2/migrations.test.ts | 34 ++++++++++ .../extensions/v2/moderation-notify.test.ts | 2 + .../services/extensions/v2/moderation.test.ts | 63 ++++++++++++++++--- .../extensions/v2/public-extensions.test.ts | 14 +++++ test/services/extensions/v2/publisher.test.ts | 5 +- 20 files changed, 179 insertions(+), 57 deletions(-) diff --git a/src/services/extensions/v2/README.md b/src/services/extensions/v2/README.md index ce2c2efb..7c476811 100644 --- a/src/services/extensions/v2/README.md +++ b/src/services/extensions/v2/README.md @@ -8,7 +8,7 @@ This service owns the complete Extensions domain and its `DB_EXTENSIONS` schema. ## Directory Identity -Extensions and developers have generated UUIDv7 `id` primary keys and separate, globally unique `identifier` fields. Database relationships and machine publisher bindings use UUIDs. Public profile/extension paths use identifiers; `name` is the editable display name. Creation requests supply `identifier`, never the internal ID. The package manifest's `id` remains the public extension identifier. +Extensions and developers have generated UUIDv7 `id` primary keys and separate, globally unique `identifier` fields. Database relationships and machine publisher bindings use UUIDs. Public profile/extension paths use identifiers; the moderator-only `/developers/{id}/history` path uses the internal UUID so audit records remain accessible after profile deletion; `name` is the editable display name. Creation requests supply `identifier`, never the internal ID. The package manifest's `id` remains the public extension identifier. ## Endpoints @@ -41,7 +41,7 @@ For example, after checking an affected package's actual requirements, an operat UPDATE extensions SET releases = json_set(releases, '$[0].requires', json('{"fossbilling":">=0.8.9","php":">=8.3"}')) -WHERE identifier = 'affected-extension'; +WHERE id = 'affected-extension'; ``` Use the ID, index, and verified constraints from the preflight investigation; the values above are examples. Re-run the query until it returns no rows. @@ -60,21 +60,21 @@ The frontend revalidation tags are `extensions` and `developers`, shared with th An extension exists from `POST /extensions`, not from approval. There is no separate submission record. -- `POST /extensions` creates the record and reserves its id. It stays out of public listings until its first revision is approved. +- `POST /extensions` creates the record and reserves its public identifier. It stays out of public listings until its first revision is approved. - `PUT /extensions/{id}` proposes an edit. Published content is unchanged until approval. One unreviewed revision per extension at a time. - `PUT /extensions/{id}/revisions/{revisionId}` lets the active owner replace that pending revision; see [Editing a pending submission](#editing-a-pending-submission). -- `DELETE /extensions/{id}` withdraws an unpublished extension and releases its id. Published extensions cannot be withdrawn by owners. +- `DELETE /extensions/{id}` withdraws an unpublished extension and releases its public identifier. Published extensions cannot be withdrawn by owners. - `POST /extensions/{id}/revisions/{revisionId}/approve` publishes. `reject` leaves published content untouched. - `POST /extensions/{id}/delist` (moderator-only) hides a published extension for cause without deleting content or history. `POST /extensions/{id}/relist` restores it. Both accept `?notify=false`. - `POST /extensions/{id}/moderator-correct` (moderator-only) fixes live extension content corruption without waiting for the author. Writes an already-approved revision, leaves `published_at` and ownership untouched. Requires a published, listed extension with no pending revision (409 otherwise). No `?notify` option and no author email; the fix is recorded in revision history. -An edit cannot rename an extension or move it to another developer. A user owns at most one developer profile, so no request body names one. +An edit can change the display `name`, but cannot change the public `identifier` or move the extension to another developer. A user owns at most one developer profile, so no request body names one. ### Editing a pending submission `PUT /extensions/{id}/revisions/{revisionId}` replaces pending content using the same body as `PUT /extensions/{id}`. Only an active human owner can call it, including for publisher-created submissions. The replacement records that owner as `submitted_by` and counts toward their pending-revision and write limits. -The response is `202` with a new `revision_id` and the extension's canonical `id` casing. The supplied revision must still be pending at the current ownership epoch. A stale or reviewed revision returns `409`; refresh before retrying. +The response is `202` with a new `revision_id` and the extension's canonical `identifier` casing. The supplied revision must still be pending at the current ownership epoch. A stale or reviewed revision returns `409`; refresh before retrying. Deletion and insertion run in one transaction: a failed write keeps the original pending revision. The new ID prevents stale edits or moderator decisions from applying to the replacement. Published content and reviewed history stay unchanged. diff --git a/src/services/extensions/v2/db/developer-profiles.ts b/src/services/extensions/v2/db/developer-profiles.ts index 336a4c0b..814e7659 100644 --- a/src/services/extensions/v2/db/developer-profiles.ts +++ b/src/services/extensions/v2/db/developer-profiles.ts @@ -921,12 +921,7 @@ export class DeveloperProfilesDatabase { > { const limit = page?.limit ?? 50; const decoded = page?.cursor ? historyCursor.decode(page.cursor) : null; - // Case-insensitive like the id matching everywhere else: ids are - // lowercase slugs by schema, but adopted rows predate that. - if ( - page?.cursor && - (!decoded || decoded.d.toLowerCase() !== developerId.toLowerCase()) - ) { + if (page?.cursor && (!decoded || decoded.d !== developerId)) { return { data: null, error: { message: "Invalid pagination cursor", code: "INVALID_CURSOR" } @@ -942,9 +937,7 @@ export class DeveloperProfilesDatabase { let rows; try { - const conditions = [ - sql`${developerHistory.developerId} = (SELECT id FROM developers WHERE identifier=${developerId})` - ]; + const conditions = [eq(developerHistory.developerId, developerId)]; if (decoded) { conditions.push( or( diff --git a/src/services/extensions/v2/db/extensions.ts b/src/services/extensions/v2/db/extensions.ts index 435c9d15..7c5359f8 100644 --- a/src/services/extensions/v2/db/extensions.ts +++ b/src/services/extensions/v2/db/extensions.ts @@ -305,7 +305,9 @@ type ExtensionCursor = { // Public identifiers order the directory; the UUID breaks ties. const extensionCursor = createCursorCodec( (parsed): parsed is ExtensionCursor => - typeof parsed.id === "string" && typeof parsed.identifier === "string" + typeof parsed.id === "string" && + typeof parsed.identifier === "string" && + parsed.identifier === parsed.identifier.toLowerCase() ); export interface CreateExtensionInput { diff --git a/src/services/extensions/v2/db/migrations/0030_directory_identity.sql b/src/services/extensions/v2/db/migrations/0030_directory_identity.sql index 785b32b3..0647b7a2 100644 --- a/src/services/extensions/v2/db/migrations/0030_directory_identity.sql +++ b/src/services/extensions/v2/db/migrations/0030_directory_identity.sql @@ -2,7 +2,12 @@ -- Rebuild children before parents so D1 can enforce foreign keys throughout. CREATE TABLE _developers_ids AS SELECT id AS identifier, substr(timestamp,1,8)||'-'||substr(timestamp,9,4)||'-7'||substr(entropy,1,3)||'-'||substr('89ab',1+(abs(random() % 4)),1)||substr(entropy,4,3)||'-'||substr(entropy,7,12) AS id - FROM (SELECT id,printf('%012x',CAST(unixepoch(created_at)*1000 AS INTEGER)) AS timestamp,lower(hex(randomblob(9))) AS entropy FROM developers); + FROM (SELECT id,printf('%012x',CAST(unixepoch(created_at)*1000 AS INTEGER)) AS timestamp,lower(hex(randomblob(9))) AS entropy FROM ( + SELECT id,created_at FROM developers + UNION ALL + SELECT developer_id, min(changed_at) FROM developer_history + WHERE developer_id NOT IN (SELECT id FROM developers) GROUP BY developer_id + )); --> statement-breakpoint CREATE TABLE _extensions_ids AS SELECT id AS identifier, substr(timestamp,1,8)||'-'||substr(timestamp,9,4)||'-7'||substr(entropy,1,3)||'-'||substr('89ab',1+(abs(random() % 4)),1)||substr(entropy,4,3)||'-'||substr(entropy,7,12) AS id @@ -284,9 +289,9 @@ CREATE INDEX idx_extension_revisions_retention ON extension_revisions(compacted_ --> statement-breakpoint CREATE INDEX `idx_m2m_publisher_clients_developer` ON `m2m_publisher_clients` (`developer_id`); --> statement-breakpoint -CREATE INDEX idx_extensions_public_order ON extensions (lower(id),id) WHERE published_at IS NOT NULL AND delisted_at IS NULL; +CREATE INDEX idx_extensions_public_order ON extensions (lower(identifier),id) WHERE published_at IS NOT NULL AND delisted_at IS NULL; --> statement-breakpoint -CREATE INDEX idx_extensions_type_public_order ON extensions (type,lower(id),id) WHERE published_at IS NOT NULL AND delisted_at IS NULL; +CREATE INDEX idx_extensions_type_public_order ON extensions (type,lower(identifier),id) WHERE published_at IS NOT NULL AND delisted_at IS NULL; --> statement-breakpoint CREATE UNIQUE INDEX idx_developers_identifier_nocase ON developers(lower(identifier)); --> statement-breakpoint diff --git a/src/services/extensions/v2/db/revisions.ts b/src/services/extensions/v2/db/revisions.ts index 01aff795..88a288e2 100644 --- a/src/services/extensions/v2/db/revisions.ts +++ b/src/services/extensions/v2/db/revisions.ts @@ -146,12 +146,12 @@ export class ExtensionRevisionsDatabase { extensionId: string; callerId: string; content: ExtensionContent; - }): Promise> { + }): Promise> { const id = crypto.randomUUID(); let result; try { - result = await this.db.run(sql` + result = await this.db.all<{ identifier: string }>(sql` INSERT INTO ${extensionRevisions} (id, extension_id, developer_id, submitted_by, status, content, ownership_epoch) SELECT ${id}, e.id, d.id, ${input.callerId}, 'pending', ${JSON.stringify(input.content)}, d.ownership_epoch @@ -168,12 +168,13 @@ export class ExtensionRevisionsDatabase { WHERE submitted_by = ${input.callerId} AND status = 'pending' ) < ${MAX_PENDING_REVISIONS_PER_USER} ON CONFLICT DO NOTHING + RETURNING (SELECT identifier FROM extensions WHERE id=extension_id) AS identifier `); } catch (error) { return contentAdmissionError("propose", error); } - if (!result.meta?.changes) { + if (!result[0]) { try { return { data: null, error: await this.proposeBlockedError(input) }; } catch (error) { @@ -181,7 +182,7 @@ export class ExtensionRevisionsDatabase { } } - return { data: { id }, error: null }; + return { data: { id, identifier: result[0].identifier }, error: null }; } // Owners can replace publisher submissions; attribute the new row to the owner. @@ -329,12 +330,12 @@ export class ExtensionRevisionsDatabase { submittedBy: string; developerId: string; content: ExtensionContent; - }): Promise> { + }): Promise> { const id = crypto.randomUUID(); let result; try { - result = await this.db.run(sql` + result = await this.db.all<{ identifier: string }>(sql` INSERT INTO ${extensionRevisions} (id, extension_id, developer_id, submitted_by, status, content, ownership_epoch) SELECT ${id}, e.id, d.id, ${input.submittedBy}, 'pending', ${JSON.stringify(input.content)}, d.ownership_epoch @@ -362,12 +363,13 @@ export class ExtensionRevisionsDatabase { WHERE submitted_by = ${input.submittedBy} AND status = 'pending' ) < ${MAX_PENDING_REVISIONS_PER_USER} ON CONFLICT DO NOTHING + RETURNING (SELECT identifier FROM extensions WHERE id=extension_id) AS identifier `); } catch (error) { return contentAdmissionError("proposeAsPublisher", error); } - if (!result.meta?.changes) { + if (!result[0]) { try { return { data: null, @@ -378,7 +380,7 @@ export class ExtensionRevisionsDatabase { } } - return { data: { id }, error: null }; + return { data: { id, identifier: result[0].identifier }, error: null }; } private async proposeAsPublisherBlockedError(input: { diff --git a/src/services/extensions/v2/email/notify.ts b/src/services/extensions/v2/email/notify.ts index 94126cac..3ee434c1 100644 --- a/src/services/extensions/v2/email/notify.ts +++ b/src/services/extensions/v2/email/notify.ts @@ -81,7 +81,7 @@ export async function sendModerationNotification( db, input.developerId ); - if (!recipient) { + if (!recipient?.to) { logError("email", "No address for developer notification", { developerId: input.developerId, kind: input.kind diff --git a/src/services/extensions/v2/email/recipients.ts b/src/services/extensions/v2/email/recipients.ts index d50f9d8e..6c696226 100644 --- a/src/services/extensions/v2/email/recipients.ts +++ b/src/services/extensions/v2/email/recipients.ts @@ -47,8 +47,8 @@ export async function resolveExtensionEmail( .from(extensions) .innerJoin(developers, eq(extensions.developerId, developers.id)) .leftJoin(users, eq(developers.ownerUserId, users.id)) - // Moderation routes pass the raw URL param, and ids are matched - // case-insensitively everywhere else (LOWER(id) = LOWER(id)) - mixed + // Moderation routes pass the raw URL param, and public identifiers are + // matched case-insensitively (LOWER(identifier)) - mixed // case is a supported input, not a miss. .where(sql`LOWER(${extensions.identifier}) = LOWER(${extensionId})`); @@ -72,7 +72,7 @@ export async function resolveDeveloperProfileEmail( db: ExtensionsDb, developerId: string ): Promise<{ - to: string; + to: string | null; developerIdentifier: string; developerName?: string; } | null> { @@ -91,7 +91,6 @@ export async function resolveDeveloperProfileEmail( if (!row) return null; const to = pickEmail(row.contactEmail, row.accountEmail); - if (!to) return null; return { to, developerIdentifier: row.developerIdentifier, diff --git a/src/services/extensions/v2/routes/moderation.ts b/src/services/extensions/v2/routes/moderation.ts index a86de375..e6a2dff7 100644 --- a/src/services/extensions/v2/routes/moderation.ts +++ b/src/services/extensions/v2/routes/moderation.ts @@ -501,7 +501,15 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { summary: "List the write history of a developer profile", security: [{ Bearer: [] }], middleware: [requireModerator()] as const, - request: { params: IdParamSchema, query: CursorPaginationQuerySchema }, + request: { + params: z.object({ + id: z + .uuidv7() + .toLowerCase() + .openapi({ description: "Internal developer UUID" }) + }), + query: CursorPaginationQuerySchema + }, responses: { 200: { content: { diff --git a/src/services/extensions/v2/routes/owner-extensions.ts b/src/services/extensions/v2/routes/owner-extensions.ts index 53768b9b..0b0adb05 100644 --- a/src/services/extensions/v2/routes/owner-extensions.ts +++ b/src/services/extensions/v2/routes/owner-extensions.ts @@ -281,7 +281,7 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { return c.json( { result: { - identifier: id, + identifier: data.identifier, revision_id: data.id, status: "pending" as const } diff --git a/src/services/extensions/v2/schemas/extensions.ts b/src/services/extensions/v2/schemas/extensions.ts index 2da222ff..c7e93642 100644 --- a/src/services/extensions/v2/schemas/extensions.ts +++ b/src/services/extensions/v2/schemas/extensions.ts @@ -281,7 +281,7 @@ export const ExtensionListQuerySchema = z.object({ // Unified list query for the merged GET /extensions (optional auth, // role-aware). scope selects the projection: public (published extension // cards, anonymous allowed), mine (caller's own Owned rows), all (moderator's -// view of all Owned rows). developer_id is public-only, status/q are +// view of all Owned rows). developer_identifier is public-only, status/q are // all-only; mismatched combinations are rejected with 422 so a caller cannot // silently get a different projection than requested. export const ExtensionListScopeSchema = z.enum(["public", "mine", "all"]); @@ -318,9 +318,9 @@ export const UnifiedExtensionListQuerySchema = ExtensionListQuerySchema.extend({ .openapi({ param: { name: "q", in: "query" }, description: - "All scope only: case-insensitive substring match on the extension id" + "All scope only: case-insensitive substring match on the extension identifier" }) -}); +}).strict(); // Merged GET /extensions returns one of the two list shapes depending on // ?scope=. A union (not two routes) because the only caller is controlled; diff --git a/test/services/extensions/v2/contract.test.ts b/test/services/extensions/v2/contract.test.ts index b66ea1b9..ecf415e8 100644 --- a/test/services/extensions/v2/contract.test.ts +++ b/test/services/extensions/v2/contract.test.ts @@ -11,7 +11,7 @@ import { seedDeveloper, seedUnownedDeveloper } from "./harness"; -import { insertDeveloper, insertUser } from "./db-fixtures"; +import { getDeveloper, insertDeveloper, insertUser } from "./db-fixtures"; // Hoisted so no v2 suite can make a real GitHub call. vi.mock("@octokit/request", async () => @@ -71,6 +71,17 @@ describe("Extensions API v2 contract", () => { expect(queued.status).toBe(422); }); + it("rejects unknown extension list filters", async () => { + for (const query of [ + "developer_id=someone", + "developer_identifer=someone" + ]) { + expect((await get(`/extensions/v2/extensions?${query}`, {})).status).toBe( + 422 + ); + } + }); + it("answers every list with the cursor envelope", async () => { await seedDeveloper("new-developer", "user-1"); await insertUser(db, { id: "mod-1", is_moderator: 1 }); @@ -81,7 +92,7 @@ describe("Extensions API v2 contract", () => { "/extensions/v2/revisions", "/extensions/v2/developers", "/extensions/v2/developers/claims?scope=pending", - "/extensions/v2/developers/new-developer/history" + `/extensions/v2/developers/${(await getDeveloper(db, "new-developer"))!.id}/history` ]) { const res = await get(path, mod); expect(res.status).toBe(200); @@ -240,7 +251,7 @@ describe("Extensions API v2 contract", () => { const mod = await authHeaders("mod-1"); const first = await get( - "/extensions/v2/developers/dev-a/history?limit=1", + `/extensions/v2/developers/${(await getDeveloper(db, "dev-a"))!.id}/history?limit=1`, mod ); const firstBody = (await first.json()) as { @@ -249,7 +260,7 @@ describe("Extensions API v2 contract", () => { expect(firstBody.pagination.has_more).toBe(true); const crossed = await get( - `/extensions/v2/developers/dev-b/history?cursor=${encodeURIComponent(firstBody.pagination.next_cursor as string)}`, + `/extensions/v2/developers/${(await getDeveloper(db, "dev-b"))!.id}/history?cursor=${encodeURIComponent(firstBody.pagination.next_cursor as string)}`, mod ); expect(crossed.status).toBe(422); @@ -265,7 +276,7 @@ describe("Extensions API v2 contract", () => { "/extensions/v2/revisions?cursor=nope", "/extensions/v2/developers?cursor=nope", "/extensions/v2/developers/claims?scope=pending&cursor=nope", - "/extensions/v2/developers/new-developer/history?cursor=nope" + `/extensions/v2/developers/${(await getDeveloper(db, "new-developer"))!.id}/history?cursor=nope` ]) { const res = await get(path, mod); expect(res.status).toBe(422); diff --git a/test/services/extensions/v2/db-fixtures.ts b/test/services/extensions/v2/db-fixtures.ts index 628883fe..717d3b95 100644 --- a/test/services/extensions/v2/db-fixtures.ts +++ b/test/services/extensions/v2/db-fixtures.ts @@ -600,14 +600,14 @@ export async function expireAllDeveloperTransfers( // reproduces it exactly. export async function bumpDeveloperOwnership( db: D1Database, - developerId: string, + developerIdentifier: string, newOwnerUserId: string ): Promise { await ensureUser(db, newOwnerUserId); await db .prepare( - "UPDATE developers SET owner_user_id = ?, ownership_epoch = ownership_epoch + 1 WHERE identifier = ?" + "UPDATE developers SET owner_user_id = ?, ownership_epoch = ownership_epoch + 1 WHERE lower(identifier) = lower(?)" ) - .bind(newOwnerUserId, developerId) + .bind(newOwnerUserId, developerIdentifier) .run(); } diff --git a/test/services/extensions/v2/developer-profiles.test.ts b/test/services/extensions/v2/developer-profiles.test.ts index 7dda4758..7bcb28e9 100644 --- a/test/services/extensions/v2/developer-profiles.test.ts +++ b/test/services/extensions/v2/developer-profiles.test.ts @@ -999,6 +999,8 @@ describe("Extensions API v2", () => { sampleDeveloper() ); + const developerId = (await getDeveloper(db, "dev-developer"))!.id; + // Fires when upsertOwn's existing-profile UPDATE (identified by // touching content_revision but not ownership_epoch, which only the // ownership-transfer statements touch) is about to run - the DB @@ -1030,7 +1032,7 @@ describe("Extensions API v2", () => { ); expect( (await listDeveloperHistory(db)).filter( - (row) => row.name === "Dev Developer" + (row) => row.developer_id === developerId ) ).toHaveLength(1); }); diff --git a/test/services/extensions/v2/directory-identity.test.ts b/test/services/extensions/v2/directory-identity.test.ts index 3f8bb8ad..868ffe74 100644 --- a/test/services/extensions/v2/directory-identity.test.ts +++ b/test/services/extensions/v2/directory-identity.test.ts @@ -75,6 +75,7 @@ describe("directory identity", () => { headers, sampleDeveloper() ); + expect(response.status).toBe(200); const before = ( (await response.json()) as { result: { id: string; identifier: string } } ).result; diff --git a/test/services/extensions/v2/extension-writes.test.ts b/test/services/extensions/v2/extension-writes.test.ts index 5fccf96c..85893aac 100644 --- a/test/services/extensions/v2/extension-writes.test.ts +++ b/test/services/extensions/v2/extension-writes.test.ts @@ -523,6 +523,9 @@ describe("Extensions API v2 writes", () => { ); expect(res.status).toBe(202); + await expect(res.json()).resolves.toMatchObject({ + result: { identifier: "Existing-Ext" } + }); // The revision must hang off the stored spelling, not the requested one. const [revision] = await listRevisions(db); expect(revision.extension_id).toBe( diff --git a/test/services/extensions/v2/migrations.test.ts b/test/services/extensions/v2/migrations.test.ts index ee791835..58f0840f 100644 --- a/test/services/extensions/v2/migrations.test.ts +++ b/test/services/extensions/v2/migrations.test.ts @@ -1385,6 +1385,7 @@ describe("directory UUID identities", () => { db.exec(` INSERT INTO users(id,created_at,updated_at) VALUES ('owner',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP); INSERT INTO developers(id,type,name,owner_user_id,created_at) VALUES ('studio','user','Studio','owner','2026-11-01 00:00:00'); + INSERT INTO developer_history(id,developer_id,type,name,changed_by) VALUES ('deleted-history','deleted-studio','user','Deleted','owner'); INSERT INTO extensions(id,developer_id,created_by) VALUES ('example','studio','owner'); INSERT INTO extension_revisions(id,extension_id,developer_id,submitted_by,content) VALUES ('revision','example','studio','owner','{}'); INSERT INTO developer_claims(id,developer_id,claimant_id) VALUES ('claim','studio','owner'); @@ -1433,6 +1434,39 @@ describe("directory UUID identities", () => { ) .get(developer.id) ).toEqual(before); + const deletedHistory = db + .prepare( + "SELECT developer_id FROM developer_history WHERE id='deleted-history'" + ) + .get() as { developer_id: string }; + expect(deletedHistory.developer_id).toMatch(pattern); + expect(deletedHistory.developer_id).not.toBe(developer.id); + for (const index of [ + "idx_extensions_public_order", + "idx_extensions_type_public_order" + ]) { + const row = db + .prepare("SELECT sql FROM sqlite_master WHERE name=?") + .get(index) as { sql: string }; + expect(row.sql).toContain("lower(identifier),id"); + } + db.prepare( + "INSERT INTO extension_revisions(id,extension_id,developer_id,submitted_by,content,status) VALUES ('new-revision',?,?, 'owner','{}','approved')" + ).run(extension.id, developer.id); + expect( + db + .prepare( + "SELECT revisions FROM extension_resource_usage WHERE scope='developer' AND subject=?" + ) + .get(developer.id) + ).toEqual({ revisions: Number(before!.revisions) + 1 }); + expect( + db + .prepare( + "SELECT subject FROM extension_resource_usage WHERE scope='developer'" + ) + .all() + ).toEqual([{ subject: developer.id }]); expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); } finally { db.close(); diff --git a/test/services/extensions/v2/moderation-notify.test.ts b/test/services/extensions/v2/moderation-notify.test.ts index 52253ca3..e3630072 100644 --- a/test/services/extensions/v2/moderation-notify.test.ts +++ b/test/services/extensions/v2/moderation-notify.test.ts @@ -350,6 +350,7 @@ describe("moderation notification emails", () => { }); const body = JSON.parse(String(calls[0].init.body)); expect(body.to).toBe("claimant@example.com"); + expect(body.body).toContain("legacy-dev"); }); it("notifies the claimant with the reason on claim rejection", async () => { @@ -368,6 +369,7 @@ describe("moderation notification emails", () => { }); const body = JSON.parse(String(calls[0].init.body)); expect(body.to).toBe("claimant@example.com"); + expect(body.body).toContain("legacy-dev"); expect(body.body).toContain("Could not verify ownership"); }); diff --git a/test/services/extensions/v2/moderation.test.ts b/test/services/extensions/v2/moderation.test.ts index 6d60e6ea..9e02003b 100644 --- a/test/services/extensions/v2/moderation.test.ts +++ b/test/services/extensions/v2/moderation.test.ts @@ -9,6 +9,7 @@ import { db, authHeaders, post, + del, get, put, sampleContent, @@ -1531,7 +1532,7 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); const res = await get( - "/extensions/v2/developers/dev-developer/history", + `/extensions/v2/developers/${(await getDeveloper(db, "dev-developer"))!.id}/history`, await authHeaders("mod-1") ); @@ -1561,7 +1562,7 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); const res = await get( - "/extensions/v2/developers/dev-developer/history", + `/extensions/v2/developers/${(await getDeveloper(db, "dev-developer"))!.id}/history`, await authHeaders("mod-1") ); @@ -1591,7 +1592,7 @@ describe("Extensions API v2", () => { const mod = await authHeaders("mod-1"); const first = await get( - "/extensions/v2/developers/dev-developer/history?limit=1", + `/extensions/v2/developers/${(await getDeveloper(db, "dev-developer"))!.id}/history?limit=1`, mod ); expect(first.status).toBe(200); @@ -1604,7 +1605,7 @@ describe("Extensions API v2", () => { expect(firstBody.pagination.next_cursor).toBeTruthy(); const second = await get( - `/extensions/v2/developers/dev-developer/history?limit=1&cursor=${encodeURIComponent(firstBody.pagination.next_cursor as string)}`, + `/extensions/v2/developers/${(await getDeveloper(db, "dev-developer"))!.id}/history?limit=1&cursor=${encodeURIComponent(firstBody.pagination.next_cursor as string)}`, mod ); const secondBody = (await second.json()) as { @@ -1622,7 +1623,7 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); const res = await get( - "/extensions/v2/developers/dev-developer/history?cursor=not-a-cursor", + "/extensions/v2/developers/019a0000-0000-7000-8000-000000000000/history?cursor=not-a-cursor", await authHeaders("mod-1") ); expect(res.status).toBe(422); @@ -1642,7 +1643,7 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); const res = await get( - "/extensions/v2/developers/dev-developer/history", + `/extensions/v2/developers/${(await getDeveloper(db, "dev-developer"))!.id}/history`, await authHeaders("mod-1") ); @@ -1655,11 +1656,55 @@ describe("Extensions API v2", () => { expect(data.result[1].name).toBe("Original Name"); }); + it("keeps deleted-profile history separate when its identifier is reused", async () => { + const owner = await authHeaders("user-1"); + const created = await put( + "/extensions/v2/developers/me", + owner, + sampleDeveloper() + ); + expect(created.status).toBe(200); + const { result: original } = (await created.json()) as { + result: { id: string }; + }; + expect((await del("/extensions/v2/developers/me", owner)).status).toBe( + 200 + ); + const recreated = await put( + "/extensions/v2/developers/me", + owner, + sampleDeveloper({ name: "Replacement" }) + ); + expect(recreated.status).toBe(200); + const { result: replacement } = (await recreated.json()) as { + result: { id: string }; + }; + expect(replacement.id).not.toBe(original.id); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + const mod = await authHeaders("mod-1"); + for (const [id, name] of [ + [original.id, "Dev Developer"], + [replacement.id, "Replacement"] + ]) { + const response = await get( + `/extensions/v2/developers/${id.toUpperCase()}/history`, + mod + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + result: Array<{ developer_id: string; name: string }>; + }; + expect(body.result).toEqual([ + expect.objectContaining({ developer_id: id, name }) + ]); + } + }); + it("returns an empty array for a developer with no history", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); const res = await get( - "/extensions/v2/developers/no-such-developer/history", + "/extensions/v2/developers/019a0000-0000-7000-8000-000000000000/history", await authHeaders("mod-1") ); @@ -1676,7 +1721,7 @@ describe("Extensions API v2", () => { ); const res = await get( - "/extensions/v2/developers/dev-developer/history", + `/extensions/v2/developers/${(await getDeveloper(db, "dev-developer"))!.id}/history`, await authHeaders("user-1") ); expect(res.status).toBe(403); @@ -1698,7 +1743,7 @@ describe("Extensions API v2", () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); const history = await get( - "/extensions/v2/developers/dev-developer/history", + `/extensions/v2/developers/${(await getDeveloper(db, "dev-developer"))!.id}/history`, await authHeaders("mod-1") ); const data = (await history.json()) as { result: unknown[] }; diff --git a/test/services/extensions/v2/public-extensions.test.ts b/test/services/extensions/v2/public-extensions.test.ts index 7f784b70..9b9173d0 100644 --- a/test/services/extensions/v2/public-extensions.test.ts +++ b/test/services/extensions/v2/public-extensions.test.ts @@ -165,6 +165,20 @@ describe("Extensions API v2", () => { expect(blank.status).toBe(422); }); + it("rejects a cursor whose identifier is not normalized", async () => { + const cursor = btoa( + JSON.stringify({ v: 1, id: "example", identifier: "Example" }) + ); + const response = await get( + `/extensions/v2/extensions?cursor=${encodeURIComponent(cursor)}`, + {} + ); + expect(response.status).toBe(422); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "INVALID_CURSOR" } + }); + }); + it("supports UTF-8 extension ids in cursors", async () => { await seedExtensions(["alpha", "zulu", "éclair", "😀"]); diff --git a/test/services/extensions/v2/publisher.test.ts b/test/services/extensions/v2/publisher.test.ts index 72baaec4..297be069 100644 --- a/test/services/extensions/v2/publisher.test.ts +++ b/test/services/extensions/v2/publisher.test.ts @@ -196,7 +196,7 @@ describe("m2m publisher writes", () => { it("proposes an edit to an extension of the mapped developer", async () => { await registerPublisher(); await insertExtension(db, { - identifier: "existing-ext", + identifier: "Existing-Ext", developer_identifier: DEVELOPER, name: "Existing", source: '{"type":"github","repo":"example/existing"}' @@ -208,8 +208,9 @@ describe("m2m publisher writes", () => { ); expect(res.status).toBe(202); const body = (await res.json()) as { - result: { revision_id: string }; + result: { revision_id: string; identifier: string }; }; + expect(body.result.identifier).toBe("Existing-Ext"); const revision = await revisionRow(body.result.revision_id); expect(revision?.submitted_by).toBe(`m2m:${CLIENT}`); const repeated = await put( From aa18c2c09bdabe729c43fa07688038e6fd9cb5e2 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 17:48:45 +0000 Subject: [PATCH 3/4] Simplify identity helpers and regression coverage --- src/services/extensions/v2/db/extensions.ts | 22 ++++---- .../extensions/v2/routes/owner-extensions.ts | 52 ++++++++----------- .../services/extensions/v2/migrations.test.ts | 20 ++++--- .../extensions/v2/moderation-correct.test.ts | 20 +------ .../extensions/v2/resource-bounds.test.ts | 25 ++------- 5 files changed, 52 insertions(+), 87 deletions(-) diff --git a/src/services/extensions/v2/db/extensions.ts b/src/services/extensions/v2/db/extensions.ts index 7c5359f8..c69d5f4c 100644 --- a/src/services/extensions/v2/db/extensions.ts +++ b/src/services/extensions/v2/db/extensions.ts @@ -570,13 +570,10 @@ export class ExtensionsDatabase { // pendingContent). async getOwnership( id: string - ): Promise< - DatabaseResult<{ extensionId: string; ownerUserId: string | null }> - > { + ): Promise> { try { const rows = await this.db .select({ - extensionId: extensions.id, ownerUserId: developers.ownerUserId }) .from(extensions) @@ -586,7 +583,7 @@ export class ExtensionsDatabase { const row = rows[0]; if (!row) return notFound(id); return { - data: { extensionId: row.extensionId, ownerUserId: row.ownerUserId }, + data: row, error: null }; } catch (error) { @@ -1213,7 +1210,7 @@ export class ExtensionsDatabase { // The UPDATE below is the column list publish shares with // ExtensionRevisionsDatabase.approve() in db/revisions.ts; the two // differ only in published_at handling and how the row is matched - // (LOWER(id) here, because corrections must address legacy + // (LOWER(identifier) here, because corrections must address legacy // differently-cased rows). Change the projection in both places together. const publishStmt = toD1Statement(this.db.$client, { sql: `UPDATE extensions @@ -1241,20 +1238,23 @@ export class ExtensionsDatabase { ] }); - results = await this.db.$client.batch([correctStmt, publishStmt]); + results = await this.db.$client.batch<{ + extension_id: string; + identifier: string; + }>([correctStmt, publishStmt]); } catch (error) { return contentAdmissionError("moderatorCorrect", error); } - if (!results[0]?.meta?.changes) { + const corrected = results[0].results[0]; + if (!corrected) { return this.moderatorCorrectBlockedError(id, moderatorId); } return { data: { - id: (results[0].results[0] as { extension_id: string }).extension_id, - identifier: (results[0].results[0] as { identifier: string }) - .identifier, + id: corrected.extension_id, + identifier: corrected.identifier, revisionId }, error: null diff --git a/src/services/extensions/v2/routes/owner-extensions.ts b/src/services/extensions/v2/routes/owner-extensions.ts index 0b0adb05..dfd786cb 100644 --- a/src/services/extensions/v2/routes/owner-extensions.ts +++ b/src/services/extensions/v2/routes/owner-extensions.ts @@ -1,5 +1,5 @@ import type { Context } from "hono"; -import type { DatabaseResult } from "../../../../lib/interfaces"; +import type { DatabaseError } from "../../../../lib/interfaces"; import { errorBody, listErrorStatus, @@ -47,29 +47,23 @@ import { ExtensionsV2App } from "./app"; // Both history and detail authorize against current account/profile state; // their actual data queries repeat this guard to contain mid-request changes. -async function revisionReadAccess( +async function revisionReadError( c: Context<{ Bindings: CloudflareBindings }>, - id: string -): Promise> { + identifier: string +): Promise { const db = getExtensionsDb(c.env.DB_EXTENSIONS); const auth = getAuth(c); const [ownership, access] = await Promise.all([ - new ExtensionsDatabase(db).getOwnership(id), + new ExtensionsDatabase(db).getOwnership(identifier), new UsersDatabase(db).moderatorAccess(auth.userId) ]); - if (ownership.error || !ownership.data) return ownership; - if (access.error) return { data: null, error: access.error }; - if (!access.data?.active) - return { - data: null, - error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } - }; + if (ownership.error) return ownership.error; + if (access.error) return access.error; + if (!access.data.active) + return { code: "ACCOUNT_INACTIVE", message: "Active account required" }; if (ownership.data.ownerUserId !== auth.userId && !access.data.moderator) - return { - data: null, - error: { code: "FORBIDDEN", message: "You do not own this extension" } - }; - return { data: { extensionId: ownership.data.extensionId }, error: null }; + return { code: "FORBIDDEN", message: "You do not own this extension" }; + return null; } const AcceptedRevisionResultSchema = z.object({ @@ -459,14 +453,14 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { const auth = getAuth(c); const { id } = c.req.valid("param"); const { limit, cursor } = c.req.valid("query"); - const access = await revisionReadAccess(c, id); - if (access.error || !access.data) + const accessError = await revisionReadError(c, id); + if (accessError) return c.json( - errorBody(access.error, "Unable to read revisions"), - access.error?.code === "NOT_FOUND" + errorBody(accessError, "Unable to read revisions"), + accessError.code === "NOT_FOUND" ? 404 - : access.error?.code === "FORBIDDEN" || - access.error?.code === "ACCOUNT_INACTIVE" + : accessError.code === "FORBIDDEN" || + accessError.code === "ACCOUNT_INACTIVE" ? 403 : 500 ); @@ -529,14 +523,14 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { app.openapi(revisionDetailRoute, async (c) => { const auth = getAuth(c); const { id, revisionId } = c.req.valid("param"); - const access = await revisionReadAccess(c, id); - if (access.error || !access.data) + const accessError = await revisionReadError(c, id); + if (accessError) return c.json( - errorBody(access.error, "Unable to read revisions"), - access.error?.code === "NOT_FOUND" + errorBody(accessError, "Unable to read revisions"), + accessError.code === "NOT_FOUND" ? 404 - : access.error?.code === "FORBIDDEN" || - access.error?.code === "ACCOUNT_INACTIVE" + : accessError.code === "FORBIDDEN" || + accessError.code === "ACCOUNT_INACTIVE" ? 403 : 500 ); diff --git a/test/services/extensions/v2/migrations.test.ts b/test/services/extensions/v2/migrations.test.ts index 58f0840f..fdffc64d 100644 --- a/test/services/extensions/v2/migrations.test.ts +++ b/test/services/extensions/v2/migrations.test.ts @@ -1441,14 +1441,18 @@ describe("directory UUID identities", () => { .get() as { developer_id: string }; expect(deletedHistory.developer_id).toMatch(pattern); expect(deletedHistory.developer_id).not.toBe(developer.id); - for (const index of [ - "idx_extensions_public_order", - "idx_extensions_type_public_order" - ]) { - const row = db - .prepare("SELECT sql FROM sqlite_master WHERE name=?") - .get(index) as { sql: string }; - expect(row.sql).toContain("lower(identifier),id"); + for (const filter of ["", " AND type='module'"]) { + const plan = db + .prepare( + `EXPLAIN QUERY PLAN + SELECT id FROM extensions + WHERE published_at IS NOT NULL AND delisted_at IS NULL${filter} + ORDER BY lower(identifier), id LIMIT 50` + ) + .all() as Array<{ detail: string }>; + expect(plan.map((row) => row.detail).join(" ")).not.toContain( + "TEMP B-TREE" + ); } db.prepare( "INSERT INTO extension_revisions(id,extension_id,developer_id,submitted_by,content,status) VALUES ('new-revision',?,?, 'owner','{}','approved')" diff --git a/test/services/extensions/v2/moderation-correct.test.ts b/test/services/extensions/v2/moderation-correct.test.ts index 420ee2ac..cee38394 100644 --- a/test/services/extensions/v2/moderation-correct.test.ts +++ b/test/services/extensions/v2/moderation-correct.test.ts @@ -52,24 +52,6 @@ async function seedPublished(): Promise { } describe("POST /extensions/{id}/moderator-correct (api#251)", () => { - it("returns the canonical id without a fallible post-commit read", async () => { - await seedPublished(); - env.DB_EXTENSIONS = wrapD1WithHook(db, (query) => { - if (/^select "id" from "extensions"/i.test(query)) - throw new Error("canonical read failed"); - }); - const response = await post( - PATH.replace("live-ext", "LIVE-EXT"), - await authHeaders("mod-1"), - correctBody() - ); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - result: { id: expect.stringMatching(UUID_V7), identifier: "live-ext" } - }); - expect(await countRevisions(db)).toBe(1); - }); - it("charges a first-time moderator exactly once and keeps totals reconciled", async () => { await seedPublished(); const response = await post( @@ -316,7 +298,7 @@ describe("POST /extensions/{id}/moderator-correct (api#251)", () => { ).toHaveLength(0); }); - it("matches ids case-insensitively and returns the canonical id", async () => { + it("matches identifiers case-insensitively and returns the canonical identifier", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); await insertExtension(db, { diff --git a/test/services/extensions/v2/resource-bounds.test.ts b/test/services/extensions/v2/resource-bounds.test.ts index b8ab4732..35d91192 100644 --- a/test/services/extensions/v2/resource-bounds.test.ts +++ b/test/services/extensions/v2/resource-bounds.test.ts @@ -615,29 +615,20 @@ describe("Extension resource admission", () => { await seedDeveloper("developer", "owner"); expect((await create("first")).status).toBe(201); await ageEvents(); + const usageSubject = + scope === "developer" ? (await getDeveloper(db, subject))!.id : subject; const original = await db .prepare( `SELECT ${column} AS value FROM extension_resource_usage WHERE scope=? AND subject=?` ) - .bind( - scope, - scope === "developer" - ? (await getDeveloper(db, subject))!.id - : subject - ) + .bind(scope, usageSubject) .first("value"); try { await db .prepare( `UPDATE extension_resource_usage SET ${column}=? WHERE scope=? AND subject=?` ) - .bind( - limit, - scope, - scope === "developer" - ? (await getDeveloper(db, subject))!.id - : subject - ) + .bind(limit, scope, usageSubject) .run(); expect((await create("blocked")).status).toBe(409); expect(await countExtensions(db)).toBe(1); @@ -649,13 +640,7 @@ describe("Extension resource admission", () => { .prepare( `UPDATE extension_resource_usage SET ${column}=? WHERE scope=? AND subject=?` ) - .bind( - original, - scope, - scope === "developer" - ? (await getDeveloper(db, subject))!.id - : subject - ) + .bind(original, scope, usageSubject) .run(); } } From d846c4800fa8a19cbaa97313aa5459f4008f15be Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 17:53:57 +0000 Subject: [PATCH 4/4] Correct identity lookup comments --- src/services/extensions/v2/db/extensions.ts | 7 ++----- src/services/extensions/v2/db/revisions.ts | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/services/extensions/v2/db/extensions.ts b/src/services/extensions/v2/db/extensions.ts index c69d5f4c..1dfe7892 100644 --- a/src/services/extensions/v2/db/extensions.ts +++ b/src/services/extensions/v2/db/extensions.ts @@ -563,11 +563,8 @@ export class ExtensionsDatabase { }; } - // Light authorisation probe for the merged public/owner detail read and - // the revisions route: deciding which view a caller gets - and learning - // the canonical id for exact-match scoped queries - must not cost the - // full owner view (two revision joins plus up to 256 KiB of - // pendingContent). + // Check ownership for detail and revision reads without loading the full + // owner view, including its revision joins and pending content. async getOwnership( id: string ): Promise> { diff --git a/src/services/extensions/v2/db/revisions.ts b/src/services/extensions/v2/db/revisions.ts index 88a288e2..83df813e 100644 --- a/src/services/extensions/v2/db/revisions.ts +++ b/src/services/extensions/v2/db/revisions.ts @@ -806,8 +806,8 @@ export class ExtensionRevisionsDatabase { // The column list publish shares with ExtensionsDatabase.moderatorCorrect() // in db/extensions.ts; the two differ only in published_at handling and how - // the row is matched (exact id here, LOWER(id) there, because corrections - // address legacy rows). Change the projection in both places together. + // the row is matched (exact UUID here, LOWER(identifier) there). + // Change the projection in both places together. private buildPublishStmt( content: ExtensionContent, revisionId: string,