From c4ccd8a841ac5964128ed9f5f7e0af84b9888447 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 2 Aug 2026 20:40:39 +0900
Subject: [PATCH 1/2] fix(kit): refund rejected replay attempts
Refund the per-payload replay token only when the in-flight capacity guard returns SERVICE_BUSY before verification starts. Preserve charges for accepted work, including downstream 503 responses, and cover the behavior with integration tests.
Record that routine production docs deploys are manual and do not create a Docs GitHub Release.
---
.claude/guides/07-docs-package.md | 12 +-
.claude/guides/08-deployment.md | 11 +-
knowledge/_claude-context/context.md | 30 ++++-
knowledge/internal/06-git-deployment.md | 28 +++-
packages/kit/server/api/v1/in-flight-limit.ts | 6 +
.../api/v1/replay-guard.integration.test.ts | 122 ++++++++++++++++++
packages/kit/server/api/v1/replay-guard.ts | 38 ++++--
packages/kit/server/api/v1/routes.ts | 5 +-
.../src/pages/docs/sections/operations.tsx | 5 +-
9 files changed, 226 insertions(+), 31 deletions(-)
create mode 100644 packages/kit/server/api/v1/replay-guard.integration.test.ts
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..cbd04de01 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
+ a 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,{" "}
From 23d97ed4e8004a9a98f148eab56c61c85cc41ba7 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Sun, 2 Aug 2026 20:47:54 +0900
Subject: [PATCH 2/2] docs(kit): clarify verification store scope
---
packages/kit/src/pages/docs/sections/operations.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/kit/src/pages/docs/sections/operations.tsx b/packages/kit/src/pages/docs/sections/operations.tsx
index cbd04de01..ad084198c 100644
--- a/packages/kit/src/pages/docs/sections/operations.tsx
+++ b/packages/kit/src/pages/docs/sections/operations.tsx
@@ -57,9 +57,9 @@ export default function OperationsPage() {
with Retry-After, X-Concurrency-Limit,{" "}
X-Concurrency-Remaining, and{" "}
X-Concurrency-Scope. Capacity-rejected attempts never reach
- a store and do not consume the per-payload replay budget, so repeated
- backoff retries remain SERVICE_BUSY instead of turning into
- DUPLICATE_PAYLOAD.
+ 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,{" "}