From d1c1f44bc706250de01f49406ae2310461a1b3c9 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 8 Aug 2026 08:40:12 +0100 Subject: [PATCH 1/9] Make the extension the resource and review a state of it Submissions were modelled as a resource parallel to extensions: an extension only existed once a moderator approved one, so in-flight work lived in extension_submissions under a target id reachable only through the payload JSON. Rendering "my extensions" meant reading two separately-paginated collections and reconciling them client-side, with rules that were nowhere in the contract. The same service already solved this problem the other way for developer profiles, which are edited in place with review state on the row. Extensions now work that way too. POST /extensions creates the record, which holds its id immediately and stays out of both catalogues until its first revision is approved. PUT /extensions/{id} proposes an edit. extension_submissions becomes extension_revisions: one proposed content version, always attached to a real extension row. Consequences worth calling out: - A revision carries extension content only. Approving one no longer rewrites the developer profile and resets its approval as a side effect. - No request body names a developer; a user owns at most one profile, so the server derives it. - An extension cannot be renamed or moved to another developer by an edit. - The approval-time reserved-id re-checks are gone. Ids are validated once at creation, against a row that exists from then on. - target_key and its partial index are gone. UNIQUE(lower(id)) on extensions plus UNIQUE(extension_id) WHERE status='pending' do the same work structurally. - v1 and the v2 catalogue filter on published_at, which is what keeps their output identical now that an unpublished row can exist. Migration 0021 rebuilds extensions, extension_revisions and developers, since SQLite cannot relax NOT NULL, add a CHECK, or add a foreign key in place. Two renames ride along on a rebuild already paid for: extensions.author_id becomes developer_id, and developers' created_at and updated_at lose the placeholder 1970 default that no writer ever produced. It ends by failing the deploy if the rebuild would carry a dangling reference through its foreign_keys=OFF window, which is what lets the catalogue reads inner-join developers instead of defending against a row that cannot exist. --- src/services/extensions/v1/database.ts | 50 +- src/services/extensions/v2/README.md | 67 +- .../extensions/v2/db/developer-profiles.ts | 78 +- .../extensions/v2/db/developer-transfers.ts | 8 +- src/services/extensions/v2/db/extensions.ts | 553 +++++++-- .../0021_restructure_extensions_revisions.sql | 255 ++++ .../v2/db/migrations/meta/0021_snapshot.json | 1028 +++++++++++++++++ .../v2/db/migrations/meta/_journal.json | 7 + src/services/extensions/v2/db/revisions.ts | 470 ++++++++ src/services/extensions/v2/db/schema.ts | 176 ++- src/services/extensions/v2/db/submissions.ts | 601 ---------- src/services/extensions/v2/db/users.ts | 28 +- src/services/extensions/v2/index.ts | 17 +- .../v2/routes/developer-profiles.ts | 2 +- .../extensions/v2/routes/moderation.ts | 83 +- .../extensions/v2/routes/owner-extensions.ts | 381 +++++- .../extensions/v2/routes/submissions.ts | 140 --- .../extensions/v2/schemas/developers.ts | 20 +- .../extensions/v2/schemas/extensions.ts | 113 +- .../extensions/v2/schemas/revisions.ts | 67 ++ .../extensions/v2/schemas/submissions.ts | 85 -- test/services/extensions/v1/index.test.ts | 13 +- test/services/extensions/v2/account.test.ts | 43 +- test/services/extensions/v2/db-fixtures.ts | 131 ++- .../extensions/v2/developer-profiles.test.ts | 26 +- .../extensions/v2/extension-writes.test.ts | 552 +++++++++ test/services/extensions/v2/harness.ts | 74 +- test/services/extensions/v2/index.test.ts | 10 +- .../services/extensions/v2/migrations.test.ts | 213 +++- .../services/extensions/v2/moderation.test.ts | 407 ++++--- test/services/extensions/v2/ownership.test.ts | 24 +- .../extensions/v2/public-extensions.test.ts | 2 +- .../extensions/v2/submissions.test.ts | 385 ------ 33 files changed, 4254 insertions(+), 1855 deletions(-) create mode 100644 src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql create mode 100644 src/services/extensions/v2/db/migrations/meta/0021_snapshot.json create mode 100644 src/services/extensions/v2/db/revisions.ts delete mode 100644 src/services/extensions/v2/db/submissions.ts delete mode 100644 src/services/extensions/v2/routes/submissions.ts create mode 100644 src/services/extensions/v2/schemas/revisions.ts delete mode 100644 src/services/extensions/v2/schemas/submissions.ts create mode 100644 test/services/extensions/v2/extension-writes.test.ts delete mode 100644 test/services/extensions/v2/submissions.test.ts diff --git a/src/services/extensions/v1/database.ts b/src/services/extensions/v1/database.ts index 385695a..a388a6c 100644 --- a/src/services/extensions/v1/database.ts +++ b/src/services/extensions/v1/database.ts @@ -1,4 +1,4 @@ -import { eq, sql } from "drizzle-orm"; +import { and, eq, isNotNull, sql } from "drizzle-orm"; import { ExtensionsDb } from "../../../lib/db"; import { extensions, developers } from "../v2/db/schema"; import { DatabaseResult } from "../../../lib/interfaces"; @@ -14,7 +14,7 @@ import { const EXTENSION_COLUMNS = { id: extensions.id, type: extensions.type, - authorId: extensions.authorId, + developerId: extensions.developerId, authorType: developers.type, authorName: developers.name, authorUrl: developers.url, @@ -30,16 +30,22 @@ const EXTENSION_COLUMNS = { downloadUrl: extensions.downloadUrl }; -// A LEFT JOIN can produce no matching developers row, so every joined -// (author*) column is nullable regardless of developers' own NOT NULL -// constraints - only extensions' own columns (besides iconUrl) are -// guaranteed non-null. +// An inner join: extensions.developer_id is NOT NULL with an enforced foreign +// key, so the author* columns are as non-null as developers' own constraints +// make them. +// +// extensions' own content columns became nullable in migration 0021, where an +// extension row starts existing before it is published. They are still +// non-null here because both queries below filter on published_at IS NOT NULL +// and extensions_published_content_check makes that filter sufficient - a +// published row cannot be missing any of them. That filter is also what keeps +// unreviewed extensions out of the v1 catalogue. interface ExtensionRow { id: string; type: string; - authorId: string; - authorType: string | null; - authorName: string | null; + developerId: string; + authorType: string; + authorName: string; authorUrl: string | null; name: string; description: string; @@ -59,11 +65,14 @@ export class ExtensionsDatabase { async getAllExtensions(type?: string): Promise> { let rows: ExtensionRow[]; try { - const query = this.db + const published = isNotNull(extensions.publishedAt); + rows = (await this.db .select(EXTENSION_COLUMNS) .from(extensions) - .leftJoin(developers, eq(extensions.authorId, developers.id)); - rows = type ? await query.where(eq(extensions.type, type)) : await query; + .innerJoin(developers, eq(extensions.developerId, developers.id)) + .where( + type ? and(published, eq(extensions.type, type)) : published + )) as ExtensionRow[]; } catch (error) { return { data: null, @@ -80,11 +89,16 @@ export class ExtensionsDatabase { async getExtensionById(id: string): Promise> { let rows: ExtensionRow[]; try { - rows = await this.db + rows = (await this.db .select(EXTENSION_COLUMNS) .from(extensions) - .leftJoin(developers, eq(extensions.authorId, developers.id)) - .where(sql`LOWER(${extensions.id}) = LOWER(${id})`); + .innerJoin(developers, eq(extensions.developerId, developers.id)) + .where( + and( + sql`LOWER(${extensions.id}) = LOWER(${id})`, + isNotNull(extensions.publishedAt) + ) + )) as ExtensionRow[]; } catch (error) { return { data: null, @@ -118,9 +132,9 @@ function parseExtensionRow(row: ExtensionRow): Extension { name: row.name, description: row.description, author: { - type: (row.authorType as "organization" | "user") ?? "user", - name: row.authorName ?? "", - id: (row.authorId as Lowercase) ?? ("" as Lowercase), + type: row.authorType as "organization" | "user", + name: row.authorName, + id: row.developerId as Lowercase, URL: row.authorUrl ?? undefined } as Author, releases: sortReleasesDescending(releases), diff --git a/src/services/extensions/v2/README.md b/src/services/extensions/v2/README.md index 6f438b9..e339a14 100644 --- a/src/services/extensions/v2/README.md +++ b/src/services/extensions/v2/README.md @@ -2,7 +2,7 @@ **Base Path:** `/extensions/v2` -Self-service extension submission, developer-profile ownership, moderation, and public catalogue browsing. +Self-service extension publishing, developer-profile ownership, moderation, and public catalogue browsing. This service owns the complete Extensions domain and its `DB_EXTENSIONS` schema: users, developers, submissions, claims, transfers, history, and catalogue data. 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`. @@ -13,6 +13,50 @@ Endpoints are not listed here. The service publishes its own contract: - **OpenAPI document:** `GET /extensions/v2/openapi.json` - **Reference UI:** `GET /extensions/v2/docs` +## The Extension Lifecycle + +An extension is a resource from the moment a developer creates it, not from the +moment a moderator approves it. There is no separate "submission" to reconcile +against it. + +- `POST /extensions` creates the record. It holds its id immediately and stays + out of both catalogues until its first revision is approved. +- `PUT /extensions/{id}` proposes an edit. The published content does not + change until a moderator approves the revision, and only one revision per + extension may be unreviewed at a time. +- `DELETE /extensions/{id}` withdraws an extension that has never been + published, releasing its id. A published extension cannot be withdrawn by its + owner — consumers pin the id. +- `POST /extensions/{id}/revisions/{revisionId}/approve` publishes the + revision's content. `reject` leaves the published content untouched and the + extension available to edit and resubmit. + +The id and the developer are properties of the extension, not of a revision: an +edit cannot rename an extension or move it to another developer, and approving +one no longer rewrites the developer profile as a side effect. A user owns at +most one developer profile, so no request body names one. + +### Reading Owner State + +`GET /extensions/mine` and `GET /extensions/mine/{id}` return three independent +fields rather than a single derived status, because together they are the +state and a derived enum could only disagree with them: + +| `published` | `pending_revision` | `last_review` | Meaning | +| ----------- | ------------------ | ------------- | ---------------------------------- | +| `null` | set | `null` | Awaiting first review | +| `null` | `null` | rejected | Rejected; edit and resubmit | +| set | `null` | approved | Live, no unreviewed edit | +| set | set | either | Live, with an edit awaiting review | + +These are separate routes from the public `GET /extensions` and +`GET /extensions/{id}`, which only ever return published content. A single path +whose 200 changes shape with the caller would force every generated client to +narrow a union at each call site, and the public read is the hotter path. + +`GET /extensions/{id}/revisions` lists the full history for the extension's +owner or any moderator. + ## Authentication Requests carry a short-lived bearer assertion minted by the Extensions site and verified here with a shared HMAC secret (`ASSERTION_SIGNING_SECRET`; see the root README for where to configure it). @@ -38,7 +82,7 @@ For organization developer IDs, GitHub membership is used for automatic verifica ## List Pagination -`GET /extensions/v2/extensions` returns bounded pages of lightweight catalogue items. List items intentionally omit `readme` and `releases`; retrieve the full object from `GET /extensions/v2/extensions/{id}` for detail views. Follow `pagination.next_cursor` by passing it unchanged as `cursor`, and treat cursors as opaque. The default page size is 50 and `limit` may be set from 1 through 100. +`GET /extensions/v2/extensions` returns bounded pages of lightweight catalogue items, filtered to published extensions. List items intentionally omit `readme` and `releases`; retrieve the full object from `GET /extensions/v2/extensions/{id}` for detail views. Follow `pagination.next_cursor` by passing it unchanged as `cursor`, and treat cursors as opaque. The default page size is 50 and `limit` may be set from 1 through 100. Cursors carry a version field and are validated on decode, so a cursor from an older format is rejected with `INVALID_CURSOR` (HTTP 422) rather than being misread. Clients should treat that as "restart pagination from the first page", not as an error to surface. @@ -46,10 +90,29 @@ Cursors carry a version field and are validated on decode, so a cursor from an o Uses the D1 binding `DB_EXTENSIONS`, shared with v1 (read-only there). This service owns the schema and the migrations. +`extensions` holds the record; its content columns are the _published_ +projection and are NULL until a first approval, with `published_at` as the +marker both catalogues filter on and `extensions_published_content_check` +guaranteeing a published row is never half-written. `extension_revisions` +(renamed from `extension_submissions` in migration 0021) holds proposed +content, always attached to a real extension row and cascading with it. + +Migration 0021 rebuilds `extensions`, `extension_revisions` and `developers` +in one step, because SQLite cannot relax `NOT NULL`, add a `CHECK`, or add a +foreign key in place. It also renames `extensions.author_id` to `developer_id` +(nothing public depended on the old name — v1's response field is `author` +either way) and replaces `developers.created_at`/`updated_at`'s placeholder +1970 default with `CURRENT_TIMESTAMP`, which is what every writer already +uses. Existing 1970 values are left alone: they are the only record those rows +have, and a timestamp invented at migration time would look real without being +so. + Apply migrations **only from this repository**, from `db/migrations`, with `npm run db:migrate:extensions-v2:local` / `:remote`. The Extensions site has no D1 migration source. Migration `0020` is a check, not a schema change: it fails if an adopted row holds an id that a static route shadows (`extensions.id = 'mine'`, or `developers.id` of `me`/`claims`/`unapproved`), which would make that row's detail page unreachable. If it fails, rename the row deliberately — the id is public and consumers pin it. +Migration `0021` also drops any submission filed under a developer that no longer exists: there is no `developer_id` such a row could carry that satisfies the new foreign key, and the profile it was filed under is already gone. + ## Code Layout See `AGENTS.md` for what belongs in `routes/`, `db/`, `schemas/`, `github/`, and `middleware.ts`. This service is the reference layout for larger services. diff --git a/src/services/extensions/v2/db/developer-profiles.ts b/src/services/extensions/v2/db/developer-profiles.ts index e2368a5..5414573 100644 --- a/src/services/extensions/v2/db/developer-profiles.ts +++ b/src/services/extensions/v2/db/developer-profiles.ts @@ -7,7 +7,6 @@ import { developerHistory, developerTransfers, extensions, - extensionSubmissions, users } from "./schema"; import { databaseError } from "./errors"; @@ -68,6 +67,33 @@ function parseDeveloperRowWithOwner(row: { export class DeveloperProfilesDatabase { constructor(private db: ExtensionsDb) {} + + // The minimum an extension write needs about the caller's profile: which + // developer to publish under, and the ownership epoch to pin the write to. + // getOwn() is the full profile projection and deliberately does not expose + // ownership_epoch, which is an internal concurrency token rather than part + // of the public contract. + async getOwnRef( + userId: string + ): Promise> { + try { + const [row] = await this.db + .select({ + id: developers.id, + ownershipEpoch: developers.ownershipEpoch + }) + .from(developers) + .where(eq(developers.ownerUserId, userId)); + return { + data: row + ? { id: row.id, ownershipEpoch: Number(row.ownershipEpoch ?? 1) } + : null, + error: null + }; + } catch (error) { + return databaseError("getOwnRef", error); + } + } async getOwn( userId: string ): Promise< @@ -450,36 +476,19 @@ export class DeveloperProfilesDatabase { const [extensionCount] = await this.db .select({ count: sql`COUNT(*)` }) .from(extensions) - .where(eq(extensions.authorId, developerId)); + .where(eq(extensions.developerId, developerId)); const extensionsCount = extensionCount?.count ?? 0; if (extensionsCount > 0) { return { code: "CONFLICT", - message: `You have ${extensionsCount} published extension(s) under this profile. Transfer ownership or remove them before deleting it.` - }; - } - - const [pendingCount] = await this.db - .select({ count: sql`COUNT(*)` }) - .from(extensionSubmissions) - .where( - and( - eq(extensionSubmissions.developerId, developerId), - eq(extensionSubmissions.status, "pending") - ) - ); - if ((pendingCount?.count ?? 0) > 0) { - return { - code: "CONFLICT", - message: - "You have a pending submission under review. Wait for it to be resolved before deleting your profile." + message: `You have ${extensionsCount} extension(s) under this profile. Transfer ownership, or withdraw the unpublished ones, before deleting it.` }; } // The guard failed but a fresh look finds nothing wrong — whatever - // blocked it (someone else's transfer/claim landing, a submission - // that has since been resolved) has already cleared. Ask the caller - // to retry rather than guessing at a reason that's no longer true. + // blocked it (someone else's transfer/claim landing, an extension that + // has since been withdrawn) has already cleared. Ask the caller to retry + // rather than guessing at a reason that's no longer true. return { code: "CONFLICT", message: @@ -490,9 +499,12 @@ export class DeveloperProfilesDatabase { // Permanently removes the caller's own developer profile, for a // privacy-focused account-deletion flow. Refuses while anything would be // left dangling in a way that isn't just historical record-keeping: - // published extensions (someone still needs to own them) and pending - // submissions (nothing left to approve/reject against once the named - // developer is gone). developer_history is deliberately left alone — + // any extensions at all (someone still needs to own them, and extensions + // .developer_id is a hard FK). Unpublished ones count: withdrawing them is + // owner's own one-call operation. Pending revisions need no separate check + // since migration 0021 - every revision belongs to an extension and + // cascades with it, so "no extensions" already implies "nothing left to + // review". developer_history is deliberately left alone — // it's an append-only audit log, moderator-only, never rendered publicly, // and 0009_drop_developer_history_fk.sql dropped its FK to developers(id) // specifically so a deleted developer's history rows can outlive it. @@ -513,10 +525,9 @@ export class DeveloperProfilesDatabase { } // Every statement re-checks eligibility (still owned by this caller, - // no published extensions, no pending submission) at the moment it - // runs, rather than trusting the SELECT above: ownership can move - // (an accepted transfer/claim) and a new extension or pending - // submission can appear between that check and this write, and this + // no extensions attached) at the moment it runs, rather than trusting + // the SELECT above: ownership can move (an accepted transfer/claim) and + // a new extension can appear between that check and this write, and this // delete is the caller's only authorization check. The same guard is // repeated on all three statements — not just the last — so they're // all-or-nothing: if it fails, nothing here is touched, instead of @@ -533,12 +544,7 @@ export class DeveloperProfilesDatabase { // the inner table shadow the outer and pass whenever *any* profile were // deletable. Parameters, in order: owner user id, then active user id. const deletable = (dev: string) => `${dev}.owner_user_id = ? - AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = ${dev}.id) - AND NOT EXISTS ( - SELECT 1 FROM extension_submissions - WHERE extension_submissions.developer_id = ${dev}.id - AND extension_submissions.status = 'pending' - ) + AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.developer_id = ${dev}.id) AND EXISTS ( SELECT 1 FROM users active_user WHERE active_user.id = ? AND active_user.deleted_at IS NULL diff --git a/src/services/extensions/v2/db/developer-transfers.ts b/src/services/extensions/v2/db/developer-transfers.ts index 819c752..c8c08e4 100644 --- a/src/services/extensions/v2/db/developer-transfers.ts +++ b/src/services/extensions/v2/db/developer-transfers.ts @@ -286,7 +286,7 @@ export class DeveloperTransfersDatabase { // pending work attached to a profile whose owner just changed would put // it in front of the wrong moderator. const rejectPendingIn = ( - table: "extension_submissions" | "developer_claims" + table: "extension_revisions" | "developer_claims" ) => toD1Statement(this.db.$client, { sql: `UPDATE ${table} @@ -297,9 +297,7 @@ export class DeveloperTransfersDatabase { AND status = 'pending'`, params: [tokenHash, userId] }); - const rejectPendingSubmissionsStmt = rejectPendingIn( - "extension_submissions" - ); + const rejectPendingRevisionsStmt = rejectPendingIn("extension_revisions"); const rejectPendingClaimsStmt = rejectPendingIn("developer_claims"); let results; @@ -308,7 +306,7 @@ export class DeveloperTransfersDatabase { claimStmt, updateDeveloperStmt, assertTransferStmt, - rejectPendingSubmissionsStmt, + rejectPendingRevisionsStmt, rejectPendingClaimsStmt ]); } catch (error) { diff --git a/src/services/extensions/v2/db/extensions.ts b/src/services/extensions/v2/db/extensions.ts index 31cdd99..77fa8ef 100644 --- a/src/services/extensions/v2/db/extensions.ts +++ b/src/services/extensions/v2/db/extensions.ts @@ -1,25 +1,41 @@ -import { and, asc, eq, or, sql } from "drizzle-orm"; +import { and, asc, eq, isNotNull, or, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/sqlite-core"; import { DatabaseResult } from "../../../../lib/interfaces"; import { ExtensionsDb } from "../../../../lib/db"; import { sortReleasesDescending } from "../../../../lib/releases"; import { parseJSON } from "../../../../lib/json"; -import { extensions, developers } from "./schema"; +import { extensions, extensionRevisions, developers } from "./schema"; import { databaseError } from "./errors"; +import { toD1Statement } from "./batch"; import { encodeCursor as encode, decodeCursor as decode } from "./cursor"; import { Extension, + ExtensionContent, ExtensionListItem, License, + OwnedExtension, + OwnedExtensionListItem, Release, Repository } from "../schemas/extensions"; +import { PublicDeveloper } from "../schemas/developers"; -// LEFT JOIN defensively preserves catalogue reads if a legacy/corrupt row -// points at a missing developer. The current baseline enforces the -// extensions.author_id foreign key; COALESCE still keeps the embedded id -// available for any historical data that predates that constraint. -const EXTENSION_COLUMNS = { - id: extensions.id, +export const MAX_PENDING_REVISIONS_PER_USER = 10; + +// Joined with an inner join everywhere below: developer_id is NOT NULL with a +// foreign key D1 enforces, and migration 0021 fails the deploy rather than +// carry a dangling one through its rebuild. +const DEVELOPER_COLUMNS = { + developerId: developers.id, + developerType: developers.type, + developerName: developers.name, + developerUrl: developers.url, + developerAvatarUrl: developers.avatarUrl, + developerApprovedAt: developers.approvedAt, + developerOwnerUserId: developers.ownerUserId +}; + +const CONTENT_COLUMNS = { type: extensions.type, name: extensions.name, description: extensions.description, @@ -30,17 +46,16 @@ const EXTENSION_COLUMNS = { readme: extensions.readme, source: extensions.source, version: extensions.version, - downloadUrl: extensions.downloadUrl, - developerId: sql`COALESCE(${developers.id}, ${extensions.authorId})`, - developerType: developers.type, - developerName: developers.name, - developerUrl: developers.url, - developerAvatarUrl: developers.avatarUrl, - developerApprovedAt: developers.approvedAt, - developerOwnerUserId: developers.ownerUserId + downloadUrl: extensions.downloadUrl +}; + +const EXTENSION_COLUMNS = { + id: extensions.id, + ...CONTENT_COLUMNS, + ...DEVELOPER_COLUMNS }; -// Derived by subtraction so a column added to EXTENSION_COLUMNS cannot be +// Derived by subtraction so a column added to CONTENT_COLUMNS cannot be // forgotten here: catalogue cards omit only the two large fields. const { readme: _readme, @@ -48,7 +63,102 @@ const { ...EXTENSION_LIST_COLUMNS } = EXTENSION_COLUMNS; -interface ExtensionRow { +// The owner view joins extension_revisions twice: once for the unreviewed +// edit (at most one - idx_extension_revisions_pending), once for the most +// recent decision. "Most recently reviewed" is not expressible as a join +// predicate, so that side matches on a correlated subquery instead. +const PENDING = alias(extensionRevisions, "pending"); +const REVIEWED = alias(extensionRevisions, "reviewed"); + +const PENDING_JOIN = and( + eq(PENDING.extensionId, extensions.id), + eq(PENDING.status, "pending") +)!; + +const REVIEWED_JOIN = eq( + REVIEWED.id, + sql`( + SELECT r.id FROM ${extensionRevisions} r + WHERE r.extension_id = ${extensions.id} + AND r.status IN ('approved', 'rejected') + ORDER BY r.reviewed_at DESC, r.id DESC + LIMIT 1 + )` +); + +const REVIEW_COLUMNS = { + pendingId: PENDING.id, + pendingCreatedAt: PENDING.createdAt, + reviewedId: REVIEWED.id, + reviewedStatus: REVIEWED.status, + reviewedNote: REVIEWED.reviewNote, + reviewedAt: REVIEWED.reviewedAt +}; + +const { + readme: _ownedReadme, + releases: _ownedReleases, + ...CARD_CONTENT_COLUMNS +} = CONTENT_COLUMNS; + +// The owner list drops the same two large published fields the catalogue does, +// and the pending revision's stored content (up to 256 KiB per row) with it. +const OWNED_LIST_COLUMNS = { + id: extensions.id, + publishedAt: extensions.publishedAt, + createdAt: extensions.createdAt, + updatedAt: extensions.updatedAt, + ...CARD_CONTENT_COLUMNS, + ...DEVELOPER_COLUMNS, + ...REVIEW_COLUMNS +}; + +const OWNED_COLUMNS = { + ...OWNED_LIST_COLUMNS, + readme: extensions.readme, + releases: extensions.releases, + pendingContent: PENDING.content +}; + +// Repeated rather than factored out: drizzle's builder types are keyed on the +// selection, so a generic wrapper over it loses the join methods. +const ownedListQuery = (db: ExtensionsDb) => + db + .select(OWNED_LIST_COLUMNS) + .from(extensions) + .innerJoin(developers, eq(extensions.developerId, developers.id)) + .leftJoin(PENDING, PENDING_JOIN) + .leftJoin(REVIEWED, REVIEWED_JOIN); + +const ownedQuery = (db: ExtensionsDb) => + db + .select(OWNED_COLUMNS) + .from(extensions) + .innerJoin(developers, eq(extensions.developerId, developers.id)) + .leftJoin(PENDING, PENDING_JOIN) + .leftJoin(REVIEWED, REVIEWED_JOIN); + +// Taken from the queries rather than restated, so a column added to either +// select map cannot drift from what the parsers below expect. +type OwnedListRow = Awaited>[number]; +type OwnedRow = Awaited>[number]; + +interface DeveloperRow { + developerId: string; + developerType: string; + developerName: string; + developerUrl: string | null; + developerAvatarUrl: string | null; + developerApprovedAt: string | null; + developerOwnerUserId: string | null; +} + +// The content columns are nullable in the table (an extension exists before +// it is published) but every query that produces this row filters on +// published_at IS NOT NULL, and extensions_published_content_check makes that +// filter sufficient: a published row cannot be missing any of them. That +// constraint is what makes the non-null types here sound. +interface PublishedRow extends DeveloperRow { id: string; type: string; name: string; @@ -61,16 +171,9 @@ interface ExtensionRow { source: string; version: string; downloadUrl: string; - developerId: string; - developerType: string | null; - developerName: string | null; - developerUrl: string | null; - developerAvatarUrl: string | null; - developerApprovedAt: string | null; - developerOwnerUserId: string | null; } -type ExtensionListRow = Omit; +type PublishedListRow = Omit; export interface ExtensionListFilters { type?: string; @@ -85,11 +188,25 @@ export interface ExtensionListPage { hasMore: boolean; } +export interface OwnedExtensionListPage { + items: OwnedExtensionListItem[]; + nextCursor: string | null; + hasMore: boolean; +} + interface ExtensionCursor { normalizedId: string; id: string; } +export interface CreateExtensionInput { + extensionId: string; + developerId: string; + ownershipEpoch: number; + submittedBy: string; + content: ExtensionContent; +} + export class ExtensionsDatabase { constructor(private db: ExtensionsDb) {} @@ -97,54 +214,36 @@ export class ExtensionsDatabase { filters: ExtensionListFilters = {} ): Promise> { const limit = filters.limit ?? 50; - const conditions = []; + const conditions = [isNotNull(extensions.publishedAt)]; if (filters.type) conditions.push(eq(extensions.type, filters.type)); if (filters.developerId) - conditions.push(eq(extensions.authorId, filters.developerId)); + conditions.push(eq(extensions.developerId, filters.developerId)); if (filters.cursor) { const cursor = decodeCursor(filters.cursor); - if (!cursor) { - return { - data: null, - error: { - message: "Invalid pagination cursor", - code: "INVALID_CURSOR" - } - }; - } - conditions.push( - or( - sql`LOWER(${extensions.id}) > ${cursor.normalizedId}`, - and( - sql`LOWER(${extensions.id}) = ${cursor.normalizedId}`, - sql`${extensions.id} > ${cursor.id}` - ) - )! - ); + if (!cursor) return invalidCursor(); + conditions.push(keysetAfter(cursor)); } - let rows: ExtensionListRow[]; + let rows: PublishedListRow[]; try { - const query = this.db + rows = (await this.db .select(EXTENSION_LIST_COLUMNS) .from(extensions) - .leftJoin(developers, eq(extensions.authorId, developers.id)); - rows = await query - .where(conditions.length ? and(...conditions) : undefined) + .innerJoin(developers, eq(extensions.developerId, developers.id)) + .where(and(...conditions)) .orderBy(asc(sql`LOWER(${extensions.id})`), asc(extensions.id)) - .limit(limit + 1); + .limit(limit + 1)) as PublishedListRow[]; } catch (error) { return databaseError("list", error); } const hasMore = rows.length > limit; const pageRows = rows.slice(0, limit); - const items = pageRows.map(parseExtensionListRow); const last = pageRows.at(-1); return { data: { - items, + items: pageRows.map(parseListRow), hasMore, nextCursor: hasMore && last ? encodeCursor(last.id) : null }, @@ -153,32 +252,248 @@ export class ExtensionsDatabase { } async getById(id: string): Promise> { - let rows: ExtensionRow[]; + let rows: PublishedRow[]; try { - rows = await this.db + rows = (await this.db .select(EXTENSION_COLUMNS) .from(extensions) - .leftJoin(developers, eq(extensions.authorId, developers.id)) - .where(sql`LOWER(${extensions.id}) = LOWER(${id})`); + .innerJoin(developers, eq(extensions.developerId, developers.id)) + .where( + and( + sql`LOWER(${extensions.id}) = LOWER(${id})`, + isNotNull(extensions.publishedAt) + ) + )) as PublishedRow[]; } catch (error) { return databaseError("getById", error); } const row = rows[0]; - if (!row) { + if (!row) return notFound(id); + return { data: parseRow(row), error: null }; + } + + async listOwned(filters: { + developerId: string; + type?: string; + limit?: number; + cursor?: string; + }): Promise> { + const limit = filters.limit ?? 50; + const conditions = [eq(extensions.developerId, filters.developerId)]; + if (filters.type) conditions.push(eq(extensions.type, filters.type)); + if (filters.cursor) { + const cursor = decodeCursor(filters.cursor); + if (!cursor) return invalidCursor(); + conditions.push(keysetAfter(cursor)); + } + + let rows: OwnedListRow[]; + try { + rows = await ownedListQuery(this.db) + .where(and(...conditions)) + .orderBy(asc(sql`LOWER(${extensions.id})`), asc(extensions.id)) + .limit(limit + 1); + } catch (error) { + return databaseError("listOwned", error); + } + + const hasMore = rows.length > limit; + const pageRows = rows.slice(0, limit); + const last = pageRows.at(-1); + return { + data: { + items: pageRows.map(parseOwnedListRow), + hasMore, + nextCursor: hasMore && last ? encodeCursor(last.id) : null + }, + error: null + }; + } + + // Returns the owner view plus the two ids a route needs to authorise the + // caller, so a detail read is one query rather than a fetch-then-check. + async getOwned( + id: string + ): Promise< + DatabaseResult<{ extension: OwnedExtension; ownerUserId: string | null }> + > { + let rows: OwnedRow[]; + try { + rows = await ownedQuery(this.db).where( + sql`LOWER(${extensions.id}) = LOWER(${id})` + ); + } catch (error) { + return databaseError("getOwned", error); + } + + const row = rows[0]; + if (!row) return notFound(id); + return { + data: { + extension: parseOwnedRow(row), + ownerUserId: row.developerOwnerUserId + }, + error: null + }; + } + + // Creates the extension record and its first pending revision as one + // transaction. The revision insert is gated on `changes() = 1` from the + // preceding statement (SQLite's per-connection changes()), so an id + // collision or a failed ownership guard leaves neither row behind. See + // toD1Statement for why this is raw sql rather than two builder calls. + async create( + input: CreateExtensionInput + ): Promise> { + const revisionId = crypto.randomUUID(); + + let results; + try { + const extensionStmt = toD1Statement(this.db.$client, { + sql: `INSERT INTO extensions (id, developer_id, created_at, updated_at) + SELECT ?, d.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM developers d + WHERE d.id = ? AND d.owner_user_id = ? AND d.ownership_epoch = ? + AND EXISTS ( + SELECT 1 FROM users u WHERE u.id = ? AND u.deleted_at IS NULL + ) + AND ( + SELECT COUNT(*) FROM extension_revisions + WHERE submitted_by = ? AND status = 'pending' + ) < ? + ON CONFLICT DO NOTHING`, + params: [ + input.extensionId, + input.developerId, + input.submittedBy, + input.ownershipEpoch, + input.submittedBy, + input.submittedBy, + MAX_PENDING_REVISIONS_PER_USER + ] + }); + + const revisionStmt = toD1Statement(this.db.$client, { + sql: `INSERT INTO extension_revisions + (id, extension_id, developer_id, submitted_by, status, content, ownership_epoch) + SELECT ?, ?, ?, ?, 'pending', ?, ? + WHERE changes() = 1`, + params: [ + revisionId, + input.extensionId, + input.developerId, + input.submittedBy, + JSON.stringify(input.content), + input.ownershipEpoch + ] + }); + + results = await this.db.$client.batch([extensionStmt, revisionStmt]); + } catch (error) { + return databaseError("create", error); + } + + if (!results[0]?.meta?.changes) { + try { + return { data: null, error: await this.createBlockedError(input) }; + } catch (error) { + return databaseError("create", error); + } + } + + return { data: { id: input.extensionId, revisionId }, error: null }; + } + + // The insert affected no rows: either ON CONFLICT DO NOTHING swallowed an id + // collision, or the WHERE guard rejected the caller. Only the first has a + // specific message, so look for the row that would have caused it. + private async createBlockedError( + input: CreateExtensionInput + ): Promise<{ message: string; code: string }> { + const [taken] = await this.db + .select({ one: sql`1` }) + .from(extensions) + .where(sql`LOWER(${extensions.id}) = LOWER(${input.extensionId})`); + if (taken) { + return { + message: "An extension with this id already exists", + code: "CONFLICT" + }; + } + return { + message: + "Extension could not be created because ownership changed or the pending-revision limit was reached", + code: "CONFLICT" + }; + } + + // Withdrawing is only offered while an extension has never been published: + // once it is in the catalogue, consumers pin its id and removing it is a + // moderator's decision, not the owner's. + async withdraw( + id: string, + ownerUserId: string + ): Promise> { + let result; + try { + result = await this.db.run(sql` + DELETE FROM ${extensions} + WHERE id = ${id} + AND published_at IS NULL + AND developer_id IN ( + SELECT d.id FROM ${developers} d WHERE d.owner_user_id = ${ownerUserId} + ) + `); + } catch (error) { + return databaseError("withdraw", error); + } + + if (!result.meta?.changes) { + const [existing] = await this.db + .select({ publishedAt: extensions.publishedAt }) + .from(extensions) + .where(eq(extensions.id, id)); + if (!existing) return notFound(id); return { data: null, - error: { - message: `Cannot find extension by id: ${id}`, - code: "NOT_FOUND" - } + error: existing.publishedAt + ? { + message: "A published extension cannot be withdrawn", + code: "CONFLICT" + } + : { message: "You do not own this extension", code: "FORBIDDEN" } }; } - return { data: parseExtensionRow(row), error: null }; + return { data: { id }, error: null }; } } +function invalidCursor(): DatabaseResult { + return { + data: null, + error: { message: "Invalid pagination cursor", code: "INVALID_CURSOR" } + }; +} + +function notFound(id: string): DatabaseResult { + return { + data: null, + error: { message: `Cannot find extension by id: ${id}`, code: "NOT_FOUND" } + }; +} + +function keysetAfter(cursor: ExtensionCursor) { + return or( + sql`LOWER(${extensions.id}) > ${cursor.normalizedId}`, + and( + sql`LOWER(${extensions.id}) = ${cursor.normalizedId}`, + sql`${extensions.id} > ${cursor.id}` + ) + )!; +} + function encodeCursor(id: string): string { return encode({ normalizedId: id.toLowerCase(), id }); } @@ -204,10 +519,21 @@ export function isValidExtensionCursor(value: string): boolean { return decodeCursor(value) !== null; } +function parseDeveloper(row: DeveloperRow): PublicDeveloper { + return { + id: row.developerId, + type: row.developerType as "user" | "organization", + name: row.developerName, + URL: row.developerUrl ?? undefined, + avatar_url: row.developerAvatarUrl ?? undefined, + approved: row.developerApprovedAt !== null, + unclaimed: row.developerOwnerUserId === null + }; +} + // Shared by both parsers so the catalogue card and the detail view can never -// disagree about the embedded developer, or about the defaults applied when -// the LEFT JOIN above found no developer row. -function parseExtensionListRow(row: ExtensionListRow): ExtensionListItem { +// disagree about the embedded developer. +function parseListRow(row: PublishedListRow): ExtensionListItem { return { id: row.id, type: row.type as ExtensionListItem["type"], @@ -219,24 +545,95 @@ function parseExtensionListRow(row: ExtensionListRow): ExtensionListItem { source: parseJSON(row.source, { type: "custom", repo: "" }), version: row.version, download_url: row.downloadUrl, - developer: { - id: row.developerId, - type: (row.developerType as "user" | "organization") ?? "user", - name: row.developerName ?? "", - URL: row.developerUrl ?? undefined, - avatar_url: row.developerAvatarUrl ?? undefined, - approved: row.developerApprovedAt !== null, - unclaimed: row.developerOwnerUserId === null - } + developer: parseDeveloper(row) }; } // The detail view is the list projection plus the two large fields the // catalogue query deliberately omits. -function parseExtensionRow(row: ExtensionRow): Extension { +function parseRow(row: PublishedRow): Extension { return { - ...parseExtensionListRow(row), + ...parseListRow(row), readme: row.readme, releases: sortReleasesDescending(parseJSON(row.releases, [])) }; } + +// Only ever called for a row whose published_at is set, where +// extensions_published_content_check guarantees each of these is present. +function publishedContent( + row: OwnedListRow +): Omit { + return { + type: row.type as ExtensionContent["type"], + name: row.name as string, + description: row.description as string, + website: row.website as string, + license: parseJSON(row.license as string, { name: "" }), + icon_url: row.iconUrl ?? undefined, + source: parseJSON(row.source as string, { + type: "custom", + repo: "" + }), + version: row.version as string, + download_url: row.downloadUrl as string + }; +} + +function parseOwnedListRow(row: OwnedListRow): OwnedExtensionListItem { + return { + id: row.id, + developer: parseDeveloper(row), + published: row.publishedAt ? publishedContent(row) : null, + pending_revision: + row.pendingId && row.pendingCreatedAt + ? { id: row.pendingId, created_at: row.pendingCreatedAt } + : null, + last_review: row.reviewedId + ? { + revision_id: row.reviewedId, + status: row.reviewedStatus as "approved" | "rejected", + review_note: row.reviewedNote, + reviewed_at: row.reviewedAt + } + : null, + created_at: row.createdAt, + updated_at: row.updatedAt + }; +} + +function parseOwnedRow(row: OwnedRow): OwnedExtension { + return { + ...parseOwnedListRow(row), + published: row.publishedAt + ? { + ...publishedContent(row), + readme: row.readme as string, + releases: sortReleasesDescending( + parseJSON(row.releases as string, []) + ) + } + : null, + pending_revision: + row.pendingId && row.pendingCreatedAt + ? { + id: row.pendingId, + created_at: row.pendingCreatedAt, + content: parseContent(row.pendingContent) + } + : null + }; +} + +// Migrated revisions can hold content that predates the current schema (see +// migration 0021), so releases is defaulted rather than assumed. +export function parseContent(stored: string | null): ExtensionContent { + const content = parseJSON( + stored ?? "", + {} as ExtensionContent + ); + return { + ...content, + releases: sortReleasesDescending(content.releases ?? []) + }; +} diff --git a/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql b/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql new file mode 100644 index 0000000..b17ca35 --- /dev/null +++ b/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql @@ -0,0 +1,255 @@ +-- Make the extension the resource and the review a state of it. +-- +-- Before, an extension only existed once a moderator approved a submission, so +-- a developer's in-flight work lived in extension_submissions under a target id +-- reachable only through the payload JSON. After, `extensions` holds the record +-- from creation, its content columns are the published projection (NULL until +-- the first approval), and extension_submissions becomes extension_revisions: +-- one proposed content version, always attached to a real extension row. +-- +-- The tables are rebuilt rather than ALTERed because SQLite cannot relax NOT +-- NULL, add a CHECK, or add a foreign key in place. Two renames ride along, +-- since the rebuild is already paid for: extensions.author_id becomes +-- developer_id, and developers' created_at/updated_at lose the placeholder 1970 +-- default that migration 0002 was forced to use and no writer ever produced. +-- +-- Hand-written, not drizzle-kit-generated: the generated diff cannot infer the +-- table rename or the backfills below non-interactively, so only the snapshot +-- in meta/0021_snapshot.json comes from drizzle-kit. The end state is verified +-- against schema.ts by test/services/extensions/v2/migrations.test.ts. +PRAGMA foreign_keys=OFF;--> statement-breakpoint + +-- developers first, while every table that references it is still the old one: +-- the drop-and-rename re-parses every schema, and doing it with a referrer +-- pointing at a dropped table is the case that errors. +CREATE TABLE `__new_developers` ( + `id` text PRIMARY KEY NOT NULL, + `type` text NOT NULL, + `name` text NOT NULL, + `url` text, + `owner_user_id` text, + `approved_at` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `avatar_url` text, + `contact_email` text, + `ownership_epoch` integer DEFAULT 1 NOT NULL, + `content_revision` integer DEFAULT 1 NOT NULL, + `approved_revision` integer, + `approved_by` text, + `github_org_verified` integer, + `github_verification_note` text, + `github_verified_at` text, + `github_url_verified` integer, + `url_check_cooldown_until` text, + FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "developers_ownership_epoch_check" CHECK("__new_developers"."ownership_epoch" >= 1), + CONSTRAINT "developers_content_revision_check" CHECK("__new_developers"."content_revision" >= 1), + CONSTRAINT "developers_github_org_verified_check" CHECK("__new_developers"."github_org_verified" IN (0, 1)), + CONSTRAINT "developers_github_url_verified_check" CHECK("__new_developers"."github_url_verified" = 1) +);--> statement-breakpoint + +-- Existing 1970 values stay. They are wrong, but they are the only record +-- those rows have, and a timestamp invented here would look real without +-- being so. +INSERT INTO `__new_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 +) +SELECT + 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 +FROM `developers`;--> statement-breakpoint + +DROP TABLE `developers`;--> statement-breakpoint +ALTER TABLE `__new_developers` RENAME TO `developers`;--> 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 TABLE `__new_extensions` ( + `id` text PRIMARY KEY 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, + FOREIGN KEY (`developer_id`) REFERENCES `developers`(`id`) ON UPDATE no action ON DELETE no action, + CONSTRAINT "extensions_published_content_check" CHECK("__new_extensions"."published_at" IS NULL OR ( + "__new_extensions"."type" IS NOT NULL AND "__new_extensions"."name" IS NOT NULL AND + "__new_extensions"."description" IS NOT NULL AND "__new_extensions"."releases" IS NOT NULL AND + "__new_extensions"."website" IS NOT NULL AND "__new_extensions"."license" IS NOT NULL AND + "__new_extensions"."readme" IS NOT NULL AND "__new_extensions"."source" IS NOT NULL AND + "__new_extensions"."version" IS NOT NULL AND "__new_extensions"."download_url" IS NOT NULL + )) +);--> statement-breakpoint + +-- Every row that existed before this migration is, by definition, live in the +-- public catalogue, so it is published. published_revision_id stays NULL: +-- these rows were adopted or approved before revisions were addressable, and +-- inventing a revision id for them would fabricate a review that never +-- happened. +INSERT INTO `__new_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 +) +SELECT + id, author_id, CURRENT_TIMESTAMP, NULL, type, name, description, + releases, website, license, icon_url, readme, source, version, download_url, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM `extensions`;--> statement-breakpoint + +-- Materialise the extension record every existing submission was targeting. +-- A submission that named an extension row already covered above is skipped; +-- one that proposed a brand-new id becomes an unpublished extension owned by +-- the developer the submission named. Rejected submissions get a row too: an +-- unpublished extension whose only revision was rejected is a real state the +-- owner can see and resubmit from, and it keeps the FK below total. +-- +-- Ties on a target id are resolved by newest submission, matching the pending +-- unique index that only ever allowed one live claim on it. +INSERT INTO `__new_extensions` (id, developer_id, published_at, created_at, updated_at) +SELECT + target.target_id, + ( + SELECT s.developer_id + FROM extension_submissions s + WHERE LOWER(COALESCE(s.extension_id, json_extract(s.payload, '$.extension.id'))) = target.target_id + ORDER BY s.created_at DESC, s.id DESC + LIMIT 1 + ), + NULL, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM ( + SELECT DISTINCT + LOWER(COALESCE(extension_id, json_extract(payload, '$.extension.id'))) AS target_id + FROM extension_submissions +) AS target +WHERE target.target_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM `__new_extensions` e WHERE LOWER(e.id) = target.target_id + ) + -- A submission whose developer no longer exists cannot produce a row that + -- satisfies the developer_id foreign key. Dropping it here loses only an + -- unreviewable record: the developer it was filed under is already gone. + AND EXISTS ( + SELECT 1 FROM developers d + WHERE d.id = ( + SELECT s.developer_id + FROM extension_submissions s + WHERE LOWER(COALESCE(s.extension_id, json_extract(s.payload, '$.extension.id'))) = target.target_id + ORDER BY s.created_at DESC, s.id DESC + LIMIT 1 + ) + );--> statement-breakpoint + +DROP TABLE `extensions`;--> statement-breakpoint +ALTER TABLE `__new_extensions` RENAME TO `extensions`;--> statement-breakpoint + +CREATE UNIQUE INDEX `idx_extensions_id_nocase` ON `extensions` (lower("id"));--> statement-breakpoint +CREATE INDEX `idx_extensions_developer` ON `extensions` (`developer_id`);--> statement-breakpoint +CREATE INDEX `idx_extensions_catalogue_order` ON `extensions` (lower("id"),`id`) WHERE "extensions"."published_at" IS NOT NULL;--> statement-breakpoint +CREATE INDEX `idx_extensions_type_catalogue_order` ON `extensions` (`type`,lower("id"),`id`) WHERE "extensions"."published_at" IS NOT NULL;--> statement-breakpoint +CREATE INDEX `idx_extensions_developer_catalogue_order` ON `extensions` (`developer_id`,lower("id"),`id`) WHERE "extensions"."published_at" IS NOT NULL;--> 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, + 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 + +-- payload was {developer, extension}; content is the extension half alone, +-- minus its id. The developer half is dropped on purpose: approving an +-- extension used to rewrite the developer profile and reset its approval as a +-- side effect. Developer edits are PUT /developers/me's job and always were. +-- The id moves out because it is now the extension row's identity rather than +-- a field a revision proposes; an extension cannot be renamed by an edit. +-- +-- extension_id is resolved through the new extensions table so it matches the +-- row this migration just materialised, including when the submission's +-- proposed id differed from it only in case. +INSERT INTO `extension_revisions` ( + id, extension_id, developer_id, submitted_by, status, content, reviewer_id, + review_note, created_at, reviewed_at, ownership_epoch +) +SELECT + s.id, + e.id, + s.developer_id, + s.submitted_by, + s.status, + json_remove(json_extract(s.payload, '$.extension'), '$.id'), + s.reviewer_id, + s.review_note, + s.created_at, + s.reviewed_at, + s.ownership_epoch +FROM extension_submissions s +JOIN extensions e + ON LOWER(e.id) = LOWER(COALESCE(s.extension_id, json_extract(s.payload, '$.extension.id'))) +-- A payload without an extension object cannot become a revision. This has +-- never been writable through the API: SubmissionPayloadSchema required it. +WHERE json_extract(s.payload, '$.extension') IS NOT NULL;--> 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 + +DROP TABLE `extension_submissions`;--> statement-breakpoint + +-- The rebuilds above run with foreign_keys=OFF, which means SQLite does not +-- re-validate the copied rows against the new declarations - a pre-existing +-- extension pointing at a developer that no longer exists would be carried +-- through silently, and every read would then have to defend against it +-- forever. Fail the deploy instead, and let the reads assume the join always +-- matches. Same CHECK-on-a-scratch-table trick as migration 0020, for the +-- same reason: SQLite has no RAISE() outside a trigger. +CREATE TABLE _orphan_check (ok INTEGER NOT NULL CHECK (ok = 1));--> statement-breakpoint + +INSERT INTO _orphan_check (ok) +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM extensions e + WHERE NOT EXISTS (SELECT 1 FROM developers d WHERE d.id = e.developer_id) + ) OR EXISTS ( + SELECT 1 FROM extension_revisions r + WHERE NOT EXISTS (SELECT 1 FROM extensions e WHERE e.id = r.extension_id) + ) THEN 0 ELSE 1 END;--> statement-breakpoint + +DROP TABLE _orphan_check;--> statement-breakpoint + +PRAGMA foreign_keys=ON; diff --git a/src/services/extensions/v2/db/migrations/meta/0021_snapshot.json b/src/services/extensions/v2/db/migrations/meta/0021_snapshot.json new file mode 100644 index 0000000..ca15e11 --- /dev/null +++ b/src/services/extensions/v2/db/migrations/meta/0021_snapshot.json @@ -0,0 +1,1028 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "463ee99b-e53e-43a0-8b47-ae34518b5940", + "prevId": "18f3867b-3724-4851-a60a-9caeec1cb815", + "tables": { + "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 + } + }, + "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 + }, + "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": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "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 + }, + "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_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_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 + }, + "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_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 + } + }, + "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" + } + } + }, + "extensions": { + "name": "extensions", + "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 + }, + "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 + }, + "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" + } + }, + "indexes": { + "idx_extensions_id_nocase": { + "name": "idx_extensions_id_nocase", + "columns": ["lower(\"id\")"], + "isUnique": true + }, + "idx_extensions_developer": { + "name": "idx_extensions_developer", + "columns": ["developer_id"], + "isUnique": false + }, + "idx_extensions_catalogue_order": { + "name": "idx_extensions_catalogue_order", + "columns": ["lower(\"id\")", "id"], + "isUnique": false, + "where": "\"extensions\".\"published_at\" IS NOT NULL" + }, + "idx_extensions_type_catalogue_order": { + "name": "idx_extensions_type_catalogue_order", + "columns": ["type", "lower(\"id\")", "id"], + "isUnique": false, + "where": "\"extensions\".\"published_at\" IS NOT NULL" + }, + "idx_extensions_developer_catalogue_order": { + "name": "idx_extensions_developer_catalogue_order", + "columns": ["developer_id", "lower(\"id\")", "id"], + "isUnique": false, + "where": "\"extensions\".\"published_at\" IS NOT 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 )" + } + } + }, + "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_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_id_nocase": { + "columns": { + "lower(\"id\")": { + "isExpression": true + } + } + }, + "idx_extensions_catalogue_order": { + "columns": { + "lower(\"id\")": { + "isExpression": true + } + } + }, + "idx_extensions_type_catalogue_order": { + "columns": { + "lower(\"id\")": { + "isExpression": true + } + } + }, + "idx_extensions_developer_catalogue_order": { + "columns": { + "lower(\"id\")": { + "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 cf97cfe..1b19840 100644 --- a/src/services/extensions/v2/db/migrations/meta/_journal.json +++ b/src/services/extensions/v2/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1785916611193, "tag": "0020_check_reserved_route_ids", "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1785916611194, + "tag": "0021_restructure_extensions_revisions", + "breakpoints": true } ] } diff --git a/src/services/extensions/v2/db/revisions.ts b/src/services/extensions/v2/db/revisions.ts new file mode 100644 index 0000000..387a78f --- /dev/null +++ b/src/services/extensions/v2/db/revisions.ts @@ -0,0 +1,470 @@ +import { and, asc, desc, eq, gt, lt, or, sql, SQL } from "drizzle-orm"; +import { DatabaseResult } from "../../../../lib/interfaces"; +import { ExtensionsDb } from "../../../../lib/db"; +import { extensionRevisions, developers, extensions, users } from "./schema"; +import { databaseError } from "./errors"; +import { toD1Statement } from "./batch"; +import { encodeCursor as encode, decodeCursor as decode } from "./cursor"; +import { MAX_PENDING_REVISIONS_PER_USER, parseContent } from "./extensions"; +import { ExtensionContent } from "../schemas/extensions"; +import { ExtensionRevision, RevisionStatus } from "../schemas/revisions"; + +export interface RevisionPage { + items: ExtensionRevision[]; + nextCursor: string | null; + hasMore: boolean; +} + +interface StoredRevision extends ExtensionRevision { + ownershipEpoch: number; +} + +interface RevisionCursor { + createdAt: string; + id: string; +} + +function encodeCursor(createdAt: string, id: string): string { + return encode({ createdAt, id }); +} + +function isRevisionCursor( + parsed: Record +): parsed is RevisionCursor & Record { + return typeof parsed.createdAt === "string" && typeof parsed.id === "string"; +} + +function decodeCursor(cursor: string): RevisionCursor | null { + return decode(cursor, isRevisionCursor); +} + +interface RevisionRow { + id: string; + extensionId: string; + developerId: string; + submittedBy: string; + status: string; + content: string; + reviewerId: string | null; + reviewNote: string | null; + createdAt: string; + reviewedAt: string | null; + ownershipEpoch: number; +} + +function parseRevisionRow(row: RevisionRow): StoredRevision { + const revision = { + id: row.id, + extension_id: row.extensionId, + developer_id: row.developerId, + submitted_by: row.submittedBy, + status: row.status as RevisionStatus, + content: parseContent(row.content), + reviewer_id: row.reviewerId, + review_note: row.reviewNote, + created_at: row.createdAt, + reviewed_at: row.reviewedAt + } as StoredRevision; + Object.defineProperty(revision, "ownershipEpoch", { + value: Number(row.ownershipEpoch ?? 1), + enumerable: false + }); + return revision; +} + +const REVISION_COLUMNS = { + id: extensionRevisions.id, + extensionId: extensionRevisions.extensionId, + developerId: extensionRevisions.developerId, + submittedBy: extensionRevisions.submittedBy, + status: extensionRevisions.status, + content: extensionRevisions.content, + reviewerId: extensionRevisions.reviewerId, + reviewNote: extensionRevisions.reviewNote, + createdAt: extensionRevisions.createdAt, + reviewedAt: extensionRevisions.reviewedAt, + ownershipEpoch: extensionRevisions.ownershipEpoch +}; + +export class ExtensionRevisionsDatabase { + constructor(private db: ExtensionsDb) {} + + // Proposes an edit to an existing extension. Ownership is resolved inside + // the insert rather than by a preceding SELECT: the extension names its + // developer and the developer names its owner, so one statement can assert + // "the caller still owns this extension" atomically with the write. + async propose(input: { + extensionId: string; + callerId: string; + content: ExtensionContent; + }): Promise> { + const id = crypto.randomUUID(); + + let result; + try { + result = await this.db.run(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 + FROM ${extensions} e + JOIN ${developers} d ON d.id = e.developer_id + WHERE e.id = ${input.extensionId} + AND d.owner_user_id = ${input.callerId} + AND EXISTS ( + SELECT 1 FROM ${users} u + WHERE u.id = ${input.callerId} AND u.deleted_at IS NULL + ) + AND ( + SELECT COUNT(*) FROM ${extensionRevisions} + WHERE submitted_by = ${input.callerId} AND status = 'pending' + ) < ${MAX_PENDING_REVISIONS_PER_USER} + ON CONFLICT DO NOTHING + `); + } catch (error) { + return databaseError("propose", error); + } + + if (!result.meta?.changes) { + try { + return { data: null, error: await this.proposeBlockedError(input) }; + } catch (error) { + return databaseError("propose", error); + } + } + + return { data: { id }, error: null }; + } + + // Distinguishes the three ways the guard above can reject a write, so the + // route can map them to 404/403/409 rather than one opaque conflict. + private async proposeBlockedError(input: { + extensionId: string; + callerId: string; + }): Promise<{ message: string; code: string }> { + const [existing] = await this.db + .select({ ownerUserId: developers.ownerUserId }) + .from(extensions) + .innerJoin(developers, eq(extensions.developerId, developers.id)) + .where(eq(extensions.id, input.extensionId)); + + if (!existing) { + return { + message: `Cannot find extension by id: ${input.extensionId}`, + code: "NOT_FOUND" + }; + } + if (existing.ownerUserId !== input.callerId) { + return { + message: "You do not own this extension", + code: "FORBIDDEN" + }; + } + + const [pending] = await this.db + .select({ one: sql`1` }) + .from(extensionRevisions) + .where( + and( + eq(extensionRevisions.extensionId, input.extensionId), + eq(extensionRevisions.status, "pending") + ) + ); + if (pending) { + return { + message: "An edit to this extension is already awaiting review", + code: "CONFLICT" + }; + } + + return { + message: "The pending-revision limit was reached", + code: "CONFLICT" + }; + } + + // listByExtension and listQueue are the same keyset page in opposite + // directions: newest-first for an owner reading an extension's history, + // oldest-first for moderators working a queue front to back. Only the base + // predicate and the direction differ, so the cursor handling, the tie-break + // on id, the limit + 1 probe and the next-cursor tail live here once. + private async page( + context: string, + baseCondition: SQL, + direction: "asc" | "desc", + limit: number, + cursor?: string + ): Promise> { + const decoded = cursor ? decodeCursor(cursor) : null; + if (cursor && !decoded) { + return { + data: null, + error: { message: "Invalid pagination cursor", code: "INVALID_CURSOR" } + }; + } + + const [beyond, order] = + direction === "desc" ? [lt, desc] : ([gt, asc] as const); + + let rows: RevisionRow[]; + try { + const conditions = [baseCondition]; + if (decoded) { + const { createdAt, id: cursorId } = decoded; + conditions.push( + or( + beyond(extensionRevisions.createdAt, createdAt), + and( + eq(extensionRevisions.createdAt, createdAt), + beyond(extensionRevisions.id, cursorId) + ) + )! + ); + } + rows = await this.db + .select(REVISION_COLUMNS) + .from(extensionRevisions) + .where(and(...conditions)) + .orderBy( + order(extensionRevisions.createdAt), + order(extensionRevisions.id) + ) + .limit(limit + 1); + } catch (error) { + return databaseError(context, error); + } + + const hasMore = rows.length > limit; + const items = rows.slice(0, limit).map(parseRevisionRow); + const last = items.at(-1); + return { + data: { + items, + hasMore, + nextCursor: + hasMore && last ? encodeCursor(last.created_at, last.id) : null + }, + error: null + }; + } + + async listByExtension( + extensionId: string, + limit: number, + cursor?: string + ): Promise> { + return this.page( + "listByExtension", + eq(extensionRevisions.extensionId, extensionId), + "desc", + limit, + cursor + ); + } + + async listQueue( + status: RevisionStatus, + limit: number, + cursor?: string + ): Promise> { + return this.page( + "listQueue", + eq(extensionRevisions.status, status), + "asc", + limit, + cursor + ); + } + + async getById( + extensionId: string, + id: string + ): Promise> { + let row: RevisionRow | undefined; + try { + [row] = await this.db + .select(REVISION_COLUMNS) + .from(extensionRevisions) + .where( + and( + eq(extensionRevisions.id, id), + eq(extensionRevisions.extensionId, extensionId) + ) + ); + } catch (error) { + return databaseError("getById", error); + } + + if (!row) return revisionNotFound(id); + return { data: parseRevisionRow(row), error: null }; + } + + // Notes what happened to an id-scoped write that didn't affect any rows: + // either it never existed, or someone else already moved it off 'pending'. + private async explainNoOpTransition( + extensionId: string, + id: string + ): Promise> { + const existing = await this.getById(extensionId, id); + if (existing.error || !existing.data) { + return { + data: null, + error: existing.error ?? revisionNotFound(id).error + }; + } + return { + data: null, + error: { message: "Revision is not pending", code: "CONFLICT" } + }; + } + + // The `AND status = 'pending'` guard makes this a single atomic + // check-and-set: if two moderators race, only one's update affects a row. + async reject( + extensionId: string, + id: string, + reviewerId: string, + reviewNote: string + ): Promise> { + let result; + try { + result = await this.db + .update(extensionRevisions) + .set({ + status: "rejected", + reviewerId, + reviewNote, + reviewedAt: sql`CURRENT_TIMESTAMP` + }) + .where( + and( + eq(extensionRevisions.id, id), + eq(extensionRevisions.extensionId, extensionId), + eq(extensionRevisions.status, "pending"), + sql`EXISTS ( + SELECT 1 FROM ${users} + WHERE ${users.id} = ${reviewerId} AND ${users.deletedAt} IS NULL + )` + ) + ); + } catch (error) { + return databaseError("reject", error); + } + + if (!result.meta?.changes) { + return this.explainNoOpTransition(extensionId, id); + } + + return { data: { id, status: "rejected" }, error: null }; + } + + // Approving publishes the revision's content into the extension row. Unlike + // the pre-0021 flow this touches nothing else: the developer profile is not + // rewritten, and the extension's id and author are not up for review because + // a revision cannot propose them. + async approve( + extensionId: string, + id: string, + reviewerId: string, + reviewNote?: string + ): Promise> { + const existing = await this.getById(extensionId, id); + if (existing.error || !existing.data) { + return { + data: null, + error: existing.error ?? revisionNotFound(id).error + }; + } + const revision = existing.data; + + if (revision.status !== "pending") { + return { + data: null, + error: { message: "Revision is not pending", code: "CONFLICT" } + }; + } + + const content = revision.content; + + // Kept as raw sql via the raw D1 client (see toD1Statement) rather than + // the query builder: D1's batch() executes these two statements as one + // transaction, and the publish is deliberately gated on `changes() = 1` + // from the immediately-preceding statement (SQLite's per-connection + // changes() function) so a race or ownership change caught by the claim's + // WHERE also blocks the publish - a guarantee that's easy to silently + // lose by rewriting this as two independent query-builder calls. + let results; + try { + const claimStmt = toD1Statement(this.db.$client, { + sql: `UPDATE extension_revisions + SET status = 'approved', reviewer_id = ?, review_note = ?, reviewed_at = CURRENT_TIMESTAMP + WHERE id = ? AND extension_id = ? AND status = 'pending' + AND EXISTS ( + SELECT 1 FROM extensions e + JOIN developers d ON d.id = e.developer_id + WHERE e.id = extension_revisions.extension_id + AND d.id = extension_revisions.developer_id + AND d.owner_user_id = extension_revisions.submitted_by + AND d.ownership_epoch = extension_revisions.ownership_epoch + ) + AND EXISTS ( + SELECT 1 FROM users u + WHERE u.id = ? AND u.deleted_at IS NULL + )`, + params: [reviewerId, reviewNote ?? null, id, extensionId, reviewerId] + }); + + // published_at is COALESCEd rather than overwritten: it records when the + // extension first entered the catalogue, and updated_at carries the + // "changed just now" signal. + const publishStmt = toD1Statement(this.db.$client, { + sql: `UPDATE extensions + SET type = ?, name = ?, description = ?, releases = ?, website = ?, + license = ?, icon_url = ?, readme = ?, source = ?, version = ?, + download_url = ?, + published_at = COALESCE(published_at, CURRENT_TIMESTAMP), + published_revision_id = ?, + updated_at = CURRENT_TIMESTAMP + WHERE changes() = 1 AND id = ?`, + params: [ + content.type, + content.name, + content.description, + JSON.stringify(content.releases), + content.website, + JSON.stringify(content.license), + content.icon_url ?? null, + content.readme, + JSON.stringify(content.source), + content.version, + content.download_url, + id, + extensionId + ] + }); + + results = await this.db.$client.batch([claimStmt, publishStmt]); + } catch (error) { + return databaseError("approve", error); + } + + if (!results[0]?.meta?.changes) { + return { + data: null, + error: { + message: + "Revision is not pending, or ownership changed since it was proposed", + code: "CONFLICT" + } + }; + } + + return { data: { id, status: "approved" }, error: null }; + } +} + +function revisionNotFound(id: string): DatabaseResult { + return { + data: null, + error: { message: `Cannot find revision by id: ${id}`, code: "NOT_FOUND" } + }; +} diff --git a/src/services/extensions/v2/db/schema.ts b/src/services/extensions/v2/db/schema.ts index 4f8e14a..1441b6d 100644 --- a/src/services/extensions/v2/db/schema.ts +++ b/src/services/extensions/v2/db/schema.ts @@ -11,7 +11,7 @@ import { // The API owns the complete Extensions domain, including this user projection. // The row is keyed by the central auth service's `sub`; authentication itself // remains in the Extensions site, while this projection is the domain-side -// authorization and foreign-key anchor for developers, submissions, claims, +// authorization and foreign-key anchor for developers, revisions, claims, // transfers, and audit history. export const users = sqliteTable("users", { id: text("id").primaryKey(), @@ -29,46 +29,79 @@ export const users = sqliteTable("users", { deletedAt: text("deleted_at") }); -// Legacy catalogue table, now owned by the API along with the rest of the -// Extensions domain. The v1 read-only routes import this model rather than -// maintaining a second table definition. author_id's column name is kept for -// compatibility with the public v1 response, while its target followed -// developers in migration 0008. +// An extension record exists from the moment a developer creates it, before +// any moderator has seen it. The content columns are therefore the *published* +// projection and are NULL until the first revision is approved (migration +// 0021); published_at is the marker the public catalogue filters on, and the +// CHECK below is what keeps "published" from ever meaning "half a row". +// +// The column was author_id until migration 0021. It never had to be: the only +// thing that kept the pre-v2 name was v1's public JSON field, which is called +// "author" and is produced by a mapping in v1/database.ts either way. export const extensions = sqliteTable( "extensions", { id: text("id").primaryKey(), - type: text("type").notNull(), - authorId: text("author_id") + developerId: text("developer_id") .notNull() .references(() => developers.id), - name: text("name").notNull(), - description: text("description").notNull(), - releases: text("releases").notNull(), - website: text("website").notNull(), - license: text("license").notNull(), + publishedAt: text("published_at"), + // Which revision produced the current published content. Deliberately not + // a FK: extension_revisions.extension_id already points the other way, and + // a second FK between the same two tables would make them mutually + // dependent for both inserts and the SQLite table rebuilds that migrations + // need. NULL for rows adopted from the pre-v2 catalogue, which were never + // published through a revision. + publishedRevisionId: text("published_revision_id"), + type: text("type"), + name: text("name"), + description: text("description"), + releases: text("releases"), + website: text("website"), + license: text("license"), iconUrl: text("icon_url"), - readme: text("readme").notNull(), - source: text("source").notNull(), - version: text("version").notNull(), - downloadUrl: text("download_url").notNull() + readme: text("readme"), + source: text("source"), + version: text("version"), + downloadUrl: text("download_url"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`) }, (table) => [ - index("idx_extensions_type").on(table.type), - index("idx_extensions_author").on(table.authorId), - index("idx_extensions_catalogue_order").on( - sql`lower(${table.id})`, - table.id - ), - index("idx_extensions_type_catalogue_order").on( - table.type, - sql`lower(${table.id})`, - table.id - ), - index("idx_extensions_author_catalogue_order").on( - table.authorId, - sql`lower(${table.id})`, - table.id + // 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})`), + index("idx_extensions_developer").on(table.developerId), + // The catalogue-order indexes are partial: every public read filters on + // published_at IS NOT NULL, and unpublished rows would otherwise sit in + // the index the catalogue scans. The owner list, which does not filter, + // seeks through idx_extensions_developer instead. + index("idx_extensions_catalogue_order") + .on(sql`lower(${table.id})`, table.id) + .where(sql`${table.publishedAt} IS NOT NULL`), + index("idx_extensions_type_catalogue_order") + .on(table.type, sql`lower(${table.id})`, table.id) + .where(sql`${table.publishedAt} IS NOT NULL`), + index("idx_extensions_developer_catalogue_order") + .on(table.developerId, sql`lower(${table.id})`, table.id) + .where(sql`${table.publishedAt} IS NOT NULL`), + // "Published" must mean every column the public contract declares + // non-optional is present. icon_url is genuinely optional and is left out. + check( + "extensions_published_content_check", + sql`${table.publishedAt} IS NULL OR ( + ${table.type} IS NOT NULL AND ${table.name} IS NOT NULL AND + ${table.description} IS NOT NULL AND ${table.releases} IS NOT NULL AND + ${table.website} IS NOT NULL AND ${table.license} IS NOT NULL AND + ${table.readme} IS NOT NULL AND ${table.source} IS NOT NULL AND + ${table.version} IS NOT NULL AND ${table.downloadUrl} IS NOT NULL + )` ) ] ); @@ -86,13 +119,16 @@ export const developers = sqliteTable( url: text("url"), ownerUserId: text("owner_user_id").references(() => users.id), approvedAt: text("approved_at"), - // Placeholder default from migration 0002 (SQLite rejects non-constant - // ALTER TABLE ADD COLUMN defaults). Every write sets this explicitly - // (see db/developer-profiles.ts) - the literal default is never actually - // read, but it's part of the real column definition so it's kept here - // for baseline-diff fidelity against the existing database. - createdAt: text("created_at").notNull().default("1970-01-01T00:00:00.000Z"), - updatedAt: text("updated_at").notNull().default("1970-01-01T00:00:00.000Z"), + // Migration 0002 could only give these a constant default (SQLite rejects + // non-constant ALTER TABLE ADD COLUMN defaults), so they carried a + // placeholder 1970 epoch that no write ever produced. 0021 rebuilds the + // table and replaces it with the value every writer already uses. + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), avatarUrl: text("avatar_url"), contactEmail: text("contact_email"), ownershipEpoch: integer("ownership_epoch").notNull().default(1), @@ -143,58 +179,72 @@ export const developers = sqliteTable( ] ); -export const extensionSubmissions = sqliteTable( - "extension_submissions", +// A proposed version of one extension's content, awaiting or carrying a +// moderator decision. Renamed from extension_submissions by migration 0021, +// which also made extension_id NOT NULL: an extension row now exists before +// its first revision does, so a revision no longer has to name its target +// indirectly through the payload. `content` is the extension content only — +// developer edits go through PUT /developers/me and are no longer smuggled +// through the review queue. +export const extensionRevisions = sqliteTable( + "extension_revisions", { id: text("id").primaryKey(), - extensionId: text("extension_id").references(() => extensions.id), - // Deliberately NOT a hard FK to developers - a brand-new-developer - // submission names a developer_id that doesn't exist yet until - // approval. See migration 0001 (as author_id) / 0008 (renamed). + extensionId: text("extension_id") + .notNull() + .references(() => extensions.id, { onDelete: "cascade" }), + // Which developer the revision was proposed under, kept as an audit fact + // even after the extension is transferred. Deliberately NOT a FK, so + // DELETE /developers/me can hard-delete a profile without erasing the + // review record (same reasoning as developer_history — migration 0009). developerId: text("developer_id").notNull(), submittedBy: text("submitted_by") .notNull() .references(() => users.id), status: text("status").notNull().default("pending"), - payload: text("payload").notNull(), + content: text("content").notNull(), reviewerId: text("reviewer_id").references(() => users.id), reviewNote: text("review_note"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), reviewedAt: text("reviewed_at"), - ownershipEpoch: integer("ownership_epoch").notNull().default(1), - targetKey: text("target_key") + ownershipEpoch: integer("ownership_epoch").notNull().default(1) }, (table) => [ - index("idx_submissions_status").on(table.status), - index("idx_submissions_submitted_by").on(table.submittedBy), - index("idx_submissions_developer").on(table.developerId), - index("idx_submissions_extension").on(table.extensionId), - uniqueIndex("idx_extension_submissions_pending_target") - .on(table.targetKey) + index("idx_extension_revisions_submitted_by").on(table.submittedBy), + index("idx_extension_revisions_developer").on(table.developerId), + // At most one unreviewed revision per extension. This replaces migration + // 0011's target_key index: the target is now a real column, so the + // constraint no longer depends on a denormalised copy of an id that also + // lived inside the payload JSON. + uniqueIndex("idx_extension_revisions_pending") + .on(table.extensionId) .where(sql`${table.status} = 'pending'`), - // created_at/id are DESC in the real index (migration 0011). - // SQLiteColumn has no .desc() (confirmed via tsc - that's a pg-core-only - // builder method), so the ordering is expressed as raw SQL fragments - // instead; verified this produces "desc" in the generated SQL during - // the baseline-diff step. - index("idx_extension_submissions_submitter_page").on( + // created_at/id are DESC in the real index. SQLiteColumn has no .desc() + // (confirmed via tsc - that's a pg-core-only builder method), so the + // ordering is expressed as raw SQL fragments instead. + index("idx_extension_revisions_extension_page").on( + table.extensionId, + sql`${table.createdAt} desc`, + sql`${table.id} desc` + ), + index("idx_extension_revisions_submitter_page").on( table.submittedBy, sql`${table.createdAt} desc`, sql`${table.id} desc` ), - index("idx_extension_submissions_queue_page").on( + index("idx_extension_revisions_queue_page").on( table.status, table.createdAt, table.id ), check( - "extension_submissions_status_check", + "extension_revisions_status_check", sql`${table.status} IN ('pending', 'approved', 'rejected')` ), check( - "extension_submissions_ownership_epoch_check", + "extension_revisions_ownership_epoch_check", sql`${table.ownershipEpoch} >= 1` ) ] diff --git a/src/services/extensions/v2/db/submissions.ts b/src/services/extensions/v2/db/submissions.ts deleted file mode 100644 index 675bd14..0000000 --- a/src/services/extensions/v2/db/submissions.ts +++ /dev/null @@ -1,601 +0,0 @@ -import { and, asc, desc, eq, gt, lt, or, sql, SQL } from "drizzle-orm"; -import { DatabaseResult } from "../../../../lib/interfaces"; -import { ExtensionsDb } from "../../../../lib/db"; -import { extensionSubmissions, developers, extensions, users } from "./schema"; -import { databaseError } from "./errors"; -import { toD1Statement } from "./batch"; -import { encodeCursor as encode, decodeCursor as decode } from "./cursor"; -import { isReservedExtensionId } from "../schemas/extensions"; -import { isReservedDeveloperId } from "../schemas/developers"; -import { - Submission, - SubmissionPayload, - SubmissionStatus -} from "../schemas/submissions"; - -interface OwnershipResolution { - extensionId: string | null; - developerId: string; - ownershipEpoch: number; -} - -interface CreateInput { - extensionId: string | null; - developerId: string; - ownershipEpoch: number; - submittedBy: string; - payload: SubmissionPayload; -} - -export interface SubmissionPage { - items: Submission[]; - nextCursor: string | null; - hasMore: boolean; -} - -interface StoredSubmission extends Submission { - ownershipEpoch: number; -} - -const MAX_PENDING_SUBMISSIONS_PER_USER = 10; - -interface SubmissionCursor { - createdAt: string; - id: string; -} - -function encodeCursor(createdAt: string, id: string): string { - return encode({ createdAt, id }); -} - -function isSubmissionCursor( - parsed: Record -): parsed is SubmissionCursor & Record { - return typeof parsed.createdAt === "string" && typeof parsed.id === "string"; -} - -function decodeCursor(cursor: string): SubmissionCursor | null { - return decode(cursor, isSubmissionCursor); -} - -interface SubmissionRow { - id: string; - extensionId: string | null; - developerId: string; - submittedBy: string; - status: string; - payload: string; - reviewerId: string | null; - reviewNote: string | null; - createdAt: string; - reviewedAt: string | null; - ownershipEpoch: number; -} - -function parseSubmissionRow(row: SubmissionRow): StoredSubmission { - const submission = { - id: row.id, - extension_id: row.extensionId, - developer_id: row.developerId, - submitted_by: row.submittedBy, - status: row.status as SubmissionStatus, - payload: JSON.parse(row.payload) as SubmissionPayload, - reviewer_id: row.reviewerId, - review_note: row.reviewNote, - created_at: row.createdAt, - reviewed_at: row.reviewedAt - } as StoredSubmission; - Object.defineProperty(submission, "ownershipEpoch", { - value: Number(row.ownershipEpoch ?? 1), - enumerable: false - }); - return submission; -} - -const SUBMISSION_COLUMNS = { - id: extensionSubmissions.id, - extensionId: extensionSubmissions.extensionId, - developerId: extensionSubmissions.developerId, - submittedBy: extensionSubmissions.submittedBy, - status: extensionSubmissions.status, - payload: extensionSubmissions.payload, - reviewerId: extensionSubmissions.reviewerId, - reviewNote: extensionSubmissions.reviewNote, - createdAt: extensionSubmissions.createdAt, - reviewedAt: extensionSubmissions.reviewedAt, - ownershipEpoch: extensionSubmissions.ownershipEpoch -}; - -export class SubmissionsDatabase { - constructor(private db: ExtensionsDb) {} - - // Edits require owning the extension's current developer; the developer - // named in the payload (create, or an edit naming a different developer) - // must be owned by the caller if it already exists, or is free to claim if - // it doesn't. - async resolveOwnership( - payload: SubmissionPayload, - callerId: string - ): Promise> { - try { - const [existingExtension] = await this.db - .select({ id: extensions.id, authorId: extensions.authorId }) - .from(extensions) - .where(sql`LOWER(${extensions.id}) = LOWER(${payload.extension.id})`); - - let extensionId: string | null = null; - - if (existingExtension) { - const [existingDeveloper] = await this.db - .select({ - ownerUserId: developers.ownerUserId, - ownershipEpoch: developers.ownershipEpoch - }) - .from(developers) - .where(eq(developers.id, existingExtension.authorId)); - - if (!existingDeveloper || existingDeveloper.ownerUserId !== callerId) { - return { - data: null, - error: { - message: "You do not own the developer of this extension", - code: "FORBIDDEN" - } - }; - } - - extensionId = existingExtension.id; - } - - const [payloadDeveloper] = await this.db - .select({ - ownerUserId: developers.ownerUserId, - ownershipEpoch: developers.ownershipEpoch - }) - .from(developers) - .where(eq(developers.id, payload.developer.id)); - - if (!payloadDeveloper || payloadDeveloper.ownerUserId !== callerId) { - return { - data: null, - error: { - message: - "You do not own this developer, or it doesn't exist yet — create a developer profile first", - code: "FORBIDDEN" - } - }; - } - - return { - data: { - extensionId, - developerId: payload.developer.id, - ownershipEpoch: Number(payloadDeveloper.ownershipEpoch ?? 1) - }, - error: null - }; - } catch (error) { - return databaseError("resolveOwnership", error); - } - } - - async create(input: CreateInput): Promise> { - const id = crypto.randomUUID(); - - let result; - try { - result = await this.db.run(sql` - INSERT INTO ${extensionSubmissions} - (id, extension_id, developer_id, submitted_by, status, payload, ownership_epoch, target_key) - SELECT ${id}, ${input.extensionId}, ${input.developerId}, ${input.submittedBy}, 'pending', ${JSON.stringify(input.payload)}, d.ownership_epoch, LOWER(${input.payload.extension.id}) - FROM ${developers} d - WHERE d.id = ${input.developerId} AND d.owner_user_id = ${input.submittedBy} AND d.ownership_epoch = ${input.ownershipEpoch} - AND EXISTS ( - SELECT 1 FROM ${users} u - WHERE u.id = ${input.submittedBy} AND u.deleted_at IS NULL - ) - AND ( - SELECT COUNT(*) FROM ${extensionSubmissions} - WHERE submitted_by = ${input.submittedBy} AND status = 'pending' - ) < ${MAX_PENDING_SUBMISSIONS_PER_USER} - AND ( - (${input.extensionId} IS NULL AND NOT EXISTS ( - SELECT 1 FROM ${extensions} WHERE LOWER(id) = LOWER(${input.payload.extension.id}) - )) - OR - (${input.extensionId} IS NOT NULL AND EXISTS ( - SELECT 1 FROM ${extensions} - WHERE id = ${input.extensionId} AND author_id = d.id - )) - ) - ON CONFLICT DO NOTHING - `); - } catch (error) { - return databaseError("create", error); - } - - if (!result.meta?.changes) { - try { - return { data: null, error: await this.createBlockedError(input) }; - } catch (error) { - return databaseError("create", error); - } - } - - return { data: { id }, error: null }; - } - - // The insert affected no rows, which means either its WHERE guard rejected - // the caller or ON CONFLICT DO NOTHING swallowed a collision with the - // pending-target unique index. Only the second case has a specific message, - // so look for the row that would have caused it; anything else falls back to - // the combined guard explanation. - private async createBlockedError( - input: CreateInput - ): Promise<{ message: string; code: string }> { - const targetKey = input.payload.extension.id.toLowerCase(); - const [pending] = await this.db - .select({ one: sql`1` }) - .from(extensionSubmissions) - .where( - and( - eq(extensionSubmissions.targetKey, targetKey), - eq(extensionSubmissions.status, "pending") - ) - ); - if (pending) { - return { - message: "A submission for this extension is already pending", - code: "CONFLICT" - }; - } - - return { - message: - "Submission could not be created because ownership changed, the target changed, or the pending-submission limit was reached", - code: "CONFLICT" - }; - } - - // listBySubmitter and listQueue are the same keyset page in opposite - // directions: newest-first for a submitter reviewing their own history, - // oldest-first for moderators working a queue front to back. Only the base - // predicate and the direction differ, so the cursor handling, the tie-break - // on id, the limit + 1 probe and the next-cursor tail live here once. - private async page( - context: string, - baseCondition: SQL, - direction: "asc" | "desc", - limit: number, - cursor?: string - ): Promise> { - const decoded = cursor ? decodeCursor(cursor) : null; - if (cursor && !decoded) { - return { - data: null, - error: { message: "Invalid pagination cursor", code: "INVALID_CURSOR" } - }; - } - - const [beyond, order] = - direction === "desc" ? [lt, desc] : ([gt, asc] as const); - - let rows: SubmissionRow[]; - try { - const conditions = [baseCondition]; - if (decoded) { - const { createdAt, id: cursorId } = decoded; - conditions.push( - or( - beyond(extensionSubmissions.createdAt, createdAt), - and( - eq(extensionSubmissions.createdAt, createdAt), - beyond(extensionSubmissions.id, cursorId) - ) - )! - ); - } - rows = await this.db - .select(SUBMISSION_COLUMNS) - .from(extensionSubmissions) - .where(and(...conditions)) - .orderBy( - order(extensionSubmissions.createdAt), - order(extensionSubmissions.id) - ) - .limit(limit + 1); - } catch (error) { - return databaseError(context, error); - } - - const hasMore = rows.length > limit; - const items = rows.slice(0, limit).map(parseSubmissionRow); - const last = items.at(-1); - return { - data: { - items, - hasMore, - nextCursor: - hasMore && last ? encodeCursor(last.created_at, last.id) : null - }, - error: null - }; - } - - async listBySubmitter( - userId: string, - limit: number, - cursor?: string - ): Promise> { - return this.page( - "listBySubmitter", - eq(extensionSubmissions.submittedBy, userId), - "desc", - limit, - cursor - ); - } - - async listQueue( - status: SubmissionStatus, - limit: number, - cursor?: string - ): Promise> { - return this.page( - "listQueue", - eq(extensionSubmissions.status, status), - "asc", - limit, - cursor - ); - } - - async getById(id: string): Promise> { - let row: SubmissionRow | undefined; - try { - [row] = await this.db - .select(SUBMISSION_COLUMNS) - .from(extensionSubmissions) - .where(eq(extensionSubmissions.id, id)); - } catch (error) { - return databaseError("getById", error); - } - - if (!row) { - return { - data: null, - error: { - message: `Cannot find submission by id: ${id}`, - code: "NOT_FOUND" - } - }; - } - - return { data: parseSubmissionRow(row), error: null }; - } - - // Notes what happened to an id-scoped write that didn't affect any rows: - // either it never existed, or someone else already moved it off 'pending'. - private async explainNoOpTransition( - id: string - ): Promise> { - const existing = await this.getById(id); - if (existing.error || !existing.data) { - return { - data: null, - error: existing.error ?? { - message: `Cannot find submission by id: ${id}`, - code: "NOT_FOUND" - } - }; - } - return { - data: null, - error: { message: "Submission is not pending", code: "CONFLICT" } - }; - } - - // The `AND status = 'pending'` guard makes this a single atomic - // check-and-set: if two moderators race, only one's update affects a row. - async reject( - id: string, - reviewerId: string, - reviewNote: string - ): Promise> { - let result; - try { - result = await this.db - .update(extensionSubmissions) - .set({ - status: "rejected", - reviewerId, - reviewNote, - reviewedAt: sql`CURRENT_TIMESTAMP` - }) - .where( - and( - eq(extensionSubmissions.id, id), - eq(extensionSubmissions.status, "pending"), - sql`EXISTS ( - SELECT 1 FROM ${users} - WHERE ${users.id} = ${reviewerId} AND ${users.deletedAt} IS NULL - )` - ) - ); - } catch (error) { - return databaseError("reject", error); - } - - if (!result.meta?.changes) { - return this.explainNoOpTransition(id); - } - - return { data: { id, status: "rejected" }, error: null }; - } - - async approve( - id: string, - reviewerId: string, - reviewNote?: string - ): Promise> { - const existing = await this.getById(id); - if (existing.error || !existing.data) { - return { - data: null, - error: existing.error ?? { - message: `Cannot find submission by id: ${id}`, - code: "NOT_FOUND" - } - }; - } - const submission = existing.data; - - if (submission.status !== "pending") { - return { - data: null, - error: { message: "Submission is not pending", code: "CONFLICT" } - }; - } - - const { developer, extension } = submission.payload; - const extensionId = submission.extension_id ?? extension.id; - - // Stored submissions predate the reserved-id validation on new requests, - // so re-check the payload at the approval boundary before it can be - // written through to the public catalogue. The developer id is checked - // here too: approval only ever UPDATEs an existing developer row, so it - // cannot create a reserved profile, but it can still point a new - // extension at one that predates the reservation. - if ( - isReservedExtensionId(extension.id) || - isReservedExtensionId(extensionId) - ) { - return { - data: null, - error: { - message: "This extension id is reserved", - code: "CONFLICT" - } - }; - } - if (isReservedDeveloperId(developer.id)) { - return { - data: null, - error: { - message: "This developer id is reserved", - code: "CONFLICT" - } - }; - } - - // Kept as raw sql via the raw D1 client (see toD1Statement) rather than - // the query builder: D1's batch() executes these three statements as - // one transaction, and the developer/extension statements are - // deliberately gated on `changes() = 1` from the immediately-preceding - // statement (SQLite's per-connection changes() function) so a race or - // ownership/target change caught by the first statement's WHERE also - // blocks the other two - a guarantee that's easy to silently lose by - // rewriting this as three independent query-builder calls. Also works - // around a drizzle-orm 0.45.2 bug where db.run(sql) with bound params - // can't be used inside db.batch() at all (confirmed via an isolated - // repro against real D1). - let results; - try { - const claimStmt = toD1Statement(this.db.$client, { - sql: `UPDATE extension_submissions - SET status = 'approved', reviewer_id = ?, review_note = ?, reviewed_at = CURRENT_TIMESTAMP - WHERE id = ? AND status = 'pending' - AND EXISTS ( - SELECT 1 FROM developers d - WHERE d.id = extension_submissions.developer_id - AND d.owner_user_id = extension_submissions.submitted_by - AND d.ownership_epoch = extension_submissions.ownership_epoch - ) - AND EXISTS ( - SELECT 1 FROM users u - WHERE u.id = ? AND u.deleted_at IS NULL - ) - AND ( - (extension_id IS NULL AND NOT EXISTS ( - SELECT 1 FROM extensions e - WHERE LOWER(e.id) = LOWER(?) - )) - OR - (extension_id IS NOT NULL AND EXISTS ( - SELECT 1 FROM extensions e - WHERE e.id = extension_submissions.extension_id - AND e.author_id = extension_submissions.developer_id - )) - )`, - params: [reviewerId, reviewNote ?? null, id, reviewerId, extension.id] - }); - - const developerStmt = toD1Statement(this.db.$client, { - sql: `UPDATE developers - SET type = ?, name = ?, url = ?, - content_revision = content_revision + 1, - approved_at = NULL, approved_revision = NULL, approved_by = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE changes() = 1 AND id = ? AND owner_user_id = ? - AND ownership_epoch = ?`, - params: [ - developer.type, - developer.name, - developer.URL ?? null, - developer.id, - submission.submitted_by, - submission.ownershipEpoch - ] - }); - - const extensionStmt = toD1Statement(this.db.$client, { - sql: `INSERT INTO extensions (id, type, author_id, name, description, releases, website, license, icon_url, readme, source, version, download_url) - SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - WHERE changes() = 1 - ON CONFLICT(id) DO UPDATE SET - type = excluded.type, author_id = excluded.author_id, name = excluded.name, - description = excluded.description, releases = excluded.releases, - website = excluded.website, license = excluded.license, - icon_url = excluded.icon_url, readme = excluded.readme, - source = excluded.source, version = excluded.version, - download_url = excluded.download_url`, - params: [ - extensionId, - extension.type, - developer.id, - extension.name, - extension.description, - JSON.stringify(extension.releases), - extension.website, - JSON.stringify(extension.license), - extension.icon_url ?? null, - extension.readme, - JSON.stringify(extension.source), - extension.version, - extension.download_url - ] - }); - - results = await this.db.$client.batch([ - claimStmt, - developerStmt, - extensionStmt - ]); - } catch (error) { - return databaseError("approve", error); - } - - if (!results[0]?.meta?.changes) { - return { - data: null, - error: { - message: - "Submission is not pending, ownership changed, or the extension target changed", - code: "CONFLICT" - } - }; - } - - return { data: { id, status: "approved" }, error: null }; - } -} diff --git a/src/services/extensions/v2/db/users.ts b/src/services/extensions/v2/db/users.ts index a6c0a5e..e041240 100644 --- a/src/services/extensions/v2/db/users.ts +++ b/src/services/extensions/v2/db/users.ts @@ -242,18 +242,17 @@ export class UsersDatabase { AND NOT EXISTS ( SELECT 1 FROM developers d WHERE d.owner_user_id = ? - AND EXISTS (SELECT 1 FROM extensions e WHERE e.author_id = d.id) - ) - AND NOT EXISTS ( - SELECT 1 FROM developers d - JOIN extension_submissions s ON s.developer_id = d.id - WHERE d.owner_user_id = ? AND s.status = 'pending' + AND EXISTS (SELECT 1 FROM extensions e WHERE e.developer_id = d.id) )`, - params: [deletedAt, deletedAt, userId, userId, userId] + params: [deletedAt, deletedAt, userId, userId] }); - const rejectSubmissionsStmt = toD1Statement(this.db.$client, { - sql: `UPDATE extension_submissions + // Still needed after the guard above: a revision the caller proposed + // before transferring the developer away is pending under someone + // else's profile, so "no extensions under a developer I own" is true + // while their unreviewed work is still in a moderator's queue. + const rejectRevisionsStmt = toD1Statement(this.db.$client, { + sql: `UPDATE extension_revisions SET status = 'rejected', review_note = 'Submitter account deleted', reviewed_at = CURRENT_TIMESTAMP @@ -293,11 +292,7 @@ export class UsersDatabase { const deleteDeveloperStmt = toD1Statement(this.db.$client, { sql: `DELETE FROM developers WHERE owner_user_id = ? - AND NOT EXISTS (SELECT 1 FROM extensions WHERE author_id = developers.id) - AND NOT EXISTS ( - SELECT 1 FROM extension_submissions - WHERE developer_id = developers.id AND status = 'pending' - ) + AND NOT EXISTS (SELECT 1 FROM extensions WHERE developer_id = developers.id) AND EXISTS (SELECT 1 FROM users WHERE id = ? AND deleted_at = ?)`, params: [userId, userId, deletedAt] }); @@ -320,7 +315,7 @@ export class UsersDatabase { const results = await this.db.$client.batch([ reserveStmt, - rejectSubmissionsStmt, + rejectRevisionsStmt, rejectClaimsStmt, deleteTransfersStmt, deleteClaimsStmt, @@ -332,8 +327,7 @@ export class UsersDatabase { return { data: null, error: { - message: - "The account cannot be deleted while it owns published extensions or pending submissions", + message: "The account cannot be deleted while it owns extensions", code: "CONFLICT" } }; diff --git a/src/services/extensions/v2/index.ts b/src/services/extensions/v2/index.ts index 3900f98..28c4fff 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -4,7 +4,6 @@ import { cors } from "hono/cors"; import { trimTrailingSlash } from "hono/trailing-slash"; import { registerPublicExtensionsRoutes } from "./routes/public-extensions"; import { registerOwnerExtensionsRoutes } from "./routes/owner-extensions"; -import { registerSubmissionRoutes } from "./routes/submissions"; import { registerDeveloperProfileRoutes } from "./routes/developer-profiles"; import { registerOwnershipRoutes } from "./routes/ownership"; import { registerModerationRoutes } from "./routes/moderation"; @@ -38,14 +37,18 @@ extensionsV2.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { scheme: "bearer" }); -// Register the static owner route before the public parameter route -// (/extensions/{id}) so the reserved "mine" segment is handled as the -// owner collection. New submissions reject the reserved id; adopted rows -// predate that, and migration 0020 fails if one is present. +// Register the owner routes before the public parameter route +// (/extensions/{id}) so the reserved "mine" segment is handled as the owner +// collection rather than an extension id. New extensions reject the reserved +// id; adopted rows predate that, and migration 0020 fails if one is present. +// +// GET /extensions/mine/{id} and GET /extensions/{id}/revisions are both three +// segments and would collide on /extensions/mine/revisions — that request can +// only mean the first, because "mine" is not a usable extension id, and +// registering the owner routes first is what resolves it that way. registerOwnerExtensionsRoutes(extensionsV2); registerPublicExtensionsRoutes(extensionsV2); registerAccountRoutes(extensionsV2); -registerSubmissionRoutes(extensionsV2); registerOwnershipRoutes(extensionsV2); registerModerationRoutes(extensionsV2); // Keep this last: its GET /developers/{id} parameter route would otherwise @@ -60,7 +63,7 @@ extensionsV2.doc31("/openapi.json", { title: "FOSSBilling Extensions API (v2)", version: "2.0.0", description: - "Self-service extension submission, ownership, moderation, and public browsing. v1 (/extensions/v1) remains available for existing integrations." + "Self-service extension publishing, ownership, moderation, and public browsing. v1 (/extensions/v1) remains available for existing integrations." }, servers: [{ url: "/extensions/v2" }] }); diff --git a/src/services/extensions/v2/routes/developer-profiles.ts b/src/services/extensions/v2/routes/developer-profiles.ts index ffb39ae..23194c0 100644 --- a/src/services/extensions/v2/routes/developer-profiles.ts +++ b/src/services/extensions/v2/routes/developer-profiles.ts @@ -177,7 +177,7 @@ export function registerDeveloperProfileRoutes(app: ExtensionsV2App): void { 403: ActiveAccountRequiredResponse, 404: errorResponse("Caller has no developer profile"), 409: errorResponse( - "Profile still has published extensions, or has a pending submission awaiting review" + "Profile still has extensions attached, published or not" ), 500: errorResponse("Database error") } diff --git a/src/services/extensions/v2/routes/moderation.ts b/src/services/extensions/v2/routes/moderation.ts index 0da824d..56e5880 100644 --- a/src/services/extensions/v2/routes/moderation.ts +++ b/src/services/extensions/v2/routes/moderation.ts @@ -16,32 +16,36 @@ import { DeveloperHistoryEntrySchema, DeveloperProfileSchema } from "../schemas/developers"; -import { QueueQuerySchema, SubmissionSchema } from "../schemas/submissions"; +import { + ExtensionRevisionSchema, + RevisionIdParamSchema, + RevisionQueueQuerySchema +} from "../schemas/revisions"; import { DeveloperProfilesDatabase } from "../db/developer-profiles"; -import { SubmissionsDatabase } from "../db/submissions"; +import { ExtensionRevisionsDatabase } from "../db/revisions"; import { ExtensionsV2App } from "./app"; export function registerModerationRoutes(app: ExtensionsV2App): void { const queueRoute = createRoute({ method: "get", - path: "/submissions/queue", + path: "/moderation/extensions", tags: ["Moderation"], - summary: "List submissions in the moderation queue", + summary: "List extension revisions awaiting review", security: [{ Bearer: [] }], middleware: [requireModerator()] as const, - request: { query: QueueQuerySchema }, + request: { query: RevisionQueueQuerySchema }, responses: { 200: { content: { "application/json": { schema: z.object({ - result: z.array(SubmissionSchema), + result: z.array(ExtensionRevisionSchema), pagination: PaginationSchema }) } }, description: - "Submissions matching the requested status (default: pending)" + "Revisions matching the requested status (default: pending), oldest first" }, 401: errorResponse("Missing or invalid bearer token"), 403: { @@ -54,7 +58,9 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { }); app.openapi(queueRoute, async (c) => { - const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); + const db = new ExtensionRevisionsDatabase( + getExtensionsDb(c.env.DB_EXTENSIONS) + ); const { status, limit, cursor } = c.req.valid("query"); const { data, error } = await db.listQueue( status ?? "pending", @@ -79,15 +85,19 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { ); }); + // Reviews are addressed through the extension they belong to. The revision + // id alone would be enough to find the row, but scoping the path to the + // extension means a moderator acting from a queue entry cannot approve a + // revision of a different extension than the one they were looking at. const approveRoute = createRoute({ method: "post", - path: "/submissions/{id}/approve", + path: "/extensions/{id}/revisions/{revisionId}/approve", tags: ["Moderation"], - summary: "Approve a pending submission", + summary: "Approve a pending revision and publish it", security: [{ Bearer: [] }], middleware: [requireModerator()] as const, request: { - params: IdParamSchema, + params: RevisionIdParamSchema, body: { content: { "application/json": { schema: ReviewNoteOptionalSchema } } } @@ -105,44 +115,51 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { } }, description: - "Submission approved and written through to the live extension/developer" + "Revision approved and published as the extension's live content" }, 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 404: errorResponse("No submission with that id"), + 404: errorResponse("No such revision on that extension"), 409: errorResponse( - "Submission is not pending, or ownership has changed since it was submitted" + "Revision is not pending, or ownership has changed since it was proposed" ), - 422: errorResponse("id param or review_note body failed validation"), + 422: errorResponse("Path params or review_note body failed validation"), 500: errorResponse("Database error") } }); app.openapi(approveRoute, async (c) => { const auth = getAuth(c); - const { id } = c.req.valid("param"); + const { id, revisionId } = c.req.valid("param"); const { review_note } = c.req.valid("json"); - const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); - const { data, error } = await db.approve(id, auth.userId, review_note); + const db = new ExtensionRevisionsDatabase( + getExtensionsDb(c.env.DB_EXTENSIONS) + ); + const { data, error } = await db.approve( + id, + revisionId, + auth.userId, + review_note + ); if (error || !data) { const status = statusFromErrorCode(error?.code); - return c.json(errorBody(error, "Unable to approve submission"), status); + return c.json(errorBody(error, "Unable to approve revision"), status); } return c.json({ result: data }, 200); }); const rejectRoute = createRoute({ method: "post", - path: "/submissions/{id}/reject", + path: "/extensions/{id}/revisions/{revisionId}/reject", tags: ["Moderation"], - summary: "Reject a pending submission", + summary: "Reject a pending revision", security: [{ Bearer: [] }], middleware: [requireModerator()] as const, request: { - params: IdParamSchema, + params: RevisionIdParamSchema, body: { content: { "application/json": { schema: ReviewNoteRequiredSchema } } } @@ -159,15 +176,16 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { }) } }, - description: "Submission rejected" + description: + "Revision rejected. The extension's published content is unchanged." }, 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 404: errorResponse("No submission with that id"), - 409: errorResponse("Submission is not pending"), + 404: errorResponse("No such revision on that extension"), + 409: errorResponse("Revision is not pending"), 422: errorResponse("review_note is required"), 500: errorResponse("Database error") } @@ -175,13 +193,20 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { app.openapi(rejectRoute, async (c) => { const auth = getAuth(c); - const { id } = c.req.valid("param"); + const { id, revisionId } = c.req.valid("param"); const { review_note } = c.req.valid("json"); - const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); - const { data, error } = await db.reject(id, auth.userId, review_note); + const db = new ExtensionRevisionsDatabase( + getExtensionsDb(c.env.DB_EXTENSIONS) + ); + const { data, error } = await db.reject( + id, + revisionId, + auth.userId, + review_note + ); if (error || !data) { const status = statusFromErrorCode(error?.code); - return c.json(errorBody(error, "Unable to reject submission"), status); + return c.json(errorBody(error, "Unable to reject revision"), status); } return c.json({ result: data }, 200); }); diff --git a/src/services/extensions/v2/routes/owner-extensions.ts b/src/services/extensions/v2/routes/owner-extensions.ts index 23b4ddc..89fdf06 100644 --- a/src/services/extensions/v2/routes/owner-extensions.ts +++ b/src/services/extensions/v2/routes/owner-extensions.ts @@ -1,35 +1,55 @@ -import { errorBody } from "./errors"; +import { errorBody, statusFromErrorCode } from "./errors"; import { requireActiveAuth } from "../middleware"; import { getExtensionsDb } from "../../../../lib/db"; import { getAuth } from "../../../../lib/auth"; -import { createRoute } from "@hono/zod-openapi"; +import { createRoute, z } from "@hono/zod-openapi"; import { ActiveAccountRequiredResponse, + IdParamSchema, + PaginationSchema, errorResponse } from "../schemas/common"; import { - ExtensionListResponseSchema, - ExtensionMineListQuerySchema + ExtensionCreateSchema, + ExtensionMineListQuerySchema, + ExtensionUpdateSchema, + OwnedExtensionListResponseSchema, + OwnedExtensionSchema } from "../schemas/extensions"; +import { + ExtensionRevisionSchema, + RevisionPageQuerySchema +} from "../schemas/revisions"; import { DeveloperProfilesDatabase } from "../db/developer-profiles"; import { ExtensionsDatabase, isValidExtensionCursor } from "../db/extensions"; +import { ExtensionRevisionsDatabase } from "../db/revisions"; +import { UsersDatabase } from "../db/users"; import { ExtensionsV2App } from "./app"; +const AcceptedRevisionSchema = z.object({ + result: z.object({ + id: z.string(), + revision_id: z.string(), + status: z.literal("pending") + }) +}); + export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { const listMineRoute = createRoute({ method: "get", path: "/extensions/mine", tags: ["Extensions"], - summary: "List extensions published under the caller's developer profile", + summary: "List the caller's extensions, published or not", security: [{ Bearer: [] }], middleware: [requireActiveAuth()] as const, request: { query: ExtensionMineListQuerySchema }, responses: { 200: { content: { - "application/json": { schema: ExtensionListResponseSchema } + "application/json": { schema: OwnedExtensionListResponseSchema } }, - description: "The caller's published extensions" + description: + "Every extension under the caller's developer profile, each with its live content, any unreviewed edit, and the last moderator decision" }, 401: errorResponse("Missing or invalid bearer token"), 403: ActiveAccountRequiredResponse, @@ -57,20 +77,11 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { ); } - const ownerDb = new DeveloperProfilesDatabase( + const owner = await new DeveloperProfilesDatabase( getExtensionsDb(c.env.DB_EXTENSIONS) - ); - const owner = await ownerDb.getOwn(auth.userId); + ).getOwnRef(auth.userId); if (owner.error) { - return c.json( - { - error: { - message: owner.error.message, - code: owner.error.code ?? "DATABASE_ERROR" - } - }, - 500 - ); + return c.json(errorBody(owner.error, "Unable to load developer"), 500); } if (!owner.data) { return c.json( @@ -80,9 +91,9 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { } const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); - const { data, error } = await db.list({ - type, + const { data, error } = await db.listOwned({ developerId: owner.data.id, + type, limit, cursor }); @@ -95,11 +106,333 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { return c.json( { result: data.items, - pagination: { - next_cursor: data.nextCursor, - has_more: data.hasMore + pagination: { next_cursor: data.nextCursor, has_more: data.hasMore } + }, + 200 + ); + }); + + const getMineRoute = createRoute({ + method: "get", + path: "/extensions/mine/{id}", + tags: ["Extensions"], + summary: "Get one of the caller's extensions, published or not", + security: [{ Bearer: [] }], + middleware: [requireActiveAuth()] as const, + request: { params: IdParamSchema }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ result: OwnedExtensionSchema }) + } + }, + description: + "The extension's live content, its unreviewed edit if any, and the last moderator decision" + }, + 401: errorResponse("Missing or invalid bearer token"), + 403: { + ...ActiveAccountRequiredResponse, + description: + "The account is inactive, or the caller does not own this extension" + }, + 404: errorResponse("No extension with that id"), + 422: errorResponse("id param failed validation"), + 500: errorResponse("Database error") + } + }); + + app.openapi(getMineRoute, async (c) => { + const auth = getAuth(c); + const { id } = c.req.valid("param"); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); + const { data, error } = await db.getOwned(id); + if (error || !data) { + return c.json( + errorBody(error, "Extension not found"), + statusFromErrorCode(error?.code, false) + ); + } + if (data.ownerUserId !== auth.userId) { + return c.json( + { + error: { message: "You do not own this extension", code: "FORBIDDEN" } + }, + 403 + ); + } + return c.json({ result: data.extension }, 200); + }); + + const createRouteDefinition = createRoute({ + method: "post", + path: "/extensions", + tags: ["Extensions"], + summary: "Create an extension and submit its first version for review", + security: [{ Bearer: [] }], + middleware: [requireActiveAuth()] as const, + request: { + body: { + content: { "application/json": { schema: ExtensionCreateSchema } } + } + }, + responses: { + 201: { + content: { "application/json": { schema: AcceptedRevisionSchema } }, + description: + "Extension created. It holds the id immediately but stays out of the public catalogue until a moderator approves the revision." + }, + 401: errorResponse("Missing or invalid bearer token"), + 403: { + ...ActiveAccountRequiredResponse, + description: + "The account is inactive, or the caller has no developer profile to publish under" + }, + 409: errorResponse( + "The id is taken, ownership changed, or the pending-revision limit was reached" + ), + 422: errorResponse("Body failed validation"), + 500: errorResponse("Database error") + } + }); + + app.openapi(createRouteDefinition, async (c) => { + const auth = getAuth(c); + const { id, ...content } = c.req.valid("json"); + + const owner = await new DeveloperProfilesDatabase( + getExtensionsDb(c.env.DB_EXTENSIONS) + ).getOwnRef(auth.userId); + if (owner.error) { + return c.json(errorBody(owner.error, "Unable to load developer"), 500); + } + if (!owner.data) { + return c.json( + { + error: { + message: + "You need a developer profile before publishing — create one with PUT /developers/me", + code: "FORBIDDEN" + } + }, + 403 + ); + } + + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); + const { data, error } = await db.create({ + extensionId: id, + developerId: owner.data.id, + ownershipEpoch: owner.data.ownershipEpoch, + submittedBy: auth.userId, + content + }); + if (error || !data) { + return c.json( + errorBody(error, "Unable to create extension"), + error?.code === "CONFLICT" ? 409 : 500 + ); + } + return c.json( + { + result: { + id: data.id, + revision_id: data.revisionId, + status: "pending" as const } }, + 201 + ); + }); + + const updateRoute = createRoute({ + method: "put", + path: "/extensions/{id}", + tags: ["Extensions"], + summary: "Submit an edit to an extension the caller owns", + security: [{ Bearer: [] }], + middleware: [requireActiveAuth()] as const, + request: { + params: IdParamSchema, + body: { + content: { "application/json": { schema: ExtensionUpdateSchema } } + } + }, + responses: { + 202: { + content: { "application/json": { schema: AcceptedRevisionSchema } }, + description: + "Edit accepted as a pending revision. The published content is unchanged until a moderator approves it." + }, + 401: errorResponse("Missing or invalid bearer token"), + 403: { + ...ActiveAccountRequiredResponse, + description: + "The account is inactive, or the caller does not own this extension" + }, + 404: errorResponse("No extension with that id"), + 409: errorResponse( + "An edit is already awaiting review, or the pending-revision limit was reached" + ), + 422: errorResponse("Body failed validation"), + 500: errorResponse("Database error") + } + }); + + app.openapi(updateRoute, async (c) => { + const auth = getAuth(c); + const { id } = c.req.valid("param"); + const content = c.req.valid("json"); + const db = new ExtensionRevisionsDatabase( + getExtensionsDb(c.env.DB_EXTENSIONS) + ); + const { data, error } = await db.propose({ + extensionId: id, + callerId: auth.userId, + content + }); + if (error || !data) { + return c.json( + errorBody(error, "Unable to submit edit"), + error?.code === "FORBIDDEN" ? 403 : statusFromErrorCode(error?.code) + ); + } + return c.json( + { result: { id, revision_id: data.id, status: "pending" as const } }, + 202 + ); + }); + + const withdrawRoute = createRoute({ + method: "delete", + path: "/extensions/{id}", + tags: ["Extensions"], + summary: "Withdraw an extension that has never been published", + security: [{ Bearer: [] }], + middleware: [requireActiveAuth()] as const, + request: { params: IdParamSchema }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + result: z.object({ id: z.string(), deleted: z.literal(true) }) + }) + } + }, + description: "Extension and its revisions deleted, and the id released" + }, + 401: errorResponse("Missing or invalid bearer token"), + 403: { + ...ActiveAccountRequiredResponse, + description: + "The account is inactive, or the caller does not own this extension" + }, + 404: errorResponse("No extension with that id"), + 409: errorResponse("The extension is published and cannot be withdrawn"), + 500: errorResponse("Database error") + } + }); + + app.openapi(withdrawRoute, async (c) => { + const auth = getAuth(c); + const { id } = c.req.valid("param"); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); + const { data, error } = await db.withdraw(id, auth.userId); + if (error || !data) { + return c.json( + errorBody(error, "Unable to withdraw extension"), + error?.code === "FORBIDDEN" ? 403 : statusFromErrorCode(error?.code) + ); + } + return c.json({ result: { id: data.id, deleted: true as const } }, 200); + }); + + const revisionsRoute = createRoute({ + method: "get", + path: "/extensions/{id}/revisions", + tags: ["Extensions"], + summary: "List an extension's revisions, newest first", + security: [{ Bearer: [] }], + middleware: [requireActiveAuth()] as const, + request: { params: IdParamSchema, query: RevisionPageQuerySchema }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + result: z.array(ExtensionRevisionSchema), + pagination: PaginationSchema + }) + } + }, + description: + "Every version proposed for this extension, with its review outcome" + }, + 401: errorResponse("Missing or invalid bearer token"), + 403: { + ...ActiveAccountRequiredResponse, + description: + "The account is inactive, or the caller neither owns this extension nor moderates" + }, + 404: errorResponse("No extension with that id"), + 422: errorResponse("Pagination query failed validation"), + 500: errorResponse("Database error") + } + }); + + app.openapi(revisionsRoute, async (c) => { + const auth = getAuth(c); + const { id } = c.req.valid("param"); + const { limit, cursor } = c.req.valid("query"); + const extensionsDb = new ExtensionsDatabase( + getExtensionsDb(c.env.DB_EXTENSIONS) + ); + const owned = await extensionsDb.getOwned(id); + if (owned.error || !owned.data) { + return c.json( + errorBody(owned.error, "Extension not found"), + statusFromErrorCode(owned.error?.code, false) + ); + } + + if (owned.data.ownerUserId !== auth.userId) { + const users = new UsersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); + const moderator = await users.moderatorAccess(auth.userId); + if (moderator.error) { + return c.json( + errorBody(moderator.error, "Unable to check moderator access"), + 500 + ); + } + if (!moderator.data?.moderator) { + return c.json( + { + error: { + message: "You do not own this extension", + code: "FORBIDDEN" + } + }, + 403 + ); + } + } + + const db = new ExtensionRevisionsDatabase( + getExtensionsDb(c.env.DB_EXTENSIONS) + ); + const { data, error } = await db.listByExtension(id, limit, cursor); + if (error || !data) { + return c.json( + errorBody(error, "Unable to load revisions"), + error?.code === "INVALID_CURSOR" ? 422 : 500 + ); + } + return c.json( + { + result: data.items, + pagination: { next_cursor: data.nextCursor, has_more: data.hasMore } + }, 200 ); }); diff --git a/src/services/extensions/v2/routes/submissions.ts b/src/services/extensions/v2/routes/submissions.ts deleted file mode 100644 index c2f97ac..0000000 --- a/src/services/extensions/v2/routes/submissions.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { errorBody } from "./errors"; -import { requireActiveAuth } from "../middleware"; -import { getExtensionsDb } from "../../../../lib/db"; -import { getAuth } from "../../../../lib/auth"; -import { createRoute, z } from "@hono/zod-openapi"; -import { - ActiveAccountRequiredResponse, - PaginationSchema, - errorResponse -} from "../schemas/common"; -import { - SubmissionPayloadSchema, - SubmissionPageQuerySchema, - SubmissionSchema -} from "../schemas/submissions"; -import { SubmissionsDatabase } from "../db/submissions"; -import { ExtensionsV2App } from "./app"; - -export function registerSubmissionRoutes(app: ExtensionsV2App): void { - const createSubmissionRoute = createRoute({ - method: "post", - path: "/submissions", - tags: ["Submissions"], - summary: "Submit a new extension, or an edit to one you own", - security: [{ Bearer: [] }], - middleware: [requireActiveAuth()] as const, - request: { - body: { - content: { "application/json": { schema: SubmissionPayloadSchema } } - } - }, - responses: { - 201: { - content: { - "application/json": { - schema: z.object({ - result: z.object({ id: z.string(), status: z.literal("pending") }) - }) - } - }, - description: "Submission created and pending moderator review" - }, - 401: errorResponse("Missing or invalid bearer token"), - 403: { - ...ActiveAccountRequiredResponse, - description: - "The account is inactive, or the caller does not own the target developer or extension" - }, - 409: errorResponse( - "Ownership or target changed, a duplicate is pending, or the pending limit was reached" - ), - 422: errorResponse("Payload failed validation"), - 500: errorResponse("Database error") - } - }); - - app.openapi(createSubmissionRoute, async (c) => { - const auth = getAuth(c); - const payload = c.req.valid("json"); - const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); - const ownership = await db.resolveOwnership(payload, auth.userId); - if (ownership.error || !ownership.data) { - return c.json( - errorBody(ownership.error, "Unable to validate ownership"), - ownership.error?.code === "FORBIDDEN" ? 403 : 500 - ); - } - const created = await db.create({ - extensionId: ownership.data.extensionId, - developerId: ownership.data.developerId, - ownershipEpoch: ownership.data.ownershipEpoch, - submittedBy: auth.userId, - payload - }); - if (created.error || !created.data) { - return c.json( - errorBody(created.error, "Unable to create submission"), - created.error?.code === "CONFLICT" ? 409 : 500 - ); - } - return c.json( - { result: { id: created.data.id, status: "pending" as const } }, - 201 - ); - }); - - const mineRoute = createRoute({ - method: "get", - path: "/submissions/mine", - tags: ["Submissions"], - summary: "List the caller's own submissions, in any status", - security: [{ Bearer: [] }], - middleware: [requireActiveAuth()] as const, - request: { query: SubmissionPageQuerySchema }, - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ - result: z.array(SubmissionSchema), - pagination: PaginationSchema - }) - } - }, - description: "The caller's submissions" - }, - 401: errorResponse("Missing or invalid bearer token"), - 403: ActiveAccountRequiredResponse, - 422: errorResponse("Pagination query failed validation"), - 500: errorResponse("Database error") - } - }); - - app.openapi(mineRoute, async (c) => { - const auth = getAuth(c); - const { limit, cursor } = c.req.valid("query"); - const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); - const { data, error } = await db.listBySubmitter( - auth.userId, - limit, - cursor - ); - if (error || !data) { - return c.json( - errorBody(error, "Unable to load submissions"), - error?.code === "INVALID_CURSOR" ? 422 : 500 - ); - } - return c.json( - { - result: data.items, - pagination: { - next_cursor: data.nextCursor, - has_more: data.hasMore - } - }, - 200 - ); - }); -} diff --git a/src/services/extensions/v2/schemas/developers.ts b/src/services/extensions/v2/schemas/developers.ts index 542c5c7..12e6f4d 100644 --- a/src/services/extensions/v2/schemas/developers.ts +++ b/src/services/extensions/v2/schemas/developers.ts @@ -9,11 +9,8 @@ import { httpUrl, lowercaseId } from "./common"; // fails the deploy if one exists. const RESERVED_DEVELOPER_IDS = new Set(["claims", "me", "unapproved"]); -// Exported so the approval boundary can reuse it: submissions store their -// payload as JSON and are re-read without re-running this schema, so the one -// check has to be callable from there too. Lowercases like -// isReservedExtensionId, since route matching is case-sensitive but these -// literals are not. +// Lowercases like isReservedExtensionId, since route matching is +// case-sensitive but these literals are not. export function isReservedDeveloperId(id: string): boolean { return RESERVED_DEVELOPER_IDS.has(id.toLowerCase()); } @@ -45,19 +42,6 @@ export type Developer = z.infer; export const DeveloperInputSchema = DeveloperSchema.strict().openapi("DeveloperInput"); -// Submissions go through moderation and only ever touch identity fields — -// profile fields (avatar_url/contact_email) are direct-write-only via -// PUT /developers/me, so this schema rejects them instead of silently -// accepting-then-dropping them when a submission is approved. -export const SubmissionDeveloperSchema = DeveloperSchema.pick({ - id: true, - type: true, - name: true, - URL: true -}) - .strict() - .openapi("SubmissionDeveloper"); - export const DeveloperProfileSchema = DeveloperSchema.extend({ approved: z.boolean(), content_revision: z.int().positive(), diff --git a/src/services/extensions/v2/schemas/extensions.ts b/src/services/extensions/v2/schemas/extensions.ts index 34405cb..c0841be 100644 --- a/src/services/extensions/v2/schemas/extensions.ts +++ b/src/services/extensions/v2/schemas/extensions.ts @@ -13,9 +13,9 @@ export const EXTENSION_TYPES = [ ] as const; // GET /extensions/mine is a static owner-only route registered before -// GET /extensions/{id}. Reserve its segment for new submissions so a newly -// published extension cannot become unreachable. This schema cannot rename -// an already-adopted row, so migration 0020 fails the deploy if one exists. +// GET /extensions/{id}. Reserve its segment so a newly created extension +// cannot become unreachable. This schema cannot rename an already-adopted +// row, so migration 0020 fails the deploy if one exists. // Private: isReservedExtensionId() lowercases before the lookup, and these // literals are lowercase — reading the Set directly would miss "Mine". const RESERVED_EXTENSION_IDS = new Set(["mine"]); @@ -57,9 +57,11 @@ export const LicenseSchema = z export type License = z.infer; -export const ExtensionPayloadSchema = z +// Everything about an extension that a developer can edit. Excludes `id`, +// which is the resource's identity and is carried by the URL. This is what a +// revision stores and what moderators approve. +export const ExtensionContentSchema = z .object({ - id: lowercaseId("extension"), type: z.enum(EXTENSION_TYPES), name: z.string().min(1).max(120), description: z.string().min(1).max(4000), @@ -72,10 +74,45 @@ export const ExtensionPayloadSchema = z version: z.string().min(1).max(100), download_url: httpUrl() }) - .strict() - .openapi("ExtensionPayload"); + .openapi("ExtensionContent"); + +export type ExtensionContent = z.infer; + +const MAX_CONTENT_BYTES = 256 * 1024; + +// Applied to both the create and the edit body. The stored revision is this +// object verbatim, so bounding it here bounds the row. +function refineContentSize(content: unknown, ctx: z.RefinementCtx): void { + const size = new TextEncoder().encode(JSON.stringify(content)).byteLength; + if (size > MAX_CONTENT_BYTES) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Extension content must not exceed 256 KiB" + }); + } +} -export const ExtensionSchema = ExtensionPayloadSchema.extend({ +// POST /extensions. The id is chosen once here and is immutable afterwards. +// No developer field: a user owns at most one profile, so the server knows it. +export const ExtensionCreateSchema = ExtensionContentSchema.extend({ + id: lowercaseId("extension").refine((id) => !isReservedExtensionId(id), { + message: "This extension id is reserved" + }) +}) + .strict() + .superRefine(refineContentSize) + .openapi("ExtensionCreate"); + +// PUT /extensions/{id}. Same content, no id — that 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() + .superRefine(refineContentSize) + .openapi("ExtensionUpdate"); + +// The public projection: published content plus the developer that owns it. +export const ExtensionSchema = ExtensionContentSchema.extend({ + id: z.string(), developer: PublicDeveloperSchema }).openapi("Extension"); @@ -91,6 +128,59 @@ export const ExtensionListItemSchema = ExtensionSchema.omit({ export type ExtensionListItem = z.infer; +const ExtensionCardContentSchema = ExtensionContentSchema.omit({ + readme: true, + releases: true +}); + +// The most recent decision, kept alongside a later pending revision so the +// site can still show why the previous attempt was rejected. +export const RevisionReviewSchema = z + .object({ + revision_id: z.string(), + status: z.enum(["approved", "rejected"]), + review_note: z.string().nullable(), + reviewed_at: z.string().nullable() + }) + .openapi("RevisionReview"); + +const PendingRevisionRefSchema = z + .object({ + id: z.string(), + created_at: z.string() + }) + .openapi("PendingRevisionRef"); + +// published, pending_revision and last_review are independent — a live +// extension with an unreviewed edit has all three. There is deliberately no +// derived `status` field on top; see the README for how they map to a UI. +export const OwnedExtensionListItemSchema = z + .object({ + id: z.string(), + developer: PublicDeveloperSchema, + published: ExtensionCardContentSchema.nullable(), + pending_revision: PendingRevisionRefSchema.nullable(), + last_review: RevisionReviewSchema.nullable(), + created_at: z.string(), + updated_at: z.string() + }) + .openapi("OwnedExtensionListItem"); + +export type OwnedExtensionListItem = z.infer< + typeof OwnedExtensionListItemSchema +>; + +// The detail view carries the full content on both sides, so an owner can +// render a published-vs-pending diff from one request. +export const OwnedExtensionSchema = OwnedExtensionListItemSchema.extend({ + published: ExtensionContentSchema.nullable(), + pending_revision: PendingRevisionRefSchema.extend({ + content: ExtensionContentSchema + }).nullable() +}).openapi("OwnedExtension"); + +export type OwnedExtension = z.infer; + export const ExtensionListQuerySchema = z.object({ type: z .enum(EXTENSION_TYPES) @@ -136,3 +226,10 @@ export const ExtensionListResponseSchema = z pagination: PaginationSchema }) .openapi("ExtensionListResponse"); + +export const OwnedExtensionListResponseSchema = z + .object({ + result: z.array(OwnedExtensionListItemSchema), + pagination: PaginationSchema + }) + .openapi("OwnedExtensionListResponse"); diff --git a/src/services/extensions/v2/schemas/revisions.ts b/src/services/extensions/v2/schemas/revisions.ts new file mode 100644 index 0000000..1380a00 --- /dev/null +++ b/src/services/extensions/v2/schemas/revisions.ts @@ -0,0 +1,67 @@ +import { z } from "@hono/zod-openapi"; +import { ExtensionContentSchema } from "./extensions"; + +export const RevisionStatusSchema = z.enum(["pending", "approved", "rejected"]); + +export type RevisionStatus = z.infer; + +// A proposed version of one extension's content. No developer fields: that is +// fixed by the extension, and developer edits go through PUT /developers/me. +export const ExtensionRevisionSchema = z + .object({ + id: z.string(), + extension_id: z.string(), + developer_id: z.string(), + submitted_by: z.string(), + status: RevisionStatusSchema, + content: ExtensionContentSchema, + reviewer_id: z.string().nullable(), + review_note: z.string().nullable(), + created_at: z.string(), + reviewed_at: z.string().nullable() + }) + .openapi("ExtensionRevision"); + +export type ExtensionRevision = z.infer; + +export const RevisionIdParamSchema = z.object({ + id: z.string().openapi({ + param: { name: "id", in: "path" }, + example: "acme-gateway" + }), + revisionId: z.string().openapi({ + param: { name: "revisionId", in: "path" }, + example: "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + }) +}); + +export const RevisionQueueQuerySchema = z.object({ + status: RevisionStatusSchema.optional().openapi({ + param: { name: "status", in: "query" } + }), + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .default(50) + .openapi({ + param: { name: "limit", in: "query" } + }), + // min(1) matches ExtensionListQuerySchema: without it `?cursor=` arrives as + // an empty string, which the page helper treats as "no cursor" and silently + // restarts pagination instead of reporting the malformed value. + cursor: z + .string() + .min(1) + .max(1000) + .optional() + .openapi({ + param: { name: "cursor", in: "query" } + }) +}); + +export const RevisionPageQuerySchema = RevisionQueueQuerySchema.pick({ + limit: true, + cursor: true +}); diff --git a/src/services/extensions/v2/schemas/submissions.ts b/src/services/extensions/v2/schemas/submissions.ts deleted file mode 100644 index de0e368..0000000 --- a/src/services/extensions/v2/schemas/submissions.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { SubmissionDeveloperSchema } from "./developers"; -import { ExtensionPayloadSchema, isReservedExtensionId } from "./extensions"; - -export const SubmissionPayloadSchema = z - .object({ - developer: SubmissionDeveloperSchema, - extension: ExtensionPayloadSchema - }) - .strict() - .superRefine((payload, ctx) => { - if (isReservedExtensionId(payload.extension.id)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "This extension id is reserved", - path: ["extension", "id"] - }); - } - const size = new TextEncoder().encode(JSON.stringify(payload)).byteLength; - if (size > 256 * 1024) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Submission payload must not exceed 256 KiB" - }); - } - }) - .openapi("SubmissionPayload"); - -export type SubmissionPayload = z.infer; - -export const SubmissionStatusSchema = z.enum([ - "pending", - "approved", - "rejected" -]); - -export type SubmissionStatus = z.infer; - -export const SubmissionSchema = z - .object({ - id: z.string(), - extension_id: z.string().nullable(), - developer_id: z.string(), - submitted_by: z.string(), - status: SubmissionStatusSchema, - payload: SubmissionPayloadSchema, - reviewer_id: z.string().nullable(), - review_note: z.string().nullable(), - created_at: z.string(), - reviewed_at: z.string().nullable() - }) - .openapi("Submission"); - -export type Submission = z.infer; - -export const QueueQuerySchema = z.object({ - status: SubmissionStatusSchema.optional().openapi({ - param: { name: "status", in: "query" } - }), - limit: z.coerce - .number() - .int() - .min(1) - .max(100) - .default(50) - .openapi({ - param: { name: "limit", in: "query" } - }), - // min(1) matches ExtensionListQuerySchema: without it `?cursor=` arrives as - // an empty string, which the page helper treats as "no cursor" and silently - // restarts pagination instead of reporting the malformed value. - cursor: z - .string() - .min(1) - .max(1000) - .optional() - .openapi({ - param: { name: "cursor", in: "query" } - }) -}); - -export const SubmissionPageQuerySchema = QueueQuerySchema.pick({ - limit: true, - cursor: true -}); diff --git a/test/services/extensions/v1/index.test.ts b/test/services/extensions/v1/index.test.ts index 0ab022d..08bd07d 100644 --- a/test/services/extensions/v1/index.test.ts +++ b/test/services/extensions/v1/index.test.ts @@ -16,7 +16,7 @@ const testExtensionRows = [ { id: "Example", type: "mod" as const, - authorId: "fossbilling", + developerId: "fossbilling", name: "Example Module", description: "An example module for developers.", releases: JSON.stringify([ @@ -58,7 +58,7 @@ const testExtensionRows = [ { id: "TestTheme", type: "theme" as const, - authorId: "fossbilling", + developerId: "fossbilling", name: "Test Theme", description: "A test theme.", releases: JSON.stringify([ @@ -92,7 +92,14 @@ describe("Extensions API v1", () => { name: "fossbilling", url: "https://fossbilling.org" }); - await db.insert(extensions).values(testExtensionRows); + // publishedAt is what v1 filters on since migration 0021 - an extension + // row can now exist before a moderator has approved anything. + await db.insert(extensions).values( + testExtensionRows.map((row) => ({ + ...row, + publishedAt: "2026-01-01T00:00:00.000Z" + })) + ); }); describe("GET /list", () => { diff --git a/test/services/extensions/v2/account.test.ts b/test/services/extensions/v2/account.test.ts index 5765dbd..d08e0bb 100644 --- a/test/services/extensions/v2/account.test.ts +++ b/test/services/extensions/v2/account.test.ts @@ -7,7 +7,7 @@ import { put, patch, del, - samplePayload, + sampleContent, sampleDeveloper, seedDeveloper, seedUnownedDeveloper, @@ -17,10 +17,11 @@ import { insertUser, insertDeveloper, insertExtension, - insertSubmission, + insertUnpublishedExtension, + insertRevision, insertDeveloperClaim, hasDeveloper, - getSubmission, + getRevision, getDeveloperClaim, insertDeveloperTransfer, insertDeveloperHistory, @@ -80,7 +81,7 @@ describe("Extensions API v2", () => { await insertExtension(db, { id: "account-extension", type: "mod", - author_id: "account-developer", + developer_id: "account-developer", name: "Account Extension", description: "description", releases: "[]", @@ -264,15 +265,18 @@ describe("Extensions API v2", () => { expect(row?.deleted_at).toBeNull(); }); - it("blocks deletion while a pending submission targets the owned developer", async () => { + it("blocks deletion while an unpublished extension is still owned", async () => { await seedDeveloper("pending-developer", "pending-owner"); - await insertSubmission(db, { - id: "pending-submission", + await insertUnpublishedExtension(db, { + id: "pending-ext", + developer_id: "pending-developer" + }); + await insertRevision(db, { + id: "pending-revision", + extension_id: "pending-ext", developer_id: "pending-developer", submitted_by: "pending-owner", - payload: JSON.stringify( - samplePayload({ developerId: "pending-developer" }) - ) + content: JSON.stringify(sampleContent()) }); const deleted = await del( @@ -280,7 +284,7 @@ describe("Extensions API v2", () => { await authHeaders("pending-owner") ); expect(deleted.status).toBe(409); - expect(await getSubmission(db, "pending-submission")).toMatchObject({ + expect(await getRevision(db, "pending-revision")).toMatchObject({ status: "pending" }); const user = await db @@ -310,11 +314,18 @@ describe("Extensions API v2", () => { developer_id: "claim-target", claimant_id: "cleanup-user" }); - await insertSubmission(db, { - id: "cleanup-pending-submission", + // 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" + }); + await insertRevision(db, { + id: "cleanup-pending-revision", + extension_id: "cleanup-pending-ext", developer_id: "claim-target", submitted_by: "cleanup-user", - payload: JSON.stringify(samplePayload({ developerId: "claim-target" })) + content: JSON.stringify(sampleContent()) }); await insertDeveloperHistory(db, { id: "cleanup-history", @@ -357,9 +368,7 @@ describe("Extensions API v2", () => { ({ id }) => id === "cleanup-owned-claim" ) ).toBeUndefined(); - expect( - await getSubmission(db, "cleanup-pending-submission") - ).toMatchObject({ + expect(await getRevision(db, "cleanup-pending-revision")).toMatchObject({ status: "rejected", review_note: "Submitter account deleted" }); diff --git a/test/services/extensions/v2/db-fixtures.ts b/test/services/extensions/v2/db-fixtures.ts index d0f0337..4f6b859 100644 --- a/test/services/extensions/v2/db-fixtures.ts +++ b/test/services/extensions/v2/db-fixtures.ts @@ -27,33 +27,36 @@ export interface DeveloperRow { export interface ExtensionRow { id: string; - type: string; - author_id: string; - name: string; - description: string; - releases: string; - website: string; - license: string; + developer_id: string; + published_at: string | null; + published_revision_id: string | null; + type: string | null; + name: string | null; + description: string | null; + releases: string | null; + website: string | null; + license: string | null; icon_url: string | null; - readme: string; - source: string; - version: string; - download_url: string; + readme: string | null; + source: string | null; + version: string | null; + download_url: string | null; + created_at: string; + updated_at: string; } -export interface SubmissionRow { +export interface RevisionRow { id: string; - extension_id: string | null; + extension_id: string; developer_id: string; submitted_by: string; status: string; - payload: string; + content: string; reviewer_id: string | null; review_note: string | null; created_at: string; reviewed_at: string | null; ownership_epoch: number; - target_key: string | null; } export interface DeveloperClaimRow { @@ -128,7 +131,7 @@ async function clearDeveloperHistory(db: D1Database): Promise { export async function resetExtensionsDb(db: D1Database): Promise { await clearDeveloperHistory(db); for (const table of [ - "extension_submissions", + "extension_revisions", "developer_transfers", "developer_claims", "extensions", @@ -234,42 +237,67 @@ export async function insertDeveloper( .run(); } +// Seeds a published extension: the content columns and published_at go in +// together, which is what extensions_published_content_check requires. export async function insertExtension( db: D1Database, - row: ExtensionRow + row: Partial & { id: string; developer_id: string } ): Promise { + const now = new Date().toISOString(); await db .prepare( `INSERT INTO extensions - (id, type, author_id, name, description, releases, website, license, - icon_url, readme, source, version, download_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + (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) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( row.id, - row.type, - row.author_id, - row.name, - row.description, - row.releases, - row.website, - row.license, + row.developer_id, + row.published_at !== undefined ? row.published_at : now, + row.published_revision_id ?? null, + row.type ?? "mod", + row.name ?? "Extension", + row.description ?? "d", + row.releases ?? "[]", + row.website ?? "https://e.com", + row.license ?? '{"name":"MIT"}', row.icon_url ?? null, - row.readme, - row.source, - row.version, - row.download_url + row.readme ?? "r", + row.source ?? '{"type":"github","repo":"example/ext"}', + row.version ?? "1.0.0", + row.download_url ?? "https://e.com/d.zip", + row.created_at ?? now, + row.updated_at ?? now + ) + .run(); +} + +// An extension that exists but has never been published: no content, no +// 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 } +): Promise { + const now = new Date().toISOString(); + await db + .prepare( + `INSERT INTO extensions (id, developer_id, created_at, updated_at) + VALUES (?, ?, ?, ?)` ) + .bind(row.id, row.developer_id, row.created_at ?? now, now) .run(); } -export async function insertSubmission( +export async function insertRevision( db: D1Database, - row: Partial & { + row: Partial & { id: string; + extension_id: string; developer_id: string; submitted_by: string; - payload: string; + content: string; } ): Promise { await ensureUser(db, row.submitted_by); @@ -278,24 +306,23 @@ export async function insertSubmission( } await db .prepare( - `INSERT INTO extension_submissions - (id, extension_id, developer_id, submitted_by, status, payload, - reviewer_id, review_note, created_at, reviewed_at, ownership_epoch, target_key) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO extension_revisions + (id, extension_id, developer_id, submitted_by, status, content, + reviewer_id, review_note, created_at, reviewed_at, ownership_epoch) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( row.id, - row.extension_id ?? null, + row.extension_id, row.developer_id, row.submitted_by, row.status ?? "pending", - row.payload, + row.content, row.reviewer_id ?? null, row.review_note ?? null, row.created_at ?? new Date().toISOString(), row.reviewed_at ?? null, - row.ownership_epoch ?? 1, - row.target_key ?? null + row.ownership_epoch ?? 1 ) .run(); } @@ -440,30 +467,28 @@ export async function getExtension( .first(); } -export async function countSubmissions(db: D1Database): Promise { +export async function countRevisions(db: D1Database): Promise { const row = await db - .prepare("SELECT COUNT(*) AS count FROM extension_submissions") + .prepare("SELECT COUNT(*) AS count FROM extension_revisions") .first<{ count: number }>(); return row?.count ?? 0; } -export async function listSubmissions( - db: D1Database -): Promise { +export async function listRevisions(db: D1Database): Promise { const result = await db - .prepare("SELECT * FROM extension_submissions") - .all(); + .prepare("SELECT * FROM extension_revisions") + .all(); return result.results ?? []; } -export async function getSubmission( +export async function getRevision( db: D1Database, id: string -): Promise { +): Promise { return db - .prepare("SELECT * FROM extension_submissions WHERE id = ?") + .prepare("SELECT * FROM extension_revisions WHERE id = ?") .bind(id) - .first(); + .first(); } export async function countDeveloperClaims(db: D1Database): Promise { diff --git a/test/services/extensions/v2/developer-profiles.test.ts b/test/services/extensions/v2/developer-profiles.test.ts index 8dd1c30..cb0f5f3 100644 --- a/test/services/extensions/v2/developer-profiles.test.ts +++ b/test/services/extensions/v2/developer-profiles.test.ts @@ -11,7 +11,7 @@ import { get, put, del, - samplePayload, + sampleCreate, sampleDeveloper, seedUnownedDeveloper, seedOwnedExtension, @@ -21,7 +21,6 @@ import { import { insertUser, insertDeveloper, - insertSubmission, insertDeveloperClaim, insertDeveloperTransfer, getDeveloper, @@ -1041,7 +1040,7 @@ describe("Extensions API v2", () => { }); describe("DELETE /developers/me", () => { - it("deletes a profile with no extensions or pending submissions", async () => { + it("deletes a profile with no extensions", async () => { await put( "/extensions/v2/developers/me", await authHeaders("user-1"), @@ -1165,23 +1164,24 @@ describe("Extensions API v2", () => { error: { code: string; message: string }; }; expect(body.error.code).toBe("CONFLICT"); - expect(body.error.message).toContain("1 published extension(s)"); + expect(body.error.message).toContain("1 extension(s)"); }); - it("409s when a submission is pending", async () => { + it("409s when an unpublished extension is still owned", async () => { await put( "/extensions/v2/developers/me", await authHeaders("user-1"), sampleDeveloper() ); - await insertSubmission(db, { - id: "sub-1", - extension_id: null, - developer_id: "dev-developer", - submitted_by: "user-1", - status: "pending", - payload: JSON.stringify(samplePayload()) - }); + expect( + ( + await post( + "/extensions/v2/extensions", + await authHeaders("user-1"), + sampleCreate() + ) + ).status + ).toBe(201); const res = await del( "/extensions/v2/developers/me", diff --git a/test/services/extensions/v2/extension-writes.test.ts b/test/services/extensions/v2/extension-writes.test.ts new file mode 100644 index 0000000..a52ce4e --- /dev/null +++ b/test/services/extensions/v2/extension-writes.test.ts @@ -0,0 +1,552 @@ +import { describe, it, expect, vi } from "vitest"; +import { + setupExtensionsV2Tests, + db, + authHeaders, + post, + get, + put, + del, + sampleContent, + sampleCreate, + seedDeveloper, + seedOwnedExtension +} from "./harness"; +import { + countRevisions, + getExtension, + getRevision, + listRevisions +} from "./db-fixtures"; + +// Hoisted so no v2 suite can make a real GitHub call. harness.ts applies the +// default "not found" behaviour in beforeEach and documents why. +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); + +setupExtensionsV2Tests(); + +async function createExtension( + user: string, + overrides?: { extensionId?: string; name?: string } +) { + return post( + "/extensions/v2/extensions", + await authHeaders(user), + sampleCreate(overrides) + ); +} + +describe("Extensions API v2 writes", () => { + describe("POST /extensions", () => { + it("requires auth", async () => { + const res = await post( + "/extensions/v2/extensions", + { "Content-Type": "application/json" }, + sampleCreate() + ); + expect(res.status).toBe(401); + }); + + it("rejects an invalid body", async () => { + const res = await post( + "/extensions/v2/extensions", + await authHeaders("user-1"), + {} + ); + expect(res.status).toBe(422); + const data = (await res.json()) as { error: { code: string } }; + expect(data.error.code).toBe("VALIDATION_ERROR"); + }); + + it("rejects the reserved extension id mine", async () => { + await seedDeveloper("new-developer", "user-1"); + const res = await createExtension("user-1", { extensionId: "mine" }); + + expect(res.status).toBe(422); + expect(await countRevisions(db)).toBe(0); + }); + + it("refuses a caller with no developer profile to publish under", async () => { + const res = await createExtension("user-1"); + + expect(res.status).toBe(403); + expect(await countRevisions(db)).toBe(0); + }); + + it("creates the extension record and its first pending revision", async () => { + await seedDeveloper("new-developer", "user-1"); + const res = await createExtension("user-1"); + + expect(res.status).toBe(201); + const data = (await res.json()) as { + result: { id: string; revision_id: string; status: string }; + }; + expect(data.result).toMatchObject({ id: "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", + published_at: null, + published_revision_id: null, + name: null + }); + + const revision = await getRevision(db, data.result.revision_id); + expect(revision).toMatchObject({ + extension_id: "new-ext", + submitted_by: "user-1", + status: "pending" + }); + // The id is the extension's identity, not something a revision proposes. + expect(JSON.parse(revision!.content)).not.toHaveProperty("id"); + }); + + it("leaves no extension behind when the revision cannot be written", async () => { + await seedDeveloper("new-developer", "user-1"); + expect((await createExtension("user-1")).status).toBe(201); + + // Same id, different owner: the insert is swallowed by ON CONFLICT and + // the batch's changes()-gate must stop the revision too. + await seedDeveloper("other-developer", "user-2"); + const res = await createExtension("user-2"); + + expect(res.status).toBe(409); + expect(await countRevisions(db)).toBe(1); + }); + + it("rejects an id already taken by a published extension", async () => { + await seedOwnedExtension(); + const res = await createExtension("owner-1", { + extensionId: "existing-ext" + }); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "CONFLICT" } + }); + }); + + it("rejects an id that differs from an existing one only in case", async () => { + await seedOwnedExtension(); + const res = await post( + "/extensions/v2/extensions", + await authHeaders("owner-1"), + { ...sampleCreate(), id: "existing-ext" } + ); + expect(res.status).toBe(409); + }); + + it("bounds content size, unknown fields, and the number of releases", async () => { + await seedDeveloper("new-developer", "user-1"); + const headers = await authHeaders("user-1"); + const body = sampleCreate(); + + const oversized = await post("/extensions/v2/extensions", headers, { + ...body, + readme: "x".repeat(100_001) + }); + expect(oversized.status).toBe(422); + + const unknownField = await post("/extensions/v2/extensions", headers, { + ...body, + padding: "x" + }); + expect(unknownField.status).toBe(422); + const unknownBody = (await unknownField.json()) as { + error: { details: Array<{ code: string; path: PropertyKey[] }> }; + }; + expect(unknownBody.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "unrecognized_keys", path: [] }) + ]) + ); + + const unknownReleaseField = await post( + "/extensions/v2/extensions", + headers, + { ...body, releases: [{ ...body.releases[0], padding: "x" }] } + ); + expect(unknownReleaseField.status).toBe(422); + const unknownReleaseBody = (await unknownReleaseField.json()) as { + error: { details: Array<{ code: string; path: PropertyKey[] }> }; + }; + expect(unknownReleaseBody.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + path: ["releases", 0] + }) + ]) + ); + + const tooManyReleases = await post("/extensions/v2/extensions", headers, { + ...body, + releases: Array.from({ length: 101 }, () => body.releases[0]) + }); + expect(tooManyReleases.status).toBe(422); + }); + + it("preserves compatibility with stored slug ids over 100 characters", async () => { + await seedDeveloper("d".repeat(120), "user-1"); + const res = await createExtension("user-1", { + extensionId: "e".repeat(120) + }); + expect(res.status).toBe(201); + }); + + it("caps each user's unreviewed backlog", async () => { + await seedDeveloper("new-developer", "user-1"); + for (let index = 0; index < 10; index++) { + const result = await createExtension("user-1", { + extensionId: `new-ext-${index}` + }); + expect(result.status).toBe(201); + } + + const overLimit = await createExtension("user-1", { + extensionId: "over-limit" + }); + expect(overLimit.status).toBe(409); + expect(await countRevisions(db)).toBe(10); + expect(await getExtension(db, "over-limit")).toBeNull(); + }); + }); + + describe("PUT /extensions/{id}", () => { + it("requires auth", async () => { + const res = await put( + "/extensions/v2/extensions/existing-ext", + { "Content-Type": "application/json" }, + sampleContent() + ); + expect(res.status).toBe(401); + }); + + it("reports an unknown extension as 404, not 403", async () => { + const res = await put( + "/extensions/v2/extensions/no-such-ext", + await authHeaders("user-1"), + sampleContent() + ); + expect(res.status).toBe(404); + }); + + it("rejects an edit from someone who does not own the extension", async () => { + await seedOwnedExtension(); + const res = await put( + "/extensions/v2/extensions/existing-ext", + await authHeaders("intruder"), + sampleContent() + ); + + expect(res.status).toBe(403); + expect(await countRevisions(db)).toBe(0); + }); + + it("accepts an edit from the owner without changing the published content", async () => { + await seedOwnedExtension(); + const res = await put( + "/extensions/v2/extensions/existing-ext", + await authHeaders("owner-1"), + sampleContent({ name: "Renamed" }) + ); + + expect(res.status).toBe(202); + const [revision] = await listRevisions(db); + expect(revision).toMatchObject({ + extension_id: "existing-ext", + status: "pending" + }); + expect(JSON.parse(revision.content).name).toBe("Renamed"); + + // Still the pre-edit content: an edit is a proposal, not a write. + const stored = await getExtension(db, "existing-ext"); + expect(stored?.name).toBe("Existing"); + expect(stored?.published_at).not.toBeNull(); + }); + + it("allows only one unreviewed edit per extension", async () => { + await seedOwnedExtension(); + const headers = await authHeaders("owner-1"); + expect( + ( + await put( + "/extensions/v2/extensions/existing-ext", + headers, + sampleContent() + ) + ).status + ).toBe(202); + + const second = await put( + "/extensions/v2/extensions/existing-ext", + headers, + sampleContent({ name: "Again" }) + ); + expect(second.status).toBe(409); + expect(await countRevisions(db)).toBe(1); + }); + + it("cannot rename an extension: the id comes from the path", async () => { + await seedOwnedExtension(); + const res = await put( + "/extensions/v2/extensions/existing-ext", + await authHeaders("owner-1"), + { ...sampleContent(), id: "renamed" } + ); + + expect(res.status).toBe(422); + expect(await getExtension(db, "renamed")).toBeNull(); + }); + }); + + describe("DELETE /extensions/{id}", () => { + it("withdraws an unpublished extension and releases its id", async () => { + await seedDeveloper("new-developer", "user-1"); + expect((await createExtension("user-1")).status).toBe(201); + + const res = await del( + "/extensions/v2/extensions/new-ext", + await authHeaders("user-1") + ); + + expect(res.status).toBe(200); + expect(await getExtension(db, "new-ext")).toBeNull(); + // The revision cascades with it - there is nothing left to review. + expect(await countRevisions(db)).toBe(0); + + expect((await createExtension("user-1")).status).toBe(201); + }); + + it("refuses to withdraw a published extension", async () => { + await seedOwnedExtension(); + const res = await del( + "/extensions/v2/extensions/existing-ext", + await authHeaders("owner-1") + ); + + expect(res.status).toBe(409); + expect(await getExtension(db, "existing-ext")).not.toBeNull(); + }); + + it("refuses to withdraw someone else's extension", async () => { + await seedDeveloper("new-developer", "user-1"); + await createExtension("user-1"); + + const res = await del( + "/extensions/v2/extensions/new-ext", + await authHeaders("intruder") + ); + + expect(res.status).toBe(403); + expect(await getExtension(db, "new-ext")).not.toBeNull(); + }); + }); + + describe("GET /extensions/mine", () => { + it("returns published and unpublished extensions in one page", async () => { + await seedOwnedExtension(); + const headers = await authHeaders("owner-1"); + expect( + ( + await post("/extensions/v2/extensions", headers, { + ...sampleCreate({ extensionId: "draft-ext" }) + }) + ).status + ).toBe(201); + + const res = await get("/extensions/v2/extensions/mine", headers); + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: Array<{ + id: string; + published: { name: string } | null; + pending_revision: { id: string } | null; + last_review: unknown; + }>; + }; + + expect(data.result.map((item) => item.id)).toEqual([ + "draft-ext", + "existing-ext" + ]); + const [draft, published] = data.result; + expect(draft.published).toBeNull(); + expect(draft.pending_revision).not.toBeNull(); + expect(draft.last_review).toBeNull(); + expect(published.published).toMatchObject({ name: "Existing" }); + expect(published.pending_revision).toBeNull(); + }); + + it("shows a published extension and its unreviewed edit together", async () => { + await seedOwnedExtension(); + const headers = await authHeaders("owner-1"); + await put( + "/extensions/v2/extensions/existing-ext", + headers, + sampleContent({ name: "Renamed" }) + ); + + const res = await get("/extensions/v2/extensions/mine", headers); + const data = (await res.json()) as { + result: Array<{ + published: { name: string } | null; + pending_revision: { id: string } | null; + }>; + }; + expect(data.result[0].published).toMatchObject({ name: "Existing" }); + expect(data.result[0].pending_revision).not.toBeNull(); + }); + + it("excludes other developers' extensions", async () => { + await seedOwnedExtension(); + await seedDeveloper("other-developer", "user-2"); + await createExtension("user-2", { extensionId: "other-ext" }); + + const res = await get( + "/extensions/v2/extensions/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"]); + }); + + it("requires auth", async () => { + const res = await get("/extensions/v2/extensions/mine", {}); + expect(res.status).toBe(401); + }); + + it("identifies invalid cursors", async () => { + const res = await get( + "/extensions/v2/extensions/mine?cursor=not-a-cursor", + await authHeaders("user-1") + ); + expect(res.status).toBe(422); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "INVALID_CURSOR" } + }); + }); + + it("paginates deterministically with an opaque cursor", async () => { + await seedDeveloper("new-developer", "user-1"); + const headers = await authHeaders("user-1"); + for (const extensionId of ["page-a", "page-b", "page-c"]) { + expect((await createExtension("user-1", { extensionId })).status).toBe( + 201 + ); + } + + const first = await get( + "/extensions/v2/extensions/mine?limit=2", + headers + ); + const firstBody = (await first.json()) as { + result: Array<{ id: string }>; + pagination: { has_more: boolean; next_cursor: string }; + }; + expect(firstBody.result.map((item) => item.id)).toEqual([ + "page-a", + "page-b" + ]); + expect(firstBody.pagination.has_more).toBe(true); + + const second = await get( + `/extensions/v2/extensions/mine?limit=2&cursor=${encodeURIComponent(firstBody.pagination.next_cursor)}`, + headers + ); + const secondBody = (await second.json()) as { + result: Array<{ id: string }>; + pagination: { has_more: boolean; next_cursor: null }; + }; + expect(secondBody.result.map((item) => item.id)).toEqual(["page-c"]); + expect(secondBody.pagination).toEqual({ + has_more: false, + next_cursor: null + }); + }); + }); + + describe("GET /extensions/mine/{id}", () => { + it("returns an unpublished extension with its pending content", async () => { + await seedDeveloper("new-developer", "user-1"); + await createExtension("user-1"); + + const res = await get( + "/extensions/v2/extensions/mine/new-ext", + await authHeaders("user-1") + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: { + published: unknown; + pending_revision: { content: { name: string } }; + }; + }; + expect(data.result.published).toBeNull(); + expect(data.result.pending_revision.content.name).toBe("New Extension"); + }); + + it("refuses to show someone else's extension", async () => { + await seedOwnedExtension(); + const res = await get( + "/extensions/v2/extensions/mine/existing-ext", + await authHeaders("intruder") + ); + expect(res.status).toBe(403); + }); + + it("404s an unknown id", async () => { + const res = await get( + "/extensions/v2/extensions/mine/no-such-ext", + await authHeaders("user-1") + ); + expect(res.status).toBe(404); + }); + + // "mine" is a reserved extension id, so /extensions/mine/revisions can + // only be the owner detail route - see the registration order in index.ts. + it("resolves /extensions/mine/revisions as an owner detail read", async () => { + const res = await get( + "/extensions/v2/extensions/mine/revisions", + await authHeaders("user-1") + ); + expect(res.status).toBe(404); + }); + }); + + describe("GET /extensions/{id}/revisions", () => { + it("lists an extension's revisions newest first", async () => { + await seedDeveloper("new-developer", "user-1"); + await createExtension("user-1"); + const headers = await authHeaders("user-1"); + + const res = await get( + "/extensions/v2/extensions/new-ext/revisions", + headers + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: Array<{ extension_id: string; status: string }>; + }; + expect(data.result).toHaveLength(1); + expect(data.result[0]).toMatchObject({ + extension_id: "new-ext", + status: "pending" + }); + }); + + it("refuses a caller who neither owns the extension nor moderates", async () => { + await seedOwnedExtension(); + const res = await get( + "/extensions/v2/extensions/existing-ext/revisions", + await authHeaders("intruder") + ); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/test/services/extensions/v2/harness.ts b/test/services/extensions/v2/harness.ts index 44c220e..cd1dae2 100644 --- a/test/services/extensions/v2/harness.ts +++ b/test/services/extensions/v2/harness.ts @@ -96,37 +96,38 @@ export async function authHeaders( }; } -export function samplePayload(overrides?: { +// The PUT /extensions/{id} body: content only, no id and no developer. Both +// are now properties of the extension record rather than of the edit. +export function sampleContent(overrides?: { name?: string }) { + return { + type: "mod", + name: overrides?.name ?? "New Extension", + description: "A new extension", + releases: [ + { + tag: "1.0.0", + date: "2026-01-01T00:00:00Z", + download_url: "https://example.com/download.zip", + min_fossbilling_version: "0.6" + } + ], + website: "https://example.com", + license: { name: "MIT" }, + readme: "# Readme", + source: { type: "github", repo: "example/new-ext" }, + version: "1.0.0", + download_url: "https://example.com/download.zip" + }; +} + +// The POST /extensions body: the same content plus the id being claimed. +export function sampleCreate(overrides?: { extensionId?: string; - developerId?: string; + name?: string; }) { return { - developer: { - id: overrides?.developerId ?? "new-developer", - type: "user", - name: "Some Developer", - URL: "https://example.com" - }, - extension: { - id: overrides?.extensionId ?? "new-ext", - type: "mod", - name: "New Extension", - description: "A new extension", - releases: [ - { - tag: "1.0.0", - date: "2026-01-01T00:00:00Z", - download_url: "https://example.com/download.zip", - min_fossbilling_version: "0.6" - } - ], - website: "https://example.com", - license: { name: "MIT" }, - readme: "# Readme", - source: { type: "github", repo: "example/new-ext" }, - version: "1.0.0", - download_url: "https://example.com/download.zip" - } + id: overrides?.extensionId ?? "new-ext", + ...sampleContent(overrides) }; } @@ -139,8 +140,8 @@ export function sampleDeveloper(overrides?: { id?: string; name?: string }) { }; } -// Extension submissions now require the named developer to already exist -// (created via PUT /developers/me) and be owned by the caller. +// Creating an extension requires the caller to already own a developer +// profile (created via PUT /developers/me). export async function seedDeveloper( id: string, ownerUserId: string @@ -177,18 +178,9 @@ export async function seedOwnedExtension(): Promise { }); await insertExtension(db, { id: "existing-ext", - type: "mod", - author_id: "owner-developer", + developer_id: "owner-developer", name: "Existing", - description: "d", - releases: "[]", - website: "https://e.com", - license: '{"name":"MIT"}', - icon_url: null, - readme: "r", - source: '{"type":"github","repo":"example/existing"}', - version: "1.0.0", - download_url: "https://e.com/d.zip" + source: '{"type":"github","repo":"example/existing"}' }); } diff --git a/test/services/extensions/v2/index.test.ts b/test/services/extensions/v2/index.test.ts index 02d8b27..7522ad9 100644 --- a/test/services/extensions/v2/index.test.ts +++ b/test/services/extensions/v2/index.test.ts @@ -23,14 +23,14 @@ describe("Extensions API v2", () => { expect.arrayContaining([ "/extensions", "/extensions/mine", + "/extensions/mine/{id}", "/extensions/{id}", + "/extensions/{id}/revisions", + "/extensions/{id}/revisions/{revisionId}/approve", + "/extensions/{id}/revisions/{revisionId}/reject", + "/moderation/extensions", "/users/me/identity", "/users/me", - "/submissions", - "/submissions/mine", - "/submissions/queue", - "/submissions/{id}/approve", - "/submissions/{id}/reject", "/developers/me", "/developers/{id}", "/developers/unapproved", diff --git a/test/services/extensions/v2/migrations.test.ts b/test/services/extensions/v2/migrations.test.ts index 1fe2e54..01f3caa 100644 --- a/test/services/extensions/v2/migrations.test.ts +++ b/test/services/extensions/v2/migrations.test.ts @@ -75,9 +75,15 @@ describe("Extensions D1 migrations", () => { // Apply the complete API chain, including the idempotent bootstrap, to // the already-populated users table. 0019 is kept separate so the // assertions prove that the adoption migration is the only schema - // change needed for the old split-owned database. + // change needed for the old split-owned database. 0021 is held back + // with it so this test can seed pre-0021 rows and then watch them + // migrate; the assertions after it cover the restructure. + const heldBack = new Set([ + "0019_add_user_deleted_at.sql", + "0021_restructure_extensions_revisions.sql" + ]); for (const name of migrationNames.filter( - (candidate) => candidate !== "0019_add_user_deleted_at.sql" + (candidate) => !heldBack.has(candidate) )) { db.exec(migration(name)); } @@ -142,6 +148,7 @@ describe("Extensions D1 migrations", () => { ); db.exec(migration("0019_add_user_deleted_at.sql")); + db.exec(migration("0021_restructure_extensions_revisions.sql")); expect(columnNames(db, "users")).toEqual([ "id", @@ -178,9 +185,7 @@ describe("Extensions D1 migrations", () => { ).toEqual({ owner_user_id: "legacy-user" }); expect( db - .prepare( - "SELECT submitted_by FROM extension_submissions WHERE id = ?" - ) + .prepare("SELECT submitted_by FROM extension_revisions WHERE id = ?") .get("legacy-submission") ).toEqual({ submitted_by: "legacy-user" }); expect( @@ -189,6 +194,204 @@ describe("Extensions D1 migrations", () => { .get("legacy-history") ).toEqual({ changed_by: "legacy-user" }); expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + + // 0021 rebuilds developers only to replace the placeholder 1970 default + // migration 0002 was forced to use. Rows keep whatever they had - a + // wrong-but-real timestamp beats one invented here - while a new insert + // that omits the column now gets the value every writer already uses. + expect( + db + .prepare("SELECT created_at FROM developers WHERE id = ?") + .get("legacy-developer") + ).toEqual({ created_at: "1970-01-01T00:00:00.000Z" }); + + db.prepare( + "INSERT INTO developers (id, type, name, owner_user_id) VALUES (?,?,?,?)" + ).run("post-migration", "user", "After", null); + const fresh = db + .prepare("SELECT created_at, updated_at FROM developers WHERE id = ?") + .get("post-migration") as { created_at: string; updated_at: string }; + expect(fresh.created_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + expect(fresh.updated_at).toBe(fresh.created_at); + } finally { + db.close(); + } + }); + + // The reads in db/extensions.ts and v1/database.ts inner-join developers and + // treat the result as always present. That is only sound because 0021 + // refuses to carry a dangling reference through its foreign_keys=OFF + // rebuild, so the refusal is worth pinning. + it("0021 refuses to migrate an extension whose developer is missing", () => { + const db = new DatabaseSync(":memory:"); + + try { + for (const name of migrationNames.filter( + (candidate) => !candidate.startsWith("0021") + )) { + db.exec(migration(name)); + } + + // Enforcement off, which is exactly how such a row could have come to + // exist before the constraint was there to stop it. + db.exec("PRAGMA foreign_keys = OFF;"); + db.prepare( + `INSERT INTO extensions ( + id, type, author_id, name, description, releases, website, license, + icon_url, readme, source, version, download_url + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)` + ).run( + "dangling", + "mod", + "developer-that-never-existed", + "Dangling", + "d", + "[]", + "https://example.com", + '{"name":"MIT"}', + null, + "# d", + '{"type":"github","repo":"example/d"}', + "1.0.0", + "https://example.com/d.zip" + ); + + expect(() => + db.exec(migration("0021_restructure_extensions_revisions.sql")) + ).toThrow(/CHECK constraint failed/); + } finally { + db.close(); + } + }); + + it("0021 gives every submission a real extension row to hang off", () => { + const db = new DatabaseSync(":memory:"); + + try { + db.exec("PRAGMA foreign_keys = ON;"); + for (const name of migrationNames.filter( + (candidate) => !candidate.startsWith("0021") + )) { + db.exec(migration(name)); + } + + const now = "2026-01-01T00:00:00.000Z"; + db.prepare( + "INSERT INTO users (id, created_at, updated_at) VALUES (?,?,?)" + ).run("submitter", now, now); + db.prepare( + "INSERT INTO developers (id, type, name, owner_user_id) VALUES (?,?,?,?)" + ).run("acme", "organization", "Acme", "submitter"); + db.prepare( + `INSERT INTO extensions ( + id, type, author_id, name, description, releases, website, license, + icon_url, readme, source, version, download_url + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)` + ).run( + "live-ext", + "mod", + "acme", + "Live", + "description", + "[]", + "https://example.com", + '{"name":"MIT"}', + null, + "# Live", + '{"type":"github","repo":"example/live"}', + "1.0.0", + "https://example.com/live.zip" + ); + + const payload = (extensionId: string) => + JSON.stringify({ + developer: { id: "acme", type: "organization", name: "Acme" }, + extension: { id: extensionId, name: "Proposed", type: "mod" } + }); + const insertSubmission = db.prepare( + `INSERT INTO extension_submissions + (id, extension_id, developer_id, submitted_by, status, payload, created_at, target_key) + VALUES (?,?,?,?,?,?,?,?)` + ); + insertSubmission.run( + "edit-of-live", + "live-ext", + "acme", + "submitter", + "pending", + payload("live-ext"), + "2026-01-02", + "live-ext" + ); + insertSubmission.run( + "brand-new", + null, + "acme", + "submitter", + "pending", + payload("not-yet-approved"), + "2026-01-03", + "not-yet-approved" + ); + // Filed under a developer that no longer exists: there is no developer_id + // that would satisfy the foreign key, so this one is dropped. + insertSubmission.run( + "orphaned", + null, + "ghost", + "submitter", + "rejected", + payload("orphan-ext"), + "2026-01-04", + "orphan-ext" + ); + + db.exec(migration("0021_restructure_extensions_revisions.sql")); + + // Rows that were already in the catalogue are published; a submission + // that only ever proposed an id becomes an unpublished extension. + expect( + db + .prepare( + "SELECT id, developer_id, published_at IS NOT NULL AS published FROM extensions ORDER BY id" + ) + .all() + ).toEqual([ + { id: "live-ext", developer_id: "acme", published: 1 }, + { id: "not-yet-approved", developer_id: "acme", published: 0 } + ]); + + expect( + db + .prepare( + "SELECT id, extension_id, status FROM extension_revisions ORDER BY id" + ) + .all() + ).toEqual([ + { + id: "brand-new", + extension_id: "not-yet-approved", + status: "pending" + }, + { id: "edit-of-live", extension_id: "live-ext", status: "pending" } + ]); + + // The payload's developer half and the extension id are both gone: a + // revision proposes content, and nothing else. + expect( + db + .prepare("SELECT content FROM extension_revisions WHERE id = ?") + .get("edit-of-live") + ).toEqual({ content: '{"name":"Proposed","type":"mod"}' }); + + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + + // published_at cannot be set on a row with no content. + expect(() => + db + .prepare("UPDATE extensions SET published_at = ? WHERE id = ?") + .run(now, "not-yet-approved") + ).toThrow(/extensions_published_content_check/); } finally { db.close(); } diff --git a/test/services/extensions/v2/moderation.test.ts b/test/services/extensions/v2/moderation.test.ts index f8a6d79..2deed30 100644 --- a/test/services/extensions/v2/moderation.test.ts +++ b/test/services/extensions/v2/moderation.test.ts @@ -8,7 +8,8 @@ import { post, get, put, - samplePayload, + sampleContent, + sampleCreate, sampleDeveloper, seedDeveloper } from "./harness"; @@ -16,11 +17,10 @@ import { insertUser, insertDeveloper, insertExtension, - insertSubmission, getDeveloper, countExtensions, getExtension, - getSubmission, + getRevision, bumpDeveloperOwnership } from "./db-fixtures"; @@ -32,11 +32,36 @@ vi.mock("@octokit/request", async () => setupExtensionsV2Tests(); +// Creates an extension and returns the ids the review routes are addressed by. +async function createPending( + user: string, + overrides?: { extensionId?: string; name?: string } +): Promise<{ id: string; revisionId: string }> { + const res = await post( + "/extensions/v2/extensions", + await authHeaders(user), + sampleCreate(overrides) + ); + expect(res.status).toBe(201); + const { result } = (await res.json()) as { + result: { id: string; revision_id: string }; + }; + return { id: result.id, revisionId: result.revision_id }; +} + +function reviewPath( + id: string, + revisionId: string, + action: "approve" | "reject" +): string { + return `/extensions/v2/extensions/${id}/revisions/${revisionId}/${action}`; +} + describe("Extensions API v2", () => { - describe("GET /submissions/queue", () => { + describe("GET /moderation/extensions", () => { it("requires moderator access", async () => { const res = await get( - "/extensions/v2/submissions/queue", + "/extensions/v2/moderation/extensions", await authHeaders("user-1") ); expect(res.status).toBe(403); @@ -45,7 +70,7 @@ describe("Extensions API v2", () => { it("identifies invalid cursors", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); const res = await get( - "/extensions/v2/submissions/queue?cursor=not-a-cursor", + "/extensions/v2/moderation/extensions?cursor=not-a-cursor", await authHeaders("mod-1") ); expect(res.status).toBe(422); @@ -54,174 +79,114 @@ describe("Extensions API v2", () => { }); }); - it("returns pending submissions for a moderator", async () => { + it("returns pending revisions for a moderator", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); - await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); + await createPending("user-1"); const res = await get( - "/extensions/v2/submissions/queue", + "/extensions/v2/moderation/extensions", await authHeaders("mod-1") ); expect(res.status).toBe(200); - const data = (await res.json()) as { result: Array<{ status: string }> }; + const data = (await res.json()) as { + result: Array<{ status: string; extension_id: string }>; + }; expect(data.result).toHaveLength(1); - expect(data.result[0].status).toBe("pending"); + expect(data.result[0]).toMatchObject({ + status: "pending", + extension_id: "new-ext" + }); }); }); describe("approve / reject", () => { - it("does not approve a former owner's payload when ownership changes at approval", async () => { + it("does not approve a former owner's content when ownership changes at approval", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; + const { id, revisionId } = await createPending("user-1"); - // ownership_epoch is captured on the submission at creation time and - // only compared later, so unlike the deleteOwn/upsertOwn races below, - // simply changing ownership before the approve call (rather than - // mid-request) reproduces this exactly. + // ownership_epoch is captured on the revision at creation time and only + // compared later, so unlike the deleteOwn/upsertOwn races below, simply + // changing ownership before the approve call (rather than mid-request) + // reproduces this exactly. await bumpDeveloperOwnership(db, "new-developer", "user-2"); const approved = await post( - `/extensions/v2/submissions/${result.id}/approve`, + reviewPath(id, revisionId, "approve"), await authHeaders("mod-1"), {} ); expect(approved.status).toBe(409); - expect((await getSubmission(db, result.id))?.status).toBe("pending"); - expect(await countExtensions(db)).toBe(0); + expect((await getRevision(db, revisionId))?.status).toBe("pending"); + expect((await getExtension(db, id))?.published_at).toBeNull(); }); - it("does not approve a legacy pending submission with a reserved extension id", async () => { + it("refuses a revision that belongs to a different extension", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); - const legacyPayload = samplePayload({ extensionId: "mine" }); - await insertSubmission(db, { - id: "legacy-mine-submission", - developer_id: "new-developer", - submitted_by: "user-1", - payload: JSON.stringify(legacyPayload), - target_key: "mine" - }); + const first = await createPending("user-1", { extensionId: "ext-one" }); + await createPending("user-1", { extensionId: "ext-two" }); - const approved = await post( - "/extensions/v2/submissions/legacy-mine-submission/approve", + const res = await post( + reviewPath("ext-two", first.revisionId, "approve"), await authHeaders("mod-1"), {} ); - expect(approved.status).toBe(409); - expect(await getSubmission(db, "legacy-mine-submission")).toMatchObject({ - status: "pending" - }); - expect(await countExtensions(db)).toBe(0); + expect(res.status).toBe(404); + expect((await getRevision(db, first.revisionId))?.status).toBe("pending"); }); - // The developer half of the same guard. Approval only ever UPDATEs an - // existing developer row, so this cannot create a reserved profile - but a - // profile predating the reservation would otherwise gain a new extension - // pointing at an id that GET /developers/{id} can never serve. - it("does not approve a legacy pending submission with a reserved developer id", async () => { - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - await insertDeveloper(db, { - id: "me", - type: "user", - name: "Legacy Reserved", - url: null, - owner_user_id: "user-1" - }); - const legacyPayload = samplePayload({ developerId: "me" }); - await insertSubmission(db, { - id: "legacy-me-submission", - developer_id: "me", - submitted_by: "user-1", - payload: JSON.stringify(legacyPayload) - }); - - const approved = await post( - "/extensions/v2/submissions/legacy-me-submission/approve", - await authHeaders("mod-1"), - {} - ); - - expect(approved.status).toBe(409); - expect(await approved.json()).toMatchObject({ - error: { message: "This developer id is reserved" } - }); - expect(await getSubmission(db, "legacy-me-submission")).toMatchObject({ - status: "pending" - }); - expect(await countExtensions(db)).toBe(0); - }); - it("leaves the submission pending if the extension write-through fails mid-batch", async () => { + it("leaves the revision pending if the publish fails mid-batch", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); - - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; - - // approve()'s three statements (submission status, developer, extension) - // run as one atomic db.batch() call, so D1 itself rolls back the whole - // thing on any failure - there's no app-level "revert" to test, and no - // way to make the earlier statements really commit before this one - // fails (see db-interceptor.ts). This verifies that guarantee end to - // end: a failure on the last statement still leaves nothing committed. + const { id, revisionId } = await createPending("user-1"); + + // approve()'s two statements (claim the revision, publish it into the + // extension) run as one atomic db.batch() call, so D1 itself rolls back + // the whole thing on any failure - there's no app-level "revert" to + // test, and no way to make the earlier statement really commit before + // this one fails (see db-interceptor.ts). This verifies that guarantee + // end to end: a failure on the last statement still leaves nothing + // committed. env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { - if ( - sql.includes("INSERT INTO") && - sql.includes("extensions") && - !sql.includes("extension_submissions") - ) { - throw new Error("simulated write-through failure"); + if (sql.includes("UPDATE extensions")) { + throw new Error("simulated publish failure"); } }); const approved = await post( - `/extensions/v2/submissions/${result.id}/approve`, + reviewPath(id, revisionId, "approve"), await authHeaders("mod-1"), {} ); expect(approved.status).toBe(500); - expect(await countExtensions(db)).toBe(0); - - const stored = await getSubmission(db, result.id); - expect(stored?.status).toBe("pending"); + expect((await getExtension(db, id))?.published_at).toBeNull(); + expect((await getRevision(db, revisionId))?.status).toBe("pending"); // Recovers cleanly once the underlying failure is gone. env.DB_EXTENSIONS = db; const retried = await post( - `/extensions/v2/submissions/${result.id}/approve`, + reviewPath(id, revisionId, "approve"), await authHeaders("mod-1"), {} ); expect(retried.status).toBe(200); - expect(await countExtensions(db)).toBe(1); + expect((await getExtension(db, id))?.published_at).not.toBeNull(); }); - it("approves a submission and it becomes visible via the v1 read path", async () => { + it("publishes on approval and it becomes visible via the v1 read path", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); + const { id, revisionId } = await createPending("user-1"); - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() + // Until approval the extension exists but is in neither catalogue. + expect((await get("/extensions/v1/new-ext", {})).status).toBe(404); + expect((await get("/extensions/v2/extensions/new-ext", {})).status).toBe( + 404 ); - const { result } = (await created.json()) as { result: { id: string } }; const approved = await post( - `/extensions/v2/submissions/${result.id}/approve`, + reviewPath(id, revisionId, "approve"), await authHeaders("mod-1"), {} ); @@ -231,6 +196,11 @@ describe("Extensions API v2", () => { }; expect(approvedBody.result.status).toBe("approved"); + const stored = await getExtension(db, "new-ext"); + expect(stored?.published_at).not.toBeNull(); + expect(stored?.published_revision_id).toBe(revisionId); + expect(stored?.name).toBe("New Extension"); + // v1's read-only API keeps calling this field "author" — its JSON // response shape is intentionally unchanged by the v2 rename. const v1Res = await get("/extensions/v1/new-ext", {}); @@ -242,49 +212,95 @@ describe("Extensions API v2", () => { expect(v1Body.result.author.id).toBe("new-developer"); }); - it("blocks non-moderators from approving", async () => { + // Approving an extension used to rewrite the developer row from the + // submission payload and clear its approval as a side effect. A revision + // carries extension content only, so there is nothing left to write. + it("does not touch the developer profile", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const before = await getDeveloper(db, "new-developer"); + const { id, revisionId } = await createPending("user-1"); + + expect( + ( + await post( + reviewPath(id, revisionId, "approve"), + await authHeaders("mod-1"), + {} + ) + ).status + ).toBe(200); + + expect(await getDeveloper(db, "new-developer")).toEqual(before); + }); + + it("keeps published_at at the first publication across later edits", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); - const created = await post( - "/extensions/v2/submissions", + const first = await createPending("user-1"); + await post( + reviewPath(first.id, first.revisionId, "approve"), + await authHeaders("mod-1"), + {} + ); + const afterFirst = await getExtension(db, first.id); + + const edit = await put( + `/extensions/v2/extensions/${first.id}`, await authHeaders("user-1"), - samplePayload() + sampleContent({ name: "Second Version" }) ); - const { result } = (await created.json()) as { result: { id: string } }; + const { result } = (await edit.json()) as { + result: { revision_id: string }; + }; + expect( + ( + await post( + reviewPath(first.id, result.revision_id, "approve"), + await authHeaders("mod-1"), + {} + ) + ).status + ).toBe(200); + + const afterEdit = await getExtension(db, first.id); + expect(afterEdit?.published_at).toBe(afterFirst?.published_at); + expect(afterEdit?.published_revision_id).toBe(result.revision_id); + expect(afterEdit?.name).toBe("Second Version"); + }); + + it("blocks non-moderators from approving", async () => { + await seedDeveloper("new-developer", "user-1"); + const { id, revisionId } = await createPending("user-1"); const res = await post( - `/extensions/v2/submissions/${result.id}/approve`, + reviewPath(id, revisionId, "approve"), await authHeaders("user-1"), {} ); expect(res.status).toBe(403); }); - it("rejects approving a submission that is not pending", async () => { + it("rejects approving a revision that is not pending", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; + const { id, revisionId } = await createPending("user-1"); + const headers = await authHeaders("mod-1"); - await post( - `/extensions/v2/submissions/${result.id}/approve`, - await authHeaders("mod-1"), - {} - ); + await post(reviewPath(id, revisionId, "approve"), headers, {}); const secondApprove = await post( - `/extensions/v2/submissions/${result.id}/approve`, - await authHeaders("mod-1"), + reviewPath(id, revisionId, "approve"), + headers, {} ); expect(secondApprove.status).toBe(409); - // The second (raced) approve must not write through again. expect(await countExtensions(db)).toBe(1); }); - it("updates the existing row instead of duplicating it when an edit's id differs only by case", async () => { + // Legacy v1 data can have mixed-case ids. The id now comes from the path + // rather than a payload field, so an edit addresses that row directly and + // cannot fork a second, lowercase one. + it("edits a mixed-case legacy extension in place", async () => { await insertDeveloper(db, { id: "owner-developer", type: "user", @@ -292,83 +308,100 @@ describe("Extensions API v2", () => { url: null, owner_user_id: "owner-1" }); - // Legacy v1 data can have mixed-case ids; v2 submissions must be lowercase. await insertExtension(db, { id: "Existing-Ext", - type: "mod", - author_id: "owner-developer", - name: "Existing", - description: "d", - releases: "[]", - website: "https://e.com", - license: '{"name":"MIT"}', - icon_url: null, - readme: "r", - source: '{"type":"github","repo":"example/existing"}', - version: "1.0.0", - download_url: "https://e.com/d.zip" + developer_id: "owner-developer", + name: "Existing" }); await insertUser(db, { id: "mod-1", is_moderator: 1 }); - const created = await post( - "/extensions/v2/submissions", + const edit = await put( + "/extensions/v2/extensions/Existing-Ext", await authHeaders("owner-1"), - samplePayload({ - extensionId: "existing-ext", - developerId: "owner-developer" - }) + sampleContent() ); - const { result } = (await created.json()) as { result: { id: string } }; + expect(edit.status).toBe(202); + const { result } = (await edit.json()) as { + result: { revision_id: string }; + }; const approved = await post( - `/extensions/v2/submissions/${result.id}/approve`, + reviewPath("Existing-Ext", result.revision_id, "approve"), await authHeaders("mod-1"), {} ); expect(approved.status).toBe(200); expect(await countExtensions(db)).toBe(1); - const stored = await getExtension(db, "Existing-Ext"); - expect(stored?.name).toBe("New Extension"); + expect((await getExtension(db, "Existing-Ext"))?.name).toBe( + "New Extension" + ); }); it("requires a review_note to reject", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; + const { id, revisionId } = await createPending("user-1"); const res = await post( - `/extensions/v2/submissions/${result.id}/reject`, + reviewPath(id, revisionId, "reject"), await authHeaders("mod-1"), {} ); expect(res.status).toBe(422); }); - it("rejects a submission with a note", async () => { + it("rejects a revision with a note and leaves the extension unpublished", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; + const { id, revisionId } = await createPending("user-1"); const res = await post( - `/extensions/v2/submissions/${result.id}/reject`, + reviewPath(id, revisionId, "reject"), await authHeaders("mod-1"), { review_note: "Needs a valid license URL" } ); expect(res.status).toBe(200); const body = (await res.json()) as { result: { status: string } }; expect(body.result.status).toBe("rejected"); - expect(await countExtensions(db)).toBe(0); + + // The record survives so the owner can see the reason and resubmit. + const stored = await getExtension(db, id); + expect(stored).not.toBeNull(); + expect(stored?.published_at).toBeNull(); + + const mine = await get( + `/extensions/v2/extensions/mine/${id}`, + await authHeaders("user-1") + ); + await expect(mine.json()).resolves.toMatchObject({ + result: { + published: null, + pending_revision: null, + last_review: { + status: "rejected", + review_note: "Needs a valid license URL" + } + } + }); + }); + + it("lets the owner resubmit after a rejection", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const { id, revisionId } = await createPending("user-1"); + await post( + reviewPath(id, revisionId, "reject"), + await authHeaders("mod-1"), + { review_note: "no" } + ); + + const retry = await put( + `/extensions/v2/extensions/${id}`, + await authHeaders("user-1"), + sampleContent({ name: "Fixed" }) + ); + expect(retry.status).toBe(202); }); // Both review-note bodies are strict: the reviewer decision is derived @@ -377,20 +410,14 @@ describe("Extensions API v2", () => { it("rejects an unknown field in the approve and reject bodies", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); - await insertSubmission(db, { - id: "strict-body-submission", - developer_id: "new-developer", - submitted_by: "user-1", - payload: JSON.stringify(samplePayload({ developerId: "new-developer" })) - }); + const { id, revisionId } = await createPending("user-1"); const headers = await authHeaders("mod-1"); - for (const path of ["approve", "reject"]) { - const res = await post( - `/extensions/v2/submissions/strict-body-submission/${path}`, - headers, - { review_note: "looks fine", reviewer_id: "someone-else" } - ); + for (const action of ["approve", "reject"] as const) { + const res = await post(reviewPath(id, revisionId, action), headers, { + review_note: "looks fine", + reviewer_id: "someone-else" + }); expect(res.status).toBe(422); const body = (await res.json()) as { @@ -403,7 +430,7 @@ describe("Extensions API v2", () => { ); } - expect(await getSubmission(db, "strict-body-submission")).toMatchObject({ + expect(await getRevision(db, revisionId)).toMatchObject({ status: "pending" }); }); diff --git a/test/services/extensions/v2/ownership.test.ts b/test/services/extensions/v2/ownership.test.ts index 98baf23..0a1c164 100644 --- a/test/services/extensions/v2/ownership.test.ts +++ b/test/services/extensions/v2/ownership.test.ts @@ -9,7 +9,7 @@ import { post, get, put, - samplePayload, + sampleContent, sampleDeveloper, seedUnownedDeveloper, mockGithubEntity, @@ -18,10 +18,11 @@ import { import { insertUser, insertDeveloper, - insertSubmission, + insertUnpublishedExtension, + insertRevision, insertDeveloperClaim, getDeveloper, - getSubmission, + getRevision, countDeveloperClaims, getDeveloperClaim, listDeveloperClaims, @@ -168,7 +169,7 @@ describe("Extensions API v2", () => { }); }); - it("rejects pending submissions and claims when ownership changes", async () => { + it("rejects pending revisions and claims when ownership changes", async () => { await put( "/extensions/v2/developers/me", await authHeaders("user-1"), @@ -179,11 +180,16 @@ describe("Extensions API v2", () => { developer_id: "dev-developer", claimant_id: "user-3" }); - await insertSubmission(db, { - id: "transfer-pending-submission", + await insertUnpublishedExtension(db, { + id: "transfer-pending-ext", + developer_id: "dev-developer" + }); + await insertRevision(db, { + id: "transfer-pending-revision", + extension_id: "transfer-pending-ext", developer_id: "dev-developer", submitted_by: "user-3", - payload: JSON.stringify(samplePayload({ developerId: "dev-developer" })) + content: JSON.stringify(sampleContent()) }); const initiate = await post( @@ -206,9 +212,7 @@ describe("Extensions API v2", () => { status: "rejected", review_note: "Ownership changed before review" }); - expect( - await getSubmission(db, "transfer-pending-submission") - ).toMatchObject({ + expect(await getRevision(db, "transfer-pending-revision")).toMatchObject({ status: "rejected", review_note: "Ownership changed before review" }); diff --git a/test/services/extensions/v2/public-extensions.test.ts b/test/services/extensions/v2/public-extensions.test.ts index 2a28204..656a89a 100644 --- a/test/services/extensions/v2/public-extensions.test.ts +++ b/test/services/extensions/v2/public-extensions.test.ts @@ -24,7 +24,7 @@ describe("Extensions API v2", () => { await insertExtension(db, { id, type: "mod", - author_id: "catalogue-developer", + developer_id: "catalogue-developer", name: id, description: `Description for ${id}`, releases: '[{"tag":"1.0.0"}]', diff --git a/test/services/extensions/v2/submissions.test.ts b/test/services/extensions/v2/submissions.test.ts deleted file mode 100644 index 6171da0..0000000 --- a/test/services/extensions/v2/submissions.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { - setupExtensionsV2Tests, - db, - authHeaders, - post, - get, - samplePayload, - seedDeveloper, - seedOwnedExtension -} from "./harness"; -import { - countSubmissions, - getSubmission, - listSubmissions -} from "./db-fixtures"; - -// Hoisted so no v2 suite can make a real GitHub call. harness.ts applies the -// default "not found" behaviour in beforeEach and documents why. -vi.mock("@octokit/request", async () => - (await import("../../../mocks/octokit")).octokitRequestMock() -); - -setupExtensionsV2Tests(); - -describe("Extensions API v2", () => { - describe("POST /submissions", () => { - it("requires auth", async () => { - const res = await post( - "/extensions/v2/submissions", - { - "Content-Type": "application/json" - }, - samplePayload() - ); - expect(res.status).toBe(401); - }); - - it("rejects an invalid payload", async () => { - const headers = await authHeaders("user-1"); - const res = await post("/extensions/v2/submissions", headers, { - developer: {}, - extension: {} - }); - expect(res.status).toBe(422); - const data = (await res.json()) as { error: { code: string } }; - expect(data.error.code).toBe("VALIDATION_ERROR"); - }); - - it("rejects the reserved extension id mine", async () => { - const payload = samplePayload({ extensionId: "mine" }); - const res = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - payload - ); - - expect(res.status).toBe(422); - expect(await countSubmissions(db)).toBe(0); - }); - - it("rejects profile fields (avatar_url/contact_email) on a submission's developer", async () => { - await seedDeveloper("new-developer", "user-1"); - const headers = await authHeaders("user-1"); - const payload = samplePayload(); - const res = await post("/extensions/v2/submissions", headers, { - ...payload, - developer: { - ...payload.developer, - avatar_url: "https://example.com/should-not-be-accepted.png" - } - }); - - expect(res.status).toBe(422); - expect(await countSubmissions(db)).toBe(0); - }); - - it("creates a pending submission for a brand-new extension under an existing developer", async () => { - await seedDeveloper("new-developer", "user-1"); - const headers = await authHeaders("user-1"); - const res = await post( - "/extensions/v2/submissions", - headers, - samplePayload() - ); - - expect(res.status).toBe(201); - const data = (await res.json()) as { - result: { id: string; status: string }; - }; - expect(data.result.status).toBe("pending"); - expect(await countSubmissions(db)).toBe(1); - - const stored = await getSubmission(db, data.result.id); - expect(stored?.extension_id).toBeNull(); - expect(stored?.submitted_by).toBe("user-1"); - }); - - it("rejects editing an extension not owned by the caller", async () => { - await seedOwnedExtension(); - const headers = await authHeaders("intruder"); - - const res = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ - extensionId: "existing-ext", - developerId: "owner-developer" - }) - ); - - expect(res.status).toBe(403); - expect(await countSubmissions(db)).toBe(0); - }); - - it("allows editing an extension owned by the caller", async () => { - await seedOwnedExtension(); - const headers = await authHeaders("owner-1"); - - const res = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ - extensionId: "existing-ext", - developerId: "owner-developer" - }) - ); - - expect(res.status).toBe(201); - const [stored] = await listSubmissions(db); - expect(stored.extension_id).toBe("existing-ext"); - }); - - it("rejects claiming a developer already owned by someone else", async () => { - await seedOwnedExtension(); - const headers = await authHeaders("intruder"); - - const res = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ - extensionId: "another-new-ext", - developerId: "owner-developer" - }) - ); - - expect(res.status).toBe(403); - }); - - it("rejects naming a developer id that doesn't exist at all", async () => { - const headers = await authHeaders("user-1"); - - const res = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ developerId: "no-such-developer" }) - ); - - expect(res.status).toBe(403); - expect(await countSubmissions(db)).toBe(0); - }); - - it("bounds payload size and the number of releases", async () => { - await seedDeveloper("new-developer", "user-1"); - const payload = samplePayload(); - const oversized = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - { - ...payload, - extension: { ...payload.extension, readme: "x".repeat(100_001) } - } - ); - expect(oversized.status).toBe(422); - - const unknownExtensionField = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - { - ...payload, - extension: { - ...payload.extension, - padding: "x" - } - } - ); - expect(unknownExtensionField.status).toBe(422); - const unknownExtensionBody = (await unknownExtensionField.json()) as { - error: { details: Array<{ code: string; path: PropertyKey[] }> }; - }; - expect(unknownExtensionBody.error.details).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: "unrecognized_keys", - path: ["extension"] - }) - ]) - ); - - const unknownReleaseField = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - { - ...payload, - extension: { - ...payload.extension, - releases: [ - { - ...payload.extension.releases[0], - padding: "x" - } - ] - } - } - ); - expect(unknownReleaseField.status).toBe(422); - const unknownReleaseBody = (await unknownReleaseField.json()) as { - error: { details: Array<{ code: string; path: PropertyKey[] }> }; - }; - expect(unknownReleaseBody.error.details).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: "unrecognized_keys", - path: ["extension", "releases", 0] - }) - ]) - ); - - const tooManyReleases = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - { - ...payload, - extension: { - ...payload.extension, - releases: Array.from( - { length: 101 }, - () => payload.extension.releases[0] - ) - } - } - ); - expect(tooManyReleases.status).toBe(422); - }); - - it("preserves compatibility with stored slug ids over 100 characters", async () => { - const developerId = "d".repeat(120); - const extensionId = "e".repeat(120); - await seedDeveloper(developerId, "user-1"); - - const res = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload({ developerId, extensionId }) - ); - - expect(res.status).toBe(201); - }); - - it("rejects duplicate pending targets and caps each user's backlog", async () => { - await seedDeveloper("new-developer", "user-1"); - const headers = await authHeaders("user-1"); - expect( - (await post("/extensions/v2/submissions", headers, samplePayload())) - .status - ).toBe(201); - expect( - (await post("/extensions/v2/submissions", headers, samplePayload())) - .status - ).toBe(409); - - await seedDeveloper("other-developer", "user-2"); - expect( - ( - await post( - "/extensions/v2/submissions", - await authHeaders("user-2"), - samplePayload({ developerId: "other-developer" }) - ) - ).status - ).toBe(409); - - for (let index = 1; index < 10; index++) { - const result = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ extensionId: `new-ext-${index}` }) - ); - expect(result.status).toBe(201); - } - const overLimit = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ extensionId: "new-ext-over-limit" }) - ); - expect(overLimit.status).toBe(409); - expect(await countSubmissions(db)).toBe(10); - }); - }); - - describe("GET /submissions/mine", () => { - it("returns only the caller's own submissions", async () => { - await seedDeveloper("developer-a", "user-1"); - await seedDeveloper("developer-b", "user-2"); - await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload({ extensionId: "ext-a", developerId: "developer-a" }) - ); - await post( - "/extensions/v2/submissions", - await authHeaders("user-2"), - samplePayload({ extensionId: "ext-b", developerId: "developer-b" }) - ); - - const res = await get( - "/extensions/v2/submissions/mine", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: Array<{ submitted_by: string }>; - }; - expect(data.result).toHaveLength(1); - expect(data.result[0].submitted_by).toBe("user-1"); - }); - - it("requires auth", async () => { - const res = await get("/extensions/v2/submissions/mine", {}); - expect(res.status).toBe(401); - }); - - it("identifies invalid cursors", async () => { - const res = await get( - "/extensions/v2/submissions/mine?cursor=not-a-cursor", - await authHeaders("user-1") - ); - expect(res.status).toBe(422); - await expect(res.json()).resolves.toMatchObject({ - error: { code: "INVALID_CURSOR" } - }); - }); - - it("paginates deterministically with an opaque cursor", async () => { - await seedDeveloper("new-developer", "user-1"); - const headers = await authHeaders("user-1"); - for (const extensionId of ["page-a", "page-b", "page-c"]) { - expect( - ( - await post( - "/extensions/v2/submissions", - headers, - samplePayload({ extensionId }) - ) - ).status - ).toBe(201); - } - - const first = await get( - "/extensions/v2/submissions/mine?limit=2", - headers - ); - const firstBody = (await first.json()) as { - result: unknown[]; - pagination: { has_more: boolean; next_cursor: string }; - }; - expect(firstBody.result).toHaveLength(2); - expect(firstBody.pagination.has_more).toBe(true); - - const second = await get( - `/extensions/v2/submissions/mine?limit=2&cursor=${encodeURIComponent(firstBody.pagination.next_cursor)}`, - headers - ); - const secondBody = (await second.json()) as { - result: unknown[]; - pagination: { has_more: boolean; next_cursor: null }; - }; - expect(secondBody.result).toHaveLength(1); - expect(secondBody.pagination).toEqual({ - has_more: false, - next_cursor: null - }); - }); - }); -}); From 55028e4db1b49edf151a874f2ea8ce3626bae2b4 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 8 Aug 2026 09:14:30 +0100 Subject: [PATCH 2/9] Fix review findings on the extension restructure Six findings from review, each now covered by a test that fails without its fix. Owner list paginated without an index. The catalogue-order indexes were made partial on published_at, but GET /extensions/mine cannot filter on it, so the query fell back to a plain developer_id index and sorted into a temp B-tree past the first page. idx_extensions_developer_order is unfiltered and covers both that query and the public developer_id filter, which replaces the partial one. Owner type filter hid drafts. extensions.type is NULL until first approval, so ?type=mod dropped every unpublished extension from its own owner's list. It now falls back to the pending revision's content, then the last reviewed one, which covers both unpublished states. Id case handling was inconsistent. Reads resolved ids case-insensitively for legacy mixed-case rows, but propose/withdraw/approve/reject and the revision list did not, so such an extension was readable but not editable, and its revision list came back empty. Writes now resolve the same way, and the revision list is given the stored spelling rather than the requested one. Withdrawal had no active-account guard. Every other write re-checks users.deleted_at inside the statement; this one trusted requireActiveAuth(), which can only reject before the write. Gated, and mapped to ACCOUNT_INACTIVE/403. Migration 0021 carried two hazards forward. A legacy submission targeting a reserved id would have materialised an extension that GET /extensions/{id} can never serve, now that approval no longer re-checks the id; the migration fails the deploy instead, matching 0020. And a pending submission whose developer or ownership state can never satisfy approve()'s predicate would have sat pending forever while holding the one-pending-per-extension slot, blocking the owner's next edit; those are rejected during the migration with the same note the transfer and account-deletion paths already use. The case-collision test also seeded the id it claimed to differ from, so it only re-tested the plain duplicate path. --- src/services/extensions/v2/db/extensions.ts | 74 ++++++-- .../0021_restructure_extensions_revisions.sql | 50 +++++- .../v2/db/migrations/meta/0021_snapshot.json | 18 +- src/services/extensions/v2/db/revisions.ts | 20 ++- src/services/extensions/v2/db/schema.ts | 22 ++- .../extensions/v2/routes/owner-extensions.ts | 16 +- .../extensions/v2/extension-writes.test.ts | 159 +++++++++++++++++- .../services/extensions/v2/migrations.test.ts | 151 +++++++++++++++++ 8 files changed, 456 insertions(+), 54 deletions(-) diff --git a/src/services/extensions/v2/db/extensions.ts b/src/services/extensions/v2/db/extensions.ts index 77fa8ef..a758255 100644 --- a/src/services/extensions/v2/db/extensions.ts +++ b/src/services/extensions/v2/db/extensions.ts @@ -4,7 +4,7 @@ import { DatabaseResult } from "../../../../lib/interfaces"; import { ExtensionsDb } from "../../../../lib/db"; import { sortReleasesDescending } from "../../../../lib/releases"; import { parseJSON } from "../../../../lib/json"; -import { extensions, extensionRevisions, developers } from "./schema"; +import { extensions, extensionRevisions, developers, users } from "./schema"; import { databaseError } from "./errors"; import { toD1Statement } from "./batch"; import { encodeCursor as encode, decodeCursor as decode } from "./cursor"; @@ -281,7 +281,19 @@ export class ExtensionsDatabase { }): Promise> { const limit = filters.limit ?? 50; const conditions = [eq(extensions.developerId, filters.developerId)]; - if (filters.type) conditions.push(eq(extensions.type, filters.type)); + // extensions.type is NULL until a first approval, so filtering the column + // alone would hide every draft and every rejected extension from their own + // owner. Fall back to the type the unreviewed edit proposes, then to the + // last reviewed one, which between them cover both unpublished states. + if (filters.type) { + conditions.push( + sql`COALESCE( + ${extensions.type}, + json_extract(${PENDING.content}, '$.type'), + json_extract(${REVIEWED.content}, '$.type') + ) = ${filters.type}` + ); + } if (filters.cursor) { const cursor = decodeCursor(filters.cursor); if (!cursor) return invalidCursor(); @@ -439,34 +451,64 @@ export class ExtensionsDatabase { try { result = await this.db.run(sql` DELETE FROM ${extensions} - WHERE id = ${id} + WHERE LOWER(id) = LOWER(${id}) AND published_at IS NULL AND developer_id IN ( SELECT d.id FROM ${developers} d WHERE d.owner_user_id = ${ownerUserId} ) + AND EXISTS ( + SELECT 1 FROM ${users} u + WHERE u.id = ${ownerUserId} AND u.deleted_at IS NULL + ) `); } catch (error) { return databaseError("withdraw", error); } if (!result.meta?.changes) { - const [existing] = await this.db - .select({ publishedAt: extensions.publishedAt }) - .from(extensions) - .where(eq(extensions.id, id)); - if (!existing) return notFound(id); + return this.withdrawBlockedError(id, ownerUserId); + } + + return { data: { id }, error: null }; + } + + // Separates the four ways the delete can affect no rows, so the route can + // answer 404/403/409 rather than one opaque failure. The active-account + // check is repeated inside the statement above rather than trusted from + // requireActiveAuth(), which can only reject before the write; a deletion + // landing in between would otherwise still take effect. + private async withdrawBlockedError( + id: string, + ownerUserId: string + ): Promise> { + const [existing] = await this.db + .select({ + publishedAt: extensions.publishedAt, + ownerUserId: developers.ownerUserId + }) + .from(extensions) + .innerJoin(developers, eq(extensions.developerId, developers.id)) + .where(sql`LOWER(${extensions.id}) = LOWER(${id})`); + if (!existing) return notFound(id); + if (existing.publishedAt) { return { data: null, - error: existing.publishedAt - ? { - message: "A published extension cannot be withdrawn", - code: "CONFLICT" - } - : { message: "You do not own this extension", code: "FORBIDDEN" } + error: { + message: "A published extension cannot be withdrawn", + code: "CONFLICT" + } }; } - - return { data: { id }, error: null }; + if (existing.ownerUserId !== ownerUserId) { + return { + data: null, + error: { message: "You do not own this extension", code: "FORBIDDEN" } + }; + } + return { + data: null, + error: { message: "Active account required", code: "ACCOUNT_INACTIVE" } + }; } } diff --git a/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql b/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql index b17ca35..8e5287f 100644 --- a/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql +++ b/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql @@ -19,6 +19,28 @@ -- against schema.ts by test/services/extensions/v2/migrations.test.ts. PRAGMA foreign_keys=OFF;--> statement-breakpoint +-- A submission whose target id is reserved would materialise an extension +-- that GET /extensions/{id} can never serve, because the static +-- GET /extensions/mine route is registered first. The old code rejected these +-- at submission time and again at approval; with approval no longer looking at +-- the id, the check has to happen here, before the row exists. +-- +-- This fails the deploy rather than dropping the submission, matching +-- migration 0020. If it fires, reject or delete the offending row by hand and +-- re-run - unlike 0020's case the id is not yet public, so nothing pins it and +-- there is nothing to preserve. Comparison is on the lowercased target because +-- that is what the materialisation below would insert. +CREATE TABLE _reserved_target_check (ok INTEGER NOT NULL CHECK (ok = 1));--> statement-breakpoint + +INSERT INTO _reserved_target_check (ok) +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM extension_submissions + WHERE LOWER(COALESCE(extension_id, json_extract(payload, '$.extension.id'))) + IN ('mine') + ) THEN 0 ELSE 1 END;--> statement-breakpoint + +DROP TABLE _reserved_target_check;--> statement-breakpoint + -- developers first, while every table that references it is still the old one: -- the drop-and-rename re-parses every schema, and doing it with a referrer -- pointing at a dropped table is the case that errors. @@ -166,10 +188,9 @@ DROP TABLE `extensions`;--> statement-breakpoint ALTER TABLE `__new_extensions` RENAME TO `extensions`;--> statement-breakpoint CREATE UNIQUE INDEX `idx_extensions_id_nocase` ON `extensions` (lower("id"));--> statement-breakpoint -CREATE INDEX `idx_extensions_developer` ON `extensions` (`developer_id`);--> statement-breakpoint +CREATE INDEX `idx_extensions_developer_order` ON `extensions` (`developer_id`,lower("id"),`id`);--> statement-breakpoint CREATE INDEX `idx_extensions_catalogue_order` ON `extensions` (lower("id"),`id`) WHERE "extensions"."published_at" IS NOT NULL;--> statement-breakpoint CREATE INDEX `idx_extensions_type_catalogue_order` ON `extensions` (`type`,lower("id"),`id`) WHERE "extensions"."published_at" IS NOT NULL;--> statement-breakpoint -CREATE INDEX `idx_extensions_developer_catalogue_order` ON `extensions` (`developer_id`,lower("id"),`id`) WHERE "extensions"."published_at" IS NOT NULL;--> statement-breakpoint CREATE TABLE `extension_revisions` ( `id` text PRIMARY KEY NOT NULL, @@ -223,6 +244,31 @@ JOIN extensions e -- never been writable through the API: SubmissionPayloadSchema required it. WHERE json_extract(s.payload, '$.extension') IS NOT NULL;--> statement-breakpoint +-- A pending revision is only approvable if its developer still exists, still +-- belongs to the submitter, still has the ownership epoch the revision was +-- filed under, and is still the extension's developer - that is exactly the +-- EXISTS predicate in ExtensionRevisionsDatabase.approve(). Legacy rows that +-- fail it can never be approved, and because at most one revision per +-- extension may be pending they would also block the owner's next edit +-- indefinitely. +-- +-- Rejecting rather than deleting keeps the record and frees the slot, and uses +-- the same note the transfer and account-deletion paths already write when +-- they invalidate pending work. +UPDATE extension_revisions +SET status = 'rejected', + review_note = 'Ownership changed before review', + reviewed_at = CURRENT_TIMESTAMP +WHERE status = 'pending' + AND NOT EXISTS ( + SELECT 1 FROM developers d + JOIN extensions e ON e.id = extension_revisions.extension_id + WHERE d.id = extension_revisions.developer_id + AND d.id = e.developer_id + AND d.owner_user_id = extension_revisions.submitted_by + AND d.ownership_epoch = extension_revisions.ownership_epoch + );--> 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 diff --git a/src/services/extensions/v2/db/migrations/meta/0021_snapshot.json b/src/services/extensions/v2/db/migrations/meta/0021_snapshot.json index ca15e11..809b481 100644 --- a/src/services/extensions/v2/db/migrations/meta/0021_snapshot.json +++ b/src/services/extensions/v2/db/migrations/meta/0021_snapshot.json @@ -818,9 +818,9 @@ "columns": ["lower(\"id\")"], "isUnique": true }, - "idx_extensions_developer": { - "name": "idx_extensions_developer", - "columns": ["developer_id"], + "idx_extensions_developer_order": { + "name": "idx_extensions_developer_order", + "columns": ["developer_id", "lower(\"id\")", "id"], "isUnique": false }, "idx_extensions_catalogue_order": { @@ -834,12 +834,6 @@ "columns": ["type", "lower(\"id\")", "id"], "isUnique": false, "where": "\"extensions\".\"published_at\" IS NOT NULL" - }, - "idx_extensions_developer_catalogue_order": { - "name": "idx_extensions_developer_catalogue_order", - "columns": ["developer_id", "lower(\"id\")", "id"], - "isUnique": false, - "where": "\"extensions\".\"published_at\" IS NOT NULL" } }, "foreignKeys": { @@ -1002,21 +996,21 @@ } } }, - "idx_extensions_catalogue_order": { + "idx_extensions_developer_order": { "columns": { "lower(\"id\")": { "isExpression": true } } }, - "idx_extensions_type_catalogue_order": { + "idx_extensions_catalogue_order": { "columns": { "lower(\"id\")": { "isExpression": true } } }, - "idx_extensions_developer_catalogue_order": { + "idx_extensions_type_catalogue_order": { "columns": { "lower(\"id\")": { "isExpression": true diff --git a/src/services/extensions/v2/db/revisions.ts b/src/services/extensions/v2/db/revisions.ts index 387a78f..aefe1fd 100644 --- a/src/services/extensions/v2/db/revisions.ts +++ b/src/services/extensions/v2/db/revisions.ts @@ -108,7 +108,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 e.id = ${input.extensionId} + WHERE LOWER(e.id) = LOWER(${input.extensionId}) AND d.owner_user_id = ${input.callerId} AND EXISTS ( SELECT 1 FROM ${users} u @@ -145,7 +145,7 @@ export class ExtensionRevisionsDatabase { .select({ ownerUserId: developers.ownerUserId }) .from(extensions) .innerJoin(developers, eq(extensions.developerId, developers.id)) - .where(eq(extensions.id, input.extensionId)); + .where(sql`LOWER(${extensions.id}) = LOWER(${input.extensionId})`); if (!existing) { return { @@ -165,7 +165,7 @@ export class ExtensionRevisionsDatabase { .from(extensionRevisions) .where( and( - eq(extensionRevisions.extensionId, input.extensionId), + sql`LOWER(${extensionRevisions.extensionId}) = LOWER(${input.extensionId})`, eq(extensionRevisions.status, "pending") ) ); @@ -287,7 +287,7 @@ export class ExtensionRevisionsDatabase { .where( and( eq(extensionRevisions.id, id), - eq(extensionRevisions.extensionId, extensionId) + sql`LOWER(${extensionRevisions.extensionId}) = LOWER(${extensionId})` ) ); } catch (error) { @@ -338,7 +338,7 @@ export class ExtensionRevisionsDatabase { .where( and( eq(extensionRevisions.id, id), - eq(extensionRevisions.extensionId, extensionId), + sql`LOWER(${extensionRevisions.extensionId}) = LOWER(${extensionId})`, eq(extensionRevisions.status, "pending"), sql`EXISTS ( SELECT 1 FROM ${users} @@ -410,7 +410,13 @@ export class ExtensionRevisionsDatabase { SELECT 1 FROM users u WHERE u.id = ? AND u.deleted_at IS NULL )`, - params: [reviewerId, reviewNote ?? null, id, extensionId, reviewerId] + params: [ + reviewerId, + reviewNote ?? null, + id, + revision.extension_id, + reviewerId + ] }); // published_at is COALESCEd rather than overwritten: it records when the @@ -438,7 +444,7 @@ export class ExtensionRevisionsDatabase { content.version, content.download_url, id, - extensionId + revision.extension_id ] }); diff --git a/src/services/extensions/v2/db/schema.ts b/src/services/extensions/v2/db/schema.ts index 1441b6d..8a7aff0 100644 --- a/src/services/extensions/v2/db/schema.ts +++ b/src/services/extensions/v2/db/schema.ts @@ -77,20 +77,26 @@ export const extensions = sqliteTable( // 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})`), - index("idx_extensions_developer").on(table.developerId), - // The catalogue-order indexes are partial: every public read filters on - // published_at IS NOT NULL, and unpublished rows would otherwise sit in - // the index the catalogue scans. The owner list, which does not filter, - // seeks through idx_extensions_developer instead. + // 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 + // leave the owner query sorting into a temporary B-tree once a developer + // has more than one page. The public read gets its ordered seek from the + // same index and checks published_at per row. + index("idx_extensions_developer_order").on( + table.developerId, + sql`lower(${table.id})`, + table.id + ), + // These two are partial: every read that uses them filters on + // published_at IS NOT NULL, so unpublished rows would only bloat the + // index the catalogue scans. index("idx_extensions_catalogue_order") .on(sql`lower(${table.id})`, table.id) .where(sql`${table.publishedAt} IS NOT NULL`), index("idx_extensions_type_catalogue_order") .on(table.type, sql`lower(${table.id})`, table.id) .where(sql`${table.publishedAt} IS NOT NULL`), - index("idx_extensions_developer_catalogue_order") - .on(table.developerId, sql`lower(${table.id})`, table.id) - .where(sql`${table.publishedAt} IS NOT NULL`), // "Published" must mean every column the public contract declares // non-optional is present. icon_url is genuinely optional and is left out. check( diff --git a/src/services/extensions/v2/routes/owner-extensions.ts b/src/services/extensions/v2/routes/owner-extensions.ts index 89fdf06..0499fdf 100644 --- a/src/services/extensions/v2/routes/owner-extensions.ts +++ b/src/services/extensions/v2/routes/owner-extensions.ts @@ -1,4 +1,8 @@ -import { errorBody, statusFromErrorCode } from "./errors"; +import { + errorBody, + statusFromErrorCode, + statusFromOwnershipErrorCode +} from "./errors"; import { requireActiveAuth } from "../middleware"; import { getExtensionsDb } from "../../../../lib/db"; import { getAuth } from "../../../../lib/auth"; @@ -342,7 +346,9 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { if (error || !data) { return c.json( errorBody(error, "Unable to withdraw extension"), - error?.code === "FORBIDDEN" ? 403 : statusFromErrorCode(error?.code) + statusFromOwnershipErrorCode(error?.code) === 403 + ? 403 + : statusFromErrorCode(error?.code) ); } return c.json({ result: { id: data.id, deleted: true as const } }, 200); @@ -421,7 +427,11 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { const db = new ExtensionRevisionsDatabase( getExtensionsDb(c.env.DB_EXTENSIONS) ); - const { data, error } = await db.listByExtension(id, limit, cursor); + const { data, error } = await db.listByExtension( + owned.data.extension.id, + limit, + cursor + ); if (error || !data) { return c.json( errorBody(error, "Unable to load revisions"), diff --git a/test/services/extensions/v2/extension-writes.test.ts b/test/services/extensions/v2/extension-writes.test.ts index a52ce4e..123c43e 100644 --- a/test/services/extensions/v2/extension-writes.test.ts +++ b/test/services/extensions/v2/extension-writes.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi } from "vitest"; +import { env } from "cloudflare:workers"; +import { wrapD1WithHook } from "./db-interceptor"; import { setupExtensionsV2Tests, db, @@ -13,9 +15,13 @@ import { seedOwnedExtension } from "./harness"; import { + countExtensions, countRevisions, + insertUser, getExtension, getRevision, + insertDeveloper, + insertExtension, listRevisions } from "./db-fixtures"; @@ -27,6 +33,22 @@ vi.mock("@octokit/request", async () => setupExtensionsV2Tests(); +// Adopted pre-v2 rows can carry mixed-case ids; new ones cannot, since +// lowercaseId() rejects them at validation. +async function seedMixedCaseExtension() { + await insertDeveloper(db, { + id: "owner-developer", + type: "user", + name: "Owner", + owner_user_id: "owner-1" + }); + await insertExtension(db, { + id: "Existing-Ext", + developer_id: "owner-developer", + name: "Existing" + }); +} + async function createExtension( user: string, overrides?: { extensionId?: string; name?: string } @@ -131,13 +153,13 @@ describe("Extensions API v2 writes", () => { }); it("rejects an id that differs from an existing one only in case", async () => { - await seedOwnedExtension(); - const res = await post( - "/extensions/v2/extensions", - await authHeaders("owner-1"), - { ...sampleCreate(), id: "existing-ext" } - ); + await seedMixedCaseExtension(); + const res = await createExtension("owner-1", { + extensionId: "existing-ext" + }); + expect(res.status).toBe(409); + expect(await countExtensions(db)).toBe(1); }); it("bounds content size, unknown fields, and the number of releases", async () => { @@ -304,6 +326,54 @@ describe("Extensions API v2 writes", () => { }); }); + // Every read resolves an extension id case-insensitively, so the writes have + // to agree: otherwise a legacy mixed-case extension is visible but not + // editable, and its own revision list comes back empty. + describe("mixed-case legacy ids", () => { + it("accepts an edit addressed in lower case", async () => { + await seedMixedCaseExtension(); + const res = await put( + "/extensions/v2/extensions/existing-ext", + await authHeaders("owner-1"), + sampleContent({ name: "Renamed" }) + ); + + 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"); + }); + + it("lists revisions addressed in lower case", async () => { + await seedMixedCaseExtension(); + await put( + "/extensions/v2/extensions/existing-ext", + await authHeaders("owner-1"), + sampleContent() + ); + + const res = await get( + "/extensions/v2/extensions/existing-ext/revisions", + await authHeaders("owner-1") + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { result: unknown[] }; + expect(data.result).toHaveLength(1); + }); + + it("withdraws an unpublished extension addressed in the other case", async () => { + await seedDeveloper("new-developer", "user-1"); + await createExtension("user-1", { extensionId: "new-ext" }); + + const res = await del( + "/extensions/v2/extensions/NEW-EXT", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + expect(await getExtension(db, "new-ext")).toBeNull(); + }); + }); + describe("DELETE /extensions/{id}", () => { it("withdraws an unpublished extension and releases its id", async () => { await seedDeveloper("new-developer", "user-1"); @@ -333,6 +403,33 @@ describe("Extensions API v2 writes", () => { expect(await getExtension(db, "existing-ext")).not.toBeNull(); }); + // requireActiveAuth() can only reject before the write; a deletion landing + // between it and the DELETE has to be caught by the statement itself. + it("refuses to withdraw once the account is deactivated mid-request", async () => { + await seedDeveloper("new-developer", "user-1"); + await createExtension("user-1"); + const headers = await authHeaders("user-1"); + + let tombstoned = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!tombstoned && sql.includes('DELETE FROM "extensions"')) { + tombstoned = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "user-1") + .run(); + } + }); + + const res = await del("/extensions/v2/extensions/new-ext", headers); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "ACCOUNT_INACTIVE" } + }); + expect(await getExtension(db, "new-ext")).not.toBeNull(); + }); + it("refuses to withdraw someone else's extension", async () => { await seedDeveloper("new-developer", "user-1"); await createExtension("user-1"); @@ -415,6 +512,56 @@ describe("Extensions API v2 writes", () => { expect(data.result.map((item) => item.id)).toEqual(["existing-ext"]); }); + // extensions.type is NULL until a first approval, so filtering the column + // alone would hide an owner's own drafts from them. + it("filters by type across published, pending and rejected states", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const headers = await authHeaders("user-1"); + + // Published. + const live = await createExtension("user-1", { extensionId: "live-ext" }); + const liveIds = (await live.json()) as { + result: { revision_id: string }; + }; + await post( + `/extensions/v2/extensions/live-ext/revisions/${liveIds.result.revision_id}/approve`, + await authHeaders("mod-1"), + {} + ); + + // Never reviewed. + await createExtension("user-1", { extensionId: "draft-ext" }); + + // Reviewed and rejected: no pending revision left to read a type from. + const rejected = await createExtension("user-1", { + extensionId: "rejected-ext" + }); + const rejectedIds = (await rejected.json()) as { + result: { revision_id: string }; + }; + await post( + `/extensions/v2/extensions/rejected-ext/revisions/${rejectedIds.result.revision_id}/reject`, + await authHeaders("mod-1"), + { review_note: "no" } + ); + + const res = await get("/extensions/v2/extensions/mine?type=mod", headers); + expect(res.status).toBe(200); + const data = (await res.json()) as { result: Array<{ id: string }> }; + expect(data.result.map((item) => item.id)).toEqual([ + "draft-ext", + "live-ext", + "rejected-ext" + ]); + + const other = await get( + "/extensions/v2/extensions/mine?type=theme", + headers + ); + await expect(other.json()).resolves.toMatchObject({ result: [] }); + }); + it("requires auth", async () => { const res = await get("/extensions/v2/extensions/mine", {}); expect(res.status).toBe(401); diff --git a/test/services/extensions/v2/migrations.test.ts b/test/services/extensions/v2/migrations.test.ts index 01f3caa..071196b 100644 --- a/test/services/extensions/v2/migrations.test.ts +++ b/test/services/extensions/v2/migrations.test.ts @@ -44,6 +44,38 @@ const historicalUsersSchema = ` ); `; +// A published extension owned by an active submitter, the starting point for +// the legacy-submission cases below. +function seedSubmissionFixture(db: DatabaseSync): void { + const now = "2026-01-01T00:00:00.000Z"; + db.prepare( + "INSERT INTO users (id, created_at, updated_at) VALUES (?,?,?)" + ).run("submitter", now, now); + db.prepare( + "INSERT INTO developers (id, type, name, owner_user_id) VALUES (?,?,?,?)" + ).run("acme", "organization", "Acme", "submitter"); + db.prepare( + `INSERT INTO extensions ( + id, type, author_id, name, description, releases, website, license, + icon_url, readme, source, version, download_url + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)` + ).run( + "live-ext", + "mod", + "acme", + "Live", + "description", + "[]", + "https://example.com", + '{"name":"MIT"}', + null, + "# Live", + '{"type":"github","repo":"example/live"}', + "1.0.0", + "https://example.com/live.zip" + ); +} + describe("Extensions D1 migrations", () => { it("upgrades the split-owned schema without losing users or domain references", () => { const db = new DatabaseSync(":memory:"); @@ -218,6 +250,125 @@ describe("Extensions D1 migrations", () => { } }); + // Approval no longer looks at the proposed id - there is nothing to look at, + // since the id lives on the extension row. So the reserved-id check that + // used to run at the approval boundary has to run here instead, before a + // submission can materialise an extension the public route cannot serve. + it("0021 refuses to materialise a submission targeting a reserved id", () => { + const db = new DatabaseSync(":memory:"); + + try { + for (const name of migrationNames.filter( + (candidate) => !candidate.startsWith("0021") + )) { + db.exec(migration(name)); + } + seedSubmissionFixture(db); + db.prepare( + `INSERT INTO extension_submissions + (id, extension_id, developer_id, submitted_by, status, payload, target_key) + VALUES (?,?,?,?,?,?,?)` + ).run( + "reserved", + null, + "acme", + "submitter", + "pending", + '{"developer":{"id":"acme"},"extension":{"id":"Mine"}}', + "mine" + ); + + expect(() => + db.exec(migration("0021_restructure_extensions_revisions.sql")) + ).toThrow(/CHECK constraint failed/); + } finally { + db.close(); + } + }); + + // At most one revision per extension may be pending, so a legacy row that + // can never satisfy approve()'s ownership predicate would sit there forever + // and block the owner from ever submitting another edit. + it("0021 rejects pending submissions that could never be approved", () => { + const db = new DatabaseSync(":memory:"); + + try { + for (const name of migrationNames.filter( + (candidate) => !candidate.startsWith("0021") + )) { + db.exec(migration(name)); + } + seedSubmissionFixture(db); + db.prepare( + "INSERT INTO developers (id, type, name, owner_user_id) VALUES (?,?,?,?)" + ).run("other", "user", "Other", null); + + const insert = db.prepare( + `INSERT INTO extension_submissions + (id, extension_id, developer_id, submitted_by, status, payload, ownership_epoch, target_key) + VALUES (?,?,?,?,?,?,?,?)` + ); + // Approvable: developer exists, owned by the submitter, epoch matches, + // and is the extension's own developer. + insert.run( + "ok", + "live-ext", + "acme", + "submitter", + "pending", + '{"developer":{"id":"acme"},"extension":{"id":"live-ext","name":"A"}}', + 1, + "live-ext" + ); + // Names a developer that is not the extension's. + insert.run( + "wrong-developer", + "live-ext", + "other", + "submitter", + "pending", + '{"developer":{"id":"other"},"extension":{"id":"live-ext","name":"B"}}', + 1, + "live-ext-2" + ); + // Filed under an ownership epoch that has since moved on. + insert.run( + "stale-epoch", + "live-ext", + "acme", + "submitter", + "pending", + '{"developer":{"id":"acme"},"extension":{"id":"live-ext","name":"C"}}', + 7, + "live-ext-3" + ); + + db.exec(migration("0021_restructure_extensions_revisions.sql")); + + expect( + db + .prepare( + "SELECT id, status, review_note FROM extension_revisions ORDER BY id" + ) + .all() + ).toEqual([ + { id: "ok", status: "pending", review_note: null }, + { + id: "stale-epoch", + status: "rejected", + review_note: "Ownership changed before review" + }, + { + id: "wrong-developer", + status: "rejected", + review_note: "Ownership changed before review" + } + ]); + } finally { + db.close(); + } + }); + // The reads in db/extensions.ts and v1/database.ts inner-join developers and // treat the result as always present. That is only sound because 0021 // refuses to carry a dangling reference through its foreign_keys=OFF From 4d78d80c8477208fa6fe7f2d1bc19e6e7dac6f4c Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 8 Aug 2026 09:21:56 +0100 Subject: [PATCH 3/9] Cover every id-addressed path in one case-insensitivity test --- .../extensions/v2/extension-writes.test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/test/services/extensions/v2/extension-writes.test.ts b/test/services/extensions/v2/extension-writes.test.ts index 123c43e..599e56f 100644 --- a/test/services/extensions/v2/extension-writes.test.ts +++ b/test/services/extensions/v2/extension-writes.test.ts @@ -361,6 +361,70 @@ describe("Extensions API v2 writes", () => { expect(data.result).toHaveLength(1); }); + // One walk over every path that addresses an extension by id, each asked + // in a case that does not match the stored spelling. The finding was that + // reads and writes disagreed, so the guard has to cover all of them at + // once rather than one endpoint at a time. + it("resolves every id-addressed path regardless of case", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedMixedCaseExtension(); + const owner = await authHeaders("owner-1"); + const mod = await authHeaders("mod-1"); + + expect( + (await get("/extensions/v2/extensions/existing-EXT", {})).status + ).toBe(200); + expect( + (await get("/extensions/v2/extensions/mine/EXISTING-ext", owner)).status + ).toBe(200); + + const edit = await put( + "/extensions/v2/extensions/existing-ext", + owner, + sampleContent({ name: "Renamed" }) + ); + expect(edit.status).toBe(202); + const first = (await edit.json()) as { result: { revision_id: string } }; + + expect( + (await get("/extensions/v2/extensions/EXISTING-EXT/revisions", owner)) + .status + ).toBe(200); + expect( + ( + await post( + `/extensions/v2/extensions/existing-EXT/revisions/${first.result.revision_id}/reject`, + mod, + { review_note: "no" } + ) + ).status + ).toBe(200); + + const retry = await put( + "/extensions/v2/extensions/EXISTING-ext", + owner, + sampleContent({ name: "Second" }) + ); + const second = (await retry.json()) as { + result: { revision_id: string }; + }; + expect( + ( + await post( + `/extensions/v2/extensions/existing-ext/revisions/${second.result.revision_id}/approve`, + mod, + {} + ) + ).status + ).toBe(200); + + // Published through the stored spelling, not a second lowercase row. + expect(await countExtensions(db)).toBe(1); + expect(await getExtension(db, "Existing-Ext")).toMatchObject({ + name: "Second" + }); + }); + it("withdraws an unpublished extension addressed in the other case", async () => { await seedDeveloper("new-developer", "user-1"); await createExtension("user-1", { extensionId: "new-ext" }); From f0c3e67f8a87b7a6fc069088209c2cf6d5ca9043 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 8 Aug 2026 09:48:51 +0100 Subject: [PATCH 4/9] Report a deactivated account as 403 from every guarded write Three of these four findings were one bug wearing four hats. Every guarded write repeats an active-account check inside its own statement, because requireActiveAuth() can only reject before the write. But when such a statement affected no rows, only withdraw() asked whether the account had gone; create, propose, approve and reject each let that case fall through to whatever branch their diagnosis ended on. A caller deactivated mid-request was told their id was taken, that they had hit the pending-revision limit, or that the revision was no longer pending - the last being flatly untrue, since the revision was still sitting there pending. inactiveActorError() is now the first question each blocked-write diagnosis asks, and statusFromWriteErrorCode maps the result to the 403 those routes already document. It takes includeNotFound the way statusFromErrorCode takes includeConflict, so POST /extensions, which creates rather than addresses a row and declares no 404, cannot emit one. withdraw() asks the same question rather than concluding inactivity by elimination. Separately, migration 0021 created idx_extensions_id_nocase over a catalogue that may already hold ids differing only in case. That aborted the rebuild halfway with a bare "UNIQUE constraint failed: index 'idx_extensions_id_nocase'" naming no rows. It is now checked up front, alongside the reserved-target check, so the migration fails before rewriting anything and says what to reconcile. Which of the two ids survives is not a decision a migration can make: both are public and consumers pin them. Each fix has a test that fails without it, driven through the db-interceptor hook for the mid-request deactivations. --- src/services/extensions/v2/README.md | 2 + src/services/extensions/v2/db/errors.ts | 20 +++++++ src/services/extensions/v2/db/extensions.ts | 15 +++-- .../0021_restructure_extensions_revisions.sql | 19 +++++++ src/services/extensions/v2/db/revisions.ts | 23 +++++--- src/services/extensions/v2/routes/errors.ts | 23 ++++++++ .../extensions/v2/routes/moderation.ts | 10 +++- .../extensions/v2/routes/owner-extensions.ts | 10 ++-- .../extensions/v2/extension-writes.test.ts | 55 +++++++++++++++++++ .../services/extensions/v2/migrations.test.ts | 48 ++++++++++++++++ .../services/extensions/v2/moderation.test.ts | 35 ++++++++++++ 11 files changed, 240 insertions(+), 20 deletions(-) diff --git a/src/services/extensions/v2/README.md b/src/services/extensions/v2/README.md index e339a14..ec4c5e4 100644 --- a/src/services/extensions/v2/README.md +++ b/src/services/extensions/v2/README.md @@ -111,6 +111,8 @@ Apply migrations **only from this repository**, from `db/migrations`, with `npm Migration `0020` is a check, not a schema change: it fails if an adopted row holds an id that a static route shadows (`extensions.id = 'mine'`, or `developers.id` of `me`/`claims`/`unapproved`), which would make that row's detail page unreachable. If it fails, rename the row deliberately — the id is public and consumers pin it. +Migration `0021` runs three pre-flight checks before it rewrites anything, each failing the deploy with a `CHECK` violation rather than aborting halfway: ids differing only in case (which `idx_extensions_id_nocase` cannot accept), submissions targeting a reserved id, and — at the end — a dangling developer or extension reference that the `foreign_keys=OFF` rebuild would otherwise carry through. It also rejects pending submissions whose ownership state can never satisfy approval, since one pending revision per extension would otherwise block the owner's next edit forever. + Migration `0021` also drops any submission filed under a developer that no longer exists: there is no `developer_id` such a row could carry that satisfies the new foreign key, and the profile it was filed under is already gone. ## Code Layout diff --git a/src/services/extensions/v2/db/errors.ts b/src/services/extensions/v2/db/errors.ts index 0951867..e415576 100644 --- a/src/services/extensions/v2/db/errors.ts +++ b/src/services/extensions/v2/db/errors.ts @@ -1,3 +1,6 @@ +import { DatabaseError } from "../../../../lib/interfaces"; +import { ExtensionsDb } from "../../../../lib/db"; +import { UsersDatabase } from "./users"; import { DatabaseResult } from "../../../../lib/interfaces"; import { logError } from "../../../../lib/logger"; @@ -66,3 +69,20 @@ export function databaseError( error: { message: "A database error occurred", code: "DATABASE_ERROR" } }; } + +// Every guarded write in this service repeats an active-account check inside +// its own statement, because requireActiveAuth() can only reject before the +// write. When such a statement affects no rows the diagnosis has to ask this +// first: otherwise a deactivation lands in whatever branch the diagnosis falls +// through to, and the caller is told their edit conflicted rather than that +// their account is gone. +export async function inactiveActorError( + db: ExtensionsDb, + userId: string +): Promise { + const { data, error } = await new UsersDatabase(db).isActive(userId); + if (error) return error; + return data + ? null + : { message: "Active account required", code: "ACCOUNT_INACTIVE" }; +} diff --git a/src/services/extensions/v2/db/extensions.ts b/src/services/extensions/v2/db/extensions.ts index a758255..8f22258 100644 --- a/src/services/extensions/v2/db/extensions.ts +++ b/src/services/extensions/v2/db/extensions.ts @@ -1,11 +1,11 @@ import { and, asc, eq, isNotNull, or, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/sqlite-core"; -import { DatabaseResult } from "../../../../lib/interfaces"; +import { DatabaseError, DatabaseResult } from "../../../../lib/interfaces"; import { ExtensionsDb } from "../../../../lib/db"; import { sortReleasesDescending } from "../../../../lib/releases"; import { parseJSON } from "../../../../lib/json"; import { extensions, extensionRevisions, developers, users } from "./schema"; -import { databaseError } from "./errors"; +import { databaseError, inactiveActorError } from "./errors"; import { toD1Statement } from "./batch"; import { encodeCursor as encode, decodeCursor as decode } from "./cursor"; import { @@ -422,7 +422,10 @@ export class ExtensionsDatabase { // specific message, so look for the row that would have caused it. private async createBlockedError( input: CreateExtensionInput - ): Promise<{ message: string; code: string }> { + ): Promise { + const inactive = await inactiveActorError(this.db, input.submittedBy); + if (inactive) return inactive; + const [taken] = await this.db .select({ one: sql`1` }) .from(extensions) @@ -505,9 +508,13 @@ export class ExtensionsDatabase { error: { message: "You do not own this extension", code: "FORBIDDEN" } }; } + const inactive = await inactiveActorError(this.db, ownerUserId); return { data: null, - error: { message: "Active account required", code: "ACCOUNT_INACTIVE" } + error: inactive ?? { + message: "Extension could not be withdrawn", + code: "CONFLICT" + } }; } } diff --git a/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql b/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql index 8e5287f..eb82a74 100644 --- a/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql +++ b/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql @@ -19,6 +19,25 @@ -- against schema.ts by test/services/extensions/v2/migrations.test.ts. PRAGMA foreign_keys=OFF;--> statement-breakpoint +-- idx_extensions_id_nocase, created further down, is the constraint that stops +-- two developers racing for ids that differ only in case. A catalogue adopted +-- from v1 predates it and may already hold such a pair, in which case CREATE +-- UNIQUE INDEX would abort the migration halfway through the rebuild with a +-- bare "UNIQUE constraint failed" and no indication of which rows caused it. +-- +-- Check first, so the failure happens before anything is rewritten and names +-- the problem. Reconcile the duplicates by hand and re-run: both ids are +-- public and consumers pin them, so which one survives is not a decision this +-- migration can make. +CREATE TABLE _nocase_duplicate_check (ok INTEGER NOT NULL CHECK (ok = 1));--> statement-breakpoint + +INSERT INTO _nocase_duplicate_check (ok) +SELECT CASE WHEN EXISTS ( + SELECT 1 FROM extensions GROUP BY LOWER(id) HAVING COUNT(*) > 1 + ) THEN 0 ELSE 1 END;--> statement-breakpoint + +DROP TABLE _nocase_duplicate_check;--> statement-breakpoint + -- A submission whose target id is reserved would materialise an extension -- that GET /extensions/{id} can never serve, because the static -- GET /extensions/mine route is registered first. The old code rejected these diff --git a/src/services/extensions/v2/db/revisions.ts b/src/services/extensions/v2/db/revisions.ts index aefe1fd..63a4063 100644 --- a/src/services/extensions/v2/db/revisions.ts +++ b/src/services/extensions/v2/db/revisions.ts @@ -1,8 +1,8 @@ import { and, asc, desc, eq, gt, lt, or, sql, SQL } from "drizzle-orm"; -import { DatabaseResult } from "../../../../lib/interfaces"; +import { DatabaseError, DatabaseResult } from "../../../../lib/interfaces"; import { ExtensionsDb } from "../../../../lib/db"; import { extensionRevisions, developers, extensions, users } from "./schema"; -import { databaseError } from "./errors"; +import { databaseError, inactiveActorError } from "./errors"; import { toD1Statement } from "./batch"; import { encodeCursor as encode, decodeCursor as decode } from "./cursor"; import { MAX_PENDING_REVISIONS_PER_USER, parseContent } from "./extensions"; @@ -140,7 +140,10 @@ export class ExtensionRevisionsDatabase { private async proposeBlockedError(input: { extensionId: string; callerId: string; - }): Promise<{ message: string; code: string }> { + }): Promise { + const inactive = await inactiveActorError(this.db, input.callerId); + if (inactive) return inactive; + const [existing] = await this.db .select({ ownerUserId: developers.ownerUserId }) .from(extensions) @@ -302,7 +305,8 @@ export class ExtensionRevisionsDatabase { // either it never existed, or someone else already moved it off 'pending'. private async explainNoOpTransition( extensionId: string, - id: string + id: string, + reviewerId: string ): Promise> { const existing = await this.getById(extensionId, id); if (existing.error || !existing.data) { @@ -311,9 +315,13 @@ export class ExtensionRevisionsDatabase { error: existing.error ?? revisionNotFound(id).error }; } + const inactive = await inactiveActorError(this.db, reviewerId); return { data: null, - error: { message: "Revision is not pending", code: "CONFLICT" } + error: inactive ?? { + message: "Revision is not pending", + code: "CONFLICT" + } }; } @@ -351,7 +359,7 @@ export class ExtensionRevisionsDatabase { } if (!result.meta?.changes) { - return this.explainNoOpTransition(extensionId, id); + return this.explainNoOpTransition(extensionId, id, reviewerId); } return { data: { id, status: "rejected" }, error: null }; @@ -454,9 +462,10 @@ export class ExtensionRevisionsDatabase { } if (!results[0]?.meta?.changes) { + const inactive = await inactiveActorError(this.db, reviewerId); return { data: null, - error: { + error: inactive ?? { message: "Revision is not pending, or ownership changed since it was proposed", code: "CONFLICT" diff --git a/src/services/extensions/v2/routes/errors.ts b/src/services/extensions/v2/routes/errors.ts index 64d4fa2..01e1846 100644 --- a/src/services/extensions/v2/routes/errors.ts +++ b/src/services/extensions/v2/routes/errors.ts @@ -29,6 +29,29 @@ export function statusFromGithubErrorCode( return fallback; } +// Guarded writes can fail for any of four reasons, including an account +// deactivated between requireActiveAuth() and the statement itself, so their +// routes need one mapper rather than a chain of ternaries per handler. +// includeNotFound follows statusFromErrorCode's includeConflict: a route that +// creates rather than addresses a row has no 404 to declare, and passes false +// so an unexpected NOT_FOUND cannot escape its OpenAPI contract. +export function statusFromWriteErrorCode( + code: string | undefined, + includeNotFound: false +): 403 | 409 | 500; +export function statusFromWriteErrorCode( + code?: string, + includeNotFound?: true +): 403 | 404 | 409 | 500; +export function statusFromWriteErrorCode( + code?: string, + includeNotFound = true +): 403 | 404 | 409 | 500 { + if (code === "FORBIDDEN" || code === "ACCOUNT_INACTIVE") return 403; + if (!includeNotFound && code === "NOT_FOUND") return 500; + return statusFromErrorCode(code); +} + export function statusFromOwnershipErrorCode(code?: string): 403 | 404 | 500 { if (code === "NOT_FOUND") return 404; if (code === "FORBIDDEN" || code === "ACCOUNT_INACTIVE") return 403; diff --git a/src/services/extensions/v2/routes/moderation.ts b/src/services/extensions/v2/routes/moderation.ts index 56e5880..46c33a7 100644 --- a/src/services/extensions/v2/routes/moderation.ts +++ b/src/services/extensions/v2/routes/moderation.ts @@ -2,7 +2,11 @@ import { requireModerator } from "../middleware"; import { getExtensionsDb } from "../../../../lib/db"; import { getAuth } from "../../../../lib/auth"; import { createRoute, z } from "@hono/zod-openapi"; -import { errorBody, statusFromErrorCode } from "./errors"; +import { + errorBody, + statusFromErrorCode, + statusFromWriteErrorCode +} from "./errors"; import { ActiveAccountRequiredResponse, IdParamSchema, @@ -145,7 +149,7 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { review_note ); if (error || !data) { - const status = statusFromErrorCode(error?.code); + const status = statusFromWriteErrorCode(error?.code); return c.json(errorBody(error, "Unable to approve revision"), status); } return c.json({ result: data }, 200); @@ -205,7 +209,7 @@ export function registerModerationRoutes(app: ExtensionsV2App): void { review_note ); if (error || !data) { - const status = statusFromErrorCode(error?.code); + const status = statusFromWriteErrorCode(error?.code); return c.json(errorBody(error, "Unable to reject revision"), status); } return c.json({ result: data }, 200); diff --git a/src/services/extensions/v2/routes/owner-extensions.ts b/src/services/extensions/v2/routes/owner-extensions.ts index 0499fdf..9a9bbcb 100644 --- a/src/services/extensions/v2/routes/owner-extensions.ts +++ b/src/services/extensions/v2/routes/owner-extensions.ts @@ -1,7 +1,7 @@ import { errorBody, statusFromErrorCode, - statusFromOwnershipErrorCode + statusFromWriteErrorCode } from "./errors"; import { requireActiveAuth } from "../middleware"; import { getExtensionsDb } from "../../../../lib/db"; @@ -234,7 +234,7 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { if (error || !data) { return c.json( errorBody(error, "Unable to create extension"), - error?.code === "CONFLICT" ? 409 : 500 + statusFromWriteErrorCode(error?.code, false) ); } return c.json( @@ -298,7 +298,7 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { if (error || !data) { return c.json( errorBody(error, "Unable to submit edit"), - error?.code === "FORBIDDEN" ? 403 : statusFromErrorCode(error?.code) + statusFromWriteErrorCode(error?.code) ); } return c.json( @@ -346,9 +346,7 @@ export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { if (error || !data) { return c.json( errorBody(error, "Unable to withdraw extension"), - statusFromOwnershipErrorCode(error?.code) === 403 - ? 403 - : statusFromErrorCode(error?.code) + statusFromWriteErrorCode(error?.code) ); } return c.json({ result: { id: data.id, deleted: true as const } }, 200); diff --git a/test/services/extensions/v2/extension-writes.test.ts b/test/services/extensions/v2/extension-writes.test.ts index 599e56f..7ff5c01 100644 --- a/test/services/extensions/v2/extension-writes.test.ts +++ b/test/services/extensions/v2/extension-writes.test.ts @@ -329,6 +329,61 @@ describe("Extensions API v2 writes", () => { // Every read resolves an extension id case-insensitively, so the writes have // to agree: otherwise a legacy mixed-case extension is visible but not // editable, and its own revision list comes back empty. + // requireActiveAuth() can only reject before a write. Each guarded statement + // repeats the check, so each blocked-write diagnosis has to recognise it — + // otherwise a deactivated caller is told their write conflicted. + describe("account deactivated mid-request", () => { + function deactivateBefore(match: string, userId: string) { + let done = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!done && sql.includes(match)) { + done = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), userId) + .run(); + } + }); + } + + it("reports 403 from create, not a conflict", async () => { + await seedDeveloper("new-developer", "user-1"); + const headers = await authHeaders("user-1"); + deactivateBefore("INSERT INTO extensions", "user-1"); + + const res = await post( + "/extensions/v2/extensions", + headers, + sampleCreate() + ); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "ACCOUNT_INACTIVE" } + }); + expect(await getExtension(db, "new-ext")).toBeNull(); + }); + + it("reports 403 from an edit, not the pending-limit conflict", async () => { + await seedOwnedExtension(); + const headers = await authHeaders("owner-1"); + // The propose insert is the first statement of the PUT to touch this table. + deactivateBefore("extension_revisions", "owner-1"); + + const res = await put( + "/extensions/v2/extensions/existing-ext", + headers, + sampleContent() + ); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "ACCOUNT_INACTIVE" } + }); + expect(await countRevisions(db)).toBe(0); + }); + }); + describe("mixed-case legacy ids", () => { it("accepts an edit addressed in lower case", async () => { await seedMixedCaseExtension(); diff --git a/test/services/extensions/v2/migrations.test.ts b/test/services/extensions/v2/migrations.test.ts index 071196b..06f8add 100644 --- a/test/services/extensions/v2/migrations.test.ts +++ b/test/services/extensions/v2/migrations.test.ts @@ -250,6 +250,54 @@ describe("Extensions D1 migrations", () => { } }); + // idx_extensions_id_nocase cannot be created over a catalogue that already + // holds a case-colliding pair. Without a check first, the failure lands + // mid-rebuild as a bare UNIQUE constraint error naming no rows. + it("0021 refuses to run against ids that differ only in case", () => { + const db = new DatabaseSync(":memory:"); + + try { + for (const name of migrationNames.filter( + (candidate) => !candidate.startsWith("0021") + )) { + db.exec(migration(name)); + } + seedSubmissionFixture(db); + db.prepare( + `INSERT INTO extensions ( + id, type, author_id, name, description, releases, website, license, + icon_url, readme, source, version, download_url + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)` + ).run( + "LIVE-EXT", + "mod", + "acme", + "Colliding", + "description", + "[]", + "https://example.com", + '{"name":"MIT"}', + null, + "# Colliding", + '{"type":"github","repo":"example/colliding"}', + "1.0.0", + "https://example.com/colliding.zip" + ); + + expect(() => + db.exec(migration("0021_restructure_extensions_revisions.sql")) + ).toThrow(/CHECK constraint failed/); + + // Failed before touching anything, not halfway through the rebuild. + expect( + db.prepare("SELECT COUNT(*) AS n FROM extension_submissions").get() + ).toEqual({ n: 0 }); + expect(columnNames(db, "extensions")).toContain("author_id"); + } finally { + db.close(); + } + }); + // Approval no longer looks at the proposed id - there is nothing to look at, // since the id lives on the extension row. So the reserved-id check that // used to run at the approval boundary has to run here instead, before a diff --git a/test/services/extensions/v2/moderation.test.ts b/test/services/extensions/v2/moderation.test.ts index 2deed30..0f07749 100644 --- a/test/services/extensions/v2/moderation.test.ts +++ b/test/services/extensions/v2/moderation.test.ts @@ -269,6 +269,41 @@ describe("Extensions API v2", () => { expect(afterEdit?.name).toBe("Second Version"); }); + // requireModerator() runs before the write; the statement repeats the + // active check, so the zero-row diagnosis has to recognise it rather than + // reporting the revision as no longer pending. + it.each(["approve", "reject"] as const)( + "reports 403 when the moderator is deactivated mid-%s", + async (action) => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const { id, revisionId } = await createPending("user-1"); + const headers = await authHeaders("mod-1"); + + let done = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!done && sql.includes("extension_revisions")) { + done = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "mod-1") + .run(); + } + }); + + const res = await post(reviewPath(id, revisionId, action), headers, { + review_note: "note" + }); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "ACCOUNT_INACTIVE" } + }); + // The revision is untouched, which is why 409 was the wrong answer. + expect((await getRevision(db, revisionId))?.status).toBe("pending"); + } + ); + it("blocks non-moderators from approving", async () => { await seedDeveloper("new-developer", "user-1"); const { id, revisionId } = await createPending("user-1"); From 08c1cb562ef3c2ec1cce215640c651e2d1895af6 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 8 Aug 2026 10:06:19 +0100 Subject: [PATCH 5/9] Name what migration 0021 refuses to migrate The three pre-flight checks all aborted with an anonymous "CHECK constraint failed", which tells an operator that something is wrong with their data but not what, or which rows. SQLite has no RAISE() outside a trigger, so the constraint name is the only place a diagnosis can go. Each check now selects the offending rows into a scratch table whose named CHECK can never hold: a clean database inserts nothing and passes, a dirty one fails with extension_ids_must_not_differ_only_by_case, submission_target_ids_must_not_be_reserved, or extension_references_must_resolve. The scratch table's columns name the rows involved, so the same query listed in the migration is what an operator runs to find them. Case-colliding ids are still not reconciled automatically. The pair is already ambiguous to every reader - v1 and v2 both resolve ids with LOWER(), so one of the two rows is unreachable today depending on which the query happens to return first - but choosing which id survives, and whether the other is renamed or deleted, is a decision about published data that a migration should not make silently. --- src/services/extensions/v2/README.md | 12 ++- .../0021_restructure_extensions_revisions.sql | 81 +++++++++++-------- .../services/extensions/v2/migrations.test.ts | 10 ++- 3 files changed, 67 insertions(+), 36 deletions(-) diff --git a/src/services/extensions/v2/README.md b/src/services/extensions/v2/README.md index ec4c5e4..909dda7 100644 --- a/src/services/extensions/v2/README.md +++ b/src/services/extensions/v2/README.md @@ -111,7 +111,17 @@ Apply migrations **only from this repository**, from `db/migrations`, with `npm Migration `0020` is a check, not a schema change: it fails if an adopted row holds an id that a static route shadows (`extensions.id = 'mine'`, or `developers.id` of `me`/`claims`/`unapproved`), which would make that row's detail page unreachable. If it fails, rename the row deliberately — the id is public and consumers pin it. -Migration `0021` runs three pre-flight checks before it rewrites anything, each failing the deploy with a `CHECK` violation rather than aborting halfway: ids differing only in case (which `idx_extensions_id_nocase` cannot accept), submissions targeting a reserved id, and — at the end — a dangling developer or extension reference that the `foreign_keys=OFF` rebuild would otherwise carry through. It also rejects pending submissions whose ownership state can never satisfy approval, since one pending revision per extension would otherwise block the owner's next edit forever. +Migration `0021` refuses to run against data it cannot migrate, rather than aborting halfway through the rebuild. Each check selects the offending rows into a scratch table whose named `CHECK` can never hold, so the constraint name is the error message — SQLite has no `RAISE()` outside a trigger: + +| Failure | Meaning | +| -------------------------------------------- | ----------------------------------------------------------------------- | +| `extension_ids_must_not_differ_only_by_case` | Two catalogue ids collide under `idx_extensions_id_nocase` | +| `submission_target_ids_must_not_be_reserved` | A submission targets an id a static route shadows | +| `extension_references_must_resolve` | The `foreign_keys=OFF` rebuild would carry a dangling reference through | + +None of these are repaired automatically: each is a decision about published data that belongs to a human. Reconcile and re-run — the migration has touched nothing at that point. + +It does resolve one case itself: pending submissions whose ownership state can never satisfy approval are rejected, since one pending revision per extension would otherwise block the owner's next edit forever. Migration `0021` also drops any submission filed under a developer that no longer exists: there is no `developer_id` such a row could carry that satisfies the new foreign key, and the profile it was filed under is already gone. diff --git a/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql b/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql index eb82a74..4dd37b5 100644 --- a/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql +++ b/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql @@ -20,23 +20,33 @@ PRAGMA foreign_keys=OFF;--> statement-breakpoint -- idx_extensions_id_nocase, created further down, is the constraint that stops --- two developers racing for ids that differ only in case. A catalogue adopted --- from v1 predates it and may already hold such a pair, in which case CREATE --- UNIQUE INDEX would abort the migration halfway through the rebuild with a --- bare "UNIQUE constraint failed" and no indication of which rows caused it. +-- a new lowercase id colliding with an adopted mixed-case one. A catalogue +-- adopted from v1 predates it and may already hold such a pair, in which case +-- CREATE UNIQUE INDEX would abort the migration halfway through the rebuild. -- --- Check first, so the failure happens before anything is rewritten and names --- the problem. Reconcile the duplicates by hand and re-run: both ids are --- public and consumers pin them, so which one survives is not a decision this --- migration can make. -CREATE TABLE _nocase_duplicate_check (ok INTEGER NOT NULL CHECK (ok = 1));--> statement-breakpoint +-- This and the two checks that follow are written the same way: select the +-- offending rows into a scratch table whose CHECK can never hold, so a clean +-- database inserts nothing and passes, and a dirty one fails with the +-- constraint's name as the message. SQLite has no RAISE() outside a trigger, +-- so the constraint name is the only place a diagnosis can be put. +-- +-- These duplicates are not reconciled automatically. Both ids are public, and +-- the pair is already ambiguous to every reader - v1 and v2 both resolve ids +-- with LOWER(), so one of the two rows is currently unreachable depending on +-- which the query happens to return first. Choosing which one survives, and +-- whether the other is renamed or removed, is a decision about published data +-- that belongs to a human. Run the query in the INSERT below to list them. +CREATE TABLE _extension_id_case_conflicts ( + lowercased_id TEXT NOT NULL, + copies INTEGER NOT NULL, + CONSTRAINT extension_ids_must_not_differ_only_by_case CHECK (copies = 0) +);--> statement-breakpoint -INSERT INTO _nocase_duplicate_check (ok) -SELECT CASE WHEN EXISTS ( - SELECT 1 FROM extensions GROUP BY LOWER(id) HAVING COUNT(*) > 1 - ) THEN 0 ELSE 1 END;--> statement-breakpoint +INSERT INTO _extension_id_case_conflicts (lowercased_id, copies) +SELECT LOWER(id), COUNT(*) FROM extensions +GROUP BY LOWER(id) HAVING COUNT(*) > 1;--> statement-breakpoint -DROP TABLE _nocase_duplicate_check;--> statement-breakpoint +DROP TABLE _extension_id_case_conflicts;--> statement-breakpoint -- A submission whose target id is reserved would materialise an extension -- that GET /extensions/{id} can never serve, because the static @@ -49,16 +59,21 @@ DROP TABLE _nocase_duplicate_check;--> statement-breakpoint -- re-run - unlike 0020's case the id is not yet public, so nothing pins it and -- there is nothing to preserve. Comparison is on the lowercased target because -- that is what the materialisation below would insert. -CREATE TABLE _reserved_target_check (ok INTEGER NOT NULL CHECK (ok = 1));--> statement-breakpoint +CREATE TABLE _reserved_submission_targets ( + submission_id TEXT NOT NULL, + target_id TEXT NOT NULL, + CONSTRAINT submission_target_ids_must_not_be_reserved CHECK (1 = 0) +);--> statement-breakpoint -INSERT INTO _reserved_target_check (ok) -SELECT CASE WHEN EXISTS ( - SELECT 1 FROM extension_submissions - WHERE LOWER(COALESCE(extension_id, json_extract(payload, '$.extension.id'))) - IN ('mine') - ) THEN 0 ELSE 1 END;--> statement-breakpoint +INSERT INTO _reserved_submission_targets (submission_id, target_id) +SELECT + id, + LOWER(COALESCE(extension_id, json_extract(payload, '$.extension.id'))) +FROM extension_submissions +WHERE LOWER(COALESCE(extension_id, json_extract(payload, '$.extension.id'))) + IN ('mine');--> statement-breakpoint -DROP TABLE _reserved_target_check;--> statement-breakpoint +DROP TABLE _reserved_submission_targets;--> statement-breakpoint -- developers first, while every table that references it is still the old one: -- the drop-and-rename re-parses every schema, and doing it with a referrer @@ -304,17 +319,19 @@ DROP TABLE `extension_submissions`;--> statement-breakpoint -- forever. Fail the deploy instead, and let the reads assume the join always -- matches. Same CHECK-on-a-scratch-table trick as migration 0020, for the -- same reason: SQLite has no RAISE() outside a trigger. -CREATE TABLE _orphan_check (ok INTEGER NOT NULL CHECK (ok = 1));--> statement-breakpoint +CREATE TABLE _unresolved_references ( + kind TEXT NOT NULL, + row_id TEXT NOT NULL, + CONSTRAINT extension_references_must_resolve CHECK (1 = 0) +);--> statement-breakpoint -INSERT INTO _orphan_check (ok) -SELECT CASE WHEN EXISTS ( - SELECT 1 FROM extensions e - WHERE NOT EXISTS (SELECT 1 FROM developers d WHERE d.id = e.developer_id) - ) OR EXISTS ( - SELECT 1 FROM extension_revisions r - WHERE NOT EXISTS (SELECT 1 FROM extensions e WHERE e.id = r.extension_id) - ) THEN 0 ELSE 1 END;--> statement-breakpoint +INSERT INTO _unresolved_references (kind, row_id) +SELECT 'extension.developer_id', e.id FROM extensions e +WHERE NOT EXISTS (SELECT 1 FROM developers d WHERE d.id = e.developer_id) +UNION ALL +SELECT 'revision.extension_id', r.id FROM extension_revisions r +WHERE NOT EXISTS (SELECT 1 FROM extensions e WHERE e.id = r.extension_id);--> statement-breakpoint -DROP TABLE _orphan_check;--> statement-breakpoint +DROP TABLE _unresolved_references;--> statement-breakpoint PRAGMA foreign_keys=ON; diff --git a/test/services/extensions/v2/migrations.test.ts b/test/services/extensions/v2/migrations.test.ts index 06f8add..1a8e0b3 100644 --- a/test/services/extensions/v2/migrations.test.ts +++ b/test/services/extensions/v2/migrations.test.ts @@ -286,7 +286,9 @@ describe("Extensions D1 migrations", () => { expect(() => db.exec(migration("0021_restructure_extensions_revisions.sql")) - ).toThrow(/CHECK constraint failed/); + ).toThrow( + /CHECK constraint failed: extension_ids_must_not_differ_only_by_case/ + ); // Failed before touching anything, not halfway through the rebuild. expect( @@ -328,7 +330,9 @@ describe("Extensions D1 migrations", () => { expect(() => db.exec(migration("0021_restructure_extensions_revisions.sql")) - ).toThrow(/CHECK constraint failed/); + ).toThrow( + /CHECK constraint failed: submission_target_ids_must_not_be_reserved/ + ); } finally { db.close(); } @@ -457,7 +461,7 @@ describe("Extensions D1 migrations", () => { expect(() => db.exec(migration("0021_restructure_extensions_revisions.sql")) - ).toThrow(/CHECK constraint failed/); + ).toThrow(/CHECK constraint failed: extension_references_must_resolve/); } finally { db.close(); } From 0ffcfb3a27e6cca8e88a56f614c228e0cb1ed4bc Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 8 Aug 2026 10:19:22 +0100 Subject: [PATCH 6/9] Separate what a revision may hold from what may be published Revision history is an audit log that outlives the rules its content was written under. ExtensionRevisionSchema promised every stored revision satisfies today's input validation - at least one release, among other things - which migration 0021 cannot honour, since it carries legacy submissions through verbatim, and which any future tightening would break again for older rows. StoredExtensionContentSchema now describes what a revision may actually hold, and approve() re-validates against the strict schema before publishing. That keeps the public catalogue's contract exactly as strict as it was while letting history be history, and it puts the check at the boundary that matters rather than trusting submission-time validation - the same reasoning that had the pre-0021 code re-check reserved ids at approval. A revision that cannot be published now says so, instead of publishing content the public schema disowns. Also from review: The owner view could show an older decision as last_review. reviewed_at comes from CURRENT_TIMESTAMP and is second-granular, so two reviews can share one, and the tie was broken by a random UUID. Broken by rowid now, which is assigned in insert order - and since only one revision per extension may be pending, insert order is review order. withdraw() classified published-ness and ownership before asking whether the account had been deactivated, so a deactivated owner of a published extension got 409. It asks first now, like the other blocked-write diagnoses already did. The README's owner-state table claimed to be the whole state space while omitting two reachable rows: an extension adopted from the pre-v2 catalogue (live, no review history at all) and a rejected revision the owner has already resubmitted. Both are now documented and both have a test proving they are reachable. --- src/services/extensions/v2/README.md | 19 ++-- src/services/extensions/v2/db/extensions.ts | 14 ++- src/services/extensions/v2/db/revisions.ts | 24 +++++- .../extensions/v2/schemas/extensions.ts | 15 +++- .../extensions/v2/schemas/revisions.ts | 4 +- .../extensions/v2/extension-writes.test.ts | 86 +++++++++++++++++++ .../services/extensions/v2/moderation.test.ts | 85 ++++++++++++++++++ 7 files changed, 233 insertions(+), 14 deletions(-) diff --git a/src/services/extensions/v2/README.md b/src/services/extensions/v2/README.md index 909dda7..c5f9e2a 100644 --- a/src/services/extensions/v2/README.md +++ b/src/services/extensions/v2/README.md @@ -42,12 +42,19 @@ most one developer profile, so no request body names one. fields rather than a single derived status, because together they are the state and a derived enum could only disagree with them: -| `published` | `pending_revision` | `last_review` | Meaning | -| ----------- | ------------------ | ------------- | ---------------------------------- | -| `null` | set | `null` | Awaiting first review | -| `null` | `null` | rejected | Rejected; edit and resubmit | -| set | `null` | approved | Live, no unreviewed edit | -| set | set | either | Live, with an edit awaiting review | +| `published` | `pending_revision` | `last_review` | Meaning | +| ----------- | ------------------ | ------------- | --------------------------------------- | +| `null` | set | `null` | Awaiting first review | +| `null` | set | rejected | Rejected, and already resubmitted | +| `null` | `null` | rejected | Rejected; edit and resubmit | +| set | `null` | approved | Live, no unreviewed edit | +| set | `null` | `null` | Live, adopted from the pre-v2 catalogue | +| set | set | either | Live, with an edit awaiting review | + +The adopted row is the one worth reading twice: migration 0021 published every +extension that already existed, and those have no revisions at all, so a live +extension with no review history is normal rather than a gap. `published` +being set is the only thing that means "in the catalogue". These are separate routes from the public `GET /extensions` and `GET /extensions/{id}`, which only ever return published content. A single path diff --git a/src/services/extensions/v2/db/extensions.ts b/src/services/extensions/v2/db/extensions.ts index 8f22258..44b3dc5 100644 --- a/src/services/extensions/v2/db/extensions.ts +++ b/src/services/extensions/v2/db/extensions.ts @@ -67,6 +67,12 @@ const { // edit (at most one - idx_extension_revisions_pending), once for the most // recent decision. "Most recently reviewed" is not expressible as a join // predicate, so that side matches on a correlated subquery instead. +// +// The tie-break is rowid, not id. reviewed_at comes from CURRENT_TIMESTAMP and +// is only second-granular, so two reviews can share one, and id is a random +// UUID that would then pick a winner at random. rowid is assigned in insert +// order, and revisions on one extension are strictly serialised - only one may +// be pending at a time - so insert order is review order. const PENDING = alias(extensionRevisions, "pending"); const REVIEWED = alias(extensionRevisions, "reviewed"); @@ -81,7 +87,7 @@ const REVIEWED_JOIN = eq( SELECT r.id FROM ${extensionRevisions} r WHERE r.extension_id = ${extensions.id} AND r.status IN ('approved', 'rejected') - ORDER BY r.reviewed_at DESC, r.id DESC + ORDER BY r.reviewed_at DESC, r.rowid DESC LIMIT 1 )` ); @@ -484,6 +490,9 @@ export class ExtensionsDatabase { id: string, ownerUserId: string ): Promise> { + const inactive = await inactiveActorError(this.db, ownerUserId); + if (inactive) return { data: null, error: inactive }; + const [existing] = await this.db .select({ publishedAt: extensions.publishedAt, @@ -508,10 +517,9 @@ export class ExtensionsDatabase { error: { message: "You do not own this extension", code: "FORBIDDEN" } }; } - const inactive = await inactiveActorError(this.db, ownerUserId); return { data: null, - error: inactive ?? { + error: { message: "Extension could not be withdrawn", code: "CONFLICT" } diff --git a/src/services/extensions/v2/db/revisions.ts b/src/services/extensions/v2/db/revisions.ts index 63a4063..84f346f 100644 --- a/src/services/extensions/v2/db/revisions.ts +++ b/src/services/extensions/v2/db/revisions.ts @@ -6,7 +6,10 @@ import { databaseError, inactiveActorError } from "./errors"; import { toD1Statement } from "./batch"; import { encodeCursor as encode, decodeCursor as decode } from "./cursor"; import { MAX_PENDING_REVISIONS_PER_USER, parseContent } from "./extensions"; -import { ExtensionContent } from "../schemas/extensions"; +import { + ExtensionContent, + ExtensionContentSchema +} from "../schemas/extensions"; import { ExtensionRevision, RevisionStatus } from "../schemas/revisions"; export interface RevisionPage { @@ -391,7 +394,24 @@ export class ExtensionRevisionsDatabase { }; } - const content = revision.content; + // The stored content is deliberately validated again here rather than + // trusted from submission time. A revision can predate the current content + // rules - migration 0021 carries legacy submissions through verbatim - and + // publishing is the one boundary where the public catalogue's stricter + // contract has to hold. This is the same reason the pre-0021 code + // re-checked reserved ids at approval. + const parsed = ExtensionContentSchema.safeParse(revision.content); + if (!parsed.success) { + return { + data: null, + error: { + message: + "This revision predates the current content requirements and cannot be published as-is; ask the developer to resubmit it", + code: "CONFLICT" + } + }; + } + const content = parsed.data; // Kept as raw sql via the raw D1 client (see toD1Statement) rather than // the query builder: D1's batch() executes these two statements as one diff --git a/src/services/extensions/v2/schemas/extensions.ts b/src/services/extensions/v2/schemas/extensions.ts index c0841be..c0cd45d 100644 --- a/src/services/extensions/v2/schemas/extensions.ts +++ b/src/services/extensions/v2/schemas/extensions.ts @@ -78,6 +78,19 @@ export const ExtensionContentSchema = z export type ExtensionContent = z.infer; +// What a *stored* revision may hold, as opposed to what may be submitted now. +// Revision history is an audit log that outlives the rules content was written +// under, so promising that every historical record satisfies today's input +// validation is a promise this service cannot keep - migration 0021 carries +// submissions through verbatim, and any future tightening would break older +// rows the same way. Only the constraints that are genuinely input-side are +// relaxed: releases must be non-empty to *publish*, which approve() enforces +// at the boundary that matters, but a revision that never got that far may +// legitimately have none. +export const StoredExtensionContentSchema = ExtensionContentSchema.extend({ + releases: z.array(ReleaseSchema).max(100) +}).openapi("StoredExtensionContent"); + const MAX_CONTENT_BYTES = 256 * 1024; // Applied to both the create and the edit body. The stored revision is this @@ -175,7 +188,7 @@ export type OwnedExtensionListItem = z.infer< export const OwnedExtensionSchema = OwnedExtensionListItemSchema.extend({ published: ExtensionContentSchema.nullable(), pending_revision: PendingRevisionRefSchema.extend({ - content: ExtensionContentSchema + content: StoredExtensionContentSchema }).nullable() }).openapi("OwnedExtension"); diff --git a/src/services/extensions/v2/schemas/revisions.ts b/src/services/extensions/v2/schemas/revisions.ts index 1380a00..a254418 100644 --- a/src/services/extensions/v2/schemas/revisions.ts +++ b/src/services/extensions/v2/schemas/revisions.ts @@ -1,5 +1,5 @@ import { z } from "@hono/zod-openapi"; -import { ExtensionContentSchema } from "./extensions"; +import { StoredExtensionContentSchema } from "./extensions"; export const RevisionStatusSchema = z.enum(["pending", "approved", "rejected"]); @@ -14,7 +14,7 @@ export const ExtensionRevisionSchema = z developer_id: z.string(), submitted_by: z.string(), status: RevisionStatusSchema, - content: ExtensionContentSchema, + content: StoredExtensionContentSchema, reviewer_id: z.string().nullable(), review_note: z.string().nullable(), created_at: z.string(), diff --git a/test/services/extensions/v2/extension-writes.test.ts b/test/services/extensions/v2/extension-writes.test.ts index 7ff5c01..013d3bb 100644 --- a/test/services/extensions/v2/extension-writes.test.ts +++ b/test/services/extensions/v2/extension-writes.test.ts @@ -549,6 +549,36 @@ describe("Extensions API v2 writes", () => { expect(await getExtension(db, "new-ext")).not.toBeNull(); }); + // The inactive check has to run before the published and ownership + // branches, or a deactivated caller is told their extension is published + // (409) instead of that their account is gone. The deactivation has to + // land mid-request: done beforehand, requireActiveAuth() answers first and + // the diagnosis never runs. + it("reports a deactivated account ahead of any other reason", async () => { + await seedOwnedExtension(); + const headers = await authHeaders("owner-1"); + + let done = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!done && sql.toLowerCase().includes("delete from")) { + done = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "owner-1") + .run(); + } + }); + + // Published *and* deactivated: the 409 branch would otherwise win. + const res = await del("/extensions/v2/extensions/existing-ext", headers); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "ACCOUNT_INACTIVE" } + }); + expect(await getExtension(db, "existing-ext")).not.toBeNull(); + }); + it("refuses to withdraw someone else's extension", async () => { await seedDeveloper("new-developer", "user-1"); await createExtension("user-1"); @@ -618,6 +648,62 @@ describe("Extensions API v2 writes", () => { expect(data.result[0].pending_revision).not.toBeNull(); }); + // Both of these are states the README's mapping table now documents, and + // both were reachable before it did. + it("reports a live extension adopted from the pre-v2 catalogue", async () => { + await seedOwnedExtension(); + + const res = await get( + "/extensions/v2/extensions/mine", + await authHeaders("owner-1") + ); + const data = (await res.json()) as { + result: Array<{ + published: unknown; + pending_revision: unknown; + last_review: unknown; + }>; + }; + // Published with no review history at all: migration 0021 published + // every extension that already existed, and those have no revisions. + expect(data.result[0].published).not.toBeNull(); + expect(data.result[0].pending_revision).toBeNull(); + expect(data.result[0].last_review).toBeNull(); + }); + + it("reports a rejected extension that has already been resubmitted", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const created = await createExtension("user-1"); + const { result } = (await created.json()) as { + result: { revision_id: string }; + }; + await post( + `/extensions/v2/extensions/new-ext/revisions/${result.revision_id}/reject`, + await authHeaders("mod-1"), + { review_note: "no" } + ); + await put( + "/extensions/v2/extensions/new-ext", + await authHeaders("user-1"), + sampleContent({ name: "Fixed" }) + ); + + const res = await get( + "/extensions/v2/extensions/mine", + await authHeaders("user-1") + ); + await expect(res.json()).resolves.toMatchObject({ + result: [ + { + published: null, + pending_revision: { id: expect.any(String) }, + last_review: { status: "rejected" } + } + ] + }); + }); + it("excludes other developers' extensions", async () => { await seedOwnedExtension(); await seedDeveloper("other-developer", "user-2"); diff --git a/test/services/extensions/v2/moderation.test.ts b/test/services/extensions/v2/moderation.test.ts index 0f07749..c3d90b8 100644 --- a/test/services/extensions/v2/moderation.test.ts +++ b/test/services/extensions/v2/moderation.test.ts @@ -17,6 +17,8 @@ import { insertUser, insertDeveloper, insertExtension, + insertUnpublishedExtension, + insertRevision, getDeveloper, countExtensions, getExtension, @@ -304,6 +306,89 @@ describe("Extensions API v2", () => { } ); + // Revision history outlives the rules its content was written under, so + // the response schema tolerates a stored revision with no releases. The + // public catalogue does not, which makes approval the boundary that has to + // re-check rather than trust submission-time validation. + it("refuses to publish a revision that predates current content rules", async () => { + 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" + }); + await insertRevision(db, { + id: "legacy-revision", + extension_id: "legacy-ext", + developer_id: "new-developer", + submitted_by: "user-1", + // Carried through by migration 0021 from a submission that predates + // the releases requirement. + content: JSON.stringify({ ...sampleContent(), releases: [] }) + }); + + const res = await post( + reviewPath("legacy-ext", "legacy-revision", "approve"), + await authHeaders("mod-1"), + {} + ); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "CONFLICT" } + }); + expect((await getExtension(db, "legacy-ext"))?.published_at).toBeNull(); + + // But it is still readable as history, with the empty releases intact. + const history = await get( + "/extensions/v2/extensions/legacy-ext/revisions", + await authHeaders("user-1") + ); + expect(history.status).toBe(200); + const body = (await history.json()) as { + result: Array<{ content: { releases: unknown[] } }>; + }; + expect(body.result[0].content.releases).toEqual([]); + }); + + // reviewed_at is only second-granular, so two reviews can share one and + // the tie-break decides which decision the owner sees. + it("reports the newer decision when two reviews share a timestamp", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const first = await createPending("user-1"); + const mod = await authHeaders("mod-1"); + + await post(reviewPath(first.id, first.revisionId, "reject"), mod, { + review_note: "first decision" + }); + const second = await put( + `/extensions/v2/extensions/${first.id}`, + await authHeaders("user-1"), + sampleContent({ name: "Second" }) + ); + const secondId = ( + (await second.json()) as { result: { revision_id: string } } + ).result.revision_id; + await post(reviewPath(first.id, secondId, "reject"), mod, { + review_note: "second decision" + }); + + // Force the collision the tie-break exists for. + await db + .prepare("UPDATE extension_revisions SET reviewed_at = ?") + .bind("2026-01-01 00:00:00") + .run(); + + const mine = await get( + `/extensions/v2/extensions/mine/${first.id}`, + await authHeaders("user-1") + ); + await expect(mine.json()).resolves.toMatchObject({ + result: { last_review: { review_note: "second decision" } } + }); + }); + it("blocks non-moderators from approving", async () => { await seedDeveloper("new-developer", "user-1"); const { id, revisionId } = await createPending("user-1"); From 4389a881c4a13314f18e6e04186718bb4053dbf9 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 8 Aug 2026 11:35:28 +0100 Subject: [PATCH 7/9] Refuse, rather than silently drop, submissions 0021 cannot migrate A submission naming a developer that does not exist was filtered out of the materialisation and vanished. Such a row is already unapprovable - the pre-0021 approve() only ever UPDATEs a developer, never inserts one, so approving it marked the submission approved and published nothing - but losing the record without saying so is not a migration's call. It now fails with submissions_must_name_an_existing_developer, and the filter is gone so the check is the only mechanism rather than a second one masking it. The developer is deliberately not backfilled from payload.developer. Creating a profile would mint an ownership grant no moderator approved, which is what the claim and approval flows exist to prevent. StoredExtensionContentSchema only relaxed releases, so migrated content missing any other field still contradicted the advertised contract. Every field is optional now: this schema describes what is *there*, and history written under older rules is exactly the case it exists for. Field types and upper bounds stay, since those remain true. Publication is unaffected - approve() still revalidates against the strict schema. explainNoOpTransition returned getById()'s NOT_FOUND before asking whether the moderator had been deactivated, so a reject racing deactivation on an already-gone revision reported 404 instead of the documented 403. Actor first, matching the other diagnoses. The stored-content test now parses the served response back through ExtensionRevisionSchema. Hono does not validate responses at runtime, so without that assertion nothing catches a response schema disagreeing with the data until a generated client does. --- src/services/extensions/v2/db/extensions.ts | 8 +- .../0021_restructure_extensions_revisions.sql | 43 ++++++--- src/services/extensions/v2/db/revisions.ts | 9 +- .../extensions/v2/schemas/extensions.ts | 20 +++- .../services/extensions/v2/migrations.test.ts | 93 ++++++++++++++++--- .../services/extensions/v2/moderation.test.ts | 56 ++++++++++- 6 files changed, 184 insertions(+), 45 deletions(-) diff --git a/src/services/extensions/v2/db/extensions.ts b/src/services/extensions/v2/db/extensions.ts index 44b3dc5..3cf9058 100644 --- a/src/services/extensions/v2/db/extensions.ts +++ b/src/services/extensions/v2/db/extensions.ts @@ -15,6 +15,7 @@ import { License, OwnedExtension, OwnedExtensionListItem, + StoredExtensionContent, Release, Repository } from "../schemas/extensions"; @@ -684,11 +685,8 @@ function parseOwnedRow(row: OwnedRow): OwnedExtension { // Migrated revisions can hold content that predates the current schema (see // migration 0021), so releases is defaulted rather than assumed. -export function parseContent(stored: string | null): ExtensionContent { - const content = parseJSON( - stored ?? "", - {} as ExtensionContent - ); +export function parseContent(stored: string | null): StoredExtensionContent { + const content = parseJSON(stored ?? "", {}); return { ...content, releases: sortReleasesDescending(content.releases ?? []) diff --git a/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql b/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql index 4dd37b5..d47d736 100644 --- a/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql +++ b/src/services/extensions/v2/db/migrations/0021_restructure_extensions_revisions.sql @@ -48,6 +48,35 @@ GROUP BY LOWER(id) HAVING COUNT(*) > 1;--> statement-breakpoint DROP TABLE _extension_id_case_conflicts;--> statement-breakpoint +-- A submission naming a developer that does not exist cannot become an +-- extension row: developer_id is NOT NULL with a foreign key. Such a +-- submission is already unapprovable today - the pre-0021 approve() only ever +-- UPDATEs a developer, never inserts one, so it would mark the submission +-- approved and publish nothing - but dropping the row here would lose that +-- record silently, which is not a migration's decision to make. +-- +-- The developer is deliberately NOT backfilled from payload.developer. +-- Creating a profile would mint an ownership grant that no moderator ever +-- approved, which is the exact thing the claim and approval flows exist to +-- prevent. Create the developer deliberately, or delete the submission, then +-- re-run. +CREATE TABLE _submissions_without_a_developer ( + submission_id TEXT NOT NULL, + missing_developer_id TEXT NOT NULL, + CONSTRAINT submissions_must_name_an_existing_developer CHECK (1 = 0) +);--> statement-breakpoint + +INSERT INTO _submissions_without_a_developer (submission_id, missing_developer_id) +SELECT s.id, s.developer_id +FROM extension_submissions s +WHERE NOT EXISTS (SELECT 1 FROM developers d WHERE d.id = s.developer_id) + AND NOT EXISTS ( + SELECT 1 FROM extensions e + WHERE LOWER(e.id) = LOWER(COALESCE(s.extension_id, json_extract(s.payload, '$.extension.id'))) + );--> statement-breakpoint + +DROP TABLE _submissions_without_a_developer;--> statement-breakpoint + -- A submission whose target id is reserved would materialise an extension -- that GET /extensions/{id} can never serve, because the static -- GET /extensions/mine route is registered first. The old code rejected these @@ -204,19 +233,7 @@ WHERE target.target_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM `__new_extensions` e WHERE LOWER(e.id) = target.target_id ) - -- A submission whose developer no longer exists cannot produce a row that - -- satisfies the developer_id foreign key. Dropping it here loses only an - -- unreviewable record: the developer it was filed under is already gone. - AND EXISTS ( - SELECT 1 FROM developers d - WHERE d.id = ( - SELECT s.developer_id - FROM extension_submissions s - WHERE LOWER(COALESCE(s.extension_id, json_extract(s.payload, '$.extension.id'))) = target.target_id - ORDER BY s.created_at DESC, s.id DESC - LIMIT 1 - ) - );--> statement-breakpoint + ;--> statement-breakpoint DROP TABLE `extensions`;--> statement-breakpoint ALTER TABLE `__new_extensions` RENAME TO `extensions`;--> statement-breakpoint diff --git a/src/services/extensions/v2/db/revisions.ts b/src/services/extensions/v2/db/revisions.ts index 84f346f..a2f9574 100644 --- a/src/services/extensions/v2/db/revisions.ts +++ b/src/services/extensions/v2/db/revisions.ts @@ -311,6 +311,9 @@ export class ExtensionRevisionsDatabase { id: string, reviewerId: string ): Promise> { + const inactive = await inactiveActorError(this.db, reviewerId); + if (inactive) return { data: null, error: inactive }; + const existing = await this.getById(extensionId, id); if (existing.error || !existing.data) { return { @@ -318,13 +321,9 @@ export class ExtensionRevisionsDatabase { error: existing.error ?? revisionNotFound(id).error }; } - const inactive = await inactiveActorError(this.db, reviewerId); return { data: null, - error: inactive ?? { - message: "Revision is not pending", - code: "CONFLICT" - } + error: { message: "Revision is not pending", code: "CONFLICT" } }; } diff --git a/src/services/extensions/v2/schemas/extensions.ts b/src/services/extensions/v2/schemas/extensions.ts index c0cd45d..aaebb24 100644 --- a/src/services/extensions/v2/schemas/extensions.ts +++ b/src/services/extensions/v2/schemas/extensions.ts @@ -83,13 +83,23 @@ export type ExtensionContent = z.infer; // under, so promising that every historical record satisfies today's input // validation is a promise this service cannot keep - migration 0021 carries // submissions through verbatim, and any future tightening would break older -// rows the same way. Only the constraints that are genuinely input-side are -// relaxed: releases must be non-empty to *publish*, which approve() enforces -// at the boundary that matters, but a revision that never got that far may -// legitimately have none. +// rows the same way. +// +// Every field is therefore optional, and releases loses its minimum: this +// describes what is *there*, and a consumer reading history has to cope with +// a record written under rules that no longer exist. Field types and upper +// bounds are kept, since those still say something true about the shape. +// Nothing is weakened for publication - approve() revalidates against the +// strict schema before anything reaches the catalogue. export const StoredExtensionContentSchema = ExtensionContentSchema.extend({ releases: z.array(ReleaseSchema).max(100) -}).openapi("StoredExtensionContent"); +}) + .partial() + .openapi("StoredExtensionContent"); + +export type StoredExtensionContent = z.infer< + typeof StoredExtensionContentSchema +>; const MAX_CONTENT_BYTES = 256 * 1024; diff --git a/test/services/extensions/v2/migrations.test.ts b/test/services/extensions/v2/migrations.test.ts index 1a8e0b3..6089982 100644 --- a/test/services/extensions/v2/migrations.test.ts +++ b/test/services/extensions/v2/migrations.test.ts @@ -300,6 +300,87 @@ describe("Extensions D1 migrations", () => { } }); + // The pre-0021 flow could leave a submission naming a developer that does + // not exist. Such a row is already unapprovable - the old approve() only + // ever UPDATEd a developer - but it must not vanish without saying so. + it("0021 refuses to drop a submission whose developer does not exist", () => { + const db = new DatabaseSync(":memory:"); + + try { + for (const name of migrationNames.filter( + (candidate) => !candidate.startsWith("0021") + )) { + db.exec(migration(name)); + } + seedSubmissionFixture(db); + db.prepare( + `INSERT INTO extension_submissions + (id, extension_id, developer_id, submitted_by, status, payload, target_key) + VALUES (?,?,?,?,?,?,?)` + ).run( + "introduces-a-developer", + null, + "not-yet-created", + "submitter", + "pending", + '{"developer":{"id":"not-yet-created"},"extension":{"id":"brand-new"}}', + "brand-new" + ); + + expect(() => + db.exec(migration("0021_restructure_extensions_revisions.sql")) + ).toThrow( + /CHECK constraint failed: submissions_must_name_an_existing_developer/ + ); + } finally { + db.close(); + } + }); + + // The same row targeting an extension that already exists is fine: it + // becomes a revision, and the ownership pass rejects it rather than leaving + // it pending forever. + it("0021 keeps a submission whose developer is missing but whose extension exists", () => { + const db = new DatabaseSync(":memory:"); + + try { + for (const name of migrationNames.filter( + (candidate) => !candidate.startsWith("0021") + )) { + db.exec(migration(name)); + } + seedSubmissionFixture(db); + db.prepare( + `INSERT INTO extension_submissions + (id, extension_id, developer_id, submitted_by, status, payload, target_key) + VALUES (?,?,?,?,?,?,?)` + ).run( + "ghost-developer", + "live-ext", + "vanished", + "submitter", + "pending", + '{"developer":{"id":"vanished"},"extension":{"id":"live-ext"}}', + "live-ext" + ); + + db.exec(migration("0021_restructure_extensions_revisions.sql")); + + expect( + db + .prepare( + "SELECT status, review_note FROM extension_revisions WHERE id = ?" + ) + .get("ghost-developer") + ).toEqual({ + status: "rejected", + review_note: "Ownership changed before review" + }); + } finally { + db.close(); + } + }); + // Approval no longer looks at the proposed id - there is nothing to look at, // since the id lives on the extension row. So the reserved-id check that // used to run at the approval boundary has to run here instead, before a @@ -536,18 +617,6 @@ describe("Extensions D1 migrations", () => { "2026-01-03", "not-yet-approved" ); - // Filed under a developer that no longer exists: there is no developer_id - // that would satisfy the foreign key, so this one is dropped. - insertSubmission.run( - "orphaned", - null, - "ghost", - "submitter", - "rejected", - payload("orphan-ext"), - "2026-01-04", - "orphan-ext" - ); db.exec(migration("0021_restructure_extensions_revisions.sql")); diff --git a/test/services/extensions/v2/moderation.test.ts b/test/services/extensions/v2/moderation.test.ts index c3d90b8..f39ab1a 100644 --- a/test/services/extensions/v2/moderation.test.ts +++ b/test/services/extensions/v2/moderation.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { env } from "cloudflare:workers"; import { wrapD1WithHook } from "./db-interceptor"; +import { ExtensionRevisionSchema } from "../../../../src/services/extensions/v2/schemas/revisions"; import { setupExtensionsV2Tests, db, @@ -322,9 +323,9 @@ describe("Extensions API v2", () => { extension_id: "legacy-ext", developer_id: "new-developer", submitted_by: "user-1", - // Carried through by migration 0021 from a submission that predates - // the releases requirement. - content: JSON.stringify({ ...sampleContent(), releases: [] }) + // Carried through by migration 0021 from a submission written under + // rules that required far less than today's. + content: JSON.stringify({ type: "mod", name: "Legacy" }) }); const res = await post( @@ -346,9 +347,22 @@ describe("Extensions API v2", () => { ); expect(history.status).toBe(200); const body = (await history.json()) as { - result: Array<{ content: { releases: unknown[] } }>; + result: Array<{ content: Record }>; }; - expect(body.result[0].content.releases).toEqual([]); + // Served as stored, with the fields it never had simply absent. + expect(body.result[0].content).toMatchObject({ + type: "mod", + name: "Legacy" + }); + expect(body.result[0].content.description).toBeUndefined(); + + // The advertised contract has to accept what is actually served. Hono + // does not validate responses at runtime, so nothing else catches a + // response schema that disagrees with the data - the generated client + // would be the first to find out. + expect(ExtensionRevisionSchema.safeParse(body.result[0]).success).toBe( + true + ); }); // reviewed_at is only second-granular, so two reviews can share one and @@ -389,6 +403,38 @@ describe("Extensions API v2", () => { }); }); + // The reject guard can fail for two reasons at once. Asking about the + // account first keeps the documented 403 rather than reporting the + // revision as missing. + it("reports a deactivated moderator ahead of a missing revision", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const { id } = await createPending("user-1"); + const headers = await authHeaders("mod-1"); + + let done = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!done && sql.toLowerCase().includes("update")) { + done = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "mod-1") + .run(); + } + }); + + const res = await post( + reviewPath(id, "no-such-revision", "reject"), + headers, + { review_note: "note" } + ); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "ACCOUNT_INACTIVE" } + }); + }); + it("blocks non-moderators from approving", async () => { await seedDeveloper("new-developer", "user-1"); const { id, revisionId } = await createPending("user-1"); From 595d0bdec195414867b9aea9318927410ba1d5b9 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 8 Aug 2026 12:00:12 +0100 Subject: [PATCH 8/9] Let the owner view describe an adopted extension as it really is OwnedExtensionSchema.published required at least one release, but v1 constrained extensions.releases to NOT NULL and nothing more, so a row adopted by migration 0021 can be published with none. The owner view is where someone looks at what is actually there, and it was advertising a shape the data cannot always take. published now uses a schema whose releases has no minimum. Only the owner detail view changes: the owner list omits releases entirely, and approve() still requires one, so this can only ever describe a pre-v2 row. The public ExtensionSchema is deliberately left strict. The same legacy rows flow through it, but that contract predates this PR and every catalogue consumer relies on it; relaxing it would push the empty-array case onto all of them to fix a condition v1 has always had. Worth revisiting as its own change rather than smuggling it in here. The test parses the served body back through OwnedExtensionSchema, since Hono does not validate responses and nothing else would notice the contract drifting from the data. --- .../extensions/v2/schemas/extensions.ts | 13 +++++++- .../extensions/v2/extension-writes.test.ts | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/services/extensions/v2/schemas/extensions.ts b/src/services/extensions/v2/schemas/extensions.ts index aaebb24..5750c8f 100644 --- a/src/services/extensions/v2/schemas/extensions.ts +++ b/src/services/extensions/v2/schemas/extensions.ts @@ -156,6 +156,17 @@ const ExtensionCardContentSchema = ExtensionContentSchema.omit({ releases: true }); +// The published projection as its *owner* sees it. Identical to the +// catalogue's, except releases may be empty: v1 constrained +// extensions.releases to NOT NULL and nothing more, so a row adopted by +// migration 0021 can legitimately have none, and the owner view is where +// someone looks at what is actually there rather than at an idealised copy. +// This can only ever describe a pre-v2 row - approve() requires a release +// before anything reaches the catalogue through v2. +const PublishedExtensionContentSchema = ExtensionContentSchema.extend({ + releases: z.array(ReleaseSchema).max(100) +}); + // The most recent decision, kept alongside a later pending revision so the // site can still show why the previous attempt was rejected. export const RevisionReviewSchema = z @@ -196,7 +207,7 @@ export type OwnedExtensionListItem = z.infer< // The detail view carries the full content on both sides, so an owner can // render a published-vs-pending diff from one request. export const OwnedExtensionSchema = OwnedExtensionListItemSchema.extend({ - published: ExtensionContentSchema.nullable(), + published: PublishedExtensionContentSchema.nullable(), pending_revision: PendingRevisionRefSchema.extend({ content: StoredExtensionContentSchema }).nullable() diff --git a/test/services/extensions/v2/extension-writes.test.ts b/test/services/extensions/v2/extension-writes.test.ts index 013d3bb..cac5371 100644 --- a/test/services/extensions/v2/extension-writes.test.ts +++ b/test/services/extensions/v2/extension-writes.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { env } from "cloudflare:workers"; import { wrapD1WithHook } from "./db-interceptor"; +import { OwnedExtensionSchema } from "../../../../src/services/extensions/v2/schemas/extensions"; import { setupExtensionsV2Tests, db, @@ -843,6 +844,38 @@ describe("Extensions API v2 writes", () => { expect(data.result.pending_revision.content.name).toBe("New Extension"); }); + // v1 constrained extensions.releases to NOT NULL and nothing more, so a + // row adopted by migration 0021 can be published with none. The owner view + // has to describe that rather than a shape the data cannot take. Hono does + // not validate responses, so only parsing the body back through the + // advertised schema catches the disagreement. + it("serves an adopted extension with no releases against its own schema", async () => { + await insertDeveloper(db, { + id: "owner-developer", + type: "user", + name: "Owner", + owner_user_id: "owner-1" + }); + await insertExtension(db, { + id: "adopted-ext", + developer_id: "owner-developer", + name: "Adopted", + releases: "[]" + }); + + const res = await get( + "/extensions/v2/extensions/mine/adopted-ext", + await authHeaders("owner-1") + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { published: { releases: unknown[] } }; + }; + expect(body.result.published.releases).toEqual([]); + expect(OwnedExtensionSchema.safeParse(body.result).success).toBe(true); + }); + it("refuses to show someone else's extension", async () => { await seedOwnedExtension(); const res = await get( From 64adc51d10d0fbc43fae5924593631f6dcc94dcb Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 8 Aug 2026 12:12:17 +0100 Subject: [PATCH 9/9] Actually exercise the 256 KiB content guard The oversized-content case sent a 100_001-character readme, which the readme field's own .max(100_000) rejects at roughly 100 KB. The 422 was real but came from the wrong constraint, and refineContentSize had no coverage at all: deleting the guard outright left the whole suite green. Reaching it needs content that is valid field by field yet large in aggregate, since the biggest single field is the readme. 100 releases - the maximum - carrying maximum-length URLs comes to roughly 440 KB. The test asserts the size guard's own message rather than just a 422, so it cannot start passing for a different reason again, and covers the edit body as well since ExtensionUpdateSchema carries the same refinement. The readme case stays, now asserting too_big at ["readme"] so it is pinned to the bound it actually tests. --- .../extensions/v2/extension-writes.test.ts | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/test/services/extensions/v2/extension-writes.test.ts b/test/services/extensions/v2/extension-writes.test.ts index cac5371..a3ddfee 100644 --- a/test/services/extensions/v2/extension-writes.test.ts +++ b/test/services/extensions/v2/extension-writes.test.ts @@ -168,11 +168,21 @@ describe("Extensions API v2 writes", () => { const headers = await authHeaders("user-1"); const body = sampleCreate(); - const oversized = await post("/extensions/v2/extensions", headers, { + // The readme's own bound, which fires at ~100 KB. Asserted by path so it + // cannot quietly become the reason some other case passes. + const oversizedReadme = await post("/extensions/v2/extensions", headers, { ...body, readme: "x".repeat(100_001) }); - expect(oversized.status).toBe(422); + expect(oversizedReadme.status).toBe(422); + const oversizedReadmeBody = (await oversizedReadme.json()) as { + error: { details: Array<{ code: string; path: PropertyKey[] }> }; + }; + expect(oversizedReadmeBody.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "too_big", path: ["readme"] }) + ]) + ); const unknownField = await post("/extensions/v2/extensions", headers, { ...body, @@ -213,6 +223,56 @@ describe("Extensions API v2 writes", () => { expect(tooManyReleases.status).toBe(422); }); + // The 256 KiB guard is a separate limit from the per-field bounds, and + // nothing else reaches it: the largest single field is the readme at + // ~100 KB. Only content that is valid field-by-field yet large in + // aggregate exercises it - 100 releases (the maximum) carrying + // maximum-length URLs comes to roughly 440 KB. + it("rejects content that is within every field bound but over 256 KiB", async () => { + await seedDeveloper("new-developer", "user-1"); + const headers = await authHeaders("user-1"); + const longUrl = `https://example.com/${"x".repeat(2028)}`; + expect(longUrl).toHaveLength(2048); + + const releases = Array.from({ length: 100 }, (_unused, index) => ({ + tag: `1.0.${index}`.padEnd(100, "0"), + date: "2026-01-01T00:00:00Z", + download_url: longUrl, + changelog_url: longUrl, + min_fossbilling_version: "0.6" + })); + const body = { ...sampleCreate(), releases }; + expect( + new TextEncoder().encode(JSON.stringify(body)).byteLength + ).toBeGreaterThan(256 * 1024); + + const created = await post("/extensions/v2/extensions", headers, body); + expect(created.status).toBe(422); + const detail = (await created.json()) as { + error: { details: Array<{ code: string; message: string }> }; + }; + // Specifically the size guard, not some field constraint tripping first. + expect(detail.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "Extension content must not exceed 256 KiB" + }) + ]) + ); + expect(await countExtensions(db)).toBe(0); + + // The edit body carries the same guard. + await seedOwnedExtension(); + const { id: _id, ...content } = body; + const edited = await put( + "/extensions/v2/extensions/existing-ext", + await authHeaders("owner-1"), + content + ); + expect(edited.status).toBe(422); + expect(await countRevisions(db)).toBe(0); + }); + it("preserves compatibility with stored slug ids over 100 characters", async () => { await seedDeveloper("d".repeat(120), "user-1"); const res = await createExtension("user-1", {