diff --git a/.claude/guides/07-docs-package.md b/.claude/guides/07-docs-package.md index 9665ef593..f9972df5f 100644 --- a/.claude/guides/07-docs-package.md +++ b/.claude/guides/07-docs-package.md @@ -50,7 +50,11 @@ up-to-date `main` checkout at the repository root: npm run deploy ``` -Then create the matching Docs GitHub Release as documented in -`.claude/commands/release.md`. Branch guards, version ownership, and the full -deployment contract live in `knowledge/internal/06-git-deployment.md`; do not -duplicate them here. +Merging to `main` does not publish docs — there is no docs deploy workflow, so +this local command is the only path to production. + +A routine docs deployment stops there. Do not create a Docs GitHub Release for +it; that step belongs to a spec release, as documented in +`.claude/commands/release.md`. Branch guards, version ownership, deploy +verification, and the full deployment contract live in +`knowledge/internal/06-git-deployment.md`; do not duplicate them here. diff --git a/.claude/guides/08-deployment.md b/.claude/guides/08-deployment.md index f4ec7e1a3..cc2b8da42 100644 --- a/.claude/guides/08-deployment.md +++ b/.claude/guides/08-deployment.md @@ -13,11 +13,12 @@ This file is a route map, not a second deployment specification. ## Deployment surfaces -| Surface | Canonical entrypoint | -| -------------------------------------- | ------------------------------------------------------------------- | -| Apple, Google, and framework libraries | Sequential stable workflows listed in `.claude/commands/release.md` | -| Production docs and spec release | Root `npm run deploy`, then `release.yml` with `version=current` | -| IAPKit | `.github/workflows/deploy-kit.yml` on relevant pushes to `main` | +| Surface | Canonical entrypoint | +| -------------------------------------- | ---------------------------------------------------------------------------- | +| Apple, Google, and framework libraries | Sequential stable workflows listed in `.claude/commands/release.md` | +| Production docs (routine) | Root `npm run deploy` only — no GitHub Release, and never automatic on merge | +| Spec release | Root `npm run deploy`, then `release.yml` with `version=current` | +| IAPKit | `.github/workflows/deploy-kit.yml` on relevant pushes to `main` | For the rare IAPKit manual fallback, follow the Convex-first sequence in `packages/kit/README.md#deployment-convex--flyio`. IAPKit has its own Convex diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index 7230a21ec..e81fee9c5 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-08-01T08:01:07.836Z +> Last updated: 2026-08-02T11:31:06.959Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -1958,6 +1958,16 @@ node --test scripts/release-branch-policy.test.mjs ### Deploying Documentation +**Merging to `main` does not publish documentation.** No workflow deploys the +production docs on merge; `deploy-kit.yml` auto-deploys IAPKit instead. +Production docs go out only when a human runs the local deploy below. + +This matters most for a PR that changes both `packages/kit/` and +`packages/docs/`: the kit server auto-deploys from `main` while the docs half +stays on the previously deployed build. Server behavior can therefore go live +while the documentation describing it is still unpublished. After merging such a +PR, deploy the docs and verify both surfaces. + Production documentation is stable-only and must deploy from a clean `main` checkout that exactly matches `origin/main`. The script rejects prerelease spec versions, other branches, and stale or unpublished local snapshots. @@ -1973,16 +1983,24 @@ This will: 2. Typecheck and build the docs site 3. Deploy production documentation to Vercel -It does **not** trigger the Docs GitHub Release. After the Vercel deployment is -verified, run the stable Docs workflow without another version bump: +`npm run deploy` uses the current native-derived `spec` value from +`openiap-versions.json`. It rejects any explicit argument that differs from the +native floor; docs deployment is not a version-bump path. + +**Routine docs deployments stop here.** Do not follow them with a Docs GitHub +Release: the spec version has not moved, so the release would carry no new +version information and only adds tag churn. Run the stable Docs workflow only +when the spec version itself is being released, or when the maintainer asks for +it explicitly: ```bash gh workflow run release.yml --ref main -f version=current ``` -`npm run deploy` uses the current native-derived `spec` value from -`openiap-versions.json`. It rejects any explicit argument that differs from the -native floor; docs deployment is not a version-bump path. +Verifying a docs deployment: `llms-full.txt` carries a `Generated:` timestamp +that must match the committed file, and the deployed entry bundle should contain +any newly added page copy. A stale timestamp under a cache-busting query string +means the deploy has not landed, not that a CDN is caching. --- diff --git a/knowledge/internal/06-git-deployment.md b/knowledge/internal/06-git-deployment.md index d1e63e80e..43abe8df2 100644 --- a/knowledge/internal/06-git-deployment.md +++ b/knowledge/internal/06-git-deployment.md @@ -216,6 +216,16 @@ node --test scripts/release-branch-policy.test.mjs ### Deploying Documentation +**Merging to `main` does not publish documentation.** No workflow deploys the +production docs on merge; `deploy-kit.yml` auto-deploys IAPKit instead. +Production docs go out only when a human runs the local deploy below. + +This matters most for a PR that changes both `packages/kit/` and +`packages/docs/`: the kit server auto-deploys from `main` while the docs half +stays on the previously deployed build. Server behavior can therefore go live +while the documentation describing it is still unpublished. After merging such a +PR, deploy the docs and verify both surfaces. + Production documentation is stable-only and must deploy from a clean `main` checkout that exactly matches `origin/main`. The script rejects prerelease spec versions, other branches, and stale or unpublished local snapshots. @@ -231,16 +241,24 @@ This will: 2. Typecheck and build the docs site 3. Deploy production documentation to Vercel -It does **not** trigger the Docs GitHub Release. After the Vercel deployment is -verified, run the stable Docs workflow without another version bump: +`npm run deploy` uses the current native-derived `spec` value from +`openiap-versions.json`. It rejects any explicit argument that differs from the +native floor; docs deployment is not a version-bump path. + +**Routine docs deployments stop here.** Do not follow them with a Docs GitHub +Release: the spec version has not moved, so the release would carry no new +version information and only adds tag churn. Run the stable Docs workflow only +when the spec version itself is being released, or when the maintainer asks for +it explicitly: ```bash gh workflow run release.yml --ref main -f version=current ``` -`npm run deploy` uses the current native-derived `spec` value from -`openiap-versions.json`. It rejects any explicit argument that differs from the -native floor; docs deployment is not a version-bump path. +Verifying a docs deployment: `llms-full.txt` carries a `Generated:` timestamp +that must match the committed file, and the deployed entry bundle should contain +any newly added page copy. A stale timestamp under a cache-busting query string +means the deploy has not landed, not that a CDN is caching. --- diff --git a/packages/kit/server/api/v1/in-flight-limit.ts b/packages/kit/server/api/v1/in-flight-limit.ts index 17a16e076..a45dad699 100644 --- a/packages/kit/server/api/v1/in-flight-limit.ts +++ b/packages/kit/server/api/v1/in-flight-limit.ts @@ -45,6 +45,7 @@ const sharedVerifyState: InFlightState = { type InFlightLimitVars = { apiKeyHash?: string; + verifyCapacityRejected?: boolean; }; /** @@ -132,6 +133,11 @@ export function inFlightLimitMiddleware( c.header("X-Concurrency-Limit", String(limit)); c.header("X-Concurrency-Remaining", "0"); c.header("X-Concurrency-Scope", scope); + // The replay guard runs before this middleware so cheap duplicate + // detection still happens before capacity accounting. Tell that outer + // guard that this request never received a verification slot, allowing + // it to refund the token consumed for this attempt. + c.set("verifyCapacityRejected", true); return c.json( { errors: [ diff --git a/packages/kit/server/api/v1/replay-guard.integration.test.ts b/packages/kit/server/api/v1/replay-guard.integration.test.ts new file mode 100644 index 000000000..632ff8a4a --- /dev/null +++ b/packages/kit/server/api/v1/replay-guard.integration.test.ts @@ -0,0 +1,122 @@ +import { Hono } from "hono"; +import { describe, expect, test } from "vitest"; + +import { inFlightLimitMiddleware, type InFlightState } from "./in-flight-limit"; +import { replayGuardMiddleware, type ReplayBucket } from "./replay-guard"; +import { verifyPurchaseInputSchema } from "./route-input-schemas"; +import { validator } from "./validator"; + +interface TestVariables { + apiKeyHash: string; + verifyCapacityRejected?: boolean; +} + +const verifyBody = { + store: "apple", + jws: `${"a".repeat(40)}.${"b".repeat(40)}.${"c".repeat(40)}`, +} as const; + +function createApp( + store: Map, + state: InFlightState, + handlerStatus: 200 | 503 = 200, +): Hono<{ Variables: TestVariables }> { + const app = new Hono<{ Variables: TestVariables }>(); + app.use("*", async (c, next) => { + c.set("apiKeyHash", "test-key"); + await next(); + }); + app.post( + "/verify", + validator(verifyPurchaseInputSchema), + replayGuardMiddleware({ + capacity: 1, + refillPerSecond: 1 / 3_600, + maxStoreSize: 100, + failureCooldownMs: 60_000, + now: () => 1_000, + store, + }), + inFlightLimitMiddleware({ + maxInFlight: 1, + maxInFlightPerKey: 1, + maxInFlightPerIp: 1, + state, + getIp: () => "203.0.113.1", + }), + (c) => + handlerStatus === 200 + ? c.json({ ok: true }, 200) + : c.json({ errors: [{ code: "UPSTREAM_UNAVAILABLE" }] }, 503), + ); + return app; +} + +function verifyRequest(): RequestInit { + return { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(verifyBody), + }; +} + +describe("replay guard with verification capacity", () => { + test("refunds attempts rejected with SERVICE_BUSY", async () => { + const store = new Map(); + const state: InFlightState = { + active: 1, + byKey: new Map(), + byIp: new Map(), + }; + const app = createApp(store, state); + + const first = await app.request("/verify", verifyRequest()); + const second = await app.request("/verify", verifyRequest()); + + expect(first.status).toBe(503); + expect(second.status).toBe(503); + expect(await second.json()).toMatchObject({ + errors: [{ code: "SERVICE_BUSY" }], + }); + expect([...store.values()]).toHaveLength(1); + expect([...store.values()][0]?.tokens).toBe(1); + }); + + test("keeps the replay charge after successful verification", async () => { + const store = new Map(); + const state: InFlightState = { + active: 0, + byKey: new Map(), + byIp: new Map(), + }; + const app = createApp(store, state); + + const first = await app.request("/verify", verifyRequest()); + const second = await app.request("/verify", verifyRequest()); + + expect(first.status).toBe(200); + expect(second.status).toBe(429); + expect(await second.json()).toMatchObject({ + errors: [{ code: "DUPLICATE_PAYLOAD" }], + }); + }); + + test("does not refund a downstream 503 after capacity was accepted", async () => { + const store = new Map(); + const state: InFlightState = { + active: 0, + byKey: new Map(), + byIp: new Map(), + }; + const app = createApp(store, state, 503); + + const first = await app.request("/verify", verifyRequest()); + const second = await app.request("/verify", verifyRequest()); + + expect(first.status).toBe(503); + expect(second.status).toBe(429); + expect(await second.json()).toMatchObject({ + errors: [{ code: "DUPLICATE_PAYLOAD" }], + }); + }); +}); diff --git a/packages/kit/server/api/v1/replay-guard.ts b/packages/kit/server/api/v1/replay-guard.ts index b8353dbe5..e4554a67a 100644 --- a/packages/kit/server/api/v1/replay-guard.ts +++ b/packages/kit/server/api/v1/replay-guard.ts @@ -259,6 +259,7 @@ const sharedStore = new Map(); type ReplayGuardVars = { apiKeyHash?: string; verifyOutcome?: { isValid: boolean; state: string }; + verifyCapacityRejected?: boolean; }; export function replayGuardMiddleware( @@ -272,6 +273,18 @@ export function replayGuardMiddleware( const store = config.store ?? sharedStore; const clock = config.now ?? (() => Date.now()); + function refundCapacityRejectedAttempt(bucketKey: string): void { + const bucket = store.get(bucketKey); + if (!bucket) { + // LRU churn can evict an in-flight request's bucket. Its absence already + // gives the next request a fresh bucket, so recreating it is unnecessary. + return; + } + store.delete(bucketKey); + bucket.tokens = Math.min(capacity, bucket.tokens + 1); + store.set(bucketKey, bucket); + } + return createMiddleware<{ Variables: ReplayGuardVars }>(async (c, next) => { const apiKeyHash = c.var.apiKeyHash; @@ -340,15 +353,22 @@ export function replayGuardMiddleware( try { await next(); } finally { - // After the handler completes, mark the bucket if the upstream - // verification returned invalid. Lives in `finally` so an exception - // bubbling out of the handler doesn't skip the marking step — - // we only mark on the explicit `isValid: false` signal so - // configuration / network errors aren't conflated with stable - // receipt or product-match failures. - const outcome = c.get("verifyOutcome"); - if (outcome && outcome.isValid === false) { - markPayloadFailure(store, bucketKey, capacity, clock(), maxStoreSize); + if (c.get("verifyCapacityRejected") === true) { + // SERVICE_BUSY is emitted before the handler or upstream store runs. + // Charging it would turn legitimate backoff retries into a misleading + // DUPLICATE_PAYLOAD response after enough capacity rejections. + refundCapacityRejectedAttempt(bucketKey); + } else { + // After the handler completes, mark the bucket if the upstream + // verification returned invalid. Lives in `finally` so an exception + // bubbling out of the handler doesn't skip the marking step — + // we only mark on the explicit `isValid: false` signal so + // configuration / network errors aren't conflated with stable + // receipt or product-match failures. + const outcome = c.get("verifyOutcome"); + if (outcome && outcome.isValid === false) { + markPayloadFailure(store, bucketKey, capacity, clock(), maxStoreSize); + } } } }); diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index ef2e81b94..9e3c3e14c 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -37,6 +37,8 @@ type V1AppVariables = { apiKeyHash: string; // request-logger middleware corrId: string; + // in-flight limit → replay guard + verifyCapacityRejected?: boolean; // verify-purchase handler → request-logger verifyOutcome: { isValid: boolean; state: string }; }; @@ -540,7 +542,8 @@ const verifyInFlightLimit = inFlightLimitMiddleware(); // 5. verifyReplayGuard — per-(key, payload) burst cap + 5-minute // negative cooldown after an `isValid: false` from the store. // 6. verifyInFlightLimit — bounds accepted verification work already -// waiting on Convex or an upstream store. Rejects instead of queueing. +// waiting on Convex or an upstream store. Rejects instead of queueing and +// tells the replay guard to refund attempts that never received a slot. // 7. verifyPurchaseHandler — the actual Convex call. The verify // action increments the per-org monthly counter for telemetry // (powers the dashboard usage view + sponsor CTA threshold) diff --git a/packages/kit/src/pages/docs/sections/operations.tsx b/packages/kit/src/pages/docs/sections/operations.tsx index 758585ced..ad084198c 100644 --- a/packages/kit/src/pages/docs/sections/operations.tsx +++ b/packages/kit/src/pages/docs/sections/operations.tsx @@ -56,7 +56,10 @@ export default function OperationsPage() { work is not queued in memory; it returns 503 SERVICE_BUSY{" "} with Retry-After, X-Concurrency-Limit,{" "} X-Concurrency-Remaining, and{" "} - X-Concurrency-Scope. + X-Concurrency-Scope. Capacity-rejected attempts never reach + an upstream verification store and do not consume the per-payload replay + budget, so repeated backoff retries remain SERVICE_BUSY{" "} + instead of turning into DUPLICATE_PAYLOAD.

Self-hosters can tune VERIFY_MAX_IN_FLIGHT,{" "}