@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
package/dist/policy.js ADDED
@@ -0,0 +1,111 @@
1
+ import { sql } from "drizzle-orm";
2
+ /** Non-superuser role used for policy-scoped work; RLS is bypassed by superusers (D-008). */
3
+ export const APP_ROLE = "facilis_app";
4
+ /** Thrown when the app role has not been bootstrapped yet (`ensureAppRole`). */
5
+ export class AppRoleUnavailableError extends Error {
6
+ code = "app_role_unavailable";
7
+ constructor(cause) {
8
+ super(`App role "${APP_ROLE}" is unavailable; run ensureAppRole() after migrations.`, {
9
+ cause,
10
+ });
11
+ this.name = "AppRoleUnavailableError";
12
+ }
13
+ }
14
+ /**
15
+ * Creates the non-superuser role that policy-scoped queries run as and reconciles its
16
+ * privileges with the live schema: **RLS is the grant boundary** (D-136). A table with RLS
17
+ * enabled gets DML for the app role (its policies then decide the rows); a table without RLS
18
+ * gets no table privilege at all, so only the system identity can touch it. Sequences stay
19
+ * granted to both classes: USAGE can neither read nor write a table, and serial/identity
20
+ * columns need it for inserts. Idempotent and re-asserting: safe on every boot, and the
21
+ * blanket grants an older cluster carries are revoked the next time this runs.
22
+ *
23
+ * The role is `NOINHERIT` on purpose. It is a member of `facilis_system` so a deployment
24
+ * whose connection already is `facilis_app` can `SET ROLE` for the audited elevation
25
+ * (D-099); membership must not hand the request identity the system role's grants, or the
26
+ * system-only tables would be reachable from every handler. `SET ROLE` still works with
27
+ * `NOINHERIT`: only implicit inheritance of the member role's privileges is disabled.
28
+ */
29
+ export async function ensureAppRole(db) {
30
+ const existing = await db.db.execute(sql `select 1 from pg_roles where rolname = ${APP_ROLE}`);
31
+ if (existing.rows.length === 0) {
32
+ await db.db.execute(sql.raw(`CREATE ROLE ${APP_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT`));
33
+ }
34
+ // Re-assert attributes in case an older dev dataDir created the role differently.
35
+ await db.db.execute(sql.raw(`ALTER ROLE ${APP_ROLE} NOSUPERUSER NOBYPASSRLS NOLOGIN NOINHERIT`));
36
+ await db.db.execute(sql.raw(`GRANT USAGE ON SCHEMA public TO ${APP_ROLE}`));
37
+ // No table default privilege: a table a migration creates stays closed to the app role
38
+ // until its RLS is on and this function runs again after the migration. The REVOKE also
39
+ // clears the default an older version of this function installed on existing clusters.
40
+ await db.db.execute(sql.raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM ${APP_ROLE}`));
41
+ await db.db.execute(sql.raw(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${APP_ROLE}`));
42
+ await db.db.execute(sql.raw(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${APP_ROLE}`));
43
+ // Clear every table-class privilege before re-granting: it removes the blanket grant an
44
+ // older cluster installed on objects the classification below does not cover (partition
45
+ // children, materialized and foreign tables) and on system-only tables. Views lose their
46
+ // grant here and get it back from applyViews, the only code that grants one (D-097).
47
+ await db.db.execute(sql.raw(`REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM ${APP_ROLE}`));
48
+ const systemOnlyTables = [];
49
+ for (const table of await publicTables(db)) {
50
+ const identifier = sql.identifier(table.name);
51
+ if (table.rowSecurityEnabled) {
52
+ await db.db.execute(sql `grant select, insert, update, delete on table ${identifier} to ${sql.identifier(APP_ROLE)}`);
53
+ continue;
54
+ }
55
+ // Belt and braces: PUBLIC holds nothing by default, but a hand-written grant must not
56
+ // reopen a system-only table.
57
+ await db.db.execute(sql `revoke all privileges on table ${identifier} from ${sql.identifier(APP_ROLE)}`);
58
+ await db.db.execute(sql `revoke all privileges on table ${identifier} from public`);
59
+ systemOnlyTables.push(table.name);
60
+ }
61
+ return { systemOnlyTables };
62
+ }
63
+ /**
64
+ * Runs `fn` in one transaction whose Postgres identity is facilis_app and whose GUCs carry
65
+ * the scope. GUC values travel through set_config parameters, never string concatenation.
66
+ */
67
+ export async function runWithPolicyScope(db, scope, fn) {
68
+ return db.db.transaction(async (tx) => {
69
+ try {
70
+ // SET LOCAL ROLE drops back to the connecting (superuser) role at commit.
71
+ await tx.execute(sql.raw(`SET LOCAL ROLE ${APP_ROLE}`));
72
+ }
73
+ catch (cause) {
74
+ throw new AppRoleUnavailableError(cause);
75
+ }
76
+ const roles = JSON.stringify(scope.roles ?? []);
77
+ const permissions = JSON.stringify(scope.permissions ?? []);
78
+ await tx.execute(sql `
79
+ select
80
+ set_config('app.tenant_id', ${scope.tenantId ?? ""}, true),
81
+ set_config('app.user_id', ${scope.userId ?? ""}, true),
82
+ set_config('app.roles', ${roles}, true),
83
+ set_config('app.permissions', ${permissions}, true)
84
+ `);
85
+ return fn(tx);
86
+ });
87
+ }
88
+ /**
89
+ * The grant loop's input. The catalog, not the code schema, decides: migrations are what
90
+ * actually enabled RLS. Partition children are skipped — routing through a partitioned
91
+ * parent checks the parent's privilege, and the framework has no partitioned tables today.
92
+ */
93
+ async function publicTables(db) {
94
+ const result = await db.db.execute(sql `
95
+ select c.relname as name, c.relrowsecurity as row_security
96
+ from pg_class c
97
+ join pg_namespace n on n.oid = c.relnamespace
98
+ where n.nspname = 'public' and c.relkind in ('r', 'p') and not c.relispartition
99
+ order by c.relname
100
+ `);
101
+ const tables = [];
102
+ for (const row of result.rows) {
103
+ const name = row["name"];
104
+ const rowSecurityEnabled = row["row_security"];
105
+ if (typeof name !== "string" || typeof rowSecurityEnabled !== "boolean") {
106
+ throw new Error("ensureAppRole: unexpected pg_class row shape");
107
+ }
108
+ tables.push({ name, rowSecurityEnabled });
109
+ }
110
+ return tables;
111
+ }
@@ -0,0 +1,44 @@
1
+ import type { FacilisDb } from "./client.js";
2
+ export declare const DEFAULT_QUERY_LIMIT = 100;
3
+ export declare const MAX_QUERY_LIMIT = 500;
4
+ /** A console query must not hang the dev server; 5s is generous for a preview. */
5
+ export declare const QUERY_STATEMENT_TIMEOUT_MS = 5000;
6
+ export type QueryExecutionErrorCode = "invalid_name" | "invalid_sql" | "invalid_permission" | "query_failed" | "create_failed";
7
+ /** A console operation the route reports as a 400 `{ code }` body. */
8
+ export declare class QueryExecutionError extends Error {
9
+ readonly code: QueryExecutionErrorCode;
10
+ constructor(code: QueryExecutionErrorCode, message: string);
11
+ }
12
+ export interface QueryColumn {
13
+ readonly name: string;
14
+ /** Postgres type name from pg_type (e.g. "uuid", "timestamptz"). */
15
+ readonly pgType: string;
16
+ }
17
+ export interface QueryExecution {
18
+ readonly columns: readonly QueryColumn[];
19
+ readonly rows: readonly (readonly unknown[])[];
20
+ readonly rowCount: number;
21
+ readonly elapsedMs: number;
22
+ readonly truncated: boolean;
23
+ }
24
+ /**
25
+ * Run one read-only statement for the console.
26
+ *
27
+ * The prefix check is the friendly error; the read-only transaction is the boundary: a
28
+ * data-modifying CTE (WITH ... DELETE) passes the prefix check and is rejected by
29
+ * Postgres. The statement is wrapped in an outer LIMIT so a preview cannot stream the
30
+ * whole table into the browser, and a statement timeout keeps a runaway query from
31
+ * pinning the dev server.
32
+ */
33
+ export declare function runReadOnlyQuery(db: FacilisDb, sqlText: string, limit?: number): Promise<QueryExecution>;
34
+ /**
35
+ * Create (or replace) a live view from the console's SQL and grant the policy role.
36
+ * The Studio writes db/views/<name>.ts afterwards; this call makes the view usable
37
+ * immediately, before the next boot re-applies the code.
38
+ */
39
+ export declare function createLiveView(db: FacilisDb, name: string, queryText: string, permission?: string): Promise<void>;
40
+ /**
41
+ * JSON-safe cell value. Postgres drivers hand back bigint, Date, Uint8Array and nested
42
+ * jsonb; JSON.stringify would throw on bigint and silently reshape the rest.
43
+ */
44
+ export declare function toJsonValue(value: unknown): unknown;
@@ -0,0 +1,173 @@
1
+ // Read-only SQL execution and live view creation for the Studio's dev console
2
+ // (SPEC §11.2, D-097).
3
+ //
4
+ // This lives in @facilis.dev/db because it is SQL: AGENTS.md security rule 5 keeps raw Drizzle
5
+ // imports inside this package. The Nitro route owns transport and validation; this module
6
+ // owns the read-only transaction and the JSON-safe result shape.
7
+ import { sql } from "drizzle-orm";
8
+ import { APP_ROLE } from "./policy.js";
9
+ import { isReadOnlySql, stripTrailingSemicolon } from "./read-only-sql.js";
10
+ import { grantViewSelect, ViewDefinitionError, viewDefinitionStatement } from "./views.js";
11
+ export const DEFAULT_QUERY_LIMIT = 100;
12
+ export const MAX_QUERY_LIMIT = 500;
13
+ /** A console query must not hang the dev server; 5s is generous for a preview. */
14
+ export const QUERY_STATEMENT_TIMEOUT_MS = 5000;
15
+ /** A console operation the route reports as a 400 `{ code }` body. */
16
+ export class QueryExecutionError extends Error {
17
+ code;
18
+ constructor(code, message) {
19
+ super(message);
20
+ this.name = "QueryExecutionError";
21
+ this.code = code;
22
+ }
23
+ }
24
+ /**
25
+ * Run one read-only statement for the console.
26
+ *
27
+ * The prefix check is the friendly error; the read-only transaction is the boundary: a
28
+ * data-modifying CTE (WITH ... DELETE) passes the prefix check and is rejected by
29
+ * Postgres. The statement is wrapped in an outer LIMIT so a preview cannot stream the
30
+ * whole table into the browser, and a statement timeout keeps a runaway query from
31
+ * pinning the dev server.
32
+ */
33
+ export async function runReadOnlyQuery(db, sqlText, limit = DEFAULT_QUERY_LIMIT) {
34
+ if (!isReadOnlySql(sqlText)) {
35
+ throw new QueryExecutionError("invalid_sql", "The console runs one read-only statement (SELECT, WITH, VALUES or TABLE).");
36
+ }
37
+ const statement = stripTrailingSemicolon(sqlText);
38
+ const capped = Math.min(Math.max(Math.trunc(limit), 1), MAX_QUERY_LIMIT);
39
+ const startedAt = performance.now();
40
+ try {
41
+ return await db.db.transaction(async (tx) => {
42
+ await tx.execute(sql.raw("set transaction read only"));
43
+ await tx.execute(sql.raw("set local statement_timeout = '" + QUERY_STATEMENT_TIMEOUT_MS + "ms'"));
44
+ const result = await tx.execute(sql.raw("select * from (\n" + statement + "\n) as __facilis_query limit " + (capped + 1)));
45
+ const fields = readFields(result);
46
+ if (fields.length === 0) {
47
+ throw new QueryExecutionError("query_failed", "The statement returned no result columns.");
48
+ }
49
+ const types = await readTypeNames(tx, fields);
50
+ const rows = result.rows
51
+ .slice(0, capped)
52
+ .map((row) => fields.map((field) => toJsonValue(row[field.name])));
53
+ return {
54
+ columns: fields.map((field) => ({
55
+ name: field.name,
56
+ pgType: types.get(field.dataTypeID) ?? "unknown",
57
+ })),
58
+ rows,
59
+ rowCount: rows.length,
60
+ elapsedMs: Math.round(performance.now() - startedAt),
61
+ truncated: result.rows.length > capped,
62
+ };
63
+ });
64
+ }
65
+ catch (error) {
66
+ // Postgres errors are plain driver objects; the console reports the reason as a code,
67
+ // never a 500 with a stack.
68
+ if (error instanceof QueryExecutionError)
69
+ throw error;
70
+ throw new QueryExecutionError("query_failed", messageOf(error));
71
+ }
72
+ }
73
+ /**
74
+ * Create (or replace) a live view from the console's SQL and grant the policy role.
75
+ * The Studio writes db/views/<name>.ts afterwards; this call makes the view usable
76
+ * immediately, before the next boot re-applies the code.
77
+ */
78
+ export async function createLiveView(db, name, queryText, permission) {
79
+ let statement;
80
+ try {
81
+ statement = viewDefinitionStatement(name, queryText, permission);
82
+ }
83
+ catch (error) {
84
+ if (error instanceof ViewDefinitionError) {
85
+ throw new QueryExecutionError(error.code, error.message);
86
+ }
87
+ throw error;
88
+ }
89
+ try {
90
+ await db.db.execute(statement);
91
+ }
92
+ catch (error) {
93
+ throw new QueryExecutionError("create_failed", messageOf(error));
94
+ }
95
+ try {
96
+ await grantViewSelect(db, name);
97
+ }
98
+ catch (error) {
99
+ throw new QueryExecutionError("create_failed", "View created, but the grant to " + APP_ROLE + " failed: " + messageOf(error));
100
+ }
101
+ }
102
+ async function readTypeNames(tx, fields) {
103
+ const types = new Map();
104
+ const oids = [...new Set(fields.map((field) => field.dataTypeID))].filter((oid) => Number.isInteger(oid));
105
+ if (oids.length === 0)
106
+ return types;
107
+ const result = await tx.execute(sql.raw("select oid, typname from pg_type where oid in (" + oids.join(", ") + ")"));
108
+ for (const row of result.rows) {
109
+ const oid = Number(row["oid"]);
110
+ const name = row["typname"];
111
+ if (Number.isInteger(oid) && typeof name === "string")
112
+ types.set(oid, name);
113
+ }
114
+ return types;
115
+ }
116
+ function readFields(result) {
117
+ if (typeof result !== "object" || result === null)
118
+ return [];
119
+ const fields = Reflect.get(result, "fields");
120
+ if (!Array.isArray(fields))
121
+ return [];
122
+ const out = [];
123
+ for (const entry of fields) {
124
+ if (typeof entry !== "object" || entry === null)
125
+ continue;
126
+ const name = Reflect.get(entry, "name");
127
+ const oid = Reflect.get(entry, "dataTypeID");
128
+ if (typeof name === "string" && typeof oid === "number")
129
+ out.push({ name, dataTypeID: oid });
130
+ }
131
+ return out;
132
+ }
133
+ /**
134
+ * JSON-safe cell value. Postgres drivers hand back bigint, Date, Uint8Array and nested
135
+ * jsonb; JSON.stringify would throw on bigint and silently reshape the rest.
136
+ */
137
+ export function toJsonValue(value) {
138
+ if (value === null || value === undefined)
139
+ return null;
140
+ if (typeof value === "string" || typeof value === "boolean")
141
+ return value;
142
+ if (typeof value === "number")
143
+ return Number.isFinite(value) ? value : null;
144
+ if (typeof value === "bigint")
145
+ return value.toString();
146
+ if (value instanceof Date)
147
+ return value.toISOString();
148
+ if (value instanceof Uint8Array)
149
+ return "\\x" + [...value].map(hexByte).join("");
150
+ if (Array.isArray(value))
151
+ return value.map(toJsonValue);
152
+ if (typeof value === "object") {
153
+ const out = {};
154
+ for (const [key, entry] of Object.entries(value))
155
+ out[key] = toJsonValue(entry);
156
+ return out;
157
+ }
158
+ return String(value);
159
+ }
160
+ function hexByte(byte) {
161
+ return byte.toString(16).padStart(2, "0");
162
+ }
163
+ function messageOf(error) {
164
+ if (!(error instanceof Error))
165
+ return String(error);
166
+ // Drizzle wraps a driver error in "Failed query: …"; the reason (e.g. "cannot execute
167
+ // DELETE in a read-only transaction") is in the cause and is what the console shows.
168
+ const cause = error.cause;
169
+ if (cause instanceof Error && cause.message.length > 0) {
170
+ return error.message + ": " + cause.message;
171
+ }
172
+ return error.message;
173
+ }
@@ -0,0 +1,13 @@
1
+ /** True for a single read-only statement: SELECT, WITH, VALUES or TABLE. */
2
+ export declare function isReadOnlySql(text: string): boolean;
3
+ /** The statement without a trailing semicolon, for embedding in a wrapper or in DDL. */
4
+ export declare function stripTrailingSemicolon(text: string): string;
5
+ /** The first keyword of the statement, comments and whitespace skipped. */
6
+ export declare function firstKeyword(text: string): string | null;
7
+ /**
8
+ * True when a ';' is followed by anything but comments/whitespace, or by another ';'.
9
+ * Backslashes are NOT treated as escapes: standard_conforming_strings is on, and a
10
+ * missed closing quote would make the scanner swallow a real statement separator (a
11
+ * false "single statement" is the one answer this must never give).
12
+ */
13
+ export declare function hasMultipleStatements(text: string): boolean;
@@ -0,0 +1,145 @@
1
+ // Static guards for SQL text that reaches a read-only surface (SPEC §11.2).
2
+ //
3
+ // These checks are the UX half of the contract: the dev query endpoint wraps every
4
+ // statement in a read-only transaction, which is what actually rejects writes. They still
5
+ // matter where a query is inlined into another statement — a view definition is embedded
6
+ // in CREATE VIEW, so a second statement must never slip through, and a prefix check alone
7
+ // would accept "select 1; drop table todos".
8
+ const READ_ONLY_KEYWORDS = new Set(["SELECT", "WITH", "VALUES", "TABLE"]);
9
+ /** True for a single read-only statement: SELECT, WITH, VALUES or TABLE. */
10
+ export function isReadOnlySql(text) {
11
+ const keyword = firstKeyword(text);
12
+ if (keyword === null || !READ_ONLY_KEYWORDS.has(keyword))
13
+ return false;
14
+ return !hasMultipleStatements(text);
15
+ }
16
+ /** The statement without a trailing semicolon, for embedding in a wrapper or in DDL. */
17
+ export function stripTrailingSemicolon(text) {
18
+ return text.trim().replace(/;\s*$/, "");
19
+ }
20
+ function isSpace(ch) {
21
+ return ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
22
+ }
23
+ /** True when the text at `cursor.index` starts a comment; advances past it when so. */
24
+ function skipComment(cursor) {
25
+ const { text, index } = cursor;
26
+ if (text[index] === "-" && text[index + 1] === "-") {
27
+ const newline = text.indexOf("\n", index + 2);
28
+ cursor.index = newline === -1 ? text.length : newline + 1;
29
+ return true;
30
+ }
31
+ if (text[index] === "/" && text[index + 1] === "*") {
32
+ // Postgres block comments nest; count them so an inner comment cannot end the outer.
33
+ let depth = 1;
34
+ let at = index + 2;
35
+ while (at < text.length && depth > 0) {
36
+ if (text[at] === "/" && text[at + 1] === "*") {
37
+ depth += 1;
38
+ at += 2;
39
+ continue;
40
+ }
41
+ if (text[at] === "*" && text[at + 1] === "/") {
42
+ depth -= 1;
43
+ at += 2;
44
+ continue;
45
+ }
46
+ at += 1;
47
+ }
48
+ cursor.index = at;
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+ /** The first keyword of the statement, comments and whitespace skipped. */
54
+ export function firstKeyword(text) {
55
+ const cursor = { text, index: 0 };
56
+ while (cursor.index < text.length) {
57
+ while (cursor.index < text.length && isSpace(text[cursor.index] ?? ""))
58
+ cursor.index += 1;
59
+ if (skipComment(cursor))
60
+ continue;
61
+ break;
62
+ }
63
+ const match = /^[A-Za-z]+/.exec(text.slice(cursor.index));
64
+ return match === null ? null : match[0].toUpperCase();
65
+ }
66
+ /**
67
+ * True when a ';' is followed by anything but comments/whitespace, or by another ';'.
68
+ * Backslashes are NOT treated as escapes: standard_conforming_strings is on, and a
69
+ * missed closing quote would make the scanner swallow a real statement separator (a
70
+ * false "single statement" is the one answer this must never give).
71
+ */
72
+ export function hasMultipleStatements(text) {
73
+ const cursor = { text, index: 0 };
74
+ let state = { kind: "normal" };
75
+ let separatorSeen = false;
76
+ while (cursor.index < text.length) {
77
+ const ch = text[cursor.index] ?? "";
78
+ if (state.kind === "normal") {
79
+ if (skipComment(cursor))
80
+ continue;
81
+ if (isSpace(ch)) {
82
+ cursor.index += 1;
83
+ continue;
84
+ }
85
+ if (separatorSeen)
86
+ return true;
87
+ if (ch === "'") {
88
+ state = { kind: "single" };
89
+ cursor.index += 1;
90
+ continue;
91
+ }
92
+ if (ch === '"') {
93
+ state = { kind: "double" };
94
+ cursor.index += 1;
95
+ continue;
96
+ }
97
+ if (ch === "$") {
98
+ const tag = dollarTagAt(text, cursor.index);
99
+ if (tag !== null) {
100
+ state = { kind: "dollar", tag };
101
+ cursor.index += tag.length;
102
+ continue;
103
+ }
104
+ }
105
+ if (ch === ";")
106
+ separatorSeen = true;
107
+ cursor.index += 1;
108
+ continue;
109
+ }
110
+ if (state.kind === "single") {
111
+ if (ch === "'") {
112
+ if (text[cursor.index + 1] === "'") {
113
+ cursor.index += 2;
114
+ continue;
115
+ }
116
+ state = { kind: "normal" };
117
+ }
118
+ cursor.index += 1;
119
+ continue;
120
+ }
121
+ if (state.kind === "double") {
122
+ if (ch === '"') {
123
+ if (text[cursor.index + 1] === '"') {
124
+ cursor.index += 2;
125
+ continue;
126
+ }
127
+ state = { kind: "normal" };
128
+ }
129
+ cursor.index += 1;
130
+ continue;
131
+ }
132
+ if (text.startsWith(state.tag, cursor.index)) {
133
+ cursor.index += state.tag.length;
134
+ state = { kind: "normal" };
135
+ continue;
136
+ }
137
+ cursor.index += 1;
138
+ }
139
+ return false;
140
+ }
141
+ /** A complete dollar-quote tag (`$tag$`) at `index`, or null for a positional parameter. */
142
+ function dollarTagAt(text, index) {
143
+ const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(text.slice(index));
144
+ return match === null ? null : match[0];
145
+ }
package/dist/rls.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { type PgPolicy } from "drizzle-orm/pg-core";
2
+ /** Row-level-security extras returned by `rlsForTable` for a pgTable definition. */
3
+ export interface RlsOptions {
4
+ readonly tenantColumn?: string;
5
+ readonly ownerColumn?: string;
6
+ readonly rolesColumn?: string;
7
+ }
8
+ export interface RlsExtras {
9
+ readonly enableRLS: true;
10
+ readonly policies: PgPolicy[];
11
+ }
12
+ /**
13
+ * Tenant isolation as a RESTRICTIVE policy. Deviation from SPEC §8.4.5's original SQL:
14
+ * as written there the policy is permissive, and Postgres ORs permissive policies, so
15
+ * role/owner clauses let callers cross tenants. D-008 (Phase 0 evidence) fixes this by
16
+ * making tenant isolation RESTRICTIVE (always AND-ed); this generator emits that form.
17
+ */
18
+ export declare function tenantIsolationPolicy(column?: string): PgPolicy;
19
+ /** Permissive policy granting a caller rows they own (SPEC §8.4.5 owner clause). */
20
+ export declare function ownerIsolationPolicy(column?: string): PgPolicy;
21
+ /**
22
+ * Permissive role policy: a row is visible when the row's `column` names one of the
23
+ * caller's app roles (`app.roles::jsonb ? column`). Columns hold a role name such as
24
+ * "admin"; roles themselves are never Postgres roles (SPEC §8.4.5).
25
+ */
26
+ export declare function rolePolicy(column: string, policyName?: string): PgPolicy;
27
+ /**
28
+ * Builds the standard policy set for a scoped table. Use as
29
+ * `pgTable(..., () => rlsForTable({ tenantColumn }).policies).enableRLS()`.
30
+ * (`enableRLS` is returned so callers can assert/forward the flag; Drizzle applies it
31
+ * through `.enableRLS()`, since pgTable's extras callback only accepts config values.)
32
+ */
33
+ export declare function rlsForTable(options?: RlsOptions): RlsExtras;
34
+ /** Alias kept for the wording used in the Phase 1 contracts file. */
35
+ export declare const withRls: typeof rlsForTable;
36
+ /** Renders a complete CREATE POLICY statement (used by migrations and the manifest). */
37
+ export declare function policyCreateSql(tableName: string, policy: PgPolicy): string;
package/dist/rls.js ADDED
@@ -0,0 +1,90 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { PgDialect, pgPolicy } from "drizzle-orm/pg-core";
3
+ const dialect = new PgDialect();
4
+ /**
5
+ * Tenant isolation as a RESTRICTIVE policy. Deviation from SPEC §8.4.5's original SQL:
6
+ * as written there the policy is permissive, and Postgres ORs permissive policies, so
7
+ * role/owner clauses let callers cross tenants. D-008 (Phase 0 evidence) fixes this by
8
+ * making tenant isolation RESTRICTIVE (always AND-ed); this generator emits that form.
9
+ */
10
+ export function tenantIsolationPolicy(column = "tenant_id") {
11
+ return pgPolicy("tenant_isolation", {
12
+ as: "restrictive",
13
+ for: "all",
14
+ using: tenantPredicate(column),
15
+ withCheck: tenantPredicate(column),
16
+ });
17
+ }
18
+ /** Permissive policy granting a caller rows they own (SPEC §8.4.5 owner clause). */
19
+ export function ownerIsolationPolicy(column = "owner_id") {
20
+ return pgPolicy("owner_isolation", {
21
+ for: "all",
22
+ using: ownerPredicate(column),
23
+ withCheck: ownerPredicate(column),
24
+ });
25
+ }
26
+ /**
27
+ * Permissive role policy: a row is visible when the row's `column` names one of the
28
+ * caller's app roles (`app.roles::jsonb ? column`). Columns hold a role name such as
29
+ * "admin"; roles themselves are never Postgres roles (SPEC §8.4.5).
30
+ */
31
+ export function rolePolicy(column, policyName = "role_access") {
32
+ return pgPolicy(policyName, {
33
+ for: "all",
34
+ using: rolePredicate(column),
35
+ withCheck: rolePredicate(column),
36
+ });
37
+ }
38
+ /**
39
+ * Builds the standard policy set for a scoped table. Use as
40
+ * `pgTable(..., () => rlsForTable({ tenantColumn }).policies).enableRLS()`.
41
+ * (`enableRLS` is returned so callers can assert/forward the flag; Drizzle applies it
42
+ * through `.enableRLS()`, since pgTable's extras callback only accepts config values.)
43
+ */
44
+ export function rlsForTable(options = {}) {
45
+ const policies = [tenantIsolationPolicy(options.tenantColumn)];
46
+ if (options.ownerColumn !== undefined)
47
+ policies.push(ownerIsolationPolicy(options.ownerColumn));
48
+ if (options.rolesColumn !== undefined)
49
+ policies.push(rolePolicy(options.rolesColumn));
50
+ return { enableRLS: true, policies };
51
+ }
52
+ /** Alias kept for the wording used in the Phase 1 contracts file. */
53
+ export const withRls = rlsForTable;
54
+ /** Renders a complete CREATE POLICY statement (used by migrations and the manifest). */
55
+ export function policyCreateSql(tableName, policy) {
56
+ const parts = [`CREATE POLICY ${quoteIdentifier(policy.name)} ON ${quoteIdentifier(tableName)}`];
57
+ if (policy.as !== undefined)
58
+ parts.push(`AS ${policy.as.toUpperCase()}`);
59
+ parts.push(`FOR ${(policy.for ?? "all").toUpperCase()}`);
60
+ if (policy.to !== undefined)
61
+ parts.push(`TO ${policyToList(policy.to)}`);
62
+ if (policy.using !== undefined)
63
+ parts.push(`USING (${dialect.sqlToQuery(policy.using).sql})`);
64
+ if (policy.withCheck !== undefined) {
65
+ parts.push(`WITH CHECK (${dialect.sqlToQuery(policy.withCheck).sql})`);
66
+ }
67
+ return `${parts.join(" ")};`;
68
+ }
69
+ function tenantPredicate(column) {
70
+ return sql `${sql.identifier(column)} = nullif(current_setting('app.tenant_id', true), '')::uuid`;
71
+ }
72
+ function ownerPredicate(column) {
73
+ return sql `${sql.identifier(column)} = nullif(current_setting('app.user_id', true), '')::uuid`;
74
+ }
75
+ function rolePredicate(column) {
76
+ return sql `nullif(current_setting('app.roles', true), '')::jsonb ? ${sql.identifier(column)}`;
77
+ }
78
+ function quoteIdentifier(name) {
79
+ return `"${name.replaceAll('"', '""')}"`;
80
+ }
81
+ function policyToList(to) {
82
+ if (Array.isArray(to))
83
+ return to.map((entry) => policyToList(entry)).join(", ");
84
+ if (typeof to === "string") {
85
+ return ["public", "current_role", "current_user", "session_user"].includes(to)
86
+ ? to.toUpperCase()
87
+ : quoteIdentifier(to);
88
+ }
89
+ return quoteIdentifier(to.name);
90
+ }