@apifuse/provider-sdk 2.1.0-beta.11 → 2.1.0-beta.13

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/src/cli/create.ts CHANGED
@@ -734,8 +734,8 @@ function renderAuthBlock(authMode: CreateAuthMode): string {
734
734
  return `{
735
735
  mode: "credentials",
736
736
  flow: {
737
- start: async () => ({
738
- kind: "input",
737
+ start: async (_ctx) => ({
738
+ kind: "form",
739
739
  turnId: crypto.randomUUID(),
740
740
  expectedInput: {
741
741
  schema: {
@@ -778,7 +778,7 @@ function renderAuthBlock(authMode: CreateAuthMode): string {
778
778
  return `{
779
779
  mode: "oauth2",
780
780
  flow: {
781
- start: async () => ({
781
+ start: async (_ctx) => ({
782
782
  kind: "redirect",
783
783
  turnId: crypto.randomUUID(),
784
784
  data: {
package/src/define.ts CHANGED
@@ -374,6 +374,14 @@ function validateProviderShape(config: unknown): void {
374
374
  VALID_AUTH_MODES,
375
375
  String(config.id),
376
376
  );
377
+ if (auth && typeof auth === "object" && "exchange" in auth) {
378
+ throw new ProviderError(
379
+ `Provider "${String(config.id)}" auth.exchange is not part of the Provider SDK auth contract`,
380
+ {
381
+ fix: "Use the single canonical auth interface: auth.flow. Gateway calls auth.flow.start/continue/poll/abort/refresh only and persists complete turn data.credential as-is, so put login/token/session exchange inside auth.flow.continue.",
382
+ },
383
+ );
384
+ }
377
385
  if (
378
386
  auth &&
379
387
  typeof auth === "object" &&
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // @apifuse/provider-sdk
2
2
 
3
+ export * from "./auth";
3
4
  export * from "./ceremonies";
4
5
  export * from "./choice-token";
5
6
  export type {
@@ -122,6 +123,12 @@ export type {
122
123
  Bcp47Locale,
123
124
  BrowserEngine,
124
125
  BrowserOptions,
126
+ BrowserResourceBody,
127
+ BrowserResourceDecision,
128
+ BrowserResourceMethod,
129
+ BrowserResourcePolicy,
130
+ BrowserResourceRequest,
131
+ BrowserResourceRoute,
125
132
  ConnectionMode,
126
133
  ContextDeclaration,
127
134
  CookieJar,
package/src/lint.ts CHANGED
@@ -22,8 +22,12 @@ type ProviderAuthLike = {
22
22
  abort?: unknown;
23
23
  refresh?: unknown;
24
24
  };
25
+ exchange?: unknown;
25
26
  };
26
27
 
28
+ const AUTH_OPERATION_ID_PATTERN =
29
+ /^(?:auth[-_])?(?:login|exchange|continue|refresh|callback)(?:[-_]|$)/i;
30
+
27
31
  type ProviderContractMetaLike = {
28
32
  publicSchemaFieldNames?: "normalized";
29
33
  };
@@ -127,6 +131,10 @@ function lintReviewed(
127
131
  ];
128
132
  }
129
133
 
134
+ function isProviderAuthLike(value: unknown): value is ProviderAuthLike {
135
+ return !!value && typeof value === "object" && !Array.isArray(value);
136
+ }
137
+
130
138
  function hasReusableSecretKeys(keys: readonly string[] | undefined): boolean {
131
139
  if (!keys) {
132
140
  return false;
@@ -215,6 +223,15 @@ function lintAuthModel(provider: {
215
223
  });
216
224
  }
217
225
 
226
+ if (isProviderAuthLike(provider.auth) && "exchange" in provider.auth) {
227
+ diagnostics.push({
228
+ rule: "auth-exchange-unsupported",
229
+ level: "error",
230
+ field: "auth.exchange",
231
+ message: `${providerLabel} must not define auth.exchange. The Provider SDK has one auth interface: auth.flow. Gateway only calls auth.flow.start/continue/poll/abort/refresh and persists complete turn data.credential as-is; put login/token/session exchange inside auth.flow.continue.`,
232
+ });
233
+ }
234
+
218
235
  if (authMode === "credentials" && credentialKeys.length === 0) {
219
236
  diagnostics.push({
220
237
  rule: "credential-keys-required-when-credentials-mode",
@@ -1006,6 +1023,22 @@ export function lintProvider(
1006
1023
  ...lintSelfHostedBrowserPatterns(provider, options),
1007
1024
  ];
1008
1025
 
1026
+ if (provider.operations) {
1027
+ const authMode = provider.auth?.mode;
1028
+ if (authMode === "credentials" || authMode === "oauth2") {
1029
+ for (const operationKey of Object.keys(provider.operations)) {
1030
+ if (AUTH_OPERATION_ID_PATTERN.test(operationKey)) {
1031
+ diagnostics.push({
1032
+ rule: "auth-operation-unsupported",
1033
+ level: "error",
1034
+ field: `operations.${operationKey}`,
1035
+ message: `Provider "${provider.id ?? "unknown"}" operation "${operationKey}" looks like a login/token/session exchange endpoint. Authenticated providers must expose login through the single auth.flow interface because Gateway persists only auth.flow complete turn data.credential as the connection credential. Move this logic into auth.flow.continue instead of a provider operation.`,
1036
+ });
1037
+ }
1038
+ }
1039
+ }
1040
+ }
1041
+
1009
1042
  if (!provider.operations) {
1010
1043
  return diagnostics;
1011
1044
  }
package/src/provider.ts CHANGED
@@ -1,3 +1,22 @@
1
+ export {
2
+ AuthAbortError,
3
+ credentialsAuthChallenge,
4
+ createAuthFlowHelpers,
5
+ defineCredentialsAuth,
6
+ } from "./auth";
7
+ export type {
8
+ CredentialsAuthChallengeDefinition,
9
+ CredentialsAuthChallengeRequest,
10
+ CredentialsAuthCompleteResult,
11
+ CredentialsAuthCredential,
12
+ CredentialsAuthField,
13
+ CredentialsAuthFields,
14
+ CredentialsAuthFieldType,
15
+ CredentialsAuthInput,
16
+ CredentialsAuthLoginResult,
17
+ DefineCredentialsAuthOptions,
18
+ DefinedCredentialsAuth,
19
+ } from "./auth";
1
20
  export { createFormCeremony } from "./ceremonies";
2
21
  export {
3
22
  assertFreshProviderChoiceIssuedAt,
@@ -50,7 +69,12 @@ export {
50
69
  z,
51
70
  } from "./schema";
52
71
  export type {
72
+ AuthAbortData,
73
+ AuthAbortRetry,
74
+ AuthFlowTerminalContext,
53
75
  AuthMode,
76
+ AuthSafeData,
77
+ AuthSafeJson,
54
78
  FlowContext,
55
79
  HealthCheckAssertionContext,
56
80
  HealthCheckCase,
@@ -1,4 +1,5 @@
1
1
  import { ContextAccessError } from "../errors";
2
+ import { createAuthFlowHelpers } from "../auth";
2
3
  import type {
3
4
  ContextScratchpad,
4
5
  EnvContext,
@@ -70,5 +71,6 @@ export function createFlowContext(options: {
70
71
  env: options.env,
71
72
  context: createScratchpad(options.allowedKeys, options.initialContext),
72
73
  stt: options.stt ?? createUnsupportedSttClient(),
74
+ auth: createAuthFlowHelpers(),
73
75
  };
74
76
  }
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import type { Frame, LaunchOptions, Locator, Page } from "playwright";
2
+ import type { Frame, LaunchOptions, Locator, Page, Request, Route } from "playwright";
3
3
 
4
4
  import { ProviderError } from "../errors";
5
5
  import type {
@@ -11,11 +11,18 @@ import type {
11
11
  BrowserLocator,
12
12
  BrowserOptions,
13
13
  BrowserPage,
14
+ BrowserResourceBody,
15
+ BrowserResourceDecision,
16
+ BrowserResourceMethod,
17
+ BrowserResourcePolicy,
18
+ BrowserResourceRequest,
14
19
  } from "../types";
15
20
 
16
21
  const require = createRequire(import.meta.url);
17
22
  const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
18
23
  const SELECTOR_POLL_INTERVAL_MS = 100;
24
+ const RESOURCE_POLICY_ROUTE_PATTERN = "**/*";
25
+ const DEFAULT_RESOURCE_METHODS = ["GET", "HEAD"] as const;
19
26
 
20
27
  type PlaywrightModule = typeof import("playwright");
21
28
  type PlaywrightExtraModule = {
@@ -62,8 +69,153 @@ type CdpFrameTreeNode = {
62
69
  };
63
70
  };
64
71
 
72
+ type CdpFetchFulfillParams = {
73
+ readonly requestId: string;
74
+ readonly responseCode: number;
75
+ readonly responseHeaders?: readonly {
76
+ readonly name: string;
77
+ readonly value: string;
78
+ }[];
79
+ readonly body?: string;
80
+ };
81
+
65
82
  type BrowserPageContract = BrowserPage;
66
83
 
84
+ function toResourceBody(
85
+ body: BrowserResourceBody | undefined,
86
+ ): Buffer | string | undefined {
87
+ if (body === undefined || typeof body === "string" || Buffer.isBuffer(body)) {
88
+ return body;
89
+ }
90
+
91
+ if (body instanceof ArrayBuffer) {
92
+ return Buffer.from(new Uint8Array(body));
93
+ }
94
+
95
+ return Buffer.from(body);
96
+ }
97
+
98
+ function isResourceMethod(method: string): method is BrowserResourceMethod {
99
+ return method === "GET" || method === "HEAD";
100
+ }
101
+
102
+ async function toResourceRequest(
103
+ request: Request,
104
+ ): Promise<BrowserResourceRequest | null> {
105
+ const method = request.method().toUpperCase();
106
+ if (!isResourceMethod(method)) {
107
+ return null;
108
+ }
109
+
110
+ return {
111
+ headers: await request.allHeaders(),
112
+ method,
113
+ resourceType: request.resourceType(),
114
+ url: request.url(),
115
+ };
116
+ }
117
+
118
+ function toCdpResourceRequest(
119
+ params: unknown,
120
+ ): { requestId: string; request: BrowserResourceRequest } | null {
121
+ if (!isRecord(params)) {
122
+ return null;
123
+ }
124
+
125
+ const requestId = params.requestId;
126
+ const rawRequest = params.request;
127
+ if (typeof requestId !== "string" || !isRecord(rawRequest)) {
128
+ return null;
129
+ }
130
+
131
+ const url = rawRequest.url;
132
+ const method = String(rawRequest.method ?? "").toUpperCase();
133
+ if (typeof url !== "string" || !isResourceMethod(method)) {
134
+ return null;
135
+ }
136
+
137
+ return {
138
+ requestId,
139
+ request: {
140
+ headers: toCdpResourceHeaders(rawRequest.headers),
141
+ method,
142
+ resourceType:
143
+ typeof params.resourceType === "string" ? params.resourceType : undefined,
144
+ url,
145
+ },
146
+ };
147
+ }
148
+
149
+ function getCdpPausedRequestId(params: unknown): string | null {
150
+ if (!isRecord(params) || typeof params.requestId !== "string") {
151
+ return null;
152
+ }
153
+
154
+ return params.requestId;
155
+ }
156
+
157
+ function toCdpResourceHeaders(value: unknown): Record<string, string> {
158
+ if (!isRecord(value)) {
159
+ return {};
160
+ }
161
+
162
+ const headers: Record<string, string> = {};
163
+ for (const [name, headerValue] of Object.entries(value)) {
164
+ if (typeof headerValue === "string") {
165
+ headers[name] = headerValue;
166
+ }
167
+ }
168
+
169
+ return headers;
170
+ }
171
+
172
+ function matchesResourceRoute(
173
+ match: BrowserResourcePolicy["routes"][number]["match"],
174
+ request: BrowserResourceRequest,
175
+ ): boolean {
176
+ if (typeof match === "string") {
177
+ return request.url === match;
178
+ }
179
+ if (match instanceof RegExp) {
180
+ return match.test(request.url);
181
+ }
182
+
183
+ return match(request);
184
+ }
185
+
186
+ function toCdpFulfillParams(
187
+ requestId: string,
188
+ decision: Extract<BrowserResourceDecision, { readonly action: "fulfill" }>,
189
+ ): CdpFetchFulfillParams {
190
+ const body = toResourceBody(decision.body);
191
+ return {
192
+ ...(body === undefined
193
+ ? {}
194
+ : { body: Buffer.from(body).toString("base64") }),
195
+ ...(decision.headers === undefined
196
+ ? {}
197
+ : {
198
+ responseHeaders: Object.entries(decision.headers).map(
199
+ ([name, value]) => ({ name, value }),
200
+ ),
201
+ }),
202
+ requestId,
203
+ responseCode: decision.status ?? 200,
204
+ };
205
+ }
206
+
207
+ async function fulfillResourceRoute(
208
+ route: Route,
209
+ decision: Extract<BrowserResourceDecision, { readonly action: "fulfill" }>,
210
+ ): Promise<void> {
211
+ const body = toResourceBody(decision.body);
212
+ await route.fulfill({
213
+ ...(body === undefined ? {} : { body }),
214
+ ...(decision.headers === undefined ? {} : { headers: decision.headers }),
215
+ status: decision.status ?? 200,
216
+ });
217
+ }
218
+
67
219
  export type BrowserClientOptions = BrowserOptions & {
68
220
  allowedHosts?: string[];
69
221
  cdpUrl?: string;
@@ -398,6 +550,47 @@ class PlaywrightBrowserPage implements BrowserPageContract {
398
550
  async close(): Promise<void> {
399
551
  await this.page.close();
400
552
  }
553
+
554
+ async withResourcePolicy<T>(
555
+ policy: BrowserResourcePolicy,
556
+ run: () => Promise<T>,
557
+ ): Promise<T> {
558
+ const allowedMethods = new Set(
559
+ policy.allowedMethods ?? DEFAULT_RESOURCE_METHODS,
560
+ );
561
+ const handler = async (route: Route): Promise<void> => {
562
+ const request = await toResourceRequest(route.request());
563
+ if (!request || !allowedMethods.has(request.method)) {
564
+ await route.abort("blockedbyclient");
565
+ return;
566
+ }
567
+
568
+ for (const resourceRoute of policy.routes) {
569
+ if (!matchesResourceRoute(resourceRoute.match, request)) {
570
+ continue;
571
+ }
572
+
573
+ const decision = await resourceRoute.handle(request);
574
+ switch (decision.action) {
575
+ case "fulfill":
576
+ await fulfillResourceRoute(route, decision);
577
+ return;
578
+ case "block":
579
+ await route.abort("blockedbyclient");
580
+ return;
581
+ }
582
+ }
583
+
584
+ await route.abort("blockedbyclient");
585
+ };
586
+
587
+ await this.page.route(RESOURCE_POLICY_ROUTE_PATTERN, handler);
588
+ try {
589
+ return await run();
590
+ } finally {
591
+ await this.page.unroute(RESOURCE_POLICY_ROUTE_PATTERN, handler);
592
+ }
593
+ }
401
594
  }
402
595
 
403
596
  class PlaywrightBrowserClient implements SupportedBrowserClient {
@@ -1055,6 +1248,105 @@ class CdpPoolBrowserPage implements BrowserPageContract {
1055
1248
  }
1056
1249
  }
1057
1250
 
1251
+ async withResourcePolicy<T>(
1252
+ policy: BrowserResourcePolicy,
1253
+ run: () => Promise<T>,
1254
+ ): Promise<T> {
1255
+ const allowedMethods = new Set(
1256
+ policy.allowedMethods ?? DEFAULT_RESOURCE_METHODS,
1257
+ );
1258
+ const handlePausedRequest = (params: unknown): void => {
1259
+ void this.handleResourcePolicyPausedRequest(
1260
+ params,
1261
+ policy,
1262
+ allowedMethods,
1263
+ );
1264
+ };
1265
+
1266
+ const unsubscribe = this.pageClient.on(
1267
+ "Fetch.requestPaused",
1268
+ handlePausedRequest,
1269
+ );
1270
+
1271
+ try {
1272
+ await this.pageClient.send("Fetch.enable", {
1273
+ patterns: [{ requestStage: "Request", urlPattern: "*" }],
1274
+ });
1275
+ } catch (error) {
1276
+ unsubscribe();
1277
+ throw new ProviderError(
1278
+ "CDP browser target does not support BrowserPage.withResourcePolicy()",
1279
+ {
1280
+ cause: error instanceof Error ? error : undefined,
1281
+ code: "BROWSER_RUNTIME_UNSUPPORTED",
1282
+ fix: "Use a Chromium CDP target with the Fetch domain enabled, or use the local Playwright browser runtime.",
1283
+ },
1284
+ );
1285
+ }
1286
+
1287
+ try {
1288
+ return await run();
1289
+ } finally {
1290
+ unsubscribe();
1291
+ await this.pageClient.send("Fetch.disable");
1292
+ }
1293
+ }
1294
+
1295
+ private async handleResourcePolicyPausedRequest(
1296
+ params: unknown,
1297
+ policy: BrowserResourcePolicy,
1298
+ allowedMethods: ReadonlySet<BrowserResourceMethod>,
1299
+ ): Promise<void> {
1300
+ const requestId = getCdpPausedRequestId(params);
1301
+ if (requestId === null) {
1302
+ return;
1303
+ }
1304
+
1305
+ try {
1306
+ const parsed = toCdpResourceRequest(params);
1307
+ if (!parsed || !allowedMethods.has(parsed.request.method)) {
1308
+ await this.failCdpResourceRequest(requestId);
1309
+ return;
1310
+ }
1311
+
1312
+ for (const resourceRoute of policy.routes) {
1313
+ if (!matchesResourceRoute(resourceRoute.match, parsed.request)) {
1314
+ continue;
1315
+ }
1316
+
1317
+ const decision = await resourceRoute.handle(parsed.request);
1318
+ switch (decision.action) {
1319
+ case "fulfill":
1320
+ await this.pageClient.send(
1321
+ "Fetch.fulfillRequest",
1322
+ toCdpFulfillParams(parsed.requestId, decision),
1323
+ );
1324
+ return;
1325
+ case "block":
1326
+ await this.failCdpResourceRequest(parsed.requestId);
1327
+ return;
1328
+ }
1329
+ }
1330
+
1331
+ await this.failCdpResourceRequest(parsed.requestId);
1332
+ } catch {
1333
+ await this.failCdpResourceRequest(requestId);
1334
+ }
1335
+ }
1336
+
1337
+ private async failCdpResourceRequest(requestId: string): Promise<void> {
1338
+ try {
1339
+ await this.pageClient.send("Fetch.failRequest", {
1340
+ errorReason: "BlockedByClient",
1341
+ requestId,
1342
+ });
1343
+ } catch (error) {
1344
+ if (error instanceof Error) {
1345
+ return;
1346
+ }
1347
+ }
1348
+ }
1349
+
1058
1350
  private async initialize(): Promise<void> {
1059
1351
  if (this.initialized) {
1060
1352
  return;
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
 
4
4
  import { Hono } from "hono";
5
5
  import { z } from "zod";
6
+ import { AuthAbortError, createAuthFlowHelpers } from "../auth";
6
7
  import {
7
8
  AuthError,
8
9
  ProviderError,
@@ -329,6 +330,7 @@ function createAuthFlowContext(
329
330
  provider: ProviderDefinition,
330
331
  request: AuthFlowRequest,
331
332
  options: ProviderServerOptions = {},
333
+ signal?: AbortSignal,
332
334
  ): {
333
335
  context: FlowContext;
334
336
  getPatch: () => Record<string, unknown | null> | undefined;
@@ -383,6 +385,7 @@ function createAuthFlowContext(
383
385
  credential,
384
386
  context: flowContextStore.context,
385
387
  stt: options.stt ?? createSttClientFromEnv(provider.stt),
388
+ auth: createAuthFlowHelpers({ signal }),
386
389
  },
387
390
  getPatch: flowContextStore.getPatch,
388
391
  };
@@ -1291,6 +1294,7 @@ async function handleAuthFlow(
1291
1294
  request: AuthFlowRequest,
1292
1295
  route: AuthRoute,
1293
1296
  options: ProviderServerOptions = {},
1297
+ signal?: AbortSignal,
1294
1298
  ): Promise<Response | AuthFlowResponse> {
1295
1299
  const flow = provider.auth?.flow;
1296
1300
  if (!flow) {
@@ -1303,6 +1307,7 @@ async function handleAuthFlow(
1303
1307
  provider,
1304
1308
  request,
1305
1309
  options,
1310
+ signal,
1306
1311
  );
1307
1312
  try {
1308
1313
  const result =
@@ -1336,6 +1341,11 @@ async function handleAuthFlow(
1336
1341
  ? materializeAuthFlowTurn(provider, request, result)
1337
1342
  : result;
1338
1343
  return toAuthFlowResponse(materializedResult, getPatch());
1344
+ } catch (error) {
1345
+ if (error instanceof AuthAbortError) {
1346
+ return toAuthFlowResponse(error.turn, getPatch());
1347
+ }
1348
+ throw error;
1339
1349
  } finally {
1340
1350
  context.stealth.close?.();
1341
1351
  }
@@ -1450,7 +1460,13 @@ export function createServerApp(
1450
1460
  AuthFlowRequestSchema.parse(rawBody),
1451
1461
  c.req.raw.headers,
1452
1462
  );
1453
- const response = await handleAuthFlow(provider, body, "start", options);
1463
+ const response = await handleAuthFlow(
1464
+ provider,
1465
+ body,
1466
+ "start",
1467
+ options,
1468
+ c.req.raw.signal,
1469
+ );
1454
1470
  logProviderSuccess(
1455
1471
  logger,
1456
1472
  provider,
@@ -1495,6 +1511,7 @@ export function createServerApp(
1495
1511
  body,
1496
1512
  "continue",
1497
1513
  options,
1514
+ c.req.raw.signal,
1498
1515
  );
1499
1516
  logProviderSuccess(
1500
1517
  logger,
@@ -1535,7 +1552,13 @@ export function createServerApp(
1535
1552
  AuthFlowRequestSchema.parse(rawBody),
1536
1553
  c.req.raw.headers,
1537
1554
  );
1538
- const response = await handleAuthFlow(provider, body, "poll", options);
1555
+ const response = await handleAuthFlow(
1556
+ provider,
1557
+ body,
1558
+ "poll",
1559
+ options,
1560
+ c.req.raw.signal,
1561
+ );
1539
1562
  logProviderSuccess(
1540
1563
  logger,
1541
1564
  provider,
@@ -1575,7 +1598,13 @@ export function createServerApp(
1575
1598
  AuthFlowRequestSchema.parse(rawBody),
1576
1599
  c.req.raw.headers,
1577
1600
  );
1578
- const response = await handleAuthFlow(provider, body, "refresh", options);
1601
+ const response = await handleAuthFlow(
1602
+ provider,
1603
+ body,
1604
+ "refresh",
1605
+ options,
1606
+ c.req.raw.signal,
1607
+ );
1579
1608
  logProviderSuccess(
1580
1609
  logger,
1581
1610
  provider,
@@ -1615,7 +1644,13 @@ export function createServerApp(
1615
1644
  AuthFlowRequestSchema.parse(rawBody),
1616
1645
  c.req.raw.headers,
1617
1646
  );
1618
- const response = await handleAuthFlow(provider, body, "abort", options);
1647
+ const response = await handleAuthFlow(
1648
+ provider,
1649
+ body,
1650
+ "abort",
1651
+ options,
1652
+ c.req.raw.signal,
1653
+ );
1619
1654
  logProviderSuccess(
1620
1655
  logger,
1621
1656
  provider,