Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .claude/guides/07-docs-package.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 6 additions & 5 deletions .claude/guides/08-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 24 additions & 6 deletions knowledge/_claude-context/context.md
Original file line number Diff line number Diff line change
@@ -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`

Expand Down Expand Up @@ -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.
Expand All @@ -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.

---

Expand Down
28 changes: 23 additions & 5 deletions knowledge/internal/06-git-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

---

Expand Down
6 changes: 6 additions & 0 deletions packages/kit/server/api/v1/in-flight-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const sharedVerifyState: InFlightState = {

type InFlightLimitVars = {
apiKeyHash?: string;
verifyCapacityRejected?: boolean;
};

/**
Expand Down Expand Up @@ -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: [
Expand Down
122 changes: 122 additions & 0 deletions packages/kit/server/api/v1/replay-guard.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ReplayBucket>,
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<string, ReplayBucket>();
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<string, ReplayBucket>();
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<string, ReplayBucket>();
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" }],
});
});
});
38 changes: 29 additions & 9 deletions packages/kit/server/api/v1/replay-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ const sharedStore = new Map<string, ReplayBucket>();
type ReplayGuardVars = {
apiKeyHash?: string;
verifyOutcome?: { isValid: boolean; state: string };
verifyCapacityRejected?: boolean;
};

export function replayGuardMiddleware(
Expand All @@ -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;

Expand Down Expand Up @@ -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);
}
}
}
});
Expand Down
5 changes: 4 additions & 1 deletion packages/kit/server/api/v1/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion packages/kit/src/pages/docs/sections/operations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ export default function OperationsPage() {
work is not queued in memory; it returns <code>503 SERVICE_BUSY</code>{" "}
with <code>Retry-After</code>, <code>X-Concurrency-Limit</code>,{" "}
<code>X-Concurrency-Remaining</code>, and{" "}
<code>X-Concurrency-Scope</code>.
<code>X-Concurrency-Scope</code>. Capacity-rejected attempts never reach
an upstream verification store and do not consume the per-payload replay
budget, so repeated backoff retries remain <code>SERVICE_BUSY</code>{" "}
instead of turning into <code>DUPLICATE_PAYLOAD</code>.
</p>
<p>
Self-hosters can tune <code>VERIFY_MAX_IN_FLIGHT</code>,{" "}
Expand Down