@apifuse/provider-sdk 2.2.0-beta.35 → 2.2.0-beta.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.36
4
+
5
+ - Release candidate for main commit c6858f8b87d78b3b755adeb28982e875434be406.
6
+
3
7
  ## 2.2.0-beta.35
4
8
 
5
9
  - Release candidate for main commit 027fa0087b883a6281bc6d8bdaaf6d0be382000f.
@@ -1,8 +1,16 @@
1
+ import type { AuthStartNoInputGuard } from "../define.js";
1
2
  import type { AuthFlowDefinition, AuthTurn } from "../types.js";
2
3
  type JsonObject = Record<string, unknown>;
3
4
  export declare const OAUTH2_PROXIED_AUTH_PROXY_ORIGIN_ENV_KEY = "APIFUSE__AUTH_PROXY__URL";
4
5
  export declare const OAUTH2_PROXIED_PKCE_VERIFIER_KEY = "__oauth2_proxied_pkce_verifier";
5
6
  export declare function validateCeremonyOutput(turn: unknown): AuthTurn;
7
+ /**
8
+ * Defines an auth flow while preserving its concrete type for downstream checks.
9
+ * The compile-time guard validates the inferred literal; annotating or widening
10
+ * a flow to `AuthFlowDefinition` before passing it defeats the check. Full
11
+ * enforcement requires branding, which is deferred to a future major.
12
+ */
13
+ export declare function defineAuthFlow<const TFlow extends AuthFlowDefinition>(flow: TFlow & AuthStartNoInputGuard<TFlow>): TFlow;
6
14
  export declare function createOAuth2Ceremony(options: {
7
15
  authorizeUrl: string;
8
16
  tokenUrl: string;
@@ -163,8 +163,17 @@ export function validateCeremonyOutput(turn) {
163
163
  }
164
164
  return turn;
165
165
  }
166
+ /**
167
+ * Defines an auth flow while preserving its concrete type for downstream checks.
168
+ * The compile-time guard validates the inferred literal; annotating or widening
169
+ * a flow to `AuthFlowDefinition` before passing it defeats the check. Full
170
+ * enforcement requires branding, which is deferred to a future major.
171
+ */
172
+ export function defineAuthFlow(flow) {
173
+ return flow;
174
+ }
166
175
  export function createOAuth2Ceremony(options) {
167
- return {
176
+ return defineAuthFlow({
168
177
  start: (ctx) => runCeremonyHandler(async () => {
169
178
  const clientId = getRequiredEnv(ctx, options.clientIdEnvKey);
170
179
  getRequiredEnv(ctx, options.clientSecretEnvKey);
@@ -221,7 +230,7 @@ export function createOAuth2Ceremony(options) {
221
230
  });
222
231
  }, "OAuth token exchange failed", ctx, input),
223
232
  abort: async () => validateCeremonyOutput(createTurn("abort", { hint: "OAuth flow aborted." })),
224
- };
233
+ });
225
234
  }
