@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.
- package/AUTHORING.md +93 -0
- package/CHANGELOG.md +21 -0
- package/README.md +133 -28
- package/bin/apifuse-check.ts +78 -71
- package/bin/apifuse-create.ts +12 -0
- package/bin/apifuse-dev.ts +24 -61
- package/bin/apifuse-pack-check.ts +87 -0
- package/bin/apifuse-pack-smoke.ts +122 -0
- package/bin/apifuse-perf.ts +33 -32
- package/bin/apifuse-record.ts +17 -7
- package/bin/apifuse-test.ts +6 -4
- package/bin/apifuse.ts +36 -35
- package/package.json +29 -9
- package/src/ceremonies/index.ts +768 -0
- package/src/cli/commands.ts +87 -0
- package/src/cli/create.ts +845 -0
- package/src/cli/templates/provider/Dockerfile.tpl +7 -0
- package/src/cli/templates/provider/README.md.tpl +41 -0
- package/src/cli/templates/provider/dev.ts.tpl +5 -0
- package/src/cli/templates/provider/index.test.ts.tpl +13 -0
- package/src/cli/templates/provider/index.ts.tpl +58 -0
- package/src/cli/templates/provider/start.ts.tpl +5 -0
- package/src/config/loader.ts +61 -1
- package/src/define.ts +565 -41
- package/src/dev.ts +2 -6
- package/src/errors.ts +42 -0
- package/src/index.ts +44 -38
- package/src/lint.ts +574 -0
- package/src/provider.ts +13 -0
- package/src/runtime/auth-flow.ts +67 -0
- package/src/runtime/credential.ts +95 -0
- package/src/runtime/env.ts +13 -0
- package/src/runtime/executor.ts +13 -14
- package/src/runtime/http.ts +36 -12
- package/src/runtime/insights.ts +3 -3
- package/src/runtime/key-derivation.ts +122 -0
- package/src/runtime/keyring.ts +148 -0
- package/src/runtime/namespace.ts +33 -0
- package/src/runtime/prevalidate.ts +252 -0
- package/src/runtime/tls.ts +41 -17
- package/src/runtime/waterfall.ts +0 -1
- package/src/schema.ts +77 -0
- package/src/serve.ts +1 -664
- package/src/server/index.ts +22 -0
- package/src/server/serve.ts +624 -0
- package/src/server/types.ts +78 -0
- package/src/stealth/profiles.ts +10 -93
- package/src/testing/run.ts +391 -32
- package/src/types.ts +390 -41
- package/bin/apifuse-init.ts +0 -387
- package/src/__tests__/auth.test.ts +0 -396
- package/src/__tests__/browser-auth.test.ts +0 -180
- package/src/__tests__/browser.test.ts +0 -632
- package/src/__tests__/define.test.ts +0 -225
- package/src/__tests__/errors.test.ts +0 -69
- package/src/__tests__/executor.test.ts +0 -214
- package/src/__tests__/http.test.ts +0 -238
- package/src/__tests__/insights.test.ts +0 -210
- package/src/__tests__/instrumentation.test.ts +0 -290
- package/src/__tests__/otlp.test.ts +0 -141
- package/src/__tests__/perf.test.ts +0 -60
- package/src/__tests__/providers-yaml.test.ts +0 -135
- package/src/__tests__/proxy.test.ts +0 -359
- package/src/__tests__/recipes.test.ts +0 -36
- package/src/__tests__/serve.test.ts +0 -233
- package/src/__tests__/session.test.ts +0 -231
- package/src/__tests__/state.test.ts +0 -100
- package/src/__tests__/stealth.test.ts +0 -57
- package/src/__tests__/testing.test.ts +0 -97
- package/src/__tests__/tls.test.ts +0 -345
- package/src/__tests__/types.test.ts +0 -142
- package/src/__tests__/utils.test.ts +0 -62
- package/src/__tests__/waterfall.test.ts +0 -270
- package/src/config/providers-yaml.ts +0 -370
- package/src/index.test.ts +0 -1
- package/src/protocol.ts +0 -183
- package/src/runtime/auth.ts +0 -245
- package/src/runtime/session.ts +0 -573
- package/src/runtime/state.ts +0 -124
package/src/lint.ts
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import type { ZodType } from "zod";
|
|
2
|
+
|
|
3
|
+
type AuthModeLike =
|
|
4
|
+
| "none"
|
|
5
|
+
| "platform-managed"
|
|
6
|
+
| "credentials"
|
|
7
|
+
| "oauth2"
|
|
8
|
+
| "api-key";
|
|
9
|
+
|
|
10
|
+
type ProviderAuthLike = {
|
|
11
|
+
mode?: AuthModeLike;
|
|
12
|
+
flow?: {
|
|
13
|
+
start?: unknown;
|
|
14
|
+
continue?: unknown;
|
|
15
|
+
poll?: unknown;
|
|
16
|
+
abort?: unknown;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type SchemaLike = ZodType & {
|
|
21
|
+
description?: string;
|
|
22
|
+
def?: Record<string, unknown>;
|
|
23
|
+
_def?: Record<string, unknown>;
|
|
24
|
+
shape?: Record<string, SchemaLike> | (() => Record<string, SchemaLike>);
|
|
25
|
+
element?: SchemaLike;
|
|
26
|
+
items?: SchemaLike[];
|
|
27
|
+
options?: SchemaLike[] | Set<SchemaLike> | Map<string, SchemaLike>;
|
|
28
|
+
innerType?: SchemaLike;
|
|
29
|
+
sourceType?: () => SchemaLike;
|
|
30
|
+
unwrap?: () => SchemaLike;
|
|
31
|
+
in?: SchemaLike;
|
|
32
|
+
out?: SchemaLike;
|
|
33
|
+
left?: SchemaLike;
|
|
34
|
+
right?: SchemaLike;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface LintDiagnostic {
|
|
38
|
+
rule: string;
|
|
39
|
+
level: "error" | "warn";
|
|
40
|
+
message: string;
|
|
41
|
+
field?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function lintAllowedHosts(
|
|
45
|
+
providerId: string | undefined,
|
|
46
|
+
allowedHosts: string[] | undefined,
|
|
47
|
+
): LintDiagnostic[] {
|
|
48
|
+
const prefix = providerId ? `Provider "${providerId}"` : "Provider";
|
|
49
|
+
|
|
50
|
+
if (!allowedHosts) {
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
rule: "allowed-hosts-required",
|
|
54
|
+
level: "error",
|
|
55
|
+
field: "allowedHosts",
|
|
56
|
+
message: `${prefix} must declare allowedHosts.`,
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (allowedHosts.length === 0) {
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
rule: "allowed-hosts-non-empty",
|
|
65
|
+
level: "error",
|
|
66
|
+
field: "allowedHosts",
|
|
67
|
+
message: `${prefix} must declare at least one allowed host.`,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const wildcardHost = allowedHosts.find((host) => host.trim().includes("*"));
|
|
73
|
+
if (wildcardHost) {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
rule: "allowed-hosts-no-wildcards",
|
|
77
|
+
level: "error",
|
|
78
|
+
field: "allowedHosts",
|
|
79
|
+
message: `${prefix} must not declare wildcard allowedHosts entries like "${wildcardHost}".`,
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function lintReviewed(
|
|
88
|
+
providerId: string | undefined,
|
|
89
|
+
reviewed: string | undefined,
|
|
90
|
+
): LintDiagnostic[] {
|
|
91
|
+
if (reviewed === "first-party" || reviewed === "community") {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const prefix = providerId ? `Provider "${providerId}"` : "Provider";
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
rule: "reviewed-required",
|
|
99
|
+
level: "error",
|
|
100
|
+
field: "reviewed",
|
|
101
|
+
message: `${prefix} must declare reviewed as "first-party" or "community".`,
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hasReusableSecretKeys(keys: string[] | undefined): boolean {
|
|
107
|
+
if (!keys) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return keys.some((key) =>
|
|
112
|
+
/(access_token|refresh_token|password|secret|cookie|session|token|api[_-]?key)/i.test(
|
|
113
|
+
key,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getAuthFlowSource(provider: {
|
|
119
|
+
auth?: ProviderAuthLike;
|
|
120
|
+
authFlowSource?: string;
|
|
121
|
+
}): string {
|
|
122
|
+
if (provider.authFlowSource) {
|
|
123
|
+
return provider.authFlowSource;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const parts = [
|
|
127
|
+
provider.auth?.flow?.start,
|
|
128
|
+
provider.auth?.flow?.continue,
|
|
129
|
+
provider.auth?.flow?.poll,
|
|
130
|
+
provider.auth?.flow?.abort,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
return parts
|
|
134
|
+
.filter(
|
|
135
|
+
(part): part is (...args: unknown[]) => unknown =>
|
|
136
|
+
typeof part === "function",
|
|
137
|
+
)
|
|
138
|
+
.map((part) => part.toString())
|
|
139
|
+
.join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function lintAuthModel(provider: {
|
|
143
|
+
id?: string;
|
|
144
|
+
auth?: ProviderAuthLike;
|
|
145
|
+
credential?: {
|
|
146
|
+
keys?: string[];
|
|
147
|
+
storesReusableSecret?: boolean;
|
|
148
|
+
justification?: string;
|
|
149
|
+
};
|
|
150
|
+
context?: {
|
|
151
|
+
keys?: string[];
|
|
152
|
+
};
|
|
153
|
+
authFlowSource?: string;
|
|
154
|
+
}): LintDiagnostic[] {
|
|
155
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
156
|
+
const providerLabel = provider.id ? `Provider "${provider.id}"` : "Provider";
|
|
157
|
+
const authMode = provider.auth?.mode;
|
|
158
|
+
const credentialKeys = provider.credential?.keys ?? [];
|
|
159
|
+
|
|
160
|
+
if (authMode === "api-key") {
|
|
161
|
+
diagnostics.push({
|
|
162
|
+
rule: "auth-mode-api-key-removed",
|
|
163
|
+
level: "error",
|
|
164
|
+
field: "auth.mode",
|
|
165
|
+
message: `${providerLabel} must not use auth.mode "api-key".`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (
|
|
170
|
+
(authMode === "credentials" || authMode === "oauth2") &&
|
|
171
|
+
typeof provider.auth?.flow?.continue !== "function"
|
|
172
|
+
) {
|
|
173
|
+
diagnostics.push({
|
|
174
|
+
rule: "auth-flow-continue-required",
|
|
175
|
+
level: "error",
|
|
176
|
+
field: "auth.flow.continue",
|
|
177
|
+
message: `${providerLabel} must define auth.flow.continue for ${authMode} auth mode.`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (authMode === "credentials" && credentialKeys.length === 0) {
|
|
182
|
+
diagnostics.push({
|
|
183
|
+
rule: "credential-keys-required-when-credentials-mode",
|
|
184
|
+
level: "error",
|
|
185
|
+
field: "credential.keys",
|
|
186
|
+
message: `${providerLabel} must declare credential.keys for credentials auth mode.`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
hasReusableSecretKeys(credentialKeys) &&
|
|
192
|
+
(!provider.credential?.storesReusableSecret ||
|
|
193
|
+
!provider.credential.justification)
|
|
194
|
+
) {
|
|
195
|
+
diagnostics.push({
|
|
196
|
+
rule: "credential-reusable-secret",
|
|
197
|
+
level: "error",
|
|
198
|
+
field: "credential",
|
|
199
|
+
message: `${providerLabel} must set storesReusableSecret and justification when credential.keys includes reusable secrets.`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (authMode === "platform-managed" && credentialKeys.length > 0) {
|
|
204
|
+
diagnostics.push({
|
|
205
|
+
rule: "platform-managed-no-credential-keys",
|
|
206
|
+
level: "error",
|
|
207
|
+
field: "credential.keys",
|
|
208
|
+
message: `${providerLabel} must not declare credential.keys for platform-managed auth mode.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const authFlowSource = getAuthFlowSource(provider);
|
|
213
|
+
if (
|
|
214
|
+
authFlowSource.includes("ctx.context") &&
|
|
215
|
+
(provider.context?.keys?.length ?? 0) === 0
|
|
216
|
+
) {
|
|
217
|
+
diagnostics.push({
|
|
218
|
+
rule: "context-keys-required",
|
|
219
|
+
level: "warn",
|
|
220
|
+
field: "context.keys",
|
|
221
|
+
message: `${providerLabel} should declare context.keys when auth flow code accesses ctx.context.*.`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return diagnostics;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isSchema(value: unknown): value is SchemaLike {
|
|
229
|
+
return (
|
|
230
|
+
!!value &&
|
|
231
|
+
typeof value === "object" &&
|
|
232
|
+
"safeParse" in value &&
|
|
233
|
+
typeof value.safeParse === "function"
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function getSchemaDef(schema: SchemaLike): Record<string, unknown> {
|
|
238
|
+
const def = schema.def ?? schema._def;
|
|
239
|
+
if (def && typeof def === "object") {
|
|
240
|
+
return def;
|
|
241
|
+
}
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isSchemaRecord(value: unknown): value is Record<string, SchemaLike> {
|
|
246
|
+
if (!value || typeof value !== "object") {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
for (const entry of Object.values(value)) {
|
|
250
|
+
if (!isSchema(entry)) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getObjectShape(schema: SchemaLike): Record<string, SchemaLike> {
|
|
258
|
+
const rawShape =
|
|
259
|
+
typeof schema.shape === "function" ? schema.shape() : schema.shape;
|
|
260
|
+
if (isSchemaRecord(rawShape)) {
|
|
261
|
+
return rawShape;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const defShape = getSchemaDef(schema).shape;
|
|
265
|
+
if (typeof defShape === "function") {
|
|
266
|
+
const resolved = defShape();
|
|
267
|
+
if (isSchemaRecord(resolved)) {
|
|
268
|
+
return resolved;
|
|
269
|
+
}
|
|
270
|
+
return {};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (isSchemaRecord(defShape)) {
|
|
274
|
+
return defShape;
|
|
275
|
+
}
|
|
276
|
+
return {};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getChildSchemas(
|
|
280
|
+
schema: SchemaLike,
|
|
281
|
+
): Array<{ key: string; schema: SchemaLike }> {
|
|
282
|
+
const seen = new Map<string, SchemaLike>();
|
|
283
|
+
const def = getSchemaDef(schema);
|
|
284
|
+
|
|
285
|
+
const add = (key: string, value: unknown) => {
|
|
286
|
+
if (!isSchema(value)) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
seen.set(`${key}:${seen.size}`, value);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
for (const [key, value] of Object.entries(getObjectShape(schema))) {
|
|
293
|
+
add(key, value);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
add("element", schema.element);
|
|
297
|
+
add("innerType", schema.innerType);
|
|
298
|
+
add("unwrap", schema.unwrap?.());
|
|
299
|
+
add("sourceType", schema.sourceType?.());
|
|
300
|
+
add("in", schema.in);
|
|
301
|
+
add("out", schema.out);
|
|
302
|
+
add("left", schema.left);
|
|
303
|
+
add("right", schema.right);
|
|
304
|
+
|
|
305
|
+
if (Array.isArray(schema.items)) {
|
|
306
|
+
for (const [index, item] of schema.items.entries()) {
|
|
307
|
+
add(String(index), item);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (Array.isArray(def.items)) {
|
|
312
|
+
for (const [index, item] of def.items.entries()) {
|
|
313
|
+
add(String(index), item);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const options = schema.options ?? def.options;
|
|
318
|
+
if (Array.isArray(options)) {
|
|
319
|
+
for (const [index, option] of options.entries()) {
|
|
320
|
+
add(String(index), option);
|
|
321
|
+
}
|
|
322
|
+
} else if (options instanceof Set) {
|
|
323
|
+
for (const [index, option] of Array.from(options).entries()) {
|
|
324
|
+
add(String(index), option);
|
|
325
|
+
}
|
|
326
|
+
} else if (options instanceof Map) {
|
|
327
|
+
for (const [key, option] of options.entries()) {
|
|
328
|
+
add(String(key), option);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const key of [
|
|
333
|
+
"schema",
|
|
334
|
+
"innerType",
|
|
335
|
+
"type",
|
|
336
|
+
"valueType",
|
|
337
|
+
"keyType",
|
|
338
|
+
"item",
|
|
339
|
+
"rest",
|
|
340
|
+
"catchall",
|
|
341
|
+
"option",
|
|
342
|
+
"pipe",
|
|
343
|
+
"payload",
|
|
344
|
+
"shape",
|
|
345
|
+
]) {
|
|
346
|
+
const value = def[key];
|
|
347
|
+
if (Array.isArray(value)) {
|
|
348
|
+
for (const [index, item] of value.entries()) {
|
|
349
|
+
add(`${key}.${index}`, item);
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
add(key, value);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return Array.from(seen.entries()).map(([entryKey, child]) => ({
|
|
357
|
+
key: entryKey.split(":")[0] ?? entryKey,
|
|
358
|
+
schema: child,
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function collectMissingDescriptions(
|
|
363
|
+
schema: unknown,
|
|
364
|
+
basePath: string,
|
|
365
|
+
seen = new Set<SchemaLike>(),
|
|
366
|
+
): string[] {
|
|
367
|
+
if (!isSchema(schema) || seen.has(schema)) {
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
seen.add(schema);
|
|
372
|
+
const missing: string[] = [];
|
|
373
|
+
const currentPath = basePath || "schema";
|
|
374
|
+
|
|
375
|
+
if (!schema.description) {
|
|
376
|
+
missing.push(currentPath);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const child of getChildSchemas(schema)) {
|
|
380
|
+
const isWrapperNode = [
|
|
381
|
+
"unwrap",
|
|
382
|
+
"innerType",
|
|
383
|
+
"sourceType",
|
|
384
|
+
"schema",
|
|
385
|
+
"type",
|
|
386
|
+
"option",
|
|
387
|
+
"pipe",
|
|
388
|
+
"payload",
|
|
389
|
+
"item",
|
|
390
|
+
"rest",
|
|
391
|
+
"catchall",
|
|
392
|
+
].includes(child.key);
|
|
393
|
+
const childPath = isWrapperNode
|
|
394
|
+
? currentPath
|
|
395
|
+
: currentPath === "schema"
|
|
396
|
+
? child.key
|
|
397
|
+
: /^\d+$/.test(child.key)
|
|
398
|
+
? `${currentPath}[${child.key}]`
|
|
399
|
+
: child.key.startsWith("element")
|
|
400
|
+
? `${currentPath}[]`
|
|
401
|
+
: `${currentPath}.${child.key}`;
|
|
402
|
+
missing.push(...collectMissingDescriptions(child.schema, childPath, seen));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return missing;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function uniqueFields(fields: string[]): string[] {
|
|
409
|
+
return Array.from(new Set(fields));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function isComplexSchema(
|
|
413
|
+
schema: unknown,
|
|
414
|
+
seen = new Set<SchemaLike>(),
|
|
415
|
+
): boolean {
|
|
416
|
+
if (!isSchema(schema) || seen.has(schema)) {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
seen.add(schema);
|
|
421
|
+
const children = getChildSchemas(schema);
|
|
422
|
+
const hasNestedComposite = children.some(({ schema: child }) => {
|
|
423
|
+
const childChildren = getChildSchemas(child);
|
|
424
|
+
return childChildren.length > 0;
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
hasNestedComposite ||
|
|
429
|
+
children.some(({ schema: child }) => isComplexSchema(child, seen))
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function hasBidirectionalFixtures(fixtures: unknown): boolean {
|
|
434
|
+
if (!fixtures || typeof fixtures !== "object") {
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return "request" in fixtures && "response" in fixtures;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function lintOperation(op: {
|
|
442
|
+
description: string;
|
|
443
|
+
input: unknown;
|
|
444
|
+
output: unknown;
|
|
445
|
+
fixtures?: unknown;
|
|
446
|
+
inputExamples?: unknown[];
|
|
447
|
+
derivations?: Record<string, string>;
|
|
448
|
+
}): LintDiagnostic[] {
|
|
449
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
450
|
+
const description = op.description ?? "";
|
|
451
|
+
|
|
452
|
+
if (description.length < 150) {
|
|
453
|
+
diagnostics.push({
|
|
454
|
+
rule: "description-min-length",
|
|
455
|
+
level: "error",
|
|
456
|
+
field: "description",
|
|
457
|
+
message: "Operation description must be at least 150 characters.",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const lowerDescription = description.toLowerCase();
|
|
462
|
+
if (
|
|
463
|
+
!(lowerDescription.includes("use") && lowerDescription.includes("when"))
|
|
464
|
+
) {
|
|
465
|
+
diagnostics.push({
|
|
466
|
+
rule: "description-has-when-clause",
|
|
467
|
+
level: "warn",
|
|
468
|
+
field: "description",
|
|
469
|
+
message: 'Operation description should include both "use" and "when".',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
for (const field of uniqueFields(
|
|
474
|
+
collectMissingDescriptions(op.input, "input"),
|
|
475
|
+
)) {
|
|
476
|
+
diagnostics.push({
|
|
477
|
+
rule: "all-fields-described",
|
|
478
|
+
level: "error",
|
|
479
|
+
field,
|
|
480
|
+
message: `Schema field "${field}" is missing a description.`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const field of uniqueFields(
|
|
485
|
+
collectMissingDescriptions(op.output, "output"),
|
|
486
|
+
)) {
|
|
487
|
+
diagnostics.push({
|
|
488
|
+
rule: "all-fields-described",
|
|
489
|
+
level: "error",
|
|
490
|
+
field,
|
|
491
|
+
message: `Schema field "${field}" is missing a description.`,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!hasBidirectionalFixtures(op.fixtures)) {
|
|
496
|
+
diagnostics.push({
|
|
497
|
+
rule: "fixtures-both-directions",
|
|
498
|
+
level: "error",
|
|
499
|
+
field: "fixtures",
|
|
500
|
+
message: "Fixtures must include both request and response.",
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (isComplexSchema(op.input) && (op.inputExamples?.length ?? 0) < 2) {
|
|
505
|
+
diagnostics.push({
|
|
506
|
+
rule: "complex-input-has-examples",
|
|
507
|
+
level: "warn",
|
|
508
|
+
field: "inputExamples",
|
|
509
|
+
message:
|
|
510
|
+
"Complex input schemas should provide at least 2 input examples.",
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return diagnostics;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function lintProvider(provider: {
|
|
518
|
+
id?: string;
|
|
519
|
+
allowedHosts?: string[];
|
|
520
|
+
auth?: ProviderAuthLike;
|
|
521
|
+
credential?: {
|
|
522
|
+
keys?: string[];
|
|
523
|
+
storesReusableSecret?: boolean;
|
|
524
|
+
justification?: string;
|
|
525
|
+
};
|
|
526
|
+
context?: {
|
|
527
|
+
keys?: string[];
|
|
528
|
+
};
|
|
529
|
+
authFlowSource?: string;
|
|
530
|
+
operations?: Record<
|
|
531
|
+
string,
|
|
532
|
+
{
|
|
533
|
+
description?: string;
|
|
534
|
+
input: unknown;
|
|
535
|
+
output: unknown;
|
|
536
|
+
fixtures?: unknown;
|
|
537
|
+
inputExamples?: unknown[];
|
|
538
|
+
derivations?: Record<string, string>;
|
|
539
|
+
}
|
|
540
|
+
>;
|
|
541
|
+
reviewed?: string;
|
|
542
|
+
}): LintDiagnostic[] {
|
|
543
|
+
const diagnostics: LintDiagnostic[] = [
|
|
544
|
+
...lintAllowedHosts(provider.id, provider.allowedHosts),
|
|
545
|
+
...lintReviewed(provider.id, provider.reviewed),
|
|
546
|
+
...lintAuthModel(provider),
|
|
547
|
+
];
|
|
548
|
+
|
|
549
|
+
if (!provider.operations) {
|
|
550
|
+
return diagnostics;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
diagnostics.push(
|
|
554
|
+
...Object.entries(provider.operations).flatMap(
|
|
555
|
+
([operationKey, operation]) =>
|
|
556
|
+
lintOperation({
|
|
557
|
+
description: operation.description ?? "",
|
|
558
|
+
input: operation.input,
|
|
559
|
+
output: operation.output,
|
|
560
|
+
fixtures: operation.fixtures,
|
|
561
|
+
inputExamples: operation.inputExamples,
|
|
562
|
+
derivations: operation.derivations,
|
|
563
|
+
}).map((diagnostic) => ({
|
|
564
|
+
...diagnostic,
|
|
565
|
+
field: diagnostic.field
|
|
566
|
+
? `operations.${operationKey}.${diagnostic.field}`
|
|
567
|
+
: `operations.${operationKey}`,
|
|
568
|
+
message: `[${operationKey}] ${diagnostic.message}`,
|
|
569
|
+
})),
|
|
570
|
+
),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
return diagnostics;
|
|
574
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export { createFormCeremony } from "./ceremonies";
|
|
4
|
+
export { defineOperation, defineProvider } from "./define";
|
|
5
|
+
export { AuthError, ProviderError, ValidationError } from "./errors";
|
|
6
|
+
export type {
|
|
7
|
+
FlowContext,
|
|
8
|
+
InferSchemaOutput,
|
|
9
|
+
OperationDefinition,
|
|
10
|
+
ProviderContext,
|
|
11
|
+
SchemaLike,
|
|
12
|
+
StandardSchemaV1,
|
|
13
|
+
} from "./types";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ContextAccessError } from "../errors";
|
|
2
|
+
import type {
|
|
3
|
+
ContextScratchpad,
|
|
4
|
+
EnvContext,
|
|
5
|
+
FlowContext,
|
|
6
|
+
HttpClient,
|
|
7
|
+
} from "../types";
|
|
8
|
+
|
|
9
|
+
function normalizeAllowedKeys(allowedKeys: string[]): Set<string> {
|
|
10
|
+
return new Set(allowedKeys.filter((key) => key.trim().length > 0));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertAllowedKey(allowedKeys: Set<string>, key: string): void {
|
|
14
|
+
if (!allowedKeys.has(key)) {
|
|
15
|
+
throw new ContextAccessError(
|
|
16
|
+
`Context key "${key}" is not declared in context.keys.`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createScratchpad(
|
|
22
|
+
allowedKeys: string[],
|
|
23
|
+
initial: Record<string, unknown> = {},
|
|
24
|
+
): ContextScratchpad {
|
|
25
|
+
const normalizedAllowedKeys = normalizeAllowedKeys(allowedKeys);
|
|
26
|
+
const values = new Map<string, unknown>();
|
|
27
|
+
|
|
28
|
+
for (const [key, value] of Object.entries(initial)) {
|
|
29
|
+
assertAllowedKey(normalizedAllowedKeys, key);
|
|
30
|
+
values.set(key, value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
get(key: string): unknown {
|
|
35
|
+
assertAllowedKey(normalizedAllowedKeys, key);
|
|
36
|
+
return values.get(key);
|
|
37
|
+
},
|
|
38
|
+
set(key: string, value: unknown): void {
|
|
39
|
+
assertAllowedKey(normalizedAllowedKeys, key);
|
|
40
|
+
values.set(key, value);
|
|
41
|
+
},
|
|
42
|
+
toJSON(): Record<string, unknown> {
|
|
43
|
+
return Object.fromEntries(values.entries());
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createFlowContext(options: {
|
|
49
|
+
http: HttpClient;
|
|
50
|
+
env: EnvContext;
|
|
51
|
+
tenantId: string;
|
|
52
|
+
providerId: string;
|
|
53
|
+
connectionId?: string;
|
|
54
|
+
externalRef?: string;
|
|
55
|
+
allowedKeys: string[];
|
|
56
|
+
initialContext?: Record<string, unknown>;
|
|
57
|
+
}): FlowContext {
|
|
58
|
+
return {
|
|
59
|
+
connectionId: options.connectionId,
|
|
60
|
+
externalRef: options.externalRef,
|
|
61
|
+
tenantId: options.tenantId,
|
|
62
|
+
providerId: options.providerId,
|
|
63
|
+
http: options.http,
|
|
64
|
+
env: options.env,
|
|
65
|
+
context: createScratchpad(options.allowedKeys, options.initialContext),
|
|
66
|
+
};
|
|
67
|
+
}
|