@apifuse/provider-sdk 2.2.0-beta.63 → 2.2.0-beta.64
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 +99 -0
- package/CHANGELOG.md +4 -0
- package/LICENSE +21 -0
- package/bin/apifuse-submit-check.ts +9 -5
- package/dist/handle-meta.d.ts +36 -1
- package/dist/handle-meta.js +16 -0
- package/dist/handle.d.ts +23 -5
- package/dist/handle.js +54 -8
- package/dist/health-scenario.d.ts +38 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/lint.d.ts +5 -0
- package/dist/lint.js +57 -33
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +1 -1
- package/dist/runtime/handle.js +9 -1
- package/dist/runtime/ocr.js +5 -0
- package/dist/runtime-boundary-lint.d.ts +132 -0
- package/dist/runtime-boundary-lint.js +586 -0
- package/dist/types.d.ts +29 -0
- package/dist/typescript-module.d.ts +39 -0
- package/dist/typescript-module.js +72 -0
- package/package.json +1 -1
- package/src/handle-meta.ts +51 -1
- package/src/handle.ts +92 -10
- package/src/health-scenario.ts +38 -0
- package/src/index.ts +14 -0
- package/src/lint.ts +82 -41
- package/src/provider.ts +2 -0
- package/src/runtime/handle.ts +9 -1
- package/src/runtime/ocr.ts +5 -0
- package/src/runtime-boundary-lint.ts +703 -0
- package/src/types.ts +29 -0
- package/src/typescript-module.ts +100 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime boundary lint: provider runtime source must reach the outside world
|
|
3
|
+
* through the SDK context (`ctx.http`, `ctx.stealth`, `ctx.env`,
|
|
4
|
+
* `ctx.credential`, `ctx.browser`), not through ambient Node/Bun globals.
|
|
5
|
+
*
|
|
6
|
+
* Three rules, all reported at `warn` level in this first release so the
|
|
7
|
+
* fleet can migrate before the level is raised:
|
|
8
|
+
*
|
|
9
|
+
* - `process-env-direct-read`: `process.env.X`, `process.env["X"]`, a bare
|
|
10
|
+
* `process.env` object use, and the `Bun.env` equivalents. Runtime bootstrap
|
|
11
|
+
* keys (`APIFUSE__RUNTIME__*`) are exempt everywhere because `serve()`
|
|
12
|
+
* callers read the port and pod identity before a context exists.
|
|
13
|
+
* - `node-runtime-module-import`: value imports of `fs`, `fs/promises`, `net`,
|
|
14
|
+
* `tls`, `dgram`, `http`, `https`, `http2`, `child_process` (with or without
|
|
15
|
+
* the `node:` prefix) via import/export declarations, `require()`, or dynamic
|
|
16
|
+
* `import()`, plus the Bun equivalents `Bun.spawn`, `Bun.spawnSync`, `Bun.$`,
|
|
17
|
+
* `Bun.file`, `Bun.write`. Type-only imports are not runtime reach and are
|
|
18
|
+
* ignored.
|
|
19
|
+
* - `direct-fetch-call`: a call to the global `fetch` (bare identifier, or
|
|
20
|
+
* `globalThis`/`window`/`self`/`global` member). A bare `fetch` call whose
|
|
21
|
+
* enclosing scope declares a `fetch` binding (parameter, variable, import,
|
|
22
|
+
* function, catch clause) is calling that binding, not the global, and is
|
|
23
|
+
* left alone; the check is lexical, so a wrapper that takes `fetch` as a
|
|
24
|
+
* parameter does not hide a global `fetch()` elsewhere in the same file.
|
|
25
|
+
* `ctx.stealth.fetch()` is a member call and never matches.
|
|
26
|
+
*
|
|
27
|
+
* Files are parsed with the script kind their extension implies (`.ts` as
|
|
28
|
+
* TypeScript, `.tsx` as TSX, …). Parsing everything as TSX would make a
|
|
29
|
+
* generic arrow (`<T>(value: T) => value`) or an angle-bracket assertion in an
|
|
30
|
+
* ordinary `.ts` file read as JSX, and everything after it would silently drop
|
|
31
|
+
* out of the AST.
|
|
32
|
+
*
|
|
33
|
+
* Scope is the provider *runtime* source: JavaScript/TypeScript files under
|
|
34
|
+
* `providerSourceFiles` minus tests, recorded fixtures, the root bootstrap
|
|
35
|
+
* entrypoints (`dev.ts`, `start.ts`, `deploy.ts`) and the operator tooling
|
|
36
|
+
* directories (`scripts/`, `tools/`, `bin/`). Tooling legitimately reads the
|
|
37
|
+
* ambient environment and the filesystem when a human runs it; the rule is
|
|
38
|
+
* about code the pod executes on behalf of a request.
|
|
39
|
+
*
|
|
40
|
+
* A deliberate exception is acknowledged with the same escape hatch the submit
|
|
41
|
+
* check uses: `// @apifuse-allow <rule>: <reason>` on the finding line or the
|
|
42
|
+
* line directly above it. Acknowledged findings are reported as information so
|
|
43
|
+
* `apifuse check` still lists them.
|
|
44
|
+
*/
|
|
45
|
+
import { getTypeScript, isTypeScriptCompilerModule, } from "./typescript-module.js";
|
|
46
|
+
export const PROCESS_ENV_DIRECT_READ_RULE = "process-env-direct-read";
|
|
47
|
+
export const NODE_RUNTIME_MODULE_IMPORT_RULE = "node-runtime-module-import";
|
|
48
|
+
export const DIRECT_FETCH_CALL_RULE = "direct-fetch-call";
|
|
49
|
+
export const RUNTIME_BOUNDARY_RULES = [
|
|
50
|
+
PROCESS_ENV_DIRECT_READ_RULE,
|
|
51
|
+
NODE_RUNTIME_MODULE_IMPORT_RULE,
|
|
52
|
+
DIRECT_FETCH_CALL_RULE,
|
|
53
|
+
];
|
|
54
|
+
/**
|
|
55
|
+
* Environment names a provider may read from `process.env` directly. These
|
|
56
|
+
* are consumed before `serve()` builds a context (port, pod identity), so
|
|
57
|
+
* `ctx.env` cannot carry them. The whole `APIFUSE__RUNTIME__` family is the
|
|
58
|
+
* bootstrap contract; nothing else is exempt.
|
|
59
|
+
*/
|
|
60
|
+
export const BOOTSTRAP_ENV_NAME_PREFIX = "APIFUSE__RUNTIME__";
|
|
61
|
+
export function isBootstrapEnvName(name) {
|
|
62
|
+
return name.startsWith(BOOTSTRAP_ENV_NAME_PREFIX);
|
|
63
|
+
}
|
|
64
|
+
/** Root entrypoints `serve()`/`startDevServer()` are called from; never request-path code. */
|
|
65
|
+
const BOOTSTRAP_ENTRYPOINT_FILE_PATTERN = /^(?:dev|start|deploy)\.[cm]?[jt]sx?$/;
|
|
66
|
+
/** Operator tooling directories at the provider root (fixture recorders, smoke scripts). */
|
|
67
|
+
const TOOLING_DIRECTORY_PATTERN = /^(?:scripts|tools|bin)\//;
|
|
68
|
+
const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__|tests)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
69
|
+
const RECORDED_FIXTURE_SOURCE_FILE_PATTERN = /(?:^|\/)__fixtures__(?:\/|$)|(?:^|\/)__tests__\/fixtures(?:\/|$)/;
|
|
70
|
+
const JAVASCRIPT_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;
|
|
71
|
+
const DECLARATION_FILE_PATTERN = /\.d\.[cm]?ts$/;
|
|
72
|
+
/**
|
|
73
|
+
* Node built-ins whose value import means the provider talks to the
|
|
74
|
+
* filesystem, the network, or child processes on its own. Deliberately not the
|
|
75
|
+
* whole `node:` namespace: `node:path`, `node:crypto`, `node:url`,
|
|
76
|
+
* `node:buffer` and friends are pure helpers the SDK itself uses.
|
|
77
|
+
*/
|
|
78
|
+
const FORBIDDEN_NODE_MODULES = new Set([
|
|
79
|
+
"fs",
|
|
80
|
+
"fs/promises",
|
|
81
|
+
"net",
|
|
82
|
+
"tls",
|
|
83
|
+
"dgram",
|
|
84
|
+
"http",
|
|
85
|
+
"https",
|
|
86
|
+
"http2",
|
|
87
|
+
"child_process",
|
|
88
|
+
]);
|
|
89
|
+
const FORBIDDEN_BUN_MEMBERS = new Set([
|
|
90
|
+
"spawn",
|
|
91
|
+
"spawnSync",
|
|
92
|
+
"$",
|
|
93
|
+
"file",
|
|
94
|
+
"write",
|
|
95
|
+
]);
|
|
96
|
+
const GLOBAL_OBJECT_NAMES = new Set([
|
|
97
|
+
"globalThis",
|
|
98
|
+
"window",
|
|
99
|
+
"self",
|
|
100
|
+
"global",
|
|
101
|
+
]);
|
|
102
|
+
const COMMENT_ONLY_LINE_PATTERN = /^\s*(?:\/\/|\/\*|\*)/;
|
|
103
|
+
const ALLOW_COMMENT_PATTERN = /@apifuse-allow\s+([a-z][a-z0-9-]*)\b(?:\s*:\s*(.*?))?\s*(?:\*\/)?\s*$/;
|
|
104
|
+
/** True for the files the runtime boundary rules look at. */
|
|
105
|
+
export function isRuntimeBoundarySourceFile(relativePath) {
|
|
106
|
+
const normalized = relativePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
107
|
+
return (JAVASCRIPT_SOURCE_FILE_PATTERN.test(normalized) &&
|
|
108
|
+
!DECLARATION_FILE_PATTERN.test(normalized) &&
|
|
109
|
+
!TEST_SOURCE_FILE_PATTERN.test(normalized) &&
|
|
110
|
+
!RECORDED_FIXTURE_SOURCE_FILE_PATTERN.test(normalized) &&
|
|
111
|
+
!BOOTSTRAP_ENTRYPOINT_FILE_PATTERN.test(normalized) &&
|
|
112
|
+
!TOOLING_DIRECTORY_PATTERN.test(normalized));
|
|
113
|
+
}
|
|
114
|
+
function normalizeModuleSpecifier(specifier) {
|
|
115
|
+
return specifier.startsWith("node:") ? specifier.slice("node:".length) : specifier;
|
|
116
|
+
}
|
|
117
|
+
function isForbiddenNodeModule(specifier) {
|
|
118
|
+
return FORBIDDEN_NODE_MODULES.has(normalizeModuleSpecifier(specifier));
|
|
119
|
+
}
|
|
120
|
+
function staticString(ts, node) {
|
|
121
|
+
if (!node)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
124
|
+
return node.text;
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
function unwrapExpression(ts, expression) {
|
|
128
|
+
let current = expression;
|
|
129
|
+
while (ts.isParenthesizedExpression(current) ||
|
|
130
|
+
ts.isAsExpression(current) ||
|
|
131
|
+
ts.isSatisfiesExpression(current) ||
|
|
132
|
+
ts.isNonNullExpression(current) ||
|
|
133
|
+
ts.isTypeAssertionExpression(current)) {
|
|
134
|
+
current = current.expression;
|
|
135
|
+
}
|
|
136
|
+
return current;
|
|
137
|
+
}
|
|
138
|
+
/** `process` / `globalThis.process` / `Bun` / `globalThis.Bun` */
|
|
139
|
+
function isGlobalReference(ts, node, name) {
|
|
140
|
+
if (ts.isIdentifier(node))
|
|
141
|
+
return node.text === name;
|
|
142
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
143
|
+
return (ts.isIdentifier(node.expression) &&
|
|
144
|
+
GLOBAL_OBJECT_NAMES.has(node.expression.text) &&
|
|
145
|
+
node.name.text === name);
|
|
146
|
+
}
|
|
147
|
+
if (ts.isElementAccessExpression(node)) {
|
|
148
|
+
return (ts.isIdentifier(node.expression) &&
|
|
149
|
+
GLOBAL_OBJECT_NAMES.has(node.expression.text) &&
|
|
150
|
+
staticString(ts, node.argumentExpression) === name);
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
/** `process.env`, `process["env"]`, `Bun.env`, and their globalThis-prefixed forms. */
|
|
155
|
+
function envObjectOwner(ts, node) {
|
|
156
|
+
let owner;
|
|
157
|
+
let member;
|
|
158
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
159
|
+
owner = node.expression;
|
|
160
|
+
member = node.name.text;
|
|
161
|
+
}
|
|
162
|
+
else if (ts.isElementAccessExpression(node)) {
|
|
163
|
+
owner = node.expression;
|
|
164
|
+
member = staticString(ts, node.argumentExpression);
|
|
165
|
+
}
|
|
166
|
+
if (!owner || member !== "env")
|
|
167
|
+
return undefined;
|
|
168
|
+
const unwrapped = unwrapExpression(ts, owner);
|
|
169
|
+
if (isGlobalReference(ts, unwrapped, "process"))
|
|
170
|
+
return "process";
|
|
171
|
+
if (isGlobalReference(ts, unwrapped, "Bun"))
|
|
172
|
+
return "Bun";
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* For an env-object node, the statically known key read from it
|
|
177
|
+
* (`process.env.KEY`, `process.env["KEY"]`), or `undefined` when the object
|
|
178
|
+
* itself is used (spread, passed along, dynamic key).
|
|
179
|
+
*/
|
|
180
|
+
function envKeyReadFrom(ts, envNode) {
|
|
181
|
+
const parent = envNode.parent;
|
|
182
|
+
if (!parent)
|
|
183
|
+
return undefined;
|
|
184
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === envNode) {
|
|
185
|
+
return parent.name.text;
|
|
186
|
+
}
|
|
187
|
+
if (ts.isElementAccessExpression(parent) && parent.expression === envNode) {
|
|
188
|
+
return staticString(ts, parent.argumentExpression);
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* True when the import statement still loads the module at runtime. A
|
|
194
|
+
* clause-level `import type`, and a named import whose every binding is
|
|
195
|
+
* inline `type`, are erased by Bun and by tsc (without
|
|
196
|
+
* `verbatimModuleSyntax`), so they never reach the module. A side-effect
|
|
197
|
+
* import (`import "node:fs"`), a default or namespace binding, or any
|
|
198
|
+
* value-level named binding does load it.
|
|
199
|
+
*/
|
|
200
|
+
function importDeclarationLoadsModule(ts, node) {
|
|
201
|
+
const clause = node.importClause;
|
|
202
|
+
if (!clause)
|
|
203
|
+
return true;
|
|
204
|
+
if (clause.isTypeOnly)
|
|
205
|
+
return false;
|
|
206
|
+
if (clause.name)
|
|
207
|
+
return true;
|
|
208
|
+
const bindings = clause.namedBindings;
|
|
209
|
+
if (!bindings)
|
|
210
|
+
return true;
|
|
211
|
+
if (ts.isNamespaceImport(bindings))
|
|
212
|
+
return true;
|
|
213
|
+
return bindings.elements.some((element) => !element.isTypeOnly);
|
|
214
|
+
}
|
|
215
|
+
/** Same rule for `export … from`: erased when type-only at the clause or on every specifier. */
|
|
216
|
+
function exportDeclarationLoadsModule(ts, node) {
|
|
217
|
+
if (node.isTypeOnly)
|
|
218
|
+
return false;
|
|
219
|
+
const clause = node.exportClause;
|
|
220
|
+
if (!clause)
|
|
221
|
+
return true;
|
|
222
|
+
if (ts.isNamespaceExport(clause))
|
|
223
|
+
return true;
|
|
224
|
+
return clause.elements.some((element) => !element.isTypeOnly);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Script kind for a provider source file, from its extension. `.tsx`/`.jsx`
|
|
228
|
+
* enable JSX; everything else (`.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`,
|
|
229
|
+
* and the synthetic names used for in-memory sources) does not, so a generic
|
|
230
|
+
* arrow or `<T>value` assertion in a `.ts` file parses as TypeScript instead
|
|
231
|
+
* of swallowing the rest of the file as unterminated JSX.
|
|
232
|
+
*/
|
|
233
|
+
export function scriptKindForSourceFile(ts, fileName) {
|
|
234
|
+
const lower = fileName.toLowerCase();
|
|
235
|
+
if (lower.endsWith(".tsx"))
|
|
236
|
+
return ts.ScriptKind.TSX;
|
|
237
|
+
if (lower.endsWith(".jsx"))
|
|
238
|
+
return ts.ScriptKind.JSX;
|
|
239
|
+
if (/\.[cm]?js$/.test(lower))
|
|
240
|
+
return ts.ScriptKind.JS;
|
|
241
|
+
return ts.ScriptKind.TS;
|
|
242
|
+
}
|
|
243
|
+
/** Containers that own `let`/`const`/`class`/`function` declarations. */
|
|
244
|
+
function isBlockScopeContainer(ts, node) {
|
|
245
|
+
return (ts.isSourceFile(node) ||
|
|
246
|
+
ts.isBlock(node) ||
|
|
247
|
+
ts.isModuleBlock(node) ||
|
|
248
|
+
ts.isCaseBlock(node) ||
|
|
249
|
+
ts.isForStatement(node) ||
|
|
250
|
+
ts.isForInStatement(node) ||
|
|
251
|
+
ts.isForOfStatement(node));
|
|
252
|
+
}
|
|
253
|
+
/** Containers that own `var` declarations (hoisted past blocks). */
|
|
254
|
+
function isFunctionScopeContainer(ts, node) {
|
|
255
|
+
return (ts.isSourceFile(node) ||
|
|
256
|
+
ts.isModuleBlock(node) ||
|
|
257
|
+
ts.isFunctionLike(node) ||
|
|
258
|
+
ts.isClassStaticBlockDeclaration(node));
|
|
259
|
+
}
|
|
260
|
+
function nearestAncestor(node, sourceFile, predicate) {
|
|
261
|
+
let current = node.parent;
|
|
262
|
+
while (current && !predicate(current))
|
|
263
|
+
current = current.parent;
|
|
264
|
+
return current ?? sourceFile;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Value bindings per lexical scope: parameters on their function, `let`/`const`
|
|
268
|
+
* and named function/class declarations on the enclosing block, `var` on the
|
|
269
|
+
* enclosing function body, imports on the file, a catch variable on its
|
|
270
|
+
* clause, a named function/class expression on itself. Destructuring patterns
|
|
271
|
+
* contribute every leaf name.
|
|
272
|
+
*/
|
|
273
|
+
function collectScopeBindings(ts, sourceFile) {
|
|
274
|
+
const bindings = new Map();
|
|
275
|
+
const bind = (scope, name) => {
|
|
276
|
+
const names = bindings.get(scope) ?? new Set();
|
|
277
|
+
names.add(name);
|
|
278
|
+
bindings.set(scope, names);
|
|
279
|
+
};
|
|
280
|
+
const bindPattern = (scope, name) => {
|
|
281
|
+
if (ts.isIdentifier(name)) {
|
|
282
|
+
bind(scope, name.text);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
for (const element of name.elements) {
|
|
286
|
+
if (ts.isBindingElement(element))
|
|
287
|
+
bindPattern(scope, element.name);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const blockScopeOf = (node) => {
|
|
291
|
+
const container = nearestAncestor(node, sourceFile, (candidate) => isBlockScopeContainer(ts, candidate));
|
|
292
|
+
// A for-in/for-of binding is visible in the loop body only; the iterable
|
|
293
|
+
// expression is evaluated before it exists (`for (const fetch of
|
|
294
|
+
// fetch(url))` reaches past the loop binding).
|
|
295
|
+
if (ts.isForInStatement(container) || ts.isForOfStatement(container)) {
|
|
296
|
+
return container.statement;
|
|
297
|
+
}
|
|
298
|
+
return container;
|
|
299
|
+
};
|
|
300
|
+
const functionScopeOf = (node) => {
|
|
301
|
+
const container = nearestAncestor(node, sourceFile, (candidate) => isFunctionScopeContainer(ts, candidate));
|
|
302
|
+
// `var` hoists to the function *body*, which parameter initializers cannot
|
|
303
|
+
// see: `function load(result = fetch(url)) { var fetch = transport; }`
|
|
304
|
+
// still calls the global in the initializer.
|
|
305
|
+
const body = container.body;
|
|
306
|
+
if (ts.isFunctionLike(container) && body && ts.isBlock(body))
|
|
307
|
+
return body;
|
|
308
|
+
return container;
|
|
309
|
+
};
|
|
310
|
+
const visit = (node) => {
|
|
311
|
+
if (ts.isVariableDeclaration(node)) {
|
|
312
|
+
if (ts.isCatchClause(node.parent)) {
|
|
313
|
+
bindPattern(node.parent, node.name);
|
|
314
|
+
}
|
|
315
|
+
else if (ts.getCombinedNodeFlags(node) & ts.NodeFlags.BlockScoped) {
|
|
316
|
+
bindPattern(blockScopeOf(node.parent), node.name);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
bindPattern(functionScopeOf(node.parent), node.name);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else if (ts.isParameter(node)) {
|
|
323
|
+
bindPattern(node.parent, node.name);
|
|
324
|
+
}
|
|
325
|
+
else if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) {
|
|
326
|
+
if (node.name)
|
|
327
|
+
bind(blockScopeOf(node), node.name.text);
|
|
328
|
+
}
|
|
329
|
+
else if (ts.isFunctionExpression(node) || ts.isClassExpression(node)) {
|
|
330
|
+
if (node.name)
|
|
331
|
+
bind(node, node.name.text);
|
|
332
|
+
}
|
|
333
|
+
else if (ts.isEnumDeclaration(node)) {
|
|
334
|
+
bind(blockScopeOf(node), node.name.text);
|
|
335
|
+
}
|
|
336
|
+
else if (ts.isModuleDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
337
|
+
bind(blockScopeOf(node), node.name.text);
|
|
338
|
+
}
|
|
339
|
+
else if (ts.isImportClause(node) || ts.isImportSpecifier(node)) {
|
|
340
|
+
if (node.name)
|
|
341
|
+
bind(sourceFile, node.name.text);
|
|
342
|
+
}
|
|
343
|
+
else if (ts.isNamespaceImport(node) || ts.isImportEqualsDeclaration(node)) {
|
|
344
|
+
bind(sourceFile, node.name.text);
|
|
345
|
+
}
|
|
346
|
+
ts.forEachChild(node, visit);
|
|
347
|
+
};
|
|
348
|
+
visit(sourceFile);
|
|
349
|
+
return bindings;
|
|
350
|
+
}
|
|
351
|
+
/** True when a scope enclosing `node` (or `node` itself) declares `name`. */
|
|
352
|
+
function isBindingInScope(bindings, node, name) {
|
|
353
|
+
let current = node;
|
|
354
|
+
while (current) {
|
|
355
|
+
if (bindings.get(current)?.has(name))
|
|
356
|
+
return true;
|
|
357
|
+
current = current.parent;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
function readAllowComment(lines, line, rule) {
|
|
362
|
+
const current = lines[line - 1];
|
|
363
|
+
const previous = lines[line - 2];
|
|
364
|
+
// A trailing comment on the finding line, or a comment-only line directly
|
|
365
|
+
// above it. A trailing acknowledgement on the previous line belongs to that
|
|
366
|
+
// line's finding and must not leak onto this one.
|
|
367
|
+
const candidates = [current, previous?.match(COMMENT_ONLY_LINE_PATTERN) ? previous : undefined];
|
|
368
|
+
for (const candidate of candidates) {
|
|
369
|
+
if (candidate === undefined)
|
|
370
|
+
continue;
|
|
371
|
+
const match = ALLOW_COMMENT_PATTERN.exec(candidate);
|
|
372
|
+
if (match && match[1] === rule) {
|
|
373
|
+
return match[2]?.trim() || "(no reason given)";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
export function analyzeRuntimeBoundary(ts, fileName, source) {
|
|
379
|
+
const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindForSourceFile(ts, fileName));
|
|
380
|
+
const lines = source.split(/\r?\n/);
|
|
381
|
+
const scopeBindings = collectScopeBindings(ts, sourceFile);
|
|
382
|
+
const raw = [];
|
|
383
|
+
const seen = new Set();
|
|
384
|
+
const record = (rule, node, subject) => {
|
|
385
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
386
|
+
const key = `${rule}:${line}:${subject}`;
|
|
387
|
+
if (seen.has(key))
|
|
388
|
+
return;
|
|
389
|
+
seen.add(key);
|
|
390
|
+
raw.push({ rule, line, subject });
|
|
391
|
+
};
|
|
392
|
+
const visit = (node) => {
|
|
393
|
+
// --- process.env / Bun.env ---------------------------------------
|
|
394
|
+
const envOwner = envObjectOwner(ts, node);
|
|
395
|
+
if (envOwner) {
|
|
396
|
+
const key = envKeyReadFrom(ts, node);
|
|
397
|
+
if (key === undefined || !isBootstrapEnvName(key)) {
|
|
398
|
+
record(PROCESS_ENV_DIRECT_READ_RULE, node, key === undefined ? `${envOwner}.env` : `${envOwner}.env.${key}`);
|
|
399
|
+
}
|
|
400
|
+
// Do not descend: the owner is a global, nothing below is interesting.
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// --- node:fs / node:net / node:child_process … ---------------------
|
|
404
|
+
if (ts.isImportDeclaration(node)) {
|
|
405
|
+
const specifier = staticString(ts, node.moduleSpecifier);
|
|
406
|
+
if (specifier && importDeclarationLoadsModule(ts, node) && isForbiddenNodeModule(specifier)) {
|
|
407
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (ts.isExportDeclaration(node)) {
|
|
412
|
+
const specifier = staticString(ts, node.moduleSpecifier);
|
|
413
|
+
if (specifier && exportDeclarationLoadsModule(ts, node) && isForbiddenNodeModule(specifier)) {
|
|
414
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (ts.isImportEqualsDeclaration(node) && !node.isTypeOnly) {
|
|
419
|
+
const reference = node.moduleReference;
|
|
420
|
+
if (ts.isExternalModuleReference(reference)) {
|
|
421
|
+
const specifier = staticString(ts, reference.expression);
|
|
422
|
+
if (specifier && isForbiddenNodeModule(specifier)) {
|
|
423
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (ts.isCallExpression(node)) {
|
|
429
|
+
const callee = unwrapExpression(ts, node.expression);
|
|
430
|
+
// require("node:fs") / import("node:fs")
|
|
431
|
+
if ((ts.isIdentifier(callee) && callee.text === "require") ||
|
|
432
|
+
callee.kind === ts.SyntaxKind.ImportKeyword) {
|
|
433
|
+
const specifier = staticString(ts, node.arguments[0]);
|
|
434
|
+
if (specifier && isForbiddenNodeModule(specifier)) {
|
|
435
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Bun.spawn(...) / Bun.file(...) / Bun.write(...) / Bun.$(...)
|
|
439
|
+
if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) {
|
|
440
|
+
const owner = unwrapExpression(ts, callee.expression);
|
|
441
|
+
const member = ts.isPropertyAccessExpression(callee)
|
|
442
|
+
? callee.name.text
|
|
443
|
+
: staticString(ts, callee.argumentExpression);
|
|
444
|
+
if (member !== undefined &&
|
|
445
|
+
FORBIDDEN_BUN_MEMBERS.has(member) &&
|
|
446
|
+
isGlobalReference(ts, owner, "Bun")) {
|
|
447
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, `Bun.${member}()`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// fetch(...) / globalThis.fetch(...)
|
|
451
|
+
if (ts.isIdentifier(callee) &&
|
|
452
|
+
callee.text === "fetch" &&
|
|
453
|
+
!isBindingInScope(scopeBindings, node, "fetch")) {
|
|
454
|
+
record(DIRECT_FETCH_CALL_RULE, node, "fetch()");
|
|
455
|
+
}
|
|
456
|
+
else if ((ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) &&
|
|
457
|
+
isGlobalReference(ts, callee, "fetch")) {
|
|
458
|
+
record(DIRECT_FETCH_CALL_RULE, node, "globalThis.fetch()");
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Bun.$`…` tagged template
|
|
462
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
463
|
+
const tag = unwrapExpression(ts, node.tag);
|
|
464
|
+
if (ts.isPropertyAccessExpression(tag) &&
|
|
465
|
+
tag.name.text === "$" &&
|
|
466
|
+
isGlobalReference(ts, unwrapExpression(ts, tag.expression), "Bun")) {
|
|
467
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, "Bun.$``");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
ts.forEachChild(node, visit);
|
|
471
|
+
};
|
|
472
|
+
visit(sourceFile);
|
|
473
|
+
const findings = [];
|
|
474
|
+
const acknowledged = [];
|
|
475
|
+
for (const finding of raw) {
|
|
476
|
+
const reason = readAllowComment(lines, finding.line, finding.rule);
|
|
477
|
+
if (reason === undefined)
|
|
478
|
+
findings.push(finding);
|
|
479
|
+
else
|
|
480
|
+
acknowledged.push({ ...finding, reason });
|
|
481
|
+
}
|
|
482
|
+
return { findings, acknowledged };
|
|
483
|
+
}
|
|
484
|
+
function describeSubjects(findings) {
|
|
485
|
+
const bySubject = new Map();
|
|
486
|
+
for (const finding of findings) {
|
|
487
|
+
const lines = bySubject.get(finding.subject) ?? [];
|
|
488
|
+
lines.push(finding.line);
|
|
489
|
+
bySubject.set(finding.subject, lines);
|
|
490
|
+
}
|
|
491
|
+
return [...bySubject.entries()]
|
|
492
|
+
.map(([subject, lines]) => `${subject} (line ${lines.join(", ")})`)
|
|
493
|
+
.join("; ");
|
|
494
|
+
}
|
|
495
|
+
function remediation(rule) {
|
|
496
|
+
switch (rule) {
|
|
497
|
+
case PROCESS_ENV_DIRECT_READ_RULE:
|
|
498
|
+
return `Provider runtime source reads the ambient environment directly. ctx.env.get(name) sees only the names declared in defineProvider secrets[].name (engine-owned names excluded); declare the value there so the SDK presence gate, redaction, and Doppler projection see it. env: true only enables the ctx.env capability (type and runtime gate) and does not widen the readable names. Test/E2E toggles are not secrets and cannot move to ctx.env: replace them with test doubles (inject ctx.env / ctx.http) or recorded fixtures. Only the ${BOOTSTRAP_ENV_NAME_PREFIX}* bootstrap family may be read from process.env, and only dev.ts/start.ts/deploy.ts and scripts/, tools/, bin/ are outside this rule. Acknowledge a deliberate exception with \`// @apifuse-allow ${PROCESS_ENV_DIRECT_READ_RULE}: <reason>\`.`;
|
|
499
|
+
case NODE_RUNTIME_MODULE_IMPORT_RULE:
|
|
500
|
+
return `Provider runtime source reaches the filesystem, the network, or child processes on its own. Use ctx.http / ctx.stealth for HTTP, ctx.browser for browser work, ctx.files / ctx.cache / ctx.state for data that must persist, and keep recorder or smoke tooling under scripts/. Acknowledge a deliberate exception with \`// @apifuse-allow ${NODE_RUNTIME_MODULE_IMPORT_RULE}: <reason>\`.`;
|
|
501
|
+
case DIRECT_FETCH_CALL_RULE:
|
|
502
|
+
return `Provider runtime source calls the global fetch(), bypassing allowedHosts, proxy policy, retries, redaction, and telemetry. Use ctx.http.get/post/request for ordinary HTTP and ctx.stealth.fetch() for browser-shaped requests. Acknowledge a deliberate exception with \`// @apifuse-allow ${DIRECT_FETCH_CALL_RULE}: <reason>\`.`;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Lint the provider runtime boundary. Returns warn-level diagnostics (one per
|
|
507
|
+
* file and rule, listing every line) and information entries for findings
|
|
508
|
+
* acknowledged with `@apifuse-allow`.
|
|
509
|
+
*/
|
|
510
|
+
export function lintRuntimeBoundary(ts, provider, readOperationSource) {
|
|
511
|
+
const sources = [];
|
|
512
|
+
const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(([filePath]) => isRuntimeBoundarySourceFile(filePath));
|
|
513
|
+
if (Object.keys(provider.providerSourceFiles ?? {}).length > 0) {
|
|
514
|
+
for (const [filePath, source] of sourceFiles) {
|
|
515
|
+
sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
// In-memory providers (tests, embedded definitions) have no file tree;
|
|
520
|
+
// fall back to the auth flow and operation sources the way the other
|
|
521
|
+
// source rules do.
|
|
522
|
+
if (provider.authFlowSource) {
|
|
523
|
+
sources.push({
|
|
524
|
+
field: "auth.flow",
|
|
525
|
+
fileName: "auth-flow.ts",
|
|
526
|
+
source: provider.authFlowSource,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
530
|
+
const source = readOperationSource(operation);
|
|
531
|
+
if (source) {
|
|
532
|
+
sources.push({
|
|
533
|
+
field: `operations.${operationKey}.handler`,
|
|
534
|
+
fileName: `${operationKey}.ts`,
|
|
535
|
+
source,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const diagnostics = [];
|
|
541
|
+
const information = [];
|
|
542
|
+
for (const { field, fileName, source } of sources) {
|
|
543
|
+
const report = analyzeRuntimeBoundary(ts, fileName, source);
|
|
544
|
+
for (const rule of RUNTIME_BOUNDARY_RULES) {
|
|
545
|
+
const findings = report.findings.filter((finding) => finding.rule === rule);
|
|
546
|
+
if (findings.length > 0) {
|
|
547
|
+
diagnostics.push({
|
|
548
|
+
rule,
|
|
549
|
+
level: "warn",
|
|
550
|
+
field,
|
|
551
|
+
message: `${describeSubjects(findings)}: ${remediation(rule)}`,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
for (const finding of report.acknowledged.filter((entry) => entry.rule === rule)) {
|
|
555
|
+
information.push({
|
|
556
|
+
rule,
|
|
557
|
+
field,
|
|
558
|
+
message: `Acknowledged ${finding.subject} at line ${finding.line}. Reason: ${finding.reason}`,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return { diagnostics, information };
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Lint provider runtime source files for the runtime boundary rules without a
|
|
567
|
+
* provider definition: `files` maps provider-root-relative paths to source
|
|
568
|
+
* text (the same shape `apifuse check` collects as `providerSourceFiles`).
|
|
569
|
+
* Out-of-scope paths (tests, recorded fixtures, `.d.ts`, the root
|
|
570
|
+
* `dev.ts`/`start.ts`/`deploy.ts`, `scripts/`, `tools/`, `bin/`) are skipped
|
|
571
|
+
* with `isRuntimeBoundarySourceFile`. Diagnostics carry
|
|
572
|
+
* `field: "sourceFiles.<path>"`; `@apifuse-allow` acknowledgements come back
|
|
573
|
+
* as `information`.
|
|
574
|
+
*
|
|
575
|
+
* Parses with `options.typescript` when given, otherwise with the compiler
|
|
576
|
+
* module resolved from the installed SDK (see `getTypeScript`). This is the
|
|
577
|
+
* entry point the platform contract check uses to apply the rules with the
|
|
578
|
+
* monorepo's SDK pin instead of each provider's own pin.
|
|
579
|
+
*/
|
|
580
|
+
export function lintRuntimeBoundarySources(files, options = {}) {
|
|
581
|
+
const ts = options.typescript ?? getTypeScript();
|
|
582
|
+
if (!isTypeScriptCompilerModule(ts)) {
|
|
583
|
+
throw new TypeError("lintRuntimeBoundarySources: options.typescript must be a loaded TypeScript compiler API module (typescript 6.x or @typescript/typescript6) exposing createSourceFile.");
|
|
584
|
+
}
|
|
585
|
+
return lintRuntimeBoundary(ts, { providerSourceFiles: files }, () => "");
|
|
586
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -198,6 +198,25 @@ export interface OcrCaptchaResult {
|
|
|
198
198
|
readonly model: string;
|
|
199
199
|
}
|
|
200
200
|
export interface OcrContext {
|
|
201
|
+
/**
|
|
202
|
+
* Whether this context is backed by a configured OCR backend.
|
|
203
|
+
*
|
|
204
|
+
* The SDK owns this verdict: clients built by `createOcrClientFromEnv` for a
|
|
205
|
+
* configured backend report `true`; the unsupported/error clients handed to
|
|
206
|
+
* undeclared or misconfigured providers report `false`. Providers branch on
|
|
207
|
+
* this flag (for example, "require manual CAPTCHA input") instead of probing
|
|
208
|
+
* the engine-owned `APIFUSE__OCR__*` environment names themselves.
|
|
209
|
+
*
|
|
210
|
+
* `true` promises a configured backend, not a successful call: upstream
|
|
211
|
+
* failures still surface as errors from `recognize` / `extractCaptchaText`.
|
|
212
|
+
*
|
|
213
|
+
* Implementations must expose this as an own enumerable data property (not a
|
|
214
|
+
* getter or prototype member) so that wrappers which spread the context, such
|
|
215
|
+
* as `bindOcrTelemetry`, preserve it. Custom `OcrContext` overrides passed to
|
|
216
|
+
* `serve({ ocr })`, `createFlowContext({ ocr })`, or test contexts must set it
|
|
217
|
+
* explicitly.
|
|
218
|
+
*/
|
|
219
|
+
readonly available: boolean;
|
|
201
220
|
recognize(request: OcrRecognizeRequest): Promise<OcrResult>;
|
|
202
221
|
extractCaptchaText(image: OcrImageInput, options?: OcrCaptchaOptions): Promise<OcrCaptchaResult>;
|
|
203
222
|
}
|
|
@@ -670,6 +689,16 @@ export type HealthCheckCase<TInput = unknown, TOutput = unknown> = {
|
|
|
670
689
|
*
|
|
671
690
|
* MUST NOT access scheduler, recorder, or any runtime type — pure data
|
|
672
691
|
* + lambda only.
|
|
692
|
+
*
|
|
693
|
+
* NOTE: a closure cannot cross the registry's serialization boundary, so
|
|
694
|
+
* the platform health monitor cannot execute this hook — it runs a case's
|
|
695
|
+
* serialized `scenario` and nothing else. A case that carries only
|
|
696
|
+
* `assertions` is still published as a probe, but its outcome is
|
|
697
|
+
* permanently `unknown` / `monitoring_unavailable`, so the operation
|
|
698
|
+
* reads as monitored while nothing is being checked. This hook runs only
|
|
699
|
+
* in the provider runtime's own self-test
|
|
700
|
+
* (`/internal/health/self-test`). Express anything that must be
|
|
701
|
+
* monitored in production as a `scenario`.
|
|
673
702
|
*/
|
|
674
703
|
assertions: (ctx: HealthCheckAssertionContext<TOutput>) => void | Promise<void> | HealthCheckCaseResult | Promise<HealthCheckCaseResult>;
|
|
675
704
|
/** Declarative scenarios replace the imperative preparation and assertion hooks. */
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The TypeScript compiler API surface the source-level lint rules parse with. */
|
|
2
|
+
export type TypeScriptModule = typeof import("typescript");
|
|
3
|
+
/**
|
|
4
|
+
* What a caller hands over as a compiler module. Typed structurally so the
|
|
5
|
+
* option does not depend on which `typescript` package the caller's own type
|
|
6
|
+
* resolution lands on: under a `typescript@7` pin `typeof import("typescript")`
|
|
7
|
+
* is the native shell's version module, not the compiler API. The members are
|
|
8
|
+
* optional for the same reason — the shell has none of them — and
|
|
9
|
+
* `isTypeScriptCompilerModule` decides at runtime whether the module can parse.
|
|
10
|
+
*/
|
|
11
|
+
export type TypeScriptCompilerModuleLike = {
|
|
12
|
+
readonly createSourceFile?: (...parameters: never[]) => unknown;
|
|
13
|
+
readonly ScriptTarget?: object;
|
|
14
|
+
readonly version?: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Package specifiers that may carry the compiler API, in resolution order.
|
|
18
|
+
*
|
|
19
|
+
* `typescript@7` (the Go-native toolchain) resolves but its JavaScript entry
|
|
20
|
+
* exports only `version`/`getExePath` — no `createSourceFile`, so the lint
|
|
21
|
+
* rules cannot parse with it. The compiler API of that generation is published
|
|
22
|
+
* as `@typescript/typescript6`, which is what a `typescript@7` dependency tree
|
|
23
|
+
* (for example the APIFuse monorepo root) installs alongside the shell.
|
|
24
|
+
*/
|
|
25
|
+
export declare const TYPESCRIPT_COMPILER_MODULE_SPECIFIERS: readonly ["typescript", "@typescript/typescript6"];
|
|
26
|
+
/**
|
|
27
|
+
* True when `candidate` is a loaded module that exposes the parser the lint
|
|
28
|
+
* rules need. Internal (narrows to the `typescript` module type, which is not
|
|
29
|
+
* part of the package surface); `lintRuntimeBoundarySources` applies it to the
|
|
30
|
+
* caller-supplied option.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isTypeScriptCompilerModule(candidate: unknown): candidate is TypeScriptModule;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the compiler API through `load`, trying each specifier in order and
|
|
35
|
+
* accepting the first module that exposes `createSourceFile`. Exposed for the
|
|
36
|
+
* tests; production code uses `getTypeScript()`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function loadTypeScriptModule(load: (specifier: string) => unknown, specifiers?: readonly string[]): TypeScriptModule;
|
|
39
|
+
export declare function getTypeScript(): TypeScriptModule;
|