ambit-ts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/checker/authority.d.ts +13 -0
- package/dist/checker/authority.js +87 -0
- package/dist/checker/backend/legacy-ts.d.ts +26 -0
- package/dist/checker/backend/legacy-ts.js +1936 -0
- package/dist/checker/config.d.ts +84 -0
- package/dist/checker/config.js +391 -0
- package/dist/checker/coverage.d.ts +78 -0
- package/dist/checker/coverage.js +84 -0
- package/dist/checker/diagnose.d.ts +89 -0
- package/dist/checker/diagnose.js +734 -0
- package/dist/checker/index.d.ts +8 -0
- package/dist/checker/index.js +8 -0
- package/dist/checker/init.d.ts +38 -0
- package/dist/checker/init.js +205 -0
- package/dist/checker/propagate.d.ts +69 -0
- package/dist/checker/propagate.js +259 -0
- package/dist/checker/summarize.d.ts +27 -0
- package/dist/checker/summarize.js +411 -0
- package/dist/cli/analyze.d.ts +33 -0
- package/dist/cli/analyze.js +98 -0
- package/dist/cli/approvals.d.ts +28 -0
- package/dist/cli/approvals.js +55 -0
- package/dist/cli/diff.d.ts +66 -0
- package/dist/cli/diff.js +235 -0
- package/dist/cli/github.d.ts +33 -0
- package/dist/cli/github.js +41 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +385 -0
- package/dist/cli/worktree.d.ts +75 -0
- package/dist/cli/worktree.js +154 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +10 -0
- package/dist/core/approvals.d.ts +82 -0
- package/dist/core/approvals.js +0 -0
- package/dist/core/authority-diff.d.ts +98 -0
- package/dist/core/authority-diff.js +209 -0
- package/dist/core/authority.d.ts +109 -0
- package/dist/core/authority.js +50 -0
- package/dist/core/backend.d.ts +355 -0
- package/dist/core/backend.js +1 -0
- package/dist/core/budget.d.ts +61 -0
- package/dist/core/budget.js +95 -0
- package/dist/core/capability.d.ts +53 -0
- package/dist/core/capability.js +117 -0
- package/dist/core/config.d.ts +59 -0
- package/dist/core/config.js +10 -0
- package/dist/core/diagnostic.d.ts +126 -0
- package/dist/core/diagnostic.js +13 -0
- package/dist/core/effects.d.ts +39 -0
- package/dist/core/effects.js +72 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/location.d.ts +15 -0
- package/dist/core/location.js +1 -0
- package/dist/core/sql.d.ts +22 -0
- package/dist/core/sql.js +38 -0
- package/dist/core/summary.d.ts +240 -0
- package/dist/core/summary.js +8 -0
- package/dist/core/symbol-id.d.ts +25 -0
- package/dist/core/symbol-id.js +23 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/runtime/child-process.d.ts +29 -0
- package/dist/runtime/child-process.js +124 -0
- package/dist/runtime/context.d.ts +37 -0
- package/dist/runtime/context.js +8 -0
- package/dist/runtime/enforce.d.ts +52 -0
- package/dist/runtime/enforce.js +95 -0
- package/dist/runtime/fs.d.ts +46 -0
- package/dist/runtime/fs.js +188 -0
- package/dist/runtime/hono.d.ts +55 -0
- package/dist/runtime/hono.js +68 -0
- package/dist/runtime/index.d.ts +71 -0
- package/dist/runtime/index.js +126 -0
- package/dist/runtime/next.d.ts +95 -0
- package/dist/runtime/next.js +60 -0
- package/dist/runtime/pg.d.ts +48 -0
- package/dist/runtime/pg.js +122 -0
- package/dist/stubs/constructors.d.ts +34 -0
- package/dist/stubs/constructors.js +111 -0
- package/dist/stubs/data-clients.d.ts +9 -0
- package/dist/stubs/data-clients.js +109 -0
- package/dist/stubs/http-capabilities.d.ts +15 -0
- package/dist/stubs/http-capabilities.js +70 -0
- package/dist/stubs/mutating-builtins.d.ts +1 -0
- package/dist/stubs/mutating-builtins.js +48 -0
- package/dist/stubs/node-builtins.d.ts +2 -0
- package/dist/stubs/node-builtins.js +77 -0
- package/dist/stubs/pure-builtins.d.ts +1 -0
- package/dist/stubs/pure-builtins.js +89 -0
- package/docs/diagnostics/README.md +519 -0
- package/docs/limitations.md +712 -0
- package/package.json +89 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server.js";
|
|
2
|
+
import type { AmbitSpec } from "./index.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The Next.js App Router adapter (DESIGN.md §4.4, "Mapping contracts to
|
|
5
|
+
* handlers").
|
|
6
|
+
*
|
|
7
|
+
* `next` is imported for **types only** and is a devDependency here, never a
|
|
8
|
+
* dependency of the published package — the same treatment `hono` gets in
|
|
9
|
+
* `./hono.ts`, and the same reason `installPgHook(pg)` takes the module from
|
|
10
|
+
* the caller instead of importing `pg` (§4.4 (c)). Nothing in this file
|
|
11
|
+
* imports `typescript` or anything under `src/checker/`.
|
|
12
|
+
*
|
|
13
|
+
* **What this adapter does not reach.** It covers one thing: a Route Handler
|
|
14
|
+
* exported from `app/**\/route.ts` and registered through {@link ambitRoute},
|
|
15
|
+
* running on the Node.js runtime. It establishes no context for, and enforces
|
|
16
|
+
* nothing in:
|
|
17
|
+
*
|
|
18
|
+
* - a route that sets `export const runtime = "edge"` — the hooks
|
|
19
|
+
* (`installFetchHook` and friends) are Node.js hooks and are not installed
|
|
20
|
+
* there, so no capability is checked on such a route. Nothing else about
|
|
21
|
+
* `ambitRoute` on Edge is claimed either: no test runs there. DESIGN.md §12
|
|
22
|
+
* "Edge runtimes": Phase 1 guarantees Node.js only.
|
|
23
|
+
* - Server Actions (`"use server"`), which are not route modules and have no
|
|
24
|
+
* registration call to attach a spec to.
|
|
25
|
+
* - `middleware.ts`, which runs on the Edge runtime and outside any route
|
|
26
|
+
* module.
|
|
27
|
+
* - the Pages Router (`pages/api/*`), which has a different handler shape.
|
|
28
|
+
*
|
|
29
|
+
* Reaching any of those is out of scope for this adapter, not something it
|
|
30
|
+
* silently half-covers: with no `ambitRoute` registration there is no context
|
|
31
|
+
* at all, and what an operation does then is `setUnscopedPolicy`'s decision
|
|
32
|
+
* (`allow` by default).
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* The second argument Next.js passes a Route Handler: the matched dynamic
|
|
36
|
+
* segments. Declared here rather than imported because Next.js generates the
|
|
37
|
+
* per-route version into `.next/types/`, which does not exist in a repository
|
|
38
|
+
* that never runs `next build`.
|
|
39
|
+
*/
|
|
40
|
+
export interface RouteContext<Params extends Record<string, string | readonly string[]> = Record<string, string | readonly string[]>> {
|
|
41
|
+
/** A Promise since Next.js 15; `await`ed inside the context by `decode`. */
|
|
42
|
+
readonly params: Promise<Params>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The shape Next.js expects `export const GET = …` in `app/**\/route.ts` to
|
|
46
|
+
* have.
|
|
47
|
+
*/
|
|
48
|
+
export type RouteHandler<Params extends Record<string, string | readonly string[]> = Record<string, string | readonly string[]>> = (request: NextRequest, context: RouteContext<Params>) => Promise<Response>;
|
|
49
|
+
/**
|
|
50
|
+
* Register a contract-bearing handler as a Next.js Route Handler,
|
|
51
|
+
* establishing the entrypoint's capability set and budget for the whole
|
|
52
|
+
* request.
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* // app/orders/route.ts
|
|
56
|
+
* export const POST = ambitRoute(
|
|
57
|
+
* { capabilities: ["db:write:orders"], budget: { timeMs: 800 } },
|
|
58
|
+
* createOrder,
|
|
59
|
+
* async (request) => [await request.json() as CreateOrderInput] as const,
|
|
60
|
+
* );
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* `spec` and `handler` are in the **same first two positions** as
|
|
64
|
+
* {@link ambitHandler}'s, and that is load-bearing rather than cosmetic:
|
|
65
|
+
* `RUNTIME_WRAPPER_NAMES` in `src/checker/backend/legacy-ts.ts` reads
|
|
66
|
+
* `arguments[0]` as the spec and `arguments[1]` as the handler for every
|
|
67
|
+
* registered wrapper, so one extraction serves all of them. Changing the order
|
|
68
|
+
* here would cost the check without saying so.
|
|
69
|
+
*
|
|
70
|
+
* Per §4.4, "Removing the double declaration", a literal `spec` beside a
|
|
71
|
+
* handler declared in the same file *is* that handler's `@capabilities` and
|
|
72
|
+
* `@budget`: the JSDoc tags need not repeat it. Writing both stays legal, and
|
|
73
|
+
* a disagreement is still an error — the capability list against
|
|
74
|
+
* `@capabilities` (`AMB-E010`) and `spec.budget` against `@budget`
|
|
75
|
+
* (`AMB-E011`).
|
|
76
|
+
*
|
|
77
|
+
* `decode` keeps the framework out of `handler`, exactly as in the Hono
|
|
78
|
+
* adapter: `NextRequest` is in no stub table, so a `request.json()` inside a
|
|
79
|
+
* contract-bearing function would make that function's requirement partly
|
|
80
|
+
* `unknown` (`AMB-W003`). It runs **inside** the context, so reading the
|
|
81
|
+
* request body and awaiting `context.params` count toward `timeMs`.
|
|
82
|
+
*
|
|
83
|
+
* `AmbitCapabilityError` and `AmbitBudgetError` are **not** translated into
|
|
84
|
+
* HTTP statuses. A denied capability is this server's own code exceeding its
|
|
85
|
+
* grant, which is not what 403 says, and the message names the granted set —
|
|
86
|
+
* so it propagates out of the handler to Next.js's error handling, not to the
|
|
87
|
+
* client (§4.4).
|
|
88
|
+
*
|
|
89
|
+
* The hooks this enforcement depends on are installed once per process, in
|
|
90
|
+
* `instrumentation.ts`'s `register()` — see `docs/integrations/nextjs.md`.
|
|
91
|
+
* Without them, `ambitRoute` still establishes the context and applies
|
|
92
|
+
* `timeMs`, but no capability is checked, because nothing is intercepting the
|
|
93
|
+
* operations.
|
|
94
|
+
*/
|
|
95
|
+
export declare function ambitRoute<Args extends readonly unknown[], Result, Params extends Record<string, string | readonly string[]> = Record<string, string | readonly string[]>>(spec: AmbitSpec, handler: (...args: Args) => Result | Promise<Result>, decode: (request: NextRequest, context: RouteContext<Params>) => readonly [...Args] | Promise<readonly [...Args]>): RouteHandler<Params>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { withAmbit } from "./index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Register a contract-bearing handler as a Next.js Route Handler,
|
|
4
|
+
* establishing the entrypoint's capability set and budget for the whole
|
|
5
|
+
* request.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* // app/orders/route.ts
|
|
9
|
+
* export const POST = ambitRoute(
|
|
10
|
+
* { capabilities: ["db:write:orders"], budget: { timeMs: 800 } },
|
|
11
|
+
* createOrder,
|
|
12
|
+
* async (request) => [await request.json() as CreateOrderInput] as const,
|
|
13
|
+
* );
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* `spec` and `handler` are in the **same first two positions** as
|
|
17
|
+
* {@link ambitHandler}'s, and that is load-bearing rather than cosmetic:
|
|
18
|
+
* `RUNTIME_WRAPPER_NAMES` in `src/checker/backend/legacy-ts.ts` reads
|
|
19
|
+
* `arguments[0]` as the spec and `arguments[1]` as the handler for every
|
|
20
|
+
* registered wrapper, so one extraction serves all of them. Changing the order
|
|
21
|
+
* here would cost the check without saying so.
|
|
22
|
+
*
|
|
23
|
+
* Per §4.4, "Removing the double declaration", a literal `spec` beside a
|
|
24
|
+
* handler declared in the same file *is* that handler's `@capabilities` and
|
|
25
|
+
* `@budget`: the JSDoc tags need not repeat it. Writing both stays legal, and
|
|
26
|
+
* a disagreement is still an error — the capability list against
|
|
27
|
+
* `@capabilities` (`AMB-E010`) and `spec.budget` against `@budget`
|
|
28
|
+
* (`AMB-E011`).
|
|
29
|
+
*
|
|
30
|
+
* `decode` keeps the framework out of `handler`, exactly as in the Hono
|
|
31
|
+
* adapter: `NextRequest` is in no stub table, so a `request.json()` inside a
|
|
32
|
+
* contract-bearing function would make that function's requirement partly
|
|
33
|
+
* `unknown` (`AMB-W003`). It runs **inside** the context, so reading the
|
|
34
|
+
* request body and awaiting `context.params` count toward `timeMs`.
|
|
35
|
+
*
|
|
36
|
+
* `AmbitCapabilityError` and `AmbitBudgetError` are **not** translated into
|
|
37
|
+
* HTTP statuses. A denied capability is this server's own code exceeding its
|
|
38
|
+
* grant, which is not what 403 says, and the message names the granted set —
|
|
39
|
+
* so it propagates out of the handler to Next.js's error handling, not to the
|
|
40
|
+
* client (§4.4).
|
|
41
|
+
*
|
|
42
|
+
* The hooks this enforcement depends on are installed once per process, in
|
|
43
|
+
* `instrumentation.ts`'s `register()` — see `docs/integrations/nextjs.md`.
|
|
44
|
+
* Without them, `ambitRoute` still establishes the context and applies
|
|
45
|
+
* `timeMs`, but no capability is checked, because nothing is intercepting the
|
|
46
|
+
* operations.
|
|
47
|
+
*/
|
|
48
|
+
export function ambitRoute(spec, handler, decode) {
|
|
49
|
+
const scoped = withAmbit(spec, async (request, context) =>
|
|
50
|
+
// `decode` may return a `readonly` tuple — `[x] as const` is what a caller
|
|
51
|
+
// naturally writes — and a readonly tuple spreads into rest parameters.
|
|
52
|
+
handler(...(await decode(request, context))));
|
|
53
|
+
return async (request, context) => {
|
|
54
|
+
const result = await scoped(request, context);
|
|
55
|
+
// `Response.json` rather than `NextResponse.json`: the handler's return
|
|
56
|
+
// type is the domain's, and `NextResponse` adds nothing a Route Handler
|
|
57
|
+
// needs here. Next.js accepts any `Response`.
|
|
58
|
+
return result instanceof Response ? result : Response.json(result ?? null);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime enforcement for the `pg` client (DESIGN.md §4.4 (a), (c)).
|
|
3
|
+
*
|
|
4
|
+
* Unlike the `node:` builtins this is a **client wrap**, not a global
|
|
5
|
+
* monkeypatch: Ambit does not depend on `pg`, so the caller hands the module
|
|
6
|
+
* in — `installPgHook(await import("pg"))` — and Ambit replaces
|
|
7
|
+
* `Pool.prototype.query` and `Client.prototype.query`. `pg` was chosen over
|
|
8
|
+
* `@prisma/client` because those prototypes give install and restore the same
|
|
9
|
+
* symmetry `installFetchHook` has, while Prisma's only stable extension point
|
|
10
|
+
* (`$extends`) returns a *new* client and so cannot be undone (P5).
|
|
11
|
+
*
|
|
12
|
+
* What this does **not** do: derive a table from the statement. §4.4 is
|
|
13
|
+
* explicit — "the mere existence of a hook into a DB client is not taken to
|
|
14
|
+
* mean that table-level permissions can be decided for arbitrary SQL" — so the
|
|
15
|
+
* target is the database the
|
|
16
|
+
* connection names, and the exception says so rather than leaving the reader
|
|
17
|
+
* to assume table granularity.
|
|
18
|
+
*/
|
|
19
|
+
/** The `pg` surface this hook needs. Structural, so a test double satisfies it. */
|
|
20
|
+
export interface PgModule {
|
|
21
|
+
readonly Pool?: {
|
|
22
|
+
readonly prototype: object;
|
|
23
|
+
};
|
|
24
|
+
readonly Client?: {
|
|
25
|
+
readonly prototype: object;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The capabilities one `query` requires, given the client it was called on and
|
|
30
|
+
* the arguments it was called with.
|
|
31
|
+
*
|
|
32
|
+
* Direction comes from `sqlStatementDirection` in `src/core/`, the same rule
|
|
33
|
+
* the static effect table uses — §4.4 (c) puts it in one place so the checker
|
|
34
|
+
* and the running process cannot answer differently about one statement.
|
|
35
|
+
*/
|
|
36
|
+
export declare function pgCapabilities(client: unknown, args: readonly unknown[]): {
|
|
37
|
+
readonly capabilities: readonly string[];
|
|
38
|
+
readonly detail: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Wrap `Pool.prototype.query` and `Client.prototype.query` on the `pg` module
|
|
42
|
+
* handed in, returning the function that restores both.
|
|
43
|
+
*
|
|
44
|
+
* `query` is promise-returning unless a callback is passed, so a denial
|
|
45
|
+
* rejects, or reaches the callback through `process.nextTick` when there is
|
|
46
|
+
* one — §4.4 (a)'s rule for each family.
|
|
47
|
+
*/
|
|
48
|
+
export declare function installPgHook(pg: PgModule): () => void;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { sqlStatementDirection } from "../core/index.js";
|
|
2
|
+
import { checkCapabilities } from "./enforce.js";
|
|
3
|
+
const TARGET_DETAIL = "the target is the database this connection names, not a table: Ambit does not read table names out of arbitrary SQL";
|
|
4
|
+
const UNKNOWN_DATABASE_DETAIL = "the connection does not name a database, so the target could not be determined; only a grant that does not name one (db:read:*) can cover it";
|
|
5
|
+
const OPAQUE_STATEMENT_DETAIL = "the statement's direction is not decidable from this call, so both db:read and db:write are required";
|
|
6
|
+
/**
|
|
7
|
+
* The capabilities one `query` requires, given the client it was called on and
|
|
8
|
+
* the arguments it was called with.
|
|
9
|
+
*
|
|
10
|
+
* Direction comes from `sqlStatementDirection` in `src/core/`, the same rule
|
|
11
|
+
* the static effect table uses — §4.4 (c) puts it in one place so the checker
|
|
12
|
+
* and the running process cannot answer differently about one statement.
|
|
13
|
+
*/
|
|
14
|
+
export function pgCapabilities(client, args) {
|
|
15
|
+
const database = databaseOf(client);
|
|
16
|
+
const target = database ?? "unknown";
|
|
17
|
+
const direction = sqlStatementDirection(statementText(args[0]));
|
|
18
|
+
const capabilities = direction === "read"
|
|
19
|
+
? [`db:read:${target}`]
|
|
20
|
+
: direction === "write"
|
|
21
|
+
? [`db:write:${target}`]
|
|
22
|
+
: [`db:read:${target}`, `db:write:${target}`];
|
|
23
|
+
const notes = [TARGET_DETAIL];
|
|
24
|
+
if (database === undefined)
|
|
25
|
+
notes.push(UNKNOWN_DATABASE_DETAIL);
|
|
26
|
+
if (direction === "both")
|
|
27
|
+
notes.push(OPAQUE_STATEMENT_DETAIL);
|
|
28
|
+
return { capabilities, detail: notes.join("; ") };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `undefined` when the statement is not a string this call fixes — a
|
|
32
|
+
* `Submittable` (a cursor, say), or a config object with no `text`. That is
|
|
33
|
+
* not "no statement": it is "the direction is not decidable", which
|
|
34
|
+
* {@link pgCapabilities} turns into both directions.
|
|
35
|
+
*/
|
|
36
|
+
function statementText(value) {
|
|
37
|
+
if (typeof value === "string")
|
|
38
|
+
return value;
|
|
39
|
+
if (value !== null && typeof value === "object") {
|
|
40
|
+
const text = value.text;
|
|
41
|
+
if (typeof text === "string")
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The database a `pg` client is connected to, or `undefined` when the client
|
|
48
|
+
* does not name one — an environment-only configuration, or a
|
|
49
|
+
* `connectionString` with no path. Each of the shapes below is somewhere `pg`
|
|
50
|
+
* actually keeps it (`Pool#options`, `Client#connectionParameters`, and the
|
|
51
|
+
* `database` a connected client exposes).
|
|
52
|
+
*/
|
|
53
|
+
function databaseOf(client) {
|
|
54
|
+
if (client === null || typeof client !== "object")
|
|
55
|
+
return undefined;
|
|
56
|
+
const record = client;
|
|
57
|
+
for (const source of [record, record.options, record.connectionParameters]) {
|
|
58
|
+
if (source === null || typeof source !== "object")
|
|
59
|
+
continue;
|
|
60
|
+
const config = source;
|
|
61
|
+
const database = config.database;
|
|
62
|
+
if (typeof database === "string" && database.length > 0)
|
|
63
|
+
return database;
|
|
64
|
+
const fromUrl = databaseFromConnectionString(config.connectionString);
|
|
65
|
+
if (fromUrl !== undefined)
|
|
66
|
+
return fromUrl;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
function databaseFromConnectionString(value) {
|
|
71
|
+
if (typeof value !== "string")
|
|
72
|
+
return undefined;
|
|
73
|
+
try {
|
|
74
|
+
const name = new URL(value).pathname.replace(/^\//, "");
|
|
75
|
+
return name.length > 0 ? decodeURIComponent(name) : undefined;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Wrap `Pool.prototype.query` and `Client.prototype.query` on the `pg` module
|
|
83
|
+
* handed in, returning the function that restores both.
|
|
84
|
+
*
|
|
85
|
+
* `query` is promise-returning unless a callback is passed, so a denial
|
|
86
|
+
* rejects, or reaches the callback through `process.nextTick` when there is
|
|
87
|
+
* one — §4.4 (a)'s rule for each family.
|
|
88
|
+
*/
|
|
89
|
+
export function installPgHook(pg) {
|
|
90
|
+
const restores = [];
|
|
91
|
+
for (const clientClass of [pg.Pool, pg.Client]) {
|
|
92
|
+
const prototype = clientClass?.prototype;
|
|
93
|
+
if (!prototype)
|
|
94
|
+
continue;
|
|
95
|
+
const original = prototype.query;
|
|
96
|
+
if (typeof original !== "function")
|
|
97
|
+
continue;
|
|
98
|
+
const target = original;
|
|
99
|
+
const hooked = function (...args) {
|
|
100
|
+
const { capabilities, detail } = pgCapabilities(this, args);
|
|
101
|
+
const error = checkCapabilities(capabilities, detail);
|
|
102
|
+
if (!error)
|
|
103
|
+
return target.apply(this, args);
|
|
104
|
+
const callback = args[args.length - 1];
|
|
105
|
+
if (typeof callback === "function") {
|
|
106
|
+
process.nextTick(callback, error);
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return Promise.reject(error);
|
|
110
|
+
};
|
|
111
|
+
Object.defineProperty(hooked, "name", { value: "query" });
|
|
112
|
+
prototype.query = hooked;
|
|
113
|
+
restores.push(() => {
|
|
114
|
+
if (prototype.query === hooked)
|
|
115
|
+
prototype.query = original;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return () => {
|
|
119
|
+
for (const restore of restores)
|
|
120
|
+
restore();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { KnownEffect } from "../core/index.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Effect table for `new X(...)`, keyed in its own namespace: every key is the
|
|
4
|
+
* `calleeQualifiedName` of a call site, prefixed by {@link CONSTRUCTOR_KEY_PREFIX}
|
|
5
|
+
* (`"new Date"`, `"new @prisma/client.PrismaClient"`). The prefix keeps
|
|
6
|
+
* construction and plain calls apart — `URL(...)` and `new URL(...)` are not
|
|
7
|
+
* the same operation, and a space cannot occur in the qualified name a call
|
|
8
|
+
* produces, so the two key spaces can never collide.
|
|
9
|
+
*
|
|
10
|
+
* This table exists because before it, `new X(...)` was dropped from the call
|
|
11
|
+
* graph entirely: a function declared `@effects pure` that constructed a
|
|
12
|
+
* database client reported no call at all, not even `unknown`. DESIGN.md §3.4
|
|
13
|
+
* forbids turning an unanalyzed path into "no violation".
|
|
14
|
+
*
|
|
15
|
+
* Trust level: bundled with Ambit — the highest level in DESIGN.md §8.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CONSTRUCTOR_KEY_PREFIX = "new ";
|
|
18
|
+
export declare function constructorStubKey(qualifiedName: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* The effect of constructing `qualifiedName` (a {@link constructorStubKey}),
|
|
21
|
+
* or `undefined` when this table says nothing about it — which leaves the
|
|
22
|
+
* call `unknown`, never "no effect".
|
|
23
|
+
*
|
|
24
|
+
* `withoutArguments` distinguishes `new Date()` from `new Date(x)`; pass
|
|
25
|
+
* `true` when the construction has no arguments.
|
|
26
|
+
*/
|
|
27
|
+
export declare function lookupConstructorEffect(qualifiedName: string, withoutArguments: boolean): KnownEffect | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* True when constructing `qualifiedName` is known to have no `KnownEffect`.
|
|
30
|
+
* A nullary-only effect entry (`new Date()`) is excluded when the
|
|
31
|
+
* construction actually is nullary.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isKnownPureConstructor(qualifiedName: string, withoutArguments: boolean): boolean;
|
|
34
|
+
export declare function isConstructorKey(qualifiedName: string): boolean;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect table for `new X(...)`, keyed in its own namespace: every key is the
|
|
3
|
+
* `calleeQualifiedName` of a call site, prefixed by {@link CONSTRUCTOR_KEY_PREFIX}
|
|
4
|
+
* (`"new Date"`, `"new @prisma/client.PrismaClient"`). The prefix keeps
|
|
5
|
+
* construction and plain calls apart — `URL(...)` and `new URL(...)` are not
|
|
6
|
+
* the same operation, and a space cannot occur in the qualified name a call
|
|
7
|
+
* produces, so the two key spaces can never collide.
|
|
8
|
+
*
|
|
9
|
+
* This table exists because before it, `new X(...)` was dropped from the call
|
|
10
|
+
* graph entirely: a function declared `@effects pure` that constructed a
|
|
11
|
+
* database client reported no call at all, not even `unknown`. DESIGN.md §3.4
|
|
12
|
+
* forbids turning an unanalyzed path into "no violation".
|
|
13
|
+
*
|
|
14
|
+
* Trust level: bundled with Ambit — the highest level in DESIGN.md §8.
|
|
15
|
+
*/
|
|
16
|
+
export const CONSTRUCTOR_KEY_PREFIX = "new ";
|
|
17
|
+
export function constructorStubKey(qualifiedName) {
|
|
18
|
+
return `${CONSTRUCTOR_KEY_PREFIX}${qualifiedName}`;
|
|
19
|
+
}
|
|
20
|
+
const CONSTRUCTOR_EFFECTS = new Map([
|
|
21
|
+
["new node:net.Socket", "network"],
|
|
22
|
+
["new node:tls.TLSSocket", "network"],
|
|
23
|
+
["new node:http.Agent", "network"],
|
|
24
|
+
["new node:https.Agent", "network"],
|
|
25
|
+
["new WebSocket", "network"],
|
|
26
|
+
["new node:worker_threads.Worker", "process"],
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* Constructors that read non-deterministic input only when called with no
|
|
30
|
+
* arguments. `new Date()` reads the clock (DESIGN.md §4.2 lists the clock
|
|
31
|
+
* under `env`); `new Date(2020, 0, 1)` is a pure conversion of its arguments.
|
|
32
|
+
*/
|
|
33
|
+
const NULLARY_ONLY_EFFECTS = new Map([["new Date", "env"]]);
|
|
34
|
+
/**
|
|
35
|
+
* Constructors known to perform none of Ambit's `KnownEffect`s. As with
|
|
36
|
+
* `src/stubs/pure-builtins.ts`, this claims "no `KnownEffect`", not purity in
|
|
37
|
+
* a stricter sense — `new Map()` allocates mutable state, which is outside
|
|
38
|
+
* the effect model (DESIGN.md §12).
|
|
39
|
+
*
|
|
40
|
+
* A constructor that takes a callback (`Promise`) is listed here, but the
|
|
41
|
+
* connector layer marks `new Promise(namedExecutor)` `callbackByReference`
|
|
42
|
+
* and `summarize.ts` then refuses the pure verdict — the executor's body was
|
|
43
|
+
* never walked (DESIGN.md §4.2 rule 4).
|
|
44
|
+
*/
|
|
45
|
+
const PURE_CONSTRUCTORS = new Set([
|
|
46
|
+
"new Map",
|
|
47
|
+
"new Set",
|
|
48
|
+
"new WeakMap",
|
|
49
|
+
"new WeakSet",
|
|
50
|
+
"new Array",
|
|
51
|
+
"new Object",
|
|
52
|
+
"new Promise",
|
|
53
|
+
"new RegExp",
|
|
54
|
+
"new Error",
|
|
55
|
+
"new TypeError",
|
|
56
|
+
"new RangeError",
|
|
57
|
+
"new SyntaxError",
|
|
58
|
+
"new EvalError",
|
|
59
|
+
"new ReferenceError",
|
|
60
|
+
"new AggregateError",
|
|
61
|
+
"new URL",
|
|
62
|
+
"new URLSearchParams",
|
|
63
|
+
"new TextEncoder",
|
|
64
|
+
"new TextDecoder",
|
|
65
|
+
"new AbortController",
|
|
66
|
+
"new Number",
|
|
67
|
+
"new String",
|
|
68
|
+
"new Boolean",
|
|
69
|
+
"new Int8Array",
|
|
70
|
+
"new Uint8Array",
|
|
71
|
+
"new Uint8ClampedArray",
|
|
72
|
+
"new Int16Array",
|
|
73
|
+
"new Uint16Array",
|
|
74
|
+
"new Int32Array",
|
|
75
|
+
"new Uint32Array",
|
|
76
|
+
"new Float32Array",
|
|
77
|
+
"new Float64Array",
|
|
78
|
+
"new BigInt64Array",
|
|
79
|
+
"new BigUint64Array",
|
|
80
|
+
"new ArrayBuffer",
|
|
81
|
+
"new DataView",
|
|
82
|
+
]);
|
|
83
|
+
/**
|
|
84
|
+
* The effect of constructing `qualifiedName` (a {@link constructorStubKey}),
|
|
85
|
+
* or `undefined` when this table says nothing about it — which leaves the
|
|
86
|
+
* call `unknown`, never "no effect".
|
|
87
|
+
*
|
|
88
|
+
* `withoutArguments` distinguishes `new Date()` from `new Date(x)`; pass
|
|
89
|
+
* `true` when the construction has no arguments.
|
|
90
|
+
*/
|
|
91
|
+
export function lookupConstructorEffect(qualifiedName, withoutArguments) {
|
|
92
|
+
const always = CONSTRUCTOR_EFFECTS.get(qualifiedName);
|
|
93
|
+
if (always)
|
|
94
|
+
return always;
|
|
95
|
+
return withoutArguments ? NULLARY_ONLY_EFFECTS.get(qualifiedName) : undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* True when constructing `qualifiedName` is known to have no `KnownEffect`.
|
|
99
|
+
* A nullary-only effect entry (`new Date()`) is excluded when the
|
|
100
|
+
* construction actually is nullary.
|
|
101
|
+
*/
|
|
102
|
+
export function isKnownPureConstructor(qualifiedName, withoutArguments) {
|
|
103
|
+
if (lookupConstructorEffect(qualifiedName, withoutArguments) !== undefined)
|
|
104
|
+
return false;
|
|
105
|
+
if (NULLARY_ONLY_EFFECTS.has(qualifiedName))
|
|
106
|
+
return true;
|
|
107
|
+
return PURE_CONSTRUCTORS.has(qualifiedName);
|
|
108
|
+
}
|
|
109
|
+
export function isConstructorKey(qualifiedName) {
|
|
110
|
+
return qualifiedName.startsWith(CONSTRUCTOR_KEY_PREFIX);
|
|
111
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { KnownEffect, LiteralArgument } from "../core/index.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The effects of a database or LLM client call, or `undefined` when this table
|
|
4
|
+
* says nothing about it — which leaves the call `unknown`, never "no effect".
|
|
5
|
+
*
|
|
6
|
+
* `literalArguments` is what the connector layer could read at the call site;
|
|
7
|
+
* it decides the direction for the SQL-statement methods above.
|
|
8
|
+
*/
|
|
9
|
+
export declare function lookupClientEffects(qualifiedName: string, literalArguments: readonly (LiteralArgument | undefined)[] | undefined): readonly KnownEffect[] | undefined;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { sqlStatementDirection } from "../core/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Both directions, for an operation whose direction the source does not fix.
|
|
4
|
+
*
|
|
5
|
+
* This is the conservative reading, and it is a choice with a cost: a function
|
|
6
|
+
* that only ever reads, but builds its statement dynamically, has to declare
|
|
7
|
+
* `db_write` too. The alternative — picking `db_read` — would let a generated
|
|
8
|
+
* `UPDATE` pass a `@effects db_read` contract, which is the failure this whole
|
|
9
|
+
* layer exists to prevent (DESIGN.md §3.4).
|
|
10
|
+
*/
|
|
11
|
+
const READ_AND_WRITE = ["db_read", "db_write"];
|
|
12
|
+
/**
|
|
13
|
+
* Methods whose effect is decided by a SQL statement argument, and which
|
|
14
|
+
* argument holds it. A literal statement (or a template literal whose static
|
|
15
|
+
* head reaches the first keyword) is classified by that keyword; anything else
|
|
16
|
+
* is {@link READ_AND_WRITE}.
|
|
17
|
+
*/
|
|
18
|
+
const SQL_STATEMENT_ARGUMENT = new Map([
|
|
19
|
+
["pg.Pool.query", 0],
|
|
20
|
+
["pg.Client.query", 0],
|
|
21
|
+
["pg.PoolClient.query", 0],
|
|
22
|
+
["mysql2.Pool.query", 0],
|
|
23
|
+
["mysql2.Pool.execute", 0],
|
|
24
|
+
["mysql2.Connection.query", 0],
|
|
25
|
+
["mysql2.Connection.execute", 0],
|
|
26
|
+
]);
|
|
27
|
+
const CLIENT_METHOD_RULES = [
|
|
28
|
+
// @prisma/client — the delegate methods are the documented public API; the
|
|
29
|
+
// model segment is whatever the user's schema declares.
|
|
30
|
+
{ pattern: "@prisma/client.PrismaClient.*.findMany", effects: ["db_read"] },
|
|
31
|
+
{ pattern: "@prisma/client.PrismaClient.*.findFirst", effects: ["db_read"] },
|
|
32
|
+
{ pattern: "@prisma/client.PrismaClient.*.findFirstOrThrow", effects: ["db_read"] },
|
|
33
|
+
{ pattern: "@prisma/client.PrismaClient.*.findUnique", effects: ["db_read"] },
|
|
34
|
+
{ pattern: "@prisma/client.PrismaClient.*.findUniqueOrThrow", effects: ["db_read"] },
|
|
35
|
+
{ pattern: "@prisma/client.PrismaClient.*.count", effects: ["db_read"] },
|
|
36
|
+
{ pattern: "@prisma/client.PrismaClient.*.aggregate", effects: ["db_read"] },
|
|
37
|
+
{ pattern: "@prisma/client.PrismaClient.*.groupBy", effects: ["db_read"] },
|
|
38
|
+
{ pattern: "@prisma/client.PrismaClient.*.create", effects: ["db_write"] },
|
|
39
|
+
{ pattern: "@prisma/client.PrismaClient.*.createMany", effects: ["db_write"] },
|
|
40
|
+
{ pattern: "@prisma/client.PrismaClient.*.update", effects: ["db_write"] },
|
|
41
|
+
{ pattern: "@prisma/client.PrismaClient.*.updateMany", effects: ["db_write"] },
|
|
42
|
+
{ pattern: "@prisma/client.PrismaClient.*.upsert", effects: ["db_write"] },
|
|
43
|
+
{ pattern: "@prisma/client.PrismaClient.*.delete", effects: ["db_write"] },
|
|
44
|
+
{ pattern: "@prisma/client.PrismaClient.*.deleteMany", effects: ["db_write"] },
|
|
45
|
+
// Prisma splits raw access by direction itself, so no statement inspection
|
|
46
|
+
// is needed: `$queryRaw` is for `SELECT`, `$executeRaw` for everything else.
|
|
47
|
+
{ pattern: "@prisma/client.PrismaClient.$queryRaw", effects: ["db_read"] },
|
|
48
|
+
{ pattern: "@prisma/client.PrismaClient.$queryRawUnsafe", effects: ["db_read"] },
|
|
49
|
+
{ pattern: "@prisma/client.PrismaClient.$executeRaw", effects: ["db_write"] },
|
|
50
|
+
{ pattern: "@prisma/client.PrismaClient.$executeRawUnsafe", effects: ["db_write"] },
|
|
51
|
+
// A transaction's callback is not walked from here, and its statements are
|
|
52
|
+
// whatever the caller put in it.
|
|
53
|
+
{ pattern: "@prisma/client.PrismaClient.$transaction", effects: READ_AND_WRITE },
|
|
54
|
+
// openai — both the named and the default export are in use in the wild, so
|
|
55
|
+
// both spellings of the class key are listed.
|
|
56
|
+
{ pattern: "openai.OpenAI.chat.completions.create", effects: ["llm"] },
|
|
57
|
+
{ pattern: "openai.default.chat.completions.create", effects: ["llm"] },
|
|
58
|
+
{ pattern: "openai.OpenAI.responses.create", effects: ["llm"] },
|
|
59
|
+
{ pattern: "openai.default.responses.create", effects: ["llm"] },
|
|
60
|
+
{ pattern: "openai.OpenAI.embeddings.create", effects: ["llm"] },
|
|
61
|
+
{ pattern: "openai.default.embeddings.create", effects: ["llm"] },
|
|
62
|
+
// @anthropic-ai/sdk
|
|
63
|
+
{ pattern: "@anthropic-ai/sdk.Anthropic.messages.create", effects: ["llm"] },
|
|
64
|
+
{ pattern: "@anthropic-ai/sdk.default.messages.create", effects: ["llm"] },
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* The effects of a database or LLM client call, or `undefined` when this table
|
|
68
|
+
* says nothing about it — which leaves the call `unknown`, never "no effect".
|
|
69
|
+
*
|
|
70
|
+
* `literalArguments` is what the connector layer could read at the call site;
|
|
71
|
+
* it decides the direction for the SQL-statement methods above.
|
|
72
|
+
*/
|
|
73
|
+
export function lookupClientEffects(qualifiedName, literalArguments) {
|
|
74
|
+
const statementArgument = SQL_STATEMENT_ARGUMENT.get(qualifiedName);
|
|
75
|
+
if (statementArgument !== undefined) {
|
|
76
|
+
return sqlStatementEffects(literalArguments?.[statementArgument]);
|
|
77
|
+
}
|
|
78
|
+
for (const rule of CLIENT_METHOD_RULES) {
|
|
79
|
+
if (patternMatches(rule.pattern, qualifiedName))
|
|
80
|
+
return rule.effects;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The direction of one SQL statement, as effects. The keyword rule itself is
|
|
86
|
+
* `src/core/sql.ts`, shared with the runtime `pg` hook so the checker and the
|
|
87
|
+
* running process cannot disagree about the same statement (DESIGN.md §4.4).
|
|
88
|
+
*/
|
|
89
|
+
function sqlStatementEffects(argument) {
|
|
90
|
+
switch (sqlStatementDirection(argument?.text)) {
|
|
91
|
+
case "read":
|
|
92
|
+
return ["db_read"];
|
|
93
|
+
case "write":
|
|
94
|
+
return ["db_write"];
|
|
95
|
+
default:
|
|
96
|
+
return READ_AND_WRITE;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function patternMatches(pattern, qualifiedName) {
|
|
100
|
+
if (pattern === qualifiedName)
|
|
101
|
+
return true;
|
|
102
|
+
if (!pattern.includes("*"))
|
|
103
|
+
return false;
|
|
104
|
+
const patternSegments = pattern.split(".");
|
|
105
|
+
const nameSegments = qualifiedName.split(".");
|
|
106
|
+
if (patternSegments.length !== nameSegments.length)
|
|
107
|
+
return false;
|
|
108
|
+
return patternSegments.every((segment, index) => segment === "*" || segment === nameSegments[index]);
|
|
109
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Capability, LiteralArgument } from "../core/index.ts";
|
|
2
|
+
export interface HttpCapabilityRequirement {
|
|
3
|
+
/** The capability the call requires, when the host could be read from the source. */
|
|
4
|
+
readonly capability?: Capability;
|
|
5
|
+
/** Set when this operation has a target but the source does not fix it. */
|
|
6
|
+
readonly targetUnknown?: true;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The capability requirement of one call, or `undefined` when this table knows
|
|
10
|
+
* of no target for it — which is not the same as "requires nothing": every
|
|
11
|
+
* other operation simply has no rule here yet (`node:fs`, DB clients, LLM
|
|
12
|
+
* SDKs), and §4.4's caveat forbids inferring one for a DB client from a SQL
|
|
13
|
+
* string.
|
|
14
|
+
*/
|
|
15
|
+
export declare function lookupHttpCapability(qualifiedName: string, literalArguments: readonly (LiteralArgument | undefined)[] | undefined): HttpCapabilityRequirement | undefined;
|