226
235
  /**
227
236
  * Builds the start handler for a custom-scheme OAuth provider. The shared
@@ -272,7 +281,7 @@ export function createOAuth2ProxiedStart(options) {
272
281
  }, "OAuth2 proxied start failed", ctx);
273
282
  }
274
283
  export function createDeviceFlowCeremony(options) {
275
- return {
284
+ return defineAuthFlow({
276
285
  start: (ctx) => runCeremonyHandler(async () => {
277
286
  const response = await ctx.http.post(options.deviceCodeUrl, {
278
287
  client_id: getRequiredEnv(ctx, options.clientIdEnvKey),
@@ -330,10 +339,10 @@ export function createDeviceFlowCeremony(options) {
330
339
  });
331
340
  }, "Device flow polling failed", ctx),
332
341
  abort: async () => validateCeremonyOutput(createTurn("abort", { hint: "Device flow aborted." })),
333
- };
342
+ });
334
343
  }
335
344
  export function createWebAuthnCeremony(options) {
336
- return {
345
+ return defineAuthFlow({
337
346
  start: (ctx) => runCeremonyHandler(async () => {
338
347
  const challenge = toBase64Url(randomBytes(32));
339
348
  ctx.context.set("__webauthn_challenge", challenge);
@@ -375,21 +384,23 @@ export function createWebAuthnCeremony(options) {
375
384
  });
376
385
  }, "WebAuthn verification failed", ctx, input),
377
386
  abort: async () => validateCeremonyOutput(createTurn("abort", { hint: "WebAuthn ceremony aborted." })),
378
- };
387
+ });
379
388
  }
380
389
  export function createMagicLinkCeremony(options) {
381
390
  const emailField = options.emailField ?? "email";
382
- return {
383
- start: (ctx, input = {}) => runCeremonyHandler(async () => {
391
+ const buildEmailForm = () => buildJsonSchemaForm({
392
+ type: "object",
393
+ required: [emailField],
394
+ properties: {
395
+ [emailField]: { type: "string", format: "email" },
396
+ },
397
+ }, "Provide the email address to receive a magic link.");
398
+ return defineAuthFlow({
399
+ start: (ctx) => runCeremonyHandler(async () => buildEmailForm(), "Magic link start failed", ctx),
400
+ continue: (ctx, input = {}) => runCeremonyHandler(async () => {
384
401
  const email = getString(input, emailField);
385
402
  if (!email) {
386
- return buildJsonSchemaForm({
387
- type: "object",
388
- required: [emailField],
389
- properties: {
390
- [emailField]: { type: "string", format: "email" },
391
- },
392
- }, "Provide the email address to receive a magic link.");
403
+ return buildEmailForm();
393
404
  }
394
405
  await ctx.http.post(options.sendUrl, { email });
395
406
  ctx.context.set(MAGIC_LINK_KEY, {
@@ -401,11 +412,7 @@ export function createMagicLinkCeremony(options) {
401
412
  hint: "Check your email for the magic link, then poll for completion.",
402
413
  timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 300_000 },
403
414
  });
404
- }, "Magic link start failed", ctx, input),
405
- continue: async () => validateCeremonyOutput(createTurn("poll", {
406
- hint: "Continue polling for magic link completion.",
407
- timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 300_000 },
408
- })),
415
+ }, "Magic link continuation failed", ctx, input),
409
416
  poll: (ctx) => runCeremonyHandler(async () => {
410
417
  const state = getNestedRecord(ctx, MAGIC_LINK_KEY);
411
418
  const email = getString(state, "email");
@@ -431,10 +438,10 @@ export function createMagicLinkCeremony(options) {
431
438
  });
432
439
  }, "Magic link polling failed", ctx),
433
440
  abort: async () => validateCeremonyOutput(createTurn("abort", { hint: "Magic link flow aborted." })),
434
- };
441
+ });
435
442
  }
436
443
  export function createFormCeremony(options) {
437
- return {
444
+ return defineAuthFlow({
438
445
  start: async () => validateCeremonyOutput(buildJsonSchemaForm(options.schema, options.hint ?? "Provide the required input to continue.")),
439
446
  continue: (ctx, input = {}) => runCeremonyHandler(async () => {
440
447
  const { prevalidate } = await import("../runtime/prevalidate.js");
@@ -452,7 +459,7 @@ export function createFormCeremony(options) {
452
459
  });
453
460
  }, "Form submission failed", ctx, input),
454
461
  abort: async () => validateCeremonyOutput(createTurn("abort", { hint: "Form ceremony aborted." })),
455
- };
462
+ });
456
463
  }
457
464
  export function combineCeremonies(...ceremonies) {
458
465
  function getStage(ctx) {
@@ -504,7 +511,7 @@ export function combineCeremonies(...ceremonies) {
504
511
  }
505
512
  export function createSwitchCeremony(options) {
506
513
  const choiceKeys = Object.keys(options.choices);
507
- return {
514
+ return defineAuthFlow({
508
515
  start: async () => validateCeremonyOutput(createTurn("multi_choice", {
509
516
  data: { choices: choiceKeys },
510
517
  hint: options.prompt ?? "Choose an authentication method.",
@@ -550,5 +557,5 @@ export function createSwitchCeremony(options) {
550
557
  }
551
558
  return createTurn("abort", { hint: "Switch ceremony aborted." });
552
559
  }, "Switch ceremony abort failed", ctx),
553
- };
560
+ });
554
561
  }
@@ -1,6 +1,6 @@
1
1
  FROM oven/bun:1.2-alpine
2
2
  WORKDIR /provider
3
- COPY package.json bun.lockb* ./
3
+ COPY package.json bun.lock ./
4
4
  RUN bun install --frozen-lockfile
5
5
  COPY . .
6
6
  EXPOSE 3000
package/dist/define.d.ts CHANGED
@@ -30,15 +30,18 @@ type WebSocketOperationConfig<TInput extends SchemaLike, TOutput extends SchemaL
30
30
  transport: OperationWebSocketTransport;
31
31
  handler(ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0], input: InferSchemaOutput<TInput>): Response | ReadableStream<Uint8Array> | Promise<Response | ReadableStream<Uint8Array>>;
32
32
  };
33
- type AuthStartNoInputGuard<TConfig> = TConfig extends {
33
+ type AuthStartHandlerNoInputGuard<TStart> = TStart extends (...args: infer TArgs) => unknown ? TArgs["length"] extends 0 | 1 ? unknown : {
34
+ "auth start handlers must not declare input parameters; return a form turn from start and receive user input in continue": never;
35
+ } : unknown;
36
+ export type AuthStartNoInputGuard<TConfig> = TConfig extends {
34
37
  auth?: {
35
38
  flow?: {
36
39
  start: infer TStart;
37
40
  };
38
41
  };
39
- } ? TStart extends (...args: infer TArgs) => unknown ? TArgs extends [unknown] ? unknown : {
40
- "auth start handlers must not declare input parameters; return a form turn from start and receive user input in continue": never;
41
- } : unknown : unknown;
42
+ } ? AuthStartHandlerNoInputGuard<TStart> : TConfig extends {
43
+ start: infer TStart;
44
+ } ? AuthStartHandlerNoInputGuard<TStart> : unknown;
42
45
  export interface ProviderConfig<TOperations extends Record<string, ProviderOperation>> {
43
46
  id: string;
44
47
  version: string;
package/dist/define.js CHANGED
@@ -109,6 +109,346 @@ function parsePositiveMsDuration(value) {
109
109
  return undefined;
110
110
  return parsed;
111
111
  }
112
+ function splitAuthStartParameters(parameters) {
113
+ const parts = [];
114
+ let start = 0;
115
+ let round = 0;
116
+ let square = 0;
117
+ let curly = 0;
118
+ let quote;
119
+ let escaped = false;
120
+ let lineComment = false;
121
+ let blockComment = false;
122
+ const templateDepths = [0];
123
+ templateDepths.length = 0;
124
+ for (let index = 0; index < parameters.length; index++) {
125
+ const character = parameters[index];
126
+ const nextCharacter = parameters[index + 1];
127
+ if (lineComment) {
128
+ if (character === "\n" || character === "\r")
129
+ lineComment = false;
130
+ else
131
+ continue;
132
+ }
133
+ if (blockComment) {
134
+ if (character === "*" && nextCharacter === "/") {
135
+ blockComment = false;
136
+ index++;
137
+ }
138
+ continue;
139
+ }
140
+ if (quote) {
141
+ if (escaped) {
142
+ escaped = false;
143
+ }
144
+ else if (character === "\\") {
145
+ escaped = true;
146
+ }
147
+ else if (quote === "`" && character === "$" && nextCharacter === "{") {
148
+ curly++;
149
+ templateDepths.push(curly);
150
+ quote = undefined;
151
+ index++;
152
+ }
153
+ else if (character === quote) {
154
+ quote = undefined;
155
+ }
156
+ continue;
157
+ }
158
+ if (character === "'" || character === '"' || character === "`") {
159
+ quote = character;
160
+ continue;
161
+ }
162
+ if (character === "/" && nextCharacter === "/") {
163
+ lineComment = true;
164
+ index++;
165
+ continue;
166
+ }
167
+ if (character === "/" && nextCharacter === "*") {
168
+ blockComment = true;
169
+ index++;
170
+ continue;
171
+ }
172
+ if (character === "/")
173
+ return undefined;
174
+ // Annex B HTML-like comments are not lexed here; give up rather than
175
+ // risk misreading the parameter list.
176
+ if (character === "<" && parameters.startsWith("!--", index + 1))
177
+ return undefined;
178
+ if (character === "-" && parameters.startsWith("->", index + 1))
179
+ return undefined;
180
+ if (character === "(")
181
+ round++;
182
+ else if (character === ")")
183
+ round--;
184
+ else if (character === "[")
185
+ square++;
186
+ else if (character === "]")
187
+ square--;
188
+ else if (character === "{")
189
+ curly++;
190
+ else if (character === "}") {
191
+ if (templateDepths.at(-1) === curly) {
192
+ templateDepths.pop();
193
+ curly--;
194
+ quote = "`";
195
+ }
196
+ else
197
+ curly--;
198
+ }
199
+ else if (character === "," && round === 0 && square === 0 && curly === 0) {
200
+ parts.push(parameters.slice(start, index));
201
+ start = index + 1;
202
+ }
203
+ if (round < 0 || square < 0 || curly < 0)
204
+ return undefined;
205
+ }
206
+ if (quote ||
207
+ blockComment ||
208
+ templateDepths.length > 0 ||
209
+ round !== 0 ||
210
+ square !== 0 ||
211
+ curly !== 0)
212
+ return undefined;
213
+ parts.push(parameters.slice(start));
214
+ return parts;
215
+ }
216
+ function authStartParameterList(source) {
217
+ let index = 0;
218
+ while (index < source.length && /\s/.test(source[index] ?? ""))
219
+ index++;
220
+ if (index >= source.length)
221
+ return undefined;
222
+ let openIndex = -1;
223
+ let parenthesizedArrow = false;
224
+ let asyncMethodOrArrow = false;
225
+ const skipTrivia = () => {
226
+ while (index < source.length) {
227
+ if (/\s/.test(source[index] ?? "")) {
228
+ index++;
229
+ continue;
230
+ }
231
+ if (source[index] === "/" && source[index + 1] === "/") {
232
+ index += 2;
233
+ while (index < source.length && source[index] !== "\n" && source[index] !== "\r")
234
+ index++;
235
+ continue;
236
+ }
237
+ if (source[index] === "/" && source[index + 1] === "*") {
238
+ const end = source.indexOf("*/", index + 2);
239
+ if (end < 0) {
240
+ index = source.length;
241
+ return;
242
+ }
243
+ index = end + 2;
244
+ continue;
245
+ }
246
+ return;
247
+ }
248
+ };
249
+ const initial = source.slice(index);
250
+ const isAsync = initial.startsWith("async") && !/[\w$]/.test(initial[5] ?? "");
251
+ if (isAsync) {
252
+ index += 5;
253
+ skipTrivia();
254
+ }
255
+ const afterAsync = source.slice(index);
256
+ const isFunction = afterAsync.startsWith("function") && !/[\w$]/.test(afterAsync[8] ?? "");
257
+ if (isFunction) {
258
+ index += 8;
259
+ skipTrivia();
260
+ if (source[index] === "*") {
261
+ index++;
262
+ skipTrivia();
263
+ }
264
+ }
265
+ else if (source[index] === "*") {
266
+ index++;
267
+ skipTrivia();
268
+ }
269
+ if (source[index] === "(") {
270
+ openIndex = index;
271
+ parenthesizedArrow = !isFunction;
272
+ asyncMethodOrArrow = isAsync && !isFunction;
273
+ }
274
+ else if (isFunction) {
275
+ if (!/[A-Za-z_$]/.test(source[index] ?? ""))
276
+ return undefined;
277
+ index++;
278
+ while (index < source.length && /[A-Za-z0-9_$]/.test(source[index] ?? ""))
279
+ index++;
280
+ skipTrivia();
281
+ if (source[index] !== "(")
282
+ return undefined;
283
+ openIndex = index;
284
+ }
285
+ else {
286
+ const identifierStart = index;
287
+ if (!/[A-Za-z_$]/.test(source[index] ?? ""))
288
+ return undefined;
289
+ index++;
290
+ while (index < source.length && /[A-Za-z0-9_$]/.test(source[index] ?? ""))
291
+ index++;
292
+ const firstIdentifier = source.slice(identifierStart, index);
293
+ skipTrivia();
294
+ if (source[index] === "=" && source[index + 1] === ">")
295
+ return undefined;
296
+ if (source[index] !== "(") {
297
+ if (firstIdentifier !== "get" && firstIdentifier !== "set")
298
+ return undefined;
299
+ if (!/[A-Za-z_$]/.test(source[index] ?? ""))
300
+ return undefined;
301
+ index++;
302
+ while (index < source.length && /[A-Za-z0-9_$]/.test(source[index] ?? ""))
303
+ index++;
304
+ skipTrivia();
305
+ }
306
+ if (source[index] !== "(")
307
+ return undefined;
308
+ openIndex = index;
309
+ }
310
+ if (openIndex < 0)
311
+ return undefined;
312
+ let depth = 1;
313
+ let square = 0;
314
+ let curly = 0;
315
+ let quote;
316
+ let escaped = false;
317
+ let lineComment = false;
318
+ let blockComment = false;
319
+ const templateDepths = [0];
320
+ templateDepths.length = 0;
321
+ for (index = openIndex + 1; index < source.length; index++) {
322
+ const character = source[index];
323
+ const nextCharacter = source[index + 1];
324
+ if (lineComment) {
325
+ if (character === "\n" || character === "\r")
326
+ lineComment = false;
327
+ else
328
+ continue;
329
+ }
330
+ if (blockComment) {
331
+ if (character === "*" && nextCharacter === "/") {
332
+ blockComment = false;
333
+ index++;
334
+ }
335
+ continue;
336
+ }
337
+ if (quote) {
338
+ if (escaped)
339
+ escaped = false;
340
+ else if (character === "\\")
341
+ escaped = true;
342
+ else if (quote === "`" && character === "$" && nextCharacter === "{") {
343
+ curly++;
344
+ templateDepths.push(curly);
345
+ quote = undefined;
346
+ index++;
347
+ }
348
+ else if (character === quote)
349
+ quote = undefined;
350
+ continue;
351
+ }
352
+ if (character === "'" || character === '"' || character === "`") {
353
+ quote = character;
354
+ continue;
355
+ }
356
+ if (character === "/" && nextCharacter === "/") {
357
+ lineComment = true;
358
+ index++;
359
+ continue;
360
+ }
361
+ if (character === "/" && nextCharacter === "*") {
362
+ blockComment = true;
363
+ index++;
364
+ continue;
365
+ }
366
+ if (character === "/")
367
+ return undefined;
368
+ // Annex B HTML-like comments are not lexed here; give up rather than
369
+ // risk misreading the parameter list.
370
+ if (character === "<" && source.startsWith("!--", index + 1))
371
+ return undefined;
372
+ if (character === "-" && source.startsWith("->", index + 1))
373
+ return undefined;
374
+ if (character === "(")
375
+ depth++;
376
+ else if (character === ")") {
377
+ depth--;
378
+ if (depth === 0 && square === 0 && curly === 0) {
379
+ const closeIndex = index;
380
+ if (parenthesizedArrow) {
381
+ index++;
382
+ skipTrivia();
383
+ const hasArrow = source[index] === "=" && source[index + 1] === ">";
384
+ if (!hasArrow && (!asyncMethodOrArrow || source[index] !== "{"))
385
+ return undefined;
386
+ }
387
+ return source.slice(openIndex + 1, closeIndex);
388
+ }
389
+ if (depth < 0)
390
+ return undefined;
391
+ }
392
+ else if (character === "[")
393
+ square++;
394
+ else if (character === "]") {
395
+ square--;
396
+ if (square < 0)
397
+ return undefined;
398
+ }
399
+ else if (character === "{")
400
+ curly++;
401
+ else if (character === "}") {
402
+ if (templateDepths.at(-1) === curly) {
403
+ templateDepths.pop();
404
+ curly--;
405
+ quote = "`";
406
+ }
407
+ else
408
+ curly--;
409
+ if (curly < 0)
410
+ return undefined;
411
+ }
412
+ }
413
+ return undefined;
414
+ }
415
+ /**
416
+ * Conservative defense in depth for defaulted second parameters, which
417
+ * JavaScript intentionally omits from Function.length. Ambiguous source is
418
+ * ignored so this check can never reject a valid provider on weak evidence.
419
+ */
420
+ function authStartHasHiddenInput(start) {
421
+ let source;
422
+ try {
423
+ source = Function.prototype.toString.call(start);
424
+ }
425
+ catch {
426
+ return false;
427
+ }
428
+ if (!source ||
429
+ source.includes("[native code]") ||
430
+ /^\s*(?:async\s+)?function\s+bound\b/.test(source))
431
+ return false;
432
+ const parameters = authStartParameterList(source);
433
+ if (!parameters)
434
+ return false;
435
+ const parts = splitAuthStartParameters(parameters);
436
+ if (!parts || parts.length < 2)
437
+ return false;
438
+ // Require ordinary, readable source formatting. This intentionally fails
439
+ // open for minified output and for transpilers that rewrite defaults.
440
+ const commaIndex = parameters.indexOf(",");
441
+ if (commaIndex < 0 || !/\s/.test(parameters[commaIndex + 1] ?? ""))
442
+ return false;
443
+ const first = parts[0].trim();
444
+ const second = parts[1].trim();
445
+ const identifier = /^[_$A-Za-z][_$A-Za-z0-9]*/;
446
+ const firstName = first.match(identifier)?.[0];
447
+ const secondName = second.match(identifier)?.[0];
448
+ if (!firstName || !secondName || firstName.length < 3 || secondName.length < 3)
449
+ return false;
450
+ return /\s=\s/.test(second);
451
+ }
112
452
  /** Define one provider operation with schema-driven handler inference. */
