@astram/engine 0.3.0 → 0.5.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/decorators/access-bundle.decorator.d.ts.map +1 -1
- package/dist/decorators/object-type.decorator.d.ts +2 -0
- package/dist/decorators/object-type.decorator.d.ts.map +1 -1
- package/dist/decorators/object-type.decorator.js +3 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/internal.d.ts +10 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +13 -0
- package/dist/pipe/context.d.ts +17 -6
- package/dist/pipe/context.d.ts.map +1 -1
- package/dist/pipe/record-engine.d.ts +38 -5
- package/dist/pipe/record-engine.d.ts.map +1 -1
- package/dist/pipe/record-engine.js +210 -75
- package/dist/pipe/records-service.d.ts +18 -1
- package/dist/pipe/records-service.d.ts.map +1 -1
- package/dist/pipe/records-service.js +42 -6
- package/dist/pipe/stages/audit.d.ts.map +1 -1
- package/dist/pipe/stages/derive-write-context.d.ts.map +1 -1
- package/dist/pipe/stages/derive-write-context.js +6 -0
- package/dist/pipe/stages/fetch-record.d.ts +56 -0
- package/dist/pipe/stages/fetch-record.d.ts.map +1 -0
- package/dist/pipe/stages/fetch-record.js +103 -0
- package/dist/pipe/stages/permission-gate.d.ts +29 -0
- package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
- package/dist/pipe/stages/permission-gate.js +80 -22
- package/dist/pipe/stages/write.d.ts.map +1 -1
- package/dist/pipe/stages/write.js +75 -52
- package/dist/pipe/where-by-id.d.ts +33 -0
- package/dist/pipe/where-by-id.d.ts.map +1 -0
- package/dist/pipe/where-by-id.js +69 -0
- package/dist/registry.d.ts +8 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +25 -1
- package/dist/types.d.ts +59 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +13 -2
- package/dist/adapters/mariadb-audit-sink.js.map +0 -1
- package/dist/adapters/mariadb-unit-of-work.js.map +0 -1
- package/dist/decorators/access-bundle.decorator.js.map +0 -1
- package/dist/decorators/object-type.decorator.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/module/engine.module.js.map +0 -1
- package/dist/module/tokens.js.map +0 -1
- package/dist/pipe/context.js.map +0 -1
- package/dist/pipe/record-engine.js.map +0 -1
- package/dist/pipe/records-service.js.map +0 -1
- package/dist/pipe/stages/audit.js.map +0 -1
- package/dist/pipe/stages/derive-write-context.js.map +0 -1
- package/dist/pipe/stages/fetch-owner.d.ts +0 -28
- package/dist/pipe/stages/fetch-owner.d.ts.map +0 -1
- package/dist/pipe/stages/fetch-owner.js +0 -99
- package/dist/pipe/stages/fetch-owner.js.map +0 -1
- package/dist/pipe/stages/junction-sync.js.map +0 -1
- package/dist/pipe/stages/permission-gate.js.map +0 -1
- package/dist/pipe/stages/validate.js.map +0 -1
- package/dist/pipe/stages/write.js.map +0 -1
- package/dist/ports.js.map +0 -1
- package/dist/registry.js.map +0 -1
- package/dist/types.js.map +0 -1
|
@@ -4,23 +4,27 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Every write (create/update/delete) runs the FIXED, ordered stage sequence inside
|
|
6
6
|
* ONE transaction:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* type-pre-gate (update/delete) → fetch-record (update/delete) → permission-gate (MANDATORY) →
|
|
8
|
+
* derive-write-context →
|
|
9
|
+
* validate → compute (no-op Phase 1) → write → junction-sync → audit (MANDATORY) →
|
|
10
|
+
* emit-event (no-op).
|
|
9
11
|
* A caller cannot reorder, add, or drop stages — the pipe shape is fixed here, so
|
|
10
12
|
* the security gate + audit ALWAYS run (fixes ORS's optional gate + off-road writes).
|
|
11
13
|
* Any stage throwing aborts the transaction (rollback) — fail-closed.
|
|
12
14
|
*
|
|
13
|
-
* Reads (
|
|
14
|
-
* (
|
|
15
|
-
*
|
|
15
|
+
* Reads: get() is fetch → gate (on the row's fields + tenant) → project the same row;
|
|
16
|
+
* list() is gate (object type) → SQL-scoped query (list scope fragment, no fetch-then-
|
|
17
|
+
* discard) → project, ordered with a PK tiebreaker. (Column projection is still pending, G2.)
|
|
16
18
|
*/
|
|
17
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
20
|
exports.RecordEngineImpl = void 0;
|
|
21
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
19
22
|
const errors_js_1 = require("../errors.js");
|
|
20
23
|
const registry_js_1 = require("../registry.js");
|
|
21
24
|
const types_js_1 = require("../types.js");
|
|
22
25
|
const context_js_1 = require("./context.js");
|
|
23
|
-
const
|
|
26
|
+
const fetch_record_js_1 = require("./stages/fetch-record.js");
|
|
27
|
+
const where_by_id_js_1 = require("./where-by-id.js");
|
|
24
28
|
const permission_gate_js_1 = require("./stages/permission-gate.js");
|
|
25
29
|
const derive_write_context_js_1 = require("./stages/derive-write-context.js");
|
|
26
30
|
const validate_js_1 = require("./stages/validate.js");
|
|
@@ -28,6 +32,16 @@ const write_js_1 = require("./stages/write.js");
|
|
|
28
32
|
const junction_sync_js_1 = require("./stages/junction-sync.js");
|
|
29
33
|
const audit_js_1 = require("./stages/audit.js");
|
|
30
34
|
const errors_js_2 = require("../errors.js");
|
|
35
|
+
/** Fail closed when a transaction handle outlives its transaction or is already doomed. */
|
|
36
|
+
function assertUsable(tx) {
|
|
37
|
+
if (!tx.open) {
|
|
38
|
+
throw new errors_js_2.EngineUnavailableError('record transaction has already ended; a transaction handle cannot be used after its ' +
|
|
39
|
+
'transaction(fn) settled.');
|
|
40
|
+
}
|
|
41
|
+
if (tx.failure !== undefined) {
|
|
42
|
+
throw new errors_js_2.EngineUnavailableError('record transaction is rollback-only: an earlier write inside it failed.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
31
45
|
class RecordEngineImpl {
|
|
32
46
|
registry;
|
|
33
47
|
uow;
|
|
@@ -38,6 +52,8 @@ class RecordEngineImpl {
|
|
|
38
52
|
listScope;
|
|
39
53
|
/** The FIXED write-stage order. Built once; identical for every write. */
|
|
40
54
|
writeStages;
|
|
55
|
+
/** The ambient `transaction(fn)` (ADR-0034) so plain calls made inside `fn` join it. */
|
|
56
|
+
activeTx = new node_async_hooks_1.AsyncLocalStorage();
|
|
41
57
|
constructor(deps) {
|
|
42
58
|
this.registry = deps.registry;
|
|
43
59
|
this.uow = deps.uow;
|
|
@@ -47,11 +63,14 @@ class RecordEngineImpl {
|
|
|
47
63
|
this.onRecordDeleted = deps.onRecordDeleted;
|
|
48
64
|
this.listScope = deps.listScope;
|
|
49
65
|
// permission-gate + audit are ALWAYS present, in this order — no skip flag.
|
|
50
|
-
// fetch-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
66
|
+
// fetch-record runs FIRST (before the gate): for update/delete on EVERY type it row-locks
|
|
67
|
+
// and reads the existing row through the shared whereById, so decide() sees the record's
|
|
68
|
+
// fields, owner and tenant (block D, #10). A missing row throws there. No-op for create.
|
|
69
|
+
// type-pre-gate runs before it (Phase 8 #7): a coarse object-type decide() so an actor with
|
|
70
|
+
// no permission on the type is denied before it can take any row lock. No-op for create.
|
|
53
71
|
this.writeStages = [
|
|
54
|
-
(0,
|
|
72
|
+
(0, permission_gate_js_1.typePreGateStage)(this.decision),
|
|
73
|
+
(0, fetch_record_js_1.fetchRecordStage)(),
|
|
55
74
|
(0, permission_gate_js_1.permissionGateStage)(this.decision),
|
|
56
75
|
(0, derive_write_context_js_1.deriveWriteContextStage)(),
|
|
57
76
|
(0, validate_js_1.validateStage)(),
|
|
@@ -62,10 +81,100 @@ class RecordEngineImpl {
|
|
|
62
81
|
// emit-event stage: Phase-1 no-op (business rules land later)
|
|
63
82
|
];
|
|
64
83
|
}
|
|
65
|
-
|
|
84
|
+
// --- transactions (ADR-0034) ---------------------------------------------------
|
|
85
|
+
/**
|
|
86
|
+
* Run `fn` in ONE unit of work: every op on the handle, and every plain call on this engine
|
|
87
|
+
* made from inside `fn` (found via AsyncLocalStorage), shares the transaction. Each op still
|
|
88
|
+
* runs its full pipe. Commit on resolve, rollback on throw. ROLLBACK-ONLY once any write op
|
|
89
|
+
* (or a nested `fn`) inside it has failed, even if the caller swallowed the error — so the
|
|
90
|
+
* INSERT of a create the wall denied can never be committed by a careless `catch`. Nested calls
|
|
91
|
+
* reuse the outer transaction; a handle used after its transaction ended fails closed.
|
|
92
|
+
*/
|
|
93
|
+
async transaction(fn) {
|
|
94
|
+
return this.transactionIn(undefined, fn);
|
|
95
|
+
}
|
|
96
|
+
async transactionIn(bound, fn) {
|
|
97
|
+
const outer = bound ?? this.activeTx.getStore();
|
|
98
|
+
if (outer !== undefined) {
|
|
99
|
+
// NESTED: reuse the outer unit of work. A throw out of the nested fn marks the outer
|
|
100
|
+
// transaction rollback-only (its writes must not commit just because an outer catch ran).
|
|
101
|
+
assertUsable(outer);
|
|
102
|
+
try {
|
|
103
|
+
return await fn(this.handleFor(outer));
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
outer.failure ??= err;
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return this.uow.run(async (query) => {
|
|
111
|
+
const state = { query, open: true, failure: undefined };
|
|
112
|
+
try {
|
|
113
|
+
const out = await this.activeTx.run(state, () => fn(this.handleFor(state)));
|
|
114
|
+
// Rollback-only: a write failed inside fn and the caller swallowed it ⇒ re-raise it so
|
|
115
|
+
// the unit of work rolls back instead of committing a partial write.
|
|
116
|
+
if (state.failure !== undefined)
|
|
117
|
+
throw state.failure;
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
state.open = false;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** The transaction-bound engine handle passed to `transaction(fn)`. */
|
|
126
|
+
handleFor(state) {
|
|
127
|
+
return {
|
|
128
|
+
create: (typeKey, input, actor) => this.createIn(state, typeKey, input, actor),
|
|
129
|
+
update: (typeKey, id, input, actor) => this.updateIn(state, typeKey, id, input, actor),
|
|
130
|
+
delete: (typeKey, id, actor) => this.deleteIn(state, typeKey, id, actor),
|
|
131
|
+
get: (typeKey, id, actor) => this.getIn(state, typeKey, id, actor),
|
|
132
|
+
list: (typeKey, query, actor) => this.listIn(state, typeKey, query, actor),
|
|
133
|
+
transaction: (fn) => this.transactionIn(state, fn),
|
|
134
|
+
lockForUpdate: (typeKey, id, actor) => this.lockIn(state, typeKey, id, actor),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Row lock on one record inside the open transaction (see `RecordEngine.lockForUpdate`). Keyed
|
|
139
|
+
* by the primary key only and returning nothing, so it reveals no data and needs no read gate.
|
|
140
|
+
*/
|
|
141
|
+
async lockForUpdate(typeKey, id, actor) {
|
|
142
|
+
return this.lockIn(undefined, typeKey, id, actor);
|
|
143
|
+
}
|
|
144
|
+
async lockIn(bound, typeKey, id, _actor) {
|
|
145
|
+
const tx = bound ?? this.activeTx.getStore();
|
|
146
|
+
if (tx === undefined) {
|
|
147
|
+
throw new errors_js_2.EngineUnavailableError('lockForUpdate needs an open transaction(fn): a lock taken outside one is released at ' +
|
|
148
|
+
'once and serialises nothing.');
|
|
149
|
+
}
|
|
150
|
+
assertUsable(tx);
|
|
151
|
+
const reg = this.registry.require(typeKey);
|
|
152
|
+
const where = (0, where_by_id_js_1.pkClause)(reg, id);
|
|
153
|
+
await tx.query(`SELECT 1 AS locked FROM ${this.qualified(reg)} WHERE ${where.sql} FOR UPDATE`, where.params);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Run one op's body in the right unit of work: the bound/ambient transaction when there is
|
|
157
|
+
* one, else a fresh `uow.run` (the original per-op transaction). A WRITE failure inside a
|
|
158
|
+
* shared transaction marks it rollback-only.
|
|
159
|
+
*/
|
|
160
|
+
async inUnit(bound, write, body) {
|
|
161
|
+
const tx = bound ?? this.activeTx.getStore();
|
|
162
|
+
if (tx === undefined)
|
|
163
|
+
return this.uow.run(body);
|
|
164
|
+
assertUsable(tx);
|
|
165
|
+
try {
|
|
166
|
+
return await body(tx.query);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
if (write)
|
|
170
|
+
tx.failure ??= err;
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async runWrite(tx, op, typeKey, actor, opts) {
|
|
66
175
|
const registration = this.registry.require(typeKey); // resolve-schema
|
|
67
176
|
const nowMs = this.now();
|
|
68
|
-
return this.
|
|
177
|
+
return this.inUnit(tx, true, async (query) => {
|
|
69
178
|
const ctx = (0, context_js_1.newWriteContext)({ op, actor, registration, query, nowMs }, {
|
|
70
179
|
...(opts.id !== undefined ? { id: opts.id } : {}),
|
|
71
180
|
...(opts.input !== undefined ? { input: opts.input } : {}),
|
|
@@ -90,32 +199,42 @@ class RecordEngineImpl {
|
|
|
90
199
|
});
|
|
91
200
|
}
|
|
92
201
|
async create(typeKey, input, actor) {
|
|
93
|
-
|
|
202
|
+
return this.createIn(undefined, typeKey, input, actor);
|
|
203
|
+
}
|
|
204
|
+
async update(typeKey, id, input, actor) {
|
|
205
|
+
return this.updateIn(undefined, typeKey, id, input, actor);
|
|
206
|
+
}
|
|
207
|
+
async delete(typeKey, id, actor) {
|
|
208
|
+
return this.deleteIn(undefined, typeKey, id, actor);
|
|
209
|
+
}
|
|
210
|
+
async createIn(tx, typeKey, input, actor) {
|
|
211
|
+
const id = await this.runWrite(tx, 'create', typeKey, actor, { input });
|
|
94
212
|
if (id === undefined)
|
|
95
213
|
throw new errors_js_1.RegistrationError(`create '${typeKey}' produced no id`);
|
|
96
214
|
return id;
|
|
97
215
|
}
|
|
98
|
-
async
|
|
99
|
-
await this.runWrite('update', typeKey, actor, { id, input });
|
|
216
|
+
async updateIn(tx, typeKey, id, input, actor) {
|
|
217
|
+
await this.runWrite(tx, 'update', typeKey, actor, { id, input });
|
|
100
218
|
}
|
|
101
|
-
async
|
|
102
|
-
await this.runWrite('delete', typeKey, actor, { id });
|
|
219
|
+
async deleteIn(tx, typeKey, id, actor) {
|
|
220
|
+
await this.runWrite(tx, 'delete', typeKey, actor, { id });
|
|
103
221
|
}
|
|
104
222
|
// --- reads -----------------------------------------------------------------
|
|
105
|
-
async gateRead(registration, actor, id,
|
|
223
|
+
async gateRead(registration, actor, id, record) {
|
|
106
224
|
const action = { key: 'read' };
|
|
107
225
|
const resource = {
|
|
108
226
|
level: id !== undefined ? 'record' : 'object_type',
|
|
109
227
|
typeKey: registration.typeKey,
|
|
110
|
-
|
|
228
|
+
// Block D (#10): a fetched record is judged at ITS tenant; otherwise the actor's.
|
|
229
|
+
tenantId: record?.tenantId ?? actor.tenantId,
|
|
111
230
|
...(typeof id === 'string' ? { recordId: id } : {}),
|
|
112
231
|
};
|
|
113
|
-
// For
|
|
114
|
-
//
|
|
115
|
-
//
|
|
232
|
+
// For a fetched record, every registered field + `ownerId` reach decide() (block D), so a
|
|
233
|
+
// field-conditioned rule fires on get() as it does in list(). No record (a missing row) ⇒
|
|
234
|
+
// no attributes ⇒ an owned type's owner baseline cannot match ⇒ deny, as before.
|
|
116
235
|
const context = {
|
|
117
236
|
isAuthenticated: true,
|
|
118
|
-
...(
|
|
237
|
+
...(record !== undefined ? { attributes: record.attributes } : {}),
|
|
119
238
|
};
|
|
120
239
|
let outcome;
|
|
121
240
|
try {
|
|
@@ -129,7 +248,7 @@ class RecordEngineImpl {
|
|
|
129
248
|
}
|
|
130
249
|
}
|
|
131
250
|
qualified(reg) {
|
|
132
|
-
return
|
|
251
|
+
return (0, where_by_id_js_1.qualifiedTable)(reg);
|
|
133
252
|
}
|
|
134
253
|
/** Project a raw DB row → RecordView (field keys, not columns). */
|
|
135
254
|
project(reg, row) {
|
|
@@ -152,29 +271,30 @@ class RecordEngineImpl {
|
|
|
152
271
|
return { id, fields };
|
|
153
272
|
}
|
|
154
273
|
async get(typeKey, id, actor) {
|
|
274
|
+
return this.getIn(undefined, typeKey, id, actor);
|
|
275
|
+
}
|
|
276
|
+
async list(typeKey, query, actor) {
|
|
277
|
+
return this.listIn(undefined, typeKey, query, actor);
|
|
278
|
+
}
|
|
279
|
+
async getIn(tx, typeKey, id, actor) {
|
|
155
280
|
const reg = this.registry.require(typeKey);
|
|
156
|
-
return this.
|
|
157
|
-
const pk = reg
|
|
158
|
-
let where;
|
|
159
|
-
let params;
|
|
160
|
-
if (pk.kind === 'uuid') {
|
|
161
|
-
if (typeof id !== 'string')
|
|
162
|
-
throw new errors_js_1.RegistrationError(`'${typeKey}' needs a string id`);
|
|
163
|
-
where = `\`${(0, registry_js_1.uuidPkColumn)(pk)}\` = ?`;
|
|
164
|
-
params = [id];
|
|
165
|
-
}
|
|
166
|
-
else {
|
|
167
|
-
if (typeof id === 'string')
|
|
168
|
-
throw new errors_js_1.RegistrationError(`'${typeKey}' needs a key map`);
|
|
169
|
-
where = pk.columns.map((c) => `\`${c}\` = ?`).join(' AND ');
|
|
170
|
-
params = pk.columns.map((c) => id[c]);
|
|
171
|
-
}
|
|
281
|
+
return this.inUnit(tx, false, async (query) => {
|
|
282
|
+
const pk = (0, where_by_id_js_1.pkClause)(reg, id);
|
|
283
|
+
let where = pk.sql;
|
|
284
|
+
let params = pk.params;
|
|
172
285
|
// Tenant scoping: if the table has a tenant column, constrain to the actor's reachable
|
|
173
286
|
// subtree (ADR-0027 — active node + descendants), NOT just the self-node. ROOT spans all
|
|
174
287
|
// tenants — only a STRICT `=== true` isRoot counts (fail-closed). Fail-safe to the
|
|
175
288
|
// self-node if the subtree is malformed/absent (non-widening).
|
|
289
|
+
// Review M6 (get ≡ list): exactly as in list(), when the record-level list scope is wired
|
|
290
|
+
// its fragment is the ONE tenant authority (it carries the own-subtree bound AND the
|
|
291
|
+
// cross-tenant grant reach), so the built-in clause is deferred to it.
|
|
292
|
+
// Resolve the list-scope fragment FIRST (a throw propagates before any SELECT). An EMPTY
|
|
293
|
+
// fragment never drops the tenant bound (review L1): the built-in clause then applies.
|
|
294
|
+
const fragment = this.listScope ? await this.listScope(reg, actor, 'read') : null;
|
|
295
|
+
const fragmentSql = fragment?.sql ? fragment : null;
|
|
176
296
|
const tenantCol = this.tenantColumn(reg);
|
|
177
|
-
if (tenantCol && actor.isRoot !== true) {
|
|
297
|
+
if (tenantCol && actor.isRoot !== true && !fragmentSql) {
|
|
178
298
|
const nodes = this.tenantScopeNodes(actor);
|
|
179
299
|
where += ` AND \`${tenantCol}\` IN (${nodes.map(() => '?').join(', ')})`;
|
|
180
300
|
params = [...params, ...nodes];
|
|
@@ -188,37 +308,39 @@ class RecordEngineImpl {
|
|
|
188
308
|
where += ` AND ${scope.sql}`;
|
|
189
309
|
params = [...params, ...scope.params];
|
|
190
310
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// presence/absence differently from a real deny — both throw/deny before any row
|
|
198
|
-
// data is returned).
|
|
199
|
-
const rows = (await query(`SELECT * FROM ${this.qualified(reg)} WHERE ${where} LIMIT 1`, params));
|
|
200
|
-
const first = rows[0];
|
|
201
|
-
const ownerCol = (0, registry_js_1.ownerColumn)(reg);
|
|
202
|
-
const owner = first && ownerCol ? first[ownerCol] : undefined;
|
|
203
|
-
// gateRead THROWS AuthorizationDeniedError on deny → the row below is never reached.
|
|
204
|
-
await this.gateRead(reg, actor, id, typeof owner === 'string' ? owner : undefined);
|
|
205
|
-
// Allowed. Project the ALREADY-fetched row (no second query). A genuinely absent
|
|
206
|
-
// row (owner undefined) would have been denied above by the missing owner match,
|
|
207
|
-
// so reaching here with `!first` is not expected for owned types; guard anyway.
|
|
208
|
-
return first ? this.project(reg, first) : null;
|
|
311
|
+
// Review M6: AND the SAME list-scope fragment list() uses, so get() reaches exactly the
|
|
312
|
+
// rows list() shows (incl. cross-tenant-granted ones) and hides what list() hides. A throw
|
|
313
|
+
// propagates (fail closed) before any SELECT runs.
|
|
314
|
+
if (fragmentSql) {
|
|
315
|
+
where += ` AND ${fragmentSql.sql}`;
|
|
316
|
+
params = [...params, ...fragmentSql.params];
|
|
209
317
|
}
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
318
|
+
// FETCH-THEN-GATE for EVERY type (block D, #10). Core's decide() is zero-dep and cannot
|
|
319
|
+
// read the row, so the row is SELECTed first and its fields, owner and tenant are handed
|
|
320
|
+
// to the gate. ⛔ THE FETCHED ROW IS NEVER SURFACED UNLESS THE GATE ALLOWS: gateRead
|
|
321
|
+
// throws on deny before project(). The SAME row is projected (no second query).
|
|
213
322
|
const rows = (await query(`SELECT * FROM ${this.qualified(reg)} WHERE ${where} LIMIT 1`, params));
|
|
214
323
|
const first = rows[0];
|
|
215
|
-
|
|
324
|
+
if (first === undefined) {
|
|
325
|
+
// Missing — or outside the walled WHERE (foreign tenant/subtree, hidden by the list
|
|
326
|
+
// scope): indistinguishable by construction. Gate as before this change: record level,
|
|
327
|
+
// no attributes, actor tenant ⇒ an owned type denies (no owner match), any other type
|
|
328
|
+
// answers null. The caller learns nothing it did not learn before.
|
|
329
|
+
await this.gateRead(reg, actor, id);
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
const facts = (0, fetch_record_js_1.recordFacts)(reg, first);
|
|
333
|
+
await this.gateRead(reg, actor, id, {
|
|
334
|
+
attributes: (0, fetch_record_js_1.decisionAttributes)(reg, facts),
|
|
335
|
+
...(facts.tenantId !== undefined ? { tenantId: facts.tenantId } : {}),
|
|
336
|
+
});
|
|
337
|
+
return this.project(reg, first);
|
|
216
338
|
});
|
|
217
339
|
}
|
|
218
|
-
async
|
|
340
|
+
async listIn(tx, typeKey, query, actor) {
|
|
219
341
|
const reg = this.registry.require(typeKey);
|
|
220
342
|
await this.gateRead(reg, actor);
|
|
221
|
-
return this.
|
|
343
|
+
return this.inUnit(tx, false, async (q) => {
|
|
222
344
|
const clauses = [];
|
|
223
345
|
const params = [];
|
|
224
346
|
// Mandatory tenant scoping when the table is tenant-owned (cross-tenant safety).
|
|
@@ -239,8 +361,12 @@ class RecordEngineImpl {
|
|
|
239
361
|
// So when `listScope` is present we DEFER the tenant bound entirely to the fragment — one
|
|
240
362
|
// authority, no double-filter. (readScope system-types are unaffected: they have no
|
|
241
363
|
// tenant column, so this clause never fired for them anyway; their wall is `readScope`.)
|
|
364
|
+
// The list-scope fragment is resolved up-front (a throw propagates — fail closed). An EMPTY
|
|
365
|
+
// fragment never drops the tenant bound (review L1): the built-in clause then applies.
|
|
366
|
+
const listFragment = this.listScope ? await this.listScope(reg, actor, 'read') : null;
|
|
367
|
+
const listFragmentSql = listFragment?.sql ? listFragment : null;
|
|
242
368
|
const tenantCol = this.tenantColumn(reg);
|
|
243
|
-
if (tenantCol && actor.isRoot !== true && !
|
|
369
|
+
if (tenantCol && actor.isRoot !== true && !listFragmentSql) {
|
|
244
370
|
const nodes = this.tenantScopeNodes(actor);
|
|
245
371
|
clauses.push(`\`${tenantCol}\` IN (${nodes.map(() => '?').join(', ')})`);
|
|
246
372
|
params.push(...nodes);
|
|
@@ -289,28 +415,37 @@ class RecordEngineImpl {
|
|
|
289
415
|
// (gateRead, at the TOP of list()) has allowed the read — the scope narrows rows WITHIN
|
|
290
416
|
// an allowed object-type list; it NEVER substitutes for the gate (the root/data-plane
|
|
291
417
|
// leak-guard). A scope-fn throw PROPAGATES (fail-closed — the read fails; we never fall
|
|
292
|
-
// back to unscoped rows). An empty `sql` is
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
clauses.push(scope.sql);
|
|
297
|
-
params.push(...scope.params);
|
|
298
|
-
}
|
|
418
|
+
// back to unscoped rows). An empty `sql` is not ANDed; the built-in tenant clause above applies instead.
|
|
419
|
+
if (listFragmentSql) {
|
|
420
|
+
clauses.push(listFragmentSql.sql);
|
|
421
|
+
params.push(...listFragmentSql.params);
|
|
299
422
|
}
|
|
300
423
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
301
424
|
// Sort: resolve `sortBy` to a column the SAME whitelisted way as where-keys
|
|
302
425
|
// (unknown key → throw). The direction is an enum-checked literal (only 'desc'
|
|
303
426
|
// flips to DESC; anything else is ascending) — neither is ever raw-interpolated
|
|
304
427
|
// user input.
|
|
305
|
-
|
|
428
|
+
//
|
|
429
|
+
// Review M1: the PK column(s) are ALWAYS appended as the final tiebreaker (`ORDER BY <pk>`
|
|
430
|
+
// alone when there is no sortBy), so the order is total and OFFSET paging never repeats or
|
|
431
|
+
// skips rows that tie on the sort column. PK columns come from `primaryKey` (often not a
|
|
432
|
+
// registered field); a PK column already used as the sort key is not repeated.
|
|
433
|
+
const orderTerms = [];
|
|
434
|
+
let sortColumn;
|
|
306
435
|
if (query.sortBy !== undefined) {
|
|
307
436
|
const field = reg.fields.find((f) => f.key === query.sortBy);
|
|
308
437
|
if (!field) {
|
|
309
438
|
throw new errors_js_1.RegistrationError(`list '${typeKey}': unknown sort field '${query.sortBy}'`);
|
|
310
439
|
}
|
|
311
440
|
const dir = query.sortOrder === 'desc' ? 'DESC' : 'ASC';
|
|
312
|
-
|
|
441
|
+
sortColumn = (0, registry_js_1.columnOf)(field);
|
|
442
|
+
orderTerms.push(`\`${sortColumn}\` ${dir}`);
|
|
443
|
+
}
|
|
444
|
+
for (const col of (0, where_by_id_js_1.pkColumns)(reg)) {
|
|
445
|
+
if (col !== sortColumn)
|
|
446
|
+
orderTerms.push(`\`${col}\` ASC`);
|
|
313
447
|
}
|
|
448
|
+
const orderBy = `ORDER BY ${orderTerms.join(', ')}`;
|
|
314
449
|
const limit = Math.min(Math.max(query.limit ?? 100, 1), 1000);
|
|
315
450
|
const offset = Math.max(query.offset ?? 0, 0);
|
|
316
451
|
const rows = (await q(`SELECT * FROM ${this.qualified(reg)} ${where} ${orderBy} LIMIT ? OFFSET ?`, [...params, limit, offset]));
|
|
@@ -24,6 +24,16 @@ export declare class RecordsService {
|
|
|
24
24
|
list(typeKey: string, query: ListQuery): Promise<RecordPage>;
|
|
25
25
|
update(typeKey: string, id: RecordId, input: RecordInput): Promise<void>;
|
|
26
26
|
delete(typeKey: string, id: RecordId): Promise<void>;
|
|
27
|
+
/** Row lock inside the open `transaction(fn)` (see `RecordEngine.lockForUpdate`). */
|
|
28
|
+
lockForUpdate(typeKey: string, id: RecordId): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Run `fn` in ONE unit of work (ADR-0034): `tx` carries the same five ops bound to the request
|
|
31
|
+
* principal, and every op on it — plus any `records.*` call made from inside `fn` — shares one
|
|
32
|
+
* transaction, each still through the full gate/validate/write/audit pipe. Commits when `fn`
|
|
33
|
+
* resolves, rolls back when it throws (and when a write inside it failed, even if swallowed).
|
|
34
|
+
* Nested calls reuse the outer transaction. Fail-closed: no principal ⇒ throw before any work.
|
|
35
|
+
*/
|
|
36
|
+
transaction<T>(fn: (tx: BoundRecords) => Promise<T>): Promise<T>;
|
|
27
37
|
/**
|
|
28
38
|
* Run subsequent ops as a declared identity (an in-request act-as, or — via a
|
|
29
39
|
* registered resolver — a named service account). Explicit + auditable; never a
|
|
@@ -33,15 +43,22 @@ export declare class RecordsService {
|
|
|
33
43
|
private actor;
|
|
34
44
|
private resolveServiceAccount;
|
|
35
45
|
}
|
|
36
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The `.as(...)` bound view — same 5 ops, fixed actor. Also the handle `transaction(fn)` passes
|
|
48
|
+
* (ADR-0034): there `engine` is the transaction-bound engine, so its ops share one unit of work.
|
|
49
|
+
*/
|
|
37
50
|
export declare class BoundRecords {
|
|
38
51
|
private readonly engine;
|
|
39
52
|
private readonly actor;
|
|
40
53
|
constructor(engine: RecordEngine, actor: Principal);
|
|
54
|
+
/** Run `fn` in one unit of work as this actor; nested inside a transaction it is reused. */
|
|
55
|
+
transaction<T>(fn: (tx: BoundRecords) => Promise<T>): Promise<T>;
|
|
41
56
|
create(typeKey: string, input: RecordInput): Promise<RecordId>;
|
|
42
57
|
get(typeKey: string, id: RecordId): Promise<RecordView | null>;
|
|
43
58
|
list(typeKey: string, query: ListQuery): Promise<RecordPage>;
|
|
44
59
|
update(typeKey: string, id: RecordId, input: RecordInput): Promise<void>;
|
|
45
60
|
delete(typeKey: string, id: RecordId): Promise<void>;
|
|
61
|
+
/** Row lock inside the open transaction (see `RecordEngine.lockForUpdate`). */
|
|
62
|
+
lockForUpdate(typeKey: string, id: RecordId): Promise<void>;
|
|
46
63
|
}
|
|
47
64
|
//# sourceMappingURL=records-service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"records-service.d.ts","sourceRoot":"","sources":["../../src/pipe/records-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"records-service.d.ts","sourceRoot":"","sources":["../../src/pipe/records-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,KAAK,EACV,YAAY,EACZ,WAAW,EACX,QAAQ,EACR,UAAU,EACV,SAAS,EACT,UAAU,EACX,MAAM,aAAa,CAAC;AAGrB,0EAA0E;AAC1E,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AACD,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;;;GAMG;AACH,qBACa,cAAc;IAEA,OAAO,CAAC,QAAQ,CAAC,MAAM;IAC7B,OAAO,CAAC,QAAQ,CAAC,GAAG;IAGrC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC;gBAJO,MAAM,EAAE,YAAY,EAC1B,GAAG,EAAE;QAAE,SAAS,CAAC,EAAE,SAAS,CAAA;KAAE,EAG/C,eAAe,CAAC,EAAE,sBAAsB,YAAA;IAWrD,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAG9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAG9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAG5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAGxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAG1D,qFAAqF;IAC/E,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAIjE;;;;;;OAMG;IACG,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAKtE;;;;OAIG;IACH,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY;IAKzC,OAAO,CAAC,KAAK;IAWb,OAAO,CAAC,qBAAqB;CAS9B;AAED;;;GAGG;AACH,qBAAa,YAAY;IAErB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS;IAEnC,4FAA4F;IAC5F,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAGhE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAG9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAG9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAG5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAGxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAGpD,+EAA+E;IAC/E,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;CAG5D"}
|
|
@@ -57,6 +57,21 @@ let RecordsService = class RecordsService {
|
|
|
57
57
|
async delete(typeKey, id) {
|
|
58
58
|
return this.engine.delete(typeKey, id, this.actor());
|
|
59
59
|
}
|
|
60
|
+
/** Row lock inside the open `transaction(fn)` (see `RecordEngine.lockForUpdate`). */
|
|
61
|
+
async lockForUpdate(typeKey, id) {
|
|
62
|
+
return this.engine.lockForUpdate(typeKey, id, this.actor());
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Run `fn` in ONE unit of work (ADR-0034): `tx` carries the same five ops bound to the request
|
|
66
|
+
* principal, and every op on it — plus any `records.*` call made from inside `fn` — shares one
|
|
67
|
+
* transaction, each still through the full gate/validate/write/audit pipe. Commits when `fn`
|
|
68
|
+
* resolves, rolls back when it throws (and when a write inside it failed, even if swallowed).
|
|
69
|
+
* Nested calls reuse the outer transaction. Fail-closed: no principal ⇒ throw before any work.
|
|
70
|
+
*/
|
|
71
|
+
async transaction(fn) {
|
|
72
|
+
const actor = this.actor();
|
|
73
|
+
return this.engine.transaction((tx) => fn(new BoundRecords(tx, actor)));
|
|
74
|
+
}
|
|
60
75
|
/**
|
|
61
76
|
* Run subsequent ops as a declared identity (an in-request act-as, or — via a
|
|
62
77
|
* registered resolver — a named service account). Explicit + auditable; never a
|
|
@@ -91,7 +106,10 @@ exports.RecordsService = RecordsService = __decorate([
|
|
|
91
106
|
__param(2, (0, common_1.Inject)(exports.SERVICE_ACCOUNT_RESOLVER)),
|
|
92
107
|
__metadata("design:paramtypes", [Object, Object, Object])
|
|
93
108
|
], RecordsService);
|
|
94
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* The `.as(...)` bound view — same 5 ops, fixed actor. Also the handle `transaction(fn)` passes
|
|
111
|
+
* (ADR-0034): there `engine` is the transaction-bound engine, so its ops share one unit of work.
|
|
112
|
+
*/
|
|
95
113
|
class BoundRecords {
|
|
96
114
|
engine;
|
|
97
115
|
actor;
|
|
@@ -99,10 +117,28 @@ class BoundRecords {
|
|
|
99
117
|
this.engine = engine;
|
|
100
118
|
this.actor = actor;
|
|
101
119
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
/** Run `fn` in one unit of work as this actor; nested inside a transaction it is reused. */
|
|
121
|
+
transaction(fn) {
|
|
122
|
+
return this.engine.transaction((tx) => fn(new BoundRecords(tx, this.actor)));
|
|
123
|
+
}
|
|
124
|
+
create(typeKey, input) {
|
|
125
|
+
return this.engine.create(typeKey, input, this.actor);
|
|
126
|
+
}
|
|
127
|
+
get(typeKey, id) {
|
|
128
|
+
return this.engine.get(typeKey, id, this.actor);
|
|
129
|
+
}
|
|
130
|
+
list(typeKey, query) {
|
|
131
|
+
return this.engine.list(typeKey, query, this.actor);
|
|
132
|
+
}
|
|
133
|
+
update(typeKey, id, input) {
|
|
134
|
+
return this.engine.update(typeKey, id, input, this.actor);
|
|
135
|
+
}
|
|
136
|
+
delete(typeKey, id) {
|
|
137
|
+
return this.engine.delete(typeKey, id, this.actor);
|
|
138
|
+
}
|
|
139
|
+
/** Row lock inside the open transaction (see `RecordEngine.lockForUpdate`). */
|
|
140
|
+
lockForUpdate(typeKey, id) {
|
|
141
|
+
return this.engine.lockForUpdate(typeKey, id, this.actor);
|
|
142
|
+
}
|
|
107
143
|
}
|
|
108
144
|
exports.BoundRecords = BoundRecords;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAgC,MAAM,gBAAgB,CAAC;AAC9E,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAMlE,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,
|
|
1
|
+
{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAgC,MAAM,gBAAgB,CAAC;AAC9E,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAMlE,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CA2BlD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"derive-write-context.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/derive-write-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAYzD,wBAAgB,uBAAuB,IAAI,KAAK,
|
|
1
|
+
{"version":3,"file":"derive-write-context.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/derive-write-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAYzD,wBAAgB,uBAAuB,IAAI,KAAK,CAiC/C"}
|
|
@@ -52,6 +52,12 @@ function deriveWriteContextStage() {
|
|
|
52
52
|
ctx.columns[col] = trustedValue(field.trusted, ctx);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
else if (field.immutable === true && ctx.op !== 'create') {
|
|
56
|
+
// ADR-0034: a CREATE-ONLY field. Like a trusted field on update, any client value is
|
|
57
|
+
// IGNORED — the column is not written, so the stored value stands. Moving such a link
|
|
58
|
+
// (a company's parent, a membership's user/company) is a dedicated, checked operation,
|
|
59
|
+
// never a side effect of a generic patch.
|
|
60
|
+
}
|
|
55
61
|
else if (Object.prototype.hasOwnProperty.call(input, field.key)) {
|
|
56
62
|
ctx.columns[col] = input[field.key];
|
|
57
63
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage: fetch-record (update/delete on EVERY type — runs BEFORE the permission gate).
|
|
3
|
+
* Security hardening block D (#10); replaces the owner-only `fetch-owner` stage. Since Phase 8
|
|
4
|
+
* #7 it runs AFTER `type-pre-gate` (permission-gate.ts), so an actor with no permission on the
|
|
5
|
+
* type is denied before this stage can take a row lock.
|
|
6
|
+
*
|
|
7
|
+
* Core's decision authority is zero-dep (no DB), so it cannot read the record it judges. This
|
|
8
|
+
* stage reads the EXISTING row, inside the pipe's unit of work, with
|
|
9
|
+
*
|
|
10
|
+
* SELECT * FROM <table> WHERE <whereById> LIMIT 1 FOR UPDATE
|
|
11
|
+
*
|
|
12
|
+
* through the SAME `whereById` the write stage uses (pk + actor-tenant clause + ADR-0030 write
|
|
13
|
+
* scope), and stashes:
|
|
14
|
+
* - `ctx.existing` — the row projected to registered field keys (the PRE-image, D9),
|
|
15
|
+
* - `ctx.ownerId` — the `trusted:'actorId'` column, when it is a string,
|
|
16
|
+
* - `ctx.recordTenantId` — the `trusted:'tenantId'` column, when it is a string.
|
|
17
|
+
* The gate passes `attributes = { ...existing, ownerId }` and `resource.tenantId =
|
|
18
|
+
* recordTenantId ?? actor.tenantId` to `decide()`, so field-conditioned rules (e.g. deny
|
|
19
|
+
* `status eq locked`) fire on update/delete exactly as they do in `list()`.
|
|
20
|
+
*
|
|
21
|
+
* ── SECURITY (fail-closed, no existence oracle):
|
|
22
|
+
* - No row ⇒ `AuthorizationDeniedError('no_record_permit')`, thrown inside the transaction
|
|
23
|
+
* (rollback: no write, no junction sync, no audit). A missing id and a foreign-tenant or
|
|
24
|
+
* foreign-subtree id both match zero rows here, so they are the same error. It is also the
|
|
25
|
+
* reason core returns for an owned record the actor does not own, which is what the
|
|
26
|
+
* previous owner pre-fetch produced for a missing owned row.
|
|
27
|
+
* - `FOR UPDATE` row-locks the pre-image until commit, so the row the gate judged is the row
|
|
28
|
+
* the write changes (no TOCTOU between gate and write).
|
|
29
|
+
* - The row is never returned to the caller; only the gate sees it.
|
|
30
|
+
* - `create` no-ops: there is no existing row (the create wall is ADR-0034, in `write.ts`).
|
|
31
|
+
*/
|
|
32
|
+
import { AuthorizationDeniedError } from '../../errors.js';
|
|
33
|
+
import type { ObjectType } from '../../types.js';
|
|
34
|
+
import type { Stage } from '../context.js';
|
|
35
|
+
/** The decision-relevant facts of one fetched row. */
|
|
36
|
+
export interface RecordFacts {
|
|
37
|
+
/** Registered fields present in the row, keyed by FIELD KEY (never the column). */
|
|
38
|
+
readonly fields: Record<string, unknown>;
|
|
39
|
+
/** The `trusted:'actorId'` column value, when the type has one and it is a string. */
|
|
40
|
+
readonly ownerId?: string;
|
|
41
|
+
/** The `trusted:'tenantId'` column value, when the type has one and it is a string. */
|
|
42
|
+
readonly tenantId?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Project a raw row to its decision facts (shared by the write pre-fetch and `get()`). */
|
|
45
|
+
export declare function recordFacts(reg: ObjectType, row: Record<string, unknown>): RecordFacts;
|
|
46
|
+
/**
|
|
47
|
+
* The `context.attributes` bag for `decide()`: every registered field by key, plus `ownerId`
|
|
48
|
+
* (the owner baseline's input). When the type has an owner column, `ownerId` is that column's
|
|
49
|
+
* string value or absent: a null/non-string owner is never passed, so core's strict `===` owner
|
|
50
|
+
* match can never spuriously succeed.
|
|
51
|
+
*/
|
|
52
|
+
export declare function decisionAttributes(reg: ObjectType, facts: Pick<RecordFacts, 'fields' | 'ownerId'>): Record<string, unknown>;
|
|
53
|
+
/** The ONE refusal for a record the actor cannot reach (missing ≡ foreign; no ids). */
|
|
54
|
+
export declare function recordNotReachable(reg: ObjectType): AuthorizationDeniedError;
|
|
55
|
+
export declare function fetchRecordStage(): Stage;
|
|
56
|
+
//# sourceMappingURL=fetch-record.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch-record.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/fetch-record.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,wBAAwB,EAAqB,MAAM,iBAAiB,CAAC;AAE9E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAGzD,sDAAsD;AACtD,MAAM,WAAW,WAAW;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC,sFAAsF;IACtF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,uFAAuF;IACvF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,WAAW,CAetF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,QAAQ,GAAG,SAAS,CAAC,GAC7C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOzB;AAED,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,UAAU,GAAG,wBAAwB,CAK5E;AAED,wBAAgB,gBAAgB,IAAI,KAAK,CAwBxC"}
|