@astram/engine 0.2.0 → 0.4.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 +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/pipe/context.d.ts +17 -6
- package/dist/pipe/context.d.ts.map +1 -1
- package/dist/pipe/record-engine.d.ts +37 -5
- package/dist/pipe/record-engine.d.ts.map +1 -1
- package/dist/pipe/record-engine.js +206 -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 +54 -0
- package/dist/pipe/stages/fetch-record.d.ts.map +1 -0
- package/dist/pipe/stages/fetch-record.js +101 -0
- package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
- package/dist/pipe/stages/permission-gate.js +17 -9
- package/dist/pipe/stages/write.d.ts.map +1 -1
- package/dist/pipe/stages/write.js +75 -38
- 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 +32 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +70 -0
- package/dist/types.d.ts +66 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- 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 -90
package/dist/registry.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Runtime-registered (no-code) tables will register through the SAME registry later
|
|
9
9
|
* (the no-code phase); the pipe does not distinguish how a registration arrived.
|
|
10
10
|
*/
|
|
11
|
-
import type { FieldSpec, PkStrategy, ObjectType } from './types.js';
|
|
11
|
+
import type { FieldSpec, PkStrategy, ObjectType, Principal } from './types.js';
|
|
12
12
|
export declare class TableRegistry {
|
|
13
13
|
private readonly byType;
|
|
14
14
|
/** Register a table. Throws on a duplicate typeKey or an invalid registration. */
|
|
@@ -37,6 +37,37 @@ export declare function ownerColumn(reg: ObjectType): string | null;
|
|
|
37
37
|
export declare function tenantColumnOf(reg: ObjectType): string | null;
|
|
38
38
|
/** The physical column of the field bearing `role`, or null if unclaimed. */
|
|
39
39
|
export declare function columnForRole(reg: ObjectType, role: FieldSpec['role']): string | null;
|
|
40
|
+
/**
|
|
41
|
+
* The WRITE-scope predicate for a type's update/delete WHERE (ADR-0030) — the write-path twin of
|
|
42
|
+
* the read wall (`readScope`). For a type declaring `readScope`, it constrains a NON-ROOT actor's
|
|
43
|
+
* update/delete to rows within the actor's reachable subtree, so a targeted write to a
|
|
44
|
+
* foreign-subtree row matches ZERO rows (fail-closed no-op) instead of touching it. It is a
|
|
45
|
+
* FILTER, never a stamp — no column value is set, so the ADR-0027 create-stamping decoupling
|
|
46
|
+
* (add-to-sub-company) is preserved. ADR-0034 reuses the SAME clause for the post-insert create
|
|
47
|
+
* wall (`write.ts`), so update, delete and create share one definition of "inside my subtree".
|
|
48
|
+
*
|
|
49
|
+
* Returns `null` (nothing to AND) when:
|
|
50
|
+
* - the type declares no `readScope`, OR
|
|
51
|
+
* - the actor is ROOT (`isRoot === true`, strict) — root spans all subtrees.
|
|
52
|
+
* Otherwise returns an AND-able fragment (the leading ` AND ` is the CALLER's to prepend — this
|
|
53
|
+
* returns the bare predicate + its `?`-bound params), over the actor's subtree nodes:
|
|
54
|
+
* - `nodeColumn` → `` `<column>` IN (?, ...) ``.
|
|
55
|
+
* - `subtreeSubquery` → `` `<pkColumn>` IN (<subquerySql, :nodes → IN (?, ...)>) ``.
|
|
56
|
+
*
|
|
57
|
+
* INJECTION-FREE BY CONSTRUCTION: identifiers are compile-time constants (the type's own column
|
|
58
|
+
* or the fixed framework template); the ONLY dynamic values are the `?`-bound subtree node ids.
|
|
59
|
+
* Mirrors the engine's private `readScopeClause` exactly (one definition of the subtree predicate).
|
|
60
|
+
*/
|
|
61
|
+
export declare function writeScopeClause(reg: ObjectType, actor: Principal): {
|
|
62
|
+
sql: string;
|
|
63
|
+
params: readonly unknown[];
|
|
64
|
+
} | null;
|
|
65
|
+
/**
|
|
66
|
+
* Whether a NON-ROOT create of this type must pass the post-insert create wall (ADR-0034): the
|
|
67
|
+
* type declares `readScope` and has not explicitly opted out with `create: 'unscoped'`. Default
|
|
68
|
+
* walled (fail closed). Root is handled by {@link writeScopeClause} returning `null`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function isCreateWalled(reg: ObjectType): boolean;
|
|
40
71
|
/** Boot-enforce that exactly-one field claims `role`; throw if none. */
|
|
41
72
|
export declare function requireRole(reg: ObjectType, role: NonNullable<FieldSpec['role']>): void;
|
|
42
73
|
//# sourceMappingURL=registry.d.ts.map
|
package/dist/registry.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE/E,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IAExD,kFAAkF;IAClF,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI;IAQ/B,uEAAuE;IACvE,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU;IAWpC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAI7B,GAAG,IAAI,SAAS,UAAU,EAAE;CAG7B;AAED,iFAAiF;AACjF,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAEjD;AAED,mEAAmE;AACnE,wBAAgB,YAAY,CAAC,EAAE,EAAE,UAAU,GAAG,MAAM,CAKnD;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAEhD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAG1D;AAED,6FAA6F;AAC7F,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAG7D;AAED,6EAA6E;AAC7E,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,IAAI,CAGrF;AAaD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,SAAS,GACf;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAA;CAAE,GAAG,IAAI,CAYpD;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAEvD;AAED,wEAAwE;AACxE,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,WAAW,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,CAMvF"}
|
package/dist/registry.js
CHANGED
|
@@ -17,8 +17,11 @@ exports.isOwned = isOwned;
|
|
|
17
17
|
exports.ownerColumn = ownerColumn;
|
|
18
18
|
exports.tenantColumnOf = tenantColumnOf;
|
|
19
19
|
exports.columnForRole = columnForRole;
|
|
20
|
+
exports.writeScopeClause = writeScopeClause;
|
|
21
|
+
exports.isCreateWalled = isCreateWalled;
|
|
20
22
|
exports.requireRole = requireRole;
|
|
21
23
|
const errors_js_1 = require("./errors.js");
|
|
24
|
+
const types_js_1 = require("./types.js");
|
|
22
25
|
class TableRegistry {
|
|
23
26
|
byType = new Map();
|
|
24
27
|
/** Register a table. Throws on a duplicate typeKey or an invalid registration. */
|
|
@@ -83,6 +86,59 @@ function columnForRole(reg, role) {
|
|
|
83
86
|
const f = reg.fields.find((x) => x.role === role);
|
|
84
87
|
return f?.column ?? f?.key ?? null;
|
|
85
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The subtree node set a non-root actor's scope is bounded to (ADR-0027) — its
|
|
91
|
+
* `scopeSubtreeNodeIds`, resolved once at login and cached. FAIL-SAFE + non-widening: a malformed
|
|
92
|
+
* or absent subtree falls back to just the actor's home node (`[actor.tenantId]`) — today's flat
|
|
93
|
+
* self-isolation; NEVER an empty set. The SAME set the read wall + tenant gate + list-scope use.
|
|
94
|
+
*/
|
|
95
|
+
function subtreeNodesOf(actor) {
|
|
96
|
+
const subtree = actor.scopeSubtreeNodeIds;
|
|
97
|
+
return Array.isArray(subtree) && subtree.length > 0 ? subtree : [actor.tenantId];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The WRITE-scope predicate for a type's update/delete WHERE (ADR-0030) — the write-path twin of
|
|
101
|
+
* the read wall (`readScope`). For a type declaring `readScope`, it constrains a NON-ROOT actor's
|
|
102
|
+
* update/delete to rows within the actor's reachable subtree, so a targeted write to a
|
|
103
|
+
* foreign-subtree row matches ZERO rows (fail-closed no-op) instead of touching it. It is a
|
|
104
|
+
* FILTER, never a stamp — no column value is set, so the ADR-0027 create-stamping decoupling
|
|
105
|
+
* (add-to-sub-company) is preserved. ADR-0034 reuses the SAME clause for the post-insert create
|
|
106
|
+
* wall (`write.ts`), so update, delete and create share one definition of "inside my subtree".
|
|
107
|
+
*
|
|
108
|
+
* Returns `null` (nothing to AND) when:
|
|
109
|
+
* - the type declares no `readScope`, OR
|
|
110
|
+
* - the actor is ROOT (`isRoot === true`, strict) — root spans all subtrees.
|
|
111
|
+
* Otherwise returns an AND-able fragment (the leading ` AND ` is the CALLER's to prepend — this
|
|
112
|
+
* returns the bare predicate + its `?`-bound params), over the actor's subtree nodes:
|
|
113
|
+
* - `nodeColumn` → `` `<column>` IN (?, ...) ``.
|
|
114
|
+
* - `subtreeSubquery` → `` `<pkColumn>` IN (<subquerySql, :nodes → IN (?, ...)>) ``.
|
|
115
|
+
*
|
|
116
|
+
* INJECTION-FREE BY CONSTRUCTION: identifiers are compile-time constants (the type's own column
|
|
117
|
+
* or the fixed framework template); the ONLY dynamic values are the `?`-bound subtree node ids.
|
|
118
|
+
* Mirrors the engine's private `readScopeClause` exactly (one definition of the subtree predicate).
|
|
119
|
+
*/
|
|
120
|
+
function writeScopeClause(reg, actor) {
|
|
121
|
+
const rs = reg.readScope;
|
|
122
|
+
if (!rs || actor.isRoot === true)
|
|
123
|
+
return null;
|
|
124
|
+
const nodes = subtreeNodesOf(actor);
|
|
125
|
+
const placeholders = nodes.map(() => '?').join(', ');
|
|
126
|
+
if (rs.kind === 'nodeColumn') {
|
|
127
|
+
return { sql: `\`${rs.column}\` IN (${placeholders})`, params: [...nodes] };
|
|
128
|
+
}
|
|
129
|
+
// subtreeSubquery: expand the FIXED template's `:nodes` token to a `?`-list. Identifiers are
|
|
130
|
+
// constants; only the node ids are `?`-bound.
|
|
131
|
+
const expanded = rs.subquerySql.replace(types_js_1.READ_SCOPE_NODES_TOKEN, placeholders);
|
|
132
|
+
return { sql: `\`${rs.pkColumn}\` IN (${expanded})`, params: [...nodes] };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether a NON-ROOT create of this type must pass the post-insert create wall (ADR-0034): the
|
|
136
|
+
* type declares `readScope` and has not explicitly opted out with `create: 'unscoped'`. Default
|
|
137
|
+
* walled (fail closed). Root is handled by {@link writeScopeClause} returning `null`.
|
|
138
|
+
*/
|
|
139
|
+
function isCreateWalled(reg) {
|
|
140
|
+
return reg.readScope !== undefined && reg.readScope.create !== 'unscoped';
|
|
141
|
+
}
|
|
86
142
|
/** Boot-enforce that exactly-one field claims `role`; throw if none. */
|
|
87
143
|
function requireRole(reg, role) {
|
|
88
144
|
if (!reg.fields.some((f) => f.role === role)) {
|
|
@@ -103,6 +159,20 @@ function validate(reg) {
|
|
|
103
159
|
throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': duplicate field key '${f.key}'`);
|
|
104
160
|
}
|
|
105
161
|
keys.add(f.key);
|
|
162
|
+
// ADR-0034: `immutable` is a security posture (create-only) — a truthy non-boolean is a
|
|
163
|
+
// malformed declaration, never silently read as "mutable".
|
|
164
|
+
const immutable = f.immutable;
|
|
165
|
+
if (immutable !== undefined && typeof immutable !== 'boolean') {
|
|
166
|
+
throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': field '${f.key}' immutable must be a boolean, got ` +
|
|
167
|
+
`${typeof immutable} (${String(immutable)}).`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// ADR-0034: the create-wall mode is either absent (walled), 'walled' or 'unscoped'. Anything
|
|
171
|
+
// else fails boot — an opt-out is an explicit decision, never a typo.
|
|
172
|
+
const createMode = reg.readScope?.create;
|
|
173
|
+
if (createMode !== undefined && createMode !== 'walled' && createMode !== 'unscoped') {
|
|
174
|
+
throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': readScope.create must be 'walled' or 'unscoped', got ` +
|
|
175
|
+
`'${String(createMode)}'.`);
|
|
106
176
|
}
|
|
107
177
|
if (reg.primaryKey.kind === 'composite' && reg.primaryKey.columns.length < 2) {
|
|
108
178
|
throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': composite primary key needs >= 2 columns`);
|
package/dist/types.d.ts
CHANGED
|
@@ -38,6 +38,14 @@ export interface FieldSpec {
|
|
|
38
38
|
* password, etc.) and carries no write-time behavior of its own.
|
|
39
39
|
*/
|
|
40
40
|
readonly role?: IdentityRole;
|
|
41
|
+
/**
|
|
42
|
+
* A CREATE-ONLY field (ADR-0034): written on create, and IGNORED on update exactly like a
|
|
43
|
+
* `trusted` field — an update's value for it never reaches the UPDATE SQL, so the stored value
|
|
44
|
+
* stands. Use it for structural links that a plain patch must never move (a company's parent,
|
|
45
|
+
* a membership's user/company, a posting's membership/role); moving them needs a dedicated,
|
|
46
|
+
* checked operation. Must be a boolean when present (boot-validated).
|
|
47
|
+
*/
|
|
48
|
+
readonly immutable?: boolean;
|
|
41
49
|
}
|
|
42
50
|
/** Server-trusted write-context sources (derive-write-context stage). */
|
|
43
51
|
export type TrustedSource = 'tenantId' | 'actorId';
|
|
@@ -101,8 +109,18 @@ export interface AccessDeclaration {
|
|
|
101
109
|
/**
|
|
102
110
|
* How a type's LIST/GET reads are scoped to the caller's reachable subtree (ADR-0027) —
|
|
103
111
|
* DECOUPLED from `trusted:'tenantId'` (which stamps WRITES via derive-write-context).
|
|
104
|
-
* `readScope` filters READS
|
|
105
|
-
*
|
|
112
|
+
* `readScope` filters READS, and (ADR-0030) also FILTERS the update/delete WHERE so a non-root
|
|
113
|
+
* actor cannot mutate/delete a row outside its subtree by id — a fail-closed no-op, the write-path
|
|
114
|
+
* twin of the read wall. It is NEVER a create-stamp (a create still stamps only the `trusted`
|
|
115
|
+
* fields — the decoupling that lets an admin add a user to a SUB-company is preserved).
|
|
116
|
+
*
|
|
117
|
+
* CREATE WALL (ADR-0034): a NON-ROOT create of a `readScope` type is re-checked inside the same
|
|
118
|
+
* unit of work right after the INSERT — `SELECT 1 … WHERE <pk> = ? AND <the same write-scope
|
|
119
|
+
* clause>`; zero rows ⇒ `AuthorizationDeniedError` and the transaction rolls back. This is the
|
|
120
|
+
* DEFAULT (`create` absent or `'walled'`): fail closed. A type whose rows are born OUTSIDE every
|
|
121
|
+
* subtree (a new company before the closure lists it, a new user before any membership, a new
|
|
122
|
+
* role before any posting) opts out EXPLICITLY with `create: 'unscoped'`. Absent `readScope` ⇒
|
|
123
|
+
* no subtree read/write-scope (today's behavior — the tenant clause,
|
|
106
124
|
* if the type has a `trusted:'tenantId'` column, still applies; a `system` type with no
|
|
107
125
|
* such column then returns unscoped, which is why the identity `system` types declare a
|
|
108
126
|
* `readScope` to wall themselves). Applied by the engine AFTER the tenant clause, for
|
|
@@ -122,8 +140,12 @@ export interface AccessDeclaration {
|
|
|
122
140
|
export type ReadScope = {
|
|
123
141
|
readonly kind: 'nodeColumn';
|
|
124
142
|
readonly column: string;
|
|
143
|
+
/** Create-wall mode (ADR-0034). Absent ⇒ `'walled'` (fail closed). */
|
|
144
|
+
readonly create?: ReadScopeCreateMode;
|
|
125
145
|
} | {
|
|
126
146
|
readonly kind: 'subtreeSubquery';
|
|
147
|
+
/** Create-wall mode (ADR-0034). Absent ⇒ `'walled'` (fail closed). */
|
|
148
|
+
readonly create?: ReadScopeCreateMode;
|
|
127
149
|
/** The type's OWN PK column to test membership of. */
|
|
128
150
|
readonly pkColumn: string;
|
|
129
151
|
/**
|
|
@@ -133,6 +155,13 @@ export type ReadScope = {
|
|
|
133
155
|
*/
|
|
134
156
|
readonly subquerySql: string;
|
|
135
157
|
};
|
|
158
|
+
/**
|
|
159
|
+
* How a non-root CREATE of a `readScope` type is checked (ADR-0034):
|
|
160
|
+
* - `'walled'` (the default when absent) — the inserted row must satisfy the write-scope clause,
|
|
161
|
+
* else the create is denied and rolled back.
|
|
162
|
+
* - `'unscoped'` — an explicit opt-out for types whose rows are born outside every subtree.
|
|
163
|
+
*/
|
|
164
|
+
export type ReadScopeCreateMode = 'walled' | 'unscoped';
|
|
136
165
|
/** The token a `subtreeSubquery` template uses for the engine-expanded subtree id list. */
|
|
137
166
|
export declare const READ_SCOPE_NODES_TOKEN = ":nodes";
|
|
138
167
|
/** Primary-key strategy for a registered table. */
|
|
@@ -176,9 +205,11 @@ export interface ObjectType {
|
|
|
176
205
|
readonly access: AccessDeclaration;
|
|
177
206
|
/**
|
|
178
207
|
* OPTIONAL read-scope declaration (ADR-0027) — how LIST/GET reads are filtered to the
|
|
179
|
-
* caller's reachable subtree
|
|
180
|
-
*
|
|
181
|
-
*
|
|
208
|
+
* caller's reachable subtree. Also FILTERS the update/delete WHERE for a non-root actor
|
|
209
|
+
* (ADR-0030 — a fail-closed no-op on a foreign-subtree target), and WALLS a non-root create
|
|
210
|
+
* (ADR-0034 — denied + rolled back unless `create: 'unscoped'`), but is NEVER a create-stamp.
|
|
211
|
+
* See {@link ReadScope}. Absent ⇒ no subtree read/write-scope (the tenant clause, if any,
|
|
212
|
+
* still applies).
|
|
182
213
|
*/
|
|
183
214
|
readonly readScope?: ReadScope;
|
|
184
215
|
}
|
|
@@ -225,7 +256,11 @@ export interface ListQuery {
|
|
|
225
256
|
readonly where?: Readonly<Record<string, unknown>>;
|
|
226
257
|
/** Free-text LIKE search across named field keys (ANDed with `where` + tenant). */
|
|
227
258
|
readonly search?: SearchQuery;
|
|
228
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* A registered field KEY to ORDER BY (unknown → RegistrationError). The primary-key
|
|
261
|
+
* column(s) are always appended ascending as the final tiebreaker, so OFFSET paging is
|
|
262
|
+
* stable; with no `sortBy` the list is ordered by the primary key.
|
|
263
|
+
*/
|
|
229
264
|
readonly sortBy?: string;
|
|
230
265
|
/** Sort direction; anything other than 'desc' is treated as ascending. */
|
|
231
266
|
readonly sortOrder?: 'asc' | 'desc';
|
|
@@ -242,10 +277,35 @@ export interface RecordPage {
|
|
|
242
277
|
*/
|
|
243
278
|
export interface RecordEngine {
|
|
244
279
|
create(typeKey: string, input: RecordInput, actor: Principal): Promise<RecordId>;
|
|
280
|
+
/**
|
|
281
|
+
* update/delete judge the EXISTING row (its fields, owner and tenant) in `decide()`. A record
|
|
282
|
+
* the actor cannot reach (missing, other tenant, outside the write scope) is refused with
|
|
283
|
+
* `AuthorizationDeniedError('no_record_permit')`, never a silent no-op. get() judges the same
|
|
284
|
+
* way and returns `null` for an unreachable record of a non-owned type.
|
|
285
|
+
*/
|
|
245
286
|
update(typeKey: string, id: RecordId, input: RecordInput, actor: Principal): Promise<void>;
|
|
246
287
|
delete(typeKey: string, id: RecordId, actor: Principal): Promise<void>;
|
|
247
288
|
get(typeKey: string, id: RecordId, actor: Principal): Promise<RecordView | null>;
|
|
248
289
|
list(typeKey: string, query: ListQuery, actor: Principal): Promise<RecordPage>;
|
|
290
|
+
/**
|
|
291
|
+
* Run `fn` in ONE unit of work (ADR-0034). Every op on the `tx` handle — and every plain call
|
|
292
|
+
* on this engine made from inside `fn` — shares the same transaction; each op still runs the
|
|
293
|
+
* full pipe (gate, validate, write, audit). Commits when `fn` resolves; rolls back when it
|
|
294
|
+
* throws. ROLLBACK-ONLY: once a WRITE op inside the transaction has failed, the transaction can
|
|
295
|
+
* no longer commit (even if `fn` swallowed the error) — the partial write of a failed op is
|
|
296
|
+
* never committed. A nested `transaction` (on the handle or on the engine) reuses the outer one.
|
|
297
|
+
* A handle used after its transaction ended fails closed (`EngineUnavailableError`).
|
|
298
|
+
*/
|
|
299
|
+
transaction<T>(fn: (tx: RecordEngine) => Promise<T>): Promise<T>;
|
|
300
|
+
/**
|
|
301
|
+
* Take an exclusive ROW LOCK on one record (`SELECT … WHERE <pk> = ? FOR UPDATE`) on the open
|
|
302
|
+
* transaction's own connection, held until that transaction commits or rolls back. Use it to
|
|
303
|
+
* serialise a check-then-write against a concurrent writer of the same row: lock first, then
|
|
304
|
+
* re-check, then write. Returns nothing (no row data, no existence signal — a missing row is
|
|
305
|
+
* simply not locked), so it runs no read gate or wall. Only valid inside `transaction(fn)`:
|
|
306
|
+
* outside one it throws `EngineUnavailableError` (a lock released at once serialises nothing).
|
|
307
|
+
*/
|
|
308
|
+
lockForUpdate(typeKey: string, id: RecordId, actor: Principal): Promise<void>;
|
|
249
309
|
}
|
|
250
310
|
export type { Principal };
|
|
251
311
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/D,iFAAiF;AACjF,MAAM,MAAM,SAAS,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astram/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Astram framework — the paved road. ONE unbypassable write pipe (resolve-schema → permission gate → derive-write-context → validate → compute → write → junction-sync → audit → emit-event) over real physical tables, that every feature's CRUD flows through. Feature code receives ONLY the RecordEngine facade — never a raw pool/DataSource/QueryRunner (the physical wall). Security-gate + audit are mandatory stages. Missing capability → a typed CapabilityNotSupportedError (the gap-flag STOP). Builds on @astram/core (metadata contract, decide(), audit) + @astram/guard (decision authority).",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"mariadb": "^3.4.0",
|
|
24
|
-
"@astram/core": "1.
|
|
24
|
+
"@astram/core": "1.4.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"@nestjs/common": "^11",
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stage: fetch-owner (record-level OWNED write ONLY — runs BEFORE the permission gate).
|
|
3
|
-
*
|
|
4
|
-
* Core's decision authority is zero-dep (no DB) and decides BEFORE the row is fetched,
|
|
5
|
-
* so it cannot see a record's owner to check DAC ownership. This stage closes that gap
|
|
6
|
-
* for `owned` types on update/delete: it SELECTs the EXISTING row's owner column —
|
|
7
|
-
* TENANT-SCOPED in the WHERE so it can never read another tenant's row — and stashes it
|
|
8
|
-
* on `ctx.ownerId`. The permission-gate stage then passes it to `decide()` as
|
|
9
|
-
* `context.attributes.ownerId`, where core's owner baseline compares it to the actor.
|
|
10
|
-
*
|
|
11
|
-
* ── EXEMPTIONS (no pre-fetch):
|
|
12
|
-
* - `create` — there is no existing record; the owner is being SET by the
|
|
13
|
-
* derive-write-context stage, not checked. (This stage no-ops for create.)
|
|
14
|
-
* - NON-owned / system types — no owner column to check; the gate runs on the
|
|
15
|
-
* grant/system baseline alone. (No unnecessary query.)
|
|
16
|
-
*
|
|
17
|
-
* ── SECURITY (fail-closed):
|
|
18
|
-
* - The row does NOT exist (or is in another tenant) ⇒ `ctx.ownerId` stays undefined
|
|
19
|
-
* ⇒ core's owner baseline finds no match ⇒ the gate denies (no_record_permit). A
|
|
20
|
-
* missing record NEVER leaks and NEVER passes.
|
|
21
|
-
* - This stage only READS the owner column (tenant-scoped) — it performs no write and
|
|
22
|
-
* surfaces no row content; the fetched owner is an opaque id fed to the gate.
|
|
23
|
-
* - The stage runs inside the SAME transaction as the gate + write (one UoW), so the
|
|
24
|
-
* owner it read is the owner the write will act on (no TOCTOU within the txn).
|
|
25
|
-
*/
|
|
26
|
-
import type { Stage } from '../context.js';
|
|
27
|
-
export declare function fetchOwnerStage(): Stage;
|
|
28
|
-
//# sourceMappingURL=fetch-owner.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"fetch-owner.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/fetch-owner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAqCzD,wBAAgB,eAAe,IAAI,KAAK,CAgCvC"}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Stage: fetch-owner (record-level OWNED write ONLY — runs BEFORE the permission gate).
|
|
4
|
-
*
|
|
5
|
-
* Core's decision authority is zero-dep (no DB) and decides BEFORE the row is fetched,
|
|
6
|
-
* so it cannot see a record's owner to check DAC ownership. This stage closes that gap
|
|
7
|
-
* for `owned` types on update/delete: it SELECTs the EXISTING row's owner column —
|
|
8
|
-
* TENANT-SCOPED in the WHERE so it can never read another tenant's row — and stashes it
|
|
9
|
-
* on `ctx.ownerId`. The permission-gate stage then passes it to `decide()` as
|
|
10
|
-
* `context.attributes.ownerId`, where core's owner baseline compares it to the actor.
|
|
11
|
-
*
|
|
12
|
-
* ── EXEMPTIONS (no pre-fetch):
|
|
13
|
-
* - `create` — there is no existing record; the owner is being SET by the
|
|
14
|
-
* derive-write-context stage, not checked. (This stage no-ops for create.)
|
|
15
|
-
* - NON-owned / system types — no owner column to check; the gate runs on the
|
|
16
|
-
* grant/system baseline alone. (No unnecessary query.)
|
|
17
|
-
*
|
|
18
|
-
* ── SECURITY (fail-closed):
|
|
19
|
-
* - The row does NOT exist (or is in another tenant) ⇒ `ctx.ownerId` stays undefined
|
|
20
|
-
* ⇒ core's owner baseline finds no match ⇒ the gate denies (no_record_permit). A
|
|
21
|
-
* missing record NEVER leaks and NEVER passes.
|
|
22
|
-
* - This stage only READS the owner column (tenant-scoped) — it performs no write and
|
|
23
|
-
* surfaces no row content; the fetched owner is an opaque id fed to the gate.
|
|
24
|
-
* - The stage runs inside the SAME transaction as the gate + write (one UoW), so the
|
|
25
|
-
* owner it read is the owner the write will act on (no TOCTOU within the txn).
|
|
26
|
-
*/
|
|
27
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
-
exports.fetchOwnerStage = fetchOwnerStage;
|
|
29
|
-
const errors_js_1 = require("../../errors.js");
|
|
30
|
-
const registry_js_1 = require("../../registry.js");
|
|
31
|
-
function qualified(ctx) {
|
|
32
|
-
const schema = ctx.registration.schema;
|
|
33
|
-
const t = ctx.registration.table;
|
|
34
|
-
return schema ? `\`${schema}\`.\`${t}\`` : `\`${t}\``;
|
|
35
|
-
}
|
|
36
|
-
/** WHERE (pk [+ tenant]) for the target id — the SAME tenant-scoping the write stage uses. */
|
|
37
|
-
function whereById(ctx, id) {
|
|
38
|
-
const pk = ctx.registration.primaryKey;
|
|
39
|
-
let sql;
|
|
40
|
-
let params;
|
|
41
|
-
if (pk.kind === 'uuid') {
|
|
42
|
-
if (typeof id !== 'string') {
|
|
43
|
-
throw new errors_js_1.RegistrationError(`uuid PK table '${ctx.registration.typeKey}' needs a string id`);
|
|
44
|
-
}
|
|
45
|
-
sql = `\`${(0, registry_js_1.uuidPkColumn)(pk)}\` = ?`;
|
|
46
|
-
params = [id];
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
if (typeof id === 'string') {
|
|
50
|
-
throw new errors_js_1.RegistrationError(`composite PK table '${ctx.registration.typeKey}' needs a key map, not a string id`);
|
|
51
|
-
}
|
|
52
|
-
sql = pk.columns.map((c) => `\`${c}\` = ?`).join(' AND ');
|
|
53
|
-
params = pk.columns.map((c) => id[c]);
|
|
54
|
-
}
|
|
55
|
-
const tenantCol = (0, registry_js_1.tenantColumnOf)(ctx.registration);
|
|
56
|
-
if (tenantCol) {
|
|
57
|
-
sql += ` AND \`${tenantCol}\` = ?`;
|
|
58
|
-
params = [...params, ctx.actor.tenantId];
|
|
59
|
-
}
|
|
60
|
-
return { sql, params };
|
|
61
|
-
}
|
|
62
|
-
function fetchOwnerStage() {
|
|
63
|
-
return {
|
|
64
|
-
name: 'fetch-owner',
|
|
65
|
-
async run(ctx) {
|
|
66
|
-
// Only OWNED update/delete pre-fetch. create + non-owned types no-op.
|
|
67
|
-
if (ctx.op === 'create')
|
|
68
|
-
return;
|
|
69
|
-
if (!(0, registry_js_1.isOwned)(ctx.registration))
|
|
70
|
-
return;
|
|
71
|
-
if (ctx.id === undefined)
|
|
72
|
-
return;
|
|
73
|
-
const ownerCol = (0, registry_js_1.ownerColumn)(ctx.registration);
|
|
74
|
-
if (!ownerCol) {
|
|
75
|
-
// An owned type without an actorId column is a boot-time impossibility
|
|
76
|
-
// (chokepoint 1). Fail-closed rather than silently skipping the owner check.
|
|
77
|
-
throw new errors_js_1.RegistrationError(`owned type '${ctx.registration.typeKey}' has no trusted:'actorId' owner column`);
|
|
78
|
-
}
|
|
79
|
-
const where = whereById(ctx, ctx.id);
|
|
80
|
-
const rows = (await ctx.query(`SELECT \`${ownerCol}\` AS owner_id FROM ${qualified(ctx)} WHERE ${where.sql} LIMIT 1`, where.params));
|
|
81
|
-
const first = rows[0];
|
|
82
|
-
// Missing row (or wrong tenant) ⇒ ownerId stays undefined ⇒ the gate denies.
|
|
83
|
-
// Only stash a genuine string owner; a null/non-string owner is left undefined so
|
|
84
|
-
// the baseline's strict === can never spuriously match.
|
|
85
|
-
if (first && typeof first.owner_id === 'string') {
|
|
86
|
-
ctx.ownerId = first.owner_id;
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
}
|