diff --git a/packages/credentagent-gate/src/index.ts b/packages/credentagent-gate/src/index.ts index 8593617..4678bf3 100644 --- a/packages/credentagent-gate/src/index.ts +++ b/packages/credentagent-gate/src/index.ts @@ -26,7 +26,7 @@ export { MemoryVerificationStore } from "./store.js"; // `await credentagent.orders.create({ order, policy })` → { id, approveUrl, manifest }; // `credentagent.orders.retrieve(id)` → the door (ok | pending+approveUrl | reason). export { Orders, MemoryOrderStore } from "./orders.js"; -export type { OrderStore, CreatedOrder, CompletedOrder, OrderDoor } from "./orders.js"; +export type { OrderStore, CreatedOrder, CompletedOrder, OrderDoor, OrderDoorCode } from "./orders.js"; // ── Webhooks (spec 010) — the REAL HTTP completion signal ─────────────────── // SEND: `new CredentAgent({ webhooks: { endpoints: [{ url, secret }] } })` → every settled order diff --git a/packages/credentagent-gate/src/orders.test.ts b/packages/credentagent-gate/src/orders.test.ts index 431335e..b955485 100644 --- a/packages/credentagent-gate/src/orders.test.ts +++ b/packages/credentagent-gate/src/orders.test.ts @@ -81,8 +81,11 @@ describe("credentagent.orders", () => { expect(seen).toEqual([id]); }); - it("retrieve of an unknown id is a typed refusal, not a throw", async () => { + it("retrieve of an unknown id is a typed refusal (code: OrderDoorCode), not a throw", async () => { const ca = new CredentAgent({ walletOrigin: "https://shop.example" }); + // Runtime: the refusal carries the union's only current member. The COMPILE-TIME guard that + // `code` is the typed OrderDoorCode (not `string`) lives in types.test-d.ts — the one file + // `tsc -p tsconfig.test.json` type-checks (this .test.ts is transpiled without type-checking). expect(await ca.orders.retrieve("ord_nope")).toMatchObject({ ok: false, code: "not-found" }); }); }); diff --git a/packages/credentagent-gate/src/orders.ts b/packages/credentagent-gate/src/orders.ts index f453b05..ddf81fb 100644 --- a/packages/credentagent-gate/src/orders.ts +++ b/packages/credentagent-gate/src/orders.ts @@ -47,11 +47,23 @@ export class MemoryOrderStore implements OrderStore { clear(id: string): void { this.m.delete(id); } } +/** + * The refusal codes the door's `{ ok:false, code }` branch can carry — a TYPED union, not + * `string`, so `res.code === "not-found"` autocompletes, a typo fails to compile, and a + * `switch` on it is exhaustiveness-checked (#95 review — a bare string is a footgun). + * + * Today `orders.retrieve` reads stores and only refuses with `"not-found"`; it does not surface + * policy failures — an age/payment gate refuses at the ceremony, not here. Widen this union + * DELIBERATELY when a new orders refusal path lands; the compile error at each `switch` is the + * reminder to handle it. (Sibling of the delegated `RefusalCode` and the webhook `WebhookRefusalCode`.) + */ +export type OrderDoorCode = "not-found"; + /** The one result shape every consent path shares (spec 009 FR-003). */ export type OrderDoor = | { ok: true; mandateBundle?: unknown; authorization: "direct"; trustLevel: TrustLevel; completion: Omit } | { ok: false; pending: true; approveUrl: string; trustLevel: TrustLevel } - | { ok: false; code: string; credential?: string; trustLevel: TrustLevel }; + | { ok: false; code: OrderDoorCode; credential?: string; trustLevel: TrustLevel }; export interface OrdersDeps { walletOrigin: string; diff --git a/packages/credentagent-gate/src/types.test-d.ts b/packages/credentagent-gate/src/types.test-d.ts index affe628..8ea642b 100644 --- a/packages/credentagent-gate/src/types.test-d.ts +++ b/packages/credentagent-gate/src/types.test-d.ts @@ -6,6 +6,7 @@ // build fails. It is type-only; nothing runs at test time. import { age, payment, membership } from "./credentials.js"; +import type { OrderDoor } from "./orders.js"; // age has only `.over()` (+ `.when()`), never `.in()`: // @ts-expect-error — age.over(...) returns a Credential with no `.in` @@ -23,3 +24,12 @@ membership.discount(10).over(21); age.over(21).when((o) => o.lines.length > 0); payment.in("usd").when(() => true); membership.discount(10).when(() => true); + +// OrderDoor's refusal `code` is the typed union OrderDoorCode, NOT a bare `string` (#95 review) — +// so a non-member literal must be a COMPILE error. This is the guard in the right place (#111, +// Codex): if `code` regresses to `string`, the suppression below goes UNUSED and this build fails. +type DoorRefusalCode = Extract["code"]; +declare function takesOrderDoorCode(c: DoorRefusalCode): void; +takesOrderDoorCode("not-found"); // the real member — compiles +// @ts-expect-error — "budget-exceded" is not an OrderDoorCode (a typo can't silently pass as `string`) +takesOrderDoorCode("budget-exceded");