113
453
  export function defineOperation(operation) {
114
454
  return operation;
@@ -241,6 +581,19 @@ function validateProviderShape(config) {
241
581
  fix: "Return a form turn from start(ctx), then receive user input in continue(ctx, input).",
242
582
  });
243
583
  }
584
+ if (auth &&
585
+ typeof auth === "object" &&
586
+ "flow" in auth &&
587
+ auth.flow &&
588
+ typeof auth.flow === "object" &&
589
+ "start" in auth.flow &&
590
+ typeof auth.flow.start === "function" &&
591
+ auth.flow.start.length <= 1 &&
592
+ authStartHasHiddenInput(auth.flow.start)) {
593
+ throw new ProviderError(`Provider "${String(config.id)}" auth.flow.start must not declare an input parameter`, {
594
+ fix: "Return a form turn from start(ctx), then receive user input in continue(ctx, input).",
595
+ });
596
+ }
244
597
  const access = config.access;
245
598
  if (access !== undefined) {
246
599
  if (!access || typeof access !== "object" || Array.isArray(access)) {
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export * from "./choice-token.js";
4
4
  export type { ApiFuseConfig, BrowserConfig, ProxyProtocol, ProxyResolutionOptions, ProxyResolutionSource, ProxyVendorName, ResolvedProxyConfig, SessionConfig, } from "./config/loader.js";
5
5
  export { defineConfig, loadApiFuseConfig, resolveProxy } from "./config/loader.js";
6
6
  export { canonicalJson, digestProviderContract, extractProviderContract, type JsonPrimitive, type JsonValue, PROVIDER_CONTRACT_SCHEMA_VERSION, type ProviderContractOperation, type ProviderContractSnapshot, } from "./contract.js";
7
- export { centered, delayed, defineHealthJourney, defineOperation, defineProvider, defineSmsOtpMatcher, defineStreamOperation, every, type ProviderConfig, } from "./define.js";
7
+ export { centered, delayed, defineHealthJourney, defineOperation, defineProvider, defineSmsOtpMatcher, defineStreamOperation, every, type AuthStartNoInputGuard, type ProviderConfig, } from "./define.js";
8
8
  export type { DevServerOptions } from "./dev.js";
9
9
  export { createDevServer, startDevServer } from "./dev.js";
10
10
  export * from "./errors.js";
@@ -407,13 +407,6 @@ async function parseWordServerStoredChoice(options) {
407
407
  record.prefix !== options.parseOptions.prefix) {
408
408
  throw wordChoiceNotFoundError();
409
409
  }
410
- assertFreshProviderChoiceIssuedAt(record.issued_at_ms, {
411
- ttlMs: options.parseOptions.ttlMs != null
412
- ? Math.min(options.parseOptions.ttlMs, record.ttl_ms)
413
- : record.ttl_ms,
414
- nowMs: options.parseOptions.nowMs,
415
- futureToleranceMs: options.parseOptions.futureToleranceMs,
416
- });
417
410
  assertPayloadDigestMatches({
418
411
  actual: digestChoicePayload(serializeChoicePayload(record.payload)),
419
412
  expected: record.payload_digest,
@@ -434,6 +427,30 @@ async function parseWordServerStoredChoice(options) {
434
427
  }
435
428
  throw error;
436
429
  }
430
+ // Freshness is classified last, reachable only after every identity,
431
+ // integrity, and binding check above has passed (ADR 0006, amended
432
+ // 2026-08-20): a caller that proved the record's binding may observe the
433
+ // canonical stale error, while an unbound record keeps the collapsed
434
+ // not-found error so expiry never becomes an existence signal for
435
+ // guessable tokens.
436
+ try {
437
+ assertFreshProviderChoiceIssuedAt(record.issued_at_ms, {
438
+ ttlMs: options.parseOptions.ttlMs != null
439
+ ? Math.min(options.parseOptions.ttlMs, record.ttl_ms)
440
+ : record.ttl_ms,
441
+ nowMs: options.parseOptions.nowMs,
442
+ futureToleranceMs: options.parseOptions.futureToleranceMs,
443
+ });
444
+ }
445
+ catch (error) {
446
+ const recordIsBound = Boolean(record.binding?.connection_hash || record.binding?.credential_hash);
447
+ if (recordIsBound && error instanceof ProviderChoiceTokenError && error.reason === "stale") {
448
+ throw error;
449
+ }
450
+ if (error instanceof ProviderChoiceTokenError)
451
+ throw wordChoiceNotFoundError();
452
+ throw error;
453
+ }
437
454
  const consumeMode = options.parseOptions.consume ?? "never";
438
455
  if (record.status === "consumed") {
439
456
  if (consumeMode === "explicit") {
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.35",
2
+ "version": "2.2.0-beta.36",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -111,6 +111,9 @@
111
111
  "type-check": "tsc --noEmit",
112
112
  "test": "bun test",
113
113
  "check": "bun run lint && bun run type-check && bun run lint:deprecated && bun run lint:test-typesafety && bun run build",
114
+ "api:update": "bun run build && bun scripts/api-reports.ts update",
115
+ "api:check": "bun run build && bun scripts/api-reports.ts check",
116
+ "changeset:check": "bun scripts/check-changeset.ts",
114
117
  "pack:check": "bun run build && bun bin/apifuse-pack-check.ts",
115
118
  "pack:smoke": "bun run build && bun bin/apifuse-pack-smoke.ts",
116
119
  "pack:types": "bun run build && bun bin/apifuse-pack-types.ts",
@@ -121,6 +124,8 @@
121
124
  "devDependencies": {
122
125
  "@arethetypeswrong/cli": "^0.18.5",
123
126
  "@biomejs/biome": "^2.5.0",
127
+ "@changesets/cli": "^3.0.1",
128
+ "@microsoft/api-extractor": "^7.58.13",
124
129
  "@types/bun": "latest",
125
130
  "@types/node": "^25.9.3",
126
131
  "ajv": "^8.17",
@@ -7,6 +7,7 @@ import {
7
7
  TurnValidationError,
8
8
  ValidationError,
9
9
  } from "../errors.js";
10
+ import type { AuthStartNoInputGuard } from "../define.js";
10
11
  import type { AuthFlowDefinition, AuthFlowInputHandler, AuthTurn, FlowContext } from "../types.js";
11
12
 
12
13
  type TurnKind = KnownAuthTurnKind;
@@ -224,6 +225,18 @@ export function validateCeremonyOutput(turn: unknown): AuthTurn {
224
225
  return turn as AuthTurn;
225
226
  }
226
227
 
228
+ /**
229
+ * Defines an auth flow while preserving its concrete type for downstream checks.
230
+ * The compile-time guard validates the inferred literal; annotating or widening
231
+ * a flow to `AuthFlowDefinition` before passing it defeats the check. Full
232
+ * enforcement requires branding, which is deferred to a future major.
233
+ */
234
+ export function defineAuthFlow<const TFlow extends AuthFlowDefinition>(
235
+ flow: TFlow & AuthStartNoInputGuard<TFlow>,
236
+ ): TFlow {
237
+ return flow;
238
+ }
239
+
227
240
  export function createOAuth2Ceremony(options: {
228
241
  authorizeUrl: string;
229
242
  tokenUrl: string;
@@ -232,7 +245,7 @@ export function createOAuth2Ceremony(options: {
232
245
  scopes: string[];
233
246
  usePKCE?: boolean;
234
247
  }): AuthFlowDefinition {
235
- return {
248
+ return defineAuthFlow({
236
249
  start: (ctx) =>
237
250
  runCeremonyHandler(
238
251
  async () => {
@@ -306,7 +319,7 @@ export function createOAuth2Ceremony(options: {
306
319
  input,
307
320
  ),
308
321
  abort: async () => validateCeremonyOutput(createTurn("abort", { hint: "OAuth flow aborted." })),
309
- };
322
+ });
310
323
  }
311
324
 
312
325
  /**
@@ -384,7 +397,7 @@ export function createDeviceFlowCeremony(options: {
384
397
  clientSecretEnvKey?: string;
385
398
  scopes: string[];
386
399
  }): AuthFlowDefinition {
387
- return {
400
+ return defineAuthFlow({
388
401
  start: (ctx) =>
389
402
  runCeremonyHandler(
390
403
  async () => {
@@ -461,7 +474,7 @@ export function createDeviceFlowCeremony(options: {
461
474
  ),
462
475
  abort: async () =>
463
476
  validateCeremonyOutput(createTurn("abort", { hint: "Device flow aborted." })),
464
- };
477
+ });
465
478
  }
466
479
 
467
480
  export function createWebAuthnCeremony(options: {
@@ -470,7 +483,7 @@ export function createWebAuthnCeremony(options: {
470
483
  verifyUrl?: string;
471
484
  timeoutMs?: number;
472
485
  }): AuthFlowDefinition {
473
- return {
486
+ return defineAuthFlow({
474
487
  start: (ctx) =>
475
488
  runCeremonyHandler(
476
489
  async () => {
@@ -529,7 +542,7 @@ export function createWebAuthnCeremony(options: {
529
542
  ),
530
543
  abort: async () =>
531
544
  validateCeremonyOutput(createTurn("abort", { hint: "WebAuthn ceremony aborted." })),
532
- };
545
+ });
533
546
  }
534
547
 
535
548
  export function createMagicLinkCeremony(options: {
@@ -539,23 +552,31 @@ export function createMagicLinkCeremony(options: {
539
552
  expiresInMs?: number;
540
553
  }): AuthFlowDefinition {
541
554
  const emailField = options.emailField ?? "email";
555
+ const buildEmailForm = () =>
556
+ buildJsonSchemaForm(
557
+ {
558
+ type: "object",
559
+ required: [emailField],
560
+ properties: {
561
+ [emailField]: { type: "string", format: "email" },
562
+ },
563
+ },
564
+ "Provide the email address to receive a magic link.",
565
+ );
542
566
 
543
- return {
544
- start: (ctx, input = {}) =>
567
+ return defineAuthFlow({
568
+ start: (ctx) =>
569
+ runCeremonyHandler(
570
+ async () => buildEmailForm(),
571
+ "Magic link start failed",
572
+ ctx,
573
+ ),
574
+ continue: (ctx, input = {}) =>
545
575
  runCeremonyHandler(
546
576
  async () => {
547
577
  const email = getString(input, emailField);
548
578
  if (!email) {
549
- return buildJsonSchemaForm(
550
- {
551
- type: "object",
552
- required: [emailField],
553
- properties: {
554
- [emailField]: { type: "string", format: "email" },
555
- },
556
- },
557
- "Provide the email address to receive a magic link.",
558
- );
579
+ return buildEmailForm();
559
580
  }
560
581
 
561
582
  await ctx.http.post(options.sendUrl, { email });
@@ -570,17 +591,10 @@ export function createMagicLinkCeremony(options: {
570
591
  timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 300_000 },
571
592
  });
572
593
  },
573
- "Magic link start failed",
594
+ "Magic link continuation failed",
574
595
  ctx,
575
596
  input,
576
597
  ),
577
- continue: async () =>
578
- validateCeremonyOutput(
579
- createTurn("poll", {
580
- hint: "Continue polling for magic link completion.",
581
- timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 300_000 },
582
- }),
583
- ),
584
598
  poll: (ctx) =>
585
599
  runCeremonyHandler(
586
600
  async () => {
@@ -616,7 +630,7 @@ export function createMagicLinkCeremony(options: {
616
630
  ),
617
631
  abort: async () =>
618
632
  validateCeremonyOutput(createTurn("abort", { hint: "Magic link flow aborted." })),
619
- };
633
+ });
620
634
  }
621
635
 
622
636
  export function createFormCeremony(options: {
@@ -624,7 +638,7 @@ export function createFormCeremony(options: {
624
638
  hint?: string;
625
639
  mapCredential?: (input: Record<string, unknown>) => JsonObject;
626
640
  }): AuthFlowDefinition {
627
- return {
641
+ return defineAuthFlow({
628
642
  start: async () =>
629
643
  validateCeremonyOutput(
630
644
  buildJsonSchemaForm(
@@ -656,7 +670,7 @@ export function createFormCeremony(options: {
656
670
  ),
657
671
  abort: async () =>
658
672
  validateCeremonyOutput(createTurn("abort", { hint: "Form ceremony aborted." })),
659
- };
673
+ });
660
674
  }
661
675
 
662
676
  export function combineCeremonies(...ceremonies: AuthFlowDefinition[]): AuthFlowDefinition {
@@ -739,7 +753,7 @@ export function createSwitchCeremony(options: {
739
753
  }): AuthFlowDefinition {
740
754
  const choiceKeys = Object.keys(options.choices);
741
755
 
742
- return {
756
+ return defineAuthFlow({
743
757
  start: async () =>
744
758
  validateCeremonyOutput(
745
759
  createTurn("multi_choice", {
@@ -811,5 +825,5 @@ export function createSwitchCeremony(options: {
811
825
  "Switch ceremony abort failed",
812
826
  ctx,
813
827
  ),
814
- };
828
+ });
815
829
  }
@@ -1,6 +1,6 @@
1
1
  FROM oven/bun:1.2-alpine
2
2
  WORKDIR /provider
3
- COPY package.json bun.lockb* ./
3
+ COPY package.json bun.lock ./
4
4
  RUN bun install --frozen-lockfile
5
5
  COPY . .
6
6
  EXPOSE 3000
package/src/define.ts CHANGED
@@ -249,17 +249,318 @@ type WebSocketOperationConfig<TInput extends SchemaLike, TOutput extends SchemaL
249
249
  ): Response | ReadableStream<Uint8Array> | Promise<Response | ReadableStream<Uint8Array>>;
250
250
  };
251
251
 
252
- type AuthStartNoInputGuard<TConfig> = TConfig extends {
252
+ type AuthStartHandlerNoInputGuard<TStart> = TStart extends (...args: infer TArgs) => unknown
253
+ ? TArgs["length"] extends 0 | 1
254
+ ? unknown
255
+ : {
256
+ "auth start handlers must not declare input parameters; return a form turn from start and receive user input in continue": never;
257
+ }
258
+ : unknown;
259
+
260
+ export type AuthStartNoInputGuard<TConfig> = TConfig extends {
253
261
  auth?: { flow?: { start: infer TStart } };
254
262
  }
255
- ? TStart extends (...args: infer TArgs) => unknown
256
- ? TArgs extends [unknown]
257
- ? unknown
258
- : {
259
- "auth start handlers must not declare input parameters; return a form turn from start and receive user input in continue": never;
263
+ ? AuthStartHandlerNoInputGuard<TStart>
264
+ : TConfig extends { start: infer TStart }
265
+ ? AuthStartHandlerNoInputGuard<TStart>
266
+ : unknown;
267
+
268
+ function splitAuthStartParameters(parameters: string): string[] | undefined {
269
+ const parts: string[] = [];
270
+ let start = 0;
271
+ let round = 0;
272
+ let square = 0;
273
+ let curly = 0;
274
+ let quote: "'" | '"' | "`" | undefined;
275
+ let escaped = false;
276
+ let lineComment = false;
277
+ let blockComment = false;
278
+ const templateDepths = [0];
279
+ templateDepths.length = 0;
280
+
281
+ for (let index = 0; index < parameters.length; index++) {
282
+ const character = parameters[index];
283
+ const nextCharacter = parameters[index + 1];
284
+ if (lineComment) {
285
+ if (character === "\n" || character === "\r") lineComment = false;
286
+ else continue;
287
+ }
288
+ if (blockComment) {
289
+ if (character === "*" && nextCharacter === "/") {
290
+ blockComment = false;
291
+ index++;
292
+ }
293
+ continue;
294
+ }
295
+ if (quote) {
296
+ if (escaped) {
297
+ escaped = false;
298
+ } else if (character === "\\") {
299
+ escaped = true;
300
+ } else if (quote === "`" && character === "$" && nextCharacter === "{") {
301
+ curly++;
302
+ templateDepths.push(curly);
303
+ quote = undefined;
304
+ index++;
305
+ } else if (character === quote) {
306
+ quote = undefined;
307
+ }
308
+ continue;
309
+ }
310
+ if (character === "'" || character === '"' || character === "`") {
311
+ quote = character;
312
+ continue;
313
+ }
314
+ if (character === "/" && nextCharacter === "/") {
315
+ lineComment = true;
316
+ index++;
317
+ continue;
318
+ }
319
+ if (character === "/" && nextCharacter === "*") {
320
+ blockComment = true;
321
+ index++;
322
+ continue;
323
+ }
324
+ if (character === "/") return undefined;
325
+ // Annex B HTML-like comments are not lexed here; give up rather than
326
+ // risk misreading the parameter list.
327
+ if (character === "<" && parameters.startsWith("!--", index + 1)) return undefined;
328
+ if (character === "-" && parameters.startsWith("->", index + 1)) return undefined;
329
+ if (character === "(") round++;
330
+ else if (character === ")") round--;
331
+ else if (character === "[") square++;
332
+ else if (character === "]") square--;
333
+ else if (character === "{") curly++;
334
+ else if (character === "}") {
335
+ if (templateDepths.at(-1) === curly) {
336
+ templateDepths.pop();
337
+ curly--;
338
+ quote = "`";
339
+ } else curly--;
340
+ } else if (character === "," && round === 0 && square === 0 && curly === 0) {
341
+ parts.push(parameters.slice(start, index));
342
+ start = index + 1;
343
+ }
344
+ if (round < 0 || square < 0 || curly < 0) return undefined;
345
+ }
346
+
347
+ if (
348
+ quote ||
349
+ blockComment ||
350
+ templateDepths.length > 0 ||
351
+ round !== 0 ||
352
+ square !== 0 ||
353
+ curly !== 0
354
+ )
355
+ return undefined;
356
+ parts.push(parameters.slice(start));
357
+ return parts;
358
+ }
359
+
360
+ function authStartParameterList(source: string): string | undefined {
361
+ let index = 0;
362
+ while (index < source.length && /\s/.test(source[index] ?? "")) index++;
363
+ if (index >= source.length) return undefined;
364
+
365
+ let openIndex = -1;
366
+ let parenthesizedArrow = false;
367
+ let asyncMethodOrArrow = false;
368
+ const skipTrivia = () => {
369
+ while (index < source.length) {
370
+ if (/\s/.test(source[index] ?? "")) {
371
+ index++;
372
+ continue;
373
+ }
374
+ if (source[index] === "/" && source[index + 1] === "/") {
375
+ index += 2;
376
+ while (index < source.length && source[index] !== "\n" && source[index] !== "\r") index++;
377
+ continue;
378
+ }
379
+ if (source[index] === "/" && source[index + 1] === "*") {
380
+ const end = source.indexOf("*/", index + 2);
381
+ if (end < 0) {
382
+ index = source.length;
383
+ return;
260
384
  }
261
- : unknown
262
- : unknown;
385
+ index = end + 2;
386
+ continue;
387
+ }
388
+ return;
389
+ }
390
+ };
391
+
392
+ const initial = source.slice(index);
393
+ const isAsync = initial.startsWith("async") && !/[\w$]/.test(initial[5] ?? "");
394
+ if (isAsync) {
395
+ index += 5;
396
+ skipTrivia();
397
+ }
398
+
399
+ const afterAsync = source.slice(index);
400
+ const isFunction = afterAsync.startsWith("function") && !/[\w$]/.test(afterAsync[8] ?? "");
401
+ if (isFunction) {
402
+ index += 8;
403
+ skipTrivia();
404
+ if (source[index] === "*") {
405
+ index++;
406
+ skipTrivia();
407
+ }
408
+ } else if (source[index] === "*") {
409
+ index++;
410
+ skipTrivia();
411
+ }
412
+
413
+ if (source[index] === "(") {
414
+ openIndex = index;
415
+ parenthesizedArrow = !isFunction;
416
+ asyncMethodOrArrow = isAsync && !isFunction;
417
+ } else if (isFunction) {
418
+ if (!/[A-Za-z_$]/.test(source[index] ?? "")) return undefined;
419
+ index++;
420
+ while (index < source.length && /[A-Za-z0-9_$]/.test(source[index] ?? "")) index++;
421
+ skipTrivia();
422
+ if (source[index] !== "(") return undefined;
423
+ openIndex = index;
424
+ } else {
425
+ const identifierStart = index;
426
+ if (!/[A-Za-z_$]/.test(source[index] ?? "")) return undefined;
427
+ index++;
428
+ while (index < source.length && /[A-Za-z0-9_$]/.test(source[index] ?? "")) index++;
429
+ const firstIdentifier = source.slice(identifierStart, index);
430
+ skipTrivia();
431
+ if (source[index] === "=" && source[index + 1] === ">") return undefined;
432
+ if (source[index] !== "(") {
433
+ if (firstIdentifier !== "get" && firstIdentifier !== "set") return undefined;
434
+ if (!/[A-Za-z_$]/.test(source[index] ?? "")) return undefined;
435
+ index++;
436
+ while (index < source.length && /[A-Za-z0-9_$]/.test(source[index] ?? "")) index++;
437
+ skipTrivia();
438
+ }
439
+ if (source[index] !== "(") return undefined;
440
+ openIndex = index;
441
+ }
442
+ if (openIndex < 0) return undefined;
443
+
444
+ let depth = 1;
445
+ let square = 0;
446
+ let curly = 0;
447
+ let quote: "'" | '"' | "`" | undefined;
448
+ let escaped = false;
449
+ let lineComment = false;
450
+ let blockComment = false;
451
+ const templateDepths = [0];
452
+ templateDepths.length = 0;
453
+ for (index = openIndex + 1; index < source.length; index++) {
454
+ const character = source[index];
455
+ const nextCharacter = source[index + 1];
456
+ if (lineComment) {
457
+ if (character === "\n" || character === "\r") lineComment = false;
458
+ else continue;
459
+ }
460
+ if (blockComment) {
461
+ if (character === "*" && nextCharacter === "/") {
462
+ blockComment = false;
463
+ index++;
464
+ }
465
+ continue;
466
+ }
467
+ if (quote) {
468
+ if (escaped) escaped = false;
469
+ else if (character === "\\") escaped = true;
470
+ else if (quote === "`" && character === "$" && nextCharacter === "{") {
471
+ curly++;
472
+ templateDepths.push(curly);
473
+ quote = undefined;
474
+ index++;
475
+ } else if (character === quote) quote = undefined;
476
+ continue;
477
+ }
478
+ if (character === "'" || character === '"' || character === "`") {
479
+ quote = character;
480
+ continue;
481
+ }
482
+ if (character === "/" && nextCharacter === "/") {
483
+ lineComment = true;
484
+ index++;
485
+ continue;
486
+ }
487
+ if (character === "/" && nextCharacter === "*") {
488
+ blockComment = true;
489
+ index++;
490
+ continue;
491
+ }
492
+ if (character === "/") return undefined;
493
+ // Annex B HTML-like comments are not lexed here; give up rather than
494
+ // risk misreading the parameter list.
495
+ if (character === "<" && source.startsWith("!--", index + 1)) return undefined;
496
+ if (character === "-" && source.startsWith("->", index + 1)) return undefined;
497
+ if (character === "(") depth++;
498
+ else if (character === ")") {
499
+ depth--;
500
+ if (depth === 0 && square === 0 && curly === 0) {
501
+ const closeIndex = index;
502
+ if (parenthesizedArrow) {
503
+ index++;
504
+ skipTrivia();
505
+ const hasArrow = source[index] === "=" && source[index + 1] === ">";
506
+ if (!hasArrow && (!asyncMethodOrArrow || source[index] !== "{")) return undefined;
507
+ }
508
+ return source.slice(openIndex + 1, closeIndex);
509
+ }
510
+ if (depth < 0) return undefined;
511
+ } else if (character === "[") square++;
512
+ else if (character === "]") {
513
+ square--;
514
+ if (square < 0) return undefined;
515
+ } else if (character === "{") curly++;
516
+ else if (character === "}") {
517
+ if (templateDepths.at(-1) === curly) {
518
+ templateDepths.pop();
519
+ curly--;
520
+ quote = "`";
521
+ } else curly--;
522
+ if (curly < 0) return undefined;
523
+ }
524
+ }
525
+ return undefined;
526
+ }
527
+
528
+ /**
529
+ * Conservative defense in depth for defaulted second parameters, which
530
+ * JavaScript intentionally omits from Function.length. Ambiguous source is
531
+ * ignored so this check can never reject a valid provider on weak evidence.
532
+ */
533
+ function authStartHasHiddenInput(start: unknown): boolean {
534
+ let source: string;
535
+ try {
536
+ source = Function.prototype.toString.call(start);
537
+ } catch {
538
+ return false;
539
+ }
540
+ if (
541
+ !source ||
542
+ source.includes("[native code]") ||
543
+ /^\s*(?:async\s+)?function\s+bound\b/.test(source)
544
+ )
545
+ return false;
546
+
547
+ const parameters = authStartParameterList(source);
548
+ if (!parameters) return false;
549
+ const parts = splitAuthStartParameters(parameters);
550
+ if (!parts || parts.length < 2) return false;
551
+
552
+ // Require ordinary, readable source formatting. This intentionally fails
553
+ // open for minified output and for transpilers that rewrite defaults.
554
+ const commaIndex = parameters.indexOf(",");
555
+ if (commaIndex < 0 || !/\s/.test(parameters[commaIndex + 1] ?? "")) return false;
556
+ const first = parts[0].trim();
557
+ const second = parts[1].trim();
558
+ const identifier = /^[_$A-Za-z][_$A-Za-z0-9]*/;
559
+ const firstName = first.match(identifier)?.[0];
560
+ const secondName = second.match(identifier)?.[0];
561
+ if (!firstName || !secondName || firstName.length < 3 || secondName.length < 3) return false;
562
+ return /\s=\s/.test(second);
563
+ }
263
564
 
264
565
  export interface ProviderConfig<TOperations extends Record<string, ProviderOperation>> {
265
566
  id: string;
@@ -510,6 +811,24 @@ function validateProviderShape(config: unknown): void {
510
811
  },
511
812
  );
512
813
  }
814
+ if (
815
+ auth &&
816
+ typeof auth === "object" &&
817
+ "flow" in auth &&
818
+ auth.flow &&
819
+ typeof auth.flow === "object" &&
820
+ "start" in auth.flow &&
821
+ typeof auth.flow.start === "function" &&
822
+ auth.flow.start.length <= 1 &&
823
+ authStartHasHiddenInput(auth.flow.start)
824
+ ) {
825
+ throw new ProviderError(
826
+ `Provider "${String(config.id)}" auth.flow.start must not declare an input parameter`,
827
+ {
828
+ fix: "Return a form turn from start(ctx), then receive user input in continue(ctx, input).",
829
+ },
830
+ );
831
+ }
513
832
  const access = config.access;
514
833
  if (access !== undefined) {
515
834
  if (!access || typeof access !== "object" || Array.isArray(access)) {
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  defineSmsOtpMatcher,
34
34
  defineStreamOperation,
35
35
  every,
36
+ type AuthStartNoInputGuard,
36
37
  type ProviderConfig,
37
38
  } from "./define.js";
38
39
  export type { DevServerOptions } from "./dev.js";
@@ -652,14 +652,6 @@ async function parseWordServerStoredChoice(options: {
652
652
  ) {
653
653
  throw wordChoiceNotFoundError();
654
654
  }
655
- assertFreshProviderChoiceIssuedAt(record.issued_at_ms, {
656
- ttlMs:
657
- options.parseOptions.ttlMs != null
658
- ? Math.min(options.parseOptions.ttlMs, record.ttl_ms)
659
- : record.ttl_ms,
660
- nowMs: options.parseOptions.nowMs,
661
- futureToleranceMs: options.parseOptions.futureToleranceMs,
662
- });
663
655
  assertPayloadDigestMatches({
664
656
  actual: digestChoicePayload(serializeChoicePayload(record.payload)),
665
657
  expected: record.payload_digest,
@@ -681,6 +673,31 @@ async function parseWordServerStoredChoice(options: {
681
673
  }
682
674
  throw error;
683
675
  }
676
+ // Freshness is classified last, reachable only after every identity,
677
+ // integrity, and binding check above has passed (ADR 0006, amended
678
+ // 2026-08-20): a caller that proved the record's binding may observe the
679
+ // canonical stale error, while an unbound record keeps the collapsed
680
+ // not-found error so expiry never becomes an existence signal for
681
+ // guessable tokens.
682
+ try {
683
+ assertFreshProviderChoiceIssuedAt(record.issued_at_ms, {
684
+ ttlMs:
685
+ options.parseOptions.ttlMs != null
686
+ ? Math.min(options.parseOptions.ttlMs, record.ttl_ms)
687
+ : record.ttl_ms,
688
+ nowMs: options.parseOptions.nowMs,
689
+ futureToleranceMs: options.parseOptions.futureToleranceMs,
690
+ });
691
+ } catch (error) {
692
+ const recordIsBound = Boolean(
693
+ record.binding?.connection_hash || record.binding?.credential_hash,
694
+ );
695
+ if (recordIsBound && error instanceof ProviderChoiceTokenError && error.reason === "stale") {
696
+ throw error;
697
+ }
698
+ if (error instanceof ProviderChoiceTokenError) throw wordChoiceNotFoundError();
699
+ throw error;
700
+ }
684
701
 
685
702
  const consumeMode = options.parseOptions.consume ?? "never";
686
703
  if (record.status === "consumed") {