@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 +4 -0
- package/dist/ceremonies/index.d.ts +8 -0
- package/dist/ceremonies/index.js +32 -25
- package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
- package/dist/define.d.ts +7 -4
- package/dist/define.js +353 -0
- package/dist/index.d.ts +1 -1
- package/dist/runtime/choice.js +24 -7
- package/package.json +6 -1
- package/src/ceremonies/index.ts +45 -31
- package/src/cli/templates/provider/Dockerfile.tpl +1 -1
- package/src/define.ts +327 -8
- package/src/index.ts +1 -0
- package/src/runtime/choice.ts +25 -8
package/CHANGELOG.md
CHANGED
|
@@ -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;
|
package/dist/ceremonies/index.js
CHANGED
|
@@ -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
|
-
|
|
383
|
-
|
|
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
|
|
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
|
|
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
|
}
|
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
|
|
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
|
|
40
|
-
|
|
41
|
-
}
|
|
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";
|
package/dist/runtime/choice.js
CHANGED
|
@@ -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.
|
|
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",
|
package/src/ceremonies/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
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
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
package/src/runtime/choice.ts
CHANGED
|
@@ -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") {
|