@wtfalch/authz-store 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/dist/schema.js ADDED
@@ -0,0 +1,193 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { bigint, boolean, index, jsonb, pgTable, primaryKey, smallint, text, timestamp, uniqueIndex, uuid, } from 'drizzle-orm/pg-core';
3
+ /**
4
+ * Drizzle mirror of the authority tables: the eight drizzle/0003_authz.sql
5
+ * ships (tenants, roles, role_permissions, memberships, invitations,
6
+ * break_glass_sessions, credentials, authz_events) and the ninth
7
+ * drizzle/0005_nesting.sql adds (attach_proposals). drizzle/0006_credentials.sql
8
+ * adds a column to `credentials` rather than a table, and
9
+ * drizzle/0008_erasure_and_boot.sql adds the tenth (system_role_versions).
10
+ *
11
+ * CHECK constraints, partial indexes, INCLUDE columns and triggers are not
12
+ * expressible in this version of Drizzle's table builder and live only in
13
+ * the SQL; the index declarations below exist so `getTableConfig` reports
14
+ * one index per table index for the host's tenancy manifest,
15
+ * not so this file reproduces the SQL's exact predicate or INCLUDE list.
16
+ * Read the migration for the authoritative definition of every constraint.
17
+ *
18
+ * `profiles` is not here: it is a person-scoped table the host's own database
19
+ * module owns, not one of the authority tables.
20
+ */
21
+ // ---------------------------------------------------------------- tenants
22
+ export const tenants = pgTable('tenants', {
23
+ id: uuid('id').primaryKey().defaultRandom(),
24
+ kind: text('kind').$type().notNull().default('customer'),
25
+ slug: text('slug').notNull(),
26
+ name: text('name').notNull(),
27
+ state: text('state').$type().notNull().default('active'),
28
+ parentId: uuid('parent_id'),
29
+ // Vocabulary-offered permissions this tenant may use (D22). No default:
30
+ // the creating transaction always writes one explicitly.
31
+ ceiling: text('ceiling').array().notNull(),
32
+ selfDenied: text('self_denied').array().notNull().default(sql `'{}'`),
33
+ // Only the single `kind = 'operator'` row may ever be true, enforced by
34
+ // tenants_frozen_operator_check. The two hosts number that migration
35
+ // differently (0010 and 0012), which is why no number is named here.
36
+ frozen: boolean('frozen').notNull().default(false),
37
+ createdBy: text('created_by'),
38
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
39
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().default(sql `now()`),
40
+ }, (t) => [
41
+ // Full definition is a partial unique index (only kind = 'operator');
42
+ // this declares the column so the manifest sees an index here.
43
+ uniqueIndex('tenants_one_operator_idx').on(t.kind),
44
+ index('tenants_parent_idx').on(t.parentId),
45
+ ]);
46
+ // ------------------------------------------------------------- memberships
47
+ export const memberships = pgTable('memberships', {
48
+ tenantId: uuid('tenant_id').notNull(),
49
+ principalId: text('principal_id').notNull(),
50
+ principalClass: text('principal_class').notNull(),
51
+ source: text('source').notNull().default('direct'),
52
+ viaTenantId: uuid('via_tenant_id'),
53
+ // Denormalised from tenants.name so the switcher reads one index range
54
+ // (D5); renameTenant rewrites it in the same transaction.
55
+ tenantName: text('tenant_name').notNull(),
56
+ grantedBy: text('granted_by'),
57
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
58
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().default(sql `now()`),
59
+ }, (t) => [
60
+ primaryKey({ columns: [t.tenantId, t.principalClass, t.principalId] }),
61
+ // Full definition also INCLUDEs (role, source, principal_class); not
62
+ // expressible here, see the file header.
63
+ index('memberships_principal_idx').on(t.principalId, t.tenantName, t.tenantId),
64
+ index('memberships_via_idx').on(t.viaTenantId),
65
+ ]);
66
+ // ------------------------------------------------------------- invitations
67
+ export const invitations = pgTable('invitations', {
68
+ id: uuid('id').primaryKey().defaultRandom(),
69
+ tenantId: uuid('tenant_id').notNull(),
70
+ email: text('email').notNull(),
71
+ roleId: uuid('role_id').notNull(),
72
+ tokenHash: text('token_hash').notNull().unique(),
73
+ status: text('status').notNull().default('pending'),
74
+ mailState: text('mail_state').notNull().default('pending'),
75
+ // When mail_state last changed, so D9's "mail failed for more than an
76
+ // hour" alert measures the right thing (0008). Null on a row that
77
+ // predates the column.
78
+ mailStateAt: timestamp('mail_state_at', { withTimezone: true }),
79
+ invitedBy: text('invited_by'),
80
+ invitedByClass: text('invited_by_class'),
81
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
82
+ acceptedBy: text('accepted_by'),
83
+ acceptedAt: timestamp('accepted_at', { withTimezone: true }),
84
+ revokedBy: text('revoked_by'),
85
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
86
+ // A child a parent creates is born attached (D10, D19): accepting this
87
+ // invitation writes the first owner's membership and performs the
88
+ // attachment in one transaction. Null on every ordinary invitation.
89
+ attachParentId: uuid('attach_parent_id'),
90
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
91
+ }, (t) => [
92
+ // Full definition is a partial unique index (only status = 'pending');
93
+ // this declares the columns so the manifest sees an index here.
94
+ uniqueIndex('invitations_pending_idx').on(t.tenantId, t.email),
95
+ index('invitations_tenant_idx').on(t.tenantId, t.createdAt.desc()),
96
+ ]);
97
+ // -------------------------------------------------------- attach_proposals
98
+ export const attachProposals = pgTable('attach_proposals', {
99
+ id: uuid('id').primaryKey().defaultRandom(),
100
+ parentId: uuid('parent_id').notNull(),
101
+ childId: uuid('child_id').notNull(),
102
+ status: text('status').notNull().default('pending'),
103
+ reason: text('reason').notNull(),
104
+ reference: text('reference').notNull(),
105
+ proposedBy: text('proposed_by').notNull(),
106
+ parentAcceptedBy: text('parent_accepted_by'),
107
+ parentAcceptedAt: timestamp('parent_accepted_at', { withTimezone: true }),
108
+ decidedBy: text('decided_by'),
109
+ decidedAt: timestamp('decided_at', { withTimezone: true }),
110
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
111
+ }, (t) => [
112
+ // Full definition is a partial unique index (only status = 'pending');
113
+ // this declares the columns so the manifest sees an index here.
114
+ uniqueIndex('attach_proposals_pending_idx').on(t.parentId, t.childId),
115
+ index('attach_proposals_child_idx').on(t.childId, t.createdAt.desc()),
116
+ index('attach_proposals_parent_idx').on(t.parentId, t.createdAt.desc()),
117
+ ]);
118
+ // ----------------------------------------------------- break_glass_sessions
119
+ export const breakGlassSessions = pgTable('break_glass_sessions', {
120
+ id: uuid('id').primaryKey().defaultRandom(),
121
+ operatorId: text('operator_id').notNull(),
122
+ tenantId: uuid('tenant_id').notNull(),
123
+ roleId: uuid('role_id').notNull(),
124
+ readOnly: boolean('read_only').notNull().default(true),
125
+ reasonCode: text('reason_code').notNull(),
126
+ reference: text('reference').notNull(),
127
+ startedAt: timestamp('started_at', { withTimezone: true }).notNull().default(sql `now()`),
128
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
129
+ endedAt: timestamp('ended_at', { withTimezone: true }),
130
+ endedBy: text('ended_by'),
131
+ }, (t) => [
132
+ // Full definition is partial (WHERE ended_at IS NULL); see the header.
133
+ index('bg_active_idx').on(t.operatorId, t.tenantId),
134
+ index('bg_tenant_idx').on(t.tenantId, t.startedAt.desc()),
135
+ ]);
136
+ // -------------------------------------------------------------- credentials
137
+ export const credentials = pgTable('credentials', {
138
+ id: uuid('id').primaryKey().defaultRandom(),
139
+ tenantId: uuid('tenant_id').notNull(),
140
+ kind: text('kind').notNull(),
141
+ issuerId: text('issuer_id').notNull(),
142
+ issuerClass: text('issuer_class').notNull(),
143
+ name: text('name').notNull(),
144
+ secretHash: text('secret_hash').notNull().unique(),
145
+ // The first characters of the secret's random half, kept in clear so a
146
+ // list can tell one credential from another (0006). Not a secret.
147
+ secretPrefix: text('secret_prefix'),
148
+ createdBy: text('created_by'),
149
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql `now()`),
150
+ expiresAt: timestamp('expires_at', { withTimezone: true }),
151
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
152
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
153
+ }, (t) => [
154
+ index('credentials_tenant_idx').on(t.tenantId),
155
+ // Full definition is partial (WHERE revoked_at IS NULL); see the header.
156
+ index('credentials_live_idx').on(t.tenantId, t.createdAt.desc()),
157
+ ]);
158
+ // ------------------------------------------------------------- authz_events
159
+ export const authzEvents = pgTable('authz_events', {
160
+ // (occurred_at, id) is the ordering; see the migration for why this is a
161
+ // bigint identity rather than a time-ordered uuid.
162
+ id: bigint('id', { mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(),
163
+ occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().default(sql `now()`),
164
+ tenantId: uuid('tenant_id'),
165
+ teamId: uuid('team_id'),
166
+ subjectId: text('subject_id'),
167
+ subjectClass: text('subject_class'),
168
+ actorClass: text('actor_class').notNull(),
169
+ actorId: text('actor_id').notNull(),
170
+ actorDisplay: text('actor_display').notNull(),
171
+ action: text('action').notNull(),
172
+ targetType: text('target_type').notNull(),
173
+ targetId: text('target_id').notNull(),
174
+ outcome: text('outcome').notNull(),
175
+ context: text('context').notNull(),
176
+ sessionId: text('session_id'),
177
+ reason: text('reason'),
178
+ reference: text('reference'),
179
+ requestId: text('request_id'),
180
+ ip: text('ip'),
181
+ userAgent: text('user_agent'),
182
+ tenantVisible: boolean('tenant_visible').notNull(),
183
+ before: jsonb('before'),
184
+ after: jsonb('after'),
185
+ erasedAt: timestamp('erased_at', { withTimezone: true }),
186
+ schemaVersion: smallint('schema_version').notNull().default(1),
187
+ }, (t) => [
188
+ index('authz_events_tenant_time_idx').on(t.tenantId, t.occurredAt.desc(), t.id.desc()),
189
+ index('authz_events_actor_time_idx').on(t.actorId, t.occurredAt.desc()),
190
+ index('authz_events_team_time_idx').on(t.tenantId, t.teamId, t.occurredAt.desc(), t.id.desc()),
191
+ index('authz_events_subject_time_idx').on(t.tenantId, t.subjectClass, t.subjectId, t.occurredAt.desc(), t.id.desc()),
192
+ index('authz_events_action_time_idx').on(t.action, t.occurredAt.desc()),
193
+ ]);
@@ -0,0 +1,140 @@
1
+ import { type SQL } from 'drizzle-orm';
2
+ import type { PgColumn, PgDatabase, PgInsertValue, PgQueryResultHKT, PgTable, PgTransaction, PgUpdateSetSource } from 'drizzle-orm/pg-core';
3
+ /**
4
+ * Thrown by a write method (insert, update, delete, transaction) called on a
5
+ * scope that is read-only: a tenant whose state is `read_only` or
6
+ * `suspended`, or a read-only break-glass session. Thrown synchronously,
7
+ * before a query is built or a connection touched, so a page that writes on
8
+ * read (a last-viewed stamp, a draft autosave) cannot leak a write through a
9
+ * read-only context regardless of what the call site already checked (D6,
10
+ * M4).
11
+ */
12
+ export declare class ReadOnlyAccessError extends Error {
13
+ }
14
+ /**
15
+ * Thrown when a table has no column registered for the scope in use.
16
+ * The host's registries are the only source of truth for what a `ScopedDb`
17
+ * may touch; a table missing from all of them is a bug in the host's registry,
18
+ * not something the caller can work around.
19
+ */
20
+ export declare class UnscopedTableError extends Error {
21
+ }
22
+ /**
23
+ * What one `ScopedDb` carries. `column` is a function rather than one fixed
24
+ * column because a single scope kind (tenant, person) covers many tables,
25
+ * each with its own scope column; `value` is the id every predicate compares
26
+ * it against; `readOnly` and `label` are used by the guard and its error
27
+ * messages respectively.
28
+ */
29
+ export interface Scope {
30
+ readonly column: (table: PgTable) => PgColumn | undefined;
31
+ readonly value: string;
32
+ readonly readOnly: boolean;
33
+ readonly label: 'tenant' | 'person';
34
+ }
35
+ /**
36
+ * Either the raw handle or a transaction already open on it. Every method
37
+ * below accepts both, so code already inside one transaction (a `grant`
38
+ * writing a membership and its audit row together) passes that transaction
39
+ * through rather than opening a second, nested one for no reason.
40
+ *
41
+ * Named through drizzle's own base types rather than one host's concrete
42
+ * handle: this package does not know which driver a host uses, and every
43
+ * method here builds statements rather than sending them.
44
+ */
45
+ type HostSchema = any;
46
+ export type DbOrTx = PgDatabase<PgQueryResultHKT, HostSchema, HostSchema> | PgTransaction<PgQueryResultHKT, HostSchema, HostSchema>;
47
+ /**
48
+ * The only way feature code touches a tenant or person table (D6). A tenant
49
+ * instance is built by `openScopedDb` from a fresh membership read inside
50
+ * the host's `accessFor`; a person instance is built from that person's own
51
+ * id. Nothing else constructs one, which is what the private constructor is
52
+ * for: even a module-level function in this same file cannot call it, only a
53
+ * static member of the class, which is why `openScopedDb` wraps
54
+ * `ScopedDb.open` rather than calling `new ScopedDb` itself.
55
+ *
56
+ * Every write method refuses at the query-builder layer when the scope is
57
+ * read-only, before building or sending anything, independent of whatever
58
+ * the call site already decided.
59
+ */
60
+ export declare class ScopedDb {
61
+ private readonly root;
62
+ private readonly scope;
63
+ private constructor();
64
+ static open(root: DbOrTx, scope: Scope): ScopedDb;
65
+ private columnFor;
66
+ /**
67
+ * The property key an insert or update payload uses for this column.
68
+ * Drizzle keys `.values()`/`.set()` by the table's own field name (for
69
+ * example `tenantId`), not the SQL column name (`tenant_id`), and a column
70
+ * does not know its own field name, so this looks it up the same way
71
+ * drizzle itself does: by identity against `getTableColumns`.
72
+ */
73
+ private keyFor;
74
+ private assertWritable;
75
+ /**
76
+ * The predicate every method here builds from: the scope column compared
77
+ * to the scope's own value. Exposed on its own because a caller sometimes
78
+ * needs it directly (a join, a count) rather than through one of the
79
+ * methods below.
80
+ */
81
+ predicate(table: PgTable): SQL;
82
+ /**
83
+ * The select builder, scoped, so `.orderBy`/`.limit`/further `.where`
84
+ * continue to chain on the result. The cast on `table` works around a
85
+ * drizzle typing limitation (a generic table parameter does not satisfy
86
+ * `.from()`'s subquery-detection conditional); the query it builds is
87
+ * identical either way, the cast only affects the row type TypeScript
88
+ * infers back, which is why insert and update below, which do not touch
89
+ * `.from()`, keep their full per-table typing.
90
+ */
91
+ select<T extends PgTable>(table: T, extra?: SQL): Omit<import("drizzle-orm/pg-core").PgSelectBase<string, Record<string, PgColumn<import("drizzle-orm").ColumnBaseConfig<import("drizzle-orm").ColumnDataType, string>, {}, {}>>, "single", Record<string, "not-null">, false, "where", {
92
+ [x: string]: unknown;
93
+ }[], {
94
+ [x: string]: PgColumn<import("drizzle-orm").ColumnBaseConfig<import("drizzle-orm").ColumnDataType, string>, {}, {}>;
95
+ }>, "where">;
96
+ /**
97
+ * Strips the scope column from every row and writes the scope's own value
98
+ * instead, after the spread rather than merged into it, so a caller-
99
+ * supplied value for that column (a compromised form body setting
100
+ * `tenantId` to some other tenant) never survives into the statement.
101
+ */
102
+ insert<T extends PgTable>(table: T, values: PgInsertValue<T> | PgInsertValue<T>[]): import("drizzle-orm/pg-core").PgInsertBase<T, PgQueryResultHKT, undefined, undefined, false, never>;
103
+ /**
104
+ * Strips the scope column from `set` (dropped, not overwritten: an update
105
+ * never moves a row between tenants) and adds the scope predicate to the
106
+ * WHERE, so a write can only ever land on the caller's own rows.
107
+ */
108
+ update<T extends PgTable>(table: T, set: Partial<PgUpdateSetSource<T>>, extra?: SQL): Omit<import("drizzle-orm/pg-core").PgUpdateBase<T, PgQueryResultHKT, undefined, undefined, undefined, Record<T["_"]["name"], "not-null">, [], false, "where" | "leftJoin" | "rightJoin" | "innerJoin" | "fullJoin">, "where" | "leftJoin" | "rightJoin" | "innerJoin" | "fullJoin">;
109
+ /**
110
+ * `extra` is required, not optional: a delete with only the scope
111
+ * predicate would remove every row this principal can see, so the type
112
+ * system refuses a call without it. The runtime check is the second net
113
+ * for a caller that reaches this past the type system (an `any`-typed call
114
+ * site).
115
+ */
116
+ delete<T extends PgTable>(table: T, extra: SQL): Omit<import("drizzle-orm/pg-core").PgDeleteBase<T, PgQueryResultHKT, undefined, undefined, false, "where">, "where">;
117
+ /**
118
+ * Refused outright on a read-only scope, the same as insert, update and
119
+ * delete: a transaction is opened to write, and refusing only once the
120
+ * callback attempts one would already have reached the client (postgres-js
121
+ * sends `BEGIN` the moment `transaction()` is called, before the callback
122
+ * ever runs), which is exactly what the read-only guard exists to prevent.
123
+ */
124
+ transaction<R>(fn: (tx: ScopedDb) => Promise<R>): Promise<R>;
125
+ }
126
+ /**
127
+ * The only way to build a `ScopedDb`. A host calls this from the two places
128
+ * that legitimately mint a scope — a tenant scope from a fresh membership
129
+ * read, and a person scope from a person's own id — and re-exports neither
130
+ * this nor the class from its own barrel, so a feature module cannot mint a
131
+ * scope of its own.
132
+ */
133
+ export declare function openScopedDb(root: DbOrTx, scope: Scope): ScopedDb;
134
+ /**
135
+ * Turns a host's table-to-column registry into a `Scope['column']`. The
136
+ * registry itself is the host's: only it knows which of its tables carry a
137
+ * tenant id and which carry a person id.
138
+ */
139
+ export declare function scopeColumnOf(tables: ReadonlyMap<PgTable, PgColumn>): (table: PgTable) => PgColumn | undefined;
140
+ export {};
package/dist/scoped.js ADDED
@@ -0,0 +1,176 @@
1
+ import { and, eq, getTableColumns } from 'drizzle-orm';
2
+ /**
3
+ * Thrown by a write method (insert, update, delete, transaction) called on a
4
+ * scope that is read-only: a tenant whose state is `read_only` or
5
+ * `suspended`, or a read-only break-glass session. Thrown synchronously,
6
+ * before a query is built or a connection touched, so a page that writes on
7
+ * read (a last-viewed stamp, a draft autosave) cannot leak a write through a
8
+ * read-only context regardless of what the call site already checked (D6,
9
+ * M4).
10
+ */
11
+ export class ReadOnlyAccessError extends Error {
12
+ }
13
+ /**
14
+ * Thrown when a table has no column registered for the scope in use.
15
+ * The host's registries are the only source of truth for what a `ScopedDb`
16
+ * may touch; a table missing from all of them is a bug in the host's registry,
17
+ * not something the caller can work around.
18
+ */
19
+ export class UnscopedTableError extends Error {
20
+ }
21
+ /**
22
+ * The only way feature code touches a tenant or person table (D6). A tenant
23
+ * instance is built by `openScopedDb` from a fresh membership read inside
24
+ * the host's `accessFor`; a person instance is built from that person's own
25
+ * id. Nothing else constructs one, which is what the private constructor is
26
+ * for: even a module-level function in this same file cannot call it, only a
27
+ * static member of the class, which is why `openScopedDb` wraps
28
+ * `ScopedDb.open` rather than calling `new ScopedDb` itself.
29
+ *
30
+ * Every write method refuses at the query-builder layer when the scope is
31
+ * read-only, before building or sending anything, independent of whatever
32
+ * the call site already decided.
33
+ */
34
+ export class ScopedDb {
35
+ root;
36
+ scope;
37
+ constructor(root, scope) {
38
+ this.root = root;
39
+ this.scope = scope;
40
+ }
41
+ static open(root, scope) {
42
+ return new ScopedDb(root, scope);
43
+ }
44
+ columnFor(table) {
45
+ const column = this.scope.column(table);
46
+ if (!column) {
47
+ throw new UnscopedTableError(`table is not registered for the ${this.scope.label} scope; add it to the host's registry`);
48
+ }
49
+ return column;
50
+ }
51
+ /**
52
+ * The property key an insert or update payload uses for this column.
53
+ * Drizzle keys `.values()`/`.set()` by the table's own field name (for
54
+ * example `tenantId`), not the SQL column name (`tenant_id`), and a column
55
+ * does not know its own field name, so this looks it up the same way
56
+ * drizzle itself does: by identity against `getTableColumns`.
57
+ */
58
+ keyFor(table, column) {
59
+ for (const [key, candidate] of Object.entries(getTableColumns(table))) {
60
+ if (candidate === column)
61
+ return key;
62
+ }
63
+ // Unreachable given the invariant that every entry in a host's registry is
64
+ // one of that table's own columns.
65
+ throw new UnscopedTableError("the scope column is not one of this table's own columns");
66
+ }
67
+ assertWritable() {
68
+ if (this.scope.readOnly) {
69
+ throw new ReadOnlyAccessError(`the ${this.scope.label} scope is read-only`);
70
+ }
71
+ }
72
+ /**
73
+ * The predicate every method here builds from: the scope column compared
74
+ * to the scope's own value. Exposed on its own because a caller sometimes
75
+ * needs it directly (a join, a count) rather than through one of the
76
+ * methods below.
77
+ */
78
+ predicate(table) {
79
+ return eq(this.columnFor(table), this.scope.value);
80
+ }
81
+ /**
82
+ * The select builder, scoped, so `.orderBy`/`.limit`/further `.where`
83
+ * continue to chain on the result. The cast on `table` works around a
84
+ * drizzle typing limitation (a generic table parameter does not satisfy
85
+ * `.from()`'s subquery-detection conditional); the query it builds is
86
+ * identical either way, the cast only affects the row type TypeScript
87
+ * infers back, which is why insert and update below, which do not touch
88
+ * `.from()`, keep their full per-table typing.
89
+ */
90
+ select(table, extra) {
91
+ const pred = this.predicate(table);
92
+ return this.root
93
+ .select()
94
+ .from(table)
95
+ .where(extra ? and(pred, extra) : pred);
96
+ }
97
+ /**
98
+ * Strips the scope column from every row and writes the scope's own value
99
+ * instead, after the spread rather than merged into it, so a caller-
100
+ * supplied value for that column (a compromised form body setting
101
+ * `tenantId` to some other tenant) never survives into the statement.
102
+ */
103
+ insert(table, values) {
104
+ this.assertWritable();
105
+ const column = this.columnFor(table);
106
+ const key = this.keyFor(table, column);
107
+ const rows = Array.isArray(values) ? values : [values];
108
+ const scoped = rows.map((row) => ({ ...row, [key]: this.scope.value }));
109
+ return this.root.insert(table).values(scoped);
110
+ }
111
+ /**
112
+ * Strips the scope column from `set` (dropped, not overwritten: an update
113
+ * never moves a row between tenants) and adds the scope predicate to the
114
+ * WHERE, so a write can only ever land on the caller's own rows.
115
+ */
116
+ update(table, set, extra) {
117
+ this.assertWritable();
118
+ const column = this.columnFor(table);
119
+ const key = this.keyFor(table, column);
120
+ const rest = { ...set };
121
+ delete rest[key];
122
+ const pred = this.predicate(table);
123
+ return this.root
124
+ .update(table)
125
+ .set(rest)
126
+ .where(extra ? and(pred, extra) : pred);
127
+ }
128
+ /**
129
+ * `extra` is required, not optional: a delete with only the scope
130
+ * predicate would remove every row this principal can see, so the type
131
+ * system refuses a call without it. The runtime check is the second net
132
+ * for a caller that reaches this past the type system (an `any`-typed call
133
+ * site).
134
+ */
135
+ delete(table, extra) {
136
+ this.assertWritable();
137
+ if (!extra) {
138
+ throw new Error('ScopedDb.delete requires an explicit predicate');
139
+ }
140
+ const pred = this.predicate(table);
141
+ return this.root.delete(table).where(and(pred, extra));
142
+ }
143
+ /**
144
+ * Refused outright on a read-only scope, the same as insert, update and
145
+ * delete: a transaction is opened to write, and refusing only once the
146
+ * callback attempts one would already have reached the client (postgres-js
147
+ * sends `BEGIN` the moment `transaction()` is called, before the callback
148
+ * ever runs), which is exactly what the read-only guard exists to prevent.
149
+ */
150
+ transaction(fn) {
151
+ // Deliberately not declared `async`: an `async` function always returns a
152
+ // promise and never throws to its caller synchronously, even when the
153
+ // very first line throws, which would turn this guard into a rejection
154
+ // instead of the synchronous refusal every other write method gives.
155
+ this.assertWritable();
156
+ return this.root.transaction((tx) => fn(ScopedDb.open(tx, this.scope)));
157
+ }
158
+ }
159
+ /**
160
+ * The only way to build a `ScopedDb`. A host calls this from the two places
161
+ * that legitimately mint a scope — a tenant scope from a fresh membership
162
+ * read, and a person scope from a person's own id — and re-exports neither
163
+ * this nor the class from its own barrel, so a feature module cannot mint a
164
+ * scope of its own.
165
+ */
166
+ export function openScopedDb(root, scope) {
167
+ return ScopedDb.open(root, scope);
168
+ }
169
+ /**
170
+ * Turns a host's table-to-column registry into a `Scope['column']`. The
171
+ * registry itself is the host's: only it knows which of its tables carry a
172
+ * tenant id and which carry a person id.
173
+ */
174
+ export function scopeColumnOf(tables) {
175
+ return (table) => tables.get(table);
176
+ }