From 6cecbf83fb58b60c10363158789e47759ffd66d6 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 5 Aug 2026 10:23:44 +0100 Subject: [PATCH 1/7] Consolidate Extensions domain ownership in API --- README.md | 16 +- src/lib/auth/middleware.ts | 3 +- src/services/extensions/v1/db/schema.sql | 4 + src/services/extensions/v2/account-routes.ts | 275 +++++ .../extensions/v2/db/external-tables.ts | 18 - .../v2/db/migrations/0000_bootstrap_users.sql | 46 + .../v2/db/migrations/0001_add_v2_tables.sql | 19 +- .../migrations/0002_add_author_approval.sql | 2 +- .../0003_add_author_profile_fields.sql | 3 +- .../migrations/0019_add_user_deleted_at.sql | 4 + .../v2/db/migrations/meta/0019_snapshot.json | 985 ++++++++++++++++++ .../v2/db/migrations/meta/_journal.json | 7 + src/services/extensions/v2/db/schema.ts | 48 +- .../extensions/v2/developer-profile-routes.ts | 51 + .../extensions/v2/developers-database.ts | 85 +- .../extensions/v2/extensions-database.ts | 23 +- src/services/extensions/v2/index.ts | 36 +- src/services/extensions/v2/interfaces.ts | 55 +- .../extensions/v2/public-extensions-routes.ts | 88 ++ .../extensions/v2/route-dependencies.ts | 4 + src/services/extensions/v2/users-database.ts | 319 +++++- test/env.d.ts | 2 - test/services/extensions/v2/db-fixtures.ts | 77 +- test/services/extensions/v2/index.test.ts | 392 ++++++- test/utils/apply-migrations.ts | 16 - vitest.config.ts | 20 +- 26 files changed, 2438 insertions(+), 160 deletions(-) create mode 100644 src/services/extensions/v2/account-routes.ts delete mode 100644 src/services/extensions/v2/db/external-tables.ts create mode 100644 src/services/extensions/v2/db/migrations/0000_bootstrap_users.sql create mode 100644 src/services/extensions/v2/db/migrations/0019_add_user_deleted_at.sql create mode 100644 src/services/extensions/v2/db/migrations/meta/0019_snapshot.json diff --git a/README.md b/README.md index e5a03b3..32114fc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Everything is built on [Hono](https://hono.dev), making it lightweight and fast. ## What it does -The worker exposes two main services: +The worker exposes three main services: - **Versions Service** (`/versions/v1`) The source of truth for FOSSBilling updates. It fetches release data from GitHub, caches it for performance, and helps instances decide if they need to update. @@ -14,6 +14,12 @@ The worker exposes two main services: - **Central Alerts** (`/central-alerts/v1`) Allows the project to push critical notifications to all FOSSBilling installations—useful for security hotfixes or major announcements. +- **Extensions** (`/extensions/v1`, `/extensions/v2`) + Owns the complete Extensions domain and its `DB_EXTENSIONS` schema, including + users, developers, submissions, claims, transfers, history, and catalogue data. + The separate Extensions site keeps OIDC/session state but accesses this domain + through the generated HTTPS API client; it must not bind or migrate `DB_EXTENSIONS`. + ## Architecture We've structured the app to separate the core logic from the specific runtime environment (Cloudflare, Node, etc.). @@ -48,6 +54,10 @@ If you're running this yourself, you'll need a few things set up. We use [Cloudflare D1](https://developers.cloudflare.com/d1/) and [KV](https://developers.cloudflare.com/kv/). - **D1 Database** (`DB_CENTRAL_ALERTS`): Stores the alert messages. +- **D1 Database** (`DB_EXTENSIONS`): Stores the complete Extensions domain. Apply + its migrations only from this repository, from + `src/services/extensions/v2/db/migrations`, with + `db:migrate:extensions-v2:*`. The Extensions site has no D1 migration source. - **KV Namespace** (`CACHE_KV`): Caches GitHub API responses so we don't hit rate limits. - **KV Namespace** (`AUTH_KV`): Stores the `UPDATE_TOKEN` value for `/versions/v1/update`. @@ -74,8 +84,8 @@ npm install 2. Apply migrations to the local D1 databases: ```bash - npm run migrate:extensions-v2:local - npm run migrate:central-alerts:local + npm run db:migrate:extensions-v2:local + npm run db:migrate:central-alerts:local ``` 3. (Optional) Store an update token in KV for `/versions/v1/update`: diff --git a/src/lib/auth/middleware.ts b/src/lib/auth/middleware.ts index f924fa5..28681d4 100644 --- a/src/lib/auth/middleware.ts +++ b/src/lib/auth/middleware.ts @@ -32,8 +32,7 @@ export function requireAuth(): MiddlewareHandler { const principal = await verifier.verify(token, platform); if (principal) { c.set("auth", principal); - await next(); - return; + return next(); } } diff --git a/src/services/extensions/v1/db/schema.sql b/src/services/extensions/v1/db/schema.sql index 1658b59..5b6b094 100644 --- a/src/services/extensions/v1/db/schema.sql +++ b/src/services/extensions/v1/db/schema.sql @@ -1,3 +1,7 @@ +-- Historical catalogue baseline. The API-owned Extensions migration chain +-- (src/services/extensions/v2/db/migrations) is the only active D1 migration +-- source; this file is retained as a readable record of the original v1 shape. + CREATE TABLE IF NOT EXISTS authors ( id TEXT PRIMARY KEY NOT NULL, type TEXT NOT NULL, diff --git a/src/services/extensions/v2/account-routes.ts b/src/services/extensions/v2/account-routes.ts new file mode 100644 index 0000000..dc53fa9 --- /dev/null +++ b/src/services/extensions/v2/account-routes.ts @@ -0,0 +1,275 @@ +import { createRoute, z } from "@hono/zod-openapi"; +import { getAuth } from "../../../lib/auth"; +import { statusFromErrorCode } from "./route-errors"; +import { + ErrorResponseSchema, + UserIdentityInputSchema, + UserProfileUpdateSchema, + UserSchema +} from "./interfaces"; +import { UsersDatabase } from "./users-database"; +import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; + +function toUserResponse(user: { + displayName: string | null; + isModerator: boolean; + githubLinked: boolean; + deletedAt: string | null; +}) { + return { + display_name: user.displayName, + is_moderator: user.isModerator, + github_linked: user.githubLinked, + active: user.deletedAt === null + }; +} + +export function registerAccountRoutes( + app: ExtensionsV2App, + dependencies: RouteDependencies +): void { + const syncIdentityRoute = createRoute({ + method: "put", + path: "/users/me/identity", + tags: ["Users"], + summary: "Synchronize the caller's OIDC identity projection", + security: [{ Bearer: [] }], + middleware: [dependencies.requireAuthAllowInactive()] as const, + request: { + body: { + content: { "application/json": { schema: UserIdentityInputSchema } } + } + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ result: UserSchema }) } + }, + description: "Identity projection synchronized" + }, + 401: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Missing or invalid bearer token" + }, + 422: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Identity payload failed validation" + }, + 500: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Database error" + } + } + }); + + app.openapi(syncIdentityRoute, async (c) => { + const auth = getAuth(c); + const body = c.req.valid("json"); + const users = new UsersDatabase(dependencies.database(c.env.DB_EXTENSIONS)); + const result = await users.syncIdentity(auth.userId, { + name: body.name, + email: body.email, + emailVerified: body.email_verified, + picture: body.picture, + githubLogin: body.github_login, + githubOrgs: body.github_orgs, + githubOrgsExpiresAt: body.github_orgs_expires_at + }); + if (result.error || !result.data) { + return c.json( + { + error: { + message: result.error?.message ?? "Unable to sync identity", + code: result.error?.code ?? "DATABASE_ERROR" + } + }, + 500 + ); + } + return c.json({ result: toUserResponse(result.data) }, 200); + }); + + const getUserRoute = createRoute({ + method: "get", + path: "/users/me", + tags: ["Users"], + summary: "Get the caller's account projection", + security: [{ Bearer: [] }], + middleware: [dependencies.requireAuthAllowInactive()] as const, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ result: UserSchema }) } + }, + description: "The caller's account projection, including active status" + }, + 401: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Missing or invalid bearer token" + }, + 404: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Account does not exist" + }, + 500: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Database error" + } + } + }); + + app.openapi(getUserRoute, async (c) => { + const auth = getAuth(c); + const users = new UsersDatabase(dependencies.database(c.env.DB_EXTENSIONS)); + const result = await users.get(auth.userId); + if (result.error && result.error.code !== "NOT_FOUND") { + return c.json( + { + error: { + message: result.error.message, + code: result.error.code ?? "DATABASE_ERROR" + } + }, + 500 + ); + } + if (!result.data) { + return c.json( + { error: { message: "User not found", code: "NOT_FOUND" } }, + 404 + ); + } + return c.json({ result: toUserResponse(result.data) }, 200); + }); + + const updateProfileRoute = createRoute({ + method: "patch", + path: "/users/me", + tags: ["Users"], + summary: "Update the caller's personal profile", + security: [{ Bearer: [] }], + middleware: [dependencies.requireAuth()] as const, + request: { + body: { + content: { "application/json": { schema: UserProfileUpdateSchema } } + } + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + result: z.object({ display_name: z.string().nullable() }) + }) + } + }, + description: "Profile updated" + }, + 401: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Missing or invalid bearer token" + }, + 404: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Account does not exist or has been deleted" + }, + 500: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Database error" + } + } + }); + + app.openapi(updateProfileRoute, async (c) => { + const auth = getAuth(c); + const body = c.req.valid("json"); + const users = new UsersDatabase(dependencies.database(c.env.DB_EXTENSIONS)); + const current = await users.get(auth.userId); + if (current.error && current.error.code !== "NOT_FOUND") { + return c.json( + { + error: { + message: current.error.message, + code: current.error.code ?? "DATABASE_ERROR" + } + }, + 500 + ); + } + if (!current.data || current.data.deletedAt !== null) { + return c.json( + { error: { message: "User not found", code: "NOT_FOUND" } }, + 404 + ); + } + const result = await users.updateDisplayName( + auth.userId, + body.display_name + ); + if (result.error || !result.data) { + return c.json( + { + error: { + message: result.error?.message ?? "Unable to update profile", + code: result.error?.code ?? "DATABASE_ERROR" + } + }, + statusFromErrorCode(result.error?.code, false) + ); + } + return c.json({ result: { display_name: result.data.displayName } }, 200); + }); + + const deleteUserRoute = createRoute({ + method: "delete", + path: "/users/me", + tags: ["Users"], + summary: "Delete the caller's account and tombstone its user row", + security: [{ Bearer: [] }], + middleware: [dependencies.requireAuthAllowInactive()] as const, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ result: z.object({ deleted: z.literal(true) }) }) + } + }, + description: "Account deleted" + }, + 401: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Missing or invalid bearer token" + }, + 404: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Account does not exist" + }, + 409: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Account still owns protected domain records" + }, + 500: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Database error" + } + } + }); + + app.openapi(deleteUserRoute, async (c) => { + const auth = getAuth(c); + const users = new UsersDatabase(dependencies.database(c.env.DB_EXTENSIONS)); + const result = await users.deleteAccount(auth.userId); + if (result.error || !result.data) { + return c.json( + { + error: { + message: result.error?.message ?? "Unable to delete account", + code: result.error?.code ?? "DATABASE_ERROR" + } + }, + statusFromErrorCode(result.error?.code) + ); + } + return c.json({ result: result.data }, 200); + }); +} diff --git a/src/services/extensions/v2/db/external-tables.ts b/src/services/extensions/v2/db/external-tables.ts deleted file mode 100644 index 58f0265..0000000 --- a/src/services/extensions/v2/db/external-tables.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; - -// Owned by the sibling FOSSBilling/extensions repo (src/lib/db/users.sql -// there), NOT this repo, but lives in the same DB_EXTENSIONS database. -// Deliberately kept out of schema.ts (drizzle-kit's scan target - see -// drizzle.extensions.config.ts) so drizzle-kit never thinks it owns and -// should generate migrations for a table this repo doesn't manage. This is -// a second, purely-for-reading definition of the same physical table -// schema.ts's `users` (id only) already declares for FK-reference purposes -// - if the sibling repo's columns change, update both. -export const users = sqliteTable("users", { - id: text("id").primaryKey(), - name: text("name"), - isModerator: integer("is_moderator"), - githubLogin: text("github_login"), - githubOrgs: text("github_orgs"), - githubOrgsExpiresAt: text("github_orgs_expires_at") -}); diff --git a/src/services/extensions/v2/db/migrations/0000_bootstrap_users.sql b/src/services/extensions/v2/db/migrations/0000_bootstrap_users.sql new file mode 100644 index 0000000..9ff0cb3 --- /dev/null +++ b/src/services/extensions/v2/db/migrations/0000_bootstrap_users.sql @@ -0,0 +1,46 @@ +-- Bootstrap the complete pre-adoption Extensions schema before the v2 +-- migrations add ownership, moderation, and workflow state. The CREATEs are +-- intentionally idempotent: production databases already contain these +-- tables from the former split migration chains, while a fresh API-owned +-- database does not. Keeping the legacy catalogue tables in this baseline +-- means the API migration directory can be applied to an empty database. +CREATE TABLE IF NOT EXISTS authors ( + id TEXT PRIMARY KEY NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + url TEXT +); + +CREATE TABLE IF NOT EXISTS extensions ( + id TEXT PRIMARY KEY NOT NULL, + type TEXT NOT NULL, + author_id TEXT NOT NULL REFERENCES authors(id), + name TEXT NOT NULL, + description TEXT NOT NULL, + releases TEXT NOT NULL, + website TEXT NOT NULL, + license TEXT NOT NULL, + icon_url TEXT, + readme TEXT NOT NULL, + source TEXT NOT NULL, + version TEXT NOT NULL, + download_url TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_extensions_type ON extensions(type); +CREATE INDEX IF NOT EXISTS idx_extensions_author ON extensions(author_id); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT, + email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + picture TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + is_moderator INTEGER NOT NULL DEFAULT 0, + display_name TEXT, + github_login TEXT, + github_orgs TEXT, + github_orgs_expires_at TEXT +); diff --git a/src/services/extensions/v2/db/migrations/0001_add_v2_tables.sql b/src/services/extensions/v2/db/migrations/0001_add_v2_tables.sql index 7178105..633c069 100644 --- a/src/services/extensions/v2/db/migrations/0001_add_v2_tables.sql +++ b/src/services/extensions/v2/db/migrations/0001_add_v2_tables.sql @@ -1,27 +1,20 @@ -- Migration number: 0001 2026-07-24T06:49:28.535Z -- -- v2: self-service submissions, ownership, moderation. --- Adds to the v1-owned `authors` table (../../../v1/db/schema.sql) and creates a new --- v2-owned table. +-- Extends the legacy catalogue tables bootstrapped by migration 0000 and +-- creates the submission workflow table. -- --- NOTE: `users` referenced below is owned by the FOSSBilling/extensions repo --- (src/lib/db/users.sql there), NOT this repo, but lives in the same DB_EXTENSIONS --- database. If that schema changes, update fossbilling/api AND that file. Assumed --- columns used here: users.id (TEXT, = auth `sub` claim), users.is_moderator --- (INTEGER 0/1). +-- NOTE: `users` is part of this API-owned Extensions domain. Migration 0000 +-- bootstraps it before this migration adds foreign keys to users(id). -- --- Bootstrap order for a fresh database: v1's schema.sql (../../../v1/db/schema.sql, --- creates `authors`/`extensions`) and the extensions repo's `users` table must --- both exist before this migration runs, since it ALTERs/references them. - ALTER TABLE authors ADD COLUMN owner_user_id TEXT REFERENCES users(id); CREATE INDEX IF NOT EXISTS idx_authors_owner ON authors(owner_user_id); CREATE TABLE IF NOT EXISTS extension_submissions ( id TEXT PRIMARY KEY NOT NULL, extension_id TEXT REFERENCES extensions(id), -- NULL = new extension, set = edit - author_id TEXT NOT NULL, -- not a hard FK: may name an author - -- that doesn't exist yet (created on approval) + author_id TEXT NOT NULL, -- submissions may name a developer + -- that does not exist yet (created on approval) submitted_by TEXT NOT NULL REFERENCES users(id), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), payload TEXT NOT NULL, -- JSON: { author: {...}, extension: {...} } diff --git a/src/services/extensions/v2/db/migrations/0002_add_author_approval.sql b/src/services/extensions/v2/db/migrations/0002_add_author_approval.sql index b32b4fe..1e93ead 100644 --- a/src/services/extensions/v2/db/migrations/0002_add_author_approval.sql +++ b/src/services/extensions/v2/db/migrations/0002_add_author_approval.sql @@ -1,5 +1,5 @@ -- v2: direct (unmoderated) developer-profile writes, with a moderator-set --- "approved" trust flag. Adds to the v1-owned `authors` table. +-- "approved" trust flag. Extends the legacy authors table now owned by the API. -- -- SQLite's ALTER TABLE ADD COLUMN rejects non-constant defaults (including -- CURRENT_TIMESTAMP), so created_at/updated_at are added with a placeholder diff --git a/src/services/extensions/v2/db/migrations/0003_add_author_profile_fields.sql b/src/services/extensions/v2/db/migrations/0003_add_author_profile_fields.sql index 7f33240..60fc338 100644 --- a/src/services/extensions/v2/db/migrations/0003_add_author_profile_fields.sql +++ b/src/services/extensions/v2/db/migrations/0003_add_author_profile_fields.sql @@ -1,6 +1,7 @@ -- v2: additional developer-profile fields for the public /developer/{id} -- page (bio, avatar_url) and moderator/maintainer contact (contact_email, --- never exposed on public reads). Adds to the v1-owned `authors` table. +-- never exposed on public reads). Extends the legacy authors table now owned by +-- the API. ALTER TABLE authors ADD COLUMN bio TEXT; ALTER TABLE authors ADD COLUMN avatar_url TEXT; diff --git a/src/services/extensions/v2/db/migrations/0019_add_user_deleted_at.sql b/src/services/extensions/v2/db/migrations/0019_add_user_deleted_at.sql new file mode 100644 index 0000000..9be1942 --- /dev/null +++ b/src/services/extensions/v2/db/migrations/0019_add_user_deleted_at.sql @@ -0,0 +1,4 @@ +-- Keep the stable auth subject as a tombstone when an account is deleted so +-- existing foreign keys and audit history remain valid. A later identity +-- sync reactivates the same row and clears this value. +ALTER TABLE users ADD COLUMN deleted_at TEXT; diff --git a/src/services/extensions/v2/db/migrations/meta/0019_snapshot.json b/src/services/extensions/v2/db/migrations/meta/0019_snapshot.json new file mode 100644 index 0000000..06c477e --- /dev/null +++ b/src/services/extensions/v2/db/migrations/meta/0019_snapshot.json @@ -0,0 +1,985 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "18f3867b-3724-4851-a60a-9caeec1cb815", + "prevId": "4043ef50-3f0f-4add-9bdd-59dc38b68f86", + "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": "'1970-01-01T00:00:00.000Z'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01-01T00:00:00.000Z'" + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownership_epoch": { + "name": "ownership_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "content_revision": { + "name": "content_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "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_submissions": { + "name": "extension_submissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "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'" + }, + "payload": { + "name": "payload", + "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 + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_submissions_status": { + "name": "idx_submissions_status", + "columns": ["status"], + "isUnique": false + }, + "idx_submissions_submitted_by": { + "name": "idx_submissions_submitted_by", + "columns": ["submitted_by"], + "isUnique": false + }, + "idx_submissions_developer": { + "name": "idx_submissions_developer", + "columns": ["developer_id"], + "isUnique": false + }, + "idx_submissions_extension": { + "name": "idx_submissions_extension", + "columns": ["extension_id"], + "isUnique": false + }, + "idx_extension_submissions_pending_target": { + "name": "idx_extension_submissions_pending_target", + "columns": ["target_key"], + "isUnique": true, + "where": "\"extension_submissions\".\"status\" = 'pending'" + }, + "idx_extension_submissions_submitter_page": { + "name": "idx_extension_submissions_submitter_page", + "columns": ["submitted_by", "\"created_at\" desc", "\"id\" desc"], + "isUnique": false + }, + "idx_extension_submissions_queue_page": { + "name": "idx_extension_submissions_queue_page", + "columns": ["status", "created_at", "id"], + "isUnique": false + } + }, + "foreignKeys": { + "extension_submissions_extension_id_extensions_id_fk": { + "name": "extension_submissions_extension_id_extensions_id_fk", + "tableFrom": "extension_submissions", + "tableTo": "extensions", + "columnsFrom": ["extension_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "extension_submissions_submitted_by_users_id_fk": { + "name": "extension_submissions_submitted_by_users_id_fk", + "tableFrom": "extension_submissions", + "tableTo": "users", + "columnsFrom": ["submitted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "extension_submissions_reviewer_id_users_id_fk": { + "name": "extension_submissions_reviewer_id_users_id_fk", + "tableFrom": "extension_submissions", + "tableTo": "users", + "columnsFrom": ["reviewer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "extension_submissions_status_check": { + "name": "extension_submissions_status_check", + "value": "\"extension_submissions\".\"status\" IN ('pending', 'approved', 'rejected')" + }, + "extension_submissions_ownership_epoch_check": { + "name": "extension_submissions_ownership_epoch_check", + "value": "\"extension_submissions\".\"ownership_epoch\" >= 1" + } + } + }, + "extensions": { + "name": "extensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "releases": { + "name": "releases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readme": { + "name": "readme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "download_url": { + "name": "download_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_extensions_type": { + "name": "idx_extensions_type", + "columns": ["type"], + "isUnique": false + }, + "idx_extensions_author": { + "name": "idx_extensions_author", + "columns": ["author_id"], + "isUnique": false + }, + "idx_extensions_catalogue_order": { + "name": "idx_extensions_catalogue_order", + "columns": ["lower(\"id\")", "id"], + "isUnique": false + }, + "idx_extensions_type_catalogue_order": { + "name": "idx_extensions_type_catalogue_order", + "columns": ["type", "lower(\"id\")", "id"], + "isUnique": false + }, + "idx_extensions_author_catalogue_order": { + "name": "idx_extensions_author_catalogue_order", + "columns": ["author_id", "lower(\"id\")", "id"], + "isUnique": false + } + }, + "foreignKeys": { + "extensions_author_id_developers_id_fk": { + "name": "extensions_author_id_developers_id_fk", + "tableFrom": "extensions", + "tableTo": "developers", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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_submissions_submitter_page": { + "columns": { + "\"created_at\" desc": { + "isExpression": true + }, + "\"id\" desc": { + "isExpression": true + } + } + }, + "idx_extensions_catalogue_order": { + "columns": { + "lower(\"id\")": { + "isExpression": true + } + } + }, + "idx_extensions_type_catalogue_order": { + "columns": { + "lower(\"id\")": { + "isExpression": true + } + } + }, + "idx_extensions_author_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 75f874d..04550aa 100644 --- a/src/services/extensions/v2/db/migrations/meta/_journal.json +++ b/src/services/extensions/v2/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1785732581516, "tag": "0018_add_extension_catalogue_indexes", "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1785916611192, + "tag": "0019_add_user_deleted_at", + "breakpoints": true } ] } diff --git a/src/services/extensions/v2/db/schema.ts b/src/services/extensions/v2/db/schema.ts index cfc3ff0..4f4af50 100644 --- a/src/services/extensions/v2/db/schema.ts +++ b/src/services/extensions/v2/db/schema.ts @@ -8,26 +8,32 @@ import { check } from "drizzle-orm/sqlite-core"; -// Owned by the sibling FOSSBilling/extensions repo (src/lib/db/users.sql -// there), not this repo - only its id is modeled here, purely so other -// tables in this file can express their FK .references(() => users.id). -// This file is drizzle-kit's schema entry point (see -// drizzle.extensions.config.ts), so a fuller definition here would make -// drizzle-kit think it owns and should generate ALTER TABLE users -// migrations, which would be wrong. users-database.ts (the only place that -// reads more than id) imports a separate, non-scanned definition from -// ./external-tables instead. +// 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, +// transfers, and audit history. export const users = sqliteTable("users", { - id: text("id").primaryKey() + id: text("id").primaryKey(), + name: text("name"), + email: text("email"), + emailVerified: integer("email_verified").notNull().default(0), + picture: text("picture"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + isModerator: integer("is_moderator").notNull().default(0), + displayName: text("display_name"), + githubLogin: text("github_login"), + githubOrgs: text("github_orgs"), + githubOrgsExpiresAt: text("github_orgs_expires_at"), + deletedAt: text("deleted_at") }); -// Owned by v1 (../../v1/db/schema.sql) - v1 only ever reads this table, v2 -// only references its id via FK, so this file is the single schema source -// for the whole DB_EXTENSIONS database and v1's Drizzle queries import -// these table objects rather than redeclaring them. author_id's column -// name is left as-is (v1's own column, not touched by the v2 rename), but -// its target followed developers per migration 0008 - SQLite's -// ALTER TABLE RENAME TO updates other tables' FK references automatically. +// 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. export const extensions = sqliteTable( "extensions", { @@ -67,10 +73,10 @@ export const extensions = sqliteTable( ] ); -// v1-owned table (../../v1/db/schema.sql), renamed authors -> developers by -// v2 migration 0008. type/name/url are v1's original columns; everything -// else was added by v2 migrations 0001-0013. bio (added in 0003) was -// dropped in 0010 and is intentionally absent here. +// Legacy catalogue table renamed from authors to developers by migration +// 0008. The API owns the full table now; type/name/url are the original +// catalogue fields and the remaining columns were added by the v2 migrations. +// bio (added in 0003) was dropped in 0010 and is intentionally absent here. export const developers = sqliteTable( "developers", { diff --git a/src/services/extensions/v2/developer-profile-routes.ts b/src/services/extensions/v2/developer-profile-routes.ts index 46d5094..af01135 100644 --- a/src/services/extensions/v2/developer-profile-routes.ts +++ b/src/services/extensions/v2/developer-profile-routes.ts @@ -5,6 +5,7 @@ import { DeveloperSchema, ErrorResponseSchema, IdParamSchema, + OwnedDeveloperProfileSchema, PublicDeveloperSchema, ReverifyQuerySchema, toPublicDeveloper @@ -16,6 +17,56 @@ export function registerDeveloperProfileRoutes( app: ExtensionsV2App, dependencies: RouteDependencies ): void { + const getOwnDeveloperRoute = createRoute({ + method: "get", + path: "/developers/me", + tags: ["Developers"], + summary: "Get the caller's own developer profile", + security: [{ Bearer: [] }], + middleware: [dependencies.requireAuth()] as const, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ result: OwnedDeveloperProfileSchema.nullable() }) + } + }, + description: "The caller's profile, or null when none exists" + }, + 401: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Missing or invalid bearer token" + }, + 500: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Database error" + } + } + }); + + app.openapi(getOwnDeveloperRoute, async (c) => { + const auth = dependencies.auth(c); + const db = new DevelopersDatabase( + dependencies.database(c.env.DB_EXTENSIONS) + ); + const { data, error } = await db.getOwn(auth.userId); + if (error || data === null) { + if (error) { + return c.json( + { + error: { + message: error.message, + code: error.code ?? "DATABASE_ERROR" + } + }, + 500 + ); + } + return c.json({ result: null }, 200); + } + return c.json({ result: data }, 200); + }); + const upsertOwnDeveloperRoute = createRoute({ method: "put", path: "/developers/me", diff --git a/src/services/extensions/v2/developers-database.ts b/src/services/extensions/v2/developers-database.ts index c25ebae..5a986f3 100644 --- a/src/services/extensions/v2/developers-database.ts +++ b/src/services/extensions/v2/developers-database.ts @@ -7,9 +7,9 @@ import { developerTransfers, developerClaims, extensions, - extensionSubmissions + extensionSubmissions, + users } from "./db/schema"; -import { users as externalUsers } from "./db/external-tables"; import { databaseError, errorMessageChain } from "./errors"; import { toD1Statement } from "./d1-batch"; import { @@ -122,7 +122,7 @@ function parseDeveloperRow(row: DeveloperRow): DeveloperProfile { }; } -// Used by listAll/listUnapproved, whose queries left-join externalUsers on +// Used by listAll/listUnapproved, whose queries left-join users on // developers.owner_user_id to save the moderator a lookup per row (see // PendingDeveloperClaim's claimant_name/claimant_github_login for the same // pattern on the claims queue). @@ -161,6 +161,45 @@ function parseClaimRow(row: ClaimRow): DeveloperClaim { export class DevelopersDatabase { constructor(private db: ExtensionsDb) {} + async getOwn( + userId: string + ): Promise< + | DatabaseResult + | { data: null; error: null } + > { + try { + const [row] = await this.db + .select() + .from(developers) + .where(eq(developers.ownerUserId, userId)); + if (!row) return { data: null, error: null }; + + const [pending] = await this.db + .select({ id: developerTransfers.id }) + .from(developerTransfers) + .where( + and( + eq(developerTransfers.developerId, row.id), + isNull(developerTransfers.acceptedAt), + isNull(developerTransfers.revokedAt), + sql`${developerTransfers.expiresAt} > CURRENT_TIMESTAMP` + ) + ) + .limit(1); + + return { + data: { + ...parseDeveloperRow(row), + unclaimed: false, + has_pending_transfer: pending !== undefined + }, + error: null + }; + } catch (error) { + return databaseError("getOwn", error); + } + } + // githubToken — see the comment on verifyGithubOwnership(). Only consulted // when creating a brand-new profile (developer.id is immutable once // owned, so an update can't need re-verifying); guards against squatting @@ -577,7 +616,9 @@ export class DevelopersDatabase { } } - async getById(id: string): Promise> { + async getById( + id: string + ): Promise> { try { const [row] = await this.db .select() @@ -592,7 +633,13 @@ export class DevelopersDatabase { } }; } - return { data: parseDeveloperRow(row), error: null }; + return { + data: { + ...parseDeveloperRow(row), + unclaimed: row.ownerUserId === null + }, + error: null + }; } catch (error) { return databaseError("getById", error); } @@ -604,11 +651,11 @@ export class DevelopersDatabase { rows = await this.db .select({ developer: developers, - ownerName: externalUsers.name, - ownerGithubLogin: externalUsers.githubLogin + ownerName: users.name, + ownerGithubLogin: users.githubLogin }) .from(developers) - .leftJoin(externalUsers, eq(externalUsers.id, developers.ownerUserId)) + .leftJoin(users, eq(users.id, developers.ownerUserId)) .orderBy(asc(developers.name)); } catch (error) { return databaseError("listAll", error); @@ -623,11 +670,11 @@ export class DevelopersDatabase { rows = await this.db .select({ developer: developers, - ownerName: externalUsers.name, - ownerGithubLogin: externalUsers.githubLogin + ownerName: users.name, + ownerGithubLogin: users.githubLogin }) .from(developers) - .leftJoin(externalUsers, eq(externalUsers.id, developers.ownerUserId)) + .leftJoin(users, eq(users.id, developers.ownerUserId)) .where(isNull(developers.approvedAt)) .orderBy(asc(developers.createdAt)); } catch (error) { @@ -696,14 +743,11 @@ export class DevelopersDatabase { name: developerHistory.name, url: developerHistory.url, changedBy: developerHistory.changedBy, - changedByName: externalUsers.name, + changedByName: users.name, changedAt: developerHistory.changedAt }) .from(developerHistory) - .leftJoin( - externalUsers, - eq(externalUsers.id, developerHistory.changedBy) - ) + .leftJoin(users, eq(users.id, developerHistory.changedBy)) .where(eq(developerHistory.developerId, developerId)) // CURRENT_TIMESTAMP has only second resolution, so two writes in // the same second tie on changed_at; rowid (insertion order, @@ -1536,15 +1580,12 @@ export class DevelopersDatabase { claim: developerClaims, developerName: developers.name, developerType: developers.type, - claimantName: externalUsers.name, - claimantGithubLogin: externalUsers.githubLogin + claimantName: users.name, + claimantGithubLogin: users.githubLogin }) .from(developerClaims) .innerJoin(developers, eq(developers.id, developerClaims.developerId)) - .leftJoin( - externalUsers, - eq(externalUsers.id, developerClaims.claimantId) - ) + .leftJoin(users, eq(users.id, developerClaims.claimantId)) .where(eq(developerClaims.status, "pending")) .orderBy(asc(developerClaims.createdAt)); } catch (error) { diff --git a/src/services/extensions/v2/extensions-database.ts b/src/services/extensions/v2/extensions-database.ts index 1f76635..265784a 100644 --- a/src/services/extensions/v2/extensions-database.ts +++ b/src/services/extensions/v2/extensions-database.ts @@ -13,12 +13,10 @@ import { parseJSON } from "./interfaces"; -// LEFT JOIN so an extension whose developer row is missing (author_id -// pointing nowhere) still lists - author_id isn't a hard FK (see -// 0001_add_v2_tables.sql). COALESCE keeps developerId non-null in that -// case: extensions.authorId is itself NOT NULL, so the id half of the -// embedded developer is never lost even when every other field falls back -// to a default in parseExtensionRow below. +// 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, type: extensions.type, @@ -37,7 +35,8 @@ const EXTENSION_COLUMNS = { developerName: developers.name, developerUrl: developers.url, developerAvatarUrl: developers.avatarUrl, - developerApprovedAt: developers.approvedAt + developerApprovedAt: developers.approvedAt, + developerOwnerUserId: developers.ownerUserId }; const EXTENSION_LIST_COLUMNS = { @@ -56,7 +55,8 @@ const EXTENSION_LIST_COLUMNS = { developerName: EXTENSION_COLUMNS.developerName, developerUrl: EXTENSION_COLUMNS.developerUrl, developerAvatarUrl: EXTENSION_COLUMNS.developerAvatarUrl, - developerApprovedAt: EXTENSION_COLUMNS.developerApprovedAt + developerApprovedAt: EXTENSION_COLUMNS.developerApprovedAt, + developerOwnerUserId: EXTENSION_COLUMNS.developerOwnerUserId }; interface ExtensionRow { @@ -78,6 +78,7 @@ interface ExtensionRow { developerUrl: string | null; developerAvatarUrl: string | null; developerApprovedAt: string | null; + developerOwnerUserId: string | null; } type ExtensionListRow = Omit; @@ -245,7 +246,8 @@ function parseExtensionRow(row: ExtensionRow): Extension { name: row.developerName ?? "", URL: row.developerUrl ?? undefined, avatar_url: row.developerAvatarUrl ?? undefined, - approved: row.developerApprovedAt !== null + approved: row.developerApprovedAt !== null, + unclaimed: row.developerOwnerUserId === null } }; } @@ -268,7 +270,8 @@ function parseExtensionListRow(row: ExtensionListRow): ExtensionListItem { name: row.developerName ?? "", URL: row.developerUrl ?? undefined, avatar_url: row.developerAvatarUrl ?? undefined, - approved: row.developerApprovedAt !== null + approved: row.developerApprovedAt !== null, + unclaimed: row.developerOwnerUserId === null } }; } diff --git a/src/services/extensions/v2/index.ts b/src/services/extensions/v2/index.ts index 72d2b9b..46568d6 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -12,8 +12,40 @@ import { registerSubmissionRoutes } from "./submission-routes"; import { registerDeveloperProfileRoutes } from "./developer-profile-routes"; import { registerOwnershipRoutes } from "./ownership-routes"; import { registerModerationRoutes } from "./moderation-routes"; +import { registerAccountRoutes } from "./account-routes"; import { RouteDependencies } from "./route-dependencies"; +const requireAuthAllowInactive = requireAuth; + +function requireActiveAuth(): MiddlewareHandler { + const authenticate = requireAuth(); + return async (c, next) => { + let response: Response | undefined; + const authenticationResult = await authenticate(c, async () => { + const users = new UsersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); + const result = await users.isActive(getAuth(c).userId); + if (result.error) { + response = c.json({ error: result.error }, 500); + return; + } + if (!result.data) { + response = c.json( + { + error: { + message: "Active account required", + code: "ACCOUNT_INACTIVE" + } + }, + 403 + ); + return; + } + await next(); + }); + return response ?? authenticationResult; + }; +} + const extensionsV2 = new OpenAPIHono<{ Bindings: CloudflareBindings }>({ defaultHook: (result, c) => { if (!result.success) { @@ -61,11 +93,13 @@ const dependencies: RouteDependencies = { database: getExtensionsDb, auth: getAuth, platform: getPlatform, - requireAuth, + requireAuth: requireActiveAuth, + requireAuthAllowInactive, requireModerator }; registerPublicExtensionsRoutes(extensionsV2, dependencies); +registerAccountRoutes(extensionsV2, dependencies); registerSubmissionRoutes(extensionsV2, dependencies); registerOwnershipRoutes(extensionsV2, dependencies); registerModerationRoutes(extensionsV2, dependencies); diff --git a/src/services/extensions/v2/interfaces.ts b/src/services/extensions/v2/interfaces.ts index 520668d..b7a9388 100644 --- a/src/services/extensions/v2/interfaces.ts +++ b/src/services/extensions/v2/interfaces.ts @@ -193,21 +193,25 @@ export const PublicDeveloperSchema = DeveloperProfileSchema.omit({ github_verification_note: true, github_verified_at: true, github_url_verified: true, - unclaimed: true, owner_name: true, owner_github_login: true -}).openapi("PublicDeveloper"); +}) + .extend({ unclaimed: z.boolean() }) + .openapi("PublicDeveloper"); export type PublicDeveloper = z.infer; -export function toPublicDeveloper(profile: DeveloperProfile): PublicDeveloper { +export function toPublicDeveloper( + profile: DeveloperProfile & { unclaimed: boolean } +): PublicDeveloper { return { id: profile.id, type: profile.type, name: profile.name, URL: profile.URL, avatar_url: profile.avatar_url, - approved: profile.approved + approved: profile.approved, + unclaimed: profile.unclaimed }; } @@ -337,6 +341,49 @@ export const ErrorResponseSchema = z }) .openapi("Error"); +// The site remains responsible for OIDC and sessions. It sends only the +// provider projection needed by the API-owned domain row; authorization +// fields such as is_moderator are never accepted from this payload. +export const UserIdentityInputSchema = z + .object({ + name: z.string().max(200).nullable(), + email: z.string().email().max(254).nullable(), + email_verified: z.boolean(), + picture: z.string().max(2048).nullable(), + github_login: z.string().max(200).nullable(), + github_orgs: z.array(z.string().max(200)).max(500).nullable(), + github_orgs_expires_at: z.string().max(64).nullable() + }) + .strict() + .openapi("UserIdentityInput"); + +export type UserIdentityInput = z.infer; + +export const UserProfileUpdateSchema = z + .object({ + display_name: z.string().max(120).nullable() + }) + .strict() + .openapi("UserProfileUpdate"); + +export const UserSchema = z + .object({ + display_name: z.string().nullable(), + is_moderator: z.boolean(), + github_linked: z.boolean(), + active: z.boolean() + }) + .openapi("User"); + +export type User = z.infer; + +export const OwnedDeveloperProfileSchema = z + .intersection( + DeveloperProfileSchema, + z.object({ has_pending_transfer: z.boolean() }) + ) + .openapi("OwnedDeveloperProfile"); + export const IdParamSchema = z.object({ id: z.string().openapi({ param: { name: "id", in: "path" }, diff --git a/src/services/extensions/v2/public-extensions-routes.ts b/src/services/extensions/v2/public-extensions-routes.ts index 5c448d3..4e05965 100644 --- a/src/services/extensions/v2/public-extensions-routes.ts +++ b/src/services/extensions/v2/public-extensions-routes.ts @@ -8,6 +8,7 @@ import { IdParamSchema } from "./interfaces"; import { ExtensionsDatabase } from "./extensions-database"; +import { DevelopersDatabase } from "./developers-database"; import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; export function registerPublicExtensionsRoutes( @@ -74,6 +75,93 @@ export function registerPublicExtensionsRoutes( ); }); + const listMineRoute = createRoute({ + method: "get", + path: "/extensions/mine", + tags: ["Extensions"], + summary: "List extensions published under the caller's developer profile", + security: [{ Bearer: [] }], + middleware: [dependencies.requireAuth()] as const, + request: { query: ExtensionListQuerySchema }, + responses: { + 200: { + content: { + "application/json": { schema: ExtensionListResponseSchema } + }, + description: "The caller's published extensions" + }, + 401: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Missing or invalid bearer token" + }, + 422: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Pagination query failed validation" + }, + 500: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Database error" + } + } + }); + + app.openapi(listMineRoute, async (c) => { + const auth = dependencies.auth(c); + const { type, limit, cursor } = c.req.valid("query"); + const ownerDb = new DevelopersDatabase( + dependencies.database(c.env.DB_EXTENSIONS) + ); + const owner = await ownerDb.getOwn(auth.userId); + if (owner.error) { + return c.json( + { + error: { + message: owner.error.message, + code: owner.error.code ?? "DATABASE_ERROR" + } + }, + 500 + ); + } + if (!owner.data) { + return c.json( + { result: [], pagination: { next_cursor: null, has_more: false } }, + 200 + ); + } + + const db = new ExtensionsDatabase( + dependencies.database(c.env.DB_EXTENSIONS) + ); + const { data, error } = await db.list({ + type, + developerId: owner.data.id, + limit, + cursor + }); + if (error || !data) { + return c.json( + { + error: { + message: error?.message ?? "Unable to load extensions", + code: error?.code ?? "DATABASE_ERROR" + } + }, + error?.code === "INVALID_CURSOR" ? 422 : 500 + ); + } + return c.json( + { + result: data.items, + pagination: { + next_cursor: data.nextCursor, + has_more: data.hasMore + } + }, + 200 + ); + }); + const getExtensionRoute = createRoute({ method: "get", path: "/extensions/{id}", diff --git a/src/services/extensions/v2/route-dependencies.ts b/src/services/extensions/v2/route-dependencies.ts index f5da8f6..d721d6e 100644 --- a/src/services/extensions/v2/route-dependencies.ts +++ b/src/services/extensions/v2/route-dependencies.ts @@ -13,5 +13,9 @@ export interface RouteDependencies { auth: typeof getAuth; platform: typeof getPlatform; requireAuth: typeof requireAuth; + // Account projection endpoints need to inspect or restore a tombstoned + // user. Every other authenticated route uses requireAuth, which also + // verifies that the caller still has an active user row. + requireAuthAllowInactive: typeof requireAuth; requireModerator: () => MiddlewareHandler; } diff --git a/src/services/extensions/v2/users-database.ts b/src/services/extensions/v2/users-database.ts index f3b55ee..7d3422f 100644 --- a/src/services/extensions/v2/users-database.ts +++ b/src/services/extensions/v2/users-database.ts @@ -1,14 +1,37 @@ -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { DatabaseResult } from "../../../lib/interfaces"; import { ExtensionsDb } from "../../../lib/db"; -import { users } from "./db/external-tables"; +import { users } from "./db/schema"; import { databaseError } from "./errors"; +import { toD1Statement } from "./d1-batch"; export type GithubIdentity = { githubLogin: string | null; githubOrgs: string[]; }; +export type UserIdentityInput = { + name: string | null; + email: string | null; + emailVerified: boolean; + picture: string | null; + githubLogin: string | null; + githubOrgs: string[] | null; + githubOrgsExpiresAt: string | null; +}; + +export type UserRecord = { + id: string; + name: string | null; + email: string | null; + emailVerified: boolean; + picture: string | null; + displayName: string | null; + isModerator: boolean; + githubLinked: boolean; + deletedAt: string | null; +}; + const RFC3339_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; @@ -21,22 +44,291 @@ function isFutureGithubOrgsExpiry( return Number.isFinite(expiresAt) && expiresAt > now; } -// `users` is owned by the FOSSBilling/extensions repo (src/lib/db/users.sql there), -// NOT this repo, but lives in the same DB_EXTENSIONS database. If that schema -// changes (columns renamed/dropped), update fossbilling/api AND that file. Assumed -// columns used here: users.id (TEXT, = auth `sub` claim), users.is_moderator -// (INTEGER 0/1), users.github_login (TEXT), users.github_orgs (TEXT, JSON -// array), and users.github_orgs_expires_at (TEXT, absolute RFC3339 expiry). +function hasUsableGithubOrgs( + value: string | null, + expiresAt: string | null +): boolean { + if (value === null || !isFutureGithubOrgsExpiry(expiresAt)) return false; + try { + const parsed: unknown = JSON.parse(value); + return ( + Array.isArray(parsed) && parsed.every((org) => typeof org === "string") + ); + } catch { + return false; + } +} + export class UsersDatabase { constructor(private db: ExtensionsDb) {} + async syncIdentity( + userId: string, + input: UserIdentityInput + ): Promise> { + const now = new Date().toISOString(); + const hasFreshGithubOrgs = + Array.isArray(input.githubOrgs) && + isFutureGithubOrgsExpiry(input.githubOrgsExpiresAt); + + try { + await this.db + .insert(users) + .values({ + id: userId, + name: input.name, + email: input.email, + emailVerified: input.emailVerified ? 1 : 0, + picture: input.picture, + createdAt: now, + updatedAt: now, + githubLogin: input.githubLogin, + githubOrgs: hasFreshGithubOrgs + ? JSON.stringify(input.githubOrgs) + : null, + githubOrgsExpiresAt: hasFreshGithubOrgs + ? input.githubOrgsExpiresAt + : null, + deletedAt: null + }) + .onConflictDoUpdate({ + target: users.id, + set: { + name: input.name, + email: input.email, + emailVerified: input.emailVerified ? 1 : 0, + picture: input.picture, + updatedAt: now, + githubLogin: input.githubLogin, + githubOrgs: hasFreshGithubOrgs + ? JSON.stringify(input.githubOrgs) + : null, + githubOrgsExpiresAt: hasFreshGithubOrgs + ? input.githubOrgsExpiresAt + : null, + deletedAt: null + } + }) + .run(); + + return this.get(userId); + } catch (error) { + return databaseError("syncIdentity", error); + } + } + + async get(userId: string): Promise> { + try { + const [row] = await this.db + .select() + .from(users) + .where(eq(users.id, userId)); + if (!row) { + return { + data: null, + error: { message: "User not found", code: "NOT_FOUND" } + }; + } + + const active = row.deletedAt === null; + return { + data: { + id: row.id, + name: row.name, + email: row.email, + emailVerified: row.emailVerified === 1, + picture: row.picture, + displayName: row.displayName, + isModerator: active && row.isModerator === 1, + githubLinked: + active && + hasUsableGithubOrgs(row.githubOrgs, row.githubOrgsExpiresAt), + deletedAt: row.deletedAt + }, + error: null + }; + } catch (error) { + return databaseError("get", error); + } + } + + async isActive(userId: string): Promise> { + try { + const [row] = await this.db + .select({ deletedAt: users.deletedAt }) + .from(users) + .where(eq(users.id, userId)); + return { data: row !== undefined && row.deletedAt === null, error: null }; + } catch (error) { + return databaseError("isActive", error); + } + } + + async updateDisplayName( + userId: string, + displayName: string | null + ): Promise> { + try { + const result = await this.db + .update(users) + .set({ displayName, updatedAt: new Date().toISOString() }) + .where(and(eq(users.id, userId), isNull(users.deletedAt))) + .run(); + if (!result.meta?.changes) { + return { + data: null, + error: { message: "User not found", code: "NOT_FOUND" } + }; + } + return { data: { displayName }, error: null }; + } catch (error) { + return databaseError("updateDisplayName", error); + } + } + + async deleteAccount( + userId: string + ): Promise> { + const deletedAt = new Date().toISOString(); + try { + const [user] = await this.db + .select({ deletedAt: users.deletedAt }) + .from(users) + .where(eq(users.id, userId)); + if (!user || user.deletedAt !== null) { + return { + data: null, + error: { message: "User not found", code: "NOT_FOUND" } + }; + } + + // The first statement is a guarded reservation. All later statements + // require this exact marker, so a blocked or raced deletion cannot + // touch any domain rows. D1 batches are transactional: SQL failures + // roll the whole reservation and cleanup back together. + const reserveStmt = toD1Statement(this.db.$client, { + sql: `UPDATE users + SET deleted_at = ?, updated_at = ? + WHERE id = ? AND deleted_at IS NULL + 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' + )`, + params: [deletedAt, deletedAt, userId, userId, userId] + }); + + const rejectSubmissionsStmt = toD1Statement(this.db.$client, { + sql: `UPDATE extension_submissions + SET status = 'rejected', + review_note = 'Submitter account deleted', + reviewed_at = CURRENT_TIMESTAMP + WHERE submitted_by = ? AND status = 'pending' + AND EXISTS (SELECT 1 FROM users WHERE id = ? AND deleted_at = ?)`, + params: [userId, userId, deletedAt] + }); + + const rejectClaimsStmt = toD1Statement(this.db.$client, { + sql: `UPDATE developer_claims + SET status = 'rejected', + review_note = 'Claimant account deleted', + reviewed_at = CURRENT_TIMESTAMP + WHERE claimant_id = ? AND status = 'pending' + AND EXISTS (SELECT 1 FROM users WHERE id = ? AND deleted_at = ?)`, + params: [userId, userId, deletedAt] + }); + + const deleteTransfersStmt = toD1Statement(this.db.$client, { + sql: `DELETE FROM developer_transfers + WHERE developer_id IN ( + SELECT id FROM developers WHERE owner_user_id = ? + ) + AND EXISTS (SELECT 1 FROM users WHERE id = ? AND deleted_at = ?)`, + params: [userId, userId, deletedAt] + }); + + const deleteClaimsStmt = toD1Statement(this.db.$client, { + sql: `DELETE FROM developer_claims + WHERE developer_id IN ( + SELECT id FROM developers WHERE owner_user_id = ? + ) + AND EXISTS (SELECT 1 FROM users WHERE id = ? AND deleted_at = ?)`, + params: [userId, userId, deletedAt] + }); + + 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 EXISTS (SELECT 1 FROM users WHERE id = ? AND deleted_at = ?)`, + params: [userId, userId, deletedAt] + }); + + const clearUserStmt = toD1Statement(this.db.$client, { + sql: `UPDATE users + SET name = NULL, + email = NULL, + email_verified = 0, + picture = NULL, + display_name = NULL, + is_moderator = 0, + github_login = NULL, + github_orgs = NULL, + github_orgs_expires_at = NULL, + updated_at = ? + WHERE id = ? AND deleted_at = ?`, + params: [deletedAt, userId, deletedAt] + }); + + const results = await this.db.$client.batch([ + reserveStmt, + rejectSubmissionsStmt, + rejectClaimsStmt, + deleteTransfersStmt, + deleteClaimsStmt, + deleteDeveloperStmt, + clearUserStmt + ]); + + if (!results[0]?.meta?.changes || !results[6]?.meta?.changes) { + return { + data: null, + error: { + message: + "The account cannot be deleted while it owns published extensions or pending submissions", + code: "CONFLICT" + } + }; + } + + return { data: { deleted: true }, error: null }; + } catch (error) { + return databaseError("deleteAccount", error); + } + } + async isModerator(userId: string): Promise> { try { const [row] = await this.db - .select({ isModerator: users.isModerator }) + .select({ + isModerator: users.isModerator, + deletedAt: users.deletedAt + }) .from(users) .where(eq(users.id, userId)); - return { data: row?.isModerator === 1, error: null }; + return { + data: row?.deletedAt == null && row?.isModerator === 1, + error: null + }; } catch (error) { return databaseError("isModerator", error); } @@ -55,11 +347,16 @@ export class UsersDatabase { .select({ githubLogin: users.githubLogin, githubOrgs: users.githubOrgs, - githubOrgsExpiresAt: users.githubOrgsExpiresAt + githubOrgsExpiresAt: users.githubOrgsExpiresAt, + deletedAt: users.deletedAt }) .from(users) .where(eq(users.id, userId)); + if (row?.deletedAt !== null && row?.deletedAt !== undefined) { + return { data: { githubLogin: null, githubOrgs: [] }, error: null }; + } + let githubOrgs: string[] = []; if ( row?.githubOrgs && diff --git a/test/env.d.ts b/test/env.d.ts index b64b219..24c8b81 100644 --- a/test/env.d.ts +++ b/test/env.d.ts @@ -13,7 +13,5 @@ declare namespace Cloudflare { interface Env { TEST_MIGRATIONS_EXTENSIONS: import("cloudflare:test").D1Migration[]; TEST_MIGRATIONS_CENTRAL_ALERTS: import("cloudflare:test").D1Migration[]; - TEST_V1_SCHEMA_SQL: string; - TEST_USERS_STUB_SQL: string; } } diff --git a/test/services/extensions/v2/db-fixtures.ts b/test/services/extensions/v2/db-fixtures.ts index 91cf05f..d0f0337 100644 --- a/test/services/extensions/v2/db-fixtures.ts +++ b/test/services/extensions/v2/db-fixtures.ts @@ -163,8 +163,9 @@ export async function insertUser( // created a bare stub row for this id before this richer call runs. await db .prepare( - `INSERT INTO users (id, is_moderator, github_login, github_orgs, github_orgs_expires_at) VALUES (?, ?, ?, ?, ?) + `INSERT INTO users (id, created_at, updated_at, is_moderator, github_login, github_orgs, github_orgs_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET + updated_at = excluded.updated_at, is_moderator = excluded.is_moderator, github_login = excluded.github_login, github_orgs = excluded.github_orgs, @@ -172,7 +173,9 @@ export async function insertUser( ) .bind( row.id, - row.is_moderator ?? null, + new Date().toISOString(), + new Date().toISOString(), + row.is_moderator ?? 0, row.github_login ?? null, row.github_orgs ?? null, githubOrgsExpiresAt @@ -184,8 +187,10 @@ export async function insertUser( // insertUser() may set up separately (before or after this runs). export async function ensureUser(db: D1Database, id: string): Promise { await db - .prepare("INSERT OR IGNORE INTO users (id) VALUES (?)") - .bind(id) + .prepare( + "INSERT OR IGNORE INTO users (id, created_at, updated_at) VALUES (?, ?, ?)" + ) + .bind(id, new Date().toISOString(), new Date().toISOString()) .run(); } @@ -330,6 +335,70 @@ export async function insertDeveloperClaim( .run(); } +export async function insertDeveloperTransfer( + db: D1Database, + row: Partial & { + id: string; + developer_id: string; + created_by: string; + token_hash: string; + expires_at: string; + } +): Promise { + await ensureUser(db, row.created_by); + if (row.accepted_by) { + await ensureUser(db, row.accepted_by); + } + await db + .prepare( + `INSERT INTO developer_transfers + (id, developer_id, token_hash, created_by, created_at, expires_at, + accepted_by, accepted_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + row.id, + row.developer_id, + row.token_hash, + row.created_by, + row.created_at ?? new Date().toISOString(), + row.expires_at, + row.accepted_by ?? null, + row.accepted_at ?? null, + row.revoked_at ?? null + ) + .run(); +} + +export async function insertDeveloperHistory( + db: D1Database, + row: Partial & { + id: string; + developer_id: string; + type: string; + name: string; + changed_by: string; + } +): Promise { + await ensureUser(db, row.changed_by); + await db + .prepare( + `INSERT INTO developer_history + (id, developer_id, type, name, url, changed_by, changed_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + row.id, + row.developer_id, + row.type, + row.name, + row.url ?? null, + row.changed_by, + row.changed_at ?? new Date().toISOString() + ) + .run(); +} + export async function getDeveloper( db: D1Database, id: string diff --git a/test/services/extensions/v2/index.test.ts b/test/services/extensions/v2/index.test.ts index 7199137..8bb8739 100644 --- a/test/services/extensions/v2/index.test.ts +++ b/test/services/extensions/v2/index.test.ts @@ -53,6 +53,8 @@ import { listSubmissions, countDeveloperClaims, getDeveloperClaim, + insertDeveloperTransfer, + insertDeveloperHistory, listDeveloperTransfers, listDeveloperClaims, listDeveloperHistory, @@ -258,6 +260,26 @@ async function put( return res; } +async function patch( + path: string, + headers: Record, + body?: unknown +) { + const ctx = createExecutionContext(); + const res = await app.request( + path, + { + method: "PATCH", + headers, + body: body !== undefined ? JSON.stringify(body) : undefined + }, + env, + ctx + ); + await waitOnExecutionContext(ctx); + return res; +} + function sampleDeveloper(overrides?: { id?: string; name?: string }) { return { id: overrides?.id ?? "dev-developer", @@ -3642,7 +3664,8 @@ describe("Extensions API v2", () => { name: "Public Dev", URL: "https://example.com", avatar_url: "https://example.com/avatar.png", - approved: true + approved: true, + unclaimed: false }); expect(body.result.contact_email).toBeUndefined(); }); @@ -3651,6 +3674,16 @@ describe("Extensions API v2", () => { const res = await get("/extensions/v2/developers/no-such-developer", {}); expect(res.status).toBe(404); }); + + it("marks an unowned developer as unclaimed", async () => { + await seedUnownedDeveloper("legacy-public"); + + const res = await get("/extensions/v2/developers/legacy-public", {}); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + result: { id: "legacy-public", unclaimed: true } + }); + }); }); describe("GET /extensions", () => { @@ -3687,15 +3720,30 @@ describe("Extensions API v2", () => { const res = await get("/extensions/v2/extensions", {}); expect(res.status).toBe(200); const body = (await res.json()) as { - result: Array<{ id: string; developer: { id: string } }>; + result: Array<{ + id: string; + developer: { id: string; unclaimed: boolean }; + }>; }; expect(body.result).toHaveLength(1); expect(body.result[0].id).toBe("existing-ext"); expect(body.result[0].developer.id).toBe("owner-developer"); + expect(body.result[0].developer.unclaimed).toBe(false); expect(body.result[0]).not.toHaveProperty("readme"); expect(body.result[0]).not.toHaveProperty("releases"); }); + it("marks unowned public developers as unclaimed", async () => { + await seedCatalogue(["legacy-extension"]); + + const res = await get("/extensions/v2/extensions", {}); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: Array<{ developer: { unclaimed: boolean } }>; + }; + expect(body.result[0].developer.unclaimed).toBe(true); + }); + it("filters by type", async () => { await seedOwnedExtension(); @@ -3822,11 +3870,15 @@ describe("Extensions API v2", () => { const res = await get("/extensions/v2/extensions/EXISTING-EXT", {}); expect(res.status).toBe(200); const body = (await res.json()) as { - result: { id: string; developer: { name: string; approved: boolean } }; + result: { + id: string; + developer: { name: string; approved: boolean; unclaimed: boolean }; + }; }; expect(body.result.id).toBe("existing-ext"); expect(body.result.developer.name).toBe("Owner"); expect(body.result.developer.approved).toBe(false); + expect(body.result.developer.unclaimed).toBe(false); expect(body.result).toMatchObject({ readme: "r", releases: [], @@ -3840,15 +3892,6 @@ describe("Extensions API v2", () => { const res = await get("/extensions/v2/extensions/no-such-extension", {}); expect(res.status).toBe(404); }); - - // A test previously lived here asserting parseExtensionRow's fallback - // for a "missing developer row" (an extension with an author_id that - // doesn't exist). It was deleted: extensions.author_id has always been - // a hard FK to developers(id) in the real schema.sql - the old mock - // just never enforced it, so that state was never actually reachable - // in production. The defensive COALESCE/fallback in - // extensions-database.ts is harmless to keep, just untestable this way - // against real D1. }); describe("OpenAPI docs", () => { @@ -3863,7 +3906,10 @@ describe("Extensions API v2", () => { expect(Object.keys(spec.paths)).toEqual( expect.arrayContaining([ "/extensions", + "/extensions/mine", "/extensions/{id}", + "/users/me/identity", + "/users/me", "/submissions", "/submissions/mine", "/submissions/queue", @@ -3892,4 +3938,326 @@ describe("Extensions API v2", () => { expect(res.headers.get("Content-Type")).toContain("text/html"); }); }); + + describe("API-owned account projection", () => { + it("syncs identity, exposes owner state, and lists owned extensions", async () => { + const headers = await authHeaders("account-1"); + const synced = await put("/extensions/v2/users/me/identity", headers, { + name: "Account User", + email: "account@example.com", + email_verified: true, + picture: "https://example.com/avatar.png", + github_login: "account-user", + github_orgs: ["fossbilling"], + github_orgs_expires_at: "2099-01-01T00:00:00.000Z" + }); + expect(synced.status).toBe(200); + expect(await synced.json()).toMatchObject({ + result: { + github_linked: true, + is_moderator: false, + active: true + } + }); + + const profile = await patch("/extensions/v2/users/me", headers, { + display_name: "Account Display" + }); + expect(profile.status).toBe(200); + expect(await profile.json()).toEqual({ + result: { display_name: "Account Display" } + }); + + const developer = await get("/extensions/v2/developers/me", headers); + expect(developer.status).toBe(200); + expect(await developer.json()).toEqual({ result: null }); + + await insertDeveloper(db, { + id: "account-developer", + type: "user", + name: "Account Developer", + owner_user_id: "account-1" + }); + await insertExtension(db, { + id: "account-extension", + type: "mod", + author_id: "account-developer", + name: "Account Extension", + description: "description", + releases: "[]", + website: "https://example.com", + license: '{"name":"MIT"}', + icon_url: null, + readme: "# Readme", + source: '{"type":"github","repo":"example/account"}', + version: "1.0.0", + download_url: "https://example.com/download.zip" + }); + + const owned = await get("/extensions/v2/extensions/mine", headers); + expect(owned.status).toBe(200); + expect(await owned.json()).toMatchObject({ + result: [{ id: "account-extension" }], + pagination: { has_more: false, next_cursor: null } + }); + }); + + it("tombstones and later reactivates an account", async () => { + const headers = await authHeaders("delete-me"); + const deleted = await del("/extensions/v2/users/me", headers); + expect(deleted.status).toBe(200); + expect(await deleted.json()).toEqual({ result: { deleted: true } }); + + const afterDelete = await get("/extensions/v2/users/me", headers); + expect(afterDelete.status).toBe(200); + expect(await afterDelete.json()).toMatchObject({ + result: { active: false, display_name: null } + }); + const row = await db + .prepare( + "SELECT name, email, email_verified, picture, display_name, is_moderator, github_login, github_orgs, github_orgs_expires_at, deleted_at FROM users WHERE id = ?" + ) + .bind("delete-me") + .first<{ + name: string | null; + email: string | null; + email_verified: number; + picture: string | null; + display_name: string | null; + is_moderator: number; + github_login: string | null; + github_orgs: string | null; + github_orgs_expires_at: string | null; + deleted_at: string | null; + }>(); + expect(row).toMatchObject({ + name: null, + email: null, + email_verified: 0, + picture: null, + display_name: null, + is_moderator: 0, + github_login: null, + github_orgs: null, + github_orgs_expires_at: null + }); + expect(row?.deleted_at).toBeTruthy(); + + const blockedWrite = await put( + "/extensions/v2/developers/me", + headers, + sampleDeveloper({ id: "deleted-developer" }) + ); + expect(blockedWrite.status).toBe(403); + expect(await blockedWrite.json()).toMatchObject({ + error: { code: "ACCOUNT_INACTIVE" } + }); + + const reactivated = await put( + "/extensions/v2/users/me/identity", + headers, + { + name: "Reactivated", + email: "reactivated@example.com", + email_verified: true, + picture: null, + github_login: null, + github_orgs: null, + github_orgs_expires_at: null + } + ); + expect(reactivated.status).toBe(200); + expect(await reactivated.json()).toMatchObject({ + result: { active: true, display_name: null } + }); + }); + + it("blocks deletion while published extensions remain owned", async () => { + await seedOwnedExtension(); + const headers = await authHeaders("owner-1"); + const deleted = await del("/extensions/v2/users/me", headers); + expect(deleted.status).toBe(409); + const row = await db + .prepare("SELECT deleted_at FROM users WHERE id = ?") + .bind("owner-1") + .first<{ deleted_at: string | null }>(); + expect(row?.deleted_at).toBeNull(); + }); + + it("blocks deletion while a pending submission targets the owned developer", async () => { + await seedDeveloper("pending-developer", "pending-owner"); + await insertSubmission(db, { + id: "pending-submission", + developer_id: "pending-developer", + submitted_by: "pending-owner", + payload: JSON.stringify( + samplePayload({ developerId: "pending-developer" }) + ) + }); + + const deleted = await del( + "/extensions/v2/users/me", + await authHeaders("pending-owner") + ); + expect(deleted.status).toBe(409); + expect(await getSubmission(db, "pending-submission")).toMatchObject({ + status: "pending" + }); + const user = await db + .prepare("SELECT deleted_at FROM users WHERE id = ?") + .bind("pending-owner") + .first<{ deleted_at: string | null }>(); + expect(user?.deleted_at).toBeNull(); + }); + + it("cancels pending work, removes disposable ownership rows, and preserves history", async () => { + await seedDeveloper("cleanup-developer", "cleanup-user"); + await seedUnownedDeveloper("claim-target"); + await insertDeveloperTransfer(db, { + id: "cleanup-transfer", + developer_id: "cleanup-developer", + token_hash: "cleanup-token-hash", + created_by: "cleanup-user", + expires_at: "2099-01-01 00:00:00" + }); + await insertDeveloperClaim(db, { + id: "cleanup-owned-claim", + developer_id: "cleanup-developer", + claimant_id: "cleanup-user" + }); + await insertDeveloperClaim(db, { + id: "cleanup-pending-claim", + developer_id: "claim-target", + claimant_id: "cleanup-user" + }); + await insertSubmission(db, { + id: "cleanup-pending-submission", + developer_id: "claim-target", + submitted_by: "cleanup-user", + payload: JSON.stringify(samplePayload({ developerId: "claim-target" })) + }); + await insertDeveloperHistory(db, { + id: "cleanup-history", + developer_id: "cleanup-developer", + type: "user", + name: "Before deletion", + changed_by: "cleanup-user" + }); + await insertUser(db, { + id: "cleanup-user", + is_moderator: 1, + github_login: "cleanup-user", + github_orgs: '["fossbilling"]' + }); + await db + .prepare( + `UPDATE users + SET name = ?, email = ?, email_verified = 1, picture = ?, display_name = ? + WHERE id = ?` + ) + .bind( + "Cleanup User", + "cleanup@example.com", + "https://example.com/cleanup.png", + "Cleanup", + "cleanup-user" + ) + .run(); + + const deleted = await del( + "/extensions/v2/users/me", + await authHeaders("cleanup-user") + ); + expect(deleted.status).toBe(200); + + expect(await hasDeveloper(db, "cleanup-developer")).toBe(false); + expect(await listDeveloperTransfers(db)).toEqual([]); + expect( + (await listDeveloperClaims(db)).find( + ({ id }) => id === "cleanup-owned-claim" + ) + ).toBeUndefined(); + expect( + await getSubmission(db, "cleanup-pending-submission") + ).toMatchObject({ + status: "rejected", + review_note: "Submitter account deleted" + }); + expect( + await getDeveloperClaim(db, "cleanup-pending-claim") + ).toMatchObject({ + status: "rejected", + review_note: "Claimant account deleted" + }); + expect(await listDeveloperHistory(db)).toEqual([ + expect.objectContaining({ + id: "cleanup-history", + developer_id: "cleanup-developer", + changed_by: "cleanup-user" + }) + ]); + + const user = await db + .prepare( + `SELECT name, email, email_verified, picture, display_name, + is_moderator, github_login, github_orgs, + github_orgs_expires_at, deleted_at + FROM users WHERE id = ?` + ) + .bind("cleanup-user") + .first>(); + expect(user).toMatchObject({ + name: null, + email: null, + email_verified: 0, + picture: null, + display_name: null, + is_moderator: 0, + github_login: null, + github_orgs: null, + github_orgs_expires_at: null + }); + expect(user?.deleted_at).toBeTruthy(); + }); + + it("rolls back the tombstone and cleanup when a batch statement fails", async () => { + await seedDeveloper("rollback-developer", "rollback-user"); + await insertDeveloperTransfer(db, { + id: "rollback-transfer", + developer_id: "rollback-developer", + token_hash: "rollback-token-hash", + created_by: "rollback-user", + expires_at: "2099-01-01 00:00:00" + }); + await db + .prepare( + `CREATE TRIGGER deletion_test_failure + BEFORE DELETE ON developers + BEGIN + SELECT RAISE(ABORT, 'deletion test failure'); + END` + ) + .run(); + + try { + const deleted = await del( + "/extensions/v2/users/me", + await authHeaders("rollback-user") + ); + expect(deleted.status).toBe(500); + } finally { + await db.prepare("DROP TRIGGER deletion_test_failure").run(); + } + + expect(await hasDeveloper(db, "rollback-developer")).toBe(true); + expect(await listDeveloperTransfers(db)).toEqual([ + expect.objectContaining({ id: "rollback-transfer" }) + ]); + const user = await db + .prepare("SELECT deleted_at FROM users WHERE id = ?") + .bind("rollback-user") + .first<{ deleted_at: string | null }>(); + expect(user?.deleted_at).toBeNull(); + }); + }); }); diff --git a/test/utils/apply-migrations.ts b/test/utils/apply-migrations.ts index e46078d..0146d1c 100644 --- a/test/utils/apply-migrations.ts +++ b/test/utils/apply-migrations.ts @@ -7,26 +7,10 @@ import { applyD1Migrations, env } from "cloudflare:test"; // harmless. let applied = false; -async function runStatements(db: D1Database, sql: string): Promise { - const statements = sql - .split(";") - .map((s) => s.trim()) - .filter(Boolean); - for (const statement of statements) { - await db.prepare(statement).run(); - } -} - export async function applyTestMigrations(): Promise { if (applied) return; applied = true; - // v1's schema.sql and the users stub must exist before the v2 migrations - // list runs (see vitest.config.ts for why these are string bindings - // rather than files read here). - await runStatements(env.DB_EXTENSIONS, env.TEST_V1_SCHEMA_SQL); - await runStatements(env.DB_EXTENSIONS, env.TEST_USERS_STUB_SQL); - await applyD1Migrations(env.DB_EXTENSIONS, env.TEST_MIGRATIONS_EXTENSIONS); await applyD1Migrations( env.DB_CENTRAL_ALERTS, diff --git a/vitest.config.ts b/vitest.config.ts index d16fd96..7592875 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,3 @@ -import { readFileSync } from "node:fs"; import path from "node:path"; import { cloudflareTest, @@ -19,21 +18,6 @@ const centralAlertsMigrations = await readD1Migrations( path.join(__dirname, "src/services/central-alerts/v1/db/migrations") ); -// v1's schema.sql (authors/extensions) and the sibling FOSSBilling/extensions -// repo's `users` table are bootstrapped by hand in every real environment -// (never through `wrangler d1 migrations`), so readD1Migrations never sees -// them - migration 0001 ALTERs `authors` and several v2 tables REFERENCE -// `users(id)`, so both must exist before the v2 migrations run against a -// fresh test-local D1. Read as a string here (Node) rather than having -// test/apply-migrations.ts read the file itself - that script runs inside -// workerd, which doesn't have host filesystem access via node:fs. -const v1SchemaSql = readFileSync( - path.join(__dirname, "src/services/extensions/v1/db/schema.sql"), - "utf8" -); -const usersStubSql = - "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY NOT NULL, name TEXT, is_moderator INTEGER, github_login TEXT, github_orgs TEXT, github_orgs_expires_at TEXT);"; - export default defineConfig({ plugins: [ cloudflareTest({ @@ -45,9 +29,7 @@ export default defineConfig({ UPDATE_TOKEN: "test-update-token", ASSERTION_SIGNING_SECRET: "test-assertion-signing-secret", TEST_MIGRATIONS_EXTENSIONS: extensionsMigrations, - TEST_MIGRATIONS_CENTRAL_ALERTS: centralAlertsMigrations, - TEST_V1_SCHEMA_SQL: v1SchemaSql, - TEST_USERS_STUB_SQL: usersStubSql + TEST_MIGRATIONS_CENTRAL_ALERTS: centralAlertsMigrations } } }) From cc63a0eaae5ae00da5611925bcfd1cfab8e10402 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 5 Aug 2026 11:14:54 +0100 Subject: [PATCH 2/7] Address Extensions API review findings --- README.md | 5 + src/services/extensions/v2/account-routes.ts | 12 +- .../v2/db/migrations/0000_bootstrap_users.sql | 7 + .../extensions/v2/developer-profile-routes.ts | 8 +- .../extensions/v2/developers-database.ts | 157 +++++++++++++----- .../extensions/v2/extensions-database.ts | 4 + src/services/extensions/v2/index.ts | 28 ++++ src/services/extensions/v2/interfaces.ts | 18 +- .../extensions/v2/moderation-routes.ts | 29 ++-- .../extensions/v2/owner-extensions-routes.ts | 122 ++++++++++++++ .../extensions/v2/ownership-routes.ts | 30 ++-- .../extensions/v2/public-extensions-routes.ts | 88 ---------- .../extensions/v2/route-dependencies.ts | 4 + .../extensions/v2/submission-routes.ts | 7 +- .../extensions/v2/submissions-database.ts | 23 ++- src/services/extensions/v2/users-database.ts | 1 + test/services/extensions/v2/index.test.ts | 83 +++++++++ 17 files changed, 460 insertions(+), 166 deletions(-) create mode 100644 src/services/extensions/v2/owner-extensions-routes.ts diff --git a/README.md b/README.md index 32114fc..79aef44 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,11 @@ We use [Cloudflare D1](https://developers.cloudflare.com/d1/) and [KV](https://d its migrations only from this repository, from `src/services/extensions/v2/db/migrations`, with `db:migrate:extensions-v2:*`. The Extensions site has no D1 migration source. + The `0000` users bootstrap mirrors the complete table created by the former + site migration, so it is safe to re-run against the existing split-owned + database without replacing rows; `0019` then adds the API-owned tombstone + column. Back up the database and inspect `PRAGMA table_info(users)` before + adoption, as with any schema ownership change. - **KV Namespace** (`CACHE_KV`): Caches GitHub API responses so we don't hit rate limits. - **KV Namespace** (`AUTH_KV`): Stores the `UPDATE_TOKEN` value for `/versions/v1/update`. diff --git a/src/services/extensions/v2/account-routes.ts b/src/services/extensions/v2/account-routes.ts index dc53fa9..8368dfe 100644 --- a/src/services/extensions/v2/account-routes.ts +++ b/src/services/extensions/v2/account-routes.ts @@ -2,6 +2,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { getAuth } from "../../../lib/auth"; import { statusFromErrorCode } from "./route-errors"; import { + ActiveAccountRequiredResponse, ErrorResponseSchema, UserIdentityInputSchema, UserProfileUpdateSchema, @@ -34,7 +35,7 @@ export function registerAccountRoutes( tags: ["Users"], summary: "Synchronize the caller's OIDC identity projection", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuthAllowInactive()] as const, + middleware: [dependencies.requireIdentitySync()] as const, request: { body: { content: { "application/json": { schema: UserIdentityInputSchema } } @@ -51,6 +52,10 @@ export function registerAccountRoutes( content: { "application/json": { schema: ErrorResponseSchema } }, description: "Missing or invalid bearer token" }, + 403: { + ...ActiveAccountRequiredResponse, + description: "Identity synchronization requires a trusted assertion" + }, 422: { content: { "application/json": { schema: ErrorResponseSchema } }, description: "Identity payload failed validation" @@ -63,6 +68,10 @@ export function registerAccountRoutes( }); app.openapi(syncIdentityRoute, async (c) => { + // requireIdentitySync has already verified the HMAC assertion minted by + // the trusted Extensions site. The projection fields below therefore + // represent the site's OIDC callback, while authorization state remains + // API-owned and is never accepted from the request body. const auth = getAuth(c); const body = c.req.valid("json"); const users = new UsersDatabase(dependencies.database(c.env.DB_EXTENSIONS)); @@ -169,6 +178,7 @@ export function registerAccountRoutes( content: { "application/json": { schema: ErrorResponseSchema } }, description: "Missing or invalid bearer token" }, + 403: ActiveAccountRequiredResponse, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, description: "Account does not exist or has been deleted" diff --git a/src/services/extensions/v2/db/migrations/0000_bootstrap_users.sql b/src/services/extensions/v2/db/migrations/0000_bootstrap_users.sql index 9ff0cb3..bd62e49 100644 --- a/src/services/extensions/v2/db/migrations/0000_bootstrap_users.sql +++ b/src/services/extensions/v2/db/migrations/0000_bootstrap_users.sql @@ -30,6 +30,13 @@ CREATE TABLE IF NOT EXISTS extensions ( CREATE INDEX IF NOT EXISTS idx_extensions_type ON extensions(type); CREATE INDEX IF NOT EXISTS idx_extensions_author ON extensions(author_id); +-- The former Extensions site migration already created this complete +-- projection (it was never a one-column placeholder). Keeping IF NOT EXISTS +-- here is what makes adoption data-preserving: the API chain reuses that +-- table and 0019 adds only the new tombstone column. Deployments should +-- inspect PRAGMA table_info(users) during the rollout backup; a database that +-- does not match this historical contract is not an Extensions production +-- database that this additive chain can safely infer or rebuild in SQL. CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY NOT NULL, name TEXT, diff --git a/src/services/extensions/v2/developer-profile-routes.ts b/src/services/extensions/v2/developer-profile-routes.ts index af01135..fd1d84b 100644 --- a/src/services/extensions/v2/developer-profile-routes.ts +++ b/src/services/extensions/v2/developer-profile-routes.ts @@ -1,6 +1,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { statusFromErrorCode, statusFromGithubErrorCode } from "./route-errors"; import { + ActiveAccountRequiredResponse, DeveloperProfileSchema, DeveloperSchema, ErrorResponseSchema, @@ -37,6 +38,7 @@ export function registerDeveloperProfileRoutes( content: { "application/json": { schema: ErrorResponseSchema } }, description: "Missing or invalid bearer token" }, + 403: ActiveAccountRequiredResponse, 500: { content: { "application/json": { schema: ErrorResponseSchema } }, description: "Database error" @@ -94,9 +96,9 @@ export function registerDeveloperProfileRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, + ...ActiveAccountRequiredResponse, description: - "This id matches a real GitHub organization or username that isn't linked to the caller's account" + "The account is inactive, or this id matches a real GitHub organization or username that isn't linked to the caller's account" }, 409: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -194,6 +196,7 @@ export function registerDeveloperProfileRoutes( content: { "application/json": { schema: ErrorResponseSchema } }, description: "Missing or invalid bearer token" }, + 403: ActiveAccountRequiredResponse, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, description: "Caller has no developer profile" @@ -252,6 +255,7 @@ export function registerDeveloperProfileRoutes( content: { "application/json": { schema: ErrorResponseSchema } }, description: "Missing or invalid bearer token" }, + 403: ActiveAccountRequiredResponse, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, description: "Caller has no developer profile" diff --git a/src/services/extensions/v2/developers-database.ts b/src/services/extensions/v2/developers-database.ts index 5a986f3..e5fe8f4 100644 --- a/src/services/extensions/v2/developers-database.ts +++ b/src/services/extensions/v2/developers-database.ts @@ -227,7 +227,7 @@ export class DevelopersDatabase { let githubUrlVerified: number | null = null; let githubVerificationNote: string | null = null; - let mainStmt; + let mainStmt: D1PreparedStatement; if (!existingOwn) { if (existingById) { // Distinct from the generic CONFLICT used elsewhere in this file — @@ -286,22 +286,39 @@ export class DevelopersDatabase { githubUrlVerified = check.githubUrlVerified; githubVerificationNote = check.note; - mainStmt = this.db.insert(developers).values({ - id: developer.id, - type: developer.type, - name: developer.name, - url: developer.URL ?? null, - avatarUrl: developer.avatar_url ?? null, - contactEmail: developer.contact_email ?? null, - ownerUserId: userId, - approvedAt: null, - githubOrgVerified, - githubUrlVerified, - githubVerificationNote, - githubVerifiedAt: - githubOrgVerified !== null ? sql`CURRENT_TIMESTAMP` : null, - createdAt: sql`CURRENT_TIMESTAMP`, - updatedAt: sql`CURRENT_TIMESTAMP` + // INSERT ... SELECT makes the active-account check part of the + // mutation itself. The middleware check is only an early rejection; + // a deletion can win between that check and this statement. + mainStmt = toD1Statement(this.db.$client, { + sql: `INSERT INTO developers ( + id, type, name, url, avatar_url, contact_email, + owner_user_id, approved_at, created_at, updated_at, + github_org_verified, + github_verification_note, github_verified_at, + github_url_verified + ) + SELECT ?, ?, ?, ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, ?, ?, + CASE WHEN ? IS NULL THEN NULL ELSE CURRENT_TIMESTAMP END, + ? + WHERE EXISTS ( + SELECT 1 FROM users + WHERE id = ? AND deleted_at IS NULL + )`, + params: [ + developer.id, + developer.type, + developer.name, + developer.URL ?? null, + developer.avatar_url ?? null, + developer.contact_email ?? null, + userId, + githubOrgVerified, + githubVerificationNote, + githubOrgVerified, + githubUrlVerified, + userId + ] }); } else { if (developer.id !== existingOwn.id) { @@ -341,7 +358,7 @@ export class DevelopersDatabase { const keepsApproval = !typeChanged && existingOwn.githubOrgVerified === 1; - mainStmt = this.db + const updateStmt = this.db .update(developers) .set({ type: developer.type, @@ -368,9 +385,14 @@ export class DevelopersDatabase { .where( and( eq(developers.id, developer.id), - eq(developers.ownerUserId, userId) + eq(developers.ownerUserId, userId), + sql`EXISTS ( + SELECT 1 FROM ${users} + WHERE ${users.id} = ${userId} AND ${users.deletedAt} IS NULL + )` ) ); + mainStmt = toD1Statement(this.db.$client, updateStmt.toSQL()); } // Batched via the raw D1 client ($client - see toD1Statement's @@ -398,10 +420,7 @@ export class DevelopersDatabase { let results; try { - results = await this.db.$client.batch([ - toD1Statement(this.db.$client, mainStmt.toSQL()), - historyStmt - ]); + results = await this.db.$client.batch([mainStmt, historyStmt]); } catch (error) { if (isOwnerConflict(error)) { return { @@ -557,8 +576,12 @@ export class DevelopersDatabase { WHERE extension_submissions.developer_id = developers.id AND extension_submissions.status = 'pending' ) + AND EXISTS ( + SELECT 1 FROM users active_user + WHERE active_user.id = ? AND active_user.deleted_at IS NULL + ) )`, - params: [developer.id, userId] + params: [developer.id, userId, userId] }); const deleteClaimsStmt = toD1Statement(this.db.$client, { @@ -574,8 +597,12 @@ export class DevelopersDatabase { WHERE extension_submissions.developer_id = developers.id AND extension_submissions.status = 'pending' ) + AND EXISTS ( + SELECT 1 FROM users active_user + WHERE active_user.id = ? AND active_user.deleted_at IS NULL + ) )`, - params: [developer.id, userId] + params: [developer.id, userId, userId] }); const deleteDeveloperStmt = toD1Statement(this.db.$client, { @@ -587,8 +614,12 @@ export class DevelopersDatabase { SELECT 1 FROM extension_submissions WHERE extension_submissions.developer_id = developers.id AND extension_submissions.status = 'pending' + ) + AND EXISTS ( + SELECT 1 FROM users active_user + WHERE active_user.id = ? AND active_user.deleted_at IS NULL )`, - params: [developer.id, userId] + params: [developer.id, userId, userId] }); let results; @@ -701,7 +732,11 @@ export class DevelopersDatabase { .where( and( eq(developers.id, id), - eq(developers.contentRevision, expectedRevision) + eq(developers.contentRevision, expectedRevision), + sql`EXISTS ( + SELECT 1 FROM ${users} + WHERE ${users.id} = ${reviewerId} AND ${users.deletedAt} IS NULL + )` ) ); } catch (error) { @@ -821,13 +856,27 @@ export class DevelopersDatabase { const revokeStmt = toD1Statement(this.db.$client, { sql: `UPDATE developer_transfers SET revoked_at = CURRENT_TIMESTAMP WHERE developer_id = ? AND accepted_at IS NULL AND revoked_at IS NULL - AND EXISTS (SELECT 1 FROM developers WHERE developers.id = developer_transfers.developer_id AND developers.owner_user_id = ?)`, - params: [developerId, userId] + AND EXISTS ( + SELECT 1 FROM developers + WHERE developers.id = developer_transfers.developer_id + AND developers.owner_user_id = ? + ) + AND EXISTS ( + SELECT 1 FROM users + WHERE users.id = ? AND users.deleted_at IS NULL + )`, + params: [developerId, userId, userId] }); const insertStmt = toD1Statement(this.db.$client, { sql: `INSERT INTO developer_transfers (id, developer_id, token_hash, created_by, expires_at) SELECT ?, ?, ?, ?, ? - WHERE EXISTS (SELECT 1 FROM developers WHERE id = ? AND owner_user_id = ?)`, + WHERE EXISTS ( + SELECT 1 FROM developers WHERE id = ? AND owner_user_id = ? + ) + AND EXISTS ( + SELECT 1 FROM users + WHERE users.id = ? AND users.deleted_at IS NULL + )`, params: [ crypto.randomUUID(), developerId, @@ -835,6 +884,7 @@ export class DevelopersDatabase { userId, expiresAt, developerId, + userId, userId ] }); @@ -871,6 +921,7 @@ export class DevelopersDatabase { UPDATE ${developerTransfers} SET revoked_at = CURRENT_TIMESTAMP WHERE developer_id = ${developerId} AND accepted_at IS NULL AND revoked_at IS NULL AND EXISTS (SELECT 1 FROM ${developers} WHERE developers.id = developer_transfers.developer_id AND developers.owner_user_id = ${userId}) + AND EXISTS (SELECT 1 FROM ${users} WHERE ${users.id} = ${userId} AND ${users.deletedAt} IS NULL) `); // Zero rows changed is ambiguous by itself (no pending transfer vs. @@ -933,8 +984,9 @@ export class DevelopersDatabase { const claimStmt = toD1Statement(this.db.$client, { sql: `UPDATE developer_transfers SET accepted_at = CURRENT_TIMESTAMP, accepted_by = ? WHERE token_hash = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > CURRENT_TIMESTAMP - AND NOT EXISTS (SELECT 1 FROM developers WHERE owner_user_id = ?)`, - params: [userId, tokenHash, userId] + AND NOT EXISTS (SELECT 1 FROM developers WHERE owner_user_id = ?) + AND EXISTS (SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL)`, + params: [userId, tokenHash, userId, userId] }); const updateDeveloperStmt = toD1Statement(this.db.$client, { // url_check_cooldown_until is reset here too — it's keyed by @@ -1256,6 +1308,10 @@ export class DevelopersDatabase { and( eq(developers.id, row.id), eq(developers.ownerUserId, userId), + sql`EXISTS ( + SELECT 1 FROM ${users} + WHERE ${users.id} = ${userId} AND ${users.deletedAt} IS NULL + )`, or( isNull(developers.urlCheckCooldownUntil), sql`${developers.urlCheckCooldownUntil} < CURRENT_TIMESTAMP` @@ -1370,6 +1426,10 @@ export class DevelopersDatabase { and( eq(developers.id, row.id), eq(developers.ownerUserId, userId), + sql`EXISTS ( + SELECT 1 FROM ${users} + WHERE ${users.id} = ${userId} AND ${users.deletedAt} IS NULL + )`, ...(writeUrlVerified ? [ row.url === null @@ -1493,6 +1553,7 @@ export class DevelopersDatabase { SELECT ${id}, ${developerId}, ${claimantId}, ${note ?? null}, ${githubOrgVerified}, ${githubVerificationNote} WHERE EXISTS (SELECT 1 FROM ${developers} WHERE id = ${developerId} AND owner_user_id IS NULL) AND NOT EXISTS (SELECT 1 FROM ${developers} WHERE owner_user_id = ${claimantId}) + AND EXISTS (SELECT 1 FROM ${users} WHERE ${users.id} = ${claimantId} AND ${users.deletedAt} IS NULL) `); } catch (error) { if (isPendingClaimConflict(error)) { @@ -1529,15 +1590,17 @@ export class DevelopersDatabase { ): Promise> { let result; try { - result = await this.db - .delete(developerClaims) - .where( - and( - eq(developerClaims.id, claimId), - eq(developerClaims.claimantId, claimantId), - eq(developerClaims.status, "pending") - ) - ); + result = await this.db.delete(developerClaims).where( + and( + eq(developerClaims.id, claimId), + eq(developerClaims.claimantId, claimantId), + eq(developerClaims.status, "pending"), + sql`EXISTS ( + SELECT 1 FROM ${users} + WHERE ${users.id} = ${claimantId} AND ${users.deletedAt} IS NULL + )` + ) + ); } catch (error) { return databaseError("cancelClaim", error); } @@ -1695,8 +1758,12 @@ export class DevelopersDatabase { AND NOT EXISTS ( SELECT 1 FROM developers owned WHERE owned.owner_user_id = developer_claims.claimant_id + ) + AND EXISTS ( + SELECT 1 FROM users + WHERE users.id = ? AND users.deleted_at IS NULL )`, - params: [reviewerId, claimId] + params: [reviewerId, claimId, reviewerId] }); const developerStmt = toD1Statement(this.db.$client, { sql: `UPDATE developers @@ -1790,7 +1857,11 @@ export class DevelopersDatabase { .where( and( eq(developerClaims.id, claimId), - eq(developerClaims.status, "pending") + eq(developerClaims.status, "pending"), + sql`EXISTS ( + SELECT 1 FROM ${users} + WHERE ${users.id} = ${reviewerId} AND ${users.deletedAt} IS NULL + )` ) ); } catch (error) { diff --git a/src/services/extensions/v2/extensions-database.ts b/src/services/extensions/v2/extensions-database.ts index 265784a..79adfff 100644 --- a/src/services/extensions/v2/extensions-database.ts +++ b/src/services/extensions/v2/extensions-database.ts @@ -225,6 +225,10 @@ function decodeCursor(value: string): ExtensionCursor | null { } } +export function isValidExtensionCursor(value: string): boolean { + return decodeCursor(value) !== null; +} + function parseExtensionRow(row: ExtensionRow): Extension { const releases = parseJSON(row.releases, []); return { diff --git a/src/services/extensions/v2/index.ts b/src/services/extensions/v2/index.ts index 46568d6..f3a2745 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -8,6 +8,7 @@ import { getExtensionsDb } from "../../../lib/db"; import { getPlatform } from "../../../lib/middleware"; import { UsersDatabase } from "./users-database"; import { registerPublicExtensionsRoutes } from "./public-extensions-routes"; +import { registerOwnerExtensionsRoutes } from "./owner-extensions-routes"; import { registerSubmissionRoutes } from "./submission-routes"; import { registerDeveloperProfileRoutes } from "./developer-profile-routes"; import { registerOwnershipRoutes } from "./ownership-routes"; @@ -46,6 +47,29 @@ function requireActiveAuth(): MiddlewareHandler { }; } +function requireIdentitySync(): MiddlewareHandler { + const authenticate = requireAuth(); + return async (c, next) => { + let response: Response | undefined; + const authenticationResult = await authenticate(c, async () => { + if (getAuth(c).scope !== "assertion") { + response = c.json( + { + error: { + message: "Identity synchronization requires a trusted assertion", + code: "FORBIDDEN" + } + }, + 403 + ); + return; + } + await next(); + }); + return response ?? authenticationResult; + }; +} + const extensionsV2 = new OpenAPIHono<{ Bindings: CloudflareBindings }>({ defaultHook: (result, c) => { if (!result.success) { @@ -95,9 +119,13 @@ const dependencies: RouteDependencies = { platform: getPlatform, requireAuth: requireActiveAuth, requireAuthAllowInactive, + requireIdentitySync, requireModerator }; +// Register the static owner route before the public parameter route +// (/extensions/{id}) so "mine" is never interpreted as an extension id. +registerOwnerExtensionsRoutes(extensionsV2, dependencies); registerPublicExtensionsRoutes(extensionsV2, dependencies); registerAccountRoutes(extensionsV2, dependencies); registerSubmissionRoutes(extensionsV2, dependencies); diff --git a/src/services/extensions/v2/interfaces.ts b/src/services/extensions/v2/interfaces.ts index b7a9388..a119083 100644 --- a/src/services/extensions/v2/interfaces.ts +++ b/src/services/extensions/v2/interfaces.ts @@ -40,7 +40,7 @@ const httpUrl = () => // instead — its public profile would be permanently unreachable there. // Rejecting these ids at creation time (rather than trying to route around // the collision) keeps every existing/future developer id resolvable. -const RESERVED_DEVELOPER_IDS = new Set(["claims", "unapproved"]); +export const RESERVED_DEVELOPER_IDS = new Set(["claims", "me", "unapproved"]); const developerId = () => lowercaseId("developer").refine((id) => !RESERVED_DEVELOPER_IDS.has(id), { @@ -262,6 +262,14 @@ export const ExtensionListQuerySchema = z.object({ }) }); +// The owner-scoped list has the same pagination and type filters as the +// public catalogue, but its developer is always taken from the authenticated +// user. Keeping a separate schema prevents OpenAPI from advertising a +// developer_id filter that this endpoint deliberately ignores. +export const ExtensionMineListQuerySchema = ExtensionListQuerySchema.omit({ + developer_id: true +}); + export const ExtensionListResponseSchema = z .object({ result: z.array(ExtensionListItemSchema), @@ -341,6 +349,14 @@ export const ErrorResponseSchema = z }) .openapi("Error"); +// All routes behind requireAuth() perform an active-account check after +// bearer authentication. Keep that response reusable so the generated +// contract documents the middleware failure consistently on every route. +export const ActiveAccountRequiredResponse = { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "The bearer is valid but the account is inactive" +} as const; + // The site remains responsible for OIDC and sessions. It sends only the // provider projection needed by the API-owned domain row; authorization // fields such as is_moderator are never accepted from this payload. diff --git a/src/services/extensions/v2/moderation-routes.ts b/src/services/extensions/v2/moderation-routes.ts index e5b19d9..43f81ea 100644 --- a/src/services/extensions/v2/moderation-routes.ts +++ b/src/services/extensions/v2/moderation-routes.ts @@ -1,6 +1,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import { statusFromErrorCode } from "./route-errors"; import { + ActiveAccountRequiredResponse, DeveloperApprovalSchema, DeveloperHistoryEntrySchema, DeveloperProfileSchema, @@ -49,8 +50,8 @@ export function registerModerationRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller is not a moderator" + ...ActiveAccountRequiredResponse, + description: "The account is inactive or the caller is not a moderator" }, 422: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -132,8 +133,8 @@ export function registerModerationRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller is not a moderator" + ...ActiveAccountRequiredResponse, + description: "The account is inactive or the caller is not a moderator" }, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -213,8 +214,8 @@ export function registerModerationRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller is not a moderator" + ...ActiveAccountRequiredResponse, + description: "The account is inactive or the caller is not a moderator" }, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -282,8 +283,8 @@ export function registerModerationRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller is not a moderator" + ...ActiveAccountRequiredResponse, + description: "The account is inactive or the caller is not a moderator" }, 500: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -335,8 +336,8 @@ export function registerModerationRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller is not a moderator" + ...ActiveAccountRequiredResponse, + description: "The account is inactive or the caller is not a moderator" }, 500: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -395,8 +396,8 @@ export function registerModerationRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller is not a moderator" + ...ActiveAccountRequiredResponse, + description: "The account is inactive or the caller is not a moderator" }, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -469,8 +470,8 @@ export function registerModerationRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller is not a moderator" + ...ActiveAccountRequiredResponse, + description: "The account is inactive or the caller is not a moderator" }, 422: { content: { "application/json": { schema: ErrorResponseSchema } }, diff --git a/src/services/extensions/v2/owner-extensions-routes.ts b/src/services/extensions/v2/owner-extensions-routes.ts new file mode 100644 index 0000000..046dad8 --- /dev/null +++ b/src/services/extensions/v2/owner-extensions-routes.ts @@ -0,0 +1,122 @@ +import { createRoute } from "@hono/zod-openapi"; +import { + ActiveAccountRequiredResponse, + ErrorResponseSchema, + ExtensionListResponseSchema, + ExtensionMineListQuerySchema +} from "./interfaces"; +import { DevelopersDatabase } from "./developers-database"; +import { + ExtensionsDatabase, + isValidExtensionCursor +} from "./extensions-database"; +import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; + +export function registerOwnerExtensionsRoutes( + app: ExtensionsV2App, + dependencies: RouteDependencies +): void { + const listMineRoute = createRoute({ + method: "get", + path: "/extensions/mine", + tags: ["Extensions"], + summary: "List extensions published under the caller's developer profile", + security: [{ Bearer: [] }], + middleware: [dependencies.requireAuth()] as const, + request: { query: ExtensionMineListQuerySchema }, + responses: { + 200: { + content: { + "application/json": { schema: ExtensionListResponseSchema } + }, + description: "The caller's published extensions" + }, + 401: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Missing or invalid bearer token" + }, + 403: ActiveAccountRequiredResponse, + 422: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Pagination query failed validation" + }, + 500: { + content: { "application/json": { schema: ErrorResponseSchema } }, + description: "Database error" + } + } + }); + + app.openapi(listMineRoute, async (c) => { + const auth = dependencies.auth(c); + const { type, limit, cursor } = c.req.valid("query"); + + // An account without a developer profile normally returns an empty page, + // but malformed cursors are still client errors and must be rejected + // before that early return. + if (cursor && !isValidExtensionCursor(cursor)) { + return c.json( + { + error: { + message: "Invalid pagination cursor", + code: "INVALID_CURSOR" + } + }, + 422 + ); + } + + const ownerDb = new DevelopersDatabase( + dependencies.database(c.env.DB_EXTENSIONS) + ); + const owner = await ownerDb.getOwn(auth.userId); + if (owner.error) { + return c.json( + { + error: { + message: owner.error.message, + code: owner.error.code ?? "DATABASE_ERROR" + } + }, + 500 + ); + } + if (!owner.data) { + return c.json( + { result: [], pagination: { next_cursor: null, has_more: false } }, + 200 + ); + } + + const db = new ExtensionsDatabase( + dependencies.database(c.env.DB_EXTENSIONS) + ); + const { data, error } = await db.list({ + type, + developerId: owner.data.id, + limit, + cursor + }); + if (error || !data) { + return c.json( + { + error: { + message: error?.message ?? "Unable to load extensions", + code: error?.code ?? "DATABASE_ERROR" + } + }, + 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/ownership-routes.ts b/src/services/extensions/v2/ownership-routes.ts index bf92fc0..a0ff85e 100644 --- a/src/services/extensions/v2/ownership-routes.ts +++ b/src/services/extensions/v2/ownership-routes.ts @@ -5,6 +5,7 @@ import { statusFromOwnershipErrorCode } from "./route-errors"; import { + ActiveAccountRequiredResponse, ClaimNoteSchema, DeveloperClaimSchema, DeveloperProfileSchema, @@ -53,9 +54,9 @@ export function registerOwnershipRoutes( description: "No developer with that id" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, + ...ActiveAccountRequiredResponse, description: - "Caller's linked GitHub account doesn't match this developer's GitHub organization or username" + "The account is inactive, or the caller's linked GitHub account doesn't match this developer's GitHub organization or username" }, 409: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -138,6 +139,7 @@ export function registerOwnershipRoutes( content: { "application/json": { schema: ErrorResponseSchema } }, description: "Missing or invalid bearer token" }, + 403: ActiveAccountRequiredResponse, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, description: "No pending claim with that id owned by the caller" @@ -195,6 +197,7 @@ export function registerOwnershipRoutes( content: { "application/json": { schema: ErrorResponseSchema } }, description: "Missing or invalid bearer token" }, + 403: ActiveAccountRequiredResponse, 500: { content: { "application/json": { schema: ErrorResponseSchema } }, description: "Database error" @@ -246,8 +249,8 @@ export function registerOwnershipRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller is not a moderator" + ...ActiveAccountRequiredResponse, + description: "The account is inactive or the caller is not a moderator" }, 500: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -301,8 +304,8 @@ export function registerOwnershipRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller is not a moderator" + ...ActiveAccountRequiredResponse, + description: "The account is inactive or the caller is not a moderator" }, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -375,8 +378,8 @@ export function registerOwnershipRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller is not a moderator" + ...ActiveAccountRequiredResponse, + description: "The account is inactive or the caller is not a moderator" }, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -439,8 +442,9 @@ export function registerOwnershipRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller does not own this profile" + ...ActiveAccountRequiredResponse, + description: + "The account is inactive or the caller does not own this profile" }, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -502,8 +506,9 @@ export function registerOwnershipRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller does not own this profile" + ...ActiveAccountRequiredResponse, + description: + "The account is inactive or the caller does not own this profile" }, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -566,6 +571,7 @@ export function registerOwnershipRoutes( content: { "application/json": { schema: ErrorResponseSchema } }, description: "Missing or invalid bearer token" }, + 403: ActiveAccountRequiredResponse, 404: { content: { "application/json": { schema: ErrorResponseSchema } }, description: "Transfer link is invalid, already used, or expired" diff --git a/src/services/extensions/v2/public-extensions-routes.ts b/src/services/extensions/v2/public-extensions-routes.ts index 4e05965..5c448d3 100644 --- a/src/services/extensions/v2/public-extensions-routes.ts +++ b/src/services/extensions/v2/public-extensions-routes.ts @@ -8,7 +8,6 @@ import { IdParamSchema } from "./interfaces"; import { ExtensionsDatabase } from "./extensions-database"; -import { DevelopersDatabase } from "./developers-database"; import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; export function registerPublicExtensionsRoutes( @@ -75,93 +74,6 @@ export function registerPublicExtensionsRoutes( ); }); - const listMineRoute = createRoute({ - method: "get", - path: "/extensions/mine", - tags: ["Extensions"], - summary: "List extensions published under the caller's developer profile", - security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, - request: { query: ExtensionListQuerySchema }, - responses: { - 200: { - content: { - "application/json": { schema: ExtensionListResponseSchema } - }, - description: "The caller's published extensions" - }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Pagination query failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } - } - }); - - app.openapi(listMineRoute, async (c) => { - const auth = dependencies.auth(c); - const { type, limit, cursor } = c.req.valid("query"); - const ownerDb = new DevelopersDatabase( - dependencies.database(c.env.DB_EXTENSIONS) - ); - const owner = await ownerDb.getOwn(auth.userId); - if (owner.error) { - return c.json( - { - error: { - message: owner.error.message, - code: owner.error.code ?? "DATABASE_ERROR" - } - }, - 500 - ); - } - if (!owner.data) { - return c.json( - { result: [], pagination: { next_cursor: null, has_more: false } }, - 200 - ); - } - - const db = new ExtensionsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) - ); - const { data, error } = await db.list({ - type, - developerId: owner.data.id, - limit, - cursor - }); - if (error || !data) { - return c.json( - { - error: { - message: error?.message ?? "Unable to load extensions", - code: error?.code ?? "DATABASE_ERROR" - } - }, - error?.code === "INVALID_CURSOR" ? 422 : 500 - ); - } - return c.json( - { - result: data.items, - pagination: { - next_cursor: data.nextCursor, - has_more: data.hasMore - } - }, - 200 - ); - }); - const getExtensionRoute = createRoute({ method: "get", path: "/extensions/{id}", diff --git a/src/services/extensions/v2/route-dependencies.ts b/src/services/extensions/v2/route-dependencies.ts index d721d6e..ff50026 100644 --- a/src/services/extensions/v2/route-dependencies.ts +++ b/src/services/extensions/v2/route-dependencies.ts @@ -17,5 +17,9 @@ export interface RouteDependencies { // user. Every other authenticated route uses requireAuth, which also // verifies that the caller still has an active user row. requireAuthAllowInactive: typeof requireAuth; + // Identity synchronization is a server-to-server projection update. Keep + // it restricted to the signed assertion verifier even if another bearer + // verifier (such as API keys) is added later. + requireIdentitySync: () => MiddlewareHandler; requireModerator: () => MiddlewareHandler; } diff --git a/src/services/extensions/v2/submission-routes.ts b/src/services/extensions/v2/submission-routes.ts index c1c4178..c1d9c2d 100644 --- a/src/services/extensions/v2/submission-routes.ts +++ b/src/services/extensions/v2/submission-routes.ts @@ -1,5 +1,6 @@ import { createRoute, z } from "@hono/zod-openapi"; import { + ActiveAccountRequiredResponse, ErrorResponseSchema, PaginationSchema, SubmissionPayloadSchema, @@ -41,8 +42,9 @@ export function registerSubmissionRoutes( description: "Missing or invalid bearer token" }, 403: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller does not own the target developer or extension" + ...ActiveAccountRequiredResponse, + description: + "The account is inactive, or the caller does not own the target developer or extension" }, 409: { content: { "application/json": { schema: ErrorResponseSchema } }, @@ -126,6 +128,7 @@ export function registerSubmissionRoutes( content: { "application/json": { schema: ErrorResponseSchema } }, description: "Missing or invalid bearer token" }, + 403: ActiveAccountRequiredResponse, 422: { content: { "application/json": { schema: ErrorResponseSchema } }, description: "Pagination query failed validation" diff --git a/src/services/extensions/v2/submissions-database.ts b/src/services/extensions/v2/submissions-database.ts index cd113c7..ac2e02a 100644 --- a/src/services/extensions/v2/submissions-database.ts +++ b/src/services/extensions/v2/submissions-database.ts @@ -1,7 +1,12 @@ import { and, asc, desc, eq, gt, lt, or, sql } from "drizzle-orm"; import { DatabaseResult } from "../../../lib/interfaces"; import { ExtensionsDb } from "../../../lib/db"; -import { extensionSubmissions, developers, extensions } from "./db/schema"; +import { + extensionSubmissions, + developers, + extensions, + users +} from "./db/schema"; import { databaseError, errorMessageChain } from "./errors"; import { toD1Statement } from "./d1-batch"; import { Submission, SubmissionPayload, SubmissionStatus } from "./interfaces"; @@ -187,6 +192,10 @@ export class SubmissionsDatabase { 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' @@ -404,7 +413,11 @@ export class SubmissionsDatabase { .where( and( eq(extensionSubmissions.id, id), - eq(extensionSubmissions.status, "pending") + eq(extensionSubmissions.status, "pending"), + sql`EXISTS ( + SELECT 1 FROM ${users} + WHERE ${users.id} = ${reviewerId} AND ${users.deletedAt} IS NULL + )` ) ); } catch (error) { @@ -468,6 +481,10 @@ export class SubmissionsDatabase { 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 @@ -480,7 +497,7 @@ export class SubmissionsDatabase { AND e.author_id = extension_submissions.developer_id )) )`, - params: [reviewerId, reviewNote ?? null, id, extension.id] + params: [reviewerId, reviewNote ?? null, id, reviewerId, extension.id] }); const developerStmt = toD1Statement(this.db.$client, { diff --git a/src/services/extensions/v2/users-database.ts b/src/services/extensions/v2/users-database.ts index 7d3422f..4118cd0 100644 --- a/src/services/extensions/v2/users-database.ts +++ b/src/services/extensions/v2/users-database.ts @@ -142,6 +142,7 @@ export class UsersDatabase { isModerator: active && row.isModerator === 1, githubLinked: active && + Boolean(row.githubLogin) && hasUsableGithubOrgs(row.githubOrgs, row.githubOrgsExpiresAt), deletedAt: row.deletedAt }, diff --git a/test/services/extensions/v2/index.test.ts b/test/services/extensions/v2/index.test.ts index 8bb8739..ce338d4 100644 --- a/test/services/extensions/v2/index.test.ts +++ b/test/services/extensions/v2/index.test.ts @@ -1693,6 +1693,33 @@ describe("Extensions API v2", () => { ).toHaveLength(1); }); + it("does not create a profile after the account is tombstoned mid-request", async () => { + const headers = await authHeaders("deleted-during-write"); + let tombstoned = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!tombstoned && sql.includes("INSERT INTO developers")) { + tombstoned = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "deleted-during-write") + .run(); + } + }); + + const res = await put( + "/extensions/v2/developers/me", + headers, + sampleDeveloper({ id: "deleted-during-write-profile" }) + ); + env.DB_EXTENSIONS = db; + + expect(tombstoned).toBe(true); + expect(res.status).toBe(409); + expect(await hasDeveloper(db, "deleted-during-write-profile")).toBe( + false + ); + }); + it("round-trips avatar_url and contact_email", async () => { const headers = await authHeaders("user-1"); const res = await put("/extensions/v2/developers/me", headers, { @@ -3930,6 +3957,22 @@ describe("Extensions API v2", () => { "/developers/claims/{id}/reject" ]) ); + + const paths = spec.paths as Record< + string, + { + get?: { + parameters?: Array<{ name?: string }>; + responses?: Record; + }; + patch?: { responses?: Record }; + } + >; + expect(paths["/extensions/mine"].get?.responses).toHaveProperty("403"); + expect( + paths["/extensions/mine"].get?.parameters?.map(({ name }) => name) + ).not.toContain("developer_id"); + expect(paths["/users/me"].patch?.responses).toHaveProperty("403"); }); it("serves the Scalar API reference UI", async () => { @@ -4000,6 +4043,46 @@ describe("Extensions API v2", () => { result: [{ id: "account-extension" }], pagination: { has_more: false, next_cursor: null } }); + + const filtered = await get( + "/extensions/v2/extensions/mine?developer_id=someone-else", + headers + ); + expect(filtered.status).toBe(200); + expect(await filtered.json()).toMatchObject({ + result: [{ id: "account-extension" }] + }); + }); + + it("validates a mine cursor before returning an empty owner page", async () => { + const res = await get( + "/extensions/v2/extensions/mine?cursor=not-a-cursor", + await authHeaders("no-developer") + ); + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ + error: { code: "INVALID_CURSOR" } + }); + }); + + it("only reports GitHub as linked when both login and fresh evidence exist", async () => { + const res = await put( + "/extensions/v2/users/me/identity", + await authHeaders("github-evidence-without-login"), + { + name: "No Login", + email: "no-login@example.com", + email_verified: true, + picture: null, + github_login: null, + github_orgs: ["fossbilling"], + github_orgs_expires_at: "2099-01-01T00:00:00.000Z" + } + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + result: { github_linked: false } + }); }); it("tombstones and later reactivates an account", async () => { From cccbaa7c01336753f192e38bfe492ad7c88dd6db Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 5 Aug 2026 11:29:37 +0100 Subject: [PATCH 3/7] Refactor authentication middleware checks --- src/services/extensions/v2/index.ts | 81 +++++++++++++++-------------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/src/services/extensions/v2/index.ts b/src/services/extensions/v2/index.ts index f3a2745..38322c0 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -2,7 +2,7 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import { Scalar } from "@scalar/hono-api-reference"; import { cors } from "hono/cors"; import { trimTrailingSlash } from "hono/trailing-slash"; -import { MiddlewareHandler } from "hono"; +import { type Context, type MiddlewareHandler } from "hono"; import { getAuth, requireAuth } from "../../../lib/auth"; import { getExtensionsDb } from "../../../lib/db"; import { getPlatform } from "../../../lib/middleware"; @@ -18,56 +18,57 @@ import { RouteDependencies } from "./route-dependencies"; const requireAuthAllowInactive = requireAuth; -function requireActiveAuth(): MiddlewareHandler { +type AuthenticatedCheck = (c: Context) => Promise; + +function withAuthenticatedCheck(check: AuthenticatedCheck): MiddlewareHandler { const authenticate = requireAuth(); return async (c, next) => { let response: Response | undefined; const authenticationResult = await authenticate(c, async () => { - const users = new UsersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); - const result = await users.isActive(getAuth(c).userId); - if (result.error) { - response = c.json({ error: result.error }, 500); - return; + const checkResponse = await check(c); + if (checkResponse) { + response = checkResponse; + } else { + await next(); } - if (!result.data) { - response = c.json( - { - error: { - message: "Active account required", - code: "ACCOUNT_INACTIVE" - } - }, - 403 - ); - return; - } - await next(); }); return response ?? authenticationResult; }; } +function requireActiveAuth(): MiddlewareHandler { + return withAuthenticatedCheck(async (c) => { + const users = new UsersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); + const result = await users.isActive(getAuth(c).userId); + if (result.error) return c.json({ error: result.error }, 500); + if (!result.data) { + return c.json( + { + error: { + message: "Active account required", + code: "ACCOUNT_INACTIVE" + } + }, + 403 + ); + } + }); +} + function requireIdentitySync(): MiddlewareHandler { - const authenticate = requireAuth(); - return async (c, next) => { - let response: Response | undefined; - const authenticationResult = await authenticate(c, async () => { - if (getAuth(c).scope !== "assertion") { - response = c.json( - { - error: { - message: "Identity synchronization requires a trusted assertion", - code: "FORBIDDEN" - } - }, - 403 - ); - return; - } - await next(); - }); - return response ?? authenticationResult; - }; + return withAuthenticatedCheck(async (c) => { + if (getAuth(c).scope !== "assertion") { + return c.json( + { + error: { + message: "Identity synchronization requires a trusted assertion", + code: "FORBIDDEN" + } + }, + 403 + ); + } + }); } const extensionsV2 = new OpenAPIHono<{ Bindings: CloudflareBindings }>({ From f66c69b09b3a6b817c0ea7815d86960909c2b03d Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 5 Aug 2026 13:10:36 +0100 Subject: [PATCH 4/7] Address API review follow-ups --- README.md | 19 ++ .../migrations/0002_add_author_approval.sql | 2 +- src/services/extensions/v2/index.ts | 6 +- src/services/extensions/v2/interfaces.ts | 26 ++- test/services/extensions/v2/index.test.ts | 14 +- .../services/extensions/v2/migrations.test.ts | 196 ++++++++++++++++++ vitest.config.ts | 6 +- vitest.node.config.ts | 5 +- 8 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 test/services/extensions/v2/migrations.test.ts diff --git a/README.md b/README.md index 79aef44..3d02142 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,30 @@ We use [Cloudflare D1](https://developers.cloudflare.com/d1/) and [KV](https://d database without replacing rows; `0019` then adds the API-owned tombstone column. Back up the database and inspect `PRAGMA table_info(users)` before adoption, as with any schema ownership change. + + The v2 API reserves the static route segments `mine` for extensions and + `me` (along with `claims` and `unapproved`) for developer routes. Before + deploying the route changes, run this read-only preflight against the backed + up/production database and stop the rollout if either query returns a row: + + ```sql + SELECT id FROM extensions WHERE lower(id) = 'mine'; + SELECT id FROM developers WHERE lower(id) IN ('claims', 'me', 'unapproved'); + ``` + + A returned row needs an explicitly reviewed data migration or an alternate + route before deployment; the API deliberately does not rename existing + catalogue or developer records as part of an additive migration. + - **KV Namespace** (`CACHE_KV`): Caches GitHub API responses so we don't hit rate limits. - **KV Namespace** (`AUTH_KV`): Stores the `UPDATE_TOKEN` value for `/versions/v1/update`. ### Environment Variables - `GITHUB_TOKEN`: A GitHub Personal Access Token (classic) with public repo read access. +- `ASSERTION_SIGNING_SECRET`: Shared HMAC secret used to verify the short-lived + bearer assertions minted by the Extensions site. Configure the same value + in both Workers; it is never sent to clients. ## Development @@ -84,6 +102,7 @@ npm install ```env GITHUB_TOKEN="your-token" + ASSERTION_SIGNING_SECRET="local-shared-secret" ``` 2. Apply migrations to the local D1 databases: diff --git a/src/services/extensions/v2/db/migrations/0002_add_author_approval.sql b/src/services/extensions/v2/db/migrations/0002_add_author_approval.sql index 1e93ead..6e38807 100644 --- a/src/services/extensions/v2/db/migrations/0002_add_author_approval.sql +++ b/src/services/extensions/v2/db/migrations/0002_add_author_approval.sql @@ -4,7 +4,7 @@ -- SQLite's ALTER TABLE ADD COLUMN rejects non-constant defaults (including -- CURRENT_TIMESTAMP), so created_at/updated_at are added with a placeholder -- default and backfilled immediately after. New rows always set these --- explicitly (see authors-database.ts), so the placeholder is never seen +-- explicitly (see developers-database.ts), so the placeholder is never seen -- outside of this migration. ALTER TABLE authors ADD COLUMN approved_at TEXT; diff --git a/src/services/extensions/v2/index.ts b/src/services/extensions/v2/index.ts index 38322c0..8456117 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -125,7 +125,9 @@ const dependencies: RouteDependencies = { }; // Register the static owner route before the public parameter route -// (/extensions/{id}) so "mine" is never interpreted as an extension id. +// (/extensions/{id}) so the reserved "mine" segment is handled as the +// owner collection. The deployment preflight in README.md must reject any +// pre-existing extension with that id before this route is enabled. registerOwnerExtensionsRoutes(extensionsV2, dependencies); registerPublicExtensionsRoutes(extensionsV2, dependencies); registerAccountRoutes(extensionsV2, dependencies); @@ -134,6 +136,8 @@ registerOwnershipRoutes(extensionsV2, dependencies); registerModerationRoutes(extensionsV2, dependencies); // Keep this last: its GET /developers/{id} parameter route would otherwise // shadow static GET /developers/* routes registered by the modules above. +// The "me" namespace is reserved for the owner profile route; the rollout +// preflight must reject a pre-existing developer with that id. registerDeveloperProfileRoutes(extensionsV2, dependencies); extensionsV2.doc31("/openapi.json", { diff --git a/src/services/extensions/v2/interfaces.ts b/src/services/extensions/v2/interfaces.ts index a119083..2b0c1a7 100644 --- a/src/services/extensions/v2/interfaces.ts +++ b/src/services/extensions/v2/interfaces.ts @@ -35,11 +35,11 @@ const httpUrl = () => }); // GET /developers/{id} is registered after the static single-segment -// GET /developers/* routes (claims, unapproved), so a developer whose id +// GET /developers/* routes (claims, me, unapproved), so a developer whose id // literally matched one of those words would always hit the static route -// instead — its public profile would be permanently unreachable there. -// Rejecting these ids at creation time (rather than trying to route around -// the collision) keeps every existing/future developer id resolvable. +// instead. Rejecting these ids at creation time keeps new profiles +// resolvable; the deployment checklist also preflights existing data because +// route reservations cannot rename a row that is already in production. export const RESERVED_DEVELOPER_IDS = new Set(["claims", "me", "unapproved"]); const developerId = () => @@ -47,6 +47,13 @@ const developerId = () => message: "This developer id is reserved" }); +// 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. Existing databases must be +// checked for this id before enabling the route (see the README rollout +// preflight); this schema cannot safely rename production catalogue rows. +export const RESERVED_EXTENSION_IDS = new Set(["mine"]); + export const DeveloperSchema = z .object({ id: developerId(), @@ -131,6 +138,13 @@ export const SubmissionPayloadSchema = z }) .strict() .superRefine((payload, ctx) => { + if (RESERVED_EXTENSION_IDS.has(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({ @@ -184,8 +198,8 @@ export type DeveloperProfile = z.infer; // DeveloperProfile except contact_email/content_revision (moderator/owner // only), the GitHub verification signal (a moderator-review aid, not meant // for public consumption), and the owner's identity (only ever an -// `unclaimed` boolean is public — see src/lib/database.ts in the extensions -// repo). +// `unclaimed` boolean is public). The Extensions site consumes this +// projection through the generated API client. export const PublicDeveloperSchema = DeveloperProfileSchema.omit({ contact_email: true, content_revision: true, diff --git a/test/services/extensions/v2/index.test.ts b/test/services/extensions/v2/index.test.ts index ce338d4..405d040 100644 --- a/test/services/extensions/v2/index.test.ts +++ b/test/services/extensions/v2/index.test.ts @@ -313,6 +313,18 @@ describe("Extensions API v2", () => { 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"); @@ -1488,7 +1500,7 @@ describe("Extensions API v2", () => { expect(await hasDeveloper(db, "acme-org")).toBe(false); }); - it.each(["claims", "unapproved"])( + it.each(["claims", "me", "unapproved"])( "rejects the reserved id %s", async (id) => { const res = await put( diff --git a/test/services/extensions/v2/migrations.test.ts b/test/services/extensions/v2/migrations.test.ts new file mode 100644 index 0000000..1fe2e54 --- /dev/null +++ b/test/services/extensions/v2/migrations.test.ts @@ -0,0 +1,196 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const migrationsDirectory = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../src/services/extensions/v2/db/migrations" +); + +const migrationNames = readdirSync(migrationsDirectory) + .filter((name) => /^\d{4}_.*\.sql$/.test(name)) + .sort(); + +function migration(name: string): string { + return readFileSync(join(migrationsDirectory, name), "utf8"); +} + +function columnNames(db: DatabaseSync, table: string): string[] { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ + name: string; + }>; + return rows.map((row) => row.name); +} + +// This is the users table created by the former Extensions site migration. +// The API's 0000 bootstrap must be able to run after this table already exists +// without replacing its rows or narrowing its identity projection. +const historicalUsersSchema = ` + CREATE TABLE users ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT, + email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + picture TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + is_moderator INTEGER NOT NULL DEFAULT 0, + display_name TEXT, + github_login TEXT, + github_orgs TEXT, + github_orgs_expires_at TEXT + ); +`; + +describe("Extensions D1 migrations", () => { + it("upgrades the split-owned schema without losing users or domain references", () => { + const db = new DatabaseSync(":memory:"); + + try { + db.exec("PRAGMA foreign_keys = ON;"); + db.exec(historicalUsersSchema); + db.prepare( + `INSERT INTO users ( + id, name, email, email_verified, picture, created_at, updated_at, + is_moderator, display_name, github_login, github_orgs, + github_orgs_expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "legacy-user", + "Legacy User", + "legacy@example.com", + 1, + "https://example.com/avatar.png", + "2026-01-01T00:00:00.000Z", + "2026-01-02T00:00:00.000Z", + 0, + "Legacy", + "legacy-github", + '["legacy-org"]', + "2030-01-01T00:00:00.000Z" + ); + + // 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. + for (const name of migrationNames.filter( + (candidate) => candidate !== "0019_add_user_deleted_at.sql" + )) { + db.exec(migration(name)); + } + + expect(columnNames(db, "users")).not.toContain("deleted_at"); + + db.prepare( + `INSERT INTO developers (id, type, name, url, owner_user_id) + VALUES (?, ?, ?, ?, ?)` + ).run( + "legacy-developer", + "user", + "Legacy Developer", + null, + "legacy-user" + ); + db.prepare( + `INSERT INTO extensions ( + id, type, author_id, name, description, releases, website, license, + icon_url, readme, source, version, download_url + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "legacy-extension", + "mod", + "legacy-developer", + "Legacy Extension", + "description", + "[]", + "https://example.com", + '{"name":"MIT"}', + null, + "# Legacy", + '{"type":"github","repo":"example/legacy"}', + "1.0.0", + "https://example.com/legacy.zip" + ); + db.prepare( + `INSERT INTO extension_submissions ( + id, extension_id, developer_id, submitted_by, status, payload, + target_key + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + "legacy-submission", + "legacy-extension", + "legacy-developer", + "legacy-user", + "pending", + '{"developer":{"id":"legacy-developer"},"extension":{"id":"legacy-extension"}}', + "legacy-extension" + ); + db.prepare( + `INSERT INTO developer_history ( + id, developer_id, type, name, url, changed_by + ) VALUES (?, ?, ?, ?, ?, ?)` + ).run( + "legacy-history", + "legacy-developer", + "user", + "Legacy Developer", + null, + "legacy-user" + ); + + db.exec(migration("0019_add_user_deleted_at.sql")); + + expect(columnNames(db, "users")).toEqual([ + "id", + "name", + "email", + "email_verified", + "picture", + "created_at", + "updated_at", + "is_moderator", + "display_name", + "github_login", + "github_orgs", + "github_orgs_expires_at", + "deleted_at" + ]); + expect( + db + .prepare( + "SELECT id, name, email, github_login, deleted_at FROM users WHERE id = ?" + ) + .get("legacy-user") + ).toEqual({ + id: "legacy-user", + name: "Legacy User", + email: "legacy@example.com", + github_login: "legacy-github", + deleted_at: null + }); + expect( + db + .prepare("SELECT owner_user_id FROM developers WHERE id = ?") + .get("legacy-developer") + ).toEqual({ owner_user_id: "legacy-user" }); + expect( + db + .prepare( + "SELECT submitted_by FROM extension_submissions WHERE id = ?" + ) + .get("legacy-submission") + ).toEqual({ submitted_by: "legacy-user" }); + expect( + db + .prepare("SELECT changed_by FROM developer_history WHERE id = ?") + .get("legacy-history") + ).toEqual({ changed_by: "legacy-user" }); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + } finally { + db.close(); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7592875..eb19fce 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,7 +47,11 @@ export default defineConfig({ // the test file's own module graph rather than a separate setupFiles one. // Exclude Node.js tests from Cloudflare Workers environment - exclude: ["**/node_modules/**", "**/test/lib/adapters/node/**"], + exclude: [ + "**/node_modules/**", + "**/test/lib/adapters/node/**", + "**/test/services/extensions/v2/migrations.test.ts" + ], // Test timeout configuration testTimeout: 30000, // 30 seconds max per test diff --git a/vitest.node.config.ts b/vitest.node.config.ts index d6943d9..d127eb8 100644 --- a/vitest.node.config.ts +++ b/vitest.node.config.ts @@ -3,7 +3,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["test/lib/adapters/node/**/*.test.ts"], + include: [ + "test/lib/adapters/node/**/*.test.ts", + "test/services/extensions/v2/migrations.test.ts" + ], testTimeout: 10000, // Code coverage configuration From 8de90f77dd3695cd3a591440a8d5dd2e1609d707 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 5 Aug 2026 13:17:35 +0100 Subject: [PATCH 5/7] Trim rollout details from README --- README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/README.md b/README.md index 3d02142..25d53a1 100644 --- a/README.md +++ b/README.md @@ -64,20 +64,6 @@ We use [Cloudflare D1](https://developers.cloudflare.com/d1/) and [KV](https://d column. Back up the database and inspect `PRAGMA table_info(users)` before adoption, as with any schema ownership change. - The v2 API reserves the static route segments `mine` for extensions and - `me` (along with `claims` and `unapproved`) for developer routes. Before - deploying the route changes, run this read-only preflight against the backed - up/production database and stop the rollout if either query returns a row: - - ```sql - SELECT id FROM extensions WHERE lower(id) = 'mine'; - SELECT id FROM developers WHERE lower(id) IN ('claims', 'me', 'unapproved'); - ``` - - A returned row needs an explicitly reviewed data migration or an alternate - route before deployment; the API deliberately does not rename existing - catalogue or developer records as part of an additive migration. - - **KV Namespace** (`CACHE_KV`): Caches GitHub API responses so we don't hit rate limits. - **KV Namespace** (`AUTH_KV`): Stores the `UPDATE_TOKEN` value for `/versions/v1/update`. From 3d53bd9c0fcc5658f5587ae7afa48e7039bf7f93 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 5 Aug 2026 14:43:09 +0100 Subject: [PATCH 6/7] Guard legacy submissions from reserved ids --- src/services/extensions/v2/index.ts | 8 +++--- src/services/extensions/v2/interfaces.ts | 14 +++++++---- .../extensions/v2/submissions-database.ts | 23 ++++++++++++++++- test/services/extensions/v2/index.test.ts | 25 +++++++++++++++++++ 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/services/extensions/v2/index.ts b/src/services/extensions/v2/index.ts index 8456117..de4c5d8 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -126,8 +126,8 @@ const dependencies: RouteDependencies = { // Register the static owner route before the public parameter route // (/extensions/{id}) so the reserved "mine" segment is handled as the -// owner collection. The deployment preflight in README.md must reject any -// pre-existing extension with that id before this route is enabled. +// owner collection. Existing rows require a one-time release data check +// before this route is enabled; new submissions reject the reserved id. registerOwnerExtensionsRoutes(extensionsV2, dependencies); registerPublicExtensionsRoutes(extensionsV2, dependencies); registerAccountRoutes(extensionsV2, dependencies); @@ -136,8 +136,8 @@ registerOwnershipRoutes(extensionsV2, dependencies); registerModerationRoutes(extensionsV2, dependencies); // Keep this last: its GET /developers/{id} parameter route would otherwise // shadow static GET /developers/* routes registered by the modules above. -// The "me" namespace is reserved for the owner profile route; the rollout -// preflight must reject a pre-existing developer with that id. +// The "me" namespace is reserved for the owner profile route; existing rows +// require the same one-time release data check before enabling the route. registerDeveloperProfileRoutes(extensionsV2, dependencies); extensionsV2.doc31("/openapi.json", { diff --git a/src/services/extensions/v2/interfaces.ts b/src/services/extensions/v2/interfaces.ts index 2b0c1a7..1a9a3fd 100644 --- a/src/services/extensions/v2/interfaces.ts +++ b/src/services/extensions/v2/interfaces.ts @@ -38,8 +38,8 @@ const httpUrl = () => // GET /developers/* routes (claims, me, unapproved), so a developer whose id // literally matched one of those words would always hit the static route // instead. Rejecting these ids at creation time keeps new profiles -// resolvable; the deployment checklist also preflights existing data because -// route reservations cannot rename a row that is already in production. +// resolvable; existing databases need a one-time release check because route +// reservations cannot rename a row that is already in production. export const RESERVED_DEVELOPER_IDS = new Set(["claims", "me", "unapproved"]); const developerId = () => @@ -50,10 +50,14 @@ const developerId = () => // 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. Existing databases must be -// checked for this id before enabling the route (see the README rollout -// preflight); this schema cannot safely rename production catalogue rows. +// checked for this id before enabling the route; this schema cannot safely +// rename production catalogue rows. export const RESERVED_EXTENSION_IDS = new Set(["mine"]); +export function isReservedExtensionId(id: string): boolean { + return RESERVED_EXTENSION_IDS.has(id.toLowerCase()); +} + export const DeveloperSchema = z .object({ id: developerId(), @@ -138,7 +142,7 @@ export const SubmissionPayloadSchema = z }) .strict() .superRefine((payload, ctx) => { - if (RESERVED_EXTENSION_IDS.has(payload.extension.id)) { + if (isReservedExtensionId(payload.extension.id)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "This extension id is reserved", diff --git a/src/services/extensions/v2/submissions-database.ts b/src/services/extensions/v2/submissions-database.ts index ac2e02a..c3571bf 100644 --- a/src/services/extensions/v2/submissions-database.ts +++ b/src/services/extensions/v2/submissions-database.ts @@ -9,7 +9,12 @@ import { } from "./db/schema"; import { databaseError, errorMessageChain } from "./errors"; import { toD1Statement } from "./d1-batch"; -import { Submission, SubmissionPayload, SubmissionStatus } from "./interfaces"; +import { + isReservedExtensionId, + Submission, + SubmissionPayload, + SubmissionStatus +} from "./interfaces"; interface OwnershipResolution { extensionId: string | null; @@ -458,6 +463,22 @@ export class SubmissionsDatabase { 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. + if ( + isReservedExtensionId(extension.id) || + isReservedExtensionId(extensionId) + ) { + return { + data: null, + error: { + message: "This extension 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 diff --git a/test/services/extensions/v2/index.test.ts b/test/services/extensions/v2/index.test.ts index 405d040..f6c55de 100644 --- a/test/services/extensions/v2/index.test.ts +++ b/test/services/extensions/v2/index.test.ts @@ -716,6 +716,31 @@ describe("Extensions API v2", () => { expect(await countExtensions(db)).toBe(0); }); + it("does not approve a legacy pending submission with a reserved extension id", 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 approved = await post( + "/extensions/v2/submissions/legacy-mine-submission/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); + }); + it("leaves the submission pending if the extension write-through fails mid-batch", async () => { await insertUser(db, { id: "mod-1", is_moderator: 1 }); await seedDeveloper("new-developer", "user-1"); From 62b3d4ace3498357770bb6915a1d1f5f935c74f5 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 5 Aug 2026 16:36:15 +0100 Subject: [PATCH 7/7] Upgrade rolldown and modernize ESM usage --- package-lock.json | 126 +++++++++++++++++++------------------- vitest.config.ts | 4 +- worker-configuration.d.ts | 4 +- 3 files changed, 67 insertions(+), 67 deletions(-) diff --git a/package-lock.json b/package-lock.json index 368e1a1..81596ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2276,9 +2276,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", "dev": true, "license": "MIT", "funding": { @@ -2328,9 +2328,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", - "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", "cpu": [ "arm64" ], @@ -2345,9 +2345,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", - "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", "cpu": [ "arm64" ], @@ -2362,9 +2362,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", - "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", "cpu": [ "x64" ], @@ -2379,9 +2379,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", - "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", "cpu": [ "x64" ], @@ -2396,9 +2396,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", - "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", "cpu": [ "arm" ], @@ -2413,9 +2413,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", - "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", "cpu": [ "arm64" ], @@ -2433,9 +2433,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", - "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", "cpu": [ "arm64" ], @@ -2453,9 +2453,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", - "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", "cpu": [ "ppc64" ], @@ -2473,9 +2473,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", - "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", "cpu": [ "s390x" ], @@ -2493,9 +2493,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", - "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", "cpu": [ "x64" ], @@ -2513,9 +2513,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", - "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", "cpu": [ "x64" ], @@ -2533,9 +2533,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", - "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", "cpu": [ "arm64" ], @@ -2550,9 +2550,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", - "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", "cpu": [ "arm64" ], @@ -2567,9 +2567,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", - "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", "cpu": [ "x64" ], @@ -5409,13 +5409,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", - "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.142.0", + "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5425,20 +5425,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.2", - "@rolldown/binding-darwin-arm64": "1.2.2", - "@rolldown/binding-darwin-x64": "1.2.2", - "@rolldown/binding-freebsd-x64": "1.2.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", - "@rolldown/binding-linux-arm64-gnu": "1.2.2", - "@rolldown/binding-linux-arm64-musl": "1.2.2", - "@rolldown/binding-linux-ppc64-gnu": "1.2.2", - "@rolldown/binding-linux-s390x-gnu": "1.2.2", - "@rolldown/binding-linux-x64-gnu": "1.2.2", - "@rolldown/binding-linux-x64-musl": "1.2.2", - "@rolldown/binding-openharmony-arm64": "1.2.2", - "@rolldown/binding-win32-arm64-msvc": "1.2.2", - "@rolldown/binding-win32-x64-msvc": "1.2.2" + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, "node_modules/semver": { diff --git a/vitest.config.ts b/vitest.config.ts index eb19fce..ac3ebcc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,10 +12,10 @@ import { defineConfig } from "vitest/config"; // config files are ESM) avoids fighting defineConfig's overload typing for // an async factory function. const extensionsMigrations = await readD1Migrations( - path.join(__dirname, "src/services/extensions/v2/db/migrations") + path.join(import.meta.dirname, "src/services/extensions/v2/db/migrations") ); const centralAlertsMigrations = await readD1Migrations( - path.join(__dirname, "src/services/central-alerts/v1/db/migrations") + path.join(import.meta.dirname, "src/services/central-alerts/v1/db/migrations") ); export default defineConfig({ diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index ee0e479..f161eef 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -10342,7 +10342,7 @@ type AIGatewayHeaders = { [key: string]: string | number | boolean | object; }; type AIGatewayUniversalRequest = { - provider: AIGatewayProviders | string; // eslint-disable-line + provider: AIGatewayProviders | string; endpoint: string; headers: Partial; query: unknown; @@ -10359,7 +10359,7 @@ declare abstract class AiGateway { extraHeaders?: object; signal?: AbortSignal; }): Promise; - getUrl(provider?: AIGatewayProviders | string): Promise; // eslint-disable-line + getUrl(provider?: AIGatewayProviders | string): Promise; } // Copyright (c) 2022-2025 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: