@facilis.dev/db 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/dist/client.d.ts +78 -0
  4. package/dist/client.js +208 -0
  5. package/dist/files.d.ts +268 -0
  6. package/dist/files.js +88 -0
  7. package/dist/index.d.ts +23 -0
  8. package/dist/index.js +23 -0
  9. package/dist/jobs.d.ts +255 -0
  10. package/dist/jobs.js +64 -0
  11. package/dist/live-schema.d.ts +39 -0
  12. package/dist/live-schema.js +97 -0
  13. package/dist/migrator.d.ts +20 -0
  14. package/dist/migrator.js +66 -0
  15. package/dist/policy.d.ts +50 -0
  16. package/dist/policy.js +111 -0
  17. package/dist/query-console.d.ts +44 -0
  18. package/dist/query-console.js +173 -0
  19. package/dist/read-only-sql.d.ts +13 -0
  20. package/dist/read-only-sql.js +145 -0
  21. package/dist/rls.d.ts +37 -0
  22. package/dist/rls.js +90 -0
  23. package/dist/schema-digest.d.ts +97 -0
  24. package/dist/schema-digest.js +178 -0
  25. package/dist/schema-drift.d.ts +43 -0
  26. package/dist/schema-drift.js +176 -0
  27. package/dist/snapshot-readers.d.ts +3 -0
  28. package/dist/snapshot-readers.js +182 -0
  29. package/dist/snapshot.d.ts +92 -0
  30. package/dist/snapshot.js +21 -0
  31. package/dist/sudo-audit.d.ts +60 -0
  32. package/dist/sudo-audit.js +45 -0
  33. package/dist/sudo.d.ts +54 -0
  34. package/dist/sudo.js +137 -0
  35. package/dist/table-guard.d.ts +37 -0
  36. package/dist/table-guard.js +228 -0
  37. package/dist/tasks.d.ts +312 -0
  38. package/dist/tasks.js +52 -0
  39. package/dist/tenant-column.d.ts +21 -0
  40. package/dist/tenant-column.js +27 -0
  41. package/dist/views.d.ts +92 -0
  42. package/dist/views.js +186 -0
  43. package/dist/workflows.d.ts +289 -0
  44. package/dist/workflows.js +55 -0
  45. package/package.json +43 -4
  46. package/src/client.ts +291 -0
  47. package/src/files.ts +124 -0
  48. package/src/index.ts +174 -0
  49. package/src/jobs.ts +76 -0
  50. package/src/live-schema.ts +145 -0
  51. package/src/migrator.ts +92 -0
  52. package/src/policy.ts +174 -0
  53. package/src/query-console.ts +215 -0
  54. package/src/read-only-sql.ts +156 -0
  55. package/src/rls.ts +109 -0
  56. package/src/schema-digest.ts +240 -0
  57. package/src/schema-drift.ts +234 -0
  58. package/src/snapshot-readers.ts +212 -0
  59. package/src/snapshot.ts +122 -0
  60. package/src/sudo-audit.ts +91 -0
  61. package/src/sudo.ts +200 -0
  62. package/src/table-guard.ts +296 -0
  63. package/src/tasks.ts +79 -0
  64. package/src/tenant-column.ts +29 -0
  65. package/src/views.ts +291 -0
  66. package/src/workflows.ts +76 -0
@@ -0,0 +1,92 @@
1
+ import type { FacilisDb } from "./client.js";
2
+ /** SPEC §7.3 taxonomy. Core re-exports these types as the manifest contract. */
3
+ export type PolicyCommand = "SELECT" | "INSERT" | "UPDATE" | "DELETE" | "ALL";
4
+ export interface PolicySnapshot {
5
+ name: string;
6
+ command: PolicyCommand;
7
+ using: string | null;
8
+ withCheck: string | null;
9
+ sql: string;
10
+ }
11
+ export interface ColumnSnapshot {
12
+ name: string;
13
+ type: string;
14
+ notNull: boolean;
15
+ default: string | null;
16
+ isPrimaryKey: boolean;
17
+ isUnique: boolean;
18
+ /**
19
+ * Identity columns are readable but not renderable by the v1 generator
20
+ * (`GENERATED ... AS IDENTITY` sequence options are lost in this projection), so the
21
+ * flag is carried and the P2 planner refuses instead of writing a plain column.
22
+ */
23
+ identity?: "always" | "byDefault";
24
+ /** Generated/computed columns: same fail-closed treatment, no faithful v1 DDL. */
25
+ generated?: true;
26
+ }
27
+ export interface ForeignKeySnapshot {
28
+ columns: string[];
29
+ targetTable: string;
30
+ targetColumns: string[];
31
+ onDelete?: "cascade" | "set null" | "restrict" | "no action";
32
+ onUpdate?: "cascade" | "set null" | "restrict" | "no action";
33
+ }
34
+ export interface IndexSnapshot {
35
+ name: string;
36
+ /** Plain column names only; any other index shape lands in `unsupported`. */
37
+ columns: string[];
38
+ unique: boolean;
39
+ }
40
+ export interface CheckSnapshot {
41
+ name: string;
42
+ /** Rendered with PgDialect, like `default`; columns may stay table-qualified. */
43
+ expression: string;
44
+ }
45
+ /** A table-level `unique()`, distinct from a column's `isUnique`. */
46
+ export interface UniqueConstraintSnapshot {
47
+ name: string;
48
+ columns: string[];
49
+ }
50
+ export interface EnumSnapshot {
51
+ name: string;
52
+ values: string[];
53
+ }
54
+ /**
55
+ * A declaration the snapshot can read but the v1 shapes above cannot represent (expression,
56
+ * partial or method-specific indexes; `ON DELETE SET DEFAULT`). It is carried, never
57
+ * dropped, so the P2 generator can fail the release closed instead of silently ignoring it
58
+ * (D-158: an unsupported declaration fails the release, never degrades silently).
59
+ */
60
+ export interface UnsupportedDeclarationSnapshot {
61
+ construct: "index" | "foreign_key";
62
+ name: string;
63
+ }
64
+ export interface ScopeColumnSnapshot {
65
+ name: string;
66
+ kind: "tenant" | "owner";
67
+ }
68
+ export interface TableSnapshot {
69
+ name: string;
70
+ rlsEnabled: boolean;
71
+ columns: ColumnSnapshot[];
72
+ foreignKeys: ForeignKeySnapshot[];
73
+ policies: PolicySnapshot[];
74
+ scopeColumn?: ScopeColumnSnapshot;
75
+ /** Ordered column names of the primary key; empty when the table has none. */
76
+ primaryKey?: string[];
77
+ indexes?: IndexSnapshot[];
78
+ checks?: CheckSnapshot[];
79
+ uniqueConstraints?: UniqueConstraintSnapshot[];
80
+ unsupported?: UnsupportedDeclarationSnapshot[];
81
+ }
82
+ export interface SchemaSnapshot {
83
+ tables: TableSnapshot[];
84
+ /** Enum types referenced by columns, deduped by name. */
85
+ enums?: EnumSnapshot[];
86
+ }
87
+ /**
88
+ * Derives the SPEC §7.3 schema snapshot from the Drizzle schema namespace passed to
89
+ * `createDb`. No database round-trip: the code schema is the source of truth, as in
90
+ * SPEC §11.1's code↔live comparison.
91
+ */
92
+ export declare function schemaSnapshot(db: FacilisDb): Promise<SchemaSnapshot>;
@@ -0,0 +1,21 @@
1
+ import { is } from "drizzle-orm";
2
+ import { PgTable } from "drizzle-orm/pg-core";
3
+ import { snapshotTable } from "./snapshot-readers.js";
4
+ /**
5
+ * Derives the SPEC §7.3 schema snapshot from the Drizzle schema namespace passed to
6
+ * `createDb`. No database round-trip: the code schema is the source of truth, as in
7
+ * SPEC §11.1's code↔live comparison.
8
+ */
9
+ export async function schemaSnapshot(db) {
10
+ const enums = new Map();
11
+ const tables = Object.values(db.schema)
12
+ .filter((value) => is(value, PgTable))
13
+ .map((table) => snapshotTable(table, enums))
14
+ .toSorted((left, right) => left.name.localeCompare(right.name));
15
+ return {
16
+ tables,
17
+ enums: [...enums.entries()]
18
+ .map(([name, values]) => ({ name, values }))
19
+ .toSorted((left, right) => left.name.localeCompare(right.name)),
20
+ };
21
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Sudo audit trail (D-099).
3
+ *
4
+ * A sudo escalation is recorded on a dedicated, append-only sink, never in the database:
5
+ * the trail must survive the transaction rollback it describes, and the framework has no
6
+ * update/delete operation for it. The default sink is stdout (one JSON line per event) so a
7
+ * deploy's log platform can ship it to WORM/retention storage; tests swap the sink.
8
+ *
9
+ * Fail closed: if the sink throws, the escalation must not happen, so writeSudoAudit throws
10
+ * a SudoAuditUnavailableError and the caller refuses to switch roles.
11
+ */
12
+ export type SudoAuditEventName = "started" | "succeeded" | "failed";
13
+ /** One immutable audit entry. `id` is shared by every entry of the same sudo call. */
14
+ export interface SudoAuditEvent {
15
+ readonly event: SudoAuditEventName;
16
+ /** Shared call id; also written to the app.sudo_id GUC while the role is elevated. */
17
+ readonly id: string;
18
+ /** ISO-8601 timestamp of the append (not of the call start). */
19
+ readonly at: string;
20
+ /** Acting user id; absent for a system job (actor is the label). */
21
+ readonly actorUserId?: string;
22
+ /** "user:<id>" for a route session, "system:<job>" for a job/CLI caller. */
23
+ readonly actor: string;
24
+ /** SHA-256 of the session id; the raw cookie/token is never logged. */
25
+ readonly sessionHash?: string;
26
+ readonly roles: readonly string[];
27
+ readonly tenantId?: string;
28
+ /** Request pathname, e.g. "/api/order-requests". */
29
+ readonly route?: string;
30
+ /** Request method, when known. */
31
+ readonly method?: string;
32
+ /** The route's declared permission (the first one), for context. */
33
+ readonly permission?: string;
34
+ readonly operation: string;
35
+ readonly reason: string;
36
+ readonly tables: readonly string[];
37
+ /** Succeeded entries: the declared tables the elevated callback actually reached (D-157). */
38
+ readonly observedTables?: readonly string[];
39
+ readonly requestId?: string;
40
+ /** Terminal entries only: how the elevated block ended. */
41
+ readonly outcome?: "succeeded" | "failed";
42
+ /** Terminal failure entries: canonical code, never the internal message. */
43
+ readonly errorCode?: string;
44
+ readonly durationMs?: number;
45
+ }
46
+ /** Appends one entry. Synchronous by design: a sink cannot silently promise to log later. */
47
+ export type SudoAuditSink = (event: SudoAuditEvent) => void;
48
+ /** Thrown when the audit sink is not writable; sudo refuses to escalate untraced. */
49
+ export declare class SudoAuditUnavailableError extends Error {
50
+ readonly code = "sudo_audit_unavailable";
51
+ constructor(cause: unknown);
52
+ }
53
+ /** New call id: WebCrypto randomUUID (available in Node and the browser VM alike). */
54
+ export declare function newSudoId(): string;
55
+ /** Replaces the process-wide sink (tests/tooling). Production leaves the stdout default. */
56
+ export declare function setSudoAuditSink(next: SudoAuditSink): void;
57
+ /** Restores the default stdout sink. */
58
+ export declare function resetSudoAuditSink(): void;
59
+ /** Append one entry, failing closed: a broken sink throws instead of dropping the trail. */
60
+ export declare function writeSudoAudit(event: SudoAuditEvent): void;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Sudo audit trail (D-099).
3
+ *
4
+ * A sudo escalation is recorded on a dedicated, append-only sink, never in the database:
5
+ * the trail must survive the transaction rollback it describes, and the framework has no
6
+ * update/delete operation for it. The default sink is stdout (one JSON line per event) so a
7
+ * deploy's log platform can ship it to WORM/retention storage; tests swap the sink.
8
+ *
9
+ * Fail closed: if the sink throws, the escalation must not happen, so writeSudoAudit throws
10
+ * a SudoAuditUnavailableError and the caller refuses to switch roles.
11
+ */
12
+ /** Thrown when the audit sink is not writable; sudo refuses to escalate untraced. */
13
+ export class SudoAuditUnavailableError extends Error {
14
+ code = "sudo_audit_unavailable";
15
+ constructor(cause) {
16
+ super("The sudo audit sink is not writable; refusing to escalate a database call.", { cause });
17
+ this.name = "SudoAuditUnavailableError";
18
+ }
19
+ }
20
+ /** New call id: WebCrypto randomUUID (available in Node and the browser VM alike). */
21
+ export function newSudoId() {
22
+ return globalThis.crypto.randomUUID();
23
+ }
24
+ const consoleSink = (event) => {
25
+ // oxlint-disable-next-line no-console -- the default audit sink IS stdout: one JSON line per entry for the deploy's log pipeline.
26
+ console.log("facilis:sudo " + JSON.stringify(event));
27
+ };
28
+ let sink = consoleSink;
29
+ /** Replaces the process-wide sink (tests/tooling). Production leaves the stdout default. */
30
+ export function setSudoAuditSink(next) {
31
+ sink = next;
32
+ }
33
+ /** Restores the default stdout sink. */
34
+ export function resetSudoAuditSink() {
35
+ sink = consoleSink;
36
+ }
37
+ /** Append one entry, failing closed: a broken sink throws instead of dropping the trail. */
38
+ export function writeSudoAudit(event) {
39
+ try {
40
+ sink(event);
41
+ }
42
+ catch (cause) {
43
+ throw new SudoAuditUnavailableError(cause);
44
+ }
45
+ }
package/dist/sudo.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Audited sudo (D-099): run a block of code in the current transaction as the framework's
3
+ * `facilis_system` Postgres role.
4
+ *
5
+ * The system role is the one named exception to SPEC §8.4.5's "no Postgres roles" rule: it
6
+ * is NOLOGIN, NOSUPERUSER with BYPASSRLS and DML grants only (no DDL), it is never assigned
7
+ * to users and never referenced by policies, and it is reachable only through this module.
8
+ * Every escalation is recorded on the append-only audit sink first (fail closed), then in
9
+ * the SAVEPOINT, so a crash between entries leaves a visible trace instead of a silent one.
10
+ */
11
+ import type { FacilisDb } from "./client.js";
12
+ import { type PolicyScopedDatabase } from "./policy.js";
13
+ /** The elevated Postgres role; provisioned by `ensureSystemRole` (dev) or the deploy step. */
14
+ export declare const SYSTEM_ROLE = "facilis_system";
15
+ /** GUC carrying the audit call id while a sudo block runs. Not a provenance column. */
16
+ export declare const SUDO_ID_GUC = "app.sudo_id";
17
+ /**
18
+ * Creates the system role and grants it DML on the public schema, plus membership to
19
+ * facilis_app (production connects as the app role and must be able to SET ROLE). Idempotent
20
+ * and re-asserting, like ensureAppRole: an older dataDir must not keep weaker attributes.
21
+ */
22
+ export declare function ensureSystemRole(db: FacilisDb): Promise<void>;
23
+ export interface RunWithSystemRoleOptions {
24
+ /** Audit call id, exposed as app.sudo_id for correlation inside the block. */
25
+ readonly sudoId: string;
26
+ }
27
+ /**
28
+ * Runs `fn` in a SAVEPOINT of the current transaction as facilis_system.
29
+ *
30
+ * On success the role is restored to facilis_app explicitly: RESET ROLE would return to the
31
+ * connecting session user (a superuser in dev) and silently widen the rest of the request.
32
+ * On failure the savepoint rollback restores both the role and the GUCs.
33
+ */
34
+ export declare function runWithSystemRole<T>(db: PolicyScopedDatabase, options: RunWithSystemRoleOptions, fn: (systemDb: PolicyScopedDatabase) => Promise<T>): Promise<T>;
35
+ export interface RunAsSystemOptions {
36
+ /** Audit actor label for a job/CLI caller, e.g. "system:nightly-reindex". */
37
+ readonly actor: `system:${string}`;
38
+ readonly operation: string;
39
+ readonly reason: string;
40
+ /**
41
+ * The job's declared tables and views (SQL names), possibly empty. The elevated client is
42
+ * guarded by this declaration, not by whatever the system role happens to reach (D-157).
43
+ */
44
+ readonly dbTables: readonly string[];
45
+ /** Explicit exception for raw SQL inside the job (D-157); default false. */
46
+ readonly rawSql?: boolean;
47
+ /** Correlation id when the job was triggered by a request. */
48
+ readonly requestId?: string;
49
+ }
50
+ /**
51
+ * Runs `fn` in its own transaction as facilis_system, audited like `ctx.sudo`, and returns
52
+ * its result. This is the job/CLI counterpart of the route context's sudo.
53
+ */
54
+ export declare function runAsSystem<T>(db: FacilisDb, options: RunAsSystemOptions, fn: (systemDb: PolicyScopedDatabase) => Promise<T>): Promise<T>;
package/dist/sudo.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Audited sudo (D-099): run a block of code in the current transaction as the framework's
3
+ * `facilis_system` Postgres role.
4
+ *
5
+ * The system role is the one named exception to SPEC §8.4.5's "no Postgres roles" rule: it
6
+ * is NOLOGIN, NOSUPERUSER with BYPASSRLS and DML grants only (no DDL), it is never assigned
7
+ * to users and never referenced by policies, and it is reachable only through this module.
8
+ * Every escalation is recorded on the append-only audit sink first (fail closed), then in
9
+ * the SAVEPOINT, so a crash between entries leaves a visible trace instead of a silent one.
10
+ */
11
+ import { sql } from "drizzle-orm";
12
+ import { APP_ROLE } from "./policy.js";
13
+ import { newSudoId, writeSudoAudit } from "./sudo-audit.js";
14
+ import { guardDatabase, TableAccessError } from "./table-guard.js";
15
+ /** The elevated Postgres role; provisioned by `ensureSystemRole` (dev) or the deploy step. */
16
+ export const SYSTEM_ROLE = "facilis_system";
17
+ /** GUC carrying the audit call id while a sudo block runs. Not a provenance column. */
18
+ export const SUDO_ID_GUC = "app.sudo_id";
19
+ /**
20
+ * Creates the system role and grants it DML on the public schema, plus membership to
21
+ * facilis_app (production connects as the app role and must be able to SET ROLE). Idempotent
22
+ * and re-asserting, like ensureAppRole: an older dataDir must not keep weaker attributes.
23
+ */
24
+ export async function ensureSystemRole(db) {
25
+ const existing = await db.db.execute(sql `select 1 from pg_roles where rolname = ${SYSTEM_ROLE}`);
26
+ if (existing.rows.length === 0) {
27
+ await db.db.execute(sql.raw(`CREATE ROLE ${SYSTEM_ROLE} NOLOGIN NOSUPERUSER BYPASSRLS`));
28
+ }
29
+ // Re-assert attributes in case an older dev dataDir created the role differently.
30
+ await db.db.execute(sql.raw(`ALTER ROLE ${SYSTEM_ROLE} NOSUPERUSER BYPASSRLS NOLOGIN`));
31
+ await db.db.execute(sql.raw(`GRANT USAGE ON SCHEMA public TO ${SYSTEM_ROLE}`));
32
+ await db.db.execute(sql.raw(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${SYSTEM_ROLE}`));
33
+ await db.db.execute(sql.raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${SYSTEM_ROLE}`));
34
+ await db.db.execute(sql.raw(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${SYSTEM_ROLE}`));
35
+ await db.db.execute(sql.raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${SYSTEM_ROLE}`));
36
+ // The runtime may connect as facilis_app: membership is what lets it SET ROLE.
37
+ await db.db.execute(sql.raw(`GRANT ${SYSTEM_ROLE} TO ${APP_ROLE}`));
38
+ }
39
+ /**
40
+ * Runs `fn` in a SAVEPOINT of the current transaction as facilis_system.
41
+ *
42
+ * On success the role is restored to facilis_app explicitly: RESET ROLE would return to the
43
+ * connecting session user (a superuser in dev) and silently widen the rest of the request.
44
+ * On failure the savepoint rollback restores both the role and the GUCs.
45
+ */
46
+ export async function runWithSystemRole(db, options, fn) {
47
+ return db.transaction(async (tx) => {
48
+ await tx.execute(sql.raw(`SET LOCAL ROLE ${SYSTEM_ROLE}`));
49
+ await tx.execute(sql `select set_config('app.sudo_id', ${options.sudoId}, true)`);
50
+ const result = await fn(tx);
51
+ await tx.execute(sql.raw(`SET LOCAL ROLE ${APP_ROLE}`));
52
+ await tx.execute(sql `select set_config('app.sudo_id', '', true)`);
53
+ return result;
54
+ });
55
+ }
56
+ /**
57
+ * Runs `fn` in its own transaction as facilis_system, audited like `ctx.sudo`, and returns
58
+ * its result. This is the job/CLI counterpart of the route context's sudo.
59
+ */
60
+ export async function runAsSystem(db, options, fn) {
61
+ assertSystemRunOptions(options);
62
+ const id = newSudoId();
63
+ const startedAt = Date.now();
64
+ // Relations the guarded client actually reached; only declared ones can get this far.
65
+ const observed = new Set();
66
+ const base = {
67
+ id,
68
+ actor: options.actor,
69
+ roles: [],
70
+ operation: options.operation,
71
+ reason: options.reason,
72
+ tables: options.dbTables,
73
+ ...(options.requestId === undefined ? {} : { requestId: options.requestId }),
74
+ };
75
+ writeSudoAudit({ ...base, event: "started", at: new Date().toISOString() });
76
+ try {
77
+ const result = await db.db.transaction((tx) => runWithSystemRole(tx, { sudoId: id }, (systemDb) => fn(guardDatabase(systemDb, {
78
+ declared: options.dbTables,
79
+ allowRawSql: options.rawSql === true,
80
+ onRelation: (relation) => observed.add(relation),
81
+ }))));
82
+ writeSudoAudit({
83
+ ...base,
84
+ event: "succeeded",
85
+ outcome: "succeeded",
86
+ observedTables: [...observed],
87
+ at: new Date().toISOString(),
88
+ durationMs: Date.now() - startedAt,
89
+ });
90
+ return result;
91
+ }
92
+ catch (error) {
93
+ // Canonical code only: the internal message may name the relation, which is not audited.
94
+ writeSudoAudit({
95
+ ...base,
96
+ event: "failed",
97
+ outcome: "failed",
98
+ at: new Date().toISOString(),
99
+ errorCode: error instanceof TableAccessError ? error.code : "handler_failed",
100
+ durationMs: Date.now() - startedAt,
101
+ });
102
+ throw error;
103
+ }
104
+ }
105
+ /**
106
+ * Rejects a job call that could not produce a meaningful audit entry.
107
+ *
108
+ * An empty `dbTables` list is deliberately allowed here (D-107): a job may declare that it
109
+ * touches no relation, and it is the only caller permitted to do so — `ctx.sudo`/`ctx.system`
110
+ * still reject an empty subset in @facilis.dev/core. The declaration is enforced by the guard.
111
+ */
112
+ function assertSystemRunOptions(options) {
113
+ if (typeof options.actor !== "string" || !options.actor.startsWith("system:")) {
114
+ throw new Error('sudo_invalid_actor: actor must be "system:<job>" for a system run.');
115
+ }
116
+ if (typeof options.operation !== "string" || options.operation.trim().length === 0) {
117
+ throw new Error("sudo_invalid_operation: operation must be a non-empty string.");
118
+ }
119
+ if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
120
+ throw new Error("sudo_invalid_reason: reason must be a non-empty string.");
121
+ }
122
+ if (!isStringList(options.dbTables)) {
123
+ throw new Error("sudo_invalid_dbTables: dbTables must be an array of non-empty strings (possibly empty).");
124
+ }
125
+ if (options.rawSql !== undefined && typeof options.rawSql !== "boolean") {
126
+ throw new Error("sudo_invalid_rawSql: rawSql must be a boolean when present.");
127
+ }
128
+ }
129
+ /**
130
+ * An array of non-empty strings, possibly empty: a job that declares no relation still
131
+ * produces a valid audit entry (D-107). The empty case is left to the caller's policy, so
132
+ * runAsSystem can accept it while the route-scoped sudo keeps its non-empty subset rule.
133
+ */
134
+ function isStringList(value) {
135
+ return (Array.isArray(value) &&
136
+ value.every((entry) => typeof entry === "string" && entry.length > 0));
137
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Runtime enforcement of a route's `dbTables` declaration (D-157).
3
+ *
4
+ * The static analyzer can see a route's queries, but only the wrapper sees every relation
5
+ * the handler actually touches. `guardDatabase` wraps the policy-scoped client in a Proxy:
6
+ * a relation is checked against the declaration before its query builder runs, so an
7
+ * incomplete declaration fails closed instead of querying and auditing whatever it likes.
8
+ *
9
+ * The wrapper never executes a query itself: it forwards fluent calls to the real Drizzle
10
+ * builder and only intercepts the next relation-bearing call. Query builders are thenable,
11
+ * so `then`/`catch`/`finally` are bound to the unwrapped target and awaiting a guarded
12
+ * builder still runs the original query exactly once.
13
+ */
14
+ import type { PolicyScopedDatabase } from "./policy.js";
15
+ /** Canonical failure codes of the guard; both are 500s in the core error registry. */
16
+ export type TableAccessErrorCode = "table_not_declared" | "raw_sql_not_allowed";
17
+ /** Thrown before execution when a route reaches outside its declaration. */
18
+ export declare class TableAccessError extends Error {
19
+ readonly code: TableAccessErrorCode;
20
+ /** Server-side context only: the route wrapper never puts it in the response body. */
21
+ readonly relation?: string;
22
+ constructor(code: TableAccessErrorCode, relation?: string);
23
+ }
24
+ /** What a route exposes to the guard: its declaration plus the audited-observation hook. */
25
+ export interface TableGuardOptions {
26
+ /** The route's `dbTables` (tables and views, SQL names). */
27
+ readonly declared: readonly string[];
28
+ /** `rawSql: true` on the route; only then may `db.execute` run. */
29
+ readonly allowRawSql?: boolean;
30
+ /** Called for every declared relation the handler actually reaches (sudo audit, D-157). */
31
+ readonly onRelation?: (relation: string) => void;
32
+ }
33
+ /**
34
+ * Wraps a policy-scoped client so every relation is checked against `declared` before the
35
+ * query executes. The returned value is the same Drizzle transaction surface.
36
+ */
37
+ export declare function guardDatabase(db: PolicyScopedDatabase, options: TableGuardOptions): PolicyScopedDatabase;
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Runtime enforcement of a route's `dbTables` declaration (D-157).
3
+ *
4
+ * The static analyzer can see a route's queries, but only the wrapper sees every relation
5
+ * the handler actually touches. `guardDatabase` wraps the policy-scoped client in a Proxy:
6
+ * a relation is checked against the declaration before its query builder runs, so an
7
+ * incomplete declaration fails closed instead of querying and auditing whatever it likes.
8
+ *
9
+ * The wrapper never executes a query itself: it forwards fluent calls to the real Drizzle
10
+ * builder and only intercepts the next relation-bearing call. Query builders are thenable,
11
+ * so `then`/`catch`/`finally` are bound to the unwrapped target and awaiting a guarded
12
+ * builder still runs the original query exactly once.
13
+ */
14
+ import { Column, Table, ViewBaseConfig, getTableName, getViewName, is, isView } from "drizzle-orm";
15
+ /** Thrown before execution when a route reaches outside its declaration. */
16
+ export class TableAccessError extends Error {
17
+ code;
18
+ /** Server-side context only: the route wrapper never puts it in the response body. */
19
+ relation;
20
+ constructor(code, relation) {
21
+ super(code === "raw_sql_not_allowed"
22
+ ? "db.execute is not allowed: the route must declare rawSql: true."
23
+ : `Relation "${relation ?? "unknown"}" is not declared in the route's dbTables.`);
24
+ this.name = "TableAccessError";
25
+ this.code = code;
26
+ if (relation !== undefined)
27
+ this.relation = relation;
28
+ }
29
+ }
30
+ /** Calls that take a relation and must be checked before the builder runs. */
31
+ const RELATION_METHODS = new Set([
32
+ "select",
33
+ "selectDistinct",
34
+ "insert",
35
+ "update",
36
+ "delete",
37
+ "from",
38
+ "join",
39
+ "leftJoin",
40
+ "rightJoin",
41
+ "innerJoin",
42
+ "fullJoin",
43
+ "into",
44
+ ]);
45
+ /** Query-builder promise methods: bound to the real builder, never wrapped as fluent calls. */
46
+ const THEN_METHODS = new Set(["then", "catch", "finally"]);
47
+ /** Runtime table symbols; Drizzle 0.45 omits its `Table.Symbol` from the declarations. */
48
+ const TABLE_SYMBOLS = Table.Symbol;
49
+ /**
50
+ * Wraps a policy-scoped client so every relation is checked against `declared` before the
51
+ * query executes. The returned value is the same Drizzle transaction surface.
52
+ */
53
+ export function guardDatabase(db, options) {
54
+ const state = {
55
+ declared: new Set(options.declared),
56
+ allowRawSql: options.allowRawSql === true,
57
+ onRelation: options.onRelation,
58
+ proxies: new WeakMap(),
59
+ targets: new WeakMap(),
60
+ rawSqlUsed: false,
61
+ };
62
+ return wrapSurface(db, "db", state);
63
+ }
64
+ function wrapSurface(target, kind, state) {
65
+ const wrapped = state.proxies.get(target);
66
+ if (wrapped !== undefined)
67
+ return wrapped;
68
+ const proxy = new Proxy(target, createHandler(state, kind));
69
+ state.proxies.set(target, proxy);
70
+ state.targets.set(proxy, target);
71
+ return proxy;
72
+ }
73
+ function createHandler(state, kind) {
74
+ return {
75
+ get(target, property) {
76
+ const value = Reflect.get(target, property);
77
+ // Awaiting a guarded builder must call the real builder's then/catch/finally.
78
+ if (typeof property === "string" && THEN_METHODS.has(property)) {
79
+ return typeof value === "function"
80
+ ? value.bind(target)
81
+ : value;
82
+ }
83
+ // The relational API is keyed by relation: validate before handing out the builder.
84
+ if (kind === "db" && property === "query") {
85
+ return isObject(value) ? wrapSurface(value, "query", state) : value;
86
+ }
87
+ if (kind === "query" && typeof property === "string") {
88
+ if (!isObject(value))
89
+ return value;
90
+ const relation = relationFromQueryEntry(value) ?? property;
91
+ observe(relation, state);
92
+ return wrapSurface(value, "relation", state);
93
+ }
94
+ // The raw driver escape hatches are exactly the SQL the declaration cannot describe.
95
+ if (kind === "db" &&
96
+ !state.allowRawSql &&
97
+ (property === "$client" || property === "session")) {
98
+ throw new TableAccessError("raw_sql_not_allowed");
99
+ }
100
+ if (typeof value !== "function")
101
+ return value;
102
+ return createMethod(target, property, value, kind, state);
103
+ },
104
+ };
105
+ }
106
+ function createMethod(target, property, method, kind, state) {
107
+ return (...args) => {
108
+ if (kind === "db" && property === "execute") {
109
+ if (!state.allowRawSql)
110
+ throw new TableAccessError("raw_sql_not_allowed");
111
+ state.rawSqlUsed = true;
112
+ }
113
+ if (kind === "db" && property === "transaction") {
114
+ return runTransaction(target, method, args, state);
115
+ }
116
+ if (typeof property === "string" && RELATION_METHODS.has(property)) {
117
+ for (const arg of args)
118
+ observeArgument(arg, state);
119
+ }
120
+ const result = Reflect.apply(method, target, unwrapArgs(args, state));
121
+ return wrapResult(result, target, state);
122
+ };
123
+ }
124
+ /** Runs the callback with a guarded nested transaction; the query itself still executes once. */
125
+ function runTransaction(target, method, args, state) {
126
+ const [callback, ...rest] = args;
127
+ if (typeof callback !== "function") {
128
+ return Reflect.apply(method, target, unwrapArgs(args, state));
129
+ }
130
+ const wrapped = (tx, ...inner) => callback(isObject(tx) ? wrapSurface(tx, "db", state) : tx, ...inner.map((value) => unwrap(value, state)));
131
+ return Reflect.apply(method, target, [wrapped, ...unwrapArgs(rest, state)]);
132
+ }
133
+ function wrapResult(result, target, state) {
134
+ // Drizzle returns `this` from fluent methods: reuse the existing proxy, do not nest.
135
+ if (result === target)
136
+ return state.proxies.get(target);
137
+ if (isObject(result) && isBuilderLike(result))
138
+ return wrapSurface(result, "builder", state);
139
+ return result;
140
+ }
141
+ /** Thenable query builders plus the pre-`from` select builder, which is not thenable yet. */
142
+ function isBuilderLike(value) {
143
+ if (isThenable(value))
144
+ return true;
145
+ for (const method of RELATION_METHODS) {
146
+ if (typeof value[method] === "function")
147
+ return true;
148
+ }
149
+ return false;
150
+ }
151
+ function observeArgument(value, state, seen = new Set()) {
152
+ if (!isObject(value) || seen.has(value))
153
+ return;
154
+ seen.add(value);
155
+ const relation = relationNameOf(value);
156
+ if (relation !== null) {
157
+ observe(relation, state);
158
+ return;
159
+ }
160
+ if (Array.isArray(value)) {
161
+ for (const item of value)
162
+ observeArgument(item, state, seen);
163
+ return;
164
+ }
165
+ if (is(value, Column)) {
166
+ observeArgument(value.table, state, seen);
167
+ return;
168
+ }
169
+ if (isPlainObject(value)) {
170
+ for (const item of Object.values(value))
171
+ observeArgument(item, state, seen);
172
+ }
173
+ }
174
+ function observe(relation, state) {
175
+ if (!state.declared.has(relation))
176
+ throw new TableAccessError("table_not_declared", relation);
177
+ state.onRelation?.(relation);
178
+ }
179
+ /**
180
+ * SQL name of a relation: aliased tables resolve to their original name, and a view is its
181
+ * own relation (D-097/D-098), never its base tables.
182
+ */
183
+ function relationNameOf(value) {
184
+ if (is(value, Table)) {
185
+ // Drizzle 0.45 exposes the symbol table at runtime but not in its type declarations.
186
+ const symbols = Table.Symbol;
187
+ const table = value;
188
+ if (table[symbols.IsAlias] === true) {
189
+ const original = table[symbols.OriginalName];
190
+ if (typeof original === "string" && original.length > 0)
191
+ return original;
192
+ }
193
+ const name = getTableName(value);
194
+ return typeof name === "string" && name.length > 0 ? name : null;
195
+ }
196
+ if (isView(value)) {
197
+ const config = value[ViewBaseConfig];
198
+ if (isObject(config)) {
199
+ const record = config;
200
+ const name = record["isAlias"] === true ? (record["originalName"] ?? record["name"]) : record["name"];
201
+ if (typeof name === "string" && name.length > 0)
202
+ return name;
203
+ }
204
+ const name = getViewName(value);
205
+ return typeof name === "string" && name.length > 0 ? name : null;
206
+ }
207
+ return null;
208
+ }
209
+ function relationFromQueryEntry(value) {
210
+ const table = value.table;
211
+ return isObject(table) ? relationNameOf(table) : null;
212
+ }
213
+ function unwrap(value, state) {
214
+ return isObject(value) ? (state.targets.get(value) ?? value) : value;
215
+ }
216
+ function unwrapArgs(args, state) {
217
+ return args.map((value) => unwrap(value, state));
218
+ }
219
+ function isThenable(value) {
220
+ return typeof value.then === "function";
221
+ }
222
+ function isObject(value) {
223
+ return typeof value === "object" && value !== null;
224
+ }
225
+ function isPlainObject(value) {
226
+ const prototype = Object.getPrototypeOf(value);
227
+ return prototype === Object.prototype || prototype === null;
228
+ }