diff --git a/AGENTS.md b/AGENTS.md index 038afc9..3d919dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,11 +3,42 @@ ## Project Structure & Module Organization - `src/app/index.ts` is the worker entrypoint and route wiring for Hono. -- Feature logic lives in `src/services/` (for example `central-alerts/v1`, `versions/v1`, `stats/v1`) and should stay runtime-agnostic. +- Feature logic lives in `src/services/` and should stay runtime-agnostic. - Platform interfaces and adapters are in `src/lib/` with Cloudflare and Node implementations under `src/lib/adapters/`. - Tests mirror the source layout under `test/`, with shared helpers in `test/utils/` and mocks in `test/mocks/`. - Runtime/config files include `wrangler.jsonc`, `worker-configuration.d.ts`, `tsconfig.json`, `eslint.config.ts`, and `prettier.config.ts`. +### Service layout + +Small services are flat: `index.ts`, `interfaces.ts`, optionally `database.ts` +and `db/` (see `central-alerts/v1`, `versions/v1`, `stats/v1`). + +`extensions/v2` is the reference layout for anything larger, and new services +should grow into it rather than inventing a third shape: + +- `index.ts` — app assembly only: middleware, route registration, OpenAPI + document. Route registration order is load-bearing where static paths must + beat parameter paths; those cases carry comments. +- `middleware.ts` — service-specific Hono middleware. +- `routes/` — one module per route group, each exporting `register*Routes(app)`. + `routes/errors.ts` maps domain error codes to HTTP status; `routes/app.ts` + holds the typed app alias. +- `db/` — `schema.ts`, `migrations/`, one `*Database` class per workflow, plus + `errors.ts` (D1 constraint classification) and `batch.ts`. +- `schemas/` — zod/OpenAPI contract split by domain. There is deliberately **no + barrel**: import from `schemas/` directly so a module's dependencies + are visible. This is why `extensions/v2` has no `interfaces.ts`. +- `github/` — outbound GitHub calls, kept out of the persistence modules. + +Route modules import `getExtensionsDb`/`getAuth`/`getPlatform` and middleware +directly. There is no dependency-injection container; tests drive the real app +through `app.request`. + +Each service documents its own contract and operational detail in its own +`README.md` (`src/services///README.md`). Keep API behaviour +there rather than here or in the root README: this file is for conventions that +apply when modifying the code. + ## Build, Test, and Development Commands - `npm install`: install dependencies. @@ -49,12 +80,3 @@ `ASSERTION_SIGNING_SECRET="..."` to `.dev.vars` for local dev; set via `wrangler secret put ASSERTION_SIGNING_SECRET` in production, matching the value configured in the extensions site's Worker. - -## Stats API v1 - -- Provides release statistics visualization for FOSSBilling versions. -- HTML endpoint: `GET /stats/v1/` - Returns a client-side rendered page with Chart.js visualizations. -- Data endpoint: `GET /stats/v1/data` - Returns aggregated statistics data for the charts. -- Charts include: Release Size Graph (line), PHP Version Requirements (line), Patches Per Release (bar), and Releases Per Year (bar). -- Stats data is cached with a TTL of 24 hours and reuses release data from the versions service. -- Service follows the same caching patterns as versions API, including graceful handling of GitHub API errors. diff --git a/README.md b/README.md index d443faf..8096ea6 100644 --- a/README.md +++ b/README.md @@ -15,49 +15,34 @@ The worker exposes three main services: 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`. + Owns the complete Extensions domain and its `DB_EXTENSIONS` schema. 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`. + See [`src/services/extensions/v2/README.md`](src/services/extensions/v2/README.md). ## Architecture We've structured the app to separate the core logic from the specific runtime environment (Cloudflare, Node, etc.). - **Application Logic**: Found in `src/services/versions/v1`, `src/services/central-alerts/v1`, etc. These feature modules don't know they are running on Cloudflare. + Smaller services are a flat `index.ts` + `interfaces.ts`; `src/services/extensions/v2` is the reference layout for larger ones, splitting into `routes/`, `db/`, `schemas/`, and `github/`. + See `AGENTS.md` for what belongs in each. - **Platform Layer**: Located in `src/lib`. This defines interfaces for things like Cache, Database, and Environment variables. - **Adapters**: -- `src/lib/adapters/cloudflare`: Real implementations using KV and D1. -- `src/lib/adapters/node`: Reference implementations (useful for testing or alternative deployments). + - `src/lib/adapters/cloudflare`: Real implementations using KV and D1. + - `src/lib/adapters/node`: Reference implementations (useful for testing or alternative deployments). ## APIs -### Versions (`/versions/v1`) +Each service documents its own endpoints and behaviour: -- `GET /versions/v1` - List all releases. -- `GET /versions/v1/:version` - Get details for a specific version (e.g. `1.0.0`); use `latest` to get the newest release. -- `GET /versions/v1/build_changelog/:current` - Generates a consolidated changelog for all releases greater than `:current` (in semantic version order). -- `GET /versions/v1/update` - Refreshes the releases cache. Requires bearer token authentication using `Authorization: Bearer `. +| Service | Base path | Docs | +| -------------- | ---------------------------------- | -------------------------------------------------------------------------------------- | +| Versions | `/versions/v1` | [`src/services/versions/v1/README.md`](src/services/versions/v1/README.md) | +| Central Alerts | `/central-alerts/v1` | [`src/services/central-alerts/v1/README.md`](src/services/central-alerts/v1/README.md) | +| Stats | `/stats/v1` | [`src/services/stats/v1/README.md`](src/services/stats/v1/README.md) | +| Extensions | `/extensions/v1`, `/extensions/v2` | [`src/services/extensions/v2/README.md`](src/services/extensions/v2/README.md) | -All version responses include a `stale` field that indicates whether the data was served from cache after a failed fetch. - -### Central Alerts (`/central-alerts/v1`) - -- `GET /central-alerts/v1/list` - Public endpoint for fetching active alerts. - -### Extensions v2 ownership verification - -For organization developer IDs, GitHub membership is used for automatic -verification only when the API has a valid, unexpired membership snapshot. A -fresh snapshot that does not contain the organization remains a confirmed -mismatch and is rejected. Missing, malformed, or expired evidence is -inconclusive instead: a new profile remains unapproved and a claim remains -pending for manual moderator review. Moderators must verify ownership through -their normal out-of-band process before approving either workflow. - -`github_org_verified` being absent or `null` is a review signal, not proof of -ownership or an authorization grant. Consumers and moderation tooling must not -treat an inconclusive result as verified. +Extensions v2 also publishes a live OpenAPI document at `/extensions/v2/openapi.json` and a reference UI at `/extensions/v2/docs`. ## Configuration @@ -68,40 +53,18 @@ 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. - 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. - +- **D1 Database** (`DB_EXTENSIONS`): Stores the complete Extensions domain. + Migrations are owned by extensions v2 and applied only from this repository — see [its README](src/services/extensions/v2/README.md#database) for the migration and adoption procedure. - **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. -- `ASSERTION_SIGNING_SECRET_PREVIOUS`: Optional previous HMAC secret accepted - during a signing-key rotation. Remove it after the new secret has been active - for at least 65 seconds and all in-flight assertions have expired. - -Extensions assertions use HS256 and include the exact issuer -`fossbilling-extensions`, audience `fossbilling-api/extensions-v2`, purpose -`user-authentication`, and protocol version `1`. Assertions are valid for at -most 60 seconds; the previous secret is accepted only as a temporary rotation -window. - -To rotate the shared secret without interrupting requests, first set the API's -`ASSERTION_SIGNING_SECRET_PREVIOUS` to the current value, then replace the API's -active `ASSERTION_SIGNING_SECRET`, and finally replace the Extensions site's -active secret. After at least 65 seconds, verify requests and remove the API -previous secret. +- `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. +- `ASSERTION_SIGNING_SECRET_PREVIOUS`: Optional previous HMAC secret accepted during a signing-key rotation. + +Only extensions v2 consumes these. For the assertion format and the rotation procedure, see [its README](src/services/extensions/v2/README.md#authentication). ## Development @@ -150,11 +113,3 @@ We use Vitest for testing. The suite includes unit tests for the endpoints and i ```bash npm run test ``` - -### Extensions v2 list pagination - -`GET /extensions/v2/extensions` returns bounded pages of lightweight catalogue -items. List items intentionally omit `readme` and `releases`; retrieve the full -object from `GET /extensions/v2/extensions/{id}` for detail views. Follow -`pagination.next_cursor` by passing it unchanged as `cursor`, and treat cursors -as opaque. The default page size is 50 and `limit` may be set from 1 through 100. diff --git a/package-lock.json b/package-lock.json index c2bda73..cf7975d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,22 +8,22 @@ "license": "AGPL-3.0-only", "dependencies": { "@hono/zod-openapi": "^1.5.1", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.2", - "@scalar/hono-api-reference": "^0.11.11", + "@octokit/graphql": "^9.0.4", + "@octokit/request": "^10.0.13", + "@scalar/hono-api-reference": "^0.11.12", "badge-maker": "^6.0.0", "drizzle-orm": "^0.45.2", - "hono": "^4.11.4", - "semver": "^7.7.2", + "hono": "^4.13.0", + "semver": "^7.8.5", "zod": "^4.4.3" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "0.20.1", + "@cloudflare/vitest-pool-workers": "0.20.2", "@eslint/js": "10.0.1", "@types/node": "24.13.3", "@types/semver": "7.8.0", - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", "@vitest/coverage-istanbul": "4.1.10", "drizzle-kit": "0.31.10", "eslint": "10.8.0", @@ -31,11 +31,11 @@ "eslint-plugin-jest": "29.16.0", "jiti": "2.7.0", "prettier": "3.9.6", - "tsx": "4.23.5", + "tsx": "4.23.9", "typescript": "6.0.3", - "typescript-eslint": "8.65.0", + "typescript-eslint": "8.66.0", "vitest": "4.1.10", - "wrangler": "4.118.0" + "wrangler": "4.119.0" } }, "node_modules/@asteasolutions/zod-to-openapi": { @@ -337,16 +337,16 @@ } }, "node_modules/@cloudflare/vitest-pool-workers": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.20.1.tgz", - "integrity": "sha512-eN5jHaX78lY/btWlyWIiNtTIgmXnI0CvwC6CPukgRjHoXs66jqX0AEKUsUJOeybfXEmZUhhy5FP/Xx+6wNwI7A==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.20.2.tgz", + "integrity": "sha512-2oLnP0B2E71S6BHig2jdgUvgxDLFD8hXp5TViALnyb5eob1UDCD+72vi547Ryy2SJBdY/H5jiA40ICS7Cm/Ntw==", "dev": true, "license": "MIT", "dependencies": { "cjs-module-lexer": "1.2.3", "esbuild": "0.28.1", - "miniflare": "5.20260730.0-alpha", - "wrangler": "4.118.0", + "miniflare": "5.20260801.0-alpha", + "wrangler": "4.119.0", "zod": "4.4.3" }, "peerDependencies": { @@ -356,9 +356,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260730.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260730.1.tgz", - "integrity": "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==", + "version": "1.20260801.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260801.1.tgz", + "integrity": "sha512-wuJWbXpKvncJi1P0GKS+iYpN5tHdb7JPJJ/+6ZQe8zzovHHVMkLJPNBsgWpqeUhpM3g9qTwEKd2rglNKejuh5A==", "cpu": [ "x64" ], @@ -373,9 +373,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260730.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260730.1.tgz", - "integrity": "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==", + "version": "1.20260801.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260801.1.tgz", + "integrity": "sha512-kwoZiTpnhNrF3+APx84Q/oAqvJ3sU9yefGagwm/ASaH/2W19x0vghkW/r4qCoHCK0WW7EPugZ+aXjgPMRtlq1Q==", "cpu": [ "arm64" ], @@ -390,9 +390,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260730.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260730.1.tgz", - "integrity": "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==", + "version": "1.20260801.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260801.1.tgz", + "integrity": "sha512-r0vAxCZH+Jih9Unm1yoyiByPNWNgawcKciOHDm5Q37ZVGOkKLsT9AtLe3yLSaul76WrKqtf+JP2n0WW32VBLJg==", "cpu": [ "x64" ], @@ -407,9 +407,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260730.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260730.1.tgz", - "integrity": "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==", + "version": "1.20260801.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260801.1.tgz", + "integrity": "sha512-zWgpdZtSozvIgzQNmQiDSF8yEOQJUkRAWNsDXXzAAoy+fCn8YUoSibj3mpFSbZRvbUldeBEaW6SCdC2VEMkhNQ==", "cpu": [ "arm64" ], @@ -424,9 +424,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260730.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260730.1.tgz", - "integrity": "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==", + "version": "1.20260801.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260801.1.tgz", + "integrity": "sha512-2oQz+Ksu4ji6e/+ZoYX+tWQEcxAii2p7l+iR8kx48W1llMalaufAsmVxTlk+3/vrM7D3/2c0iK448e0UQTcIMg==", "cpu": [ "x64" ], @@ -2749,17 +2749,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", - "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/type-utils": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2772,22 +2772,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.65.0", + "@typescript-eslint/parser": "^8.66.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", - "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3" }, "engines": { @@ -2803,14 +2803,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", - "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.65.0", - "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", "debug": "^4.4.3" }, "engines": { @@ -2825,14 +2825,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", - "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0" + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2843,9 +2843,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", - "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", "dev": true, "license": "MIT", "engines": { @@ -2860,15 +2860,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", - "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2885,9 +2885,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", - "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", "dev": true, "license": "MIT", "engines": { @@ -2899,16 +2899,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", - "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.65.0", - "@typescript-eslint/tsconfig-utils": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2927,16 +2927,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", - "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0" + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2951,13 +2951,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", - "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3285,9 +3285,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001807", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz", + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==", "dev": true, "funding": [ { @@ -4069,9 +4069,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.401", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.401.tgz", - "integrity": "sha512-H6ViHN68nGYlChEvlIU67fn8O2/tpbWQPwck98yaJmh+08LSvHiydzDQ6oXNccLU3kNRVIRS9A4mA7CG+i6fLQ==", + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", "dev": true, "license": "ISC" }, @@ -5112,16 +5112,16 @@ } }, "node_modules/miniflare": { - "version": "5.20260730.0-alpha", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260730.0-alpha.tgz", - "integrity": "sha512-8/dspSXDshP6nSkCpjKO7BYc2qZoYSXm7iM+QxY7qJyJpAB3onnQSaiu0cvKJlfuMGwULl55hG69FJCcCMXU1Q==", + "version": "5.20260801.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260801.0-alpha.tgz", + "integrity": "sha512-AfMrnQbJg81ESsGkGhOkHBtwTqCG+mosR+3GE+qDrhF1I/ieIvgg3ICxNyBvgHBZ3iapy6cysBQHNPykkzrfgw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.28.0", - "workerd": "1.20260730.1", + "workerd": "1.20260801.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -5178,9 +5178,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.52", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.52.tgz", - "integrity": "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "dev": true, "license": "MIT", "engines": { @@ -5315,9 +5315,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -5335,7 +5335,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5664,9 +5664,9 @@ "optional": true }, "node_modules/tsx": { - "version": "4.23.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.5.tgz", - "integrity": "sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==", + "version": "4.23.9", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.9.tgz", + "integrity": "sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==", "dev": true, "license": "MIT", "dependencies": { @@ -5725,16 +5725,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", - "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0" + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5823,16 +5823,16 @@ } }, "node_modules/vite": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", - "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.23", - "rolldown": "~1.2.0", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { @@ -6034,9 +6034,9 @@ } }, "node_modules/workerd": { - "version": "1.20260730.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260730.1.tgz", - "integrity": "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==", + "version": "1.20260801.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260801.1.tgz", + "integrity": "sha512-/g9JGTyqnHtoIscpBHqKD8swE2V4StBs2i69PmLiOhH45OP95jCFICl4F1hKlgN57rqfni5LiCitttoX5OOkVA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -6047,17 +6047,17 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260730.1", - "@cloudflare/workerd-darwin-arm64": "1.20260730.1", - "@cloudflare/workerd-linux-64": "1.20260730.1", - "@cloudflare/workerd-linux-arm64": "1.20260730.1", - "@cloudflare/workerd-windows-64": "1.20260730.1" + "@cloudflare/workerd-darwin-64": "1.20260801.1", + "@cloudflare/workerd-darwin-arm64": "1.20260801.1", + "@cloudflare/workerd-linux-64": "1.20260801.1", + "@cloudflare/workerd-linux-arm64": "1.20260801.1", + "@cloudflare/workerd-windows-64": "1.20260801.1" } }, "node_modules/wrangler": { - "version": "4.118.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.118.0.tgz", - "integrity": "sha512-9pkBw/b8zWqGx2S+oLhgHMR1M/4VOE8SynUFABnGWiSFGlcOQ4xiI/B71Xf66RYP2xzngU37IQFPtUruij3lYw==", + "version": "4.119.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.119.0.tgz", + "integrity": "sha512-ookClf+zly4DTc8pBMNrwGQzZKH8IpIYTXkjDw3XS7ZvBQ5mLYH6eOvfD5BEpk3U63zTbv91WRlo1UeRSKXa0g==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -6065,10 +6065,10 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "5.20260730.0-alpha", + "miniflare": "5.20260801.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260730.1" + "workerd": "1.20260801.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -6082,7 +6082,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260730.1" + "@cloudflare/workers-types": "^5.20260801.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index b3297dd..9e97e86 100644 --- a/package.json +++ b/package.json @@ -24,22 +24,22 @@ }, "dependencies": { "@hono/zod-openapi": "^1.5.1", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.2", - "@scalar/hono-api-reference": "^0.11.11", + "@octokit/graphql": "^9.0.4", + "@octokit/request": "^10.0.13", + "@scalar/hono-api-reference": "^0.11.12", "badge-maker": "^6.0.0", "drizzle-orm": "^0.45.2", - "hono": "^4.11.4", - "semver": "^7.7.2", + "hono": "^4.13.0", + "semver": "^7.8.5", "zod": "^4.4.3" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "0.20.1", + "@cloudflare/vitest-pool-workers": "0.20.2", "@eslint/js": "10.0.1", "@types/node": "24.13.3", "@types/semver": "7.8.0", - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", "@vitest/coverage-istanbul": "4.1.10", "drizzle-kit": "0.31.10", "eslint": "10.8.0", @@ -47,17 +47,15 @@ "eslint-plugin-jest": "29.16.0", "jiti": "2.7.0", "prettier": "3.9.6", - "tsx": "4.23.5", + "tsx": "4.23.9", "typescript": "6.0.3", - "typescript-eslint": "8.65.0", + "typescript-eslint": "8.66.0", "vitest": "4.1.10", - "wrangler": "4.118.0" + "wrangler": "4.119.0" }, "allowScripts": { - "esbuild@0.18.20": true, - "esbuild@0.25.12": true, - "esbuild@0.28.1": true, - "fsevents@2.3.3": true, - "workerd@1.20260730.1": true + "esbuild": true, + "fsevents": true, + "workerd": true } } diff --git a/src/lib/db.ts b/src/lib/db.ts index 9e972b1..10ed7c8 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -7,12 +7,31 @@ export type CentralAlertsDb = ReturnType< typeof drizzle >; +// drizzle(d1, {schema}) runs extractTablesRelationalConfig over every table on +// each call - ~35us against the extensions schema, versus ~0.4us without the +// schema option. Routes and middleware each build their own handle, so a +// single request paid that two or three times over. The config only powers +// db.query.*, which this codebase never uses, but dropping the schema would +// change the exported types; caching per binding keeps them identical and +// builds the config once per isolate. The wrapper is stateless, so sharing one +// instance across requests is safe. +const extensionsDbs = new WeakMap(); +const centralAlertsDbs = new WeakMap(); + // DB_EXTENSIONS is shared by v1 (read-only) and v2 (owns writes/migrations, // see src/services/extensions/v2/db/schema.ts for the full table set). export function getExtensionsDb(d1: D1Database): ExtensionsDb { - return drizzle(d1, { schema: extensionsSchema }); + const cached = extensionsDbs.get(d1); + if (cached) return cached; + const db = drizzle(d1, { schema: extensionsSchema }); + extensionsDbs.set(d1, db); + return db; } export function getCentralAlertsDb(d1: D1Database): CentralAlertsDb { - return drizzle(d1, { schema: centralAlertsSchema }); + const cached = centralAlertsDbs.get(d1); + if (cached) return cached; + const db = drizzle(d1, { schema: centralAlertsSchema }); + centralAlertsDbs.set(d1, db); + return db; } diff --git a/src/lib/github-errors.ts b/src/lib/github-errors.ts index 051de5d..3ce4c73 100644 --- a/src/lib/github-errors.ts +++ b/src/lib/github-errors.ts @@ -63,6 +63,17 @@ export function classifyGitHubError(error: unknown, url?: string): GitHubError { const errorMessage = error instanceof Error ? error.message : String(error); + // Carried through to the generic fallback below. Statuses this function has + // no specific class for - 500, 502, 503 - are still worth reporting: callers + // use them to tell an upstream outage apart from a transport failure that + // never reached GitHub at all. + const httpStatus = + typeof error === "object" && + error !== null && + typeof (error as Record).status === "number" + ? ((error as Record).status as number) + : undefined; + if (typeof error === "object" && error !== null) { const err = error as Record; @@ -74,15 +85,31 @@ export function classifyGitHubError(error: unknown, url?: string): GitHubError { return new AuthError(err.message, err.status, url); } + // GitHub returns 429 for secondary rate limits, and 403 for both primary + // rate limits and plain authorization failures. Only the message text or + // an exhausted x-ratelimit-remaining distinguishes the two; a bare 403 is + // an authorization problem, and calling it a rate limit would have callers + // back off and retry a request that will never succeed. + if (typeof err.status === "number" && err.status === 429) { + return new RateLimitError("GitHub API rate limit exceeded", 429, url); + } + if ( typeof err.status === "number" && err.status === 403 && typeof err.message === "string" ) { - const message = errorMessage.toLowerCase().includes("rate limit") - ? "GitHub API rate limit exceeded" - : err.message; - return new RateLimitError(message, err.status, url); + const response = err.response as + { headers?: Record } | undefined; + // err.message, not errorMessage: the latter is String(error) for a + // non-Error throw, which stringifies to "[object Object]" and would hide + // the "rate limit" text this check depends on. + const rateLimited = + err.message.toLowerCase().includes("rate limit") || + response?.headers?.["x-ratelimit-remaining"] === "0"; + return rateLimited + ? new RateLimitError("GitHub API rate limit exceeded", err.status, url) + : new AuthError(err.message, err.status, url); } if ( @@ -110,7 +137,7 @@ export function classifyGitHubError(error: unknown, url?: string): GitHubError { return new GitHubError( errorMessage, - undefined, + httpStatus, "unknown_error", ErrorPriority.HIGH, url diff --git a/src/services/extensions/v1/interfaces.ts b/src/services/extensions/v1/interfaces.ts index a7b41a1..1a3a81f 100644 --- a/src/services/extensions/v1/interfaces.ts +++ b/src/services/extensions/v1/interfaces.ts @@ -1,19 +1,17 @@ import { gt } from "semver"; import { sortReleasesDescending } from "../../../lib/releases"; import { parseJSON } from "../../../lib/json"; +import { EXTENSION_TYPES } from "../v2/schemas/extensions"; export { sortReleasesDescending, parseJSON }; export type Extension = { id: string; - type: - | "mod" - | "theme" - | "payment-gateway" - | "server-manager" - | "domain-registrar" - | "hook" - | "translation"; + // Derived from v2 rather than restated: both describe the same + // extensions.type column (v1/database.ts already reads v2's db/schema), so a + // new extension type would otherwise pass v2's runtime validator while v1's + // type silently disagreed. + type: (typeof EXTENSION_TYPES)[number]; name: string; description: string; author: Author; diff --git a/src/services/extensions/v2/README.md b/src/services/extensions/v2/README.md new file mode 100644 index 0000000..6f438b9 --- /dev/null +++ b/src/services/extensions/v2/README.md @@ -0,0 +1,55 @@ +# Extensions v2 + +**Base Path:** `/extensions/v2` + +Self-service extension submission, developer-profile ownership, moderation, and public catalogue browsing. + +This service owns the complete Extensions domain and its `DB_EXTENSIONS` schema: users, developers, submissions, claims, transfers, history, and catalogue data. The separate Extensions site keeps OIDC/session state but reaches this domain through the generated HTTPS API client; it must not bind or migrate `DB_EXTENSIONS`. + +## Endpoints + +Endpoints are not listed here. The service publishes its own contract: + +- **OpenAPI document:** `GET /extensions/v2/openapi.json` +- **Reference UI:** `GET /extensions/v2/docs` + +## Authentication + +Requests carry a short-lived bearer assertion minted by the Extensions site and verified here with a shared HMAC secret (`ASSERTION_SIGNING_SECRET`; see the root README for where to configure it). + +Assertions use HS256 and include the exact issuer `fossbilling-extensions`, audience `fossbilling-api/extensions-v2`, purpose `user-authentication`, and protocol version `1`. They are valid for at most 60 seconds. + +### Rotating the Shared Secret + +`ASSERTION_SIGNING_SECRET_PREVIOUS` is an optional second secret accepted only as a temporary rotation window. To rotate without interrupting requests: + +1. Set the API's `ASSERTION_SIGNING_SECRET_PREVIOUS` to the current value. +2. Replace the API's active `ASSERTION_SIGNING_SECRET`. +3. Replace the Extensions site's active secret. +4. After at least 65 seconds, verify requests and remove the API's previous secret. + +Remove the previous secret once the new one has been active for at least 65 seconds and all in-flight assertions have expired. + +## Ownership Verification + +For organization developer IDs, GitHub membership is used for automatic verification only when the API has a valid, unexpired membership snapshot. A fresh snapshot that does not contain the organization remains a confirmed mismatch and is rejected. Missing, malformed, or expired evidence is inconclusive instead: a new profile remains unapproved and a claim remains pending for manual moderator review. Moderators must verify ownership through their normal out-of-band process before approving either workflow. + +`github_org_verified` being absent or `null` is a review signal, not proof of ownership or an authorization grant. Consumers and moderation tooling must not treat an inconclusive result as verified. + +## List Pagination + +`GET /extensions/v2/extensions` returns bounded pages of lightweight catalogue items. List items intentionally omit `readme` and `releases`; retrieve the full object from `GET /extensions/v2/extensions/{id}` for detail views. Follow `pagination.next_cursor` by passing it unchanged as `cursor`, and treat cursors as opaque. The default page size is 50 and `limit` may be set from 1 through 100. + +Cursors carry a version field and are validated on decode, so a cursor from an older format is rejected with `INVALID_CURSOR` (HTTP 422) rather than being misread. Clients should treat that as "restart pagination from the first page", not as an error to surface. + +## Database + +Uses the D1 binding `DB_EXTENSIONS`, shared with v1 (read-only there). This service owns the schema and the migrations. + +Apply migrations **only from this repository**, from `db/migrations`, with `npm run db:migrate:extensions-v2:local` / `:remote`. The Extensions site has no D1 migration source. + +Migration `0020` is a check, not a schema change: it fails if an adopted row holds an id that a static route shadows (`extensions.id = 'mine'`, or `developers.id` of `me`/`claims`/`unapproved`), which would make that row's detail page unreachable. If it fails, rename the row deliberately — the id is public and consumers pin it. + +## Code Layout + +See `AGENTS.md` for what belongs in `routes/`, `db/`, `schemas/`, `github/`, and `middleware.ts`. This service is the reference layout for larger services. diff --git a/src/services/extensions/v2/d1-batch.ts b/src/services/extensions/v2/db/batch.ts similarity index 100% rename from src/services/extensions/v2/d1-batch.ts rename to src/services/extensions/v2/db/batch.ts diff --git a/src/services/extensions/v2/db/columns.ts b/src/services/extensions/v2/db/columns.ts new file mode 100644 index 0000000..13d0cbe --- /dev/null +++ b/src/services/extensions/v2/db/columns.ts @@ -0,0 +1,9 @@ +// SQLite has no boolean type, so the nullable flag columns in db/schema.ts are +// integers where NULL means "not determined yet" rather than false. Preserving +// that distinction matters for github_org_verified, where "unknown" and "no" +// lead to different moderation outcomes. +export function optionalBool( + value: number | null | undefined +): boolean | undefined { + return value == null ? undefined : value === 1; +} diff --git a/src/services/extensions/v2/db/cursor.ts b/src/services/extensions/v2/db/cursor.ts new file mode 100644 index 0000000..86a21e3 --- /dev/null +++ b/src/services/extensions/v2/db/cursor.ts @@ -0,0 +1,47 @@ +// Keyset pagination cursors. base64 of JSON, but neither half of that is safe +// by default: btoa throws on any code point above U+00FF, and atob followed by +// a plain JSON.parse will happily decode mojibake from a truncated or tampered +// cursor. Encoding goes through TextEncoder and decoding through a fatal +// TextDecoder so a corrupt cursor fails as a cursor rather than as a mangled +// query. +// +// Every cursor carries the version envelope, so a future change to any +// caller's key tuple can be recognised rather than misread as a valid cursor +// of the new shape. Callers supply only their own fields and a guard over +// them; the envelope is added and checked here. +const CURSOR_VERSION = 1; + +export function encodeCursor(payload: Record): string { + const bytes = new TextEncoder().encode( + JSON.stringify({ ...payload, v: CURSOR_VERSION }) + ); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +// `isValid` runs over the decoded payload before it is handed back, so a +// cursor that survives base64 and JSON but decodes to the wrong shape is +// rejected here rather than reaching the query builder. +export function decodeCursor( + value: string, + isValid: ( + parsed: Record + ) => parsed is T & Record +): T | null { + try { + const binary = atob(value); + const bytes = Uint8Array.from(binary, (character) => + character.charCodeAt(0) + ); + const parsed: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes) + ); + if (typeof parsed !== "object" || parsed === null) return null; + const envelope = parsed as Record; + if (envelope.v !== CURSOR_VERSION) return null; + return isValid(envelope) ? envelope : null; + } catch { + return null; + } +} diff --git a/src/services/extensions/v2/developer-claims-database.ts b/src/services/extensions/v2/db/developer-claims.ts similarity index 91% rename from src/services/extensions/v2/developer-claims-database.ts rename to src/services/extensions/v2/db/developer-claims.ts index 1a7340b..2705928 100644 --- a/src/services/extensions/v2/developer-claims-database.ts +++ b/src/services/extensions/v2/db/developer-claims.ts @@ -1,21 +1,18 @@ import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"; -import { DatabaseResult } from "../../../lib/interfaces"; -import { ExtensionsDb } from "../../../lib/db"; -import { developerClaims, developers, users } from "./db/schema"; +import { DatabaseResult } from "../../../../lib/interfaces"; +import { ExtensionsDb } from "../../../../lib/db"; +import { developerClaims, developers, users } from "./schema"; import { databaseError, - errorMessageChain, - isDeveloperOwnerConflict + isDeveloperOwnerConflict, + isOwnershipEpochRollback } from "./errors"; -import { toD1Statement } from "./d1-batch"; -import { - Developer, - DeveloperClaim, - DeveloperProfile, - PendingDeveloperClaim -} from "./interfaces"; -import { DeveloperProfilesDatabase } from "./developer-profiles-database"; -import { verifyGithubOwnership } from "./developer-identity-verification"; +import { toD1Statement } from "./batch"; +import { optionalBool } from "./columns"; +import { Developer, DeveloperProfile } from "../schemas/developers"; +import { DeveloperClaim, PendingDeveloperClaim } from "../schemas/ownership"; +import { DeveloperProfilesDatabase } from "./developer-profiles"; +import { verifyGithubOwnership } from "../github/identity"; type ClaimRow = typeof developerClaims.$inferSelect; @@ -30,20 +27,11 @@ function parseClaimRow(row: ClaimRow): DeveloperClaim { reviewer_id: row.reviewerId ?? undefined, created_at: row.createdAt, reviewed_at: row.reviewedAt ?? undefined, - github_org_verified: - row.githubOrgVerified === null || row.githubOrgVerified === undefined - ? undefined - : row.githubOrgVerified === 1, + github_org_verified: optionalBool(row.githubOrgVerified), github_verification_note: row.githubVerificationNote ?? undefined }; } -function isPendingClaimConflict(error: unknown): boolean { - return /UNIQUE constraint failed.*developer_claims/i.test( - errorMessageChain(error) - ); -} - export class DeveloperClaimsDatabase { constructor(private db: ExtensionsDb) {} private async getClaimById( @@ -102,9 +90,24 @@ export class DeveloperClaimsDatabase { if (developer.ownerUserId !== null) { return { code: "CONFLICT", message: "This profile is already owned" }; } + + const [ownProfile] = await this.db + .select({ id: developers.id }) + .from(developers) + .where(eq(developers.ownerUserId, claimantId)); + if (ownProfile) { + return { + code: "CONFLICT", + message: "You already have a developer profile" + }; + } + + // Every condition in the insert's WHERE guard holds, so the row was + // rejected by the pending-claim partial unique index instead - the + // claimant is replaying a claim they already have open here. return { code: "CONFLICT", - message: "You already have a developer profile" + message: "You already have a pending claim on this profile" }; } @@ -200,23 +203,18 @@ export class DeveloperClaimsDatabase { // re-checked here — it can't itself grant eligibility.) Kept as raw // sql: an INSERT...SELECT...WHERE EXISTS isn't expressible via // .insert().values(). + // ON CONFLICT DO NOTHING turns a lost race against the pending-claim + // partial unique index into changes = 0, which the ineligibility + // diagnosis below already has to explain. result = await this.db.run(sql` INSERT INTO ${developerClaims} (id, developer_id, claimant_id, note, github_org_verified, github_verification_note) SELECT ${id}, ${developerId}, ${claimantId}, ${note ?? null}, ${githubOrgVerified}, ${githubVerificationNote} WHERE EXISTS (SELECT 1 FROM ${developers} WHERE id = ${developerId} AND owner_user_id IS NULL) AND NOT EXISTS (SELECT 1 FROM ${developers} WHERE owner_user_id = ${claimantId}) AND EXISTS (SELECT 1 FROM ${users} WHERE ${users.id} = ${claimantId} AND ${users.deletedAt} IS NULL) + ON CONFLICT DO NOTHING `); } catch (error) { - if (isPendingClaimConflict(error)) { - return { - data: null, - error: { - code: "CONFLICT", - message: "You already have a pending claim on this profile" - } - }; - } return databaseError("claim", error); } @@ -462,11 +460,7 @@ export class DeveloperClaimsDatabase { rejectOthersStmt ]); } catch (error) { - if ( - /CHECK constraint failed.*ownership_epoch/i.test( - errorMessageChain(error) - ) - ) { + if (isOwnershipEpochRollback(error)) { return this.explainClaimApprovalNoOp(claim); } if (isDeveloperOwnerConflict(error)) { diff --git a/src/services/extensions/v2/developer-profiles-database.ts b/src/services/extensions/v2/db/developer-profiles.ts similarity index 88% rename from src/services/extensions/v2/developer-profiles-database.ts rename to src/services/extensions/v2/db/developer-profiles.ts index 4d02ec0..e2368a5 100644 --- a/src/services/extensions/v2/developer-profiles-database.ts +++ b/src/services/extensions/v2/db/developer-profiles.ts @@ -1,6 +1,7 @@ -import { and, asc, desc, eq, isNull, or, sql } from "drizzle-orm"; -import { DatabaseResult } from "../../../lib/interfaces"; -import { ExtensionsDb } from "../../../lib/db"; +import { and, asc, desc, eq, isNull, or, sql, SQL } from "drizzle-orm"; +import { SQLiteColumn } from "drizzle-orm/sqlite-core"; +import { DatabaseResult } from "../../../../lib/interfaces"; +import { ExtensionsDb } from "../../../../lib/db"; import { developers, developerHistory, @@ -8,28 +9,25 @@ import { extensions, extensionSubmissions, users -} from "./db/schema"; -import { - databaseError, - isDeveloperIdConflict, - isDeveloperOwnerConflict -} from "./errors"; -import { toD1Statement } from "./d1-batch"; +} from "./schema"; +import { databaseError } from "./errors"; +import { toD1Statement } from "./batch"; +import { optionalBool } from "./columns"; import { checkGithubEntity, matchesClaimant, urlMatchesGithubBlog -} from "./github-verification"; +} from "../github/verification"; import { Developer, DeveloperHistoryEntry, DeveloperProfile -} from "./interfaces"; +} from "../schemas/developers"; import { githubUnavailableError, verifyGithubOwnership -} from "./developer-identity-verification"; -import { UsersDatabase } from "./users-database"; +} from "../github/identity"; +import { UsersDatabase } from "./users"; const URL_CHECK_COOLDOWN_SECONDS = 60; @@ -44,15 +42,11 @@ function parseDeveloperRow(row: DeveloperRow): DeveloperProfile { avatar_url: row.avatarUrl ?? undefined, contact_email: row.contactEmail ?? undefined, approved: - row.approvedAt !== null && - row.approvedAt !== undefined && + row.approvedAt != null && (row.approvedRevision == null || Number(row.approvedRevision) === Number(row.contentRevision ?? 1)), content_revision: Number(row.contentRevision ?? 1), - github_org_verified: - row.githubOrgVerified === null || row.githubOrgVerified === undefined - ? undefined - : row.githubOrgVerified === 1, + github_org_verified: optionalBool(row.githubOrgVerified), github_verification_note: row.githubVerificationNote ?? undefined, github_verified_at: row.githubVerifiedAt ?? undefined, github_url_verified: row.githubUrlVerified === 1 ? true : undefined @@ -126,15 +120,16 @@ export class DeveloperProfilesDatabase { allowCreationAttempt: () => Promise = async () => true ): Promise> { try { - const [existingOwn] = await this.db - .select() - .from(developers) - .where(eq(developers.ownerUserId, userId)); - - const [existingById] = await this.db - .select() - .from(developers) - .where(eq(developers.id, developer.id)); + // Independent lookups - "does this caller already own a profile" and + // "is this id taken" - so they go out together rather than costing two + // serial round trips on every PUT /developers/me. + const [[existingOwn], [existingById]] = await Promise.all([ + this.db + .select() + .from(developers) + .where(eq(developers.ownerUserId, userId)), + this.db.select().from(developers).where(eq(developers.id, developer.id)) + ]); const isCreating = !existingOwn; let githubOrgVerified: number | null = null; @@ -219,7 +214,8 @@ export class DeveloperProfilesDatabase { WHERE EXISTS ( SELECT 1 FROM users WHERE id = ? AND deleted_at IS NULL - )`, + ) + ON CONFLICT DO NOTHING`, params: [ developer.id, developer.type, @@ -337,47 +333,13 @@ export class DeveloperProfilesDatabase { try { results = await this.db.$client.batch([mainStmt, historyStmt]); } catch (error) { - if (isDeveloperIdConflict(error)) { - return { - data: null, - error: { - message: "Developer id already exists", - code: "DEVELOPER_ID_TAKEN" - } - }; - } - if (isDeveloperOwnerConflict(error)) { - return { - data: null, - error: { - message: "You already have a developer profile", - code: "CONFLICT" - } - }; - } return databaseError("upsertOwn", error); } if (!results[0]?.meta?.changes) { - const [activeUser] = await this.db - .select({ id: users.id }) - .from(users) - .where(and(eq(users.id, userId), isNull(users.deletedAt))); - if (!activeUser) { - return { - data: null, - error: { - message: "Active account required", - code: "ACCOUNT_INACTIVE" - } - }; - } return { data: null, - error: { - message: "Developer ownership changed while updating the profile", - code: "CONFLICT" - } + error: await this.upsertBlockedError(userId, developer.id, isCreating) }; } @@ -405,6 +367,55 @@ export class DeveloperProfilesDatabase { } } + // The guarded upsert affected no rows. Either its WHERE rejected the caller + // or ON CONFLICT DO NOTHING swallowed a unique violation, so work through + // the same conditions the statement checked. DEVELOPER_ID_TAKEN is a + // distinct code rather than a generic CONFLICT because the create-profile + // form uses it to redirect the user to the claim flow. + private async upsertBlockedError( + userId: string, + developerId: string, + isCreating: boolean + ): Promise<{ message: string; code: string }> { + const [activeUser] = await this.db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.id, userId), isNull(users.deletedAt))); + if (!activeUser) { + return { message: "Active account required", code: "ACCOUNT_INACTIVE" }; + } + + if (isCreating) { + const [takenId, ownProfile] = await Promise.all([ + this.db + .select({ id: developers.id }) + .from(developers) + .where(eq(developers.id, developerId)), + this.db + .select({ id: developers.id }) + .from(developers) + .where(eq(developers.ownerUserId, userId)) + ]); + if (takenId.length > 0) { + return { + message: "Developer id already exists", + code: "DEVELOPER_ID_TAKEN" + }; + } + if (ownProfile.length > 0) { + return { + message: "You already have a developer profile", + code: "CONFLICT" + }; + } + } + + return { + message: "Developer ownership changed while updating the profile", + code: "CONFLICT" + }; + } + // Diagnoses why the guarded delete in deleteOwn() below affected zero // rows: distinguishes an inactive caller, no-longer-owned/nonexistent, // and the two blocking conditions, without reopening the race the guard @@ -515,62 +526,48 @@ export class DeveloperProfilesDatabase { // outer statement's own table name, which the query builder can't // express, and this batch needs the raw-D1 escape hatch regardless // (see upsertOwn's historyStmt comment). - const deleteTransfersStmt = toD1Statement(this.db.$client, { - sql: `DELETE FROM developer_transfers - WHERE developer_id = ? - AND EXISTS ( - SELECT 1 FROM developers - WHERE developers.id = developer_transfers.developer_id - AND developers.owner_user_id = ? - AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id) + // Parameterised by the developers-table reference: the two child-table + // deletes reach the predicate through a correlated EXISTS, while the + // developers delete applies it directly to the row being removed - + // wrapping that one in EXISTS (SELECT 1 FROM developers ...) would let + // the inner table shadow the outer and pass whenever *any* profile were + // deletable. Parameters, in order: owner user id, then active user id. + const deletable = (dev: string) => `${dev}.owner_user_id = ? + AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = ${dev}.id) AND NOT EXISTS ( SELECT 1 FROM extension_submissions - WHERE extension_submissions.developer_id = developers.id + WHERE extension_submissions.developer_id = ${dev}.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 - ) - )`, + )`; + + const ownedAndDeletable = (developerIdColumn: string) => `EXISTS ( + SELECT 1 FROM developers + WHERE developers.id = ${developerIdColumn} + AND ${deletable("developers")} + )`; + + const deleteTransfersStmt = toD1Statement(this.db.$client, { + sql: `DELETE FROM developer_transfers + WHERE developer_id = ? + AND ${ownedAndDeletable("developer_transfers.developer_id")}`, params: [developer.id, userId, userId] }); const deleteClaimsStmt = toD1Statement(this.db.$client, { sql: `DELETE FROM developer_claims WHERE developer_id = ? - AND EXISTS ( - SELECT 1 FROM developers - WHERE developers.id = developer_claims.developer_id - AND developers.owner_user_id = ? - AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id) - AND NOT EXISTS ( - 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 - ) - )`, + AND ${ownedAndDeletable("developer_claims.developer_id")}`, params: [developer.id, userId, userId] }); const deleteDeveloperStmt = toD1Statement(this.db.$client, { sql: `DELETE FROM developers WHERE id = ? - AND owner_user_id = ? - AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id) - AND NOT EXISTS ( - 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 - )`, + AND ${deletable("developers")}`, params: [developer.id, userId, userId] }); @@ -628,7 +625,14 @@ export class DeveloperProfilesDatabase { } } - async listAll(): Promise> { + // The two moderator listings differ only by filter and sort order. They are + // the only readers that join users for owner_name/owner_github_login, which + // is why DeveloperProfile treats those fields as optional. + private async listWithOwner( + context: string, + where: SQL | undefined, + orderBy: SQL | SQLiteColumn + ): Promise> { let rows; try { rows = await this.db @@ -639,32 +643,25 @@ export class DeveloperProfilesDatabase { }) .from(developers) .leftJoin(users, eq(users.id, developers.ownerUserId)) - .orderBy(asc(developers.name)); + .where(where) + .orderBy(orderBy); } catch (error) { - return databaseError("listAll", error); + return databaseError(context, error); } return { data: rows.map(parseDeveloperRowWithOwner), error: null }; } - async listUnapproved(): Promise> { - let rows; - try { - rows = await this.db - .select({ - developer: developers, - ownerName: users.name, - ownerGithubLogin: users.githubLogin - }) - .from(developers) - .leftJoin(users, eq(users.id, developers.ownerUserId)) - .where(isNull(developers.approvedAt)) - .orderBy(asc(developers.createdAt)); - } catch (error) { - return databaseError("listUnapproved", error); - } + async listAll(): Promise> { + return this.listWithOwner("listAll", undefined, asc(developers.name)); + } - return { data: rows.map(parseDeveloperRowWithOwner), error: null }; + async listUnapproved(): Promise> { + return this.listWithOwner( + "listUnapproved", + isNull(developers.approvedAt), + asc(developers.createdAt) + ); } async approve( diff --git a/src/services/extensions/v2/developer-transfers-database.ts b/src/services/extensions/v2/db/developer-transfers.ts similarity index 89% rename from src/services/extensions/v2/developer-transfers-database.ts rename to src/services/extensions/v2/db/developer-transfers.ts index 14b50e5..819c752 100644 --- a/src/services/extensions/v2/developer-transfers-database.ts +++ b/src/services/extensions/v2/db/developer-transfers.ts @@ -1,15 +1,16 @@ import { and, eq, isNull, sql } from "drizzle-orm"; -import { DatabaseResult } from "../../../lib/interfaces"; -import { ExtensionsDb } from "../../../lib/db"; -import { developers, developerTransfers, users } from "./db/schema"; +import { DatabaseResult } from "../../../../lib/interfaces"; +import { ExtensionsDb } from "../../../../lib/db"; +import { developers, developerTransfers, users } from "./schema"; import { databaseError, - errorMessageChain, - isDeveloperOwnerConflict + isDeveloperOwnerConflict, + isOwnershipEpochRollback } from "./errors"; -import { toD1Statement } from "./d1-batch"; -import { DeveloperProfile, DeveloperTransfer } from "./interfaces"; -import { DeveloperProfilesDatabase } from "./developer-profiles-database"; +import { toD1Statement } from "./batch"; +import { DeveloperProfile } from "../schemas/developers"; +import { DeveloperTransfer } from "../schemas/ownership"; +import { DeveloperProfilesDatabase } from "./developer-profiles"; async function sha256Hex(input: string): Promise { const digest = await crypto.subtle.digest( @@ -25,6 +26,13 @@ function toSqliteDatetime(date: Date): string { return date.toISOString().slice(0, 19).replace("T", " "); } +// The developer this token has just been accepted for. Every statement in the +// acceptTransfer batch has to agree on it - a copy that drifted would target a +// different profile than the one the batch just transferred. Takes the token +// hash and accepting user as its two parameters, in that order. +const CLAIMED_DEVELOPER = `SELECT developer_id FROM developer_transfers + WHERE token_hash = ? AND accepted_by = ? AND accepted_at IS NOT NULL`; + export class DeveloperTransfersDatabase { constructor(private db: ExtensionsDb) {} // Shared by initiateTransfer/revokeTransfer: both are owner-only actions on @@ -256,10 +264,7 @@ export class DeveloperTransfersDatabase { github_verification_note = NULL, github_verified_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE changes() = 1 - AND id = ( - SELECT developer_id FROM developer_transfers - WHERE token_hash = ? AND accepted_by = ? AND accepted_at IS NOT NULL - )`, + AND id = (${CLAIMED_DEVELOPER})`, params: [userId, tokenHash, userId] }); @@ -273,38 +278,29 @@ export class DeveloperTransfersDatabase { const assertTransferStmt = toD1Statement(this.db.$client, { sql: `UPDATE developers SET ownership_epoch = CASE WHEN changes() = 1 THEN ownership_epoch ELSE 0 END - WHERE id = ( - SELECT developer_id FROM developer_transfers - WHERE token_hash = ? AND accepted_by = ? AND accepted_at IS NOT NULL - )`, - params: [tokenHash, userId] - }); - - const rejectPendingSubmissionsStmt = toD1Statement(this.db.$client, { - sql: `UPDATE extension_submissions - SET status = 'rejected', - review_note = 'Ownership changed before review', - reviewed_at = CURRENT_TIMESTAMP - WHERE developer_id = ( - SELECT developer_id FROM developer_transfers - WHERE token_hash = ? AND accepted_by = ? AND accepted_at IS NOT NULL - ) - AND status = 'pending'`, + WHERE id = (${CLAIMED_DEVELOPER})`, params: [tokenHash, userId] }); - const rejectPendingClaimsStmt = toD1Statement(this.db.$client, { - sql: `UPDATE developer_claims + // Identical apart from the table, and both must stay that way: leaving + // pending work attached to a profile whose owner just changed would put + // it in front of the wrong moderator. + const rejectPendingIn = ( + table: "extension_submissions" | "developer_claims" + ) => + toD1Statement(this.db.$client, { + sql: `UPDATE ${table} SET status = 'rejected', review_note = 'Ownership changed before review', reviewed_at = CURRENT_TIMESTAMP - WHERE developer_id = ( - SELECT developer_id FROM developer_transfers - WHERE token_hash = ? AND accepted_by = ? AND accepted_at IS NOT NULL - ) + WHERE developer_id = (${CLAIMED_DEVELOPER}) AND status = 'pending'`, - params: [tokenHash, userId] - }); + params: [tokenHash, userId] + }); + const rejectPendingSubmissionsStmt = rejectPendingIn( + "extension_submissions" + ); + const rejectPendingClaimsStmt = rejectPendingIn("developer_claims"); let results; try { @@ -320,11 +316,7 @@ export class DeveloperTransfersDatabase { // deliberate CHECK failure rolls back the batch, and is handled like // any other unsuccessful claim below so callers still receive the // documented invalid/used/expired-link response. - if ( - /CHECK constraint failed.*ownership_epoch/i.test( - errorMessageChain(error) - ) - ) { + if (isOwnershipEpochRollback(error)) { results = [{ meta: { changes: 0 } }]; } else { if (isDeveloperOwnerConflict(error)) { diff --git a/src/services/extensions/v2/errors.ts b/src/services/extensions/v2/db/errors.ts similarity index 50% rename from src/services/extensions/v2/errors.ts rename to src/services/extensions/v2/db/errors.ts index 8a54e18..0951867 100644 --- a/src/services/extensions/v2/errors.ts +++ b/src/services/extensions/v2/db/errors.ts @@ -1,5 +1,5 @@ -import { DatabaseResult } from "../../../lib/interfaces"; -import { logError } from "../../../lib/logger"; +import { DatabaseResult } from "../../../../lib/interfaces"; +import { logError } from "../../../../lib/logger"; // Drizzle wraps the real D1 driver error in a DrizzleError whose own // .message is a generic "Failed to run the query ''" - the actual @@ -17,24 +17,40 @@ export function errorMessageChain(error: unknown): string { return parts.join(" "); } -// Matches the SQLite/D1 message for the unique owner index. Several -// ownership workflows need to translate this race into the same conflict -// response, so keep the classifier beside the shared database error helpers. -export function isDeveloperOwnerConflict(error: unknown): boolean { - return /UNIQUE constraint failed.*owner_user_id/i.test( +// Every unique-constraint classifier below matches D1 driver message text, +// which means each one is coupled to a physical index or table name in +// db/schema.ts with nothing but this comment linking them. Keep them all +// here so a migration that renames one has a single place to check. +const uniqueConstraintMatcher = (target: RegExp) => (error: unknown) => + new RegExp(`UNIQUE constraint failed.*${target.source}`, "i").test( errorMessageChain(error) ); -} + +// Matches the SQLite/D1 message for the unique owner index. Several +// ownership workflows need to translate this race into the same conflict +// response. +export const isDeveloperOwnerConflict = + uniqueConstraintMatcher(/owner_user_id/); + +// The ownership-transfer and claim-approval batches end with an assertion +// statement that sets ownership_epoch = 0 when the preceding claim matched no +// rows, deliberately violating the column's CHECK so D1 rolls the whole batch +// back (see acceptTransfer/approveClaim). That rollback is the designed +// signal for "this token was replayed", not a fault, so it is recognised here +// rather than reported as a database error. +// +// Unlike the UNIQUE matchers above this one cannot currently be replaced by a +// guard: it is how a multi-statement batch reports failure atomically, and +// D1 exposes no other way to abort one. Changing it means redesigning the +// batch protocol, not swapping a classifier. +export const isOwnershipEpochRollback = (error: unknown) => + /CHECK constraint failed.*ownership_epoch/i.test(errorMessageChain(error)); // A concurrent first-time profile creation can lose the developers primary-key // race after both requests pass the cheap existence check. Translate that // SQLite/D1 constraint failure into the same conflict returned by the // pre-flight check instead of exposing it as a generic database error. -export function isDeveloperIdConflict(error: unknown): boolean { - return /UNIQUE constraint failed.*developers\.id/i.test( - errorMessageChain(error) - ); -} +export const isDeveloperIdConflict = uniqueConstraintMatcher(/developers\.id/); // Logs the real error server-side and returns a generic message to the // caller — DB exception text can leak schema/backend details otherwise. diff --git a/src/services/extensions/v2/extensions-database.ts b/src/services/extensions/v2/db/extensions.ts similarity index 66% rename from src/services/extensions/v2/extensions-database.ts rename to src/services/extensions/v2/db/extensions.ts index 79adfff..31cdd99 100644 --- a/src/services/extensions/v2/extensions-database.ts +++ b/src/services/extensions/v2/db/extensions.ts @@ -1,17 +1,18 @@ import { and, asc, eq, or, sql } from "drizzle-orm"; -import { DatabaseResult } from "../../../lib/interfaces"; -import { ExtensionsDb } from "../../../lib/db"; -import { extensions, developers } from "./db/schema"; +import { DatabaseResult } from "../../../../lib/interfaces"; +import { ExtensionsDb } from "../../../../lib/db"; +import { sortReleasesDescending } from "../../../../lib/releases"; +import { parseJSON } from "../../../../lib/json"; +import { extensions, developers } from "./schema"; import { databaseError } from "./errors"; +import { encodeCursor as encode, decodeCursor as decode } from "./cursor"; import { Extension, ExtensionListItem, License, Release, - Repository, - sortReleasesDescending, - parseJSON -} from "./interfaces"; + Repository +} from "../schemas/extensions"; // LEFT JOIN defensively preserves catalogue reads if a legacy/corrupt row // points at a missing developer. The current baseline enforces the @@ -39,25 +40,13 @@ const EXTENSION_COLUMNS = { developerOwnerUserId: developers.ownerUserId }; -const EXTENSION_LIST_COLUMNS = { - id: EXTENSION_COLUMNS.id, - type: EXTENSION_COLUMNS.type, - name: EXTENSION_COLUMNS.name, - description: EXTENSION_COLUMNS.description, - website: EXTENSION_COLUMNS.website, - license: EXTENSION_COLUMNS.license, - iconUrl: EXTENSION_COLUMNS.iconUrl, - source: EXTENSION_COLUMNS.source, - version: EXTENSION_COLUMNS.version, - downloadUrl: EXTENSION_COLUMNS.downloadUrl, - developerId: EXTENSION_COLUMNS.developerId, - developerType: EXTENSION_COLUMNS.developerType, - developerName: EXTENSION_COLUMNS.developerName, - developerUrl: EXTENSION_COLUMNS.developerUrl, - developerAvatarUrl: EXTENSION_COLUMNS.developerAvatarUrl, - developerApprovedAt: EXTENSION_COLUMNS.developerApprovedAt, - developerOwnerUserId: EXTENSION_COLUMNS.developerOwnerUserId -}; +// Derived by subtraction so a column added to EXTENSION_COLUMNS cannot be +// forgotten here: catalogue cards omit only the two large fields. +const { + readme: _readme, + releases: _releases, + ...EXTENSION_LIST_COLUMNS +} = EXTENSION_COLUMNS; interface ExtensionRow { id: string; @@ -97,7 +86,6 @@ export interface ExtensionListPage { } interface ExtensionCursor { - v: 1; normalizedId: string; id: string; } @@ -192,55 +180,42 @@ export class ExtensionsDatabase { } function encodeCursor(id: string): string { - const cursor: ExtensionCursor = { v: 1, normalizedId: id.toLowerCase(), id }; - const bytes = new TextEncoder().encode(JSON.stringify(cursor)); - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); + return encode({ normalizedId: id.toLowerCase(), id }); +} + +// normalizedId is checked against id rather than trusted: it drives the +// keyset comparison, so a tampered cursor could otherwise seek from a +// position the id itself doesn't correspond to. +function isExtensionCursor( + parsed: Record +): parsed is ExtensionCursor & Record { + return ( + typeof parsed.id === "string" && + typeof parsed.normalizedId === "string" && + parsed.normalizedId === parsed.id.toLowerCase() + ); } function decodeCursor(value: string): ExtensionCursor | null { - try { - const binary = atob(value); - const bytes = Uint8Array.from(binary, (character) => - character.charCodeAt(0) - ); - const parsed: unknown = JSON.parse( - new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes) - ); - if ( - typeof parsed !== "object" || - parsed === null || - (parsed as Partial).v !== 1 || - typeof (parsed as Partial).id !== "string" || - typeof (parsed as Partial).normalizedId !== "string" || - (parsed as ExtensionCursor).normalizedId !== - (parsed as ExtensionCursor).id.toLowerCase() - ) { - return null; - } - return parsed as ExtensionCursor; - } catch { - return null; - } + return decode(value, isExtensionCursor); } export function isValidExtensionCursor(value: string): boolean { return decodeCursor(value) !== null; } -function parseExtensionRow(row: ExtensionRow): Extension { - const releases = parseJSON(row.releases, []); +// Shared by both parsers so the catalogue card and the detail view can never +// disagree about the embedded developer, or about the defaults applied when +// the LEFT JOIN above found no developer row. +function parseExtensionListRow(row: ExtensionListRow): ExtensionListItem { return { id: row.id, - type: row.type as Extension["type"], + type: row.type as ExtensionListItem["type"], name: row.name, description: row.description, - releases: sortReleasesDescending(releases), website: row.website, license: parseJSON(row.license, { name: "" }), icon_url: row.iconUrl ?? undefined, - readme: row.readme, source: parseJSON(row.source, { type: "custom", repo: "" }), version: row.version, download_url: row.downloadUrl, @@ -256,26 +231,12 @@ function parseExtensionRow(row: ExtensionRow): Extension { }; } -function parseExtensionListRow(row: ExtensionListRow): ExtensionListItem { +// The detail view is the list projection plus the two large fields the +// catalogue query deliberately omits. +function parseExtensionRow(row: ExtensionRow): Extension { return { - id: row.id, - type: row.type as ExtensionListItem["type"], - name: row.name, - description: row.description, - website: row.website, - license: parseJSON(row.license, { name: "" }), - icon_url: row.iconUrl ?? undefined, - source: parseJSON(row.source, { type: "custom", repo: "" }), - version: row.version, - download_url: row.downloadUrl, - developer: { - id: row.developerId, - type: (row.developerType as "user" | "organization") ?? "user", - name: row.developerName ?? "", - URL: row.developerUrl ?? undefined, - avatar_url: row.developerAvatarUrl ?? undefined, - approved: row.developerApprovedAt !== null, - unclaimed: row.developerOwnerUserId === null - } + ...parseExtensionListRow(row), + readme: row.readme, + releases: sortReleasesDescending(parseJSON(row.releases, [])) }; } diff --git a/src/services/extensions/v2/db/migrations/0020_check_reserved_route_ids.sql b/src/services/extensions/v2/db/migrations/0020_check_reserved_route_ids.sql new file mode 100644 index 0000000..4fd2b84 --- /dev/null +++ b/src/services/extensions/v2/db/migrations/0020_check_reserved_route_ids.sql @@ -0,0 +1,32 @@ +-- Fails the migration if an adopted row holds an id that a static route +-- shadows, so the collision surfaces before a deploy rather than as a +-- permanently unreachable detail page. +-- +-- GET /extensions/mine is registered before GET /extensions/{id}, and +-- GET /developers/{me,claims,unapproved} before GET /developers/{id} (see +-- index.ts). A row carrying one of those ids is still listed by the +-- collection endpoints but its own detail page resolves to the static route +-- instead. New writes are rejected by schema validation and again at the +-- approval boundary; rows adopted from the pre-v2 catalogue predate both, +-- which is what this checks. +-- +-- Exact match, not lower(id): route matching is case-sensitive, so only an +-- exact-lowercase id collides. A row id'd "Mine" resolves normally and must +-- not fail a deploy. +-- +-- There is no RAISE() outside a trigger in SQLite, so the abort is a CHECK +-- violation on a scratch table. If this migration fails, do not rename the +-- row here - the id is public and consumers pin it. Decide deliberately. +CREATE TABLE _reserved_route_id_check (ok INTEGER NOT NULL CHECK (ok = 1)); + +INSERT INTO _reserved_route_id_check (ok) +SELECT + CASE + WHEN EXISTS (SELECT 1 FROM extensions WHERE id = 'mine') THEN 0 + WHEN EXISTS ( + SELECT 1 FROM developers WHERE id IN ('me', 'claims', 'unapproved') + ) THEN 0 + ELSE 1 + END; + +DROP TABLE _reserved_route_id_check; diff --git a/src/services/extensions/v2/db/migrations/meta/_journal.json b/src/services/extensions/v2/db/migrations/meta/_journal.json index 04550aa..cf97cfe 100644 --- a/src/services/extensions/v2/db/migrations/meta/_journal.json +++ b/src/services/extensions/v2/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1785916611192, "tag": "0019_add_user_deleted_at", "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1785916611193, + "tag": "0020_check_reserved_route_ids", + "breakpoints": true } ] } diff --git a/src/services/extensions/v2/db/schema.ts b/src/services/extensions/v2/db/schema.ts index f3dda2d..4f8e14a 100644 --- a/src/services/extensions/v2/db/schema.ts +++ b/src/services/extensions/v2/db/schema.ts @@ -88,7 +88,7 @@ export const developers = sqliteTable( approvedAt: text("approved_at"), // Placeholder default from migration 0002 (SQLite rejects non-constant // ALTER TABLE ADD COLUMN defaults). Every write sets this explicitly - // (see developers-database.ts) - the literal default is never actually + // (see db/developer-profiles.ts) - the literal default is never actually // read, but it's part of the real column definition so it's kept here // for baseline-diff fidelity against the existing database. createdAt: text("created_at").notNull().default("1970-01-01T00:00:00.000Z"), @@ -107,8 +107,8 @@ export const developers = sqliteTable( // githubOrgVerified itself. githubVerifiedAt: text("github_verified_at"), // Whether `url` matches GitHub's own on-file website — see - // github-verification.ts's urlMatchesGithubBlog(). Only ever 1 or null, - // never 0 (see the schema comment on interfaces.ts's + // github/verification.ts's urlMatchesGithubBlog(). Only ever 1 or null, + // never 0 (see the schema comment on schemas/developers.ts's // DeveloperProfileSchema.github_url_verified for why). githubUrlVerified: integer("github_url_verified"), // Atomic per-owner cooldown gating reverifyOwn()'s check_url path (the @@ -133,7 +133,7 @@ export const developers = sqliteTable( sql`${table.githubOrgVerified} IN (0, 1)` ), // = 1 rather than IN (0, 1): this column is documented to only ever be - // 1 or NULL, never 0 (see interfaces.ts's DeveloperProfileSchema + // 1 or NULL, never 0 (see schemas/developers.ts's DeveloperProfileSchema // comment) — SQLite's CHECK already treats NULL as satisfying `= 1`, so // this enforces that invariant instead of just validating it's a 0/1. check( diff --git a/src/services/extensions/v2/submissions-database.ts b/src/services/extensions/v2/db/submissions.ts similarity index 79% rename from src/services/extensions/v2/submissions-database.ts rename to src/services/extensions/v2/db/submissions.ts index c3571bf..675bd14 100644 --- a/src/services/extensions/v2/submissions-database.ts +++ b/src/services/extensions/v2/db/submissions.ts @@ -1,20 +1,17 @@ -import { and, asc, desc, eq, gt, lt, or, sql } from "drizzle-orm"; -import { DatabaseResult } from "../../../lib/interfaces"; -import { ExtensionsDb } from "../../../lib/db"; +import { and, asc, desc, eq, gt, lt, or, sql, SQL } from "drizzle-orm"; +import { DatabaseResult } from "../../../../lib/interfaces"; +import { ExtensionsDb } from "../../../../lib/db"; +import { extensionSubmissions, developers, extensions, users } from "./schema"; +import { databaseError } from "./errors"; +import { toD1Statement } from "./batch"; +import { encodeCursor as encode, decodeCursor as decode } from "./cursor"; +import { isReservedExtensionId } from "../schemas/extensions"; +import { isReservedDeveloperId } from "../schemas/developers"; import { - extensionSubmissions, - developers, - extensions, - users -} from "./db/schema"; -import { databaseError, errorMessageChain } from "./errors"; -import { toD1Statement } from "./d1-batch"; -import { - isReservedExtensionId, Submission, SubmissionPayload, SubmissionStatus -} from "./interfaces"; +} from "../schemas/submissions"; interface OwnershipResolution { extensionId: string | null; @@ -42,27 +39,23 @@ interface StoredSubmission extends Submission { const MAX_PENDING_SUBMISSIONS_PER_USER = 10; -function isPendingTargetConflict(error: unknown): boolean { - return /UNIQUE constraint failed.*extension_submissions/i.test( - errorMessageChain(error) - ); +interface SubmissionCursor { + createdAt: string; + id: string; } function encodeCursor(createdAt: string, id: string): string { - return btoa(JSON.stringify([createdAt, id])); + return encode({ createdAt, id }); } -function decodeCursor(cursor: string): [string, string] | null { - try { - const value = JSON.parse(atob(cursor)) as unknown; - return Array.isArray(value) && - value.length === 2 && - value.every((part) => typeof part === "string") - ? (value as [string, string]) - : null; - } catch { - return null; - } +function isSubmissionCursor( + parsed: Record +): parsed is SubmissionCursor & Record { + return typeof parsed.createdAt === "string" && typeof parsed.id === "string"; +} + +function decodeCursor(cursor: string): SubmissionCursor | null { + return decode(cursor, isSubmissionCursor); } interface SubmissionRow { @@ -215,91 +208,64 @@ export class SubmissionsDatabase { WHERE id = ${input.extensionId} AND author_id = d.id )) ) + ON CONFLICT DO NOTHING `); } catch (error) { - if (isPendingTargetConflict(error)) { - return { - data: null, - error: { - message: "A submission for this extension is already pending", - code: "CONFLICT" - } - }; - } return databaseError("create", error); } if (!result.meta?.changes) { - return { - data: null, - error: { - message: - "Submission could not be created because ownership changed, the target changed, or the pending-submission limit was reached", - code: "CONFLICT" - } - }; + try { + return { data: null, error: await this.createBlockedError(input) }; + } catch (error) { + return databaseError("create", error); + } } return { data: { id }, error: null }; } - async listBySubmitter( - userId: string, - limit: number, - cursor?: string - ): Promise> { - const decoded = cursor ? decodeCursor(cursor) : null; - if (cursor && !decoded) { + // The insert affected no rows, which means either its WHERE guard rejected + // the caller or ON CONFLICT DO NOTHING swallowed a collision with the + // pending-target unique index. Only the second case has a specific message, + // so look for the row that would have caused it; anything else falls back to + // the combined guard explanation. + private async createBlockedError( + input: CreateInput + ): Promise<{ message: string; code: string }> { + const targetKey = input.payload.extension.id.toLowerCase(); + const [pending] = await this.db + .select({ one: sql`1` }) + .from(extensionSubmissions) + .where( + and( + eq(extensionSubmissions.targetKey, targetKey), + eq(extensionSubmissions.status, "pending") + ) + ); + if (pending) { return { - data: null, - error: { message: "Invalid pagination cursor", code: "INVALID_CURSOR" } + message: "A submission for this extension is already pending", + code: "CONFLICT" }; } - let rows: SubmissionRow[]; - try { - const conditions = [eq(extensionSubmissions.submittedBy, userId)]; - if (decoded) { - const [createdAt, cursorId] = decoded; - conditions.push( - or( - lt(extensionSubmissions.createdAt, createdAt), - and( - eq(extensionSubmissions.createdAt, createdAt), - lt(extensionSubmissions.id, cursorId) - ) - )! - ); - } - rows = await this.db - .select(SUBMISSION_COLUMNS) - .from(extensionSubmissions) - .where(and(...conditions)) - .orderBy( - desc(extensionSubmissions.createdAt), - desc(extensionSubmissions.id) - ) - .limit(limit + 1); - } catch (error) { - return databaseError("listBySubmitter", error); - } - - const hasMore = rows.length > limit; - const items = rows.slice(0, limit).map(parseSubmissionRow); - const last = items.at(-1); return { - data: { - items, - hasMore, - nextCursor: - hasMore && last ? encodeCursor(last.created_at, last.id) : null - }, - error: null + message: + "Submission could not be created because ownership changed, the target changed, or the pending-submission limit was reached", + code: "CONFLICT" }; } - async listQueue( - status: SubmissionStatus, + // listBySubmitter and listQueue are the same keyset page in opposite + // directions: newest-first for a submitter reviewing their own history, + // oldest-first for moderators working a queue front to back. Only the base + // predicate and the direction differ, so the cursor handling, the tie-break + // on id, the limit + 1 probe and the next-cursor tail live here once. + private async page( + context: string, + baseCondition: SQL, + direction: "asc" | "desc", limit: number, cursor?: string ): Promise> { @@ -311,17 +277,20 @@ export class SubmissionsDatabase { }; } + const [beyond, order] = + direction === "desc" ? [lt, desc] : ([gt, asc] as const); + let rows: SubmissionRow[]; try { - const conditions = [eq(extensionSubmissions.status, status)]; + const conditions = [baseCondition]; if (decoded) { - const [createdAt, cursorId] = decoded; + const { createdAt, id: cursorId } = decoded; conditions.push( or( - gt(extensionSubmissions.createdAt, createdAt), + beyond(extensionSubmissions.createdAt, createdAt), and( eq(extensionSubmissions.createdAt, createdAt), - gt(extensionSubmissions.id, cursorId) + beyond(extensionSubmissions.id, cursorId) ) )! ); @@ -331,12 +300,12 @@ export class SubmissionsDatabase { .from(extensionSubmissions) .where(and(...conditions)) .orderBy( - asc(extensionSubmissions.createdAt), - asc(extensionSubmissions.id) + order(extensionSubmissions.createdAt), + order(extensionSubmissions.id) ) .limit(limit + 1); } catch (error) { - return databaseError("listQueue", error); + return databaseError(context, error); } const hasMore = rows.length > limit; @@ -353,6 +322,34 @@ export class SubmissionsDatabase { }; } + async listBySubmitter( + userId: string, + limit: number, + cursor?: string + ): Promise> { + return this.page( + "listBySubmitter", + eq(extensionSubmissions.submittedBy, userId), + "desc", + limit, + cursor + ); + } + + async listQueue( + status: SubmissionStatus, + limit: number, + cursor?: string + ): Promise> { + return this.page( + "listQueue", + eq(extensionSubmissions.status, status), + "asc", + limit, + cursor + ); + } + async getById(id: string): Promise> { let row: SubmissionRow | undefined; try { @@ -465,7 +462,10 @@ export class SubmissionsDatabase { // 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. + // written through to the public catalogue. The developer id is checked + // here too: approval only ever UPDATEs an existing developer row, so it + // cannot create a reserved profile, but it can still point a new + // extension at one that predates the reservation. if ( isReservedExtensionId(extension.id) || isReservedExtensionId(extensionId) @@ -478,6 +478,15 @@ export class SubmissionsDatabase { } }; } + if (isReservedDeveloperId(developer.id)) { + return { + data: null, + error: { + message: "This developer id is reserved", + code: "CONFLICT" + } + }; + } // Kept as raw sql via the raw D1 client (see toD1Statement) rather than // the query builder: D1's batch() executes these three statements as diff --git a/src/services/extensions/v2/users-database.ts b/src/services/extensions/v2/db/users.ts similarity index 82% rename from src/services/extensions/v2/users-database.ts rename to src/services/extensions/v2/db/users.ts index 1d6a482..a6c0a5e 100644 --- a/src/services/extensions/v2/users-database.ts +++ b/src/services/extensions/v2/db/users.ts @@ -1,9 +1,9 @@ import { and, eq, isNull } from "drizzle-orm"; -import { DatabaseResult } from "../../../lib/interfaces"; -import { ExtensionsDb } from "../../../lib/db"; -import { users } from "./db/schema"; +import { DatabaseResult } from "../../../../lib/interfaces"; +import { ExtensionsDb } from "../../../../lib/db"; +import { users } from "./schema"; import { databaseError } from "./errors"; -import { toD1Statement } from "./d1-batch"; +import { toD1Statement } from "./batch"; export type GithubIdentity = { githubLogin: string | null; @@ -54,37 +54,31 @@ function isFutureGithubOrgsExpiry( const match = RFC3339_TIMESTAMP.exec(value); if (!match) return false; - // Date.parse normalizes out-of-range calendar days (for example, - // 2025-02-30 becomes 2025-03-02) instead of rejecting them. Validate the - // date portion before parsing so malformed central-auth evidence cannot be - // treated as a usable, future membership snapshot. - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - if (month < 1 || month > 12 || day < 1) return false; - - const isLeapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); - const daysInMonth = [ - 31, - isLeapYear ? 29 : 28, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31 - ][month - 1]; - if (day > daysInMonth) return false; - - const hour = Number(match[4]); - const minute = Number(match[5]); - const second = Number(match[6]); - if (hour > 23 || minute > 59 || second > 59) return false; + // Date.parse normalizes out-of-range fields instead of rejecting them + // (2025-02-30 becomes 2025-03-02, 24:00 rolls to the next day), so a + // malformed central-auth timestamp would otherwise be accepted as usable, + // future membership evidence. Round-tripping through Date.UTC and checking + // every field survived is equivalent to validating them by hand, and gets + // month lengths and leap years from the platform rather than a table here. + const [year, month, day, hour, minute, second] = match + .slice(1, 7) + .map(Number); + const roundTrip = new Date( + Date.UTC(year, month - 1, day, hour, minute, second) + ); + if ( + roundTrip.getUTCFullYear() !== year || + roundTrip.getUTCMonth() !== month - 1 || + roundTrip.getUTCDate() !== day || + roundTrip.getUTCHours() !== hour || + roundTrip.getUTCMinutes() !== minute || + roundTrip.getUTCSeconds() !== second + ) { + return false; + } + // The offset is applied by Date.parse below rather than by the round trip, + // so its range is still checked directly. const offsetHour = match[7] === undefined ? 0 : Number(match[7]); const offsetMinute = match[8] === undefined ? 0 : Number(match[8]); if (offsetHour > 23 || offsetMinute > 59) return false; @@ -120,44 +114,29 @@ export class UsersDatabase { Array.isArray(input.githubOrgs) && isFutureGithubOrgsExpiry(input.githubOrgsExpiresAt); + const projection = { + 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 + }; + 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 - } - }) + .values({ ...projection, id: userId, createdAt: now }) + // Insert and update must write the same projection - a field added to + // one and not the other would apply to new accounts but silently skip + // returning ones, or the reverse. created_at is the only difference, + // and it is deliberately not re-stamped on conflict. + .onConflictDoUpdate({ target: users.id, set: projection }) .run(); return this.get(userId); @@ -366,7 +345,13 @@ export class UsersDatabase { } } - async isModerator(userId: string): Promise> { + // Moderator routes need to tell "account deactivated" (ACCOUNT_INACTIVE) + // apart from "not a moderator" (FORBIDDEN), and both answers live in the + // same row - so they read it once here rather than stacking an isActive() + // check in front of a separate moderator lookup. + async moderatorAccess( + userId: string + ): Promise> { try { const [row] = await this.db .select({ @@ -375,12 +360,13 @@ export class UsersDatabase { }) .from(users) .where(eq(users.id, userId)); + const active = row !== undefined && row.deletedAt === null; return { - data: row?.deletedAt == null && row?.isModerator === 1, + data: { active, moderator: active && row.isModerator === 1 }, error: null }; } catch (error) { - return databaseError("isModerator", error); + return databaseError("moderatorAccess", error); } } diff --git a/src/services/extensions/v2/developer-identity-verification.ts b/src/services/extensions/v2/github/identity.ts similarity index 95% rename from src/services/extensions/v2/developer-identity-verification.ts rename to src/services/extensions/v2/github/identity.ts index 34531cf..8dc77db 100644 --- a/src/services/extensions/v2/developer-identity-verification.ts +++ b/src/services/extensions/v2/github/identity.ts @@ -1,13 +1,13 @@ -import { DatabaseError } from "../../../lib/interfaces"; -import { ExtensionsDb } from "../../../lib/db"; +import { DatabaseError } from "../../../../lib/interfaces"; +import { ExtensionsDb } from "../../../../lib/db"; import { checkGithubEntity, GithubUnavailableReason, matchesClaimant, urlMatchesGithubBlog -} from "./github-verification"; -import { Developer } from "./interfaces"; -import { UsersDatabase } from "./users-database"; +} from "./verification"; +import { Developer } from "../schemas/developers"; +import { UsersDatabase } from "../db/users"; export type GithubOwnershipVerificationResult = | { mismatch: true } diff --git a/src/services/extensions/v2/github-verification.ts b/src/services/extensions/v2/github/verification.ts similarity index 84% rename from src/services/extensions/v2/github-verification.ts rename to src/services/extensions/v2/github/verification.ts index 7d8e21e..d33b6da 100644 --- a/src/services/extensions/v2/github-verification.ts +++ b/src/services/extensions/v2/github/verification.ts @@ -1,8 +1,13 @@ import { request as ghRequest } from "@octokit/request"; -import { classifyGitHubError, NotFoundError } from "../../../lib/github-errors"; -import { logWarn } from "../../../lib/logger"; -import { Developer } from "./interfaces"; -import { GithubIdentity } from "./users-database"; +import { + AuthError, + classifyGitHubError, + RateLimitError, + NotFoundError +} from "../../../../lib/github-errors"; +import { logWarn } from "../../../../lib/logger"; +import { Developer } from "../schemas/developers"; +import { GithubIdentity } from "../db/users"; // Used by DeveloperClaimsDatabase.claim() to gate self-service claims on an // unowned developer id: does a real GitHub org/user exist for this id, and @@ -103,26 +108,15 @@ export async function checkGithubEntity( ); if (githubError instanceof NotFoundError) return { status: "not_found" }; const message = redactedFailureMessage(githubError.message); + const status = githubError.httpStatus; - const rawError = - typeof error === "object" && error !== null - ? (error as Record) - : undefined; - const status = - typeof rawError?.status === "number" - ? rawError.status - : githubError.httpStatus; - const response = rawError?.response as - { headers?: Record } | undefined; - const isRateLimited = - status === 429 || - (status === 403 && - ((typeof rawError?.message === "string" && - rawError.message.toLowerCase().includes("rate limit")) || - response?.headers?.["x-ratelimit-remaining"] === "0")); - - if (isRateLimited) return unavailable(id, "rate_limited", status, message); - if (status === 401 || status === 403) { + // classifyGitHubError owns the rate-limit-versus-authorization decision + // for the whole repo, including the 429 and x-ratelimit-remaining signals + // this module used to re-derive from the raw error. + if (githubError instanceof RateLimitError) { + return unavailable(id, "rate_limited", status, message); + } + if (githubError instanceof AuthError) { return unavailable(id, "authentication", status, message); } if (githubError.errorCode === "validation_error") { diff --git a/src/services/extensions/v2/index.ts b/src/services/extensions/v2/index.ts index de4c5d8..3900f98 100644 --- a/src/services/extensions/v2/index.ts +++ b/src/services/extensions/v2/index.ts @@ -2,74 +2,13 @@ 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 { type Context, type MiddlewareHandler } from "hono"; -import { getAuth, requireAuth } from "../../../lib/auth"; -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"; -import { registerModerationRoutes } from "./moderation-routes"; -import { registerAccountRoutes } from "./account-routes"; -import { RouteDependencies } from "./route-dependencies"; - -const requireAuthAllowInactive = requireAuth; - -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 checkResponse = await check(c); - if (checkResponse) { - response = checkResponse; - } else { - 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 { - return withAuthenticatedCheck(async (c) => { - if (getAuth(c).scope !== "assertion") { - return c.json( - { - error: { - message: "Identity synchronization requires a trusted assertion", - code: "FORBIDDEN" - } - }, - 403 - ); - } - }); -} +import { registerPublicExtensionsRoutes } from "./routes/public-extensions"; +import { registerOwnerExtensionsRoutes } from "./routes/owner-extensions"; +import { registerSubmissionRoutes } from "./routes/submissions"; +import { registerDeveloperProfileRoutes } from "./routes/developer-profiles"; +import { registerOwnershipRoutes } from "./routes/ownership"; +import { registerModerationRoutes } from "./routes/moderation"; +import { registerAccountRoutes } from "./routes/account"; const extensionsV2 = new OpenAPIHono<{ Bindings: CloudflareBindings }>({ defaultHook: (result, c) => { @@ -90,7 +29,7 @@ const extensionsV2 = new OpenAPIHono<{ Bindings: CloudflareBindings }>({ // exposeHeaders: browsers hide non-safelisted response headers from // cross-origin JS by default; Retry-After (set on 429s, see -// developer-profile-routes.ts) needs an explicit expose so callers can read +// routes/developer-profiles.ts) needs an explicit expose so callers can read // it to schedule their retry. extensionsV2.use("/*", cors({ origin: "*", exposeHeaders: ["Retry-After"] })); extensionsV2.use("/*", trimTrailingSlash()); @@ -99,46 +38,21 @@ extensionsV2.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { scheme: "bearer" }); -function requireModerator(): MiddlewareHandler { - return async (c, next) => { - const auth = getAuth(c); - const users = new UsersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); - const result = await users.isModerator(auth.userId); - if (result.error) return c.json({ error: result.error }, 500); - if (!result.data) - return c.json( - { error: { message: "Moderator access required", code: "FORBIDDEN" } }, - 403 - ); - await next(); - }; -} - -const dependencies: RouteDependencies = { - database: getExtensionsDb, - auth: getAuth, - platform: getPlatform, - requireAuth: requireActiveAuth, - requireAuthAllowInactive, - requireIdentitySync, - requireModerator -}; - // Register the static owner route before the public parameter route // (/extensions/{id}) so the reserved "mine" segment is handled as the -// 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); -registerSubmissionRoutes(extensionsV2, dependencies); -registerOwnershipRoutes(extensionsV2, dependencies); -registerModerationRoutes(extensionsV2, dependencies); +// owner collection. New submissions reject the reserved id; adopted rows +// predate that, and migration 0020 fails if one is present. +registerOwnerExtensionsRoutes(extensionsV2); +registerPublicExtensionsRoutes(extensionsV2); +registerAccountRoutes(extensionsV2); +registerSubmissionRoutes(extensionsV2); +registerOwnershipRoutes(extensionsV2); +registerModerationRoutes(extensionsV2); // 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; existing rows -// require the same one-time release data check before enabling the route. -registerDeveloperProfileRoutes(extensionsV2, dependencies); +// The "me" namespace is reserved for the owner profile route; adopted rows +// are covered by the same migration 0020 check. +registerDeveloperProfileRoutes(extensionsV2); extensionsV2.doc31("/openapi.json", { openapi: "3.1.0", diff --git a/src/services/extensions/v2/interfaces.ts b/src/services/extensions/v2/interfaces.ts deleted file mode 100644 index 4437110..0000000 --- a/src/services/extensions/v2/interfaces.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { z } from "@hono/zod-openapi"; -import { sortReleasesDescending } from "../../../lib/releases"; -import { parseJSON } from "../../../lib/json"; - -export { sortReleasesDescending, parseJSON }; - -export const EXTENSION_TYPES = [ - "mod", - "theme", - "payment-gateway", - "server-manager", - "domain-registrar", - "hook", - "translation" -] as const; - -// Lowercase alphanumeric slug (hyphens allowed, no leading/trailing hyphen) — -// matches the shape of existing ids (e.g. "fossbilling") and rules out -// anything that isn't safe to use as a URL path segment or DOM identifier. -const lowercaseId = (label: string) => - z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, { - message: `${label} id must be a lowercase alphanumeric slug` - }); - -// Restricts to http(s) — z.string().url() alone accepts any scheme, -// including javascript:/data:, which is unsafe for fields a consumer may -// render as a link or image src. -const httpUrl = () => - z - .string() - .max(2048) - .url() - .refine((value) => /^https?:\/\//i.test(value), { - message: "must use http or https" - }); - -// GET /developers/{id} is registered after the static single-segment -// 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; 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 = () => - lowercaseId("developer").refine((id) => !RESERVED_DEVELOPER_IDS.has(id), { - 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; 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(), - type: z.enum(["user", "organization"]), - name: z.string().min(1).max(120), - URL: httpUrl().optional(), - avatar_url: httpUrl().optional(), - contact_email: z.string().email().max(254).optional() - }) - .openapi("Developer"); - -export type Developer = z.infer; - -// Submissions go through moderation and only ever touch identity fields — -// profile fields (avatar_url/contact_email) are direct-write-only via -// PUT /developers/me, so this schema rejects them instead of silently -// accepting-then-dropping them when a submission is approved. -export const SubmissionDeveloperSchema = DeveloperSchema.pick({ - id: true, - type: true, - name: true, - URL: true -}) - .strict() - .openapi("SubmissionDeveloper"); - -export const ReleaseSchema = z - .object({ - tag: z.string().min(1).max(100), - date: z.string().min(1).max(64), - download_url: httpUrl(), - changelog_url: httpUrl().optional(), - min_fossbilling_version: z.string().min(1).max(100) - }) - .strict() - .openapi("Release"); - -export type Release = z.infer; - -export const RepositorySchema = z - .object({ - type: z.enum(["github", "gitlab", "custom"]), - repo: z.string().min(1).max(500) - }) - .strict() - .openapi("Repository"); - -export type Repository = z.infer; - -export const LicenseSchema = z - .object({ - name: z.string().min(1).max(100), - URL: httpUrl().optional() - }) - .strict() - .openapi("License"); - -export type License = z.infer; - -export const ExtensionPayloadSchema = z - .object({ - id: lowercaseId("extension"), - type: z.enum(EXTENSION_TYPES), - name: z.string().min(1).max(120), - description: z.string().min(1).max(4000), - releases: z.array(ReleaseSchema).min(1).max(100), - website: httpUrl(), - license: LicenseSchema, - icon_url: httpUrl().optional(), - readme: z.string().min(1).max(100_000), - source: RepositorySchema, - version: z.string().min(1).max(100), - download_url: httpUrl() - }) - .strict() - .openapi("ExtensionPayload"); - -export const SubmissionPayloadSchema = z - .object({ - developer: SubmissionDeveloperSchema, - extension: ExtensionPayloadSchema - }) - .strict() - .superRefine((payload, ctx) => { - if (isReservedExtensionId(payload.extension.id)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "This extension id is reserved", - path: ["extension", "id"] - }); - } - const size = new TextEncoder().encode(JSON.stringify(payload)).byteLength; - if (size > 256 * 1024) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Submission payload must not exceed 256 KiB" - }); - } - }) - .openapi("SubmissionPayload"); - -export type SubmissionPayload = z.infer; - -export const DeveloperProfileSchema = DeveloperSchema.extend({ - approved: z.boolean(), - content_revision: z.int().positive(), - // Server-computed — see verifyGithubOwnership() (at - // claim/creation time) and reverifyOwn() (opportunistic re-check on - // login, or the owner's own "Re-verify" action). Never part of the - // client-supplied DeveloperSchema. - github_org_verified: z.boolean().optional(), - github_verification_note: z.string().optional(), - // Set whenever github_org_verified is last (re-)computed to a definitive - // true/false — see reverifyOwn(). Absent/stale on an inconclusive check. - github_verified_at: z.string().nullish(), - // Whether Publisher URL matches GitHub's own on-file website for this - // org/user — see verifyGithubOwnership()'s urlMatchesGithubBlog() call. - // Only ever true or absent (never false): GitHub's website field is - // optional and often unset, so "doesn't match" isn't itself meaningful — - // there's nothing to flag, unlike github_org_verified's identity check. - // Computed at creation, and re-checked by the owner's own "Re-verify" - // action (never by the opportunistic per-login re-check, which stays - // GitHub-API-free by design). - github_url_verified: z.boolean().optional(), - // Only populated by the moderator listAll/listUnapproved queries (see - // DeveloperProfilesDatabase.listAll/listUnapproved) — other DeveloperProfile - // producers (getById, create/update/claim/transfer results) don't join - // for it, so it's absent rather than null there. `unclaimed` is the - // authoritative "has an owner" signal (owner_user_id IS NULL) — don't - // infer ownership from owner_name being present, since a real owner can - // still have a null name (e.g. their auth provider never supplied one). - // Owner identity is never exposed publicly — see PublicDeveloperSchema - // below and the README's note on not leaking owner identity. - unclaimed: z.boolean().optional(), - owner_name: z.string().nullish(), - owner_github_login: z.string().nullish() -}).openapi("DeveloperProfile"); - -export type DeveloperProfile = z.infer; - -// The publicly-readable view of a developer profile: everything in -// 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). The Extensions site consumes this -// projection through the generated API client. -export const PublicDeveloperSchema = DeveloperProfileSchema.omit({ - contact_email: true, - content_revision: true, - github_org_verified: true, - github_verification_note: true, - github_verified_at: true, - github_url_verified: true, - owner_name: true, - owner_github_login: true -}) - .extend({ unclaimed: z.boolean() }) - .openapi("PublicDeveloper"); - -export type PublicDeveloper = z.infer; - -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, - unclaimed: profile.unclaimed - }; -} - -export const ExtensionSchema = ExtensionPayloadSchema.extend({ - developer: PublicDeveloperSchema -}).openapi("Extension"); - -export type Extension = z.infer; - -// Catalogue cards do not need the potentially large README or every historic -// release. Consumers can fetch those fields from GET /extensions/{id} when a -// visitor opens an extension's detail page. -export const ExtensionListItemSchema = ExtensionSchema.omit({ - readme: true, - releases: true -}).openapi("ExtensionListItem"); - -export type ExtensionListItem = z.infer; - -export const ExtensionListQuerySchema = z.object({ - type: z - .enum(EXTENSION_TYPES) - .optional() - .openapi({ - param: { name: "type", in: "query" } - }), - developer_id: z - .string() - .optional() - .openapi({ - param: { name: "developer_id", in: "query" } - }), - limit: z.coerce - .number() - .int() - .min(1) - .max(100) - .default(50) - .openapi({ param: { name: "limit", in: "query" } }), - cursor: z - .string() - .min(1) - .max(1000) - .optional() - .openapi({ - param: { name: "cursor", in: "query" }, - description: "Opaque cursor returned by the previous page" - }) -}); - -// 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), - pagination: z.object({ - next_cursor: z.string().nullable(), - has_more: z.boolean() - }) - }) - .openapi("ExtensionListResponse"); - -export const DeveloperHistoryEntrySchema = z - .object({ - developer_id: z.string(), - type: z.enum(["user", "organization"]), - name: z.string(), - URL: httpUrl().optional(), - changed_by: z.string(), - // The editor's account name at read time — null if the auth provider - // never gave one, or the users row was since deleted. - changed_by_name: z.string().nullable(), - changed_at: z.string() - }) - .openapi("DeveloperHistoryEntry"); - -export type DeveloperHistoryEntry = z.infer; - -export const ReviewNoteOptionalSchema = z - .object({ - review_note: z.string().max(2000).optional() - }) - .openapi("ReviewNoteOptional"); - -export const ReviewNoteRequiredSchema = z - .object({ - review_note: z.string().min(1).max(2000) - }) - .openapi("ReviewNoteRequired"); - -export const SubmissionStatusSchema = z.enum([ - "pending", - "approved", - "rejected" -]); - -export type SubmissionStatus = z.infer; - -export const SubmissionSchema = z - .object({ - id: z.string(), - extension_id: z.string().nullable(), - developer_id: z.string(), - submitted_by: z.string(), - status: SubmissionStatusSchema, - payload: SubmissionPayloadSchema, - reviewer_id: z.string().nullable(), - review_note: z.string().nullable(), - created_at: z.string(), - reviewed_at: z.string().nullable() - }) - .openapi("Submission"); - -export type Submission = z.infer; - -export const ErrorResponseSchema = z - .object({ - error: z.object({ - message: z.string(), - code: z.string(), - details: z - .array( - z.unknown().openapi({ - type: ["string", "number", "boolean", "object", "array", "null"] - }) - ) - .optional() - }) - }) - .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. -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" }, - example: "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" - }) -}); - -export const TransferAcceptanceSchema = z - .object({ token: z.string().min(64).max(128) }) - .strict() - .openapi("TransferAcceptance"); - -export const DeveloperApprovalSchema = z - .object({ expected_revision: z.number().int().positive() }) - .strict() - .openapi("DeveloperApproval"); - -export const DeveloperTransferSchema = z - .object({ - token: z.string(), - expires_at: z.string() - }) - .openapi("DeveloperTransfer"); - -export type DeveloperTransfer = z.infer; - -export const DeveloperClaimSchema = z - .object({ - id: z.string(), - developer_id: z.string(), - claimant_id: z.string(), - status: z.enum(["pending", "approved", "rejected"]), - note: z.string().optional(), - review_note: z.string().optional(), - reviewer_id: z.string().optional(), - created_at: z.string(), - reviewed_at: z.string().optional(), - // Server-computed at claim() time only — never accepted from the - // client (see ClaimNoteSchema below). Undefined when there was no - // verifiable GitHub org/user for this id, or the claimant had no linked - // GitHub identity yet; both fall back to manual moderator review. An - // absent value is not proof of ownership and must not bypass approval. - github_org_verified: z.boolean().optional(), - github_verification_note: z.string().optional() - }) - .openapi("DeveloperClaim"); - -export type DeveloperClaim = z.infer; - -export const PendingDeveloperClaimSchema = DeveloperClaimSchema.extend({ - developer_name: z.string(), - developer_type: z.enum(["user", "organization"]), - // The claimant's own account name/GitHub handle, so the moderator sees - // who's asking instead of just their opaque id. Null if the auth - // provider never gave a name, or the claimant hasn't linked GitHub yet. - claimant_name: z.string().nullable(), - claimant_github_login: z.string().nullable() -}).openapi("PendingDeveloperClaim"); - -export type PendingDeveloperClaim = z.infer; - -export const ClaimNoteSchema = z - .object({ - note: z.string().max(500).optional() - }) - .openapi("ClaimNote"); - -// check_url — opt-in because it costs an extra GitHub API call (see -// DeveloperProfilesDatabase.reverifyOwn()); only the owner's own manual "Re-verify" -// button sets this, never the opportunistic per-login re-check. Not -// z.coerce.boolean(): that coerces the non-empty string "false" to true. -export const ReverifyQuerySchema = z.object({ - check_url: z - .enum(["true", "false"]) - .optional() - .transform((value) => value === "true") - .openapi({ - param: { name: "check_url", in: "query" } - }) -}); - -export const QueueQuerySchema = z.object({ - status: SubmissionStatusSchema.optional().openapi({ - param: { name: "status", in: "query" } - }), - limit: z.coerce - .number() - .int() - .min(1) - .max(100) - .default(50) - .openapi({ - param: { name: "limit", in: "query" } - }), - cursor: z - .string() - .max(1000) - .optional() - .openapi({ - param: { name: "cursor", in: "query" } - }) -}); - -export const SubmissionPageQuerySchema = QueueQuerySchema.pick({ - limit: true, - cursor: true -}); - -export const PaginationSchema = z - .object({ - next_cursor: z.string().nullable(), - has_more: z.boolean() - }) - .openapi("Pagination"); diff --git a/src/services/extensions/v2/middleware.ts b/src/services/extensions/v2/middleware.ts new file mode 100644 index 0000000..55ccd64 --- /dev/null +++ b/src/services/extensions/v2/middleware.ts @@ -0,0 +1,74 @@ +import { type Context, type MiddlewareHandler } from "hono"; +import { getAuth, requireAuth } from "../../../lib/auth"; +import { getExtensionsDb } from "../../../lib/db"; +import { UsersDatabase } from "./db/users"; + +export const requireAuthAllowInactive = requireAuth; + +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 checkResponse = await check(c); + if (checkResponse) { + response = checkResponse; + } else { + await next(); + } + }); + return response ?? authenticationResult; + }; +} + +const inactiveAccountResponse = { + error: { + message: "Active account required", + code: "ACCOUNT_INACTIVE" + } +} as const; + +export 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(inactiveAccountResponse, 403); + }); +} + +export function requireIdentitySync(): MiddlewareHandler { + return withAuthenticatedCheck(async (c) => { + if (getAuth(c).scope !== "assertion") { + return c.json( + { + error: { + message: "Identity synchronization requires a trusted assertion", + code: "FORBIDDEN" + } + }, + 403 + ); + } + }); +} + +// Moderator routes list this alone, not behind requireActiveAuth(): it +// authenticates through the same combinator and answers both the active and +// the moderator question from one row. The two 403s are distinct on purpose - +// a deactivated moderator gets ACCOUNT_INACTIVE, not FORBIDDEN. +export function requireModerator(): MiddlewareHandler { + return withAuthenticatedCheck(async (c) => { + const users = new UsersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); + const result = await users.moderatorAccess(getAuth(c).userId); + if (result.error) return c.json({ error: result.error }, 500); + if (!result.data?.active) return c.json(inactiveAccountResponse, 403); + if (!result.data.moderator) + return c.json( + { error: { message: "Moderator access required", code: "FORBIDDEN" } }, + 403 + ); + }); +} diff --git a/src/services/extensions/v2/route-dependencies.ts b/src/services/extensions/v2/route-dependencies.ts deleted file mode 100644 index ff50026..0000000 --- a/src/services/extensions/v2/route-dependencies.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { OpenAPIHono } from "@hono/zod-openapi"; -import { getAuth, requireAuth } from "../../../lib/auth"; -import { getExtensionsDb } from "../../../lib/db"; -import { getPlatform } from "../../../lib/middleware"; -import { MiddlewareHandler } from "hono"; - -export type ExtensionsV2App = OpenAPIHono<{ - Bindings: CloudflareBindings; -}>; - -export interface RouteDependencies { - database: typeof getExtensionsDb; - 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; - // 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/account-routes.ts b/src/services/extensions/v2/routes/account.ts similarity index 55% rename from src/services/extensions/v2/account-routes.ts rename to src/services/extensions/v2/routes/account.ts index 8368dfe..c581311 100644 --- a/src/services/extensions/v2/account-routes.ts +++ b/src/services/extensions/v2/routes/account.ts @@ -1,15 +1,23 @@ +import { + requireActiveAuth, + requireAuthAllowInactive, + requireIdentitySync +} from "../middleware"; +import { getExtensionsDb } from "../../../../lib/db"; import { createRoute, z } from "@hono/zod-openapi"; -import { getAuth } from "../../../lib/auth"; -import { statusFromErrorCode } from "./route-errors"; +import { getAuth } from "../../../../lib/auth"; +import { errorBody, statusFromErrorCode } from "./errors"; import { ActiveAccountRequiredResponse, - ErrorResponseSchema, + errorResponse +} from "../schemas/common"; +import { UserIdentityInputSchema, UserProfileUpdateSchema, UserSchema -} from "./interfaces"; -import { UsersDatabase } from "./users-database"; -import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; +} from "../schemas/users"; +import { UsersDatabase } from "../db/users"; +import { ExtensionsV2App } from "./app"; function toUserResponse(user: { displayName: string | null; @@ -25,17 +33,14 @@ function toUserResponse(user: { }; } -export function registerAccountRoutes( - app: ExtensionsV2App, - dependencies: RouteDependencies -): void { +export function registerAccountRoutes(app: ExtensionsV2App): void { const syncIdentityRoute = createRoute({ method: "put", path: "/users/me/identity", tags: ["Users"], summary: "Synchronize the caller's OIDC identity projection", security: [{ Bearer: [] }], - middleware: [dependencies.requireIdentitySync()] as const, + middleware: [requireIdentitySync()] as const, request: { body: { content: { "application/json": { schema: UserIdentityInputSchema } } @@ -48,22 +53,13 @@ export function registerAccountRoutes( }, description: "Identity projection synchronized" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("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" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 422: errorResponse("Identity payload failed validation"), + 500: errorResponse("Database error") } }); @@ -74,7 +70,7 @@ export function registerAccountRoutes( // 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)); + const users = new UsersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const result = await users.syncIdentity(auth.userId, { name: body.name, email: body.email, @@ -85,15 +81,7 @@ export function registerAccountRoutes( 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(errorBody(result.error, "Unable to sync identity"), 500); } return c.json({ result: toUserResponse(result.data) }, 200); }); @@ -104,7 +92,7 @@ export function registerAccountRoutes( tags: ["Users"], summary: "Get the caller's account projection", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuthAllowInactive()] as const, + middleware: [requireAuthAllowInactive()] as const, responses: { 200: { content: { @@ -112,24 +100,15 @@ export function registerAccountRoutes( }, 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" - } + 401: errorResponse("Missing or invalid bearer token"), + 404: errorResponse("Account does not exist"), + 500: errorResponse("Database error") } }); app.openapi(getUserRoute, async (c) => { const auth = getAuth(c); - const users = new UsersDatabase(dependencies.database(c.env.DB_EXTENSIONS)); + const users = new UsersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const result = await users.get(auth.userId); if (result.error && result.error.code !== "NOT_FOUND") { return c.json( @@ -157,7 +136,7 @@ export function registerAccountRoutes( tags: ["Users"], summary: "Update the caller's personal profile", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { body: { content: { "application/json": { schema: UserProfileUpdateSchema } } @@ -174,56 +153,28 @@ export function registerAccountRoutes( }, description: "Profile updated" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: ActiveAccountRequiredResponse, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Account does not exist or has been deleted" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("Account does not exist or has been deleted"), + 500: errorResponse("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 users = new UsersDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); + // No existence pre-check: updateDisplayName's WHERE already carries + // `deleted_at IS NULL` and reports the same NOT_FOUND on zero changes, + // so reading the row first only added a round trip to a request that + // requireActiveAuth has already validated. 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" - } - }, + errorBody(result.error, "Unable to update profile"), statusFromErrorCode(result.error?.code, false) ); } @@ -236,7 +187,7 @@ export function registerAccountRoutes( tags: ["Users"], summary: "Delete the caller's account and tombstone its user row", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuthAllowInactive()] as const, + middleware: [requireAuthAllowInactive()] as const, responses: { 200: { content: { @@ -246,37 +197,20 @@ export function registerAccountRoutes( }, 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" - } + 401: errorResponse("Missing or invalid bearer token"), + 404: errorResponse("Account does not exist"), + 409: errorResponse("Account still owns protected domain records"), + 500: errorResponse("Database error") } }); app.openapi(deleteUserRoute, async (c) => { const auth = getAuth(c); - const users = new UsersDatabase(dependencies.database(c.env.DB_EXTENSIONS)); + const users = new UsersDatabase(getExtensionsDb(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" - } - }, + errorBody(result.error, "Unable to delete account"), statusFromErrorCode(result.error?.code) ); } diff --git a/src/services/extensions/v2/routes/app.ts b/src/services/extensions/v2/routes/app.ts new file mode 100644 index 0000000..5d0a679 --- /dev/null +++ b/src/services/extensions/v2/routes/app.ts @@ -0,0 +1,5 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; + +export type ExtensionsV2App = OpenAPIHono<{ + Bindings: CloudflareBindings; +}>; diff --git a/src/services/extensions/v2/developer-profile-routes.ts b/src/services/extensions/v2/routes/developer-profiles.ts similarity index 54% rename from src/services/extensions/v2/developer-profile-routes.ts rename to src/services/extensions/v2/routes/developer-profiles.ts index b77edc7..ffb39ae 100644 --- a/src/services/extensions/v2/developer-profile-routes.ts +++ b/src/services/extensions/v2/routes/developer-profiles.ts @@ -1,30 +1,37 @@ +import { requireActiveAuth } from "../middleware"; +import { getExtensionsDb } from "../../../../lib/db"; +import { getPlatform } from "../../../../lib/middleware"; +import { getAuth } from "../../../../lib/auth"; import { createRoute, z } from "@hono/zod-openapi"; -import { statusFromErrorCode, statusFromGithubErrorCode } from "./route-errors"; +import { + errorBody, + statusFromErrorCode, + statusFromGithubErrorCode +} from "./errors"; import { ActiveAccountRequiredResponse, - DeveloperProfileSchema, - DeveloperSchema, - ErrorResponseSchema, IdParamSchema, + errorResponse +} from "../schemas/common"; +import { + DeveloperProfileSchema, + DeveloperInputSchema, OwnedDeveloperProfileSchema, PublicDeveloperSchema, ReverifyQuerySchema, toPublicDeveloper -} from "./interfaces"; -import { DeveloperProfilesDatabase } from "./developer-profiles-database"; -import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; +} from "../schemas/developers"; +import { DeveloperProfilesDatabase } from "../db/developer-profiles"; +import { ExtensionsV2App } from "./app"; -export function registerDeveloperProfileRoutes( - app: ExtensionsV2App, - dependencies: RouteDependencies -): void { +export function registerDeveloperProfileRoutes(app: ExtensionsV2App): 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, + middleware: [requireActiveAuth()] as const, responses: { 200: { content: { @@ -34,22 +41,16 @@ export function registerDeveloperProfileRoutes( }, description: "The caller's profile, or null when none exists" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: ActiveAccountRequiredResponse, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 500: errorResponse("Database error") } }); app.openapi(getOwnDeveloperRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const db = new DeveloperProfilesDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.getOwn(auth.userId); if (error || data === null) { @@ -75,10 +76,10 @@ export function registerDeveloperProfileRoutes( tags: ["Developers"], summary: "Create or update the caller's own developer profile", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { body: { - content: { "application/json": { schema: DeveloperSchema } } + content: { "application/json": { schema: DeveloperInputSchema } } } }, responses: { @@ -91,47 +92,32 @@ export function registerDeveloperProfileRoutes( description: "Developer profile created or updated and usable immediately" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "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 } }, - description: - "Developer id already taken by someone else, or id was changed on an existing profile" - }, - 429: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: - "The account exhausted its profile-creation allowance, or GitHub verification is temporarily rate limited" - }, - 503: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "GitHub verification is temporarily unavailable" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: - "Payload failed validation, or the GitHub account type is unsupported" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 409: errorResponse( + "Developer id already taken by someone else, or id was changed on an existing profile" + ), + 429: errorResponse( + "The account exhausted its profile-creation allowance, or GitHub verification is temporarily rate limited" + ), + 503: errorResponse("GitHub verification is temporarily unavailable"), + 422: errorResponse( + "Payload failed validation, or the GitHub account type is unsupported" + ), + 500: errorResponse("Database error") } }); app.openapi(upsertOwnDeveloperRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const body = c.req.valid("json"); - const platform = dependencies.platform(c); + const platform = getPlatform(c); const db = new DeveloperProfilesDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.upsertOwn( auth.userId, @@ -158,12 +144,7 @@ export function registerDeveloperProfileRoutes( ? 409 : statusFromGithubErrorCode(error?.code, 500); const response = c.json( - { - error: { - message: error?.message ?? "Unable to save developer profile", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to save developer profile"), status ); if (error?.code === "PROFILE_CREATION_RATE_LIMITED") { @@ -180,7 +161,7 @@ export function registerDeveloperProfileRoutes( tags: ["Developers"], summary: "Permanently delete the caller's own developer profile", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, responses: { 200: { content: { @@ -192,41 +173,25 @@ export function registerDeveloperProfileRoutes( }, description: "Profile deleted" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: ActiveAccountRequiredResponse, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller has no developer profile" - }, - 409: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: - "Profile still has published extensions, or has a pending submission awaiting review" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("Caller has no developer profile"), + 409: errorResponse( + "Profile still has published extensions, or has a pending submission awaiting review" + ), + 500: errorResponse("Database error") } }); app.openapi(deleteOwnDeveloperRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const db = new DeveloperProfilesDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.deleteOwn(auth.userId); if (error || !data) { return c.json( - { - error: { - message: error?.message ?? "Unable to delete developer profile", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to delete developer profile"), error?.code === "ACCOUNT_INACTIVE" ? 403 : statusFromErrorCode(error?.code) @@ -242,7 +207,7 @@ export function registerDeveloperProfileRoutes( summary: "Re-check the caller's linked GitHub identity against their own developer profile", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { query: ReverifyQuerySchema }, responses: { 200: { @@ -253,45 +218,25 @@ export function registerDeveloperProfileRoutes( }, description: "Verification re-checked (result may be verified or not)" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: ActiveAccountRequiredResponse, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller has no developer profile" - }, - 409: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Developer ownership changed while re-verifying" - }, - 429: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: - "check_url was used again too soon, or GitHub verification is rate limited" - }, - 503: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "GitHub verification is temporarily unavailable" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "The GitHub account type is unsupported" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("Caller has no developer profile"), + 409: errorResponse("Developer ownership changed while re-verifying"), + 429: errorResponse( + "check_url was used again too soon, or GitHub verification is rate limited" + ), + 503: errorResponse("GitHub verification is temporarily unavailable"), + 422: errorResponse("The GitHub account type is unsupported"), + 500: errorResponse("Database error") } }); app.openapi(reverifyOwnDeveloperRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { check_url } = c.req.valid("query"); - const platform = dependencies.platform(c); + const platform = getPlatform(c); const db = new DeveloperProfilesDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.reverifyOwn( auth.userId, @@ -307,12 +252,7 @@ export function registerDeveloperProfileRoutes( statusFromErrorCode(error?.code) ); return c.json( - { - error: { - message: error?.message ?? "Unable to re-verify developer profile", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to re-verify developer profile"), status ); } @@ -336,38 +276,21 @@ export function registerDeveloperProfileRoutes( }, description: "The developer's public profile" }, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No developer with that id" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "id param failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("No developer with that id"), + 422: errorResponse("id param failed validation"), + 500: errorResponse("Database error") } }); app.openapi(getDeveloperRoute, async (c) => { const { id } = c.req.valid("param"); const db = new DeveloperProfilesDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.getById(id); if (error || !data) { const status = statusFromErrorCode(error?.code, false); - return c.json( - { - error: { - message: error?.message ?? "Developer not found", - code: error?.code ?? "DATABASE_ERROR" - } - }, - status - ); + return c.json(errorBody(error, "Developer not found"), status); } return c.json({ result: toPublicDeveloper(data) }, 200); }); diff --git a/src/services/extensions/v2/route-errors.ts b/src/services/extensions/v2/routes/errors.ts similarity index 64% rename from src/services/extensions/v2/route-errors.ts rename to src/services/extensions/v2/routes/errors.ts index c379b01..64d4fa2 100644 --- a/src/services/extensions/v2/route-errors.ts +++ b/src/services/extensions/v2/routes/errors.ts @@ -1,3 +1,5 @@ +import { DatabaseError } from "../../../../lib/interfaces"; + // Routes that do not declare a 409 response pass false so unexpected conflict // codes remain an internal error rather than escaping their OpenAPI contract. export function statusFromErrorCode( @@ -32,3 +34,19 @@ export function statusFromOwnershipErrorCode(code?: string): 403 | 404 | 500 { if (code === "FORBIDDEN" || code === "ACCOUNT_INACTIVE") return 403; return 500; } + +// Every handler reports a failed DatabaseResult the same way: the database's +// own message and code when it supplied one, a route-specific fallback and +// DATABASE_ERROR when it did not. The status stays at the call site, since +// each route documents its own set in the OpenAPI contract. +export function errorBody( + error: DatabaseError | null | undefined, + fallbackMessage: string +) { + return { + error: { + message: error?.message ?? fallbackMessage, + code: error?.code ?? "DATABASE_ERROR" + } + }; +} diff --git a/src/services/extensions/v2/moderation-routes.ts b/src/services/extensions/v2/routes/moderation.ts similarity index 55% rename from src/services/extensions/v2/moderation-routes.ts rename to src/services/extensions/v2/routes/moderation.ts index c07fcc0..0da824d 100644 --- a/src/services/extensions/v2/moderation-routes.ts +++ b/src/services/extensions/v2/routes/moderation.ts @@ -1,36 +1,34 @@ +import { requireModerator } from "../middleware"; +import { getExtensionsDb } from "../../../../lib/db"; +import { getAuth } from "../../../../lib/auth"; import { createRoute, z } from "@hono/zod-openapi"; -import { statusFromErrorCode } from "./route-errors"; +import { errorBody, statusFromErrorCode } from "./errors"; import { ActiveAccountRequiredResponse, - DeveloperApprovalSchema, - DeveloperHistoryEntrySchema, - DeveloperProfileSchema, - ErrorResponseSchema, IdParamSchema, PaginationSchema, - QueueQuerySchema, ReviewNoteOptionalSchema, ReviewNoteRequiredSchema, - SubmissionSchema -} from "./interfaces"; -import { DeveloperProfilesDatabase } from "./developer-profiles-database"; -import { SubmissionsDatabase } from "./submissions-database"; -import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; + errorResponse +} from "../schemas/common"; +import { + DeveloperApprovalSchema, + DeveloperHistoryEntrySchema, + DeveloperProfileSchema +} from "../schemas/developers"; +import { QueueQuerySchema, SubmissionSchema } from "../schemas/submissions"; +import { DeveloperProfilesDatabase } from "../db/developer-profiles"; +import { SubmissionsDatabase } from "../db/submissions"; +import { ExtensionsV2App } from "./app"; -export function registerModerationRoutes( - app: ExtensionsV2App, - dependencies: RouteDependencies -): void { +export function registerModerationRoutes(app: ExtensionsV2App): void { const queueRoute = createRoute({ method: "get", path: "/submissions/queue", tags: ["Moderation"], summary: "List submissions in the moderation queue", security: [{ Bearer: [] }], - middleware: [ - dependencies.requireAuth(), - dependencies.requireModerator() - ] as const, + middleware: [requireModerator()] as const, request: { query: QueueQuerySchema }, responses: { 200: { @@ -45,29 +43,18 @@ export function registerModerationRoutes( description: "Submissions matching the requested status (default: pending)" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "status query param failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 422: errorResponse("status query param failed validation"), + 500: errorResponse("Database error") } }); app.openapi(queueRoute, async (c) => { - const db = new SubmissionsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) - ); + const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { status, limit, cursor } = c.req.valid("query"); const { data, error } = await db.listQueue( status ?? "pending", @@ -76,12 +63,7 @@ export function registerModerationRoutes( ); if (error || !data) { return c.json( - { - error: { - message: error?.message ?? "Unable to load queue", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to load queue"), error?.code === "INVALID_CURSOR" ? 422 : 500 ); } @@ -103,10 +85,7 @@ export function registerModerationRoutes( tags: ["Moderation"], summary: "Approve a pending submission", security: [{ Bearer: [] }], - middleware: [ - dependencies.requireAuth(), - dependencies.requireModerator() - ] as const, + middleware: [requireModerator()] as const, request: { params: IdParamSchema, body: { @@ -128,53 +107,29 @@ export function registerModerationRoutes( description: "Submission approved and written through to the live extension/developer" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No submission with that id" - }, - 409: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: - "Submission is not pending, or ownership has changed since it was submitted" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "id param or review_note body failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("No submission with that id"), + 409: errorResponse( + "Submission is not pending, or ownership has changed since it was submitted" + ), + 422: errorResponse("id param or review_note body failed validation"), + 500: errorResponse("Database error") } }); app.openapi(approveRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { id } = c.req.valid("param"); const { review_note } = c.req.valid("json"); - const db = new SubmissionsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) - ); + const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.approve(id, auth.userId, review_note); if (error || !data) { const status = statusFromErrorCode(error?.code); - return c.json( - { - error: { - message: error?.message ?? "Unable to approve submission", - code: error?.code ?? "DATABASE_ERROR" - } - }, - status - ); + return c.json(errorBody(error, "Unable to approve submission"), status); } return c.json({ result: data }, 200); }); @@ -185,10 +140,7 @@ export function registerModerationRoutes( tags: ["Moderation"], summary: "Reject a pending submission", security: [{ Bearer: [] }], - middleware: [ - dependencies.requireAuth(), - dependencies.requireModerator() - ] as const, + middleware: [requireModerator()] as const, request: { params: IdParamSchema, body: { @@ -209,52 +161,27 @@ export function registerModerationRoutes( }, description: "Submission rejected" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No submission with that id" - }, - 409: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Submission is not pending" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "review_note is required" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("No submission with that id"), + 409: errorResponse("Submission is not pending"), + 422: errorResponse("review_note is required"), + 500: errorResponse("Database error") } }); app.openapi(rejectRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { id } = c.req.valid("param"); const { review_note } = c.req.valid("json"); - const db = new SubmissionsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) - ); + const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.reject(id, auth.userId, review_note); if (error || !data) { const status = statusFromErrorCode(error?.code); - return c.json( - { - error: { - message: error?.message ?? "Unable to reject submission", - code: error?.code ?? "DATABASE_ERROR" - } - }, - status - ); + return c.json(errorBody(error, "Unable to reject submission"), status); } return c.json({ result: data }, 200); }); @@ -265,10 +192,7 @@ export function registerModerationRoutes( tags: ["Moderation"], summary: "List every developer profile, approved or not", security: [{ Bearer: [] }], - middleware: [ - dependencies.requireAuth(), - dependencies.requireModerator() - ] as const, + middleware: [requireModerator()] as const, responses: { 200: { content: { @@ -278,24 +202,18 @@ export function registerModerationRoutes( }, description: "All developer profiles" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 500: errorResponse("Database error") } }); app.openapi(allDevelopersRoute, async (c) => { const db = new DeveloperProfilesDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.listAll(); if (error || !data) { @@ -318,10 +236,7 @@ export function registerModerationRoutes( tags: ["Moderation"], summary: "List developer profiles awaiting moderator review", security: [{ Bearer: [] }], - middleware: [ - dependencies.requireAuth(), - dependencies.requireModerator() - ] as const, + middleware: [requireModerator()] as const, responses: { 200: { content: { @@ -331,24 +246,18 @@ export function registerModerationRoutes( }, description: "Developer profiles not yet approved" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 500: errorResponse("Database error") } }); app.openapi(unapprovedDevelopersRoute, async (c) => { const db = new DeveloperProfilesDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.listUnapproved(); if (error || !data) { @@ -370,10 +279,7 @@ export function registerModerationRoutes( tags: ["Moderation"], summary: "Mark a developer profile as reviewed/approved", security: [{ Bearer: [] }], - middleware: [ - dependencies.requireAuth(), - dependencies.requireModerator() - ] as const, + middleware: [requireModerator()] as const, request: { params: IdParamSchema, body: { @@ -391,39 +297,24 @@ export function registerModerationRoutes( }, description: "Developer profile marked approved" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No developer with that id" - }, - 409: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Profile changed after the reviewed revision" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "id param failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("No developer with that id"), + 409: errorResponse("Profile changed after the reviewed revision"), + 422: errorResponse("id param failed validation"), + 500: errorResponse("Database error") } }); app.openapi(approveDeveloperRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { id } = c.req.valid("param"); const { expected_revision } = c.req.valid("json"); const db = new DeveloperProfilesDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.approve( id, @@ -435,15 +326,7 @@ export function registerModerationRoutes( error?.code === "ACCOUNT_INACTIVE" ? 403 : statusFromErrorCode(error?.code); - return c.json( - { - error: { - message: error?.message ?? "Unable to approve developer", - code: error?.code ?? "DATABASE_ERROR" - } - }, - status - ); + return c.json(errorBody(error, "Unable to approve developer"), status); } return c.json({ result: data }, 200); }); @@ -454,10 +337,7 @@ export function registerModerationRoutes( tags: ["Moderation"], summary: "List the write history of a developer profile", security: [{ Bearer: [] }], - middleware: [ - dependencies.requireAuth(), - dependencies.requireModerator() - ] as const, + middleware: [requireModerator()] as const, request: { params: IdParamSchema }, responses: { 200: { @@ -468,41 +348,24 @@ export function registerModerationRoutes( }, description: "Snapshots of the profile, newest first" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "id param failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 422: errorResponse("id param failed validation"), + 500: errorResponse("Database error") } }); app.openapi(developerHistoryRoute, async (c) => { const { id } = c.req.valid("param"); const db = new DeveloperProfilesDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.listHistory(id); if (error || !data) { - return c.json( - { - error: { - message: error?.message ?? "Unable to load developer history", - code: error?.code ?? "DATABASE_ERROR" - } - }, - 500 - ); + return c.json(errorBody(error, "Unable to load developer history"), 500); } return c.json({ result: data }, 200); }); diff --git a/src/services/extensions/v2/owner-extensions-routes.ts b/src/services/extensions/v2/routes/owner-extensions.ts similarity index 63% rename from src/services/extensions/v2/owner-extensions-routes.ts rename to src/services/extensions/v2/routes/owner-extensions.ts index 1f20c3f..23b4ddc 100644 --- a/src/services/extensions/v2/owner-extensions-routes.ts +++ b/src/services/extensions/v2/routes/owner-extensions.ts @@ -1,28 +1,28 @@ +import { errorBody } from "./errors"; +import { requireActiveAuth } from "../middleware"; +import { getExtensionsDb } from "../../../../lib/db"; +import { getAuth } from "../../../../lib/auth"; import { createRoute } from "@hono/zod-openapi"; import { ActiveAccountRequiredResponse, - ErrorResponseSchema, + errorResponse +} from "../schemas/common"; +import { ExtensionListResponseSchema, ExtensionMineListQuerySchema -} from "./interfaces"; -import { DeveloperProfilesDatabase } from "./developer-profiles-database"; -import { - ExtensionsDatabase, - isValidExtensionCursor -} from "./extensions-database"; -import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; +} from "../schemas/extensions"; +import { DeveloperProfilesDatabase } from "../db/developer-profiles"; +import { ExtensionsDatabase, isValidExtensionCursor } from "../db/extensions"; +import { ExtensionsV2App } from "./app"; -export function registerOwnerExtensionsRoutes( - app: ExtensionsV2App, - dependencies: RouteDependencies -): void { +export function registerOwnerExtensionsRoutes(app: ExtensionsV2App): void { const listMineRoute = createRoute({ method: "get", path: "/extensions/mine", tags: ["Extensions"], summary: "List extensions published under the caller's developer profile", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { query: ExtensionMineListQuerySchema }, responses: { 200: { @@ -31,24 +31,15 @@ export function registerOwnerExtensionsRoutes( }, description: "The caller's published extensions" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("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" - } + 422: errorResponse("Pagination query failed validation"), + 500: errorResponse("Database error") } }); app.openapi(listMineRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { type, limit, cursor } = c.req.valid("query"); // An account without a developer profile normally returns an empty page, @@ -67,7 +58,7 @@ export function registerOwnerExtensionsRoutes( } const ownerDb = new DeveloperProfilesDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const owner = await ownerDb.getOwn(auth.userId); if (owner.error) { @@ -88,9 +79,7 @@ export function registerOwnerExtensionsRoutes( ); } - const db = new ExtensionsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) - ); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.list({ type, developerId: owner.data.id, @@ -99,12 +88,7 @@ export function registerOwnerExtensionsRoutes( }); if (error || !data) { return c.json( - { - error: { - message: error?.message ?? "Unable to load extensions", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to load extensions"), error?.code === "INVALID_CURSOR" ? 422 : 500 ); } diff --git a/src/services/extensions/v2/ownership-routes.ts b/src/services/extensions/v2/routes/ownership.ts similarity index 53% rename from src/services/extensions/v2/ownership-routes.ts rename to src/services/extensions/v2/routes/ownership.ts index 87bf67b..f70dd9f 100644 --- a/src/services/extensions/v2/ownership-routes.ts +++ b/src/services/extensions/v2/routes/ownership.ts @@ -1,36 +1,40 @@ +import { requireActiveAuth, requireModerator } from "../middleware"; +import { getExtensionsDb } from "../../../../lib/db"; +import { getPlatform } from "../../../../lib/middleware"; +import { getAuth } from "../../../../lib/auth"; import { createRoute, z } from "@hono/zod-openapi"; import { + errorBody, statusFromErrorCode, statusFromGithubErrorCode, statusFromOwnershipErrorCode -} from "./route-errors"; +} from "./errors"; import { ActiveAccountRequiredResponse, + IdParamSchema, + ReviewNoteRequiredSchema, + errorResponse +} from "../schemas/common"; +import { DeveloperProfileSchema } from "../schemas/developers"; +import { ClaimNoteSchema, DeveloperClaimSchema, - DeveloperProfileSchema, DeveloperTransferSchema, - ErrorResponseSchema, - IdParamSchema, PendingDeveloperClaimSchema, - ReviewNoteRequiredSchema, TransferAcceptanceSchema -} from "./interfaces"; -import { DeveloperClaimsDatabase } from "./developer-claims-database"; -import { DeveloperTransfersDatabase } from "./developer-transfers-database"; -import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; +} from "../schemas/ownership"; +import { DeveloperClaimsDatabase } from "../db/developer-claims"; +import { DeveloperTransfersDatabase } from "../db/developer-transfers"; +import { ExtensionsV2App } from "./app"; -export function registerOwnershipRoutes( - app: ExtensionsV2App, - dependencies: RouteDependencies -): void { +export function registerOwnershipRoutes(app: ExtensionsV2App): void { const claimDeveloperRoute = createRoute({ method: "post", path: "/developers/{id}/claim", tags: ["Developers"], summary: "Request ownership of an unowned developer profile", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { params: IdParamSchema, body: { @@ -46,51 +50,32 @@ export function registerOwnershipRoutes( }, description: "Claim created and pending moderator review" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No developer with that id" - }, + 401: errorResponse("Missing or invalid bearer token"), + 404: errorResponse("No developer with that id"), 403: { ...ActiveAccountRequiredResponse, description: "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 } }, - description: - "Profile is already owned, caller already owns a different profile, or already has a pending claim on this one" - }, - 429: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "GitHub verification is temporarily rate limited" - }, - 503: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "GitHub verification is temporarily unavailable" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: - "The request failed validation, or the GitHub account type is unsupported" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 409: errorResponse( + "Profile is already owned, caller already owns a different profile, or already has a pending claim on this one" + ), + 429: errorResponse("GitHub verification is temporarily rate limited"), + 503: errorResponse("GitHub verification is temporarily unavailable"), + 422: errorResponse( + "The request failed validation, or the GitHub account type is unsupported" + ), + 500: errorResponse("Database error") } }); app.openapi(claimDeveloperRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { id } = c.req.valid("param"); const { note } = c.req.valid("json"); - const platform = dependencies.platform(c); + const platform = getPlatform(c); const db = new DeveloperClaimsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.claim( id, @@ -100,12 +85,7 @@ export function registerOwnershipRoutes( ); if (error || !data) { return c.json( - { - error: { - message: error?.message ?? "Unable to create claim", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to create claim"), error?.code === "GITHUB_MISMATCH" || error?.code === "ACCOUNT_INACTIVE" ? 403 : statusFromGithubErrorCode( @@ -123,7 +103,7 @@ export function registerOwnershipRoutes( tags: ["Developers"], summary: "Withdraw the caller's own pending profile claim", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { params: IdParamSchema }, responses: { 200: { @@ -136,44 +116,24 @@ export function registerOwnershipRoutes( }, description: "Claim withdrawn" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: ActiveAccountRequiredResponse, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No pending claim with that id owned by the caller" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "id param failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("No pending claim with that id owned by the caller"), + 422: errorResponse("id param failed validation"), + 500: errorResponse("Database error") } }); app.openapi(cancelClaimRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { id } = c.req.valid("param"); const db = new DeveloperClaimsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.cancelClaim(id, auth.userId); if (error || !data) { const status = statusFromErrorCode(error?.code, false); - return c.json( - { - error: { - message: error?.message ?? "Unable to cancel claim", - code: error?.code ?? "DATABASE_ERROR" - } - }, - status - ); + return c.json(errorBody(error, "Unable to cancel claim"), status); } return c.json({ result: { id: data.id, cancelled: true as const } }, 200); }); @@ -184,7 +144,7 @@ export function registerOwnershipRoutes( tags: ["Developers"], summary: "List the caller's own profile claims, in any status", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, responses: { 200: { content: { @@ -194,22 +154,16 @@ export function registerOwnershipRoutes( }, description: "The caller's claims" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: ActiveAccountRequiredResponse, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 500: errorResponse("Database error") } }); app.openapi(myClaimsRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const db = new DeveloperClaimsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.listMyClaims(auth.userId); if (error || !data) { @@ -232,10 +186,7 @@ export function registerOwnershipRoutes( tags: ["Moderation"], summary: "List pending profile claims", security: [{ Bearer: [] }], - middleware: [ - dependencies.requireAuth(), - dependencies.requireModerator() - ] as const, + middleware: [requireModerator()] as const, responses: { 200: { content: { @@ -245,24 +196,18 @@ export function registerOwnershipRoutes( }, description: "Claims awaiting moderator review" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 500: errorResponse("Database error") } }); app.openapi(pendingClaimsRoute, async (c) => { const db = new DeveloperClaimsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.listPendingClaims(); if (error || !data) { @@ -285,10 +230,7 @@ export function registerOwnershipRoutes( tags: ["Moderation"], summary: "Approve a pending profile claim", security: [{ Bearer: [] }], - middleware: [ - dependencies.requireAuth(), - dependencies.requireModerator() - ] as const, + middleware: [requireModerator()] as const, request: { params: IdParamSchema }, responses: { 200: { @@ -300,49 +242,30 @@ export function registerOwnershipRoutes( description: "Claim approved; profile ownership transferred to the claimant" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No claim or developer with that id" - }, - 409: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: - "Claim is no longer pending, profile is no longer unowned, or the claimant now owns a different profile" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "id param failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("No claim or developer with that id"), + 409: errorResponse( + "Claim is no longer pending, profile is no longer unowned, or the claimant now owns a different profile" + ), + 422: errorResponse("id param failed validation"), + 500: errorResponse("Database error") } }); app.openapi(approveClaimRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { id } = c.req.valid("param"); const db = new DeveloperClaimsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.approveClaim(id, auth.userId); if (error || !data) { return c.json( - { - error: { - message: error?.message ?? "Unable to approve claim", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to approve claim"), statusFromErrorCode(error?.code) ); } @@ -355,10 +278,7 @@ export function registerOwnershipRoutes( tags: ["Moderation"], summary: "Reject a pending profile claim", security: [{ Bearer: [] }], - middleware: [ - dependencies.requireAuth(), - dependencies.requireModerator() - ] as const, + middleware: [requireModerator()] as const, request: { params: IdParamSchema, body: { @@ -374,48 +294,28 @@ export function registerOwnershipRoutes( }, description: "Claim rejected" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller is not a moderator" }, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No pending claim with that id" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "id param or review_note body failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("No pending claim with that id"), + 422: errorResponse("id param or review_note body failed validation"), + 500: errorResponse("Database error") } }); app.openapi(rejectClaimRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { id } = c.req.valid("param"); const { review_note } = c.req.valid("json"); const db = new DeveloperClaimsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.rejectClaim(id, auth.userId, review_note); if (error || !data) { const status = statusFromErrorCode(error?.code, false); - return c.json( - { - error: { - message: error?.message ?? "Unable to reject claim", - code: error?.code ?? "DATABASE_ERROR" - } - }, - status - ); + return c.json(errorBody(error, "Unable to reject claim"), status); } return c.json({ result: data }, 200); }); @@ -426,7 +326,7 @@ export function registerOwnershipRoutes( tags: ["Developers"], summary: "Create a single-use link to hand this profile to another account", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { params: IdParamSchema }, responses: { 200: { @@ -438,45 +338,28 @@ export function registerOwnershipRoutes( description: "Transfer token created; share it out-of-band with the recipient" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller does not own this profile" }, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No developer with that id" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "id param failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("No developer with that id"), + 422: errorResponse("id param failed validation"), + 500: errorResponse("Database error") } }); app.openapi(initiateTransferRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { id } = c.req.valid("param"); const db = new DeveloperTransfersDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.initiateTransfer(id, auth.userId); if (error || !data) { return c.json( - { - error: { - message: error?.message ?? "Unable to create transfer", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to create transfer"), statusFromOwnershipErrorCode(error?.code) ); } @@ -489,7 +372,7 @@ export function registerOwnershipRoutes( tags: ["Developers"], summary: "Revoke this profile's pending transfer link, if any", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { params: IdParamSchema }, responses: { 200: { @@ -502,45 +385,28 @@ export function registerOwnershipRoutes( }, description: "Any pending transfer for this profile is revoked" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive or the caller does not own this profile" }, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No developer with that id" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "id param failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("No developer with that id"), + 422: errorResponse("id param failed validation"), + 500: errorResponse("Database error") } }); app.openapi(revokeTransferRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { id } = c.req.valid("param"); const db = new DeveloperTransfersDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.revokeTransfer(id, auth.userId); if (error || !data) { return c.json( - { - error: { - message: error?.message ?? "Unable to revoke transfer", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to revoke transfer"), statusFromOwnershipErrorCode(error?.code) ); } @@ -553,7 +419,7 @@ export function registerOwnershipRoutes( tags: ["Developers"], summary: "Accept a developer profile transfer using its single-use token", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { body: { content: { "application/json": { schema: TransferAcceptanceSchema } } @@ -568,35 +434,20 @@ export function registerOwnershipRoutes( }, description: "Profile is now owned by the caller" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: ActiveAccountRequiredResponse, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Transfer link is invalid, already used, or expired" - }, - 409: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Caller already owns a different developer profile" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "token body failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("Transfer link is invalid, already used, or expired"), + 409: errorResponse("Caller already owns a different developer profile"), + 422: errorResponse("token body failed validation"), + 500: errorResponse("Database error") } }); app.openapi(acceptTransferRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { token } = c.req.valid("json"); const db = new DeveloperTransfersDatabase( - dependencies.database(c.env.DB_EXTENSIONS) + getExtensionsDb(c.env.DB_EXTENSIONS) ); const { data, error } = await db.acceptTransfer(token, auth.userId); if (error || !data) { @@ -604,15 +455,7 @@ export function registerOwnershipRoutes( error?.code === "ACCOUNT_INACTIVE" ? 403 : statusFromErrorCode(error?.code); - return c.json( - { - error: { - message: error?.message ?? "Unable to accept transfer", - code: error?.code ?? "DATABASE_ERROR" - } - }, - status - ); + return c.json(errorBody(error, "Unable to accept transfer"), status); } return c.json({ result: data }, 200); }); diff --git a/src/services/extensions/v2/public-extensions-routes.ts b/src/services/extensions/v2/routes/public-extensions.ts similarity index 51% rename from src/services/extensions/v2/public-extensions-routes.ts rename to src/services/extensions/v2/routes/public-extensions.ts index 5c448d3..30f0773 100644 --- a/src/services/extensions/v2/public-extensions-routes.ts +++ b/src/services/extensions/v2/routes/public-extensions.ts @@ -1,19 +1,16 @@ +import { getExtensionsDb } from "../../../../lib/db"; import { createRoute, z } from "@hono/zod-openapi"; -import { statusFromErrorCode } from "./route-errors"; +import { errorBody, statusFromErrorCode } from "./errors"; +import { IdParamSchema, errorResponse } from "../schemas/common"; import { - ErrorResponseSchema, ExtensionListQuerySchema, ExtensionListResponseSchema, - ExtensionSchema, - IdParamSchema -} from "./interfaces"; -import { ExtensionsDatabase } from "./extensions-database"; -import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; + ExtensionSchema +} from "../schemas/extensions"; +import { ExtensionsDatabase } from "../db/extensions"; +import { ExtensionsV2App } from "./app"; -export function registerPublicExtensionsRoutes( - app: ExtensionsV2App, - dependencies: RouteDependencies -): void { +export function registerPublicExtensionsRoutes(app: ExtensionsV2App): void { const listExtensionsRoute = createRoute({ method: "get", path: "/extensions", @@ -29,22 +26,14 @@ export function registerPublicExtensionsRoutes( }, description: "Extensions matching the given filters" }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Filter or pagination query failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 422: errorResponse("Filter or pagination query failed validation"), + 500: errorResponse("Database error") } }); app.openapi(listExtensionsRoute, async (c) => { const { type, developer_id, limit, cursor } = c.req.valid("query"); - const db = new ExtensionsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) - ); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.list({ type, developerId: developer_id, @@ -53,12 +42,7 @@ export function registerPublicExtensionsRoutes( }); if (error || !data) { return c.json( - { - error: { - message: error?.message ?? "Unable to load extensions", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to load extensions"), error?.code === "INVALID_CURSOR" ? 422 : 500 ); } @@ -87,38 +71,19 @@ export function registerPublicExtensionsRoutes( }, description: "The extension" }, - 404: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "No extension with that id" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "id param failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 404: errorResponse("No extension with that id"), + 422: errorResponse("id param failed validation"), + 500: errorResponse("Database error") } }); app.openapi(getExtensionRoute, async (c) => { const { id } = c.req.valid("param"); - const db = new ExtensionsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) - ); + const db = new ExtensionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.getById(id); if (error || !data) { const status = statusFromErrorCode(error?.code, false); - return c.json( - { - error: { - message: error?.message ?? "Extension not found", - code: error?.code ?? "DATABASE_ERROR" - } - }, - status - ); + return c.json(errorBody(error, "Extension not found"), status); } return c.json({ result: data }, 200); }); diff --git a/src/services/extensions/v2/submission-routes.ts b/src/services/extensions/v2/routes/submissions.ts similarity index 56% rename from src/services/extensions/v2/submission-routes.ts rename to src/services/extensions/v2/routes/submissions.ts index c1d9c2d..c2f97ac 100644 --- a/src/services/extensions/v2/submission-routes.ts +++ b/src/services/extensions/v2/routes/submissions.ts @@ -1,26 +1,29 @@ +import { errorBody } from "./errors"; +import { requireActiveAuth } from "../middleware"; +import { getExtensionsDb } from "../../../../lib/db"; +import { getAuth } from "../../../../lib/auth"; import { createRoute, z } from "@hono/zod-openapi"; import { ActiveAccountRequiredResponse, - ErrorResponseSchema, PaginationSchema, + errorResponse +} from "../schemas/common"; +import { SubmissionPayloadSchema, SubmissionPageQuerySchema, SubmissionSchema -} from "./interfaces"; -import { SubmissionsDatabase } from "./submissions-database"; -import { ExtensionsV2App, RouteDependencies } from "./route-dependencies"; +} from "../schemas/submissions"; +import { SubmissionsDatabase } from "../db/submissions"; +import { ExtensionsV2App } from "./app"; -export function registerSubmissionRoutes( - app: ExtensionsV2App, - dependencies: RouteDependencies -): void { +export function registerSubmissionRoutes(app: ExtensionsV2App): void { const createSubmissionRoute = createRoute({ method: "post", path: "/submissions", tags: ["Submissions"], summary: "Submit a new extension, or an edit to one you own", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { body: { content: { "application/json": { schema: SubmissionPayloadSchema } } @@ -37,46 +40,28 @@ export function registerSubmissionRoutes( }, description: "Submission created and pending moderator review" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("Missing or invalid bearer token"), 403: { ...ActiveAccountRequiredResponse, description: "The account is inactive, or the caller does not own the target developer or extension" }, - 409: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: - "Ownership or target changed, a duplicate is pending, or the pending limit was reached" - }, - 422: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Payload failed validation" - }, - 500: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Database error" - } + 409: errorResponse( + "Ownership or target changed, a duplicate is pending, or the pending limit was reached" + ), + 422: errorResponse("Payload failed validation"), + 500: errorResponse("Database error") } }); app.openapi(createSubmissionRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const payload = c.req.valid("json"); - const db = new SubmissionsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) - ); + const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const ownership = await db.resolveOwnership(payload, auth.userId); if (ownership.error || !ownership.data) { return c.json( - { - error: { - message: ownership.error?.message ?? "Unable to validate ownership", - code: ownership.error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(ownership.error, "Unable to validate ownership"), ownership.error?.code === "FORBIDDEN" ? 403 : 500 ); } @@ -89,12 +74,7 @@ export function registerSubmissionRoutes( }); if (created.error || !created.data) { return c.json( - { - error: { - message: created.error?.message ?? "Unable to create submission", - code: created.error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(created.error, "Unable to create submission"), created.error?.code === "CONFLICT" ? 409 : 500 ); } @@ -110,7 +90,7 @@ export function registerSubmissionRoutes( tags: ["Submissions"], summary: "List the caller's own submissions, in any status", security: [{ Bearer: [] }], - middleware: [dependencies.requireAuth()] as const, + middleware: [requireActiveAuth()] as const, request: { query: SubmissionPageQuerySchema }, responses: { 200: { @@ -124,28 +104,17 @@ export function registerSubmissionRoutes( }, description: "The caller's submissions" }, - 401: { - content: { "application/json": { schema: ErrorResponseSchema } }, - description: "Missing or invalid bearer token" - }, + 401: errorResponse("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" - } + 422: errorResponse("Pagination query failed validation"), + 500: errorResponse("Database error") } }); app.openapi(mineRoute, async (c) => { - const auth = dependencies.auth(c); + const auth = getAuth(c); const { limit, cursor } = c.req.valid("query"); - const db = new SubmissionsDatabase( - dependencies.database(c.env.DB_EXTENSIONS) - ); + const db = new SubmissionsDatabase(getExtensionsDb(c.env.DB_EXTENSIONS)); const { data, error } = await db.listBySubmitter( auth.userId, limit, @@ -153,12 +122,7 @@ export function registerSubmissionRoutes( ); if (error || !data) { return c.json( - { - error: { - message: error?.message ?? "Unable to load submissions", - code: error?.code ?? "DATABASE_ERROR" - } - }, + errorBody(error, "Unable to load submissions"), error?.code === "INVALID_CURSOR" ? 422 : 500 ); } diff --git a/src/services/extensions/v2/schemas/common.ts b/src/services/extensions/v2/schemas/common.ts new file mode 100644 index 0000000..fc4d8eb --- /dev/null +++ b/src/services/extensions/v2/schemas/common.ts @@ -0,0 +1,81 @@ +import { z } from "@hono/zod-openapi"; + +// Lowercase alphanumeric slug (hyphens allowed, no leading/trailing hyphen) — +// matches the shape of existing ids (e.g. "fossbilling") and rules out +// anything that isn't safe to use as a URL path segment or DOM identifier. +export const lowercaseId = (label: string) => + z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, { + message: `${label} id must be a lowercase alphanumeric slug` + }); + +// Restricts to http(s) — z.string().url() alone accepts any scheme, +// including javascript:/data:, which is unsafe for fields a consumer may +// render as a link or image src. +export const httpUrl = () => + z + .string() + .max(2048) + .url() + .refine((value) => /^https?:\/\//i.test(value), { + message: "must use http or https" + }); + +export const ErrorResponseSchema = z + .object({ + error: z.object({ + message: z.string(), + code: z.string(), + details: z + .array( + z.unknown().openapi({ + type: ["string", "number", "boolean", "object", "array", "null"] + }) + ) + .optional() + }) + }) + .openapi("Error"); + +// Every non-2xx response in this service carries ErrorResponseSchema and +// differs only by description, so routes declare them through this rather +// than restating the content block. +export const errorResponse = (description: string) => + ({ + content: { "application/json": { schema: ErrorResponseSchema } }, + description + }) as const; + +// 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 = errorResponse( + "The bearer is valid but the account is inactive" +); + +export const IdParamSchema = z.object({ + id: z.string().openapi({ + param: { name: "id", in: "path" }, + example: "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + }) +}); + +export const ReviewNoteOptionalSchema = z + .object({ + review_note: z.string().max(2000).optional() + }) + .strict() + .openapi("ReviewNoteOptional"); + +export const ReviewNoteRequiredSchema = z + .object({ + review_note: z.string().min(1).max(2000) + }) + .strict() + .openapi("ReviewNoteRequired"); + +export const PaginationSchema = z + .object({ + next_cursor: z.string().nullable(), + has_more: z.boolean() + }) + .openapi("Pagination"); diff --git a/src/services/extensions/v2/schemas/developers.ts b/src/services/extensions/v2/schemas/developers.ts new file mode 100644 index 0000000..542c5c7 --- /dev/null +++ b/src/services/extensions/v2/schemas/developers.ts @@ -0,0 +1,173 @@ +import { z } from "@hono/zod-openapi"; +import { httpUrl, lowercaseId } from "./common"; + +// GET /developers/{id} is registered after the static single-segment +// 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; adopted rows cannot be renamed by a schema, so migration 0020 +// fails the deploy if one exists. +const RESERVED_DEVELOPER_IDS = new Set(["claims", "me", "unapproved"]); + +// Exported so the approval boundary can reuse it: submissions store their +// payload as JSON and are re-read without re-running this schema, so the one +// check has to be callable from there too. Lowercases like +// isReservedExtensionId, since route matching is case-sensitive but these +// literals are not. +export function isReservedDeveloperId(id: string): boolean { + return RESERVED_DEVELOPER_IDS.has(id.toLowerCase()); +} + +const developerId = () => + lowercaseId("developer").refine((id) => !isReservedDeveloperId(id), { + message: "This developer id is reserved" + }); + +export const DeveloperSchema = z + .object({ + id: developerId(), + type: z.enum(["user", "organization"]), + name: z.string().min(1).max(120), + URL: httpUrl().optional(), + avatar_url: httpUrl().optional(), + contact_email: z.string().email().max(254).optional() + }) + .openapi("Developer"); + +export type Developer = z.infer; + +// The PUT /developers/me request body. Separate from DeveloperSchema because +// that one is also allOf/0 of the DeveloperProfile response, and strictness +// propagates through extend/pick/omit - an additionalProperties:false branch +// inside an allOf would reject the properties the sibling branch contributes, +// and would tell generated clients to reject response fields added later. +// Naming follows UserIdentityInputSchema. +export const DeveloperInputSchema = + DeveloperSchema.strict().openapi("DeveloperInput"); + +// Submissions go through moderation and only ever touch identity fields — +// profile fields (avatar_url/contact_email) are direct-write-only via +// PUT /developers/me, so this schema rejects them instead of silently +// accepting-then-dropping them when a submission is approved. +export const SubmissionDeveloperSchema = DeveloperSchema.pick({ + id: true, + type: true, + name: true, + URL: true +}) + .strict() + .openapi("SubmissionDeveloper"); + +export const DeveloperProfileSchema = DeveloperSchema.extend({ + approved: z.boolean(), + content_revision: z.int().positive(), + // Server-computed — see verifyGithubOwnership() (at + // claim/creation time) and reverifyOwn() (opportunistic re-check on + // login, or the owner's own "Re-verify" action). Never part of the + // client-supplied DeveloperSchema. + github_org_verified: z.boolean().optional(), + github_verification_note: z.string().optional(), + // Set whenever github_org_verified is last (re-)computed to a definitive + // true/false — see reverifyOwn(). Absent/stale on an inconclusive check. + github_verified_at: z.string().nullish(), + // Whether Publisher URL matches GitHub's own on-file website for this + // org/user — see verifyGithubOwnership()'s urlMatchesGithubBlog() call. + // Only ever true or absent (never false): GitHub's website field is + // optional and often unset, so "doesn't match" isn't itself meaningful — + // there's nothing to flag, unlike github_org_verified's identity check. + // Computed at creation, and re-checked by the owner's own "Re-verify" + // action (never by the opportunistic per-login re-check, which stays + // GitHub-API-free by design). + github_url_verified: z.boolean().optional(), + // Only populated by the moderator listAll/listUnapproved queries (see + // DeveloperProfilesDatabase.listAll/listUnapproved) — other DeveloperProfile + // producers (getById, create/update/claim/transfer results) don't join + // for it, so it's absent rather than null there. `unclaimed` is the + // authoritative "has an owner" signal (owner_user_id IS NULL) — don't + // infer ownership from owner_name being present, since a real owner can + // still have a null name (e.g. their auth provider never supplied one). + // Owner identity is never exposed publicly — see PublicDeveloperSchema + // below and the README's note on not leaking owner identity. + unclaimed: z.boolean().optional(), + owner_name: z.string().nullish(), + owner_github_login: z.string().nullish() +}).openapi("DeveloperProfile"); + +export type DeveloperProfile = z.infer; + +// The publicly-readable view of a developer profile: everything in +// 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). The Extensions site consumes this +// projection through the generated API client. +export const PublicDeveloperSchema = DeveloperProfileSchema.omit({ + contact_email: true, + content_revision: true, + github_org_verified: true, + github_verification_note: true, + github_verified_at: true, + github_url_verified: true, + owner_name: true, + owner_github_login: true +}) + .extend({ unclaimed: z.boolean() }) + .openapi("PublicDeveloper"); + +export type PublicDeveloper = z.infer; + +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, + unclaimed: profile.unclaimed + }; +} + +export const OwnedDeveloperProfileSchema = z + .intersection( + DeveloperProfileSchema, + z.object({ has_pending_transfer: z.boolean() }) + ) + .openapi("OwnedDeveloperProfile"); + +export const DeveloperHistoryEntrySchema = z + .object({ + developer_id: z.string(), + type: z.enum(["user", "organization"]), + name: z.string(), + URL: httpUrl().optional(), + changed_by: z.string(), + // The editor's account name at read time — null if the auth provider + // never gave one, or the users row was since deleted. + changed_by_name: z.string().nullable(), + changed_at: z.string() + }) + .openapi("DeveloperHistoryEntry"); + +export type DeveloperHistoryEntry = z.infer; + +export const DeveloperApprovalSchema = z + .object({ expected_revision: z.number().int().positive() }) + .strict() + .openapi("DeveloperApproval"); + +// check_url — opt-in because it costs an extra GitHub API call (see +// DeveloperProfilesDatabase.reverifyOwn()); only the owner's own manual "Re-verify" +// button sets this, never the opportunistic per-login re-check. Not +// z.coerce.boolean(): that coerces the non-empty string "false" to true. +export const ReverifyQuerySchema = z.object({ + check_url: z + .enum(["true", "false"]) + .optional() + .transform((value) => value === "true") + .openapi({ + param: { name: "check_url", in: "query" } + }) +}); diff --git a/src/services/extensions/v2/schemas/extensions.ts b/src/services/extensions/v2/schemas/extensions.ts new file mode 100644 index 0000000..34405cb --- /dev/null +++ b/src/services/extensions/v2/schemas/extensions.ts @@ -0,0 +1,138 @@ +import { z } from "@hono/zod-openapi"; +import { httpUrl, lowercaseId, PaginationSchema } from "./common"; +import { PublicDeveloperSchema } from "./developers"; + +export const EXTENSION_TYPES = [ + "mod", + "theme", + "payment-gateway", + "server-manager", + "domain-registrar", + "hook", + "translation" +] as const; + +// GET /extensions/mine is a static owner-only route registered before +// GET /extensions/{id}. Reserve its segment for new submissions so a newly +// published extension cannot become unreachable. This schema cannot rename +// an already-adopted row, so migration 0020 fails the deploy if one exists. +// Private: isReservedExtensionId() lowercases before the lookup, and these +// literals are lowercase — reading the Set directly would miss "Mine". +const RESERVED_EXTENSION_IDS = new Set(["mine"]); + +export function isReservedExtensionId(id: string): boolean { + return RESERVED_EXTENSION_IDS.has(id.toLowerCase()); +} + +export const ReleaseSchema = z + .object({ + tag: z.string().min(1).max(100), + date: z.string().min(1).max(64), + download_url: httpUrl(), + changelog_url: httpUrl().optional(), + min_fossbilling_version: z.string().min(1).max(100) + }) + .strict() + .openapi("Release"); + +export type Release = z.infer; + +export const RepositorySchema = z + .object({ + type: z.enum(["github", "gitlab", "custom"]), + repo: z.string().min(1).max(500) + }) + .strict() + .openapi("Repository"); + +export type Repository = z.infer; + +export const LicenseSchema = z + .object({ + name: z.string().min(1).max(100), + URL: httpUrl().optional() + }) + .strict() + .openapi("License"); + +export type License = z.infer; + +export const ExtensionPayloadSchema = z + .object({ + id: lowercaseId("extension"), + type: z.enum(EXTENSION_TYPES), + name: z.string().min(1).max(120), + description: z.string().min(1).max(4000), + releases: z.array(ReleaseSchema).min(1).max(100), + website: httpUrl(), + license: LicenseSchema, + icon_url: httpUrl().optional(), + readme: z.string().min(1).max(100_000), + source: RepositorySchema, + version: z.string().min(1).max(100), + download_url: httpUrl() + }) + .strict() + .openapi("ExtensionPayload"); + +export const ExtensionSchema = ExtensionPayloadSchema.extend({ + developer: PublicDeveloperSchema +}).openapi("Extension"); + +export type Extension = z.infer; + +// Catalogue cards do not need the potentially large README or every historic +// release. Consumers can fetch those fields from GET /extensions/{id} when a +// visitor opens an extension's detail page. +export const ExtensionListItemSchema = ExtensionSchema.omit({ + readme: true, + releases: true +}).openapi("ExtensionListItem"); + +export type ExtensionListItem = z.infer; + +export const ExtensionListQuerySchema = z.object({ + type: z + .enum(EXTENSION_TYPES) + .optional() + .openapi({ + param: { name: "type", in: "query" } + }), + developer_id: z + .string() + .optional() + .openapi({ + param: { name: "developer_id", in: "query" } + }), + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .default(50) + .openapi({ param: { name: "limit", in: "query" } }), + cursor: z + .string() + .min(1) + .max(1000) + .optional() + .openapi({ + param: { name: "cursor", in: "query" }, + description: "Opaque cursor returned by the previous page" + }) +}); + +// 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), + pagination: PaginationSchema + }) + .openapi("ExtensionListResponse"); diff --git a/src/services/extensions/v2/schemas/ownership.ts b/src/services/extensions/v2/schemas/ownership.ts new file mode 100644 index 0000000..13db601 --- /dev/null +++ b/src/services/extensions/v2/schemas/ownership.ts @@ -0,0 +1,61 @@ +import { z } from "@hono/zod-openapi"; + +export const TransferAcceptanceSchema = z + .object({ token: z.string().min(64).max(128) }) + .strict() + .openapi("TransferAcceptance"); + +export const DeveloperTransferSchema = z + .object({ + token: z.string(), + expires_at: z.string() + }) + .openapi("DeveloperTransfer"); + +export type DeveloperTransfer = z.infer; + +export const DeveloperClaimSchema = z + .object({ + id: z.string(), + developer_id: z.string(), + claimant_id: z.string(), + status: z.enum(["pending", "approved", "rejected"]), + note: z.string().optional(), + review_note: z.string().optional(), + reviewer_id: z.string().optional(), + created_at: z.string(), + reviewed_at: z.string().optional(), + // Server-computed at claim() time only — never accepted from the + // client (see ClaimNoteSchema below). Undefined when there was no + // verifiable GitHub org/user for this id, or the claimant had no linked + // GitHub identity yet; both fall back to manual moderator review. An + // absent value is not proof of ownership and must not bypass approval. + github_org_verified: z.boolean().optional(), + github_verification_note: z.string().optional() + }) + .openapi("DeveloperClaim"); + +export type DeveloperClaim = z.infer; + +export const PendingDeveloperClaimSchema = DeveloperClaimSchema.extend({ + developer_name: z.string(), + developer_type: z.enum(["user", "organization"]), + // The claimant's own account name/GitHub handle, so the moderator sees + // who's asking instead of just their opaque id. Null if the auth + // provider never gave a name, or the claimant hasn't linked GitHub yet. + claimant_name: z.string().nullable(), + claimant_github_login: z.string().nullable() +}).openapi("PendingDeveloperClaim"); + +export type PendingDeveloperClaim = z.infer; + +// strict: the claim route's server-side fields (github_org_verified and +// friends, on DeveloperClaimSchema above) are computed at claim() time and +// must never be accepted from the client, so an unknown key here is a mistake +// worth reporting rather than silently dropping. +export const ClaimNoteSchema = z + .object({ + note: z.string().max(500).optional() + }) + .strict() + .openapi("ClaimNote"); diff --git a/src/services/extensions/v2/schemas/submissions.ts b/src/services/extensions/v2/schemas/submissions.ts new file mode 100644 index 0000000..de0e368 --- /dev/null +++ b/src/services/extensions/v2/schemas/submissions.ts @@ -0,0 +1,85 @@ +import { z } from "@hono/zod-openapi"; +import { SubmissionDeveloperSchema } from "./developers"; +import { ExtensionPayloadSchema, isReservedExtensionId } from "./extensions"; + +export const SubmissionPayloadSchema = z + .object({ + developer: SubmissionDeveloperSchema, + extension: ExtensionPayloadSchema + }) + .strict() + .superRefine((payload, ctx) => { + if (isReservedExtensionId(payload.extension.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "This extension id is reserved", + path: ["extension", "id"] + }); + } + const size = new TextEncoder().encode(JSON.stringify(payload)).byteLength; + if (size > 256 * 1024) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Submission payload must not exceed 256 KiB" + }); + } + }) + .openapi("SubmissionPayload"); + +export type SubmissionPayload = z.infer; + +export const SubmissionStatusSchema = z.enum([ + "pending", + "approved", + "rejected" +]); + +export type SubmissionStatus = z.infer; + +export const SubmissionSchema = z + .object({ + id: z.string(), + extension_id: z.string().nullable(), + developer_id: z.string(), + submitted_by: z.string(), + status: SubmissionStatusSchema, + payload: SubmissionPayloadSchema, + reviewer_id: z.string().nullable(), + review_note: z.string().nullable(), + created_at: z.string(), + reviewed_at: z.string().nullable() + }) + .openapi("Submission"); + +export type Submission = z.infer; + +export const QueueQuerySchema = z.object({ + status: SubmissionStatusSchema.optional().openapi({ + param: { name: "status", in: "query" } + }), + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .default(50) + .openapi({ + param: { name: "limit", in: "query" } + }), + // min(1) matches ExtensionListQuerySchema: without it `?cursor=` arrives as + // an empty string, which the page helper treats as "no cursor" and silently + // restarts pagination instead of reporting the malformed value. + cursor: z + .string() + .min(1) + .max(1000) + .optional() + .openapi({ + param: { name: "cursor", in: "query" } + }) +}); + +export const SubmissionPageQuerySchema = QueueQuerySchema.pick({ + limit: true, + cursor: true +}); diff --git a/src/services/extensions/v2/schemas/users.ts b/src/services/extensions/v2/schemas/users.ts new file mode 100644 index 0000000..8d501b0 --- /dev/null +++ b/src/services/extensions/v2/schemas/users.ts @@ -0,0 +1,37 @@ +import { z } from "@hono/zod-openapi"; + +// 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; diff --git a/src/services/stats/v1/README.md b/src/services/stats/v1/README.md new file mode 100644 index 0000000..88a51f5 --- /dev/null +++ b/src/services/stats/v1/README.md @@ -0,0 +1,26 @@ +# Stats v1 + +**Base Path:** `/stats/v1` + +Release statistics visualization for FOSSBilling versions. Reuses the release data already fetched by the versions service rather than calling GitHub itself. + +## Endpoints + +### GET `/` + +Returns a client-side rendered HTML page with Chart.js visualizations. + +### GET `/data` + +Returns the aggregated statistics behind those charts as JSON. + +## Charts + +- Release Size Graph (line) +- PHP Version Requirements (line) +- Patches Per Release (bar) +- Releases Per Year (bar) + +## Caching + +Stats are cached with a 24-hour TTL and follow the same caching patterns as the versions service, including its graceful handling of GitHub API errors — a failed refresh serves the previous data rather than erroring. diff --git a/test/lib/github-errors.test.ts b/test/lib/github-errors.test.ts index fa63363..a8c014a 100644 --- a/test/lib/github-errors.test.ts +++ b/test/lib/github-errors.test.ts @@ -124,15 +124,65 @@ describe("classifyGitHubError", () => { expect(result.message).toBe("GitHub API rate limit exceeded"); }); - it("should classify 403 non-rate-limit errors as RateLimitError with original message", () => { + // A bare 403 carries no rate-limit evidence, so it is an authorization + // failure. Reporting it as a rate limit would tell callers to back off and + // retry a request that cannot succeed. + it("should classify 403 non-rate-limit errors as AuthError with original message", () => { const error = { status: 403, message: "Repository access denied" }; const result = classifyGitHubError(error); - expect(result).toBeInstanceOf(RateLimitError); + expect(result).toBeInstanceOf(AuthError); expect(result.message).toBe("Repository access denied"); expect(result.httpStatus).toBe(403); }); + // Regression: the rate-limit text was read from String(error), which is + // "[object Object]" for a non-Error throw. That was invisible while every + // 403 became a RateLimitError; once the message decides the class, it turned + // a real rate limit into an AuthError. + it("should detect a rate limit on a non-Error 403 payload", () => { + const error = { status: 403, message: "API rate limit exceeded" }; + const result = classifyGitHubError(error); + + expect(result).toBeInstanceOf(RateLimitError); + expect(result.httpStatus).toBe(403); + }); + + it("should classify a 403 with an exhausted quota header as RateLimitError", () => { + const error = { + status: 403, + message: "Forbidden", + response: { headers: { "x-ratelimit-remaining": "0" } } + }; + const result = classifyGitHubError(error); + + expect(result).toBeInstanceOf(RateLimitError); + expect(result.message).toBe("GitHub API rate limit exceeded"); + expect(result.httpStatus).toBe(403); + }); + + it("should classify 429 errors as RateLimitError", () => { + const error = { status: 429, message: "Too Many Requests" }; + const result = classifyGitHubError(error); + + expect(result).toBeInstanceOf(RateLimitError); + expect(result.message).toBe("GitHub API rate limit exceeded"); + expect(result.httpStatus).toBe(429); + }); + + // A 5xx has no dedicated class, but dropping its status would make an + // upstream outage indistinguishable from a transport failure that never + // reached GitHub. extensions/v2 branches on exactly that difference. + it("should retain the status for unrecognised HTTP statuses", () => { + for (const status of [500, 502, 503]) { + const error = Object.assign(new Error("upstream failure"), { status }); + const result = classifyGitHubError(error); + + expect(result.errorCode).toBe("unknown_error"); + expect(result.httpStatus).toBe(status); + } + }); + it("should classify 404 errors as NotFoundError", () => { const error = { status: 404, message: "Not found" }; const result = classifyGitHubError(error, "https://api.github.com/test"); diff --git a/test/mocks/README.md b/test/mocks/README.md index 03cd3f3..f662bda 100644 --- a/test/mocks/README.md +++ b/test/mocks/README.md @@ -8,4 +8,4 @@ This directory contains mock data used across the test suite. ## Usage -Database tests run against a real local D1 (see `@cloudflare/vitest-pool-workers` and `test/utils/apply-migrations.ts`) rather than a mock adapter - see `test/services/extensions/v2/db-fixtures.ts` for seed/read helpers and `test/services/extensions/v2/db-interceptor.ts` for the handful of tests that need to inject a fault or a mid-request race. +Database tests run against a real local D1 (see `@cloudflare/vitest-pool-workers` and `test/utils/apply-migrations.ts`) rather than a mock adapter - see `test/services/extensions/v2/db-fixtures.ts` for seed/read helpers and `test/services/extensions/v2/db-interceptor.ts` for the handful of tests that need to inject a fault or a mid-request race. The v2 suites are split one file per route module and share `test/services/extensions/v2/harness.ts`, which owns migrations, the per-test database reset, the rate-limiter stub, and the GitHub request mock. diff --git a/test/mocks/octokit.ts b/test/mocks/octokit.ts new file mode 100644 index 0000000..0c711b0 --- /dev/null +++ b/test/mocks/octokit.ts @@ -0,0 +1,15 @@ +import { vi } from "vitest"; + +// The @octokit/request module shape every suite that touches GitHub mocks. +// vi.mock's hoisting is per-module, so each test file still declares its own +// vi.mock("@octokit/request", ...) — but the factory body lives here so the +// shape only has to be updated once when the octokit surface changes. +export function octokitRequestMock() { + const endpoint = { DEFAULTS: {} }; + const derivedFn = Object.assign(vi.fn(), { defaults: vi.fn(), endpoint }); + const request = Object.assign(vi.fn(), { + defaults: vi.fn().mockReturnValue(derivedFn), + endpoint + }); + return { request }; +} diff --git a/test/services/extensions/v2/account.test.ts b/test/services/extensions/v2/account.test.ts new file mode 100644 index 0000000..5765dbd --- /dev/null +++ b/test/services/extensions/v2/account.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect, vi } from "vitest"; +import { + setupExtensionsV2Tests, + db, + authHeaders, + get, + put, + patch, + del, + samplePayload, + sampleDeveloper, + seedDeveloper, + seedUnownedDeveloper, + seedOwnedExtension +} from "./harness"; +import { + insertUser, + insertDeveloper, + insertExtension, + insertSubmission, + insertDeveloperClaim, + hasDeveloper, + getSubmission, + getDeveloperClaim, + insertDeveloperTransfer, + insertDeveloperHistory, + listDeveloperTransfers, + listDeveloperClaims, + listDeveloperHistory +} from "./db-fixtures"; + +// Hoisted so no v2 suite can make a real GitHub call. harness.ts applies the +// default "not found" behaviour in beforeEach and documents why. +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); + +setupExtensionsV2Tests(); + +describe("Extensions API v2", () => { + describe("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 } + }); + + 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.each([ + ["an impossible calendar day", "2099-02-30T00:00:00.000Z"], + ["an out-of-range hour", "2099-01-01T24:00:00.000Z"], + ["an out-of-range offset", "2099-01-01T00:00:00.000+24:00"] + ])( + "does not treat %s as usable organization evidence", + async (_description, github_orgs_expires_at) => { + const res = await put( + "/extensions/v2/users/me/identity", + await authHeaders("impossible-org-date"), + { + name: "Impossible Date", + email: "impossible-date@example.com", + email_verified: true, + picture: null, + github_login: "someone", + github_orgs: ["fossbilling"], + github_orgs_expires_at + } + ); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + result: { github_linked: false } + }); + const row = await db + .prepare( + "SELECT github_orgs, github_orgs_expires_at FROM users WHERE id = ?" + ) + .bind("impossible-org-date") + .first<{ + github_orgs: string | null; + github_orgs_expires_at: string | null; + }>(); + expect(row).toEqual({ + github_orgs: null, + github_orgs_expires_at: 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/services/extensions/v2/developer-profiles.test.ts b/test/services/extensions/v2/developer-profiles.test.ts new file mode 100644 index 0000000..8dd1c30 --- /dev/null +++ b/test/services/extensions/v2/developer-profiles.test.ts @@ -0,0 +1,2135 @@ +import { describe, it, expect, vi } from "vitest"; +import { env } from "cloudflare:workers"; +import { request as ghRequest } from "@octokit/request"; +import { MockGitHubRequest } from "../../../utils/test-types"; +import { wrapD1WithHook } from "./db-interceptor"; +import { + setupExtensionsV2Tests, + db, + authHeaders, + post, + get, + put, + del, + samplePayload, + sampleDeveloper, + seedUnownedDeveloper, + seedOwnedExtension, + mockGithubEntity, + mockGithubEntityNotFound +} from "./harness"; +import { + insertUser, + insertDeveloper, + insertSubmission, + insertDeveloperClaim, + insertDeveloperTransfer, + getDeveloper, + hasDeveloper, + listDevelopers, + listDeveloperTransfers, + listDeveloperClaims, + listDeveloperHistory, + bumpDeveloperOwnership +} from "./db-fixtures"; + +// Hoisted so no v2 suite can make a real GitHub call. harness.ts applies the +// default "not found" behaviour in beforeEach and documents why. +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); + +setupExtensionsV2Tests(); + +describe("Extensions API v2", () => { + describe("PUT /developers/me", () => { + it("limits creation attempts per account before GitHub and database writes", async () => { + mockGithubEntity("Organization"); + const firstHeaders = await authHeaders("rate-limited-account"); + + for (const id of ["attempt-one", "attempt-two", "attempt-three"]) { + const allowed = await put( + "/extensions/v2/developers/me", + firstHeaders, + { id, type: "user", name: id } + ); + expect(allowed.status).toBe(403); + } + + const denied = await put("/extensions/v2/developers/me", firstHeaders, { + id: "attempt-four", + type: "user", + name: "Attempt four" + }); + expect(denied.status).toBe(429); + expect(denied.headers.get("Retry-After")).toBe("60"); + expect(denied.headers.get("Access-Control-Expose-Headers")).toContain( + "Retry-After" + ); + expect(await denied.json()).toMatchObject({ + error: { code: "PROFILE_CREATION_RATE_LIMITED" } + }); + expect(ghRequest).toHaveBeenCalledTimes(3); + expect(await listDevelopers(db)).toHaveLength(0); + + const otherAccount = await put( + "/extensions/v2/developers/me", + await authHeaders("independent-rate-limit-account"), + { id: "other-attempt", type: "user", name: "Other attempt" } + ); + expect(otherAccount.status).toBe(403); + expect(ghRequest).toHaveBeenCalledTimes(4); + expect(await listDevelopers(db)).toHaveLength(0); + }); + + it("does not charge profile updates against creation allowance", async () => { + const headers = await authHeaders("update-rate-limit-account"); + const created = await put( + "/extensions/v2/developers/me", + headers, + sampleDeveloper({ id: "update-limit-profile" }) + ); + expect(created.status).toBe(200); + + for (const name of ["First update", "Second update", "Third update"]) { + const updated = await put( + "/extensions/v2/developers/me", + headers, + sampleDeveloper({ id: "update-limit-profile", name }) + ); + expect(updated.status).toBe(200); + } + expect(ghRequest).toHaveBeenCalledTimes(1); + + const removed = await del("/extensions/v2/developers/me", headers); + expect(removed.status).toBe(200); + mockGithubEntity("Organization"); + + for (const id of ["remaining-one", "remaining-two"]) { + const allowed = await put("/extensions/v2/developers/me", headers, { + id, + type: "user", + name: id + }); + expect(allowed.status).toBe(403); + } + const denied = await put("/extensions/v2/developers/me", headers, { + id: "no-allowance", + type: "user", + name: "No allowance" + }); + expect(denied.status).toBe(429); + expect(ghRequest).toHaveBeenCalledTimes(3); + expect(await listDevelopers(db)).toHaveLength(0); + }); + + it("creates a new developer profile, unapproved", async () => { + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { result: { approved: boolean } }; + expect(data.result.approved).toBe(false); + + const stored = await getDeveloper(db, "dev-developer"); + expect(stored).toBeDefined(); + expect(stored?.approved_at).toBeNull(); + }); + + it("updates an existing profile, still unapproved", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper({ name: "Renamed Developer" }) + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: { name: string; approved: boolean }; + }; + expect(data.result.name).toBe("Renamed Developer"); + expect(data.result.approved).toBe(false); + expect((await getDeveloper(db, "dev-developer"))?.name).toBe( + "Renamed Developer" + ); + }); + + it("only lets one of two concurrent first-time profile creations by the same caller win", async () => { + const headers = await authHeaders("user-1"); + const [resA, resB] = await Promise.all([ + put( + "/extensions/v2/developers/me", + headers, + sampleDeveloper({ id: "developer-a" }) + ), + put( + "/extensions/v2/developers/me", + headers, + sampleDeveloper({ id: "developer-b" }) + ) + ]); + + const statuses = [resA.status, resB.status].sort(); + expect(statuses).toEqual([200, 409]); + + const ownedDevelopers = (await listDevelopers(db)).filter( + (a) => a.owner_user_id === "user-1" + ); + expect(ownedDevelopers).toHaveLength(1); + }); + + it("rejects an id that already belongs to someone else", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-2"), + sampleDeveloper() + ); + + expect(res.status).toBe(409); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("DEVELOPER_ID_TAKEN"); + }); + + it("classifies a concurrent id collision as DEVELOPER_ID_TAKEN", async () => { + const headers = await authHeaders("user-1"); + let raced = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!raced && sql.includes("INSERT INTO developers")) { + raced = true; + await insertDeveloper(db, { + id: "raced-developer", + type: "user", + name: "Concurrent Creator", + owner_user_id: "user-2" + }); + } + }); + + const res = await put( + "/extensions/v2/developers/me", + headers, + sampleDeveloper({ id: "raced-developer" }) + ); + env.DB_EXTENSIONS = db; + + expect(raced).toBe(true); + expect(res.status).toBe(409); + expect(await res.json()).toMatchObject({ + error: { code: "DEVELOPER_ID_TAKEN" } + }); + expect((await getDeveloper(db, "raced-developer"))?.owner_user_id).toBe( + "user-2" + ); + }); + + it("rejects changing the id on an existing profile", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper({ id: "different-id" }) + ); + + expect(res.status).toBe(409); + }); + + it("verifies a new profile when the creator's linked GitHub org matches the id", async () => { + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["acme-org"]) + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "acme-org", type: "organization", name: "Acme Org" } + ); + + expect(res.status).toBe(200); + const created = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(created.result.github_org_verified).toBe(true); + }); + + it("keeps username verification independent of organization expiry", async () => { + mockGithubEntity("User"); + await insertUser(db, { + id: "user-1", + github_login: "dev-developer", + github_orgs: JSON.stringify(["former-org"]), + github_orgs_expires_at: "2000-01-01T00:00:00.000Z" + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(body.result.github_org_verified).toBe(true); + }); + + it.each([ + ["expired", "2000-01-01T00:00:00.000Z"], + ["missing", null], + ["malformed", "2099"] + ])( + "falls back to manual review when %s GitHub membership evidence is unavailable", + async (_state, github_orgs_expires_at) => { + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["acme-org"]), + github_orgs_expires_at + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "acme-org", type: "organization", name: "Acme Org" } + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(body.result.github_org_verified).toBeUndefined(); + } + ); + + it("falls back to manual review when the linked GitHub login is whitespace-only", async () => { + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: " ", + github_orgs: JSON.stringify(["acme-org"]), + github_orgs_expires_at: "2099-01-01T00:00:00.000Z" + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "acme-org", type: "organization", name: "Acme Org" } + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(body.result.github_org_verified).toBeUndefined(); + }); + + it("does not verify an organization from a fresh confirmed empty list", async () => { + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify([]), + github_orgs_expires_at: "2099-01-01T00:00:00.000Z" + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "acme-org", type: "organization", name: "Acme Org" } + ); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("GITHUB_MISMATCH"); + }); + + it("verifies the Publisher URL when it matches GitHub's on-file website", async () => { + mockGithubEntity("Organization", "https://www.acme.example/"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["acme-org"]) + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { + id: "acme-org", + type: "organization", + name: "Acme Org", + URL: "https://acme.example" + } + ); + + expect(res.status).toBe(200); + const created = (await res.json()) as { + result: { + github_org_verified?: boolean; + github_url_verified?: boolean; + }; + }; + expect(created.result.github_org_verified).toBe(true); + expect(created.result.github_url_verified).toBe(true); + const stored = await getDeveloper(db, "acme-org"); + expect(stored?.github_url_verified).toBe(1); + }); + + it("doesn't claim a URL match when GitHub's website field differs", async () => { + mockGithubEntity("Organization", "https://other.example"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["acme-org"]) + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { + id: "acme-org", + type: "organization", + name: "Acme Org", + URL: "https://acme.example" + } + ); + + expect(res.status).toBe(200); + const created = (await res.json()) as { + result: { github_url_verified?: boolean }; + }; + expect(created.result.github_url_verified).toBeUndefined(); + const stored = await getDeveloper(db, "acme-org"); + expect(stored?.github_url_verified).toBeNull(); + }); + + it("matches the Publisher URL to GitHub's website ignoring scheme/www/trailing slash", async () => { + mockGithubEntity("Organization", "www.acme.example/"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["acme-org"]) + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { + id: "acme-org", + type: "organization", + name: "Acme Org", + URL: "http://acme.example/" + } + ); + + const created = (await res.json()) as { + result: { github_url_verified?: boolean }; + }; + expect(created.result.github_url_verified).toBe(true); + }); + + it("doesn't match Publisher URLs that only differ by port", async () => { + mockGithubEntity("Organization", "https://acme.example:8443"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["acme-org"]) + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { + id: "acme-org", + type: "organization", + name: "Acme Org", + URL: "https://acme.example" + } + ); + + const created = (await res.json()) as { + result: { github_url_verified?: boolean }; + }; + expect(created.result.github_url_verified).toBeUndefined(); + }); + + it("doesn't match Publisher URLs that only differ by path case", async () => { + mockGithubEntity("Organization", "https://acme.example/Docs"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["acme-org"]) + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { + id: "acme-org", + type: "organization", + name: "Acme Org", + URL: "https://acme.example/docs" + } + ); + + const created = (await res.json()) as { + result: { github_url_verified?: boolean }; + }; + expect(created.result.github_url_verified).toBeUndefined(); + }); + + it("blocks creating a profile whose id matches a real GitHub org/user the creator doesn't control", async () => { + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["some-other-org"]) + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "acme-org", type: "organization", name: "Acme Org" } + ); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("GITHUB_MISMATCH"); + expect(await hasDeveloper(db, "acme-org")).toBe(false); + }); + + it("blocks creating a profile whose id matches a real GitHub entity of the opposite type", async () => { + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["acme-org"]) + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "acme-org", type: "user", name: "Acme Org" } + ); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("GITHUB_MISMATCH"); + expect(await hasDeveloper(db, "acme-org")).toBe(false); + }); + + it.each([ + ["401 authentication failure", 401, "Bad credentials", 503], + ["rate-limit 403", 403, "API rate limit exceeded", 429], + ["429 throttling", 429, "Too Many Requests", 429], + ["GitHub 500", 500, "Internal Server Error", 503], + ["GitHub 503", 503, "Service Unavailable", 503] + ])( + "does not create a developer when GitHub returns %s", + async (_case, upstreamStatus, message, expectedStatus) => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => { + throw Object.assign(new Error(message as string), { + status: upstreamStatus + }); + } + ); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "unavailable-dev", type: "user", name: "Unavailable" } + ); + + expect(res.status).toBe(expectedStatus); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe( + expectedStatus === 429 ? "RATE_LIMITED" : "SERVICE_UNAVAILABLE" + ); + expect(await hasDeveloper(db, "unavailable-dev")).toBe(false); + } + ); + + it.each(["request timed out", "network connection reset"])( + "does not create a developer after a thrown %s error", + async (message) => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => { + throw new Error(message); + } + ); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "unavailable-dev", type: "user", name: "Unavailable" } + ); + + expect(res.status).toBe(503); + expect(await hasDeveloper(db, "unavailable-dev")).toBe(false); + } + ); + + it.each([ + ["missing entity type", { blog: null }], + ["non-string entity type", { type: 123, blog: null }], + ["malformed website", { type: "User", blog: { url: "example.com" } }] + ])( + "does not create a developer for %s response data", + async (_case, data) => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ data }) + ); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "invalid-response", type: "user", name: "Invalid" } + ); + + expect(res.status).toBe(503); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("SERVICE_UNAVAILABLE"); + expect(await hasDeveloper(db, "invalid-response")).toBe(false); + } + ); + + it("returns a permanent error for an unsupported GitHub entity type", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ data: { type: "Bot", blog: null } }) + ); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "unsupported-entity", type: "user", name: "Unsupported" } + ); + + expect(res.status).toBe(422); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("GITHUB_ENTITY_UNSUPPORTED"); + expect(await hasDeveloper(db, "unsupported-entity")).toBe(false); + }); + + it("falls back to unverified creation when the creator has no linked GitHub identity", async () => { + mockGithubEntity("Organization"); + // No row in users for user-1 — never linked GitHub. + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "acme-org", type: "organization", name: "Acme Org" } + ); + + expect(res.status).toBe(200); + const created = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(created.result.github_org_verified).toBeUndefined(); + }); + + it("fails creation rather than falling back to unverified when the caller's GitHub identity lookup errors", async () => { + mockGithubEntity("Organization"); + env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { + if (sql.includes("github_login") && sql.includes("github_orgs")) { + throw new Error("D1_ERROR: simulated database failure"); + } + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { id: "acme-org", type: "organization", name: "Acme Org" } + ); + + expect(res.status).toBe(500); + env.DB_EXTENSIONS = db; + expect(await hasDeveloper(db, "acme-org")).toBe(false); + }); + + it.each(["claims", "me", "unapproved"])( + "rejects the reserved id %s", + async (id) => { + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper({ id }) + ); + + expect(res.status).toBe(422); + } + ); + + it("clears approval when an approved profile is edited", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + const approved = await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 1 } + ); + expect(approved.status).toBe(200); + const approvedBody = (await approved.json()) as { + result: { approved: boolean }; + }; + expect(approvedBody.result.approved).toBe(true); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper({ name: "Edited Again" }) + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { result: { approved: boolean } }; + expect(data.result.approved).toBe(false); + }); + + it("keeps approval when a GitHub-verified profile is edited", async () => { + mockGithubEntity("User"); + await insertUser(db, { + id: "user-1", + github_login: "dev-developer", + github_orgs: JSON.stringify([]) + }); + const created = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + const createdBody = (await created.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(createdBody.result.github_org_verified).toBe(true); + + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + const approved = await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 1 } + ); + expect(approved.status).toBe(200); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper({ name: "Edited Again" }) + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: { approved: boolean; github_org_verified?: boolean }; + }; + expect(data.result.approved).toBe(true); + expect(data.result.github_org_verified).toBe(true); + }); + + it("clears approval and GitHub verification when the profile type is changed", async () => { + mockGithubEntity("User"); + await insertUser(db, { + id: "user-1", + github_login: "dev-developer", + github_orgs: JSON.stringify([]) + }); + const created = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + const createdBody = (await created.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(createdBody.result.github_org_verified).toBe(true); + + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 1 } + ); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { ...sampleDeveloper(), type: "organization" } + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: { + approved: boolean; + github_org_verified?: boolean; + github_url_verified?: boolean; + }; + }; + expect(data.result.approved).toBe(false); + expect(data.result.github_org_verified).toBeUndefined(); + expect(data.result.github_url_verified).toBeUndefined(); + }); + + it("clears github_url_verified when the Publisher URL is edited, but keeps identity verification", async () => { + mockGithubEntity("User", "https://acme.example"); + await insertUser(db, { + id: "user-1", + github_login: "dev-developer", + github_orgs: JSON.stringify([]) + }); + const created = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { ...sampleDeveloper(), URL: "https://acme.example" } + ); + const createdBody = (await created.json()) as { + result: { + github_org_verified?: boolean; + github_url_verified?: boolean; + }; + }; + expect(createdBody.result.github_org_verified).toBe(true); + expect(createdBody.result.github_url_verified).toBe(true); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { ...sampleDeveloper(), URL: "https://different.example" } + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: { + github_org_verified?: boolean; + github_url_verified?: boolean; + }; + }; + expect(data.result.github_org_verified).toBe(true); + expect(data.result.github_url_verified).toBeUndefined(); + }); + + it("does not update a profile after ownership changes mid-request", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + // Fires when upsertOwn's existing-profile UPDATE (identified by + // touching content_revision but not ownership_epoch, which only the + // ownership-transfer statements touch) is about to run - the DB + // change lands between existingOwn's read (which still sees user-1 + // as owner) and this guarded write. + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if ( + sql.includes("developers") && + sql.includes("content_revision") && + !sql.includes("ownership_epoch") + ) { + await bumpDeveloperOwnership(db, "dev-developer", "user-2"); + } + }); + + const raced = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper({ name: "Former owner write" }) + ); + env.DB_EXTENSIONS = db; + + expect(raced.status).toBe(409); + expect((await getDeveloper(db, "dev-developer"))?.name).toBe( + "Dev Developer" + ); + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( + "user-2" + ); + expect( + (await listDeveloperHistory(db)).filter( + (row) => row.developer_id === "dev-developer" + ) + ).toHaveLength(1); + }); + + it("reports an inactive account when deactivated during an existing profile update", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + let deactivated = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + const normalizedSql = sql.toLowerCase(); + if ( + !deactivated && + normalizedSql.includes('update "developers"') && + normalizedSql.includes("content_revision") + ) { + deactivated = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "user-1") + .run(); + } + }); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper({ name: "Inactive owner write" }) + ); + env.DB_EXTENSIONS = db; + + expect(deactivated).toBe(true); + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ + error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } + }); + expect((await getDeveloper(db, "dev-developer"))?.name).toBe( + "Dev Developer" + ); + }); + + 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(403); + expect(await res.json()).toMatchObject({ + error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } + }); + 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, { + ...sampleDeveloper(), + avatar_url: "https://example.com/avatar.png", + contact_email: "dev@example.com" + }); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: { + avatar_url: string; + contact_email: string; + }; + }; + expect(data.result.avatar_url).toBe("https://example.com/avatar.png"); + expect(data.result.contact_email).toBe("dev@example.com"); + + const stored = await getDeveloper(db, "dev-developer"); + expect(stored?.avatar_url).toBe("https://example.com/avatar.png"); + expect(stored?.contact_email).toBe("dev@example.com"); + }); + + it("updates avatar_url and contact_email on an existing profile", async () => { + const headers = await authHeaders("user-1"); + await put("/extensions/v2/developers/me", headers, { + ...sampleDeveloper(), + avatar_url: "https://example.com/old.png", + contact_email: "old@example.com" + }); + + const res = await put("/extensions/v2/developers/me", headers, { + ...sampleDeveloper(), + avatar_url: "https://example.com/new.png", + contact_email: "new@example.com" + }); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: { + avatar_url: string; + contact_email: string; + }; + }; + expect(data.result.avatar_url).toBe("https://example.com/new.png"); + expect(data.result.contact_email).toBe("new@example.com"); + + const stored = await getDeveloper(db, "dev-developer"); + expect(stored?.avatar_url).toBe("https://example.com/new.png"); + expect(stored?.contact_email).toBe("new@example.com"); + }); + + it("accepts a payload without avatar_url or contact_email", async () => { + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: { + avatar_url?: string; + contact_email?: string; + }; + }; + expect(data.result.avatar_url).toBeUndefined(); + expect(data.result.contact_email).toBeUndefined(); + }); + + // The profile body is strict so server-owned fields (approved, + // content_revision, the github_* verification signals) can never be set by + // the caller. Unknown keys report at the root path - the body has no + // nesting. + it("rejects an unknown field in the profile body", async () => { + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { ...sampleDeveloper(), approved: true } + ); + + expect(res.status).toBe(422); + const body = (await res.json()) as { + error: { details: Array<{ code: string; path: PropertyKey[] }> }; + }; + expect(body.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "unrecognized_keys", path: [] }) + ]) + ); + expect(await hasDeveloper(db, "dev-developer")).toBe(false); + }); + }); + + describe("DELETE /developers/me", () => { + it("deletes a profile with no extensions or pending submissions", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const res = await del( + "/extensions/v2/developers/me", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { id: string; deleted: boolean }; + }; + expect(body.result).toEqual({ id: "dev-developer", deleted: true }); + + const getRes = await get("/extensions/v2/developers/dev-developer", {}); + expect(getRes.status).toBe(404); + }); + + // The DELETE statements re-check ownership themselves rather than trusting + // the SELECT that resolved the caller's profile, because an accepted + // transfer or claim can move ownership in between. That guard is the only + // authorization check on this path, so both its halves are pinned here: + // that the profile survives, and that the batch is all-or-nothing. + it("does not delete a profile whose ownership moved after it was resolved", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("owner-before"), + sampleDeveloper({ id: "raced-delete" }) + ); + await insertUser(db, { id: "owner-after" }); + + let raced = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + // Fires before the guarded DELETE batch is sent, which is the window + // the guard exists to close. + if (!raced && sql.includes("DELETE FROM developers")) { + raced = true; + await db + .prepare("UPDATE developers SET owner_user_id = ? WHERE id = ?") + .bind("owner-after", "raced-delete") + .run(); + } + }); + + const res = await del( + "/extensions/v2/developers/me", + await authHeaders("owner-before") + ); + env.DB_EXTENSIONS = db; + + expect(raced).toBe(true); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ + error: { code: "NOT_FOUND" } + }); + + // The row survives, still owned by whoever won the race. + const developer = await getDeveloper(db, "raced-delete"); + expect(developer?.owner_user_id).toBe("owner-after"); + }); + + it("leaves pending transfers intact when the profile delete is blocked", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("owner-before"), + sampleDeveloper({ id: "raced-delete" }) + ); + await insertUser(db, { id: "owner-after" }); + await insertDeveloperTransfer(db, { + id: "transfer-1", + developer_id: "raced-delete", + created_by: "owner-before", + token_hash: "hash-1", + expires_at: "2099-01-01T00:00:00.000Z" + }); + + let raced = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!raced && sql.includes("DELETE FROM developers")) { + raced = true; + await db + .prepare("UPDATE developers SET owner_user_id = ? WHERE id = ?") + .bind("owner-after", "raced-delete") + .run(); + } + }); + + const res = await del( + "/extensions/v2/developers/me", + await authHeaders("owner-before") + ); + env.DB_EXTENSIONS = db; + + expect(res.status).toBe(404); + // The transfer delete carries the same guard, so a blocked profile + // delete must not strip the transfer out from under it. + expect(await listDeveloperTransfers(db)).toHaveLength(1); + }); + + it("404s for a caller with no developer profile", async () => { + const res = await del( + "/extensions/v2/developers/me", + await authHeaders("no-profile-user") + ); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("NOT_FOUND"); + }); + + it("409s when the profile still has published extensions", async () => { + await seedOwnedExtension(); + + const res = await del( + "/extensions/v2/developers/me", + await authHeaders("owner-1") + ); + expect(res.status).toBe(409); + const body = (await res.json()) as { + error: { code: string; message: string }; + }; + expect(body.error.code).toBe("CONFLICT"); + expect(body.error.message).toContain("1 published extension(s)"); + }); + + it("409s when a submission is pending", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await insertSubmission(db, { + id: "sub-1", + extension_id: null, + developer_id: "dev-developer", + submitted_by: "user-1", + status: "pending", + payload: JSON.stringify(samplePayload()) + }); + + const res = await del( + "/extensions/v2/developers/me", + await authHeaders("user-1") + ); + expect(res.status).toBe(409); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("CONFLICT"); + }); + + it("removes transfer tokens and claims but keeps history", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + await insertDeveloperClaim(db, { + id: "claim-1", + developer_id: "dev-developer", + claimant_id: "user-2", + status: "rejected", + review_note: "no", + reviewer_id: "mod-1", + reviewed_at: new Date().toISOString() + }); + + const res = await del( + "/extensions/v2/developers/me", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + + expect( + (await listDeveloperTransfers(db)).filter( + (r) => r.developer_id === "dev-developer" + ) + ).toHaveLength(0); + expect( + (await listDeveloperClaims(db)).filter( + (r) => r.developer_id === "dev-developer" + ) + ).toHaveLength(0); + expect( + (await listDeveloperHistory(db)).filter( + (r) => r.developer_id === "dev-developer" + ).length + ).toBeGreaterThan(0); + }); + + it("refuses to delete if ownership moves away between the lookup and the delete", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await insertUser(db, { id: "user-2" }); + // Simulates a transfer/claim landing in the window between deleteOwn's + // initial "find my profile" lookup and its guarded delete - the + // delete must re-check ownership at that point, not trust the lookup. + // deleteTransfersStmt is the first statement in deleteOwn's batch, so + // firing this before it reproduces the race exactly. + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if ( + sql.includes("DELETE FROM") && + sql.includes("developer_transfers") + ) { + await bumpDeveloperOwnership(db, "dev-developer", "user-2"); + } + }); + + const res = await del( + "/extensions/v2/developers/me", + await authHeaders("user-1") + ); + env.DB_EXTENSIONS = db; + expect(res.status).toBe(404); + + const stillThere = await get( + "/extensions/v2/developers/dev-developer", + {} + ); + expect(stillThere.status).toBe(200); + const body = (await stillThere.json()) as { result: { id: string } }; + expect(body.result.id).toBe("dev-developer"); + }); + + it("reports an inactive owner when the account is deactivated during deletion", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + let deactivated = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!deactivated && sql.includes("DELETE FROM developer_transfers")) { + deactivated = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "user-1") + .run(); + } + }); + + const res = await del( + "/extensions/v2/developers/me", + await authHeaders("user-1") + ); + env.DB_EXTENSIONS = db; + + expect(deactivated).toBe(true); + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ + error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } + }); + expect(await hasDeveloper(db, "dev-developer")).toBe(true); + }); + }); + + describe("POST /developers/me/reverify", () => { + it("reports an inactive account when deactivated during a URL cooldown reservation", async () => { + await insertUser(db, { id: "user-1" }); + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1" + }); + + let deactivated = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + const normalizedSql = sql.toLowerCase(); + if ( + !deactivated && + normalizedSql.includes('update "developers"') && + normalizedSql.includes("url_check_cooldown_until") + ) { + deactivated = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "user-1") + .run(); + } + }); + + const res = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + env.DB_EXTENSIONS = db; + + expect(deactivated).toBe(true); + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ + error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } + }); + }); + + it("re-verifies and refreshes the timestamp when the owner's GitHub org still matches", async () => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: null, + owner_user_id: "user-1", + github_org_verified: 1, + github_verification_note: + "Verified: caller's linked GitHub identity matches.", + github_verified_at: "2020-01-01T00:00:00.000Z" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_org_verified?: boolean; github_verified_at?: string }; + }; + expect(body.result.github_org_verified).toBe(true); + expect(body.result.github_verified_at).not.toBe( + "2020-01-01T00:00:00.000Z" + ); + }); + + it.each([ + [ + "expired", + JSON.stringify(["dev-developer"]), + "2000-01-01T00:00:00.000Z" + ], + ["malformed", "not-json", "2099-01-01T00:00:00.000Z"] + ])( + "preserves verification when the owner's organization evidence is %s", + async (_state, github_orgs, github_orgs_expires_at) => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: null, + owner_user_id: "user-1", + github_org_verified: 1, + github_verification_note: + "Verified: caller's linked GitHub identity matches.", + github_verified_at: "2020-01-01T00:00:00.000Z" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs, + github_orgs_expires_at + }); + + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + github_org_verified?: boolean; + github_verified_at?: string; + }; + }; + expect(body.result.github_org_verified).toBe(true); + expect(body.result.github_verified_at).toBe("2020-01-01T00:00:00.000Z"); + } + ); + + it("preserves verification when the owner's GitHub login is whitespace-only", async () => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: null, + owner_user_id: "user-1", + github_org_verified: 1, + github_verification_note: + "Verified: caller's linked GitHub identity matches.", + github_verified_at: "2020-01-01T00:00:00.000Z" + }); + await insertUser(db, { + id: "user-1", + github_login: " ", + github_orgs: JSON.stringify(["dev-developer"]), + github_orgs_expires_at: "2099-01-01T00:00:00.000Z" + }); + + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_org_verified?: boolean; github_verified_at?: string }; + }; + expect(body.result.github_org_verified).toBe(true); + expect(body.result.github_verified_at).toBe("2020-01-01T00:00:00.000Z"); + }); + + it("flips to unverified when the owner's GitHub org membership no longer matches", async () => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: null, + owner_user_id: "user-1", + github_org_verified: 1, + github_verification_note: + "Verified: caller's linked GitHub identity matches.", + github_verified_at: "2020-01-01T00:00:00.000Z" + }); + // No longer a member of dev-developer's org. + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify([]) + }); + + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + github_org_verified?: boolean; + github_verification_note?: string; + }; + }; + expect(body.result.github_org_verified).toBe(false); + expect(body.result.github_verification_note).toBe( + "No longer verified: caller's linked GitHub identity no longer matches." + ); + }); + + it("verifies for the first time on re-check when the caller now has a matching linked GitHub identity", async () => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: null, + owner_user_id: "user-1" + // github_org_verified left null — never checked before (e.g. created + // before this feature existed, or the token was down at claim time). + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(body.result.github_org_verified).toBe(true); + }); + + it("doesn't check the Publisher URL without ?check_url=true", async () => { + mockGithubEntity("Organization", "https://acme.example"); + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_url_verified?: boolean }; + }; + expect(body.result.github_url_verified).toBeUndefined(); + expect(ghRequest).not.toHaveBeenCalled(); + }); + + it("checks the Publisher URL when re-verified with ?check_url=true", async () => { + mockGithubEntity("Organization", "https://acme.example"); + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + const res = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_url_verified?: boolean }; + }; + expect(body.result.github_url_verified).toBe(true); + }); + + it.each([ + [403, "API rate limit exceeded", 429, "RATE_LIMITED"], + [503, "Service Unavailable", 503, "SERVICE_UNAVAILABLE"] + ])( + "returns an error and retains the cooldown when GitHub responds with %s", + async (upstreamStatus, message, expectedStatus, expectedCode) => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1", + github_org_verified: 1, + github_url_verified: 1 + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => { + throw Object.assign(new Error(message as string), { + status: upstreamStatus + }); + } + ); + + const failed = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + expect(failed.status).toBe(expectedStatus); + const body = (await failed.json()) as { error: { code: string } }; + expect(body.error.code).toBe(expectedCode); + expect( + (await getDeveloper(db, "dev-developer"))?.github_url_verified + ).toBe(1); + + vi.clearAllMocks(); + const retry = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + expect(retry.status).toBe(429); + expect(ghRequest).not.toHaveBeenCalled(); + } + ); + + it("rate-limits repeated ?check_url=true calls from the same caller", async () => { + mockGithubEntity("Organization", "https://acme.example"); + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + const first = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + expect(first.status).toBe(200); + vi.clearAllMocks(); + + const second = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + expect(second.status).toBe(429); + const body = (await second.json()) as { error: { code: string } }; + expect(body.error.code).toBe("RATE_LIMITED"); + // The whole point — no GitHub API call for the blocked attempt. + expect(ghRequest).not.toHaveBeenCalled(); + }); + + it("doesn't rate-limit reverify calls that don't use ?check_url", async () => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + const first = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + const second = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + }); + + it("rate-limits ?check_url=true per caller, not globally", async () => { + mockGithubEntity("Organization", "https://acme.example"); + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1" + }); + await insertDeveloper(db, { + id: "other-developer", + type: "organization", + name: "Other", + url: "https://acme.example", + owner_user_id: "user-2" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + await insertUser(db, { + id: "user-2", + github_login: "someone-else", + github_orgs: JSON.stringify(["other-developer"]) + }); + + const first = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + const second = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-2") + ); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + }); + + it("only lets one of two concurrent ?check_url=true requests through", async () => { + mockGithubEntity("Organization", "https://acme.example"); + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + const headers = await authHeaders("user-1"); + const [first, second] = await Promise.all([ + post("/extensions/v2/developers/me/reverify?check_url=true", headers), + post("/extensions/v2/developers/me/reverify?check_url=true", headers) + ]); + + const statuses = [first.status, second.status].sort(); + expect(statuses).toEqual([200, 429]); + }); + + it("does not persist a URL verification computed against a stale URL", async () => { + mockGithubEntity("Organization", "https://acme.example"); + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + // Fires just before reverifyOwn's final write (identified by + // touching github_verified_at, which only that statement sets) — the + // Publisher URL changes between the check and the write, same shape + // as the existing ownership-race test above. + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (sql.includes("developers") && sql.includes("github_verified_at")) { + await db + .prepare("UPDATE developers SET url = ? WHERE id = ?") + .bind("https://different.example", "dev-developer") + .run(); + } + }); + + const res = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + env.DB_EXTENSIONS = db; + + expect(res.status).toBe(409); + expect( + (await getDeveloper(db, "dev-developer"))?.github_url_verified + ).toBe(null); + expect((await getDeveloper(db, "dev-developer"))?.url).toBe( + "https://different.example" + ); + }); + + it("clears a previously-verified Publisher URL when identity no longer matches", async () => { + mockGithubEntity("Organization", "https://acme.example"); + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1", + github_org_verified: 1, + github_url_verified: 1 + }); + // No longer a member of dev-developer's org. + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify([]) + }); + + const res = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + github_org_verified?: boolean; + github_url_verified?: boolean; + }; + }; + expect(body.result.github_org_verified).toBe(false); + expect(body.result.github_url_verified).toBeUndefined(); + }); + + it("clears a previously-verified Publisher URL when identity no longer matches, even without ?check_url", async () => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1", + github_org_verified: 1, + github_url_verified: 1 + }); + // No longer a member of dev-developer's org. + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify([]) + }); + + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + github_org_verified?: boolean; + github_url_verified?: boolean; + }; + }; + expect(body.result.github_org_verified).toBe(false); + expect(body.result.github_url_verified).toBeUndefined(); + // Clearing a stale URL signal on an identity mismatch is a local + // comparison, same as the identity check itself — no GitHub API call. + expect(ghRequest).not.toHaveBeenCalled(); + }); + + it("doesn't verify the Publisher URL against a GitHub entity of the wrong type", async () => { + // The stored profile is a "user", but the GitHub entity currently + // found for this id is an "organization" — matchesClaimant() only + // compares login/org membership, so this discrepancy has to be + // caught separately before trusting the entity's blog field. + mockGithubEntity("Organization", "https://acme.example"); + await insertDeveloper(db, { + id: "dev-developer", + type: "user", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1" + }); + await insertUser(db, { + id: "user-1", + github_login: "dev-developer", + github_orgs: JSON.stringify([]) + }); + + const res = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + github_org_verified?: boolean; + github_url_verified?: boolean; + github_verification_note?: string; + }; + }; + expect(body.result.github_url_verified).toBeUndefined(); + // The same discrepancy that rules out the URL match also undermines + // the identity match itself — matchesClaimant() alone can't catch + // this since it never queries GitHub's actual current entity type. + expect(body.result.github_org_verified).toBe(false); + expect(body.result.github_verification_note).toBe( + "No longer verified: GitHub's on-file entity type no longer matches this profile." + ); + }); + + it("preserves an existing Publisher URL verification when the GitHub lookup fails", async () => { + mockGithubEntityNotFound(); + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1", + github_org_verified: 1, + github_url_verified: 1 + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + const res = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_url_verified?: boolean }; + }; + expect(body.result.github_url_verified).toBe(true); + }); + + it("treats ?check_url=false the same as omitting it", async () => { + mockGithubEntity("Organization", "https://acme.example"); + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: "https://acme.example", + owner_user_id: "user-1" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + const res = await post( + "/extensions/v2/developers/me/reverify?check_url=false", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { github_url_verified?: boolean }; + }; + expect(body.result.github_url_verified).toBeUndefined(); + expect(ghRequest).not.toHaveBeenCalled(); + }); + + it("404s when the caller doesn't own a developer profile", async () => { + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + expect(res.status).toBe(404); + }); + + it("refuses to overwrite verification if ownership moves away between the lookup and the write", async () => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: null, + owner_user_id: "user-1" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + // Simulates a transfer/claim landing in the window between + // reverifyOwn's initial "find my profile" lookup and its guarded + // write - the write must re-check ownership at that point, not trust + // the lookup, or it would write a result computed from the *former* + // owner's GitHub identity onto the profile after it's changed hands. + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (sql.includes("update") && sql.includes("github_verified_at")) { + await bumpDeveloperOwnership(db, "dev-developer", "user-2"); + } + }); + + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + env.DB_EXTENSIONS = db; + expect(res.status).toBe(409); + + const developerRow = await getDeveloper(db, "dev-developer"); + expect(developerRow?.owner_user_id).toBe("user-2"); + expect(developerRow?.github_org_verified).toBeNull(); + }); + + it("refuses to overwrite verification if the profile type changes during the check", async () => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: null, + owner_user_id: "user-1", + github_org_verified: 1, + github_verification_note: + "Verified: caller's linked GitHub identity matches.", + github_verified_at: "2020-01-01T00:00:00.000Z" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]) + }); + + let changed = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!changed && sql.includes("github_verified_at")) { + changed = true; + await db + .prepare("UPDATE developers SET type = ? WHERE id = ?") + .bind("user", "dev-developer") + .run(); + } + }); + + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + env.DB_EXTENSIONS = db; + + expect(changed).toBe(true); + expect(res.status).toBe(409); + const developerRow = await getDeveloper(db, "dev-developer"); + expect(developerRow?.type).toBe("user"); + expect(developerRow?.github_org_verified).toBe(1); + expect(developerRow?.github_verified_at).toBe("2020-01-01T00:00:00.000Z"); + }); + + it("refuses to overwrite verification if GitHub identity sync wins the race", async () => { + await insertDeveloper(db, { + id: "dev-developer", + type: "organization", + name: "Dev", + url: null, + owner_user_id: "user-1", + github_org_verified: 1, + github_verification_note: + "Verified: caller's linked GitHub identity matches.", + github_verified_at: "2020-01-01T00:00:00.000Z" + }); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["dev-developer"]), + github_orgs_expires_at: "2099-01-01T00:00:00.000Z" + }); + + let synced = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!synced && sql.includes("github_verified_at")) { + synced = true; + await db + .prepare( + `UPDATE users + SET github_login = ?, github_orgs = ?, github_orgs_expires_at = ?, + updated_at = ? + WHERE id = ?` + ) + .bind( + "different-user", + JSON.stringify(["different-org"]), + "2099-01-01T00:00:00.000Z", + new Date().toISOString(), + "user-1" + ) + .run(); + } + }); + + const res = await post( + "/extensions/v2/developers/me/reverify", + await authHeaders("user-1") + ); + env.DB_EXTENSIONS = db; + + expect(synced).toBe(true); + expect(res.status).toBe(409); + const developerRow = await getDeveloper(db, "dev-developer"); + expect(developerRow?.github_org_verified).toBe(1); + expect(developerRow?.github_verified_at).toBe("2020-01-01T00:00:00.000Z"); + }); + }); + + describe("GET /developers/{id}", () => { + it("returns a developer's public profile without contact_email, unauthenticated", async () => { + await insertDeveloper(db, { + id: "public-dev", + type: "organization", + name: "Public Dev", + url: "https://example.com", + avatar_url: "https://example.com/avatar.png", + contact_email: "private@example.com", + owner_user_id: "user-1", + approved_at: new Date().toISOString() + }); + + const res = await get("/extensions/v2/developers/public-dev", {}); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: Record }; + expect(body.result).toEqual({ + id: "public-dev", + type: "organization", + name: "Public Dev", + URL: "https://example.com", + avatar_url: "https://example.com/avatar.png", + approved: true, + unclaimed: false + }); + expect(body.result.contact_email).toBeUndefined(); + }); + + it("404s for an unknown developer", async () => { + 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 } + }); + }); + }); +}); diff --git a/test/services/extensions/v2/harness.ts b/test/services/extensions/v2/harness.ts new file mode 100644 index 0000000..44c220e --- /dev/null +++ b/test/services/extensions/v2/harness.ts @@ -0,0 +1,267 @@ +import { beforeAll, beforeEach, afterEach, vi } from "vitest"; +import { + createExecutionContext, + waitOnExecutionContext +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { request as ghRequest } from "@octokit/request"; +import app from "../../../../src/app"; +import { signAssertion } from "../../../lib/auth/assertion-helper"; +import { MockGitHubRequest } from "../../../utils/test-types"; +import { applyTestMigrations } from "../../../utils/apply-migrations"; +import { + resetExtensionsDb, + ensureUser, + insertDeveloper, + insertExtension +} from "./db-fixtures"; + +// Matches the ASSERTION_SIGNING_SECRET binding configured in vitest.config.ts. +const SECRET = "test-assertion-signing-secret"; + +// The real D1_EXTENSIONS binding. beforeEach captures it fresh each time and +// afterEach always restores env.DB_EXTENSIONS to this reference, so the +// handful of tests that temporarily wrap it (see db-interceptor.ts) for a +// fault/race injection never leak that wrapper into the next test. Suites +// read it with `import { db }` — an ES module live binding, so they see each +// beforeEach reassignment. They must never assign to it; the tests that +// inject a fault replace env.DB_EXTENSIONS instead, which is what keeps the +// fixtures below running against the unwrapped binding. +export let db: D1Database; + +// The default applied in beforeEach: DeveloperClaimsDatabase.claim()'s GitHub +// entity-existence check must never make a real network call. "Not found" +// matches classifyGitHubError's NotFoundError check in +// src/services/extensions/v2/github/verification.ts, which makes claim() fall +// back to the unverified/manual-review path these tests expect. Individual +// tests call mockGithubEntity() to exercise the verified/mismatch paths. +export function mockGithubEntityNotFound(): void { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation(async () => { + throw Object.assign(new Error("Not Found"), { status: 404 }); + }); +} + +export function mockGithubEntity( + type: "User" | "Organization", + blog?: string +): void { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation(async () => ({ + data: { type, blog } + })); +} + +function freshProfileCreationRateLimiter(): RateLimit { + const attempts = new Map(); + return { + async limit({ key }) { + const next = (attempts.get(key) ?? 0) + 1; + attempts.set(key, next); + return { success: next <= 3 }; + } + }; +} + +// Every v2 suite calls this once at the top of the file. It owns migrations, +// per-test database reset, the rate-limiter stub, the GitHub mock default, +// and the exported `db` binding the suites import. +export function setupExtensionsV2Tests(): void { + beforeAll(applyTestMigrations); + + beforeEach(async () => { + db = env.DB_EXTENSIONS; + await resetExtensionsDb(db); + env.PROFILE_CREATION_RATE_LIMITER = freshProfileCreationRateLimiter(); + vi.clearAllMocks(); + mockGithubEntityNotFound(); + }); + + afterEach(() => { + env.DB_EXTENSIONS = db; + }); +} + +// In production a caller always already has a `users` row by the time they +// call this API - the shared auth service that mints the assertion is the +// same one that populates it. Real D1 enforces developers.owner_user_id +// (and similar) as a hard FK to users(id), so tests need that precondition +// too; ensureUser() is a no-op if a richer row already exists for this sub. +export async function authHeaders( + sub: string +): Promise> { + await ensureUser(db, sub); + const token = await signAssertion(SECRET, { sub }); + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }; +} + +export function samplePayload(overrides?: { + extensionId?: string; + developerId?: string; +}) { + return { + developer: { + id: overrides?.developerId ?? "new-developer", + type: "user", + name: "Some Developer", + URL: "https://example.com" + }, + extension: { + id: overrides?.extensionId ?? "new-ext", + type: "mod", + name: "New Extension", + description: "A new extension", + releases: [ + { + tag: "1.0.0", + date: "2026-01-01T00:00:00Z", + download_url: "https://example.com/download.zip", + min_fossbilling_version: "0.6" + } + ], + website: "https://example.com", + license: { name: "MIT" }, + readme: "# Readme", + source: { type: "github", repo: "example/new-ext" }, + version: "1.0.0", + download_url: "https://example.com/download.zip" + } + }; +} + +export function sampleDeveloper(overrides?: { id?: string; name?: string }) { + return { + id: overrides?.id ?? "dev-developer", + type: "user", + name: overrides?.name ?? "Dev Developer", + URL: "https://example.com" + }; +} + +// Extension submissions now require the named developer to already exist +// (created via PUT /developers/me) and be owned by the caller. +export async function seedDeveloper( + id: string, + ownerUserId: string +): Promise { + await insertDeveloper(db, { + id, + type: "user", + name: "Developer", + url: null, + owner_user_id: ownerUserId + }); +} + +export async function seedUnownedDeveloper( + id: string, + name = "Legacy Developer" +): Promise { + await insertDeveloper(db, { + id, + type: "user", + name, + url: null, + owner_user_id: null + }); +} + +export async function seedOwnedExtension(): Promise { + await insertDeveloper(db, { + id: "owner-developer", + type: "user", + name: "Owner", + url: null, + owner_user_id: "owner-1" + }); + await insertExtension(db, { + id: "existing-ext", + type: "mod", + author_id: "owner-developer", + name: "Existing", + description: "d", + releases: "[]", + website: "https://e.com", + license: '{"name":"MIT"}', + icon_url: null, + readme: "r", + source: '{"type":"github","repo":"example/existing"}', + version: "1.0.0", + download_url: "https://e.com/d.zip" + }); +} + +export async function post( + path: string, + headers: Record, + body?: unknown +) { + const ctx = createExecutionContext(); + const res = await app.request( + path, + { + method: "POST", + headers, + body: body !== undefined ? JSON.stringify(body) : undefined + }, + env, + ctx + ); + await waitOnExecutionContext(ctx); + return res; +} + +export async function get(path: string, headers: Record) { + const ctx = createExecutionContext(); + const res = await app.request(path, { headers }, env, ctx); + await waitOnExecutionContext(ctx); + return res; +} + +export async function del(path: string, headers: Record) { + const ctx = createExecutionContext(); + const res = await app.request(path, { method: "DELETE", headers }, env, ctx); + await waitOnExecutionContext(ctx); + return res; +} + +export async function put( + path: string, + headers: Record, + body?: unknown +) { + const ctx = createExecutionContext(); + const res = await app.request( + path, + { + method: "PUT", + headers, + body: body !== undefined ? JSON.stringify(body) : undefined + }, + env, + ctx + ); + await waitOnExecutionContext(ctx); + return res; +} + +export 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; +} diff --git a/test/services/extensions/v2/index.test.ts b/test/services/extensions/v2/index.test.ts index e19f342..02d8b27 100644 --- a/test/services/extensions/v2/index.test.ts +++ b/test/services/extensions/v2/index.test.ts @@ -1,4678 +1,15 @@ -import { - describe, - it, - expect, - beforeAll, - beforeEach, - afterEach, - vi -} from "vitest"; -import { - createExecutionContext, - waitOnExecutionContext -} from "cloudflare:test"; -import { env } from "cloudflare:workers"; +import { describe, it, expect, vi } from "vitest"; +import { setupExtensionsV2Tests, get } from "./harness"; -// Mocked so DeveloperClaimsDatabase.claim()'s GitHub entity-existence check never -// makes a real network call. Defaults to "not found" (matching classifyGitHubError's -// NotFoundError check in github-verification.ts), which makes claim() fall -// back to today's unverified/manual-review path — the same behavior these -// pre-existing tests expect. Individual tests override this to exercise the -// verified/mismatch paths. -vi.mock("@octokit/request", () => { - const endpoint = { DEFAULTS: {} }; - const derivedFn = Object.assign(vi.fn(), { defaults: vi.fn(), endpoint }); - const request = Object.assign(vi.fn(), { - defaults: vi.fn().mockReturnValue(derivedFn), - endpoint - }); - return { request }; -}); - -import { request as ghRequest } from "@octokit/request"; -import app from "../../../../src/app"; -import { signAssertion } from "../../../lib/auth/assertion-helper"; -import { MockGitHubRequest } from "../../../utils/test-types"; -import { applyTestMigrations } from "../../../utils/apply-migrations"; -import { wrapD1WithHook } from "./db-interceptor"; -import { - resetExtensionsDb, - ensureUser, - insertUser, - insertDeveloper, - insertExtension, - insertSubmission, - insertDeveloperClaim, - getDeveloper, - hasDeveloper, - listDevelopers, - countExtensions, - getExtension, - countSubmissions, - getSubmission, - listSubmissions, - countDeveloperClaims, - getDeveloperClaim, - insertDeveloperTransfer, - insertDeveloperHistory, - listDeveloperTransfers, - listDeveloperClaims, - listDeveloperHistory, - expireAllDeveloperTransfers, - bumpDeveloperOwnership -} from "./db-fixtures"; - -function mockGithubEntityNotFound(): void { - (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation(async () => { - throw Object.assign(new Error("Not Found"), { status: 404 }); - }); -} - -function mockGithubEntity(type: "User" | "Organization", blog?: string): void { - (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation(async () => ({ - data: { type, blog } - })); -} - -// Matches the ASSERTION_SIGNING_SECRET binding configured in vitest.config.ts. -const SECRET = "test-assertion-signing-secret"; - -// The real D1_EXTENSIONS binding. beforeEach captures it fresh each time and -// afterEach always restores env.DB_EXTENSIONS to this reference, so the -// handful of tests that temporarily wrap it (see db-interceptor.ts) for a -// fault/race injection never leak that wrapper into the next test. -let db: D1Database; - -function freshProfileCreationRateLimiter(): RateLimit { - const attempts = new Map(); - return { - async limit({ key }) { - const next = (attempts.get(key) ?? 0) + 1; - attempts.set(key, next); - return { success: next <= 3 }; - } - }; -} - -beforeAll(applyTestMigrations); - -beforeEach(async () => { - db = env.DB_EXTENSIONS; - await resetExtensionsDb(db); - env.PROFILE_CREATION_RATE_LIMITER = freshProfileCreationRateLimiter(); - vi.clearAllMocks(); - mockGithubEntityNotFound(); -}); - -afterEach(() => { - env.DB_EXTENSIONS = db; -}); - -// In production a caller always already has a `users` row by the time they -// call this API - the shared auth service that mints the assertion is the -// same one that populates it. Real D1 enforces developers.owner_user_id -// (and similar) as a hard FK to users(id), so tests need that precondition -// too; ensureUser() is a no-op if a richer row already exists for this sub. -async function authHeaders(sub: string): Promise> { - await ensureUser(db, sub); - const token = await signAssertion(SECRET, { sub }); - return { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json" - }; -} - -function samplePayload(overrides?: { - extensionId?: string; - developerId?: string; -}) { - return { - developer: { - id: overrides?.developerId ?? "new-developer", - type: "user", - name: "Some Developer", - URL: "https://example.com" - }, - extension: { - id: overrides?.extensionId ?? "new-ext", - type: "mod", - name: "New Extension", - description: "A new extension", - releases: [ - { - tag: "1.0.0", - date: "2026-01-01T00:00:00Z", - download_url: "https://example.com/download.zip", - min_fossbilling_version: "0.6" - } - ], - website: "https://example.com", - license: { name: "MIT" }, - readme: "# Readme", - source: { type: "github", repo: "example/new-ext" }, - version: "1.0.0", - download_url: "https://example.com/download.zip" - } - }; -} - -// Extension submissions now require the named developer to already exist -// (created via PUT /developers/me) and be owned by the caller. -async function seedDeveloper(id: string, ownerUserId: string): Promise { - await insertDeveloper(db, { - id, - type: "user", - name: "Developer", - url: null, - owner_user_id: ownerUserId - }); -} - -async function seedUnownedDeveloper( - id: string, - name = "Legacy Developer" -): Promise { - await insertDeveloper(db, { - id, - type: "user", - name, - url: null, - owner_user_id: null - }); -} - -async function seedOwnedExtension(): Promise { - await insertDeveloper(db, { - id: "owner-developer", - type: "user", - name: "Owner", - url: null, - owner_user_id: "owner-1" - }); - await insertExtension(db, { - id: "existing-ext", - type: "mod", - author_id: "owner-developer", - name: "Existing", - description: "d", - releases: "[]", - website: "https://e.com", - license: '{"name":"MIT"}', - icon_url: null, - readme: "r", - source: '{"type":"github","repo":"example/existing"}', - version: "1.0.0", - download_url: "https://e.com/d.zip" - }); -} - -async function post( - path: string, - headers: Record, - body?: unknown -) { - const ctx = createExecutionContext(); - const res = await app.request( - path, - { - method: "POST", - headers, - body: body !== undefined ? JSON.stringify(body) : undefined - }, - env, - ctx - ); - await waitOnExecutionContext(ctx); - return res; -} - -async function get(path: string, headers: Record) { - const ctx = createExecutionContext(); - const res = await app.request(path, { headers }, env, ctx); - await waitOnExecutionContext(ctx); - return res; -} - -async function del(path: string, headers: Record) { - const ctx = createExecutionContext(); - const res = await app.request(path, { method: "DELETE", headers }, env, ctx); - await waitOnExecutionContext(ctx); - return res; -} - -async function put( - path: string, - headers: Record, - body?: unknown -) { - const ctx = createExecutionContext(); - const res = await app.request( - path, - { - method: "PUT", - headers, - body: body !== undefined ? JSON.stringify(body) : undefined - }, - env, - ctx - ); - await waitOnExecutionContext(ctx); - 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", - type: "user", - name: overrides?.name ?? "Dev Developer", - URL: "https://example.com" - }; -} - -describe("Extensions API v2", () => { - describe("POST /submissions", () => { - it("requires auth", async () => { - const res = await post( - "/extensions/v2/submissions", - { - "Content-Type": "application/json" - }, - samplePayload() - ); - expect(res.status).toBe(401); - }); - - it("rejects an invalid payload", async () => { - const headers = await authHeaders("user-1"); - const res = await post("/extensions/v2/submissions", headers, { - developer: {}, - extension: {} - }); - expect(res.status).toBe(422); - const data = (await res.json()) as { error: { code: string } }; - expect(data.error.code).toBe("VALIDATION_ERROR"); - }); - - it("rejects the reserved extension id mine", async () => { - const payload = samplePayload({ extensionId: "mine" }); - const res = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - payload - ); - - expect(res.status).toBe(422); - expect(await countSubmissions(db)).toBe(0); - }); - - it("rejects profile fields (avatar_url/contact_email) on a submission's developer", async () => { - await seedDeveloper("new-developer", "user-1"); - const headers = await authHeaders("user-1"); - const payload = samplePayload(); - const res = await post("/extensions/v2/submissions", headers, { - ...payload, - developer: { - ...payload.developer, - avatar_url: "https://example.com/should-not-be-accepted.png" - } - }); - - expect(res.status).toBe(422); - expect(await countSubmissions(db)).toBe(0); - }); - - it("creates a pending submission for a brand-new extension under an existing developer", async () => { - await seedDeveloper("new-developer", "user-1"); - const headers = await authHeaders("user-1"); - const res = await post( - "/extensions/v2/submissions", - headers, - samplePayload() - ); - - expect(res.status).toBe(201); - const data = (await res.json()) as { - result: { id: string; status: string }; - }; - expect(data.result.status).toBe("pending"); - expect(await countSubmissions(db)).toBe(1); - - const stored = await getSubmission(db, data.result.id); - expect(stored?.extension_id).toBeNull(); - expect(stored?.submitted_by).toBe("user-1"); - }); - - it("rejects editing an extension not owned by the caller", async () => { - await seedOwnedExtension(); - const headers = await authHeaders("intruder"); - - const res = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ - extensionId: "existing-ext", - developerId: "owner-developer" - }) - ); - - expect(res.status).toBe(403); - expect(await countSubmissions(db)).toBe(0); - }); - - it("allows editing an extension owned by the caller", async () => { - await seedOwnedExtension(); - const headers = await authHeaders("owner-1"); - - const res = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ - extensionId: "existing-ext", - developerId: "owner-developer" - }) - ); - - expect(res.status).toBe(201); - const [stored] = await listSubmissions(db); - expect(stored.extension_id).toBe("existing-ext"); - }); - - it("rejects claiming a developer already owned by someone else", async () => { - await seedOwnedExtension(); - const headers = await authHeaders("intruder"); - - const res = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ - extensionId: "another-new-ext", - developerId: "owner-developer" - }) - ); - - expect(res.status).toBe(403); - }); - - it("rejects naming a developer id that doesn't exist at all", async () => { - const headers = await authHeaders("user-1"); - - const res = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ developerId: "no-such-developer" }) - ); - - expect(res.status).toBe(403); - expect(await countSubmissions(db)).toBe(0); - }); - - it("bounds payload size and the number of releases", async () => { - await seedDeveloper("new-developer", "user-1"); - const payload = samplePayload(); - const oversized = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - { - ...payload, - extension: { ...payload.extension, readme: "x".repeat(100_001) } - } - ); - expect(oversized.status).toBe(422); - - const unknownExtensionField = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - { - ...payload, - extension: { - ...payload.extension, - padding: "x" - } - } - ); - expect(unknownExtensionField.status).toBe(422); - const unknownExtensionBody = (await unknownExtensionField.json()) as { - error: { details: Array<{ code: string; path: PropertyKey[] }> }; - }; - expect(unknownExtensionBody.error.details).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: "unrecognized_keys", - path: ["extension"] - }) - ]) - ); - - const unknownReleaseField = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - { - ...payload, - extension: { - ...payload.extension, - releases: [ - { - ...payload.extension.releases[0], - padding: "x" - } - ] - } - } - ); - expect(unknownReleaseField.status).toBe(422); - const unknownReleaseBody = (await unknownReleaseField.json()) as { - error: { details: Array<{ code: string; path: PropertyKey[] }> }; - }; - expect(unknownReleaseBody.error.details).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: "unrecognized_keys", - path: ["extension", "releases", 0] - }) - ]) - ); - - const tooManyReleases = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - { - ...payload, - extension: { - ...payload.extension, - releases: Array.from( - { length: 101 }, - () => payload.extension.releases[0] - ) - } - } - ); - expect(tooManyReleases.status).toBe(422); - }); - - it("preserves compatibility with stored slug ids over 100 characters", async () => { - const developerId = "d".repeat(120); - const extensionId = "e".repeat(120); - await seedDeveloper(developerId, "user-1"); - - const res = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload({ developerId, extensionId }) - ); - - expect(res.status).toBe(201); - }); - - it("rejects duplicate pending targets and caps each user's backlog", async () => { - await seedDeveloper("new-developer", "user-1"); - const headers = await authHeaders("user-1"); - expect( - (await post("/extensions/v2/submissions", headers, samplePayload())) - .status - ).toBe(201); - expect( - (await post("/extensions/v2/submissions", headers, samplePayload())) - .status - ).toBe(409); - - await seedDeveloper("other-developer", "user-2"); - expect( - ( - await post( - "/extensions/v2/submissions", - await authHeaders("user-2"), - samplePayload({ developerId: "other-developer" }) - ) - ).status - ).toBe(409); - - for (let index = 1; index < 10; index++) { - const result = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ extensionId: `new-ext-${index}` }) - ); - expect(result.status).toBe(201); - } - const overLimit = await post( - "/extensions/v2/submissions", - headers, - samplePayload({ extensionId: "new-ext-over-limit" }) - ); - expect(overLimit.status).toBe(409); - expect(await countSubmissions(db)).toBe(10); - }); - }); - - describe("GET /submissions/mine", () => { - it("returns only the caller's own submissions", async () => { - await seedDeveloper("developer-a", "user-1"); - await seedDeveloper("developer-b", "user-2"); - await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload({ extensionId: "ext-a", developerId: "developer-a" }) - ); - await post( - "/extensions/v2/submissions", - await authHeaders("user-2"), - samplePayload({ extensionId: "ext-b", developerId: "developer-b" }) - ); - - const res = await get( - "/extensions/v2/submissions/mine", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: Array<{ submitted_by: string }>; - }; - expect(data.result).toHaveLength(1); - expect(data.result[0].submitted_by).toBe("user-1"); - }); - - it("requires auth", async () => { - const res = await get("/extensions/v2/submissions/mine", {}); - expect(res.status).toBe(401); - }); - - it("identifies invalid cursors", async () => { - const res = await get( - "/extensions/v2/submissions/mine?cursor=not-a-cursor", - await authHeaders("user-1") - ); - expect(res.status).toBe(422); - await expect(res.json()).resolves.toMatchObject({ - error: { code: "INVALID_CURSOR" } - }); - }); - - it("paginates deterministically with an opaque cursor", async () => { - await seedDeveloper("new-developer", "user-1"); - const headers = await authHeaders("user-1"); - for (const extensionId of ["page-a", "page-b", "page-c"]) { - expect( - ( - await post( - "/extensions/v2/submissions", - headers, - samplePayload({ extensionId }) - ) - ).status - ).toBe(201); - } - - const first = await get( - "/extensions/v2/submissions/mine?limit=2", - headers - ); - const firstBody = (await first.json()) as { - result: unknown[]; - pagination: { has_more: boolean; next_cursor: string }; - }; - expect(firstBody.result).toHaveLength(2); - expect(firstBody.pagination.has_more).toBe(true); - - const second = await get( - `/extensions/v2/submissions/mine?limit=2&cursor=${encodeURIComponent(firstBody.pagination.next_cursor)}`, - headers - ); - const secondBody = (await second.json()) as { - result: unknown[]; - pagination: { has_more: boolean; next_cursor: null }; - }; - expect(secondBody.result).toHaveLength(1); - expect(secondBody.pagination).toEqual({ - has_more: false, - next_cursor: null - }); - }); - }); - - describe("GET /submissions/queue", () => { - it("requires moderator access", async () => { - const res = await get( - "/extensions/v2/submissions/queue", - await authHeaders("user-1") - ); - expect(res.status).toBe(403); - }); - - it("identifies invalid cursors", async () => { - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - const res = await get( - "/extensions/v2/submissions/queue?cursor=not-a-cursor", - await authHeaders("mod-1") - ); - expect(res.status).toBe(422); - await expect(res.json()).resolves.toMatchObject({ - error: { code: "INVALID_CURSOR" } - }); - }); - - it("returns pending submissions for a moderator", async () => { - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - await seedDeveloper("new-developer", "user-1"); - await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - - const res = await get( - "/extensions/v2/submissions/queue", - await authHeaders("mod-1") - ); - expect(res.status).toBe(200); - const data = (await res.json()) as { result: Array<{ status: string }> }; - expect(data.result).toHaveLength(1); - expect(data.result[0].status).toBe("pending"); - }); - }); - - describe("approve / reject", () => { - it("does not approve a former owner's payload when ownership changes at approval", async () => { - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - await seedDeveloper("new-developer", "user-1"); - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; - - // ownership_epoch is captured on the submission at creation time and - // only compared later, so unlike the deleteOwn/upsertOwn races below, - // simply changing ownership before the approve call (rather than - // mid-request) reproduces this exactly. - await bumpDeveloperOwnership(db, "new-developer", "user-2"); - const approved = await post( - `/extensions/v2/submissions/${result.id}/approve`, - await authHeaders("mod-1"), - {} - ); - expect(approved.status).toBe(409); - expect((await getSubmission(db, result.id))?.status).toBe("pending"); - expect(await countExtensions(db)).toBe(0); - }); - - 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"); - - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; - - // approve()'s three statements (submission status, developer, extension) - // run as one atomic db.batch() call, so D1 itself rolls back the whole - // thing on any failure - there's no app-level "revert" to test, and no - // way to make the earlier statements really commit before this one - // fails (see db-interceptor.ts). This verifies that guarantee end to - // end: a failure on the last statement still leaves nothing committed. - env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { - if ( - sql.includes("INSERT INTO") && - sql.includes("extensions") && - !sql.includes("extension_submissions") - ) { - throw new Error("simulated write-through failure"); - } - }); - const approved = await post( - `/extensions/v2/submissions/${result.id}/approve`, - await authHeaders("mod-1"), - {} - ); - expect(approved.status).toBe(500); - expect(await countExtensions(db)).toBe(0); - - const stored = await getSubmission(db, result.id); - expect(stored?.status).toBe("pending"); - - // Recovers cleanly once the underlying failure is gone. - env.DB_EXTENSIONS = db; - const retried = await post( - `/extensions/v2/submissions/${result.id}/approve`, - await authHeaders("mod-1"), - {} - ); - expect(retried.status).toBe(200); - expect(await countExtensions(db)).toBe(1); - }); - - it("approves a submission and it becomes visible via the v1 read path", async () => { - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - await seedDeveloper("new-developer", "user-1"); - - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; - - const approved = await post( - `/extensions/v2/submissions/${result.id}/approve`, - await authHeaders("mod-1"), - {} - ); - expect(approved.status).toBe(200); - const approvedBody = (await approved.json()) as { - result: { status: string }; - }; - expect(approvedBody.result.status).toBe("approved"); - - // v1's read-only API keeps calling this field "author" — its JSON - // response shape is intentionally unchanged by the v2 rename. - const v1Res = await get("/extensions/v1/new-ext", {}); - expect(v1Res.status).toBe(200); - const v1Body = (await v1Res.json()) as { - result: { id: string; author: { id: string } }; - }; - expect(v1Body.result.id).toBe("new-ext"); - expect(v1Body.result.author.id).toBe("new-developer"); - }); - - it("blocks non-moderators from approving", async () => { - await seedDeveloper("new-developer", "user-1"); - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; - - const res = await post( - `/extensions/v2/submissions/${result.id}/approve`, - await authHeaders("user-1"), - {} - ); - expect(res.status).toBe(403); - }); - - it("rejects approving a submission that is not pending", async () => { - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - await seedDeveloper("new-developer", "user-1"); - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; - - await post( - `/extensions/v2/submissions/${result.id}/approve`, - await authHeaders("mod-1"), - {} - ); - const secondApprove = await post( - `/extensions/v2/submissions/${result.id}/approve`, - await authHeaders("mod-1"), - {} - ); - expect(secondApprove.status).toBe(409); - // The second (raced) approve must not write through again. - expect(await countExtensions(db)).toBe(1); - }); - - it("updates the existing row instead of duplicating it when an edit's id differs only by case", async () => { - await insertDeveloper(db, { - id: "owner-developer", - type: "user", - name: "Owner", - url: null, - owner_user_id: "owner-1" - }); - // Legacy v1 data can have mixed-case ids; v2 submissions must be lowercase. - await insertExtension(db, { - id: "Existing-Ext", - type: "mod", - author_id: "owner-developer", - name: "Existing", - description: "d", - releases: "[]", - website: "https://e.com", - license: '{"name":"MIT"}', - icon_url: null, - readme: "r", - source: '{"type":"github","repo":"example/existing"}', - version: "1.0.0", - download_url: "https://e.com/d.zip" - }); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const created = await post( - "/extensions/v2/submissions", - await authHeaders("owner-1"), - samplePayload({ - extensionId: "existing-ext", - developerId: "owner-developer" - }) - ); - const { result } = (await created.json()) as { result: { id: string } }; - - const approved = await post( - `/extensions/v2/submissions/${result.id}/approve`, - await authHeaders("mod-1"), - {} - ); - expect(approved.status).toBe(200); - - expect(await countExtensions(db)).toBe(1); - const stored = await getExtension(db, "Existing-Ext"); - expect(stored?.name).toBe("New Extension"); - }); - - it("requires a review_note to reject", async () => { - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - await seedDeveloper("new-developer", "user-1"); - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; - - const res = await post( - `/extensions/v2/submissions/${result.id}/reject`, - await authHeaders("mod-1"), - {} - ); - expect(res.status).toBe(422); - }); - - it("rejects a submission with a note", async () => { - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - await seedDeveloper("new-developer", "user-1"); - const created = await post( - "/extensions/v2/submissions", - await authHeaders("user-1"), - samplePayload() - ); - const { result } = (await created.json()) as { result: { id: string } }; - - const res = await post( - `/extensions/v2/submissions/${result.id}/reject`, - await authHeaders("mod-1"), - { review_note: "Needs a valid license URL" } - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { result: { status: string } }; - expect(body.result.status).toBe("rejected"); - expect(await countExtensions(db)).toBe(0); - }); - }); - - describe("PUT /developers/me", () => { - it("limits creation attempts per account before GitHub and database writes", async () => { - mockGithubEntity("Organization"); - const firstHeaders = await authHeaders("rate-limited-account"); - - for (const id of ["attempt-one", "attempt-two", "attempt-three"]) { - const allowed = await put( - "/extensions/v2/developers/me", - firstHeaders, - { id, type: "user", name: id } - ); - expect(allowed.status).toBe(403); - } - - const denied = await put("/extensions/v2/developers/me", firstHeaders, { - id: "attempt-four", - type: "user", - name: "Attempt four" - }); - expect(denied.status).toBe(429); - expect(denied.headers.get("Retry-After")).toBe("60"); - expect(denied.headers.get("Access-Control-Expose-Headers")).toContain( - "Retry-After" - ); - expect(await denied.json()).toMatchObject({ - error: { code: "PROFILE_CREATION_RATE_LIMITED" } - }); - expect(ghRequest).toHaveBeenCalledTimes(3); - expect(await listDevelopers(db)).toHaveLength(0); - - const otherAccount = await put( - "/extensions/v2/developers/me", - await authHeaders("independent-rate-limit-account"), - { id: "other-attempt", type: "user", name: "Other attempt" } - ); - expect(otherAccount.status).toBe(403); - expect(ghRequest).toHaveBeenCalledTimes(4); - expect(await listDevelopers(db)).toHaveLength(0); - }); - - it("does not charge profile updates against creation allowance", async () => { - const headers = await authHeaders("update-rate-limit-account"); - const created = await put( - "/extensions/v2/developers/me", - headers, - sampleDeveloper({ id: "update-limit-profile" }) - ); - expect(created.status).toBe(200); - - for (const name of ["First update", "Second update", "Third update"]) { - const updated = await put( - "/extensions/v2/developers/me", - headers, - sampleDeveloper({ id: "update-limit-profile", name }) - ); - expect(updated.status).toBe(200); - } - expect(ghRequest).toHaveBeenCalledTimes(1); - - const removed = await del("/extensions/v2/developers/me", headers); - expect(removed.status).toBe(200); - mockGithubEntity("Organization"); - - for (const id of ["remaining-one", "remaining-two"]) { - const allowed = await put("/extensions/v2/developers/me", headers, { - id, - type: "user", - name: id - }); - expect(allowed.status).toBe(403); - } - const denied = await put("/extensions/v2/developers/me", headers, { - id: "no-allowance", - type: "user", - name: "No allowance" - }); - expect(denied.status).toBe(429); - expect(ghRequest).toHaveBeenCalledTimes(3); - expect(await listDevelopers(db)).toHaveLength(0); - }); - - it("creates a new developer profile, unapproved", async () => { - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - expect(res.status).toBe(200); - const data = (await res.json()) as { result: { approved: boolean } }; - expect(data.result.approved).toBe(false); - - const stored = await getDeveloper(db, "dev-developer"); - expect(stored).toBeDefined(); - expect(stored?.approved_at).toBeNull(); - }); - - it("updates an existing profile, still unapproved", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper({ name: "Renamed Developer" }) - ); - - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: { name: string; approved: boolean }; - }; - expect(data.result.name).toBe("Renamed Developer"); - expect(data.result.approved).toBe(false); - expect((await getDeveloper(db, "dev-developer"))?.name).toBe( - "Renamed Developer" - ); - }); - - it("only lets one of two concurrent first-time profile creations by the same caller win", async () => { - const headers = await authHeaders("user-1"); - const [resA, resB] = await Promise.all([ - put( - "/extensions/v2/developers/me", - headers, - sampleDeveloper({ id: "developer-a" }) - ), - put( - "/extensions/v2/developers/me", - headers, - sampleDeveloper({ id: "developer-b" }) - ) - ]); - - const statuses = [resA.status, resB.status].sort(); - expect(statuses).toEqual([200, 409]); - - const ownedDevelopers = (await listDevelopers(db)).filter( - (a) => a.owner_user_id === "user-1" - ); - expect(ownedDevelopers).toHaveLength(1); - }); - - it("rejects an id that already belongs to someone else", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-2"), - sampleDeveloper() - ); - - expect(res.status).toBe(409); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("DEVELOPER_ID_TAKEN"); - }); - - it("classifies a concurrent id collision as DEVELOPER_ID_TAKEN", async () => { - const headers = await authHeaders("user-1"); - let raced = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if (!raced && sql.includes("INSERT INTO developers")) { - raced = true; - await insertDeveloper(db, { - id: "raced-developer", - type: "user", - name: "Concurrent Creator", - owner_user_id: "user-2" - }); - } - }); - - const res = await put( - "/extensions/v2/developers/me", - headers, - sampleDeveloper({ id: "raced-developer" }) - ); - env.DB_EXTENSIONS = db; - - expect(raced).toBe(true); - expect(res.status).toBe(409); - expect(await res.json()).toMatchObject({ - error: { code: "DEVELOPER_ID_TAKEN" } - }); - expect((await getDeveloper(db, "raced-developer"))?.owner_user_id).toBe( - "user-2" - ); - }); - - it("rejects changing the id on an existing profile", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper({ id: "different-id" }) - ); - - expect(res.status).toBe(409); - }); - - it("verifies a new profile when the creator's linked GitHub org matches the id", async () => { - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["acme-org"]) - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } - ); - - expect(res.status).toBe(200); - const created = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(created.result.github_org_verified).toBe(true); - }); - - it("keeps username verification independent of organization expiry", async () => { - mockGithubEntity("User"); - await insertUser(db, { - id: "user-1", - github_login: "dev-developer", - github_orgs: JSON.stringify(["former-org"]), - github_orgs_expires_at: "2000-01-01T00:00:00.000Z" - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(body.result.github_org_verified).toBe(true); - }); - - it.each([ - ["expired", "2000-01-01T00:00:00.000Z"], - ["missing", null], - ["malformed", "2099"] - ])( - "falls back to manual review when %s GitHub membership evidence is unavailable", - async (_state, github_orgs_expires_at) => { - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["acme-org"]), - github_orgs_expires_at - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } - ); - - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(body.result.github_org_verified).toBeUndefined(); - } - ); - - it("falls back to manual review when the linked GitHub login is whitespace-only", async () => { - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: " ", - github_orgs: JSON.stringify(["acme-org"]), - github_orgs_expires_at: "2099-01-01T00:00:00.000Z" - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } - ); - - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(body.result.github_org_verified).toBeUndefined(); - }); - - it("does not verify an organization from a fresh confirmed empty list", async () => { - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify([]), - github_orgs_expires_at: "2099-01-01T00:00:00.000Z" - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } - ); - - expect(res.status).toBe(403); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("GITHUB_MISMATCH"); - }); - - it("verifies the Publisher URL when it matches GitHub's on-file website", async () => { - mockGithubEntity("Organization", "https://www.acme.example/"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["acme-org"]) - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { - id: "acme-org", - type: "organization", - name: "Acme Org", - URL: "https://acme.example" - } - ); - - expect(res.status).toBe(200); - const created = (await res.json()) as { - result: { - github_org_verified?: boolean; - github_url_verified?: boolean; - }; - }; - expect(created.result.github_org_verified).toBe(true); - expect(created.result.github_url_verified).toBe(true); - const stored = await getDeveloper(db, "acme-org"); - expect(stored?.github_url_verified).toBe(1); - }); - - it("doesn't claim a URL match when GitHub's website field differs", async () => { - mockGithubEntity("Organization", "https://other.example"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["acme-org"]) - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { - id: "acme-org", - type: "organization", - name: "Acme Org", - URL: "https://acme.example" - } - ); - - expect(res.status).toBe(200); - const created = (await res.json()) as { - result: { github_url_verified?: boolean }; - }; - expect(created.result.github_url_verified).toBeUndefined(); - const stored = await getDeveloper(db, "acme-org"); - expect(stored?.github_url_verified).toBeNull(); - }); - - it("matches the Publisher URL to GitHub's website ignoring scheme/www/trailing slash", async () => { - mockGithubEntity("Organization", "www.acme.example/"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["acme-org"]) - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { - id: "acme-org", - type: "organization", - name: "Acme Org", - URL: "http://acme.example/" - } - ); - - const created = (await res.json()) as { - result: { github_url_verified?: boolean }; - }; - expect(created.result.github_url_verified).toBe(true); - }); - - it("doesn't match Publisher URLs that only differ by port", async () => { - mockGithubEntity("Organization", "https://acme.example:8443"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["acme-org"]) - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { - id: "acme-org", - type: "organization", - name: "Acme Org", - URL: "https://acme.example" - } - ); - - const created = (await res.json()) as { - result: { github_url_verified?: boolean }; - }; - expect(created.result.github_url_verified).toBeUndefined(); - }); - - it("doesn't match Publisher URLs that only differ by path case", async () => { - mockGithubEntity("Organization", "https://acme.example/Docs"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["acme-org"]) - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { - id: "acme-org", - type: "organization", - name: "Acme Org", - URL: "https://acme.example/docs" - } - ); - - const created = (await res.json()) as { - result: { github_url_verified?: boolean }; - }; - expect(created.result.github_url_verified).toBeUndefined(); - }); - - it("blocks creating a profile whose id matches a real GitHub org/user the creator doesn't control", async () => { - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["some-other-org"]) - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } - ); - - expect(res.status).toBe(403); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("GITHUB_MISMATCH"); - expect(await hasDeveloper(db, "acme-org")).toBe(false); - }); - - it("blocks creating a profile whose id matches a real GitHub entity of the opposite type", async () => { - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["acme-org"]) - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "acme-org", type: "user", name: "Acme Org" } - ); - - expect(res.status).toBe(403); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("GITHUB_MISMATCH"); - expect(await hasDeveloper(db, "acme-org")).toBe(false); - }); - - it.each([ - ["401 authentication failure", 401, "Bad credentials", 503], - ["rate-limit 403", 403, "API rate limit exceeded", 429], - ["429 throttling", 429, "Too Many Requests", 429], - ["GitHub 500", 500, "Internal Server Error", 503], - ["GitHub 503", 503, "Service Unavailable", 503] - ])( - "does not create a developer when GitHub returns %s", - async (_case, upstreamStatus, message, expectedStatus) => { - (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( - async () => { - throw Object.assign(new Error(message as string), { - status: upstreamStatus - }); - } - ); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "unavailable-dev", type: "user", name: "Unavailable" } - ); - - expect(res.status).toBe(expectedStatus); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe( - expectedStatus === 429 ? "RATE_LIMITED" : "SERVICE_UNAVAILABLE" - ); - expect(await hasDeveloper(db, "unavailable-dev")).toBe(false); - } - ); - - it.each(["request timed out", "network connection reset"])( - "does not create a developer after a thrown %s error", - async (message) => { - (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( - async () => { - throw new Error(message); - } - ); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "unavailable-dev", type: "user", name: "Unavailable" } - ); - - expect(res.status).toBe(503); - expect(await hasDeveloper(db, "unavailable-dev")).toBe(false); - } - ); - - it.each([ - ["missing entity type", { blog: null }], - ["non-string entity type", { type: 123, blog: null }], - ["malformed website", { type: "User", blog: { url: "example.com" } }] - ])( - "does not create a developer for %s response data", - async (_case, data) => { - (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( - async () => ({ data }) - ); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "invalid-response", type: "user", name: "Invalid" } - ); - - expect(res.status).toBe(503); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("SERVICE_UNAVAILABLE"); - expect(await hasDeveloper(db, "invalid-response")).toBe(false); - } - ); - - it("returns a permanent error for an unsupported GitHub entity type", async () => { - (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( - async () => ({ data: { type: "Bot", blog: null } }) - ); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "unsupported-entity", type: "user", name: "Unsupported" } - ); - - expect(res.status).toBe(422); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("GITHUB_ENTITY_UNSUPPORTED"); - expect(await hasDeveloper(db, "unsupported-entity")).toBe(false); - }); - - it("falls back to unverified creation when the creator has no linked GitHub identity", async () => { - mockGithubEntity("Organization"); - // No row in users for user-1 — never linked GitHub. - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } - ); - - expect(res.status).toBe(200); - const created = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(created.result.github_org_verified).toBeUndefined(); - }); - - it("fails creation rather than falling back to unverified when the caller's GitHub identity lookup errors", async () => { - mockGithubEntity("Organization"); - env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { - if (sql.includes("github_login") && sql.includes("github_orgs")) { - throw new Error("D1_ERROR: simulated database failure"); - } - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { id: "acme-org", type: "organization", name: "Acme Org" } - ); - - expect(res.status).toBe(500); - env.DB_EXTENSIONS = db; - expect(await hasDeveloper(db, "acme-org")).toBe(false); - }); - - it.each(["claims", "me", "unapproved"])( - "rejects the reserved id %s", - async (id) => { - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper({ id }) - ); - - expect(res.status).toBe(422); - } - ); - - it("clears approval when an approved profile is edited", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - const approved = await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 1 } - ); - expect(approved.status).toBe(200); - const approvedBody = (await approved.json()) as { - result: { approved: boolean }; - }; - expect(approvedBody.result.approved).toBe(true); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper({ name: "Edited Again" }) - ); - - expect(res.status).toBe(200); - const data = (await res.json()) as { result: { approved: boolean } }; - expect(data.result.approved).toBe(false); - }); - - it("keeps approval when a GitHub-verified profile is edited", async () => { - mockGithubEntity("User"); - await insertUser(db, { - id: "user-1", - github_login: "dev-developer", - github_orgs: JSON.stringify([]) - }); - const created = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - const createdBody = (await created.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(createdBody.result.github_org_verified).toBe(true); - - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - const approved = await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 1 } - ); - expect(approved.status).toBe(200); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper({ name: "Edited Again" }) - ); - - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: { approved: boolean; github_org_verified?: boolean }; - }; - expect(data.result.approved).toBe(true); - expect(data.result.github_org_verified).toBe(true); - }); - - it("clears approval and GitHub verification when the profile type is changed", async () => { - mockGithubEntity("User"); - await insertUser(db, { - id: "user-1", - github_login: "dev-developer", - github_orgs: JSON.stringify([]) - }); - const created = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - const createdBody = (await created.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(createdBody.result.github_org_verified).toBe(true); - - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 1 } - ); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { ...sampleDeveloper(), type: "organization" } - ); - - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: { - approved: boolean; - github_org_verified?: boolean; - github_url_verified?: boolean; - }; - }; - expect(data.result.approved).toBe(false); - expect(data.result.github_org_verified).toBeUndefined(); - expect(data.result.github_url_verified).toBeUndefined(); - }); - - it("clears github_url_verified when the Publisher URL is edited, but keeps identity verification", async () => { - mockGithubEntity("User", "https://acme.example"); - await insertUser(db, { - id: "user-1", - github_login: "dev-developer", - github_orgs: JSON.stringify([]) - }); - const created = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { ...sampleDeveloper(), URL: "https://acme.example" } - ); - const createdBody = (await created.json()) as { - result: { - github_org_verified?: boolean; - github_url_verified?: boolean; - }; - }; - expect(createdBody.result.github_org_verified).toBe(true); - expect(createdBody.result.github_url_verified).toBe(true); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { ...sampleDeveloper(), URL: "https://different.example" } - ); - - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: { - github_org_verified?: boolean; - github_url_verified?: boolean; - }; - }; - expect(data.result.github_org_verified).toBe(true); - expect(data.result.github_url_verified).toBeUndefined(); - }); - - it("does not update a profile after ownership changes mid-request", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - // Fires when upsertOwn's existing-profile UPDATE (identified by - // touching content_revision but not ownership_epoch, which only the - // ownership-transfer statements touch) is about to run - the DB - // change lands between existingOwn's read (which still sees user-1 - // as owner) and this guarded write. - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if ( - sql.includes("developers") && - sql.includes("content_revision") && - !sql.includes("ownership_epoch") - ) { - await bumpDeveloperOwnership(db, "dev-developer", "user-2"); - } - }); - - const raced = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper({ name: "Former owner write" }) - ); - env.DB_EXTENSIONS = db; - - expect(raced.status).toBe(409); - expect((await getDeveloper(db, "dev-developer"))?.name).toBe( - "Dev Developer" - ); - expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( - "user-2" - ); - expect( - (await listDeveloperHistory(db)).filter( - (row) => row.developer_id === "dev-developer" - ) - ).toHaveLength(1); - }); - - it("reports an inactive account when deactivated during an existing profile update", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - let deactivated = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - const normalizedSql = sql.toLowerCase(); - if ( - !deactivated && - normalizedSql.includes('update "developers"') && - normalizedSql.includes("content_revision") - ) { - deactivated = true; - await db - .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") - .bind(new Date().toISOString(), "user-1") - .run(); - } - }); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper({ name: "Inactive owner write" }) - ); - env.DB_EXTENSIONS = db; - - expect(deactivated).toBe(true); - expect(res.status).toBe(403); - expect(await res.json()).toMatchObject({ - error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } - }); - expect((await getDeveloper(db, "dev-developer"))?.name).toBe( - "Dev Developer" - ); - }); - - 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(403); - expect(await res.json()).toMatchObject({ - error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } - }); - 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, { - ...sampleDeveloper(), - avatar_url: "https://example.com/avatar.png", - contact_email: "dev@example.com" - }); - - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: { - avatar_url: string; - contact_email: string; - }; - }; - expect(data.result.avatar_url).toBe("https://example.com/avatar.png"); - expect(data.result.contact_email).toBe("dev@example.com"); - - const stored = await getDeveloper(db, "dev-developer"); - expect(stored?.avatar_url).toBe("https://example.com/avatar.png"); - expect(stored?.contact_email).toBe("dev@example.com"); - }); - - it("updates avatar_url and contact_email on an existing profile", async () => { - const headers = await authHeaders("user-1"); - await put("/extensions/v2/developers/me", headers, { - ...sampleDeveloper(), - avatar_url: "https://example.com/old.png", - contact_email: "old@example.com" - }); - - const res = await put("/extensions/v2/developers/me", headers, { - ...sampleDeveloper(), - avatar_url: "https://example.com/new.png", - contact_email: "new@example.com" - }); - - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: { - avatar_url: string; - contact_email: string; - }; - }; - expect(data.result.avatar_url).toBe("https://example.com/new.png"); - expect(data.result.contact_email).toBe("new@example.com"); - - const stored = await getDeveloper(db, "dev-developer"); - expect(stored?.avatar_url).toBe("https://example.com/new.png"); - expect(stored?.contact_email).toBe("new@example.com"); - }); - - it("accepts a payload without avatar_url or contact_email", async () => { - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: { - avatar_url?: string; - contact_email?: string; - }; - }; - expect(data.result.avatar_url).toBeUndefined(); - expect(data.result.contact_email).toBeUndefined(); - }); - }); - - describe("DELETE /developers/me", () => { - it("deletes a profile with no extensions or pending submissions", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const res = await del( - "/extensions/v2/developers/me", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { id: string; deleted: boolean }; - }; - expect(body.result).toEqual({ id: "dev-developer", deleted: true }); - - const getRes = await get("/extensions/v2/developers/dev-developer", {}); - expect(getRes.status).toBe(404); - }); - - it("404s for a caller with no developer profile", async () => { - const res = await del( - "/extensions/v2/developers/me", - await authHeaders("no-profile-user") - ); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("NOT_FOUND"); - }); - - it("409s when the profile still has published extensions", async () => { - await seedOwnedExtension(); - - const res = await del( - "/extensions/v2/developers/me", - await authHeaders("owner-1") - ); - expect(res.status).toBe(409); - const body = (await res.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("CONFLICT"); - expect(body.error.message).toContain("1 published extension(s)"); - }); - - it("409s when a submission is pending", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await insertSubmission(db, { - id: "sub-1", - extension_id: null, - developer_id: "dev-developer", - submitted_by: "user-1", - status: "pending", - payload: JSON.stringify(samplePayload()) - }); - - const res = await del( - "/extensions/v2/developers/me", - await authHeaders("user-1") - ); - expect(res.status).toBe(409); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("CONFLICT"); - }); - - it("removes transfer tokens and claims but keeps history", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - await insertDeveloperClaim(db, { - id: "claim-1", - developer_id: "dev-developer", - claimant_id: "user-2", - status: "rejected", - review_note: "no", - reviewer_id: "mod-1", - reviewed_at: new Date().toISOString() - }); - - const res = await del( - "/extensions/v2/developers/me", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - - expect( - (await listDeveloperTransfers(db)).filter( - (r) => r.developer_id === "dev-developer" - ) - ).toHaveLength(0); - expect( - (await listDeveloperClaims(db)).filter( - (r) => r.developer_id === "dev-developer" - ) - ).toHaveLength(0); - expect( - (await listDeveloperHistory(db)).filter( - (r) => r.developer_id === "dev-developer" - ).length - ).toBeGreaterThan(0); - }); - - it("refuses to delete if ownership moves away between the lookup and the delete", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await insertUser(db, { id: "user-2" }); - // Simulates a transfer/claim landing in the window between deleteOwn's - // initial "find my profile" lookup and its guarded delete - the - // delete must re-check ownership at that point, not trust the lookup. - // deleteTransfersStmt is the first statement in deleteOwn's batch, so - // firing this before it reproduces the race exactly. - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if ( - sql.includes("DELETE FROM") && - sql.includes("developer_transfers") - ) { - await bumpDeveloperOwnership(db, "dev-developer", "user-2"); - } - }); - - const res = await del( - "/extensions/v2/developers/me", - await authHeaders("user-1") - ); - env.DB_EXTENSIONS = db; - expect(res.status).toBe(404); - - const stillThere = await get( - "/extensions/v2/developers/dev-developer", - {} - ); - expect(stillThere.status).toBe(200); - const body = (await stillThere.json()) as { result: { id: string } }; - expect(body.result.id).toBe("dev-developer"); - }); - - it("reports an inactive owner when the account is deactivated during deletion", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - let deactivated = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if (!deactivated && sql.includes("DELETE FROM developer_transfers")) { - deactivated = true; - await db - .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") - .bind(new Date().toISOString(), "user-1") - .run(); - } - }); - - const res = await del( - "/extensions/v2/developers/me", - await authHeaders("user-1") - ); - env.DB_EXTENSIONS = db; - - expect(deactivated).toBe(true); - expect(res.status).toBe(403); - expect(await res.json()).toMatchObject({ - error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } - }); - expect(await hasDeveloper(db, "dev-developer")).toBe(true); - }); - }); - - describe("POST /developers/me/reverify", () => { - it("reports an inactive account when deactivated during a URL cooldown reservation", async () => { - await insertUser(db, { id: "user-1" }); - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1" - }); - - let deactivated = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - const normalizedSql = sql.toLowerCase(); - if ( - !deactivated && - normalizedSql.includes('update "developers"') && - normalizedSql.includes("url_check_cooldown_until") - ) { - deactivated = true; - await db - .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") - .bind(new Date().toISOString(), "user-1") - .run(); - } - }); - - const res = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - env.DB_EXTENSIONS = db; - - expect(deactivated).toBe(true); - expect(res.status).toBe(403); - expect(await res.json()).toMatchObject({ - error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } - }); - }); - - it("re-verifies and refreshes the timestamp when the owner's GitHub org still matches", async () => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: null, - owner_user_id: "user-1", - github_org_verified: 1, - github_verification_note: - "Verified: caller's linked GitHub identity matches.", - github_verified_at: "2020-01-01T00:00:00.000Z" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { github_org_verified?: boolean; github_verified_at?: string }; - }; - expect(body.result.github_org_verified).toBe(true); - expect(body.result.github_verified_at).not.toBe( - "2020-01-01T00:00:00.000Z" - ); - }); - - it.each([ - [ - "expired", - JSON.stringify(["dev-developer"]), - "2000-01-01T00:00:00.000Z" - ], - ["malformed", "not-json", "2099-01-01T00:00:00.000Z"] - ])( - "preserves verification when the owner's organization evidence is %s", - async (_state, github_orgs, github_orgs_expires_at) => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: null, - owner_user_id: "user-1", - github_org_verified: 1, - github_verification_note: - "Verified: caller's linked GitHub identity matches.", - github_verified_at: "2020-01-01T00:00:00.000Z" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs, - github_orgs_expires_at - }); - - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { - github_org_verified?: boolean; - github_verified_at?: string; - }; - }; - expect(body.result.github_org_verified).toBe(true); - expect(body.result.github_verified_at).toBe("2020-01-01T00:00:00.000Z"); - } - ); - - it("preserves verification when the owner's GitHub login is whitespace-only", async () => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: null, - owner_user_id: "user-1", - github_org_verified: 1, - github_verification_note: - "Verified: caller's linked GitHub identity matches.", - github_verified_at: "2020-01-01T00:00:00.000Z" - }); - await insertUser(db, { - id: "user-1", - github_login: " ", - github_orgs: JSON.stringify(["dev-developer"]), - github_orgs_expires_at: "2099-01-01T00:00:00.000Z" - }); - - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { github_org_verified?: boolean; github_verified_at?: string }; - }; - expect(body.result.github_org_verified).toBe(true); - expect(body.result.github_verified_at).toBe("2020-01-01T00:00:00.000Z"); - }); - - it("flips to unverified when the owner's GitHub org membership no longer matches", async () => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: null, - owner_user_id: "user-1", - github_org_verified: 1, - github_verification_note: - "Verified: caller's linked GitHub identity matches.", - github_verified_at: "2020-01-01T00:00:00.000Z" - }); - // No longer a member of dev-developer's org. - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify([]) - }); - - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { - github_org_verified?: boolean; - github_verification_note?: string; - }; - }; - expect(body.result.github_org_verified).toBe(false); - expect(body.result.github_verification_note).toBe( - "No longer verified: caller's linked GitHub identity no longer matches." - ); - }); - - it("verifies for the first time on re-check when the caller now has a matching linked GitHub identity", async () => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: null, - owner_user_id: "user-1" - // github_org_verified left null — never checked before (e.g. created - // before this feature existed, or the token was down at claim time). - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(body.result.github_org_verified).toBe(true); - }); - - it("doesn't check the Publisher URL without ?check_url=true", async () => { - mockGithubEntity("Organization", "https://acme.example"); - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { github_url_verified?: boolean }; - }; - expect(body.result.github_url_verified).toBeUndefined(); - expect(ghRequest).not.toHaveBeenCalled(); - }); - - it("checks the Publisher URL when re-verified with ?check_url=true", async () => { - mockGithubEntity("Organization", "https://acme.example"); - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - const res = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { github_url_verified?: boolean }; - }; - expect(body.result.github_url_verified).toBe(true); - }); - - it.each([ - [403, "API rate limit exceeded", 429, "RATE_LIMITED"], - [503, "Service Unavailable", 503, "SERVICE_UNAVAILABLE"] - ])( - "returns an error and retains the cooldown when GitHub responds with %s", - async (upstreamStatus, message, expectedStatus, expectedCode) => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1", - github_org_verified: 1, - github_url_verified: 1 - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( - async () => { - throw Object.assign(new Error(message as string), { - status: upstreamStatus - }); - } - ); - - const failed = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - expect(failed.status).toBe(expectedStatus); - const body = (await failed.json()) as { error: { code: string } }; - expect(body.error.code).toBe(expectedCode); - expect( - (await getDeveloper(db, "dev-developer"))?.github_url_verified - ).toBe(1); - - vi.clearAllMocks(); - const retry = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - expect(retry.status).toBe(429); - expect(ghRequest).not.toHaveBeenCalled(); - } - ); - - it("rate-limits repeated ?check_url=true calls from the same caller", async () => { - mockGithubEntity("Organization", "https://acme.example"); - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - const first = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - expect(first.status).toBe(200); - vi.clearAllMocks(); - - const second = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - expect(second.status).toBe(429); - const body = (await second.json()) as { error: { code: string } }; - expect(body.error.code).toBe("RATE_LIMITED"); - // The whole point — no GitHub API call for the blocked attempt. - expect(ghRequest).not.toHaveBeenCalled(); - }); - - it("doesn't rate-limit reverify calls that don't use ?check_url", async () => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - const first = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - const second = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - expect(first.status).toBe(200); - expect(second.status).toBe(200); - }); - - it("rate-limits ?check_url=true per caller, not globally", async () => { - mockGithubEntity("Organization", "https://acme.example"); - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1" - }); - await insertDeveloper(db, { - id: "other-developer", - type: "organization", - name: "Other", - url: "https://acme.example", - owner_user_id: "user-2" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - await insertUser(db, { - id: "user-2", - github_login: "someone-else", - github_orgs: JSON.stringify(["other-developer"]) - }); - - const first = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - const second = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-2") - ); - expect(first.status).toBe(200); - expect(second.status).toBe(200); - }); - - it("only lets one of two concurrent ?check_url=true requests through", async () => { - mockGithubEntity("Organization", "https://acme.example"); - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - const headers = await authHeaders("user-1"); - const [first, second] = await Promise.all([ - post("/extensions/v2/developers/me/reverify?check_url=true", headers), - post("/extensions/v2/developers/me/reverify?check_url=true", headers) - ]); - - const statuses = [first.status, second.status].sort(); - expect(statuses).toEqual([200, 429]); - }); - - it("does not persist a URL verification computed against a stale URL", async () => { - mockGithubEntity("Organization", "https://acme.example"); - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - // Fires just before reverifyOwn's final write (identified by - // touching github_verified_at, which only that statement sets) — the - // Publisher URL changes between the check and the write, same shape - // as the existing ownership-race test above. - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if (sql.includes("developers") && sql.includes("github_verified_at")) { - await db - .prepare("UPDATE developers SET url = ? WHERE id = ?") - .bind("https://different.example", "dev-developer") - .run(); - } - }); - - const res = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - env.DB_EXTENSIONS = db; - - expect(res.status).toBe(409); - expect( - (await getDeveloper(db, "dev-developer"))?.github_url_verified - ).toBe(null); - expect((await getDeveloper(db, "dev-developer"))?.url).toBe( - "https://different.example" - ); - }); - - it("clears a previously-verified Publisher URL when identity no longer matches", async () => { - mockGithubEntity("Organization", "https://acme.example"); - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1", - github_org_verified: 1, - github_url_verified: 1 - }); - // No longer a member of dev-developer's org. - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify([]) - }); - - const res = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { - github_org_verified?: boolean; - github_url_verified?: boolean; - }; - }; - expect(body.result.github_org_verified).toBe(false); - expect(body.result.github_url_verified).toBeUndefined(); - }); - - it("clears a previously-verified Publisher URL when identity no longer matches, even without ?check_url", async () => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1", - github_org_verified: 1, - github_url_verified: 1 - }); - // No longer a member of dev-developer's org. - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify([]) - }); - - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { - github_org_verified?: boolean; - github_url_verified?: boolean; - }; - }; - expect(body.result.github_org_verified).toBe(false); - expect(body.result.github_url_verified).toBeUndefined(); - // Clearing a stale URL signal on an identity mismatch is a local - // comparison, same as the identity check itself — no GitHub API call. - expect(ghRequest).not.toHaveBeenCalled(); - }); - - it("doesn't verify the Publisher URL against a GitHub entity of the wrong type", async () => { - // The stored profile is a "user", but the GitHub entity currently - // found for this id is an "organization" — matchesClaimant() only - // compares login/org membership, so this discrepancy has to be - // caught separately before trusting the entity's blog field. - mockGithubEntity("Organization", "https://acme.example"); - await insertDeveloper(db, { - id: "dev-developer", - type: "user", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1" - }); - await insertUser(db, { - id: "user-1", - github_login: "dev-developer", - github_orgs: JSON.stringify([]) - }); - - const res = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { - github_org_verified?: boolean; - github_url_verified?: boolean; - github_verification_note?: string; - }; - }; - expect(body.result.github_url_verified).toBeUndefined(); - // The same discrepancy that rules out the URL match also undermines - // the identity match itself — matchesClaimant() alone can't catch - // this since it never queries GitHub's actual current entity type. - expect(body.result.github_org_verified).toBe(false); - expect(body.result.github_verification_note).toBe( - "No longer verified: GitHub's on-file entity type no longer matches this profile." - ); - }); - - it("preserves an existing Publisher URL verification when the GitHub lookup fails", async () => { - mockGithubEntityNotFound(); - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1", - github_org_verified: 1, - github_url_verified: 1 - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - const res = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { github_url_verified?: boolean }; - }; - expect(body.result.github_url_verified).toBe(true); - }); - - it("treats ?check_url=false the same as omitting it", async () => { - mockGithubEntity("Organization", "https://acme.example"); - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: "https://acme.example", - owner_user_id: "user-1" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - const res = await post( - "/extensions/v2/developers/me/reverify?check_url=false", - await authHeaders("user-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: { github_url_verified?: boolean }; - }; - expect(body.result.github_url_verified).toBeUndefined(); - expect(ghRequest).not.toHaveBeenCalled(); - }); - - it("404s when the caller doesn't own a developer profile", async () => { - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - expect(res.status).toBe(404); - }); - - it("refuses to overwrite verification if ownership moves away between the lookup and the write", async () => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: null, - owner_user_id: "user-1" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - // Simulates a transfer/claim landing in the window between - // reverifyOwn's initial "find my profile" lookup and its guarded - // write - the write must re-check ownership at that point, not trust - // the lookup, or it would write a result computed from the *former* - // owner's GitHub identity onto the profile after it's changed hands. - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if (sql.includes("update") && sql.includes("github_verified_at")) { - await bumpDeveloperOwnership(db, "dev-developer", "user-2"); - } - }); - - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - env.DB_EXTENSIONS = db; - expect(res.status).toBe(409); - - const developerRow = await getDeveloper(db, "dev-developer"); - expect(developerRow?.owner_user_id).toBe("user-2"); - expect(developerRow?.github_org_verified).toBeNull(); - }); - - it("refuses to overwrite verification if the profile type changes during the check", async () => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: null, - owner_user_id: "user-1", - github_org_verified: 1, - github_verification_note: - "Verified: caller's linked GitHub identity matches.", - github_verified_at: "2020-01-01T00:00:00.000Z" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]) - }); - - let changed = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if (!changed && sql.includes("github_verified_at")) { - changed = true; - await db - .prepare("UPDATE developers SET type = ? WHERE id = ?") - .bind("user", "dev-developer") - .run(); - } - }); - - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - env.DB_EXTENSIONS = db; - - expect(changed).toBe(true); - expect(res.status).toBe(409); - const developerRow = await getDeveloper(db, "dev-developer"); - expect(developerRow?.type).toBe("user"); - expect(developerRow?.github_org_verified).toBe(1); - expect(developerRow?.github_verified_at).toBe("2020-01-01T00:00:00.000Z"); - }); - - it("refuses to overwrite verification if GitHub identity sync wins the race", async () => { - await insertDeveloper(db, { - id: "dev-developer", - type: "organization", - name: "Dev", - url: null, - owner_user_id: "user-1", - github_org_verified: 1, - github_verification_note: - "Verified: caller's linked GitHub identity matches.", - github_verified_at: "2020-01-01T00:00:00.000Z" - }); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["dev-developer"]), - github_orgs_expires_at: "2099-01-01T00:00:00.000Z" - }); - - let synced = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if (!synced && sql.includes("github_verified_at")) { - synced = true; - await db - .prepare( - `UPDATE users - SET github_login = ?, github_orgs = ?, github_orgs_expires_at = ?, - updated_at = ? - WHERE id = ?` - ) - .bind( - "different-user", - JSON.stringify(["different-org"]), - "2099-01-01T00:00:00.000Z", - new Date().toISOString(), - "user-1" - ) - .run(); - } - }); - - const res = await post( - "/extensions/v2/developers/me/reverify", - await authHeaders("user-1") - ); - env.DB_EXTENSIONS = db; - - expect(synced).toBe(true); - expect(res.status).toBe(409); - const developerRow = await getDeveloper(db, "dev-developer"); - expect(developerRow?.github_org_verified).toBe(1); - expect(developerRow?.github_verified_at).toBe("2020-01-01T00:00:00.000Z"); - }); - }); - - describe("developer moderation", () => { - it("binds approval to the exact profile revision reviewed", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper({ name: "Revision two" }) - ); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const stale = await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 1 } - ); - expect(stale.status).toBe(409); - - const current = await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 2 } - ); - expect(current.status).toBe(200); - }); - - it("does not turn an approval diagnosis database failure into not found", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { - if (/^\s*select/i.test(sql) && /from\s+"developers"/i.test(sql)) { - throw new Error("simulated approval diagnosis failure"); - } - }); - - const res = await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 999 } - ); - env.DB_EXTENSIONS = db; - - expect(res.status).toBe(500); - expect((await res.json()) as { error: { code: string } }).toMatchObject({ - error: { code: "DATABASE_ERROR" } - }); - }); - - it("reports an inactive moderator when the account is deactivated during approval", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - let deactivated = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if ( - !deactivated && - /update/i.test(sql) && - sql.includes("approved_at") - ) { - deactivated = true; - await db - .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") - .bind(new Date().toISOString(), "mod-1") - .run(); - } - }); - - const res = await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 1 } - ); - env.DB_EXTENSIONS = db; - - expect(deactivated).toBe(true); - expect(res.status).toBe(403); - expect(await res.json()).toMatchObject({ - error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } - }); - expect((await getDeveloper(db, "dev-developer"))?.approved_at).toBeNull(); - }); - - it("approves a developer and removes it from the unapproved list", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const approve = await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 1 } - ); - expect(approve.status).toBe(200); - const approveBody = (await approve.json()) as { - result: { id: string; approved: boolean }; - }; - expect(approveBody.result).toEqual({ - id: "dev-developer", - approved: true - }); - - const unapproved = await get( - "/extensions/v2/developers/unapproved", - await authHeaders("mod-1") - ); - expect(unapproved.status).toBe(200); - const unapprovedBody = (await unapproved.json()) as { - result: Array<{ id: string }>; - }; - expect(unapprovedBody.result.map((a) => a.id)).not.toContain( - "dev-developer" - ); - }); - - it("404s approving a nonexistent developer", async () => { - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const res = await post( - "/extensions/v2/developers/no-such-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 1 } - ); - expect(res.status).toBe(404); - }); - - it("blocks non-moderators from listing unapproved developers", async () => { - const res = await get( - "/extensions/v2/developers/unapproved", - await authHeaders("user-1") - ); - expect(res.status).toBe(403); - }); - - it("lists every developer, approved and unapproved", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await put( - "/extensions/v2/developers/me", - await authHeaders("user-2"), - sampleDeveloper({ id: "other-developer", name: "Other Developer" }) - ); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 1 } - ); - - const res = await get( - "/extensions/v2/developers", - await authHeaders("mod-1") - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { - result: Array<{ id: string; approved: boolean }>; - }; - expect(body.result.map((a) => a.id).sort()).toEqual([ - "dev-developer", - "other-developer" - ]); - expect(body.result.find((a) => a.id === "dev-developer")?.approved).toBe( - true - ); - expect( - body.result.find((a) => a.id === "other-developer")?.approved - ).toBe(false); - }); - - it("blocks non-moderators from listing all developers", async () => { - const res = await get( - "/extensions/v2/developers", - await authHeaders("user-1") - ); - expect(res.status).toBe(403); - }); - - it("blocks non-moderators from approving developers", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const res = await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("user-1"), - { expected_revision: 1 } - ); - expect(res.status).toBe(403); - }); - }); - - describe("GET /developers/{id}/history", () => { - it("records a history entry for a newly created profile", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const res = await get( - "/extensions/v2/developers/dev-developer/history", - await authHeaders("mod-1") - ); - - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: Array<{ - developer_id: string; - name: string; - changed_by: string; - }>; - }; - expect(data.result).toHaveLength(1); - expect(data.result[0].developer_id).toBe("dev-developer"); - expect(data.result[0].name).toBe("Dev Developer"); - expect(data.result[0].changed_by).toBe("user-1"); - }); - - it("orders entries newest-first and snapshots each write", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper({ name: "Original Name" }) - ); - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper({ name: "Edited Name" }) - ); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const res = await get( - "/extensions/v2/developers/dev-developer/history", - await authHeaders("mod-1") - ); - - expect(res.status).toBe(200); - const data = (await res.json()) as { - result: Array<{ name: string }>; - }; - expect(data.result).toHaveLength(2); - expect(data.result[0].name).toBe("Edited Name"); - expect(data.result[1].name).toBe("Original Name"); - }); - - it("returns an empty array for a developer with no history", async () => { - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const res = await get( - "/extensions/v2/developers/no-such-developer/history", - await authHeaders("mod-1") - ); - - expect(res.status).toBe(200); - const data = (await res.json()) as { result: unknown[] }; - expect(data.result).toEqual([]); - }); - - it("blocks non-moderators", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const res = await get( - "/extensions/v2/developers/dev-developer/history", - await authHeaders("user-1") - ); - expect(res.status).toBe(403); - }); - - it("does not record history for a rejected write (id already taken)", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const res = await put( - "/extensions/v2/developers/me", - await authHeaders("user-2"), - sampleDeveloper() - ); - expect(res.status).toBe(409); - - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - const history = await get( - "/extensions/v2/developers/dev-developer/history", - await authHeaders("mod-1") - ); - const data = (await history.json()) as { result: unknown[] }; - expect(data.result).toHaveLength(1); - }); - }); - - describe("developer transfers", () => { - it("does not accept transfer capabilities in URL paths", async () => { - const res = await post( - "/extensions/v2/developers/transfers/secret-token/accept", - await authHeaders("user-2") - ); - expect(res.status).toBe(404); - }); - - it("initiating a second transfer revokes the first token", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const first = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - expect(first.status).toBe(200); - const firstToken = ((await first.json()) as { result: { token: string } }) - .result.token; - - const second = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - expect(second.status).toBe(200); - - const acceptFirst = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token: firstToken } - ); - expect(acceptFirst.status).toBe(404); - }); - - it("accepts a valid token, transferring ownership and clearing approval", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - await post( - "/extensions/v2/developers/dev-developer/approve", - await authHeaders("mod-1"), - { expected_revision: 1 } - ); - - const initiate = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token = ((await initiate.json()) as { result: { token: string } }) - .result.token; - - const accept = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token } - ); - expect(accept.status).toBe(200); - const accepted = (await accept.json()) as { - result: { id: string; approved: boolean }; - }; - expect(accepted.result.id).toBe("dev-developer"); - expect(accepted.result.approved).toBe(false); - expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( - "user-2" - ); - - const acceptAgain = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-3"), - { token } - ); - expect(acceptAgain.status).toBe(404); - expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( - "user-2" - ); - }); - - it("does not turn a committed transfer into a database error if the profile is deleted before the response lookup", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - const initiate = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token = ((await initiate.json()) as { result: { token: string } }) - .result.token; - - let deleted = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if ( - !deleted && - /^\s*select/i.test(sql) && - /from\s+"developers"/i.test(sql) - ) { - deleted = true; - await db - .prepare("DELETE FROM developer_transfers WHERE developer_id = ?") - .bind("dev-developer") - .run(); - await db - .prepare("DELETE FROM developers WHERE id = ?") - .bind("dev-developer") - .run(); - } - }); - - const accept = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token } - ); - env.DB_EXTENSIONS = db; - - expect(deleted).toBe(true); - expect(accept.status).toBe(404); - expect(await accept.json()).toMatchObject({ - error: { code: "NOT_FOUND" } - }); - }); - - it("rejects pending submissions and claims when ownership changes", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await insertDeveloperClaim(db, { - id: "transfer-pending-claim", - developer_id: "dev-developer", - claimant_id: "user-3" - }); - await insertSubmission(db, { - id: "transfer-pending-submission", - developer_id: "dev-developer", - submitted_by: "user-3", - payload: JSON.stringify(samplePayload({ developerId: "dev-developer" })) - }); - - const initiate = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token = ((await initiate.json()) as { result: { token: string } }) - .result.token; - - const accept = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token } - ); - expect(accept.status).toBe(200); - - expect( - await getDeveloperClaim(db, "transfer-pending-claim") - ).toMatchObject({ - status: "rejected", - review_note: "Ownership changed before review" - }); - expect( - await getSubmission(db, "transfer-pending-submission") - ).toMatchObject({ - status: "rejected", - review_note: "Ownership changed before review" - }); - }); - - it("reports an inactive owner when the account is deactivated during initiation", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - let tombstoned = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if (!tombstoned && sql.includes("INSERT INTO developer_transfers")) { - tombstoned = true; - await db - .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") - .bind(new Date().toISOString(), "user-1") - .run(); - } - }); - - const res = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - env.DB_EXTENSIONS = db; - - expect(tombstoned).toBe(true); - expect(res.status).toBe(403); - expect(await res.json()).toMatchObject({ - error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } - }); - }); - - it("reports an inactive recipient when the account is deactivated during acceptance", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const initiate = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token = ((await initiate.json()) as { result: { token: string } }) - .result.token; - - let deactivated = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if (!deactivated && sql.includes("UPDATE developer_transfers")) { - deactivated = true; - await db - .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") - .bind(new Date().toISOString(), "user-2") - .run(); - } - }); - - const res = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token } - ); - env.DB_EXTENSIONS = db; - - expect(deactivated).toBe(true); - expect(res.status).toBe(403); - expect(await res.json()).toMatchObject({ - error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } - }); - expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( - "user-1" - ); - }); - - it("doesn't inherit the previous owner's check_url cooldown after a transfer", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const usedCooldown = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-1") - ); - expect(usedCooldown.status).toBe(200); - - const initiate = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token = ((await initiate.json()) as { result: { token: string } }) - .result.token; - const accept = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token } - ); - expect(accept.status).toBe(200); - - // user-2 has never called check_url themselves — the previous - // owner's still-active cooldown must not carry over onto them. - const res = await post( - "/extensions/v2/developers/me/reverify?check_url=true", - await authHeaders("user-2") - ); - expect(res.status).toBe(200); - }); - - it("clears GitHub verification on transfer — it described the previous owner's identity, not the new owner's", async () => { - mockGithubEntity("User", "https://acme.example"); - await insertUser(db, { - id: "user-1", - github_login: "dev-developer", - github_orgs: JSON.stringify([]) - }); - const created = await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - { ...sampleDeveloper(), URL: "https://acme.example" } - ); - const createdBody = (await created.json()) as { - result: { - github_org_verified?: boolean; - github_url_verified?: boolean; - }; - }; - expect(createdBody.result.github_org_verified).toBe(true); - expect(createdBody.result.github_url_verified).toBe(true); - - const initiate = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token = ((await initiate.json()) as { result: { token: string } }) - .result.token; - const accept = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token } - ); - expect(accept.status).toBe(200); - const accepted = (await accept.json()) as { - result: { - github_org_verified?: boolean; - github_url_verified?: boolean; - }; - }; - expect(accepted.result.github_org_verified).toBeUndefined(); - expect(accepted.result.github_url_verified).toBeUndefined(); - }); - - it("does not let replaying an already-used token reassign ownership away from a later owner", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const initiate1 = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token1 = ((await initiate1.json()) as { result: { token: string } }) - .result.token; - - const accept1 = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token: token1 } - ); - expect(accept1.status).toBe(200); - expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( - "user-2" - ); - - // dev-developer is legitimately handed off again, to a third user. - const initiate2 = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-2") - ); - const token2 = ((await initiate2.json()) as { result: { token: string } }) - .result.token; - const accept2 = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-3"), - { token: token2 } - ); - expect(accept2.status).toBe(200); - expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( - "user-3" - ); - - // Replaying the *first* (already-used) token, by the same user who - // originally accepted it, must not silently reassign ownership back. - const replay = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token: token1 } - ); - expect(replay.status).toBe(404); - expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( - "user-3" - ); - }); - - it("rejects an expired token", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const initiate = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token = ((await initiate.json()) as { result: { token: string } }) - .result.token; - - await expireAllDeveloperTransfers(db); - - const accept = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token } - ); - expect(accept.status).toBe(404); - }); - - it("rejects a revoked token", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const initiate = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token = ((await initiate.json()) as { result: { token: string } }) - .result.token; - - const revoke = await post( - "/extensions/v2/developers/dev-developer/transfer/revoke", - await authHeaders("user-1") - ); - expect(revoke.status).toBe(200); - - const accept = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token } - ); - expect(accept.status).toBe(404); - }); - - it("rejects acceptance by a user who already owns a different profile", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await put( - "/extensions/v2/developers/me", - await authHeaders("user-2"), - sampleDeveloper({ id: "other-developer", name: "Other Developer" }) - ); - - const initiate = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token = ((await initiate.json()) as { result: { token: string } }) - .result.token; - - const accept = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-2"), - { token } - ); - expect(accept.status).toBe(409); - expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( - "user-1" - ); - }); - - it("rejects the current owner accepting their own transfer link", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const initiate = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - const token = ((await initiate.json()) as { result: { token: string } }) - .result.token; - - const accept = await post( - "/extensions/v2/developers/transfers/accept", - await authHeaders("user-1"), - { token } - ); - expect(accept.status).toBe(409); - expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( - "user-1" - ); - }); - - it("blocks a non-owner from initiating a transfer", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const res = await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("intruder") - ); - expect(res.status).toBe(403); - }); - - it("blocks a non-owner from revoking a transfer", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - await post( - "/extensions/v2/developers/dev-developer/transfer", - await authHeaders("user-1") - ); - - const res = await post( - "/extensions/v2/developers/dev-developer/transfer/revoke", - await authHeaders("intruder") - ); - expect(res.status).toBe(403); - }); - }); - - describe("developer claims", () => { - it("lets a user claim an unowned developer, visible to the claimant and moderators", async () => { - await seedUnownedDeveloper("legacy-developer"); - - const res = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - { note: "I'm the maintainer, see github.com/x" } - ); - expect(res.status).toBe(201); - const created = (await res.json()) as { result: { id: string } }; - - const mine = await get( - "/extensions/v2/developers/claims/mine", - await authHeaders("user-1") - ); - expect(mine.status).toBe(200); - const mineData = (await mine.json()) as { - result: Array<{ id: string; status: string }>; - }; - expect(mineData.result.map((c) => c.id)).toEqual([created.result.id]); - expect(mineData.result[0].status).toBe("pending"); - - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - const pending = await get( - "/extensions/v2/developers/claims", - await authHeaders("mod-1") - ); - expect(pending.status).toBe(200); - const pendingData = (await pending.json()) as { - result: Array<{ id: string; developer_name: string }>; - }; - expect(pendingData.result.map((c) => c.id)).toEqual([created.result.id]); - expect(pendingData.result[0].developer_name).toBe("Legacy Developer"); - }); - - it("rejects claiming a developer that already has an owner", async () => { - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const res = await post( - "/extensions/v2/developers/dev-developer/claim", - await authHeaders("user-2"), - {} - ); - expect(res.status).toBe(409); - }); - - it.each([ - [ - "expired", - JSON.stringify(["some-other-org"]), - "2000-01-01T00:00:00.000Z" - ], - ["malformed", "not-json", "2099-01-01T00:00:00.000Z"] - ])( - "keeps a claim pending for manual review when %s GitHub membership evidence is unavailable", - async (_state, github_orgs, github_orgs_expires_at) => { - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs, - github_orgs_expires_at - }); - await insertDeveloper(db, { - id: "acme-org", - type: "organization", - name: "Acme Org", - url: null, - owner_user_id: null - }); - - const res = await post( - "/extensions/v2/developers/acme-org/claim", - await authHeaders("user-1"), - {} - ); - - expect(res.status).toBe(201); - const body = (await res.json()) as { - result: { - id: string; - status: string; - github_org_verified?: boolean; - github_verification_note?: string; - }; - }; - expect(body.result.status).toBe("pending"); - expect(body.result.github_org_verified).toBeUndefined(); - expect(body.result.github_verification_note).toContain( - "could not be confirmed" - ); - expect((await getDeveloper(db, "acme-org"))?.owner_user_id).toBeNull(); - - const stored = await getDeveloperClaim(db, body.result.id); - expect(stored?.status).toBe("pending"); - expect(stored?.github_org_verified).toBeNull(); - } - ); - - it("does not create a duplicate row for a second claim while one is already pending", async () => { - await seedUnownedDeveloper("legacy-developer"); - - const first = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - expect(first.status).toBe(201); - - const second = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - expect(second.status).toBe(409); - expect(await countDeveloperClaims(db)).toBe(1); - }); - - it("does not re-check GitHub when replaying an already-pending claim", async () => { - await seedUnownedDeveloper("legacy-developer"); - - await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const callsAfterFirst = vi.mocked(ghRequest).mock.calls.length; - expect(callsAfterFirst).toBeGreaterThan(0); - - const second = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - - expect(second.status).toBe(409); - expect(vi.mocked(ghRequest).mock.calls.length).toBe(callsAfterFirst); - }); - - it("still verifies GitHub ownership on a retry after the prior claim was rejected", async () => { - await seedUnownedDeveloper("legacy-developer"); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const first = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const claimId = ((await first.json()) as { result: { id: string } }) - .result.id; - await post( - `/extensions/v2/developers/claims/${claimId}/reject`, - await authHeaders("mod-1"), - { review_note: "Not enough evidence" } - ); - - // Retrying now, with a GitHub identity that doesn't match: this must - // still be blocked rather than silently creating an unverified claim - // just because the prior (now-rejected) row cleared the pending guard. - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["some-other-org"]) - }); - - const claimCountBefore = await countDeveloperClaims(db); - const retry = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - - expect(retry.status).toBe(403); - const body = (await retry.json()) as { error: { code: string } }; - expect(body.error.code).toBe("GITHUB_MISMATCH"); - expect(await countDeveloperClaims(db)).toBe(claimCountBefore); - }); - - it("rejects a claim from a user who already owns a different profile", async () => { - await seedUnownedDeveloper("legacy-developer"); - await put( - "/extensions/v2/developers/me", - await authHeaders("user-1"), - sampleDeveloper() - ); - - const res = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - expect(res.status).toBe(409); - }); - - it("reports an account deactivated during claim creation", async () => { - await seedUnownedDeveloper("legacy-developer"); - const headers = await authHeaders("user-1"); - let deactivated = false; - env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { - if ( - !deactivated && - sql.includes("developer_claims") && - sql.includes("INSERT") - ) { - deactivated = true; - await db - .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") - .bind(new Date().toISOString(), "user-1") - .run(); - } - }); - - const res = await post( - "/extensions/v2/developers/legacy-developer/claim", - headers, - {} - ); - env.DB_EXTENSIONS = db; - - expect(deactivated).toBe(true); - expect(res.status).toBe(403); - expect(await res.json()).toMatchObject({ - error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } - }); - expect(await countDeveloperClaims(db)).toBe(0); - }); - - it("rolls back claim approval when a later ownership statement fails", async () => { - await seedUnownedDeveloper("legacy-developer"); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const claim1 = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const claim1Id = ((await claim1.json()) as { result: { id: string } }) - .result.id; - const claim2 = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-2"), - {} - ); - const claim2Id = ((await claim2.json()) as { result: { id: string } }) - .result.id; - const before = await getDeveloper(db, "legacy-developer"); - - env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { - if (sql.includes("SET owner_user_id = ?")) { - // Replace this item inside the real D1 batch, rather than throwing - // from the interceptor before the batch is submitted. The claim - // transition therefore executes first and this NOT NULL violation - // proves D1 rolls it back. - return db.prepare( - "UPDATE developers SET name = NULL WHERE id = 'legacy-developer'" - ); - } - }); - - const approve = await post( - `/extensions/v2/developers/claims/${claim1Id}/approve`, - await authHeaders("mod-1") - ); - expect(approve.status).toBe(500); - - const after = await getDeveloper(db, "legacy-developer"); - expect(after?.owner_user_id).toBeNull(); - expect(after?.ownership_epoch).toBe(before?.ownership_epoch); - expect(after?.content_revision).toBe(before?.content_revision); - expect((await getDeveloperClaim(db, claim1Id))?.status).toBe("pending"); - expect((await getDeveloperClaim(db, claim2Id))?.status).toBe("pending"); - }); - - it("approving a claim transfers ownership and auto-rejects competing claims", async () => { - await seedUnownedDeveloper("legacy-developer"); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const claim1 = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const claim1Id = ((await claim1.json()) as { result: { id: string } }) - .result.id; - - const claim2 = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-2"), - {} - ); - const claim2Id = ((await claim2.json()) as { result: { id: string } }) - .result.id; - - const approve = await post( - `/extensions/v2/developers/claims/${claim1Id}/approve`, - await authHeaders("mod-1") - ); - expect(approve.status).toBe(200); - const approved = (await approve.json()) as { - result: { id: string; approved: boolean }; - }; - expect(approved.result.id).toBe("legacy-developer"); - expect(approved.result.approved).toBe(false); - expect((await getDeveloper(db, "legacy-developer"))?.owner_user_id).toBe( - "user-1" - ); - - const rejectedClaim = await getDeveloperClaim(db, claim2Id); - expect(rejectedClaim?.status).toBe("rejected"); - expect(rejectedClaim?.review_note).toBe( - "Another claim on this profile was approved" - ); - }); - - it("allows only one competing claim approval to win a race", async () => { - await seedUnownedDeveloper("legacy-developer"); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const first = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const firstId = ((await first.json()) as { result: { id: string } }) - .result.id; - const second = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-2"), - {} - ); - const secondId = ((await second.json()) as { result: { id: string } }) - .result.id; - const headers = await authHeaders("mod-1"); - - const approvals = await Promise.all([ - post(`/extensions/v2/developers/claims/${firstId}/approve`, headers), - post(`/extensions/v2/developers/claims/${secondId}/approve`, headers) - ]); - - expect(approvals.map(({ status }) => status).sort()).toEqual([200, 409]); - const claims = await listDeveloperClaims(db); - expect(claims.filter(({ status }) => status === "approved")).toHaveLength( - 1 - ); - expect(claims.filter(({ status }) => status === "rejected")).toHaveLength( - 1 - ); - }); - - it("copies the claim's GitHub verification onto the developer row it transfers ownership to", async () => { - await insertDeveloper(db, { - id: "legacy-developer", - type: "organization", - name: "Legacy Developer", - url: null, - owner_user_id: null - }); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["legacy-developer"]) - }); - - const claim = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const claimId = ((await claim.json()) as { result: { id: string } }) - .result.id; - const claimRow = await getDeveloperClaim(db, claimId); - expect(claimRow?.github_org_verified).toBe(1); - - const approve = await post( - `/extensions/v2/developers/claims/${claimId}/approve`, - await authHeaders("mod-1") - ); - expect(approve.status).toBe(200); - - const developerRow = await getDeveloper(db, "legacy-developer"); - expect(developerRow?.github_org_verified).toBe(1); - expect(developerRow?.github_verification_note).toBe( - "Verified: caller's linked GitHub identity matches." - ); - expect(developerRow?.github_verified_at).toBe(claimRow?.created_at); - }); - - it("lets a moderator reject a claim with a review note, leaving the developer unowned", async () => { - await seedUnownedDeveloper("legacy-developer"); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - - const claim = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const claimId = ((await claim.json()) as { result: { id: string } }) - .result.id; - - const reject = await post( - `/extensions/v2/developers/claims/${claimId}/reject`, - await authHeaders("mod-1"), - { review_note: "Not enough evidence of maintainership" } - ); - expect(reject.status).toBe(200); - const rejected = (await reject.json()) as { - result: { status: string; review_note?: string }; - }; - expect(rejected.result.status).toBe("rejected"); - expect(rejected.result.review_note).toBe( - "Not enough evidence of maintainership" - ); - expect( - (await getDeveloper(db, "legacy-developer"))?.owner_user_id - ).toBeNull(); - }); - - it("verifies a claim when the claimant's linked GitHub org matches the developer id", async () => { - await insertDeveloper(db, { - id: "legacy-developer", - type: "organization", - name: "Legacy Developer", - url: null, - owner_user_id: null - }); - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["legacy-developer"]) - }); - - const res = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - expect(res.status).toBe(201); - const created = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(created.result.github_org_verified).toBe(true); - }); - - it("verifies a claim when the claimant's linked GitHub login matches a user-type developer id", async () => { - await insertDeveloper(db, { - id: "legacy-user", - type: "user", - name: "Legacy User", - url: null, - owner_user_id: null - }); - mockGithubEntity("User"); - await insertUser(db, { - id: "user-1", - github_login: "legacy-user", - github_orgs: JSON.stringify([]) - }); - - const res = await post( - "/extensions/v2/developers/legacy-user/claim", - await authHeaders("user-1"), - {} - ); - expect(res.status).toBe(201); - const created = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(created.result.github_org_verified).toBe(true); - }); - - it("blocks a claim outright when the claimant's linked GitHub identity doesn't match", async () => { - await insertDeveloper(db, { - id: "legacy-developer", - type: "organization", - name: "Legacy Developer", - url: null, - owner_user_id: null - }); - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["some-other-org"]) - }); - - const res = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("GITHUB_MISMATCH"); - expect(await countDeveloperClaims(db)).toBe(0); - }); - - it("falls back to unverified manual review when the claimant has no linked GitHub identity", async () => { - await seedUnownedDeveloper("legacy-developer"); // type: "user" - mockGithubEntity("User"); - // No row in users for user-1 — never linked GitHub. - - const res = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - expect(res.status).toBe(201); - const created = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(created.result.github_org_verified).toBeUndefined(); - }); - - it("falls back to unverified manual review when organization membership evidence is stale", async () => { - await insertDeveloper(db, { - id: "legacy-developer", - type: "organization", - name: "Legacy Developer", - url: null, - owner_user_id: null - }); - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: "someone", - github_orgs: JSON.stringify(["legacy-developer"]), - github_orgs_expires_at: "2000-01-01T00:00:00.000Z" - }); - - const res = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - expect(res.status).toBe(201); - const created = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(created.result.github_org_verified).toBeUndefined(); - }); - - it("does not verify an organization claim for a whitespace-only GitHub login", async () => { - await insertDeveloper(db, { - id: "legacy-developer", - type: "organization", - name: "Legacy Developer", - url: null, - owner_user_id: null - }); - mockGithubEntity("Organization"); - await insertUser(db, { - id: "user-1", - github_login: " ", - github_orgs: JSON.stringify(["legacy-developer"]), - github_orgs_expires_at: "2099-01-01T00:00:00.000Z" - }); - - const res = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - expect(res.status).toBe(201); - const created = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(created.result.github_org_verified).toBeUndefined(); - }); - - it("falls back to unverified manual review when no matching GitHub org/user exists for the id", async () => { - await seedUnownedDeveloper("legacy-developer"); - mockGithubEntityNotFound(); - - const res = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - expect(res.status).toBe(201); - const created = (await res.json()) as { - result: { github_org_verified?: boolean }; - }; - expect(created.result.github_org_verified).toBeUndefined(); - }); - - it("blocks non-moderators from the claims queue and review routes", async () => { - await seedUnownedDeveloper("legacy-developer"); - const claim = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const claimId = ((await claim.json()) as { result: { id: string } }) - .result.id; - - const queue = await get( - "/extensions/v2/developers/claims", - await authHeaders("intruder") - ); - expect(queue.status).toBe(403); - - const approve = await post( - `/extensions/v2/developers/claims/${claimId}/approve`, - await authHeaders("intruder") - ); - expect(approve.status).toBe(403); - - const reject = await post( - `/extensions/v2/developers/claims/${claimId}/reject`, - await authHeaders("intruder"), - { review_note: "no" } - ); - expect(reject.status).toBe(403); - }); - - it("lets a claimant cancel their own pending claim", async () => { - await seedUnownedDeveloper("legacy-developer"); - const claim = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const claimId = ((await claim.json()) as { result: { id: string } }) - .result.id; - - const cancel = await post( - `/extensions/v2/developers/claims/${claimId}/cancel`, - await authHeaders("user-1") - ); - expect(cancel.status).toBe(200); - const cancelled = (await cancel.json()) as { - result: { id: string; cancelled: boolean }; - }; - expect(cancelled.result).toEqual({ id: claimId, cancelled: true }); - expect(await getDeveloperClaim(db, claimId)).toBeNull(); - }); +// Hoisted so no v2 suite can make a real GitHub call. harness.ts applies the +// default "not found" behaviour in beforeEach and documents why. +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); - it("rejects cancelling a claim that belongs to someone else", async () => { - await seedUnownedDeveloper("legacy-developer"); - const claim = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const claimId = ((await claim.json()) as { result: { id: string } }) - .result.id; - - const cancel = await post( - `/extensions/v2/developers/claims/${claimId}/cancel`, - await authHeaders("user-2") - ); - expect(cancel.status).toBe(404); - expect(await getDeveloperClaim(db, claimId)).not.toBeNull(); - }); - - it("rejects cancelling a claim that is no longer pending", async () => { - await seedUnownedDeveloper("legacy-developer"); - await insertUser(db, { id: "mod-1", is_moderator: 1 }); - const claim = await post( - "/extensions/v2/developers/legacy-developer/claim", - await authHeaders("user-1"), - {} - ); - const claimId = ((await claim.json()) as { result: { id: string } }) - .result.id; - await post( - `/extensions/v2/developers/claims/${claimId}/reject`, - await authHeaders("mod-1"), - { review_note: "no" } - ); - - const cancel = await post( - `/extensions/v2/developers/claims/${claimId}/cancel`, - await authHeaders("user-1") - ); - expect(cancel.status).toBe(404); - expect((await getDeveloperClaim(db, claimId))?.status).toBe("rejected"); - }); - }); - - describe("GET /developers/{id}", () => { - it("returns a developer's public profile without contact_email, unauthenticated", async () => { - await insertDeveloper(db, { - id: "public-dev", - type: "organization", - name: "Public Dev", - url: "https://example.com", - avatar_url: "https://example.com/avatar.png", - contact_email: "private@example.com", - owner_user_id: "user-1", - approved_at: new Date().toISOString() - }); - - const res = await get("/extensions/v2/developers/public-dev", {}); - expect(res.status).toBe(200); - const body = (await res.json()) as { result: Record }; - expect(body.result).toEqual({ - id: "public-dev", - type: "organization", - name: "Public Dev", - URL: "https://example.com", - avatar_url: "https://example.com/avatar.png", - approved: true, - unclaimed: false - }); - expect(body.result.contact_email).toBeUndefined(); - }); - - it("404s for an unknown developer", async () => { - 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", () => { - async function seedCatalogue(ids: string[]): Promise { - await insertDeveloper(db, { - id: "catalogue-developer", - type: "user", - name: "Catalogue Developer", - url: null, - owner_user_id: null - }); - for (const id of ids) { - await insertExtension(db, { - id, - type: "mod", - author_id: "catalogue-developer", - name: id, - description: `Description for ${id}`, - releases: '[{"tag":"1.0.0"}]', - website: "https://example.com", - license: '{"name":"MIT"}', - icon_url: null, - readme: `README for ${id}`, - source: '{"type":"github","repo":"example/catalogue"}', - version: "1.0.0", - download_url: "https://example.com/download.zip" - }); - } - } - - it("lists published extensions with the developer embedded", async () => { - await seedOwnedExtension(); - - 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; 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(); - - const matching = await get("/extensions/v2/extensions?type=mod", {}); - const matchingBody = (await matching.json()) as { result: unknown[] }; - expect(matchingBody.result).toHaveLength(1); - - const nonMatching = await get("/extensions/v2/extensions?type=theme", {}); - const nonMatchingBody = (await nonMatching.json()) as { - result: unknown[]; - }; - expect(nonMatchingBody.result).toHaveLength(0); - }); - - it("422s on an invalid type filter", async () => { - const res = await get("/extensions/v2/extensions?type=not-a-type", {}); - expect(res.status).toBe(422); - }); - - it("filters by developer_id", async () => { - await seedOwnedExtension(); - - const matching = await get( - "/extensions/v2/extensions?developer_id=owner-developer", - {} - ); - const matchingBody = (await matching.json()) as { result: unknown[] }; - expect(matchingBody.result).toHaveLength(1); - - const nonMatching = await get( - "/extensions/v2/extensions?developer_id=someone-else", - {} - ); - const nonMatchingBody = (await nonMatching.json()) as { - result: unknown[]; - }; - expect(nonMatchingBody.result).toHaveLength(0); - }); - - it("returns deterministic first, middle, and final pages", async () => { - await seedCatalogue(["charlie", "Alpha", "bravo", "delta", "echo"]); - - const first = await get("/extensions/v2/extensions?limit=2", {}); - const firstBody = (await first.json()) as { - result: Array<{ id: string }>; - pagination: { next_cursor: string | null; has_more: boolean }; - }; - expect(firstBody.result.map(({ id }) => id)).toEqual(["Alpha", "bravo"]); - expect(firstBody.pagination.has_more).toBe(true); - - const middle = await get( - `/extensions/v2/extensions?limit=2&cursor=${encodeURIComponent(firstBody.pagination.next_cursor!)}`, - {} - ); - const middleBody = (await middle.json()) as typeof firstBody; - expect(middleBody.result.map(({ id }) => id)).toEqual([ - "charlie", - "delta" - ]); - expect(middleBody.pagination.has_more).toBe(true); - - const final = await get( - `/extensions/v2/extensions?limit=2&cursor=${encodeURIComponent(middleBody.pagination.next_cursor!)}`, - {} - ); - const finalBody = (await final.json()) as typeof firstBody; - expect(finalBody.result.map(({ id }) => id)).toEqual(["echo"]); - expect(finalBody.pagination).toEqual({ - next_cursor: null, - has_more: false - }); - }); - - it("rejects invalid cursors", async () => { - const res = await get( - "/extensions/v2/extensions?cursor=not-a-cursor", - {} - ); - expect(res.status).toBe(422); - expect(await res.json()).toMatchObject({ - error: { code: "INVALID_CURSOR" } - }); - - const blank = await get("/extensions/v2/extensions?cursor=", {}); - expect(blank.status).toBe(422); - }); - - it("supports UTF-8 extension ids in cursors", async () => { - await seedCatalogue(["alpha", "zulu", "éclair", "😀"]); - - const first = await get("/extensions/v2/extensions?limit=3", {}); - const firstBody = (await first.json()) as { - pagination: { next_cursor: string | null }; - }; - expect(first.status).toBe(200); - expect(firstBody.pagination.next_cursor).not.toBeNull(); - - const second = await get( - `/extensions/v2/extensions?limit=3&cursor=${encodeURIComponent(firstBody.pagination.next_cursor!)}`, - {} - ); - expect(second.status).toBe(200); - expect(await second.json()).toMatchObject({ - result: [{ id: "😀" }], - pagination: { next_cursor: null, has_more: false } - }); - }); - - it("accepts the maximum limit and rejects values above it", async () => { - await seedCatalogue(["one"]); - expect( - (await get("/extensions/v2/extensions?limit=100", {})).status - ).toBe(200); - expect( - (await get("/extensions/v2/extensions?limit=101", {})).status - ).toBe(422); - }); - }); - - describe("GET /extensions/{id}", () => { - it("gets a single extension, case-insensitively", async () => { - await seedOwnedExtension(); - - 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; 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: [], - source: { type: "github", repo: "example/existing" }, - version: "1.0.0", - download_url: "https://e.com/d.zip" - }); - }); - - it("404s for an unknown extension", async () => { - const res = await get("/extensions/v2/extensions/no-such-extension", {}); - expect(res.status).toBe(404); - }); - }); +setupExtensionsV2Tests(); +describe("Extensions API v2", () => { describe("OpenAPI docs", () => { it("serves a generated OpenAPI document", async () => { const res = await get("/extensions/v2/openapi.json", {}); @@ -4733,407 +70,4 @@ 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 } - }); - - 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.each([ - ["an impossible calendar day", "2099-02-30T00:00:00.000Z"], - ["an out-of-range hour", "2099-01-01T24:00:00.000Z"], - ["an out-of-range offset", "2099-01-01T00:00:00.000+24:00"] - ])( - "does not treat %s as usable organization evidence", - async (_description, github_orgs_expires_at) => { - const res = await put( - "/extensions/v2/users/me/identity", - await authHeaders("impossible-org-date"), - { - name: "Impossible Date", - email: "impossible-date@example.com", - email_verified: true, - picture: null, - github_login: "someone", - github_orgs: ["fossbilling"], - github_orgs_expires_at - } - ); - - expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ - result: { github_linked: false } - }); - const row = await db - .prepare( - "SELECT github_orgs, github_orgs_expires_at FROM users WHERE id = ?" - ) - .bind("impossible-org-date") - .first<{ - github_orgs: string | null; - github_orgs_expires_at: string | null; - }>(); - expect(row).toEqual({ - github_orgs: null, - github_orgs_expires_at: 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/services/extensions/v2/moderation.test.ts b/test/services/extensions/v2/moderation.test.ts new file mode 100644 index 0000000..f8a6d79 --- /dev/null +++ b/test/services/extensions/v2/moderation.test.ts @@ -0,0 +1,727 @@ +import { describe, it, expect, vi } from "vitest"; +import { env } from "cloudflare:workers"; +import { wrapD1WithHook } from "./db-interceptor"; +import { + setupExtensionsV2Tests, + db, + authHeaders, + post, + get, + put, + samplePayload, + sampleDeveloper, + seedDeveloper +} from "./harness"; +import { + insertUser, + insertDeveloper, + insertExtension, + insertSubmission, + getDeveloper, + countExtensions, + getExtension, + getSubmission, + bumpDeveloperOwnership +} from "./db-fixtures"; + +// Hoisted so no v2 suite can make a real GitHub call. harness.ts applies the +// default "not found" behaviour in beforeEach and documents why. +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); + +setupExtensionsV2Tests(); + +describe("Extensions API v2", () => { + describe("GET /submissions/queue", () => { + it("requires moderator access", async () => { + const res = await get( + "/extensions/v2/submissions/queue", + await authHeaders("user-1") + ); + expect(res.status).toBe(403); + }); + + it("identifies invalid cursors", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + const res = await get( + "/extensions/v2/submissions/queue?cursor=not-a-cursor", + await authHeaders("mod-1") + ); + expect(res.status).toBe(422); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "INVALID_CURSOR" } + }); + }); + + it("returns pending submissions for a moderator", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + samplePayload() + ); + + const res = await get( + "/extensions/v2/submissions/queue", + await authHeaders("mod-1") + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { result: Array<{ status: string }> }; + expect(data.result).toHaveLength(1); + expect(data.result[0].status).toBe("pending"); + }); + }); + + describe("approve / reject", () => { + it("does not approve a former owner's payload when ownership changes at approval", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const created = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + samplePayload() + ); + const { result } = (await created.json()) as { result: { id: string } }; + + // ownership_epoch is captured on the submission at creation time and + // only compared later, so unlike the deleteOwn/upsertOwn races below, + // simply changing ownership before the approve call (rather than + // mid-request) reproduces this exactly. + await bumpDeveloperOwnership(db, "new-developer", "user-2"); + const approved = await post( + `/extensions/v2/submissions/${result.id}/approve`, + await authHeaders("mod-1"), + {} + ); + expect(approved.status).toBe(409); + expect((await getSubmission(db, result.id))?.status).toBe("pending"); + expect(await countExtensions(db)).toBe(0); + }); + + 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); + }); + + // The developer half of the same guard. Approval only ever UPDATEs an + // existing developer row, so this cannot create a reserved profile - but a + // profile predating the reservation would otherwise gain a new extension + // pointing at an id that GET /developers/{id} can never serve. + it("does not approve a legacy pending submission with a reserved developer id", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await insertDeveloper(db, { + id: "me", + type: "user", + name: "Legacy Reserved", + url: null, + owner_user_id: "user-1" + }); + const legacyPayload = samplePayload({ developerId: "me" }); + await insertSubmission(db, { + id: "legacy-me-submission", + developer_id: "me", + submitted_by: "user-1", + payload: JSON.stringify(legacyPayload) + }); + + const approved = await post( + "/extensions/v2/submissions/legacy-me-submission/approve", + await authHeaders("mod-1"), + {} + ); + + expect(approved.status).toBe(409); + expect(await approved.json()).toMatchObject({ + error: { message: "This developer id is reserved" } + }); + expect(await getSubmission(db, "legacy-me-submission")).toMatchObject({ + status: "pending" + }); + expect(await countExtensions(db)).toBe(0); + }); + it("leaves the submission pending if the extension write-through fails mid-batch", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + + const created = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + samplePayload() + ); + const { result } = (await created.json()) as { result: { id: string } }; + + // approve()'s three statements (submission status, developer, extension) + // run as one atomic db.batch() call, so D1 itself rolls back the whole + // thing on any failure - there's no app-level "revert" to test, and no + // way to make the earlier statements really commit before this one + // fails (see db-interceptor.ts). This verifies that guarantee end to + // end: a failure on the last statement still leaves nothing committed. + env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { + if ( + sql.includes("INSERT INTO") && + sql.includes("extensions") && + !sql.includes("extension_submissions") + ) { + throw new Error("simulated write-through failure"); + } + }); + const approved = await post( + `/extensions/v2/submissions/${result.id}/approve`, + await authHeaders("mod-1"), + {} + ); + expect(approved.status).toBe(500); + expect(await countExtensions(db)).toBe(0); + + const stored = await getSubmission(db, result.id); + expect(stored?.status).toBe("pending"); + + // Recovers cleanly once the underlying failure is gone. + env.DB_EXTENSIONS = db; + const retried = await post( + `/extensions/v2/submissions/${result.id}/approve`, + await authHeaders("mod-1"), + {} + ); + expect(retried.status).toBe(200); + expect(await countExtensions(db)).toBe(1); + }); + + it("approves a submission and it becomes visible via the v1 read path", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + + const created = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + samplePayload() + ); + const { result } = (await created.json()) as { result: { id: string } }; + + const approved = await post( + `/extensions/v2/submissions/${result.id}/approve`, + await authHeaders("mod-1"), + {} + ); + expect(approved.status).toBe(200); + const approvedBody = (await approved.json()) as { + result: { status: string }; + }; + expect(approvedBody.result.status).toBe("approved"); + + // v1's read-only API keeps calling this field "author" — its JSON + // response shape is intentionally unchanged by the v2 rename. + const v1Res = await get("/extensions/v1/new-ext", {}); + expect(v1Res.status).toBe(200); + const v1Body = (await v1Res.json()) as { + result: { id: string; author: { id: string } }; + }; + expect(v1Body.result.id).toBe("new-ext"); + expect(v1Body.result.author.id).toBe("new-developer"); + }); + + it("blocks non-moderators from approving", async () => { + await seedDeveloper("new-developer", "user-1"); + const created = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + samplePayload() + ); + const { result } = (await created.json()) as { result: { id: string } }; + + const res = await post( + `/extensions/v2/submissions/${result.id}/approve`, + await authHeaders("user-1"), + {} + ); + expect(res.status).toBe(403); + }); + + it("rejects approving a submission that is not pending", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const created = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + samplePayload() + ); + const { result } = (await created.json()) as { result: { id: string } }; + + await post( + `/extensions/v2/submissions/${result.id}/approve`, + await authHeaders("mod-1"), + {} + ); + const secondApprove = await post( + `/extensions/v2/submissions/${result.id}/approve`, + await authHeaders("mod-1"), + {} + ); + expect(secondApprove.status).toBe(409); + // The second (raced) approve must not write through again. + expect(await countExtensions(db)).toBe(1); + }); + + it("updates the existing row instead of duplicating it when an edit's id differs only by case", async () => { + await insertDeveloper(db, { + id: "owner-developer", + type: "user", + name: "Owner", + url: null, + owner_user_id: "owner-1" + }); + // Legacy v1 data can have mixed-case ids; v2 submissions must be lowercase. + await insertExtension(db, { + id: "Existing-Ext", + type: "mod", + author_id: "owner-developer", + name: "Existing", + description: "d", + releases: "[]", + website: "https://e.com", + license: '{"name":"MIT"}', + icon_url: null, + readme: "r", + source: '{"type":"github","repo":"example/existing"}', + version: "1.0.0", + download_url: "https://e.com/d.zip" + }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const created = await post( + "/extensions/v2/submissions", + await authHeaders("owner-1"), + samplePayload({ + extensionId: "existing-ext", + developerId: "owner-developer" + }) + ); + const { result } = (await created.json()) as { result: { id: string } }; + + const approved = await post( + `/extensions/v2/submissions/${result.id}/approve`, + await authHeaders("mod-1"), + {} + ); + expect(approved.status).toBe(200); + + expect(await countExtensions(db)).toBe(1); + const stored = await getExtension(db, "Existing-Ext"); + expect(stored?.name).toBe("New Extension"); + }); + + it("requires a review_note to reject", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const created = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + samplePayload() + ); + const { result } = (await created.json()) as { result: { id: string } }; + + const res = await post( + `/extensions/v2/submissions/${result.id}/reject`, + await authHeaders("mod-1"), + {} + ); + expect(res.status).toBe(422); + }); + + it("rejects a submission with a note", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + const created = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + samplePayload() + ); + const { result } = (await created.json()) as { result: { id: string } }; + + const res = await post( + `/extensions/v2/submissions/${result.id}/reject`, + await authHeaders("mod-1"), + { review_note: "Needs a valid license URL" } + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { status: string } }; + expect(body.result.status).toBe("rejected"); + expect(await countExtensions(db)).toBe(0); + }); + + // Both review-note bodies are strict: the reviewer decision is derived + // from the route, never from the payload, so an unknown key is a client + // mistake rather than something to drop silently. + it("rejects an unknown field in the approve and reject bodies", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await seedDeveloper("new-developer", "user-1"); + await insertSubmission(db, { + id: "strict-body-submission", + developer_id: "new-developer", + submitted_by: "user-1", + payload: JSON.stringify(samplePayload({ developerId: "new-developer" })) + }); + const headers = await authHeaders("mod-1"); + + for (const path of ["approve", "reject"]) { + const res = await post( + `/extensions/v2/submissions/strict-body-submission/${path}`, + headers, + { review_note: "looks fine", reviewer_id: "someone-else" } + ); + + expect(res.status).toBe(422); + const body = (await res.json()) as { + error: { details: Array<{ code: string; path: PropertyKey[] }> }; + }; + expect(body.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "unrecognized_keys", path: [] }) + ]) + ); + } + + expect(await getSubmission(db, "strict-body-submission")).toMatchObject({ + status: "pending" + }); + }); + }); + + describe("developer moderation", () => { + it("binds approval to the exact profile revision reviewed", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper({ name: "Revision two" }) + ); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const stale = await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 1 } + ); + expect(stale.status).toBe(409); + + const current = await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 2 } + ); + expect(current.status).toBe(200); + }); + + it("does not turn an approval diagnosis database failure into not found", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { + if (/^\s*select/i.test(sql) && /from\s+"developers"/i.test(sql)) { + throw new Error("simulated approval diagnosis failure"); + } + }); + + const res = await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 999 } + ); + env.DB_EXTENSIONS = db; + + expect(res.status).toBe(500); + expect((await res.json()) as { error: { code: string } }).toMatchObject({ + error: { code: "DATABASE_ERROR" } + }); + }); + + it("reports an inactive moderator when the account is deactivated during approval", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + let deactivated = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if ( + !deactivated && + /update/i.test(sql) && + sql.includes("approved_at") + ) { + deactivated = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "mod-1") + .run(); + } + }); + + const res = await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 1 } + ); + env.DB_EXTENSIONS = db; + + expect(deactivated).toBe(true); + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ + error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } + }); + expect((await getDeveloper(db, "dev-developer"))?.approved_at).toBeNull(); + }); + + it("approves a developer and removes it from the unapproved list", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const approve = await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 1 } + ); + expect(approve.status).toBe(200); + const approveBody = (await approve.json()) as { + result: { id: string; approved: boolean }; + }; + expect(approveBody.result).toEqual({ + id: "dev-developer", + approved: true + }); + + const unapproved = await get( + "/extensions/v2/developers/unapproved", + await authHeaders("mod-1") + ); + expect(unapproved.status).toBe(200); + const unapprovedBody = (await unapproved.json()) as { + result: Array<{ id: string }>; + }; + expect(unapprovedBody.result.map((a) => a.id)).not.toContain( + "dev-developer" + ); + }); + + it("404s approving a nonexistent developer", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const res = await post( + "/extensions/v2/developers/no-such-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 1 } + ); + expect(res.status).toBe(404); + }); + + it("blocks non-moderators from listing unapproved developers", async () => { + const res = await get( + "/extensions/v2/developers/unapproved", + await authHeaders("user-1") + ); + expect(res.status).toBe(403); + }); + + it("lists every developer, approved and unapproved", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await put( + "/extensions/v2/developers/me", + await authHeaders("user-2"), + sampleDeveloper({ id: "other-developer", name: "Other Developer" }) + ); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 1 } + ); + + const res = await get( + "/extensions/v2/developers", + await authHeaders("mod-1") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: Array<{ id: string; approved: boolean }>; + }; + expect(body.result.map((a) => a.id).sort()).toEqual([ + "dev-developer", + "other-developer" + ]); + expect(body.result.find((a) => a.id === "dev-developer")?.approved).toBe( + true + ); + expect( + body.result.find((a) => a.id === "other-developer")?.approved + ).toBe(false); + }); + + it("blocks non-moderators from listing all developers", async () => { + const res = await get( + "/extensions/v2/developers", + await authHeaders("user-1") + ); + expect(res.status).toBe(403); + }); + + it("blocks non-moderators from approving developers", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const res = await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("user-1"), + { expected_revision: 1 } + ); + expect(res.status).toBe(403); + }); + }); + + describe("GET /developers/{id}/history", () => { + it("records a history entry for a newly created profile", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const res = await get( + "/extensions/v2/developers/dev-developer/history", + await authHeaders("mod-1") + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: Array<{ + developer_id: string; + name: string; + changed_by: string; + }>; + }; + expect(data.result).toHaveLength(1); + expect(data.result[0].developer_id).toBe("dev-developer"); + expect(data.result[0].name).toBe("Dev Developer"); + expect(data.result[0].changed_by).toBe("user-1"); + }); + + it("orders entries newest-first and snapshots each write", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper({ name: "Original Name" }) + ); + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper({ name: "Edited Name" }) + ); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const res = await get( + "/extensions/v2/developers/dev-developer/history", + await authHeaders("mod-1") + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: Array<{ name: string }>; + }; + expect(data.result).toHaveLength(2); + expect(data.result[0].name).toBe("Edited Name"); + expect(data.result[1].name).toBe("Original Name"); + }); + + it("returns an empty array for a developer with no history", async () => { + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const res = await get( + "/extensions/v2/developers/no-such-developer/history", + await authHeaders("mod-1") + ); + + expect(res.status).toBe(200); + const data = (await res.json()) as { result: unknown[] }; + expect(data.result).toEqual([]); + }); + + it("blocks non-moderators", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const res = await get( + "/extensions/v2/developers/dev-developer/history", + await authHeaders("user-1") + ); + expect(res.status).toBe(403); + }); + + it("does not record history for a rejected write (id already taken)", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const res = await put( + "/extensions/v2/developers/me", + await authHeaders("user-2"), + sampleDeveloper() + ); + expect(res.status).toBe(409); + + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + const history = await get( + "/extensions/v2/developers/dev-developer/history", + await authHeaders("mod-1") + ); + const data = (await history.json()) as { result: unknown[] }; + expect(data.result).toHaveLength(1); + }); + }); +}); diff --git a/test/services/extensions/v2/ownership.test.ts b/test/services/extensions/v2/ownership.test.ts new file mode 100644 index 0000000..98baf23 --- /dev/null +++ b/test/services/extensions/v2/ownership.test.ts @@ -0,0 +1,1285 @@ +import { describe, it, expect, vi } from "vitest"; +import { env } from "cloudflare:workers"; +import { request as ghRequest } from "@octokit/request"; +import { wrapD1WithHook } from "./db-interceptor"; +import { + setupExtensionsV2Tests, + db, + authHeaders, + post, + get, + put, + samplePayload, + sampleDeveloper, + seedUnownedDeveloper, + mockGithubEntity, + mockGithubEntityNotFound +} from "./harness"; +import { + insertUser, + insertDeveloper, + insertSubmission, + insertDeveloperClaim, + getDeveloper, + getSubmission, + countDeveloperClaims, + getDeveloperClaim, + listDeveloperClaims, + expireAllDeveloperTransfers, + listDeveloperTransfers +} from "./db-fixtures"; + +// Hoisted so no v2 suite can make a real GitHub call. harness.ts applies the +// default "not found" behaviour in beforeEach and documents why. +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); + +setupExtensionsV2Tests(); + +describe("Extensions API v2", () => { + describe("developer transfers", () => { + it("does not accept transfer capabilities in URL paths", async () => { + const res = await post( + "/extensions/v2/developers/transfers/secret-token/accept", + await authHeaders("user-2") + ); + expect(res.status).toBe(404); + }); + + it("initiating a second transfer revokes the first token", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const first = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + expect(first.status).toBe(200); + const firstToken = ((await first.json()) as { result: { token: string } }) + .result.token; + + const second = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + expect(second.status).toBe(200); + + const acceptFirst = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token: firstToken } + ); + expect(acceptFirst.status).toBe(404); + }); + + it("accepts a valid token, transferring ownership and clearing approval", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + await post( + "/extensions/v2/developers/dev-developer/approve", + await authHeaders("mod-1"), + { expected_revision: 1 } + ); + + const initiate = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token = ((await initiate.json()) as { result: { token: string } }) + .result.token; + + const accept = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token } + ); + expect(accept.status).toBe(200); + const accepted = (await accept.json()) as { + result: { id: string; approved: boolean }; + }; + expect(accepted.result.id).toBe("dev-developer"); + expect(accepted.result.approved).toBe(false); + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( + "user-2" + ); + + const acceptAgain = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-3"), + { token } + ); + expect(acceptAgain.status).toBe(404); + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( + "user-2" + ); + }); + + it("does not turn a committed transfer into a database error if the profile is deleted before the response lookup", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + const initiate = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token = ((await initiate.json()) as { result: { token: string } }) + .result.token; + + let deleted = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if ( + !deleted && + /^\s*select/i.test(sql) && + /from\s+"developers"/i.test(sql) + ) { + deleted = true; + await db + .prepare("DELETE FROM developer_transfers WHERE developer_id = ?") + .bind("dev-developer") + .run(); + await db + .prepare("DELETE FROM developers WHERE id = ?") + .bind("dev-developer") + .run(); + } + }); + + const accept = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token } + ); + env.DB_EXTENSIONS = db; + + expect(deleted).toBe(true); + expect(accept.status).toBe(404); + expect(await accept.json()).toMatchObject({ + error: { code: "NOT_FOUND" } + }); + }); + + it("rejects pending submissions and claims when ownership changes", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await insertDeveloperClaim(db, { + id: "transfer-pending-claim", + developer_id: "dev-developer", + claimant_id: "user-3" + }); + await insertSubmission(db, { + id: "transfer-pending-submission", + developer_id: "dev-developer", + submitted_by: "user-3", + payload: JSON.stringify(samplePayload({ developerId: "dev-developer" })) + }); + + const initiate = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token = ((await initiate.json()) as { result: { token: string } }) + .result.token; + + const accept = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token } + ); + expect(accept.status).toBe(200); + + expect( + await getDeveloperClaim(db, "transfer-pending-claim") + ).toMatchObject({ + status: "rejected", + review_note: "Ownership changed before review" + }); + expect( + await getSubmission(db, "transfer-pending-submission") + ).toMatchObject({ + status: "rejected", + review_note: "Ownership changed before review" + }); + }); + + it("reports an inactive owner when the account is deactivated during initiation", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + let tombstoned = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!tombstoned && sql.includes("INSERT INTO developer_transfers")) { + tombstoned = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "user-1") + .run(); + } + }); + + const res = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + env.DB_EXTENSIONS = db; + + expect(tombstoned).toBe(true); + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ + error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } + }); + // The 403 alone would also be satisfied by a committed row the guard + // merely failed to report, which a later accept could still act on. + expect(await listDeveloperTransfers(db)).toHaveLength(0); + }); + + it("reports an inactive recipient when the account is deactivated during acceptance", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const initiate = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token = ((await initiate.json()) as { result: { token: string } }) + .result.token; + + let deactivated = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if (!deactivated && sql.includes("UPDATE developer_transfers")) { + deactivated = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "user-2") + .run(); + } + }); + + const res = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token } + ); + env.DB_EXTENSIONS = db; + + expect(deactivated).toBe(true); + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ + error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } + }); + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( + "user-1" + ); + // Ownership not moving is only half of it: a transfer left marked + // accepted would burn the token while the handover never happened. + const [transfer] = await listDeveloperTransfers(db); + expect(transfer.accepted_at).toBeNull(); + expect(transfer.accepted_by).toBeNull(); + }); + + it("doesn't inherit the previous owner's check_url cooldown after a transfer", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const usedCooldown = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-1") + ); + expect(usedCooldown.status).toBe(200); + + const initiate = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token = ((await initiate.json()) as { result: { token: string } }) + .result.token; + const accept = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token } + ); + expect(accept.status).toBe(200); + + // user-2 has never called check_url themselves — the previous + // owner's still-active cooldown must not carry over onto them. + const res = await post( + "/extensions/v2/developers/me/reverify?check_url=true", + await authHeaders("user-2") + ); + expect(res.status).toBe(200); + }); + + it("clears GitHub verification on transfer — it described the previous owner's identity, not the new owner's", async () => { + mockGithubEntity("User", "https://acme.example"); + await insertUser(db, { + id: "user-1", + github_login: "dev-developer", + github_orgs: JSON.stringify([]) + }); + const created = await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + { ...sampleDeveloper(), URL: "https://acme.example" } + ); + const createdBody = (await created.json()) as { + result: { + github_org_verified?: boolean; + github_url_verified?: boolean; + }; + }; + expect(createdBody.result.github_org_verified).toBe(true); + expect(createdBody.result.github_url_verified).toBe(true); + + const initiate = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token = ((await initiate.json()) as { result: { token: string } }) + .result.token; + const accept = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token } + ); + expect(accept.status).toBe(200); + const accepted = (await accept.json()) as { + result: { + github_org_verified?: boolean; + github_url_verified?: boolean; + }; + }; + expect(accepted.result.github_org_verified).toBeUndefined(); + expect(accepted.result.github_url_verified).toBeUndefined(); + }); + + it("does not let replaying an already-used token reassign ownership away from a later owner", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const initiate1 = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token1 = ((await initiate1.json()) as { result: { token: string } }) + .result.token; + + const accept1 = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token: token1 } + ); + expect(accept1.status).toBe(200); + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( + "user-2" + ); + + // dev-developer is legitimately handed off again, to a third user. + const initiate2 = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-2") + ); + const token2 = ((await initiate2.json()) as { result: { token: string } }) + .result.token; + const accept2 = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-3"), + { token: token2 } + ); + expect(accept2.status).toBe(200); + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( + "user-3" + ); + + // Replaying the *first* (already-used) token, by the same user who + // originally accepted it, must not silently reassign ownership back. + const replay = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token: token1 } + ); + expect(replay.status).toBe(404); + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( + "user-3" + ); + }); + + it("rejects an expired token", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const initiate = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token = ((await initiate.json()) as { result: { token: string } }) + .result.token; + + await expireAllDeveloperTransfers(db); + + const accept = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token } + ); + expect(accept.status).toBe(404); + }); + + it("rejects a revoked token", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const initiate = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token = ((await initiate.json()) as { result: { token: string } }) + .result.token; + + const revoke = await post( + "/extensions/v2/developers/dev-developer/transfer/revoke", + await authHeaders("user-1") + ); + expect(revoke.status).toBe(200); + + const accept = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token } + ); + expect(accept.status).toBe(404); + }); + + it("rejects acceptance by a user who already owns a different profile", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await put( + "/extensions/v2/developers/me", + await authHeaders("user-2"), + sampleDeveloper({ id: "other-developer", name: "Other Developer" }) + ); + + const initiate = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token = ((await initiate.json()) as { result: { token: string } }) + .result.token; + + const accept = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-2"), + { token } + ); + expect(accept.status).toBe(409); + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( + "user-1" + ); + }); + + it("rejects the current owner accepting their own transfer link", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const initiate = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + const token = ((await initiate.json()) as { result: { token: string } }) + .result.token; + + const accept = await post( + "/extensions/v2/developers/transfers/accept", + await authHeaders("user-1"), + { token } + ); + expect(accept.status).toBe(409); + expect((await getDeveloper(db, "dev-developer"))?.owner_user_id).toBe( + "user-1" + ); + }); + + it("blocks a non-owner from initiating a transfer", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const res = await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("intruder") + ); + expect(res.status).toBe(403); + }); + + it("blocks a non-owner from revoking a transfer", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + await post( + "/extensions/v2/developers/dev-developer/transfer", + await authHeaders("user-1") + ); + + const res = await post( + "/extensions/v2/developers/dev-developer/transfer/revoke", + await authHeaders("intruder") + ); + expect(res.status).toBe(403); + }); + }); + + describe("developer claims", () => { + // The claim body is strict so the server-computed verification fields on + // DeveloperClaimSchema can never be supplied by the caller. Unknown keys + // are reported at the root path, since the body has no nesting. + it("rejects an unknown field in the claim body", async () => { + await seedUnownedDeveloper("legacy-developer"); + + const res = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + { note: "mine", github_org_verified: true } + ); + + expect(res.status).toBe(422); + const body = (await res.json()) as { + error: { details: Array<{ code: string; path: PropertyKey[] }> }; + }; + expect(body.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + path: [] + }) + ]) + ); + expect(await countDeveloperClaims(db)).toBe(0); + }); + + it("lets a user claim an unowned developer, visible to the claimant and moderators", async () => { + await seedUnownedDeveloper("legacy-developer"); + + const res = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + { note: "I'm the maintainer, see github.com/x" } + ); + expect(res.status).toBe(201); + const created = (await res.json()) as { result: { id: string } }; + + const mine = await get( + "/extensions/v2/developers/claims/mine", + await authHeaders("user-1") + ); + expect(mine.status).toBe(200); + const mineData = (await mine.json()) as { + result: Array<{ id: string; status: string }>; + }; + expect(mineData.result.map((c) => c.id)).toEqual([created.result.id]); + expect(mineData.result[0].status).toBe("pending"); + + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + const pending = await get( + "/extensions/v2/developers/claims", + await authHeaders("mod-1") + ); + expect(pending.status).toBe(200); + const pendingData = (await pending.json()) as { + result: Array<{ id: string; developer_name: string }>; + }; + expect(pendingData.result.map((c) => c.id)).toEqual([created.result.id]); + expect(pendingData.result[0].developer_name).toBe("Legacy Developer"); + }); + + it("rejects claiming a developer that already has an owner", async () => { + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const res = await post( + "/extensions/v2/developers/dev-developer/claim", + await authHeaders("user-2"), + {} + ); + expect(res.status).toBe(409); + }); + + it.each([ + [ + "expired", + JSON.stringify(["some-other-org"]), + "2000-01-01T00:00:00.000Z" + ], + ["malformed", "not-json", "2099-01-01T00:00:00.000Z"] + ])( + "keeps a claim pending for manual review when %s GitHub membership evidence is unavailable", + async (_state, github_orgs, github_orgs_expires_at) => { + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs, + github_orgs_expires_at + }); + await insertDeveloper(db, { + id: "acme-org", + type: "organization", + name: "Acme Org", + url: null, + owner_user_id: null + }); + + const res = await post( + "/extensions/v2/developers/acme-org/claim", + await authHeaders("user-1"), + {} + ); + + expect(res.status).toBe(201); + const body = (await res.json()) as { + result: { + id: string; + status: string; + github_org_verified?: boolean; + github_verification_note?: string; + }; + }; + expect(body.result.status).toBe("pending"); + expect(body.result.github_org_verified).toBeUndefined(); + expect(body.result.github_verification_note).toContain( + "could not be confirmed" + ); + expect((await getDeveloper(db, "acme-org"))?.owner_user_id).toBeNull(); + + const stored = await getDeveloperClaim(db, body.result.id); + expect(stored?.status).toBe("pending"); + expect(stored?.github_org_verified).toBeNull(); + } + ); + + it("does not create a duplicate row for a second claim while one is already pending", async () => { + await seedUnownedDeveloper("legacy-developer"); + + const first = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + expect(first.status).toBe(201); + + const second = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + expect(second.status).toBe(409); + expect(await countDeveloperClaims(db)).toBe(1); + }); + + it("does not re-check GitHub when replaying an already-pending claim", async () => { + await seedUnownedDeveloper("legacy-developer"); + + await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const callsAfterFirst = vi.mocked(ghRequest).mock.calls.length; + expect(callsAfterFirst).toBeGreaterThan(0); + + const second = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + + expect(second.status).toBe(409); + expect(vi.mocked(ghRequest).mock.calls.length).toBe(callsAfterFirst); + }); + + it("still verifies GitHub ownership on a retry after the prior claim was rejected", async () => { + await seedUnownedDeveloper("legacy-developer"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const first = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claimId = ((await first.json()) as { result: { id: string } }) + .result.id; + await post( + `/extensions/v2/developers/claims/${claimId}/reject`, + await authHeaders("mod-1"), + { review_note: "Not enough evidence" } + ); + + // Retrying now, with a GitHub identity that doesn't match: this must + // still be blocked rather than silently creating an unverified claim + // just because the prior (now-rejected) row cleared the pending guard. + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["some-other-org"]) + }); + + const claimCountBefore = await countDeveloperClaims(db); + const retry = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + + expect(retry.status).toBe(403); + const body = (await retry.json()) as { error: { code: string } }; + expect(body.error.code).toBe("GITHUB_MISMATCH"); + expect(await countDeveloperClaims(db)).toBe(claimCountBefore); + }); + + it("rejects a claim from a user who already owns a different profile", async () => { + await seedUnownedDeveloper("legacy-developer"); + await put( + "/extensions/v2/developers/me", + await authHeaders("user-1"), + sampleDeveloper() + ); + + const res = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + expect(res.status).toBe(409); + }); + + it("reports an account deactivated during claim creation", async () => { + await seedUnownedDeveloper("legacy-developer"); + const headers = await authHeaders("user-1"); + let deactivated = false; + env.DB_EXTENSIONS = wrapD1WithHook(db, async (sql) => { + if ( + !deactivated && + sql.includes("developer_claims") && + sql.includes("INSERT") + ) { + deactivated = true; + await db + .prepare("UPDATE users SET deleted_at = ? WHERE id = ?") + .bind(new Date().toISOString(), "user-1") + .run(); + } + }); + + const res = await post( + "/extensions/v2/developers/legacy-developer/claim", + headers, + {} + ); + env.DB_EXTENSIONS = db; + + expect(deactivated).toBe(true); + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ + error: { code: "ACCOUNT_INACTIVE", message: "Active account required" } + }); + expect(await countDeveloperClaims(db)).toBe(0); + }); + + it("rolls back claim approval when a later ownership statement fails", async () => { + await seedUnownedDeveloper("legacy-developer"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const claim1 = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claim1Id = ((await claim1.json()) as { result: { id: string } }) + .result.id; + const claim2 = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-2"), + {} + ); + const claim2Id = ((await claim2.json()) as { result: { id: string } }) + .result.id; + const before = await getDeveloper(db, "legacy-developer"); + + env.DB_EXTENSIONS = wrapD1WithHook(db, (sql) => { + if (sql.includes("SET owner_user_id = ?")) { + // Replace this item inside the real D1 batch, rather than throwing + // from the interceptor before the batch is submitted. The claim + // transition therefore executes first and this NOT NULL violation + // proves D1 rolls it back. + return db.prepare( + "UPDATE developers SET name = NULL WHERE id = 'legacy-developer'" + ); + } + }); + + const approve = await post( + `/extensions/v2/developers/claims/${claim1Id}/approve`, + await authHeaders("mod-1") + ); + expect(approve.status).toBe(500); + + const after = await getDeveloper(db, "legacy-developer"); + expect(after?.owner_user_id).toBeNull(); + expect(after?.ownership_epoch).toBe(before?.ownership_epoch); + expect(after?.content_revision).toBe(before?.content_revision); + expect((await getDeveloperClaim(db, claim1Id))?.status).toBe("pending"); + expect((await getDeveloperClaim(db, claim2Id))?.status).toBe("pending"); + }); + + it("approving a claim transfers ownership and auto-rejects competing claims", async () => { + await seedUnownedDeveloper("legacy-developer"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const claim1 = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claim1Id = ((await claim1.json()) as { result: { id: string } }) + .result.id; + + const claim2 = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-2"), + {} + ); + const claim2Id = ((await claim2.json()) as { result: { id: string } }) + .result.id; + + const approve = await post( + `/extensions/v2/developers/claims/${claim1Id}/approve`, + await authHeaders("mod-1") + ); + expect(approve.status).toBe(200); + const approved = (await approve.json()) as { + result: { id: string; approved: boolean }; + }; + expect(approved.result.id).toBe("legacy-developer"); + expect(approved.result.approved).toBe(false); + expect((await getDeveloper(db, "legacy-developer"))?.owner_user_id).toBe( + "user-1" + ); + + const rejectedClaim = await getDeveloperClaim(db, claim2Id); + expect(rejectedClaim?.status).toBe("rejected"); + expect(rejectedClaim?.review_note).toBe( + "Another claim on this profile was approved" + ); + }); + + it("allows only one competing claim approval to win a race", async () => { + await seedUnownedDeveloper("legacy-developer"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const first = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const firstId = ((await first.json()) as { result: { id: string } }) + .result.id; + const second = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-2"), + {} + ); + const secondId = ((await second.json()) as { result: { id: string } }) + .result.id; + const headers = await authHeaders("mod-1"); + + const approvals = await Promise.all([ + post(`/extensions/v2/developers/claims/${firstId}/approve`, headers), + post(`/extensions/v2/developers/claims/${secondId}/approve`, headers) + ]); + + expect(approvals.map(({ status }) => status).sort()).toEqual([200, 409]); + const claims = await listDeveloperClaims(db); + expect(claims.filter(({ status }) => status === "approved")).toHaveLength( + 1 + ); + expect(claims.filter(({ status }) => status === "rejected")).toHaveLength( + 1 + ); + }); + + it("copies the claim's GitHub verification onto the developer row it transfers ownership to", async () => { + await insertDeveloper(db, { + id: "legacy-developer", + type: "organization", + name: "Legacy Developer", + url: null, + owner_user_id: null + }); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["legacy-developer"]) + }); + + const claim = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claimId = ((await claim.json()) as { result: { id: string } }) + .result.id; + const claimRow = await getDeveloperClaim(db, claimId); + expect(claimRow?.github_org_verified).toBe(1); + + const approve = await post( + `/extensions/v2/developers/claims/${claimId}/approve`, + await authHeaders("mod-1") + ); + expect(approve.status).toBe(200); + + const developerRow = await getDeveloper(db, "legacy-developer"); + expect(developerRow?.github_org_verified).toBe(1); + expect(developerRow?.github_verification_note).toBe( + "Verified: caller's linked GitHub identity matches." + ); + expect(developerRow?.github_verified_at).toBe(claimRow?.created_at); + }); + + it("lets a moderator reject a claim with a review note, leaving the developer unowned", async () => { + await seedUnownedDeveloper("legacy-developer"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + + const claim = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claimId = ((await claim.json()) as { result: { id: string } }) + .result.id; + + const reject = await post( + `/extensions/v2/developers/claims/${claimId}/reject`, + await authHeaders("mod-1"), + { review_note: "Not enough evidence of maintainership" } + ); + expect(reject.status).toBe(200); + const rejected = (await reject.json()) as { + result: { status: string; review_note?: string }; + }; + expect(rejected.result.status).toBe("rejected"); + expect(rejected.result.review_note).toBe( + "Not enough evidence of maintainership" + ); + expect( + (await getDeveloper(db, "legacy-developer"))?.owner_user_id + ).toBeNull(); + }); + + it("verifies a claim when the claimant's linked GitHub org matches the developer id", async () => { + await insertDeveloper(db, { + id: "legacy-developer", + type: "organization", + name: "Legacy Developer", + url: null, + owner_user_id: null + }); + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["legacy-developer"]) + }); + + const res = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + expect(res.status).toBe(201); + const created = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(created.result.github_org_verified).toBe(true); + }); + + it("verifies a claim when the claimant's linked GitHub login matches a user-type developer id", async () => { + await insertDeveloper(db, { + id: "legacy-user", + type: "user", + name: "Legacy User", + url: null, + owner_user_id: null + }); + mockGithubEntity("User"); + await insertUser(db, { + id: "user-1", + github_login: "legacy-user", + github_orgs: JSON.stringify([]) + }); + + const res = await post( + "/extensions/v2/developers/legacy-user/claim", + await authHeaders("user-1"), + {} + ); + expect(res.status).toBe(201); + const created = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(created.result.github_org_verified).toBe(true); + }); + + it("blocks a claim outright when the claimant's linked GitHub identity doesn't match", async () => { + await insertDeveloper(db, { + id: "legacy-developer", + type: "organization", + name: "Legacy Developer", + url: null, + owner_user_id: null + }); + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["some-other-org"]) + }); + + const res = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("GITHUB_MISMATCH"); + expect(await countDeveloperClaims(db)).toBe(0); + }); + + it("falls back to unverified manual review when the claimant has no linked GitHub identity", async () => { + await seedUnownedDeveloper("legacy-developer"); // type: "user" + mockGithubEntity("User"); + // No row in users for user-1 — never linked GitHub. + + const res = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + expect(res.status).toBe(201); + const created = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(created.result.github_org_verified).toBeUndefined(); + }); + + it("falls back to unverified manual review when organization membership evidence is stale", async () => { + await insertDeveloper(db, { + id: "legacy-developer", + type: "organization", + name: "Legacy Developer", + url: null, + owner_user_id: null + }); + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: "someone", + github_orgs: JSON.stringify(["legacy-developer"]), + github_orgs_expires_at: "2000-01-01T00:00:00.000Z" + }); + + const res = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + expect(res.status).toBe(201); + const created = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(created.result.github_org_verified).toBeUndefined(); + }); + + it("does not verify an organization claim for a whitespace-only GitHub login", async () => { + await insertDeveloper(db, { + id: "legacy-developer", + type: "organization", + name: "Legacy Developer", + url: null, + owner_user_id: null + }); + mockGithubEntity("Organization"); + await insertUser(db, { + id: "user-1", + github_login: " ", + github_orgs: JSON.stringify(["legacy-developer"]), + github_orgs_expires_at: "2099-01-01T00:00:00.000Z" + }); + + const res = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + expect(res.status).toBe(201); + const created = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(created.result.github_org_verified).toBeUndefined(); + }); + + it("falls back to unverified manual review when no matching GitHub org/user exists for the id", async () => { + await seedUnownedDeveloper("legacy-developer"); + mockGithubEntityNotFound(); + + const res = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + expect(res.status).toBe(201); + const created = (await res.json()) as { + result: { github_org_verified?: boolean }; + }; + expect(created.result.github_org_verified).toBeUndefined(); + }); + + it("blocks non-moderators from the claims queue and review routes", async () => { + await seedUnownedDeveloper("legacy-developer"); + const claim = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claimId = ((await claim.json()) as { result: { id: string } }) + .result.id; + + const queue = await get( + "/extensions/v2/developers/claims", + await authHeaders("intruder") + ); + expect(queue.status).toBe(403); + + const approve = await post( + `/extensions/v2/developers/claims/${claimId}/approve`, + await authHeaders("intruder") + ); + expect(approve.status).toBe(403); + + const reject = await post( + `/extensions/v2/developers/claims/${claimId}/reject`, + await authHeaders("intruder"), + { review_note: "no" } + ); + expect(reject.status).toBe(403); + }); + + it("lets a claimant cancel their own pending claim", async () => { + await seedUnownedDeveloper("legacy-developer"); + const claim = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claimId = ((await claim.json()) as { result: { id: string } }) + .result.id; + + const cancel = await post( + `/extensions/v2/developers/claims/${claimId}/cancel`, + await authHeaders("user-1") + ); + expect(cancel.status).toBe(200); + const cancelled = (await cancel.json()) as { + result: { id: string; cancelled: boolean }; + }; + expect(cancelled.result).toEqual({ id: claimId, cancelled: true }); + expect(await getDeveloperClaim(db, claimId)).toBeNull(); + }); + + it("rejects cancelling a claim that belongs to someone else", async () => { + await seedUnownedDeveloper("legacy-developer"); + const claim = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claimId = ((await claim.json()) as { result: { id: string } }) + .result.id; + + const cancel = await post( + `/extensions/v2/developers/claims/${claimId}/cancel`, + await authHeaders("user-2") + ); + expect(cancel.status).toBe(404); + expect(await getDeveloperClaim(db, claimId)).not.toBeNull(); + }); + + it("rejects cancelling a claim that is no longer pending", async () => { + await seedUnownedDeveloper("legacy-developer"); + await insertUser(db, { id: "mod-1", is_moderator: 1 }); + const claim = await post( + "/extensions/v2/developers/legacy-developer/claim", + await authHeaders("user-1"), + {} + ); + const claimId = ((await claim.json()) as { result: { id: string } }) + .result.id; + await post( + `/extensions/v2/developers/claims/${claimId}/reject`, + await authHeaders("mod-1"), + { review_note: "no" } + ); + + const cancel = await post( + `/extensions/v2/developers/claims/${claimId}/cancel`, + await authHeaders("user-1") + ); + expect(cancel.status).toBe(404); + expect((await getDeveloperClaim(db, claimId))?.status).toBe("rejected"); + }); + }); +}); diff --git a/test/services/extensions/v2/public-extensions.test.ts b/test/services/extensions/v2/public-extensions.test.ts new file mode 100644 index 0000000..2a28204 --- /dev/null +++ b/test/services/extensions/v2/public-extensions.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi } from "vitest"; +import { setupExtensionsV2Tests, db, get, seedOwnedExtension } from "./harness"; +import { insertDeveloper, insertExtension } from "./db-fixtures"; + +// Hoisted so no v2 suite can make a real GitHub call. harness.ts applies the +// default "not found" behaviour in beforeEach and documents why. +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); + +setupExtensionsV2Tests(); + +describe("Extensions API v2", () => { + describe("GET /extensions", () => { + async function seedCatalogue(ids: string[]): Promise { + await insertDeveloper(db, { + id: "catalogue-developer", + type: "user", + name: "Catalogue Developer", + url: null, + owner_user_id: null + }); + for (const id of ids) { + await insertExtension(db, { + id, + type: "mod", + author_id: "catalogue-developer", + name: id, + description: `Description for ${id}`, + releases: '[{"tag":"1.0.0"}]', + website: "https://example.com", + license: '{"name":"MIT"}', + icon_url: null, + readme: `README for ${id}`, + source: '{"type":"github","repo":"example/catalogue"}', + version: "1.0.0", + download_url: "https://example.com/download.zip" + }); + } + } + + it("lists published extensions with the developer embedded", async () => { + await seedOwnedExtension(); + + 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; 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(); + + const matching = await get("/extensions/v2/extensions?type=mod", {}); + const matchingBody = (await matching.json()) as { result: unknown[] }; + expect(matchingBody.result).toHaveLength(1); + + const nonMatching = await get("/extensions/v2/extensions?type=theme", {}); + const nonMatchingBody = (await nonMatching.json()) as { + result: unknown[]; + }; + expect(nonMatchingBody.result).toHaveLength(0); + }); + + it("422s on an invalid type filter", async () => { + const res = await get("/extensions/v2/extensions?type=not-a-type", {}); + expect(res.status).toBe(422); + }); + + it("filters by developer_id", async () => { + await seedOwnedExtension(); + + const matching = await get( + "/extensions/v2/extensions?developer_id=owner-developer", + {} + ); + const matchingBody = (await matching.json()) as { result: unknown[] }; + expect(matchingBody.result).toHaveLength(1); + + const nonMatching = await get( + "/extensions/v2/extensions?developer_id=someone-else", + {} + ); + const nonMatchingBody = (await nonMatching.json()) as { + result: unknown[]; + }; + expect(nonMatchingBody.result).toHaveLength(0); + }); + + it("returns deterministic first, middle, and final pages", async () => { + await seedCatalogue(["charlie", "Alpha", "bravo", "delta", "echo"]); + + const first = await get("/extensions/v2/extensions?limit=2", {}); + const firstBody = (await first.json()) as { + result: Array<{ id: string }>; + pagination: { next_cursor: string | null; has_more: boolean }; + }; + expect(firstBody.result.map(({ id }) => id)).toEqual(["Alpha", "bravo"]); + expect(firstBody.pagination.has_more).toBe(true); + + const middle = await get( + `/extensions/v2/extensions?limit=2&cursor=${encodeURIComponent(firstBody.pagination.next_cursor!)}`, + {} + ); + const middleBody = (await middle.json()) as typeof firstBody; + expect(middleBody.result.map(({ id }) => id)).toEqual([ + "charlie", + "delta" + ]); + expect(middleBody.pagination.has_more).toBe(true); + + const final = await get( + `/extensions/v2/extensions?limit=2&cursor=${encodeURIComponent(middleBody.pagination.next_cursor!)}`, + {} + ); + const finalBody = (await final.json()) as typeof firstBody; + expect(finalBody.result.map(({ id }) => id)).toEqual(["echo"]); + expect(finalBody.pagination).toEqual({ + next_cursor: null, + has_more: false + }); + }); + + it("rejects invalid cursors", async () => { + const res = await get( + "/extensions/v2/extensions?cursor=not-a-cursor", + {} + ); + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ + error: { code: "INVALID_CURSOR" } + }); + + const blank = await get("/extensions/v2/extensions?cursor=", {}); + expect(blank.status).toBe(422); + }); + + it("supports UTF-8 extension ids in cursors", async () => { + await seedCatalogue(["alpha", "zulu", "éclair", "😀"]); + + const first = await get("/extensions/v2/extensions?limit=3", {}); + const firstBody = (await first.json()) as { + pagination: { next_cursor: string | null }; + }; + expect(first.status).toBe(200); + expect(firstBody.pagination.next_cursor).not.toBeNull(); + + const second = await get( + `/extensions/v2/extensions?limit=3&cursor=${encodeURIComponent(firstBody.pagination.next_cursor!)}`, + {} + ); + expect(second.status).toBe(200); + expect(await second.json()).toMatchObject({ + result: [{ id: "😀" }], + pagination: { next_cursor: null, has_more: false } + }); + }); + + it("accepts the maximum limit and rejects values above it", async () => { + await seedCatalogue(["one"]); + expect( + (await get("/extensions/v2/extensions?limit=100", {})).status + ).toBe(200); + expect( + (await get("/extensions/v2/extensions?limit=101", {})).status + ).toBe(422); + }); + }); + + describe("GET /extensions/{id}", () => { + it("gets a single extension, case-insensitively", async () => { + await seedOwnedExtension(); + + 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; 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: [], + source: { type: "github", repo: "example/existing" }, + version: "1.0.0", + download_url: "https://e.com/d.zip" + }); + }); + + it("404s for an unknown extension", async () => { + const res = await get("/extensions/v2/extensions/no-such-extension", {}); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/test/services/extensions/v2/submissions.test.ts b/test/services/extensions/v2/submissions.test.ts new file mode 100644 index 0000000..6171da0 --- /dev/null +++ b/test/services/extensions/v2/submissions.test.ts @@ -0,0 +1,385 @@ +import { describe, it, expect, vi } from "vitest"; +import { + setupExtensionsV2Tests, + db, + authHeaders, + post, + get, + samplePayload, + seedDeveloper, + seedOwnedExtension +} from "./harness"; +import { + countSubmissions, + getSubmission, + listSubmissions +} from "./db-fixtures"; + +// Hoisted so no v2 suite can make a real GitHub call. harness.ts applies the +// default "not found" behaviour in beforeEach and documents why. +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); + +setupExtensionsV2Tests(); + +describe("Extensions API v2", () => { + describe("POST /submissions", () => { + it("requires auth", async () => { + const res = await post( + "/extensions/v2/submissions", + { + "Content-Type": "application/json" + }, + samplePayload() + ); + expect(res.status).toBe(401); + }); + + it("rejects an invalid payload", async () => { + const headers = await authHeaders("user-1"); + const res = await post("/extensions/v2/submissions", headers, { + developer: {}, + extension: {} + }); + expect(res.status).toBe(422); + const data = (await res.json()) as { error: { code: string } }; + expect(data.error.code).toBe("VALIDATION_ERROR"); + }); + + it("rejects the reserved extension id mine", async () => { + const payload = samplePayload({ extensionId: "mine" }); + const res = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + payload + ); + + expect(res.status).toBe(422); + expect(await countSubmissions(db)).toBe(0); + }); + + it("rejects profile fields (avatar_url/contact_email) on a submission's developer", async () => { + await seedDeveloper("new-developer", "user-1"); + const headers = await authHeaders("user-1"); + const payload = samplePayload(); + const res = await post("/extensions/v2/submissions", headers, { + ...payload, + developer: { + ...payload.developer, + avatar_url: "https://example.com/should-not-be-accepted.png" + } + }); + + expect(res.status).toBe(422); + expect(await countSubmissions(db)).toBe(0); + }); + + it("creates a pending submission for a brand-new extension under an existing developer", async () => { + await seedDeveloper("new-developer", "user-1"); + const headers = await authHeaders("user-1"); + const res = await post( + "/extensions/v2/submissions", + headers, + samplePayload() + ); + + expect(res.status).toBe(201); + const data = (await res.json()) as { + result: { id: string; status: string }; + }; + expect(data.result.status).toBe("pending"); + expect(await countSubmissions(db)).toBe(1); + + const stored = await getSubmission(db, data.result.id); + expect(stored?.extension_id).toBeNull(); + expect(stored?.submitted_by).toBe("user-1"); + }); + + it("rejects editing an extension not owned by the caller", async () => { + await seedOwnedExtension(); + const headers = await authHeaders("intruder"); + + const res = await post( + "/extensions/v2/submissions", + headers, + samplePayload({ + extensionId: "existing-ext", + developerId: "owner-developer" + }) + ); + + expect(res.status).toBe(403); + expect(await countSubmissions(db)).toBe(0); + }); + + it("allows editing an extension owned by the caller", async () => { + await seedOwnedExtension(); + const headers = await authHeaders("owner-1"); + + const res = await post( + "/extensions/v2/submissions", + headers, + samplePayload({ + extensionId: "existing-ext", + developerId: "owner-developer" + }) + ); + + expect(res.status).toBe(201); + const [stored] = await listSubmissions(db); + expect(stored.extension_id).toBe("existing-ext"); + }); + + it("rejects claiming a developer already owned by someone else", async () => { + await seedOwnedExtension(); + const headers = await authHeaders("intruder"); + + const res = await post( + "/extensions/v2/submissions", + headers, + samplePayload({ + extensionId: "another-new-ext", + developerId: "owner-developer" + }) + ); + + expect(res.status).toBe(403); + }); + + it("rejects naming a developer id that doesn't exist at all", async () => { + const headers = await authHeaders("user-1"); + + const res = await post( + "/extensions/v2/submissions", + headers, + samplePayload({ developerId: "no-such-developer" }) + ); + + expect(res.status).toBe(403); + expect(await countSubmissions(db)).toBe(0); + }); + + it("bounds payload size and the number of releases", async () => { + await seedDeveloper("new-developer", "user-1"); + const payload = samplePayload(); + const oversized = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + { + ...payload, + extension: { ...payload.extension, readme: "x".repeat(100_001) } + } + ); + expect(oversized.status).toBe(422); + + const unknownExtensionField = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + { + ...payload, + extension: { + ...payload.extension, + padding: "x" + } + } + ); + expect(unknownExtensionField.status).toBe(422); + const unknownExtensionBody = (await unknownExtensionField.json()) as { + error: { details: Array<{ code: string; path: PropertyKey[] }> }; + }; + expect(unknownExtensionBody.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + path: ["extension"] + }) + ]) + ); + + const unknownReleaseField = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + { + ...payload, + extension: { + ...payload.extension, + releases: [ + { + ...payload.extension.releases[0], + padding: "x" + } + ] + } + } + ); + expect(unknownReleaseField.status).toBe(422); + const unknownReleaseBody = (await unknownReleaseField.json()) as { + error: { details: Array<{ code: string; path: PropertyKey[] }> }; + }; + expect(unknownReleaseBody.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "unrecognized_keys", + path: ["extension", "releases", 0] + }) + ]) + ); + + const tooManyReleases = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + { + ...payload, + extension: { + ...payload.extension, + releases: Array.from( + { length: 101 }, + () => payload.extension.releases[0] + ) + } + } + ); + expect(tooManyReleases.status).toBe(422); + }); + + it("preserves compatibility with stored slug ids over 100 characters", async () => { + const developerId = "d".repeat(120); + const extensionId = "e".repeat(120); + await seedDeveloper(developerId, "user-1"); + + const res = await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + samplePayload({ developerId, extensionId }) + ); + + expect(res.status).toBe(201); + }); + + it("rejects duplicate pending targets and caps each user's backlog", async () => { + await seedDeveloper("new-developer", "user-1"); + const headers = await authHeaders("user-1"); + expect( + (await post("/extensions/v2/submissions", headers, samplePayload())) + .status + ).toBe(201); + expect( + (await post("/extensions/v2/submissions", headers, samplePayload())) + .status + ).toBe(409); + + await seedDeveloper("other-developer", "user-2"); + expect( + ( + await post( + "/extensions/v2/submissions", + await authHeaders("user-2"), + samplePayload({ developerId: "other-developer" }) + ) + ).status + ).toBe(409); + + for (let index = 1; index < 10; index++) { + const result = await post( + "/extensions/v2/submissions", + headers, + samplePayload({ extensionId: `new-ext-${index}` }) + ); + expect(result.status).toBe(201); + } + const overLimit = await post( + "/extensions/v2/submissions", + headers, + samplePayload({ extensionId: "new-ext-over-limit" }) + ); + expect(overLimit.status).toBe(409); + expect(await countSubmissions(db)).toBe(10); + }); + }); + + describe("GET /submissions/mine", () => { + it("returns only the caller's own submissions", async () => { + await seedDeveloper("developer-a", "user-1"); + await seedDeveloper("developer-b", "user-2"); + await post( + "/extensions/v2/submissions", + await authHeaders("user-1"), + samplePayload({ extensionId: "ext-a", developerId: "developer-a" }) + ); + await post( + "/extensions/v2/submissions", + await authHeaders("user-2"), + samplePayload({ extensionId: "ext-b", developerId: "developer-b" }) + ); + + const res = await get( + "/extensions/v2/submissions/mine", + await authHeaders("user-1") + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { + result: Array<{ submitted_by: string }>; + }; + expect(data.result).toHaveLength(1); + expect(data.result[0].submitted_by).toBe("user-1"); + }); + + it("requires auth", async () => { + const res = await get("/extensions/v2/submissions/mine", {}); + expect(res.status).toBe(401); + }); + + it("identifies invalid cursors", async () => { + const res = await get( + "/extensions/v2/submissions/mine?cursor=not-a-cursor", + await authHeaders("user-1") + ); + expect(res.status).toBe(422); + await expect(res.json()).resolves.toMatchObject({ + error: { code: "INVALID_CURSOR" } + }); + }); + + it("paginates deterministically with an opaque cursor", async () => { + await seedDeveloper("new-developer", "user-1"); + const headers = await authHeaders("user-1"); + for (const extensionId of ["page-a", "page-b", "page-c"]) { + expect( + ( + await post( + "/extensions/v2/submissions", + headers, + samplePayload({ extensionId }) + ) + ).status + ).toBe(201); + } + + const first = await get( + "/extensions/v2/submissions/mine?limit=2", + headers + ); + const firstBody = (await first.json()) as { + result: unknown[]; + pagination: { has_more: boolean; next_cursor: string }; + }; + expect(firstBody.result).toHaveLength(2); + expect(firstBody.pagination.has_more).toBe(true); + + const second = await get( + `/extensions/v2/submissions/mine?limit=2&cursor=${encodeURIComponent(firstBody.pagination.next_cursor)}`, + headers + ); + const secondBody = (await second.json()) as { + result: unknown[]; + pagination: { has_more: boolean; next_cursor: null }; + }; + expect(secondBody.result).toHaveLength(1); + expect(secondBody.pagination).toEqual({ + has_more: false, + next_cursor: null + }); + }); + }); +});