@apifuse/provider-sdk 2.0.0-beta.1 → 2.1.0-beta.1

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.
Files changed (79) hide show
  1. package/AUTHORING.md +93 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +133 -28
  4. package/bin/apifuse-check.ts +78 -71
  5. package/bin/apifuse-create.ts +12 -0
  6. package/bin/apifuse-dev.ts +24 -61
  7. package/bin/apifuse-pack-check.ts +87 -0
  8. package/bin/apifuse-pack-smoke.ts +122 -0
  9. package/bin/apifuse-perf.ts +33 -32
  10. package/bin/apifuse-record.ts +17 -7
  11. package/bin/apifuse-test.ts +6 -4
  12. package/bin/apifuse.ts +36 -35
  13. package/package.json +29 -9
  14. package/src/ceremonies/index.ts +768 -0
  15. package/src/cli/commands.ts +87 -0
  16. package/src/cli/create.ts +845 -0
  17. package/src/cli/templates/provider/Dockerfile.tpl +7 -0
  18. package/src/cli/templates/provider/README.md.tpl +41 -0
  19. package/src/cli/templates/provider/dev.ts.tpl +5 -0
  20. package/src/cli/templates/provider/index.test.ts.tpl +13 -0
  21. package/src/cli/templates/provider/index.ts.tpl +58 -0
  22. package/src/cli/templates/provider/start.ts.tpl +5 -0
  23. package/src/config/loader.ts +61 -1
  24. package/src/define.ts +565 -41
  25. package/src/dev.ts +2 -6
  26. package/src/errors.ts +42 -0
  27. package/src/index.ts +44 -38
  28. package/src/lint.ts +574 -0
  29. package/src/provider.ts +13 -0
  30. package/src/runtime/auth-flow.ts +67 -0
  31. package/src/runtime/credential.ts +95 -0
  32. package/src/runtime/env.ts +13 -0
  33. package/src/runtime/executor.ts +13 -14
  34. package/src/runtime/http.ts +36 -12
  35. package/src/runtime/insights.ts +3 -3
  36. package/src/runtime/key-derivation.ts +122 -0
  37. package/src/runtime/keyring.ts +148 -0
  38. package/src/runtime/namespace.ts +33 -0
  39. package/src/runtime/prevalidate.ts +252 -0
  40. package/src/runtime/tls.ts +41 -17
  41. package/src/runtime/waterfall.ts +0 -1
  42. package/src/schema.ts +77 -0
  43. package/src/serve.ts +1 -664
  44. package/src/server/index.ts +22 -0
  45. package/src/server/serve.ts +624 -0
  46. package/src/server/types.ts +78 -0
  47. package/src/stealth/profiles.ts +10 -93
  48. package/src/testing/run.ts +391 -32
  49. package/src/types.ts +390 -41
  50. package/bin/apifuse-init.ts +0 -387
  51. package/src/__tests__/auth.test.ts +0 -396
  52. package/src/__tests__/browser-auth.test.ts +0 -180
  53. package/src/__tests__/browser.test.ts +0 -632
  54. package/src/__tests__/define.test.ts +0 -225
  55. package/src/__tests__/errors.test.ts +0 -69
  56. package/src/__tests__/executor.test.ts +0 -214
  57. package/src/__tests__/http.test.ts +0 -238
  58. package/src/__tests__/insights.test.ts +0 -210
  59. package/src/__tests__/instrumentation.test.ts +0 -290
  60. package/src/__tests__/otlp.test.ts +0 -141
  61. package/src/__tests__/perf.test.ts +0 -60
  62. package/src/__tests__/providers-yaml.test.ts +0 -135
  63. package/src/__tests__/proxy.test.ts +0 -359
  64. package/src/__tests__/recipes.test.ts +0 -36
  65. package/src/__tests__/serve.test.ts +0 -233
  66. package/src/__tests__/session.test.ts +0 -231
  67. package/src/__tests__/state.test.ts +0 -100
  68. package/src/__tests__/stealth.test.ts +0 -57
  69. package/src/__tests__/testing.test.ts +0 -97
  70. package/src/__tests__/tls.test.ts +0 -345
  71. package/src/__tests__/types.test.ts +0 -142
  72. package/src/__tests__/utils.test.ts +0 -62
  73. package/src/__tests__/waterfall.test.ts +0 -270
  74. package/src/config/providers-yaml.ts +0 -370
  75. package/src/index.test.ts +0 -1
  76. package/src/protocol.ts +0 -183
  77. package/src/runtime/auth.ts +0 -245
  78. package/src/runtime/session.ts +0 -573
  79. package/src/runtime/state.ts +0 -124
@@ -0,0 +1,768 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+
3
+ import Ajv from "ajv";
4
+
5
+ import {
6
+ FlowExpiredError,
7
+ ProviderSecretError,
8
+ TurnValidationError,
9
+ ValidationError,
10
+ } from "../errors";
11
+ import type { AuthFlowDefinition, AuthTurn, FlowContext } from "../types";
12
+
13
+ type TurnKind =
14
+ | "abort"
15
+ | "challenge"
16
+ | "complete"
17
+ | "form"
18
+ | "message"
19
+ | "multi_choice"
20
+ | "poll"
21
+ | "redirect"
22
+ | "retry";
23
+
24
+ type CeremonyHandler = AuthFlowDefinition["start"];
25
+
26
+ type JsonObject = Record<string, unknown>;
27
+
28
+ const ajv = new Ajv({ allErrors: true, strict: true, strictSchema: true });
29
+
30
+ const authTurnSchema = {
31
+ type: "object",
32
+ additionalProperties: false,
33
+ required: ["kind", "turnId"],
34
+ properties: {
35
+ kind: { type: "string", minLength: 1 },
36
+ turnId: { type: "string", minLength: 1 },
37
+ expiresAt: { type: "string", minLength: 1 },
38
+ data: {
39
+ type: "object",
40
+ additionalProperties: true,
41
+ },
42
+ expectedInput: {
43
+ type: "object",
44
+ additionalProperties: true,
45
+ },
46
+ hint: { type: "string" },
47
+ timing: {
48
+ type: "object",
49
+ additionalProperties: false,
50
+ properties: {
51
+ suggestedPollIntervalMs: { type: "number", minimum: 1 },
52
+ maxWaitMs: { type: "number", minimum: 1 },
53
+ },
54
+ },
55
+ },
56
+ } as const;
57
+
58
+ const validateAuthTurn = ajv.compile(authTurnSchema);
59
+
60
+ const OAUTH2_STATE_KEY = "__oauth2_state";
61
+ const OAUTH2_PKCE_VERIFIER_KEY = "__oauth2_pkce_verifier";
62
+ const DEVICE_FLOW_KEY = "__device_flow";
63
+ const MAGIC_LINK_KEY = "__magic_link";
64
+ const COMBINED_STAGE_KEY = "__combined_stage";
65
+ const SWITCH_SELECTION_KEY = "__switch_selection";
66
+ const FORM_FIELD_ORDER_EXTENSION = "x-apifuse-field-order";
67
+
68
+ function isRecord(value: unknown): value is JsonObject {
69
+ return !!value && typeof value === "object" && !Array.isArray(value);
70
+ }
71
+
72
+ function ensureRecord(value: unknown): JsonObject {
73
+ return isRecord(value) ? value : {};
74
+ }
75
+
76
+ function createTurn(
77
+ kind: TurnKind,
78
+ options: Omit<AuthTurn, "kind" | "turnId"> = {},
79
+ ): AuthTurn {
80
+ return {
81
+ kind,
82
+ turnId: randomUUID(),
83
+ ...options,
84
+ };
85
+ }
86
+
87
+ function createExpiresAt(ttlMs: number): string {
88
+ return new Date(Date.now() + ttlMs).toISOString();
89
+ }
90
+
91
+ function getRequiredEnv(ctx: FlowContext, key: string): string {
92
+ const value = ctx.env.get(key);
93
+ if (!value) {
94
+ throw new ProviderSecretError(`Missing required secret: ${key}`);
95
+ }
96
+ return value;
97
+ }
98
+
99
+ function toBase64Url(input: Buffer): string {
100
+ return input
101
+ .toString("base64")
102
+ .replace(/\+/g, "-")
103
+ .replace(/\//g, "_")
104
+ .replace(/=+$/g, "");
105
+ }
106
+
107
+ function createCodeVerifier(): string {
108
+ return toBase64Url(randomBytes(32));
109
+ }
110
+
111
+ function createCodeChallenge(verifier: string): string {
112
+ return createHash("sha256").update(verifier).digest("base64url");
113
+ }
114
+
115
+ function normalizeError(error: unknown): Error {
116
+ if (error instanceof Error) {
117
+ return error;
118
+ }
119
+
120
+ return new Error("Unexpected ceremony error");
121
+ }
122
+
123
+ function toRetryTurn(error: unknown, hint: string): AuthTurn {
124
+ const normalizedError = normalizeError(error);
125
+
126
+ if (normalizedError instanceof FlowExpiredError) {
127
+ return validateCeremonyOutput(
128
+ createTurn("abort", {
129
+ hint: normalizedError.message,
130
+ data: { code: normalizedError.code ?? "flow_expired" },
131
+ }),
132
+ );
133
+ }
134
+
135
+ const retryData =
136
+ normalizedError instanceof ValidationError
137
+ ? { errors: normalizedError.zodError }
138
+ : { error: normalizedError.message };
139
+
140
+ return validateCeremonyOutput(
141
+ createTurn("retry", {
142
+ hint: `${hint}: ${normalizedError.message}`,
143
+ data: retryData,
144
+ }),
145
+ );
146
+ }
147
+
148
+ async function runCeremonyHandler(
149
+ handler: CeremonyHandler,
150
+ hint: string,
151
+ ctx: FlowContext,
152
+ input?: Record<string, unknown>,
153
+ ): Promise<AuthTurn> {
154
+ try {
155
+ return validateCeremonyOutput(await handler(ctx, input));
156
+ } catch (error) {
157
+ return toRetryTurn(error, hint);
158
+ }
159
+ }
160
+
161
+ function getString(
162
+ input: Record<string, unknown>,
163
+ key: string,
164
+ ): string | undefined {
165
+ const value = input[key];
166
+ return typeof value === "string" ? value : undefined;
167
+ }
168
+
169
+ function getNestedRecord(ctx: FlowContext, key: string): JsonObject {
170
+ return ensureRecord(ctx.context.get(key));
171
+ }
172
+
173
+ function buildJsonSchemaForm(
174
+ expectedInput: JsonObject,
175
+ hint: string,
176
+ ): AuthTurn {
177
+ return createTurn("form", {
178
+ hint,
179
+ expectedInput: withDeclaredFormFieldOrder(expectedInput),
180
+ data: {},
181
+ });
182
+ }
183
+
184
+ function withDeclaredFormFieldOrder(expectedInput: JsonObject): JsonObject {
185
+ const properties = expectedInput.properties;
186
+ if (!isRecord(properties)) {
187
+ return expectedInput;
188
+ }
189
+
190
+ const existingOrder = expectedInput[FORM_FIELD_ORDER_EXTENSION];
191
+ if (
192
+ Array.isArray(existingOrder) &&
193
+ existingOrder.every((value) => typeof value === "string")
194
+ ) {
195
+ return expectedInput;
196
+ }
197
+
198
+ return {
199
+ ...expectedInput,
200
+ [FORM_FIELD_ORDER_EXTENSION]: Object.keys(properties),
201
+ };
202
+ }
203
+
204
+ export function validateCeremonyOutput(turn: unknown): AuthTurn {
205
+ if (!validateAuthTurn(turn)) {
206
+ const detail = validateAuthTurn.errors
207
+ ?.map(
208
+ (error) => `${error.instancePath || "$"} ${error.message ?? "invalid"}`,
209
+ )
210
+ .join("; ");
211
+ throw new TurnValidationError(detail || "Invalid AuthTurn output");
212
+ }
213
+
214
+ return turn;
215
+ }
216
+
217
+ export function createOAuth2Ceremony(options: {
218
+ authorizeUrl: string;
219
+ tokenUrl: string;
220
+ clientIdEnvKey: string;
221
+ clientSecretEnvKey: string;
222
+ scopes: string[];
223
+ usePKCE?: boolean;
224
+ }): AuthFlowDefinition {
225
+ return {
226
+ start: (ctx) =>
227
+ runCeremonyHandler(
228
+ async () => {
229
+ const clientId = getRequiredEnv(ctx, options.clientIdEnvKey);
230
+ getRequiredEnv(ctx, options.clientSecretEnvKey);
231
+ const state = toBase64Url(randomBytes(24));
232
+ ctx.context.set(OAUTH2_STATE_KEY, state);
233
+
234
+ const authorizeUrl = new URL(options.authorizeUrl);
235
+ authorizeUrl.searchParams.set("response_type", "code");
236
+ authorizeUrl.searchParams.set("client_id", clientId);
237
+ authorizeUrl.searchParams.set("state", state);
238
+ if (options.scopes.length > 0) {
239
+ authorizeUrl.searchParams.set("scope", options.scopes.join(" "));
240
+ }
241
+
242
+ if (options.usePKCE) {
243
+ const verifier = createCodeVerifier();
244
+ ctx.context.set(OAUTH2_PKCE_VERIFIER_KEY, verifier);
245
+ authorizeUrl.searchParams.set(
246
+ "code_challenge",
247
+ createCodeChallenge(verifier),
248
+ );
249
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
250
+ }
251
+
252
+ return createTurn("redirect", {
253
+ data: { url: authorizeUrl.toString() },
254
+ hint: "Open the provider authorization page to continue.",
255
+ expectedInput: {
256
+ type: "object",
257
+ required: ["code", "state"],
258
+ properties: {
259
+ code: { type: "string" },
260
+ state: { type: "string" },
261
+ },
262
+ },
263
+ });
264
+ },
265
+ "OAuth start failed",
266
+ ctx,
267
+ ),
268
+ continue: (ctx, input = {}) =>
269
+ runCeremonyHandler(
270
+ async () => {
271
+ const code = getString(input, "code");
272
+ const receivedState = getString(input, "state");
273
+ const storedState = ctx.context.get(OAUTH2_STATE_KEY);
274
+ const codeVerifier = ctx.context.get(OAUTH2_PKCE_VERIFIER_KEY);
275
+
276
+ if (!code || !receivedState || receivedState !== storedState) {
277
+ throw new ValidationError("OAuth callback payload is invalid.");
278
+ }
279
+
280
+ const tokenResponse = await ctx.http.post(options.tokenUrl, {
281
+ grant_type: "authorization_code",
282
+ code,
283
+ client_id: getRequiredEnv(ctx, options.clientIdEnvKey),
284
+ client_secret: getRequiredEnv(ctx, options.clientSecretEnvKey),
285
+ ...(options.usePKCE
286
+ ? typeof codeVerifier === "string"
287
+ ? { code_verifier: codeVerifier }
288
+ : {}
289
+ : {}),
290
+ });
291
+
292
+ return createTurn("complete", {
293
+ data: { credential: ensureRecord(tokenResponse.data) },
294
+ hint: "OAuth flow completed.",
295
+ });
296
+ },
297
+ "OAuth token exchange failed",
298
+ ctx,
299
+ input,
300
+ ),
301
+ abort: async () =>
302
+ validateCeremonyOutput(
303
+ createTurn("abort", { hint: "OAuth flow aborted." }),
304
+ ),
305
+ };
306
+ }
307
+
308
+ export function createDeviceFlowCeremony(options: {
309
+ deviceCodeUrl: string;
310
+ tokenUrl: string;
311
+ clientIdEnvKey: string;
312
+ clientSecretEnvKey?: string;
313
+ scopes: string[];
314
+ }): AuthFlowDefinition {
315
+ return {
316
+ start: (ctx) =>
317
+ runCeremonyHandler(
318
+ async () => {
319
+ const response = await ctx.http.post(options.deviceCodeUrl, {
320
+ client_id: getRequiredEnv(ctx, options.clientIdEnvKey),
321
+ scope: options.scopes.join(" "),
322
+ });
323
+ const data = ensureRecord(response.data);
324
+ ctx.context.set(DEVICE_FLOW_KEY, data);
325
+
326
+ return createTurn("message", {
327
+ data: {
328
+ user_code: getString(data, "user_code") ?? "",
329
+ verification_uri: getString(data, "verification_uri") ?? "",
330
+ },
331
+ hint: "Enter the code on the verification page, then poll for completion.",
332
+ timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 120_000 },
333
+ });
334
+ },
335
+ "Device flow start failed",
336
+ ctx,
337
+ ),
338
+ continue: async () =>
339
+ validateCeremonyOutput(
340
+ createTurn("poll", {
341
+ hint: "Continue polling until the device flow completes.",
342
+ timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 120_000 },
343
+ }),
344
+ ),
345
+ poll: (ctx) =>
346
+ runCeremonyHandler(
347
+ async () => {
348
+ const deviceData = getNestedRecord(ctx, DEVICE_FLOW_KEY);
349
+ const deviceCode = getString(deviceData, "device_code");
350
+ if (!deviceCode) {
351
+ throw new FlowExpiredError("Device flow state is missing.");
352
+ }
353
+
354
+ const response = await ctx.http.post(options.tokenUrl, {
355
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
356
+ device_code: deviceCode,
357
+ client_id: getRequiredEnv(ctx, options.clientIdEnvKey),
358
+ ...(options.clientSecretEnvKey
359
+ ? {
360
+ client_secret: getRequiredEnv(
361
+ ctx,
362
+ options.clientSecretEnvKey,
363
+ ),
364
+ }
365
+ : {}),
366
+ });
367
+ const data = ensureRecord(response.data);
368
+ const errorCode = getString(data, "error");
369
+
370
+ if (
371
+ errorCode === "authorization_pending" ||
372
+ errorCode === "slow_down"
373
+ ) {
374
+ return createTurn("poll", {
375
+ data,
376
+ hint: "Authorization pending.",
377
+ timing: {
378
+ suggestedPollIntervalMs:
379
+ errorCode === "slow_down" ? 10_000 : 5_000,
380
+ maxWaitMs: 120_000,
381
+ },
382
+ });
383
+ }
384
+
385
+ if (errorCode === "expired_token") {
386
+ throw new FlowExpiredError("Device code expired.");
387
+ }
388
+
389
+ return createTurn("complete", {
390
+ data: { credential: data },
391
+ hint: "Device flow completed.",
392
+ });
393
+ },
394
+ "Device flow polling failed",
395
+ ctx,
396
+ ),
397
+ abort: async () =>
398
+ validateCeremonyOutput(
399
+ createTurn("abort", { hint: "Device flow aborted." }),
400
+ ),
401
+ };
402
+ }
403
+
404
+ export function createWebAuthnCeremony(options: {
405
+ rpId: string;
406
+ challengeUrl?: string;
407
+ verifyUrl?: string;
408
+ timeoutMs?: number;
409
+ }): AuthFlowDefinition {
410
+ return {
411
+ start: (ctx) =>
412
+ runCeremonyHandler(
413
+ async () => {
414
+ const challenge = toBase64Url(randomBytes(32));
415
+ ctx.context.set("__webauthn_challenge", challenge);
416
+
417
+ if (options.challengeUrl) {
418
+ await ctx.http.post(options.challengeUrl, {
419
+ challenge,
420
+ rpId: options.rpId,
421
+ });
422
+ }
423
+
424
+ return createTurn("challenge", {
425
+ data: { challenge, rpId: options.rpId },
426
+ hint: "Complete the WebAuthn prompt in your browser.",
427
+ expiresAt: createExpiresAt(options.timeoutMs ?? 60_000),
428
+ expectedInput: {
429
+ type: "object",
430
+ required: ["attestation"],
431
+ properties: { attestation: { type: "object" } },
432
+ },
433
+ });
434
+ },
435
+ "WebAuthn start failed",
436
+ ctx,
437
+ ),
438
+ continue: (ctx, input = {}) =>
439
+ runCeremonyHandler(
440
+ async () => {
441
+ if (!isRecord(input.attestation)) {
442
+ throw new ValidationError("WebAuthn attestation is required.");
443
+ }
444
+
445
+ const challenge = ctx.context.get("__webauthn_challenge");
446
+ if (typeof challenge !== "string" || challenge.length === 0) {
447
+ throw new FlowExpiredError("WebAuthn challenge has expired.");
448
+ }
449
+
450
+ if (options.verifyUrl) {
451
+ await ctx.http.post(options.verifyUrl, {
452
+ challenge,
453
+ attestation: input.attestation,
454
+ rpId: options.rpId,
455
+ });
456
+ }
457
+
458
+ return createTurn("complete", {
459
+ data: { credential: { attestation: input.attestation } },
460
+ hint: "WebAuthn ceremony completed.",
461
+ });
462
+ },
463
+ "WebAuthn verification failed",
464
+ ctx,
465
+ input,
466
+ ),
467
+ abort: async () =>
468
+ validateCeremonyOutput(
469
+ createTurn("abort", { hint: "WebAuthn ceremony aborted." }),
470
+ ),
471
+ };
472
+ }
473
+
474
+ export function createMagicLinkCeremony(options: {
475
+ sendUrl: string;
476
+ verifyUrl: string;
477
+ emailField?: string;
478
+ expiresInMs?: number;
479
+ }): AuthFlowDefinition {
480
+ const emailField = options.emailField ?? "email";
481
+
482
+ return {
483
+ start: (ctx, input = {}) =>
484
+ runCeremonyHandler(
485
+ async () => {
486
+ const email = getString(input, emailField);
487
+ if (!email) {
488
+ return buildJsonSchemaForm(
489
+ {
490
+ type: "object",
491
+ required: [emailField],
492
+ properties: {
493
+ [emailField]: { type: "string", format: "email" },
494
+ },
495
+ },
496
+ "Provide the email address to receive a magic link.",
497
+ );
498
+ }
499
+
500
+ await ctx.http.post(options.sendUrl, { email });
501
+ ctx.context.set(MAGIC_LINK_KEY, {
502
+ email,
503
+ expiresAt: createExpiresAt(options.expiresInMs ?? 300_000),
504
+ });
505
+
506
+ return createTurn("message", {
507
+ data: { email },
508
+ hint: "Check your email for the magic link, then poll for completion.",
509
+ timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 300_000 },
510
+ });
511
+ },
512
+ "Magic link start failed",
513
+ ctx,
514
+ input,
515
+ ),
516
+ continue: async () =>
517
+ validateCeremonyOutput(
518
+ createTurn("poll", {
519
+ hint: "Continue polling for magic link completion.",
520
+ timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 300_000 },
521
+ }),
522
+ ),
523
+ poll: (ctx) =>
524
+ runCeremonyHandler(
525
+ async () => {
526
+ const state = getNestedRecord(ctx, MAGIC_LINK_KEY);
527
+ const email = getString(state, "email");
528
+ const expiresAt = getString(state, "expiresAt");
529
+
530
+ if (!email || !expiresAt) {
531
+ throw new FlowExpiredError("Magic link state is missing.");
532
+ }
533
+
534
+ if (new Date(expiresAt).getTime() < Date.now()) {
535
+ throw new FlowExpiredError("Magic link expired.");
536
+ }
537
+
538
+ const response = await ctx.http.post(options.verifyUrl, { email });
539
+ const data = ensureRecord(response.data);
540
+ if (data.completed !== true) {
541
+ return createTurn("poll", {
542
+ data,
543
+ hint: "Waiting for the magic link click.",
544
+ timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 300_000 },
545
+ });
546
+ }
547
+
548
+ return createTurn("complete", {
549
+ data: { credential: ensureRecord(data.credential) },
550
+ hint: "Magic link completed.",
551
+ });
552
+ },
553
+ "Magic link polling failed",
554
+ ctx,
555
+ ),
556
+ abort: async () =>
557
+ validateCeremonyOutput(
558
+ createTurn("abort", { hint: "Magic link flow aborted." }),
559
+ ),
560
+ };
561
+ }
562
+
563
+ export function createFormCeremony(options: {
564
+ schema: JsonObject;
565
+ hint?: string;
566
+ mapCredential?: (input: Record<string, unknown>) => JsonObject;
567
+ }): AuthFlowDefinition {
568
+ return {
569
+ start: async () =>
570
+ validateCeremonyOutput(
571
+ buildJsonSchemaForm(
572
+ options.schema,
573
+ options.hint ?? "Provide the required input to continue.",
574
+ ),
575
+ ),
576
+ continue: (ctx, input = {}) =>
577
+ runCeremonyHandler(
578
+ async () => {
579
+ const { prevalidate } = await import("../runtime/prevalidate");
580
+ const result = prevalidate(options.schema, input);
581
+ if (!result.valid) {
582
+ throw new ValidationError("Form input failed validation.", {
583
+ zodError: result.errors,
584
+ });
585
+ }
586
+
587
+ return createTurn("complete", {
588
+ data: {
589
+ credential: options.mapCredential
590
+ ? options.mapCredential(input)
591
+ : input,
592
+ },
593
+ hint: "Form completed.",
594
+ });
595
+ },
596
+ "Form submission failed",
597
+ ctx,
598
+ input,
599
+ ),
600
+ abort: async () =>
601
+ validateCeremonyOutput(
602
+ createTurn("abort", { hint: "Form ceremony aborted." }),
603
+ ),
604
+ };
605
+ }
606
+
607
+ export function combineCeremonies(
608
+ ...ceremonies: AuthFlowDefinition[]
609
+ ): AuthFlowDefinition {
610
+ function getStage(ctx: FlowContext): number {
611
+ const rawStage = ctx.context.get(COMBINED_STAGE_KEY);
612
+ return typeof rawStage === "number" ? rawStage : 0;
613
+ }
614
+
615
+ return {
616
+ start: (ctx) =>
617
+ runCeremonyHandler(
618
+ async () => {
619
+ ctx.context.set(COMBINED_STAGE_KEY, 0);
620
+ const first = ceremonies[0];
621
+ if (!first) {
622
+ throw new ValidationError("At least one ceremony is required.");
623
+ }
624
+ return await first.start(ctx);
625
+ },
626
+ "Combined ceremony start failed",
627
+ ctx,
628
+ ),
629
+ continue: (ctx, input = {}) =>
630
+ runCeremonyHandler(
631
+ async () => {
632
+ const stage = getStage(ctx);
633
+ const current = ceremonies[stage];
634
+ if (!current) {
635
+ throw new FlowExpiredError("Combined ceremony stage is invalid.");
636
+ }
637
+
638
+ const result = await current.continue(ctx, input);
639
+ if (result.kind !== "complete") {
640
+ return result;
641
+ }
642
+
643
+ const nextStage = stage + 1;
644
+ const nextCeremony = ceremonies[nextStage];
645
+ if (!nextCeremony) {
646
+ return result;
647
+ }
648
+
649
+ ctx.context.set(COMBINED_STAGE_KEY, nextStage);
650
+ return await nextCeremony.start(ctx);
651
+ },
652
+ "Combined ceremony continue failed",
653
+ ctx,
654
+ input,
655
+ ),
656
+ poll: (ctx) =>
657
+ runCeremonyHandler(
658
+ async () => {
659
+ const current = ceremonies[getStage(ctx)];
660
+ if (!current?.poll) {
661
+ throw new ValidationError(
662
+ "Current ceremony does not support polling.",
663
+ );
664
+ }
665
+ return await current.poll(ctx);
666
+ },
667
+ "Combined ceremony poll failed",
668
+ ctx,
669
+ ),
670
+ abort: (ctx) =>
671
+ runCeremonyHandler(
672
+ async () => {
673
+ const current = ceremonies[getStage(ctx)];
674
+ if (current?.abort) {
675
+ return await current.abort(ctx);
676
+ }
677
+ return createTurn("abort", { hint: "Combined ceremony aborted." });
678
+ },
679
+ "Combined ceremony abort failed",
680
+ ctx,
681
+ ),
682
+ };
683
+ }
684
+
685
+ export function createSwitchCeremony(options: {
686
+ choices: Record<string, AuthFlowDefinition>;
687
+ prompt?: string;
688
+ }): AuthFlowDefinition {
689
+ const choiceKeys = Object.keys(options.choices);
690
+
691
+ return {
692
+ start: async () =>
693
+ validateCeremonyOutput(
694
+ createTurn("multi_choice", {
695
+ data: { choices: choiceKeys },
696
+ hint: options.prompt ?? "Choose an authentication method.",
697
+ expectedInput: {
698
+ type: "object",
699
+ required: ["choice"],
700
+ properties: {
701
+ choice: { type: "string", enum: choiceKeys },
702
+ },
703
+ },
704
+ }),
705
+ ),
706
+ continue: (ctx, input = {}) =>
707
+ runCeremonyHandler(
708
+ async () => {
709
+ const storedChoice = ctx.context.get(SWITCH_SELECTION_KEY);
710
+ const choice =
711
+ typeof storedChoice === "string"
712
+ ? storedChoice
713
+ : getString(input, "choice");
714
+
715
+ if (!choice || !options.choices[choice]) {
716
+ throw new ValidationError("A valid choice is required.");
717
+ }
718
+
719
+ ctx.context.set(SWITCH_SELECTION_KEY, choice);
720
+ const ceremony = options.choices[choice];
721
+ if (storedChoice === undefined) {
722
+ return await ceremony.start(ctx);
723
+ }
724
+
725
+ return await ceremony.continue(ctx, input);
726
+ },
727
+ "Switch ceremony failed",
728
+ ctx,
729
+ input,
730
+ ),
731
+ poll: (ctx) =>
732
+ runCeremonyHandler(
733
+ async () => {
734
+ const choice = ctx.context.get(SWITCH_SELECTION_KEY);
735
+ if (typeof choice !== "string") {
736
+ throw new FlowExpiredError("No selected ceremony is active.");
737
+ }
738
+
739
+ const ceremony = options.choices[choice];
740
+ if (!ceremony?.poll) {
741
+ throw new ValidationError(
742
+ "Selected ceremony does not support polling.",
743
+ );
744
+ }
745
+
746
+ return await ceremony.poll(ctx);
747
+ },
748
+ "Switch ceremony poll failed",
749
+ ctx,
750
+ ),
751
+ abort: (ctx) =>
752
+ runCeremonyHandler(
753
+ async () => {
754
+ const choice = ctx.context.get(SWITCH_SELECTION_KEY);
755
+ if (typeof choice === "string") {
756
+ const ceremony = options.choices[choice];
757
+ if (ceremony?.abort) {
758
+ return await ceremony.abort(ctx);
759
+ }
760
+ }
761
+
762
+ return createTurn("abort", { hint: "Switch ceremony aborted." });
763
+ },
764
+ "Switch ceremony abort failed",
765
+ ctx,
766
+ ),
767
+ };
768
+ }