@astram/engine 0.6.0 → 0.8.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/adapters/mariadb-audit-sink.d.ts +10 -1
- package/dist/adapters/mariadb-audit-sink.d.ts.map +1 -1
- package/dist/adapters/mariadb-audit-sink.js +8 -1
- package/dist/decorators/object-type.decorator.d.ts +10 -0
- package/dist/decorators/object-type.decorator.d.ts.map +1 -1
- package/dist/decorators/object-type.decorator.js +5 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -10
- package/dist/module/engine.module.d.ts +8 -0
- package/dist/module/engine.module.d.ts.map +1 -1
- package/dist/module/engine.module.js +7 -4
- package/dist/pipe/context.d.ts +8 -0
- package/dist/pipe/context.d.ts.map +1 -1
- package/dist/pipe/filter-sql.d.ts +66 -0
- package/dist/pipe/filter-sql.d.ts.map +1 -0
- package/dist/pipe/filter-sql.js +266 -0
- package/dist/pipe/record-engine.d.ts +34 -1
- package/dist/pipe/record-engine.d.ts.map +1 -1
- package/dist/pipe/record-engine.js +256 -87
- package/dist/pipe/records-service.d.ts +19 -1
- package/dist/pipe/records-service.d.ts.map +1 -1
- package/dist/pipe/records-service.js +32 -0
- package/dist/pipe/stages/audit.d.ts.map +1 -1
- package/dist/pipe/stages/audit.js +8 -1
- package/dist/pipe/stages/fetch-record.d.ts +11 -1
- package/dist/pipe/stages/fetch-record.d.ts.map +1 -1
- package/dist/pipe/stages/fetch-record.js +18 -9
- package/dist/pipe/stages/permission-gate.d.ts +8 -0
- package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
- package/dist/pipe/stages/permission-gate.js +15 -9
- package/dist/pipe/stages/validate.d.ts +6 -0
- package/dist/pipe/stages/validate.d.ts.map +1 -1
- package/dist/pipe/stages/validate.js +22 -8
- package/dist/registry.d.ts +38 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +128 -2
- package/dist/types.d.ts +133 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -1
- package/package.json +2 -2
|
@@ -33,7 +33,14 @@ function auditStage(audit) {
|
|
|
33
33
|
tenantId: ctx.actor.tenantId,
|
|
34
34
|
action,
|
|
35
35
|
resource,
|
|
36
|
-
attributes: {
|
|
36
|
+
attributes: {
|
|
37
|
+
op: ctx.op,
|
|
38
|
+
typeKey: ctx.registration.typeKey,
|
|
39
|
+
// createMany rows only: single-row writes keep exactly { op, typeKey }.
|
|
40
|
+
...(ctx.batch !== undefined
|
|
41
|
+
? { batchId: ctx.batch.id, batchIndex: ctx.batch.index }
|
|
42
|
+
: {}),
|
|
43
|
+
},
|
|
37
44
|
};
|
|
38
45
|
// Write the audit row on the pipe's OWN transaction (atomic with the data).
|
|
39
46
|
await audit.record(event, ctx.query);
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
* - `create` no-ops: there is no existing row (the create wall is ADR-0034, in `write.ts`).
|
|
31
31
|
*/
|
|
32
32
|
import { AuthorizationDeniedError } from '../../errors.js';
|
|
33
|
-
import type {
|
|
33
|
+
import type { QueryFn } from '../../ports.js';
|
|
34
|
+
import type { ObjectType, Principal, RecordId } from '../../types.js';
|
|
34
35
|
import type { Stage } from '../context.js';
|
|
35
36
|
/** The decision-relevant facts of one fetched row. */
|
|
36
37
|
export interface RecordFacts {
|
|
@@ -52,5 +53,14 @@ export declare function recordFacts(reg: ObjectType, row: Record<string, unknown
|
|
|
52
53
|
export declare function decisionAttributes(reg: ObjectType, facts: Pick<RecordFacts, 'fields' | 'ownerId'>): Record<string, unknown>;
|
|
53
54
|
/** The ONE refusal for a record the actor cannot reach (missing ≡ foreign; no ids). */
|
|
54
55
|
export declare function recordNotReachable(reg: ObjectType): AuthorizationDeniedError;
|
|
56
|
+
/**
|
|
57
|
+
* Read one record's decision facts through `whereById` (pk + actor tenant + ADR-0030 write
|
|
58
|
+
* scope), `LIMIT 1`, with `FOR UPDATE` when `lock` is set. No row (missing ≡ foreign) ⇒
|
|
59
|
+
* `recordNotReachable`. Shared by `fetchRecordStage` (lock) and `RecordEngine.authorize`
|
|
60
|
+
* (lock only inside a transaction). The row itself is never returned, only its facts.
|
|
61
|
+
*/
|
|
62
|
+
export declare function fetchRecordFacts(reg: ObjectType, actor: Principal, id: RecordId, query: QueryFn, opts: {
|
|
63
|
+
readonly lock: boolean;
|
|
64
|
+
}): Promise<RecordFacts>;
|
|
55
65
|
export declare function fetchRecordStage(): Stage;
|
|
56
66
|
//# sourceMappingURL=fetch-record.d.ts.map
|
|
@@ -1 +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;
|
|
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,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACtE,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;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,SAAS,EAChB,EAAE,EAAE,QAAQ,EACZ,KAAK,EAAE,OAAO,EACd,IAAI,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC/B,OAAO,CAAC,WAAW,CAAC,CAStB;AAED,wBAAgB,gBAAgB,IAAI,KAAK,CAexC"}
|
|
@@ -34,6 +34,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
34
34
|
exports.recordFacts = recordFacts;
|
|
35
35
|
exports.decisionAttributes = decisionAttributes;
|
|
36
36
|
exports.recordNotReachable = recordNotReachable;
|
|
37
|
+
exports.fetchRecordFacts = fetchRecordFacts;
|
|
37
38
|
exports.fetchRecordStage = fetchRecordStage;
|
|
38
39
|
const errors_js_1 = require("../../errors.js");
|
|
39
40
|
const registry_js_1 = require("../../registry.js");
|
|
@@ -76,6 +77,20 @@ function decisionAttributes(reg, facts) {
|
|
|
76
77
|
function recordNotReachable(reg) {
|
|
77
78
|
return new errors_js_1.AuthorizationDeniedError('no_record_permit', `no reachable '${reg.typeKey}' record for this operation`);
|
|
78
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Read one record's decision facts through `whereById` (pk + actor tenant + ADR-0030 write
|
|
82
|
+
* scope), `LIMIT 1`, with `FOR UPDATE` when `lock` is set. No row (missing ≡ foreign) ⇒
|
|
83
|
+
* `recordNotReachable`. Shared by `fetchRecordStage` (lock) and `RecordEngine.authorize`
|
|
84
|
+
* (lock only inside a transaction). The row itself is never returned, only its facts.
|
|
85
|
+
*/
|
|
86
|
+
async function fetchRecordFacts(reg, actor, id, query, opts) {
|
|
87
|
+
const where = (0, where_by_id_js_1.whereById)(reg, actor, id);
|
|
88
|
+
const rows = (await query(`SELECT * FROM ${(0, where_by_id_js_1.qualifiedTable)(reg)} WHERE ${where.sql} LIMIT 1${opts.lock ? ' FOR UPDATE' : ''}`, where.params));
|
|
89
|
+
const first = Array.isArray(rows) ? rows[0] : undefined;
|
|
90
|
+
if (first === undefined || first === null)
|
|
91
|
+
throw recordNotReachable(reg);
|
|
92
|
+
return recordFacts(reg, first);
|
|
93
|
+
}
|
|
79
94
|
function fetchRecordStage() {
|
|
80
95
|
return {
|
|
81
96
|
name: 'fetch-record',
|
|
@@ -84,15 +99,9 @@ function fetchRecordStage() {
|
|
|
84
99
|
return;
|
|
85
100
|
if (ctx.id === undefined)
|
|
86
101
|
throw new errors_js_1.RegistrationError(`${ctx.op} requires an id`);
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const first = Array.isArray(rows)
|
|
91
|
-
? rows[0]
|
|
92
|
-
: undefined;
|
|
93
|
-
if (first === undefined || first === null)
|
|
94
|
-
throw recordNotReachable(reg);
|
|
95
|
-
const facts = recordFacts(reg, first);
|
|
102
|
+
const facts = await fetchRecordFacts(ctx.registration, ctx.actor, ctx.id, ctx.query, {
|
|
103
|
+
lock: true,
|
|
104
|
+
});
|
|
96
105
|
ctx.existing = facts.fields;
|
|
97
106
|
if (facts.ownerId !== undefined)
|
|
98
107
|
ctx.ownerId = facts.ownerId;
|
|
@@ -6,8 +6,16 @@
|
|
|
6
6
|
* own allow/deny logic; it asks `decide()` and obeys.
|
|
7
7
|
*/
|
|
8
8
|
import type { DecisionPort } from '../../ports.js';
|
|
9
|
+
import type { Action, Resource, DecisionContext } from '../../ports.js';
|
|
10
|
+
import type { Principal } from '../../types.js';
|
|
9
11
|
import type { Stage } from '../context.js';
|
|
10
12
|
export declare function permissionGateStage(decision: DecisionPort): Stage;
|
|
13
|
+
/**
|
|
14
|
+
* Ask `decide()` once; throw on deny (AuthorizationDeniedError) or a throw (unavailable).
|
|
15
|
+
* Shared by the write stages and `RecordEngine.authorize` (custom object actions, D3); `label`
|
|
16
|
+
* names the operation in the `EngineUnavailableError` message only.
|
|
17
|
+
*/
|
|
18
|
+
export declare function decideOrThrow(decision: DecisionPort, actor: Principal, action: Action, resource: Resource, context: DecisionContext, label: string): Promise<void>;
|
|
11
19
|
/**
|
|
12
20
|
* Stage: type pre-gate (update/delete only; runs FIRST, before fetch-record's row lock).
|
|
13
21
|
* Security-hardening Phase 8 #7.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"permission-gate.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/permission-gate.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"permission-gate.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/permission-gate.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAQlE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,YAAY,GAAG,KAAK,CAiCjE;AAOD;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,SAAS,EAChB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,eAAe,EACxB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC,CAef;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,YAAY,GAAG,KAAK,CAoB9D"}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.permissionGateStage = permissionGateStage;
|
|
11
|
+
exports.decideOrThrow = decideOrThrow;
|
|
11
12
|
exports.typePreGateStage = typePreGateStage;
|
|
12
13
|
const errors_js_1 = require("../../errors.js");
|
|
13
14
|
const fetch_record_js_1 = require("./fetch-record.js");
|
|
@@ -44,21 +45,28 @@ function permissionGateStage(decision) {
|
|
|
44
45
|
}
|
|
45
46
|
: {}),
|
|
46
47
|
};
|
|
47
|
-
await decideOrThrow(decision, ctx, action, resource, context);
|
|
48
|
+
await decideOrThrow(decision, ctx.actor, action, resource, context, gateLabel(ctx));
|
|
48
49
|
},
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
|
-
/**
|
|
52
|
-
|
|
52
|
+
/** The `EngineUnavailableError` label of a pipe stage: `<op> <typeKey>`. */
|
|
53
|
+
function gateLabel(ctx) {
|
|
54
|
+
return `${ctx.op} ${ctx.registration.typeKey}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ask `decide()` once; throw on deny (AuthorizationDeniedError) or a throw (unavailable).
|
|
58
|
+
* Shared by the write stages and `RecordEngine.authorize` (custom object actions, D3); `label`
|
|
59
|
+
* names the operation in the `EngineUnavailableError` message only.
|
|
60
|
+
*/
|
|
61
|
+
async function decideOrThrow(decision, actor, action, resource, context, label) {
|
|
53
62
|
let outcome;
|
|
54
63
|
try {
|
|
55
|
-
outcome = await decision.decide(
|
|
64
|
+
outcome = await decision.decide(actor, action, resource, context);
|
|
56
65
|
}
|
|
57
66
|
catch (err) {
|
|
58
67
|
// A thrown decision = transient/unavailable (ADR-0016). Fail-closed: do NOT
|
|
59
68
|
// proceed; signal retryable.
|
|
60
|
-
throw new errors_js_1.EngineUnavailableError(`decision authority unavailable for ${
|
|
61
|
-
`${err.message}`);
|
|
69
|
+
throw new errors_js_1.EngineUnavailableError(`decision authority unavailable for ${label}: ${err.message}`);
|
|
62
70
|
}
|
|
63
71
|
if (outcome.effect !== 'allow') {
|
|
64
72
|
throw new errors_js_1.AuthorizationDeniedError(outcome.reason, outcome.detail);
|
|
@@ -103,9 +111,7 @@ function typePreGateStage(decision) {
|
|
|
103
111
|
typeKey: ctx.registration.typeKey,
|
|
104
112
|
tenantId: ctx.actor.tenantId,
|
|
105
113
|
};
|
|
106
|
-
await decideOrThrow(decision, ctx, { key: actionKey(ctx.op) }, resource, {
|
|
107
|
-
isAuthenticated: true,
|
|
108
|
-
});
|
|
114
|
+
await decideOrThrow(decision, ctx.actor, { key: actionKey(ctx.op) }, resource, { isAuthenticated: true }, gateLabel(ctx));
|
|
109
115
|
},
|
|
110
116
|
};
|
|
111
117
|
}
|
|
@@ -7,5 +7,11 @@
|
|
|
7
7
|
* Runs AFTER derive-write-context so trusted (server) values are validated too.
|
|
8
8
|
*/
|
|
9
9
|
import type { Stage } from '../context.js';
|
|
10
|
+
import type { FieldSpec } from '../../types.js';
|
|
11
|
+
/**
|
|
12
|
+
* Validate + coerce one value against a field type. Returns the coerced value. Also used by
|
|
13
|
+
* the list filter builder (`buildFilterClauses`) for every `where` operand.
|
|
14
|
+
*/
|
|
15
|
+
export declare function coerce(field: FieldSpec, value: unknown): unknown;
|
|
10
16
|
export declare function validateStage(): Stage;
|
|
11
17
|
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/validate.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/validate.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAa,MAAM,gBAAgB,CAAC;AAW3D;;;GAGG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CA6ChE;AAED,wBAAgB,aAAa,IAAI,KAAK,CAmCrC"}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Runs AFTER derive-write-context so trusted (server) values are validated too.
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.coerce = coerce;
|
|
11
12
|
exports.validateStage = validateStage;
|
|
12
13
|
const errors_js_1 = require("../../errors.js");
|
|
13
14
|
const registry_js_1 = require("../../registry.js");
|
|
@@ -20,7 +21,10 @@ const SUPPORTED = new Set([
|
|
|
20
21
|
'timestamp',
|
|
21
22
|
'json',
|
|
22
23
|
]);
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Validate + coerce one value against a field type. Returns the coerced value. Also used by
|
|
26
|
+
* the list filter builder (`buildFilterClauses`) for every `where` operand.
|
|
27
|
+
*/
|
|
24
28
|
function coerce(field, value) {
|
|
25
29
|
switch (field.type) {
|
|
26
30
|
case 'string':
|
|
@@ -31,6 +35,10 @@ function coerce(field, value) {
|
|
|
31
35
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
32
36
|
throw new errors_js_1.ValidationError(field.key, 'expected a number');
|
|
33
37
|
}
|
|
38
|
+
// ±Infinity is not a storable column value and not a meaningful filter operand.
|
|
39
|
+
if (!Number.isFinite(value)) {
|
|
40
|
+
throw new errors_js_1.ValidationError(field.key, 'expected a finite number');
|
|
41
|
+
}
|
|
34
42
|
return value;
|
|
35
43
|
case 'boolean':
|
|
36
44
|
if (typeof value === 'boolean')
|
|
@@ -44,14 +52,20 @@ function coerce(field, value) {
|
|
|
44
52
|
throw new errors_js_1.ValidationError(field.key, 'expected a uuid');
|
|
45
53
|
return canonical;
|
|
46
54
|
}
|
|
47
|
-
case 'timestamp':
|
|
55
|
+
case 'timestamp': {
|
|
56
|
+
// An invalid Date (NaN / ±Infinity epoch) is rejected here, never bound as a parameter.
|
|
57
|
+
let date = null;
|
|
48
58
|
if (value instanceof Date)
|
|
49
|
-
|
|
50
|
-
if (typeof value === 'number')
|
|
51
|
-
|
|
52
|
-
if (typeof value === 'string' && !Number.isNaN(Date.parse(value)))
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
date = value;
|
|
60
|
+
else if (typeof value === 'number')
|
|
61
|
+
date = new Date(value);
|
|
62
|
+
else if (typeof value === 'string' && !Number.isNaN(Date.parse(value)))
|
|
63
|
+
date = new Date(value);
|
|
64
|
+
if (date === null || Number.isNaN(date.getTime())) {
|
|
65
|
+
throw new errors_js_1.ValidationError(field.key, 'expected a timestamp (Date | epoch ms | ISO string)');
|
|
66
|
+
}
|
|
67
|
+
return date;
|
|
68
|
+
}
|
|
55
69
|
case 'json':
|
|
56
70
|
return JSON.stringify(value);
|
|
57
71
|
default:
|
package/dist/registry.d.ts
CHANGED
|
@@ -8,11 +8,34 @@
|
|
|
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 FrameworkSchemas } from '@astram/core';
|
|
11
12
|
import type { FieldSpec, PkStrategy, ObjectType, Principal } from './types.js';
|
|
13
|
+
/** The four resolved names `resolveObjectTypeSchema` needs (a core `FrameworkSchemas` fits). */
|
|
14
|
+
export type FrameworkSchemaNames = Pick<FrameworkSchemas, 'app' | 'identity' | 'authz' | 'audit'>;
|
|
15
|
+
/**
|
|
16
|
+
* Return `reg` with every {@link FRAMEWORK_SCHEMA} token in `schema` and
|
|
17
|
+
* `readScope.subquerySql` replaced by the resolved name (configurable-framework-db, spec 5.B).
|
|
18
|
+
* Pure: the input is not mutated. A literal schema or an absent one is returned unchanged.
|
|
19
|
+
*
|
|
20
|
+
* FAIL CLOSED (Shield #1): throws a `RegistrationError` when `schemas` is absent but a token is
|
|
21
|
+
* used, when a used token's resolved name is missing or not a plain identifier, or when any
|
|
22
|
+
* token-like text survives resolution. Every reader of an object type's schema outside the
|
|
23
|
+
* registry must call this before building SQL.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveObjectTypeSchema(reg: ObjectType, schemas: FrameworkSchemaNames | undefined): ObjectType;
|
|
12
26
|
export declare class TableRegistry {
|
|
27
|
+
private readonly schemas?;
|
|
13
28
|
private readonly byType;
|
|
14
|
-
/**
|
|
15
|
-
|
|
29
|
+
/**
|
|
30
|
+
* @param schemas the resolved framework database names `FRAMEWORK_SCHEMA` tokens resolve to.
|
|
31
|
+
* Absent ⇒ a registration that uses a token fails (fail closed); others are unaffected.
|
|
32
|
+
*/
|
|
33
|
+
constructor(schemas?: FrameworkSchemaNames | undefined);
|
|
34
|
+
/**
|
|
35
|
+
* Register a table. Resolves `FRAMEWORK_SCHEMA` tokens first (an unresolved token throws),
|
|
36
|
+
* then throws on a duplicate typeKey or an invalid registration.
|
|
37
|
+
*/
|
|
38
|
+
register(input: ObjectType): void;
|
|
16
39
|
/** Resolve a registration, or throw a RegistrationError if unknown. */
|
|
17
40
|
require(typeKey: string): ObjectType;
|
|
18
41
|
has(typeKey: string): boolean;
|
|
@@ -70,4 +93,17 @@ export declare function writeScopeClause(reg: ObjectType, actor: Principal): {
|
|
|
70
93
|
export declare function isCreateWalled(reg: ObjectType): boolean;
|
|
71
94
|
/** Boot-enforce that exactly-one field claims `role`; throw if none. */
|
|
72
95
|
export declare function requireRole(reg: ObjectType, role: NonNullable<FieldSpec['role']>): void;
|
|
96
|
+
/** What the action vocabulary reads from a registration (typeKey only names it in errors). */
|
|
97
|
+
type ActionDeclaration = Pick<ObjectType, 'actions' | 'crudActions'> & {
|
|
98
|
+
readonly typeKey?: string;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* The type's action vocabulary (ADR-0038): `read, create, update, delete` then the declared
|
|
102
|
+
* extras in declared order, or exactly the declared list when `crudActions === false`.
|
|
103
|
+
* Validates first (throws `RegistrationError`), so a registration read without
|
|
104
|
+
* `TableRegistry.register` (e.g. identity `extraObjects`) is held to the same rules.
|
|
105
|
+
* Framework-facing: the guard registry bridge, identity lookups and `authorize()` read it.
|
|
106
|
+
*/
|
|
107
|
+
export declare function effectiveActions(reg: ActionDeclaration): readonly string[];
|
|
108
|
+
export {};
|
|
73
109
|
//# 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;AAEH,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAOrE,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE/E,gGAAgG;AAChG,MAAM,MAAM,oBAAoB,GAAG,IAAI,CAAC,gBAAgB,EAAE,KAAK,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,CAAC,CAAC;AAOlG;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,oBAAoB,GAAG,SAAS,GACxC,UAAU,CAuCZ;AAED,qBAAa,aAAa;IAOZ,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;IANrC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IAExD;;;OAGG;gBAC0B,OAAO,CAAC,EAAE,oBAAoB,YAAA;IAE3D;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IASjC,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;AAmDD,8FAA8F;AAC9F,KAAK,iBAAiB,GAAG,IAAI,CAAC,UAAU,EAAE,SAAS,GAAG,aAAa,CAAC,GAAG;IACrE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B,CAAC;AAgEF;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,SAAS,MAAM,EAAE,CAI1E"}
|
package/dist/registry.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
13
|
exports.TableRegistry = void 0;
|
|
14
|
+
exports.resolveObjectTypeSchema = resolveObjectTypeSchema;
|
|
14
15
|
exports.columnOf = columnOf;
|
|
15
16
|
exports.uuidPkColumn = uuidPkColumn;
|
|
16
17
|
exports.isOwned = isOwned;
|
|
@@ -20,12 +21,73 @@ exports.columnForRole = columnForRole;
|
|
|
20
21
|
exports.writeScopeClause = writeScopeClause;
|
|
21
22
|
exports.isCreateWalled = isCreateWalled;
|
|
22
23
|
exports.requireRole = requireRole;
|
|
24
|
+
exports.effectiveActions = effectiveActions;
|
|
25
|
+
const core_1 = require("@astram/core");
|
|
23
26
|
const errors_js_1 = require("./errors.js");
|
|
24
27
|
const types_js_1 = require("./types.js");
|
|
28
|
+
/** A resolved database name: unquoted identifier, MariaDB's 64-character limit. */
|
|
29
|
+
const RESOLVED_SCHEMA_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
30
|
+
const SCHEMA_KEYS = ['app', 'identity', 'authz', 'audit'];
|
|
31
|
+
/**
|
|
32
|
+
* Return `reg` with every {@link FRAMEWORK_SCHEMA} token in `schema` and
|
|
33
|
+
* `readScope.subquerySql` replaced by the resolved name (configurable-framework-db, spec 5.B).
|
|
34
|
+
* Pure: the input is not mutated. A literal schema or an absent one is returned unchanged.
|
|
35
|
+
*
|
|
36
|
+
* FAIL CLOSED (Shield #1): throws a `RegistrationError` when `schemas` is absent but a token is
|
|
37
|
+
* used, when a used token's resolved name is missing or not a plain identifier, or when any
|
|
38
|
+
* token-like text survives resolution. Every reader of an object type's schema outside the
|
|
39
|
+
* registry must call this before building SQL.
|
|
40
|
+
*/
|
|
41
|
+
function resolveObjectTypeSchema(reg, schemas) {
|
|
42
|
+
const usesToken = (text) => text !== undefined && text.includes(types_js_1.FRAMEWORK_SCHEMA_TOKEN_MARKER);
|
|
43
|
+
const subquerySql = reg.readScope?.kind === 'subtreeSubquery' ? reg.readScope.subquerySql : undefined;
|
|
44
|
+
if (!usesToken(reg.schema) && !usesToken(subquerySql))
|
|
45
|
+
return reg;
|
|
46
|
+
const nameFor = (key) => {
|
|
47
|
+
const name = schemas?.[key];
|
|
48
|
+
if (typeof name !== 'string' || !RESOLVED_SCHEMA_NAME.test(name)) {
|
|
49
|
+
throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': framework schema '${key}' is not resolved; ` +
|
|
50
|
+
`the engine needs the resolved framework database names (SHIELD_DB_NAMESPACE).`);
|
|
51
|
+
}
|
|
52
|
+
return name;
|
|
53
|
+
};
|
|
54
|
+
const leftover = (where) => new errors_js_1.RegistrationError(`registration '${reg.typeKey}': unresolved framework schema token in ${where}.`);
|
|
55
|
+
let out = reg;
|
|
56
|
+
if (usesToken(reg.schema)) {
|
|
57
|
+
// The schema is exactly one token, never a token inside a longer name.
|
|
58
|
+
const key = SCHEMA_KEYS.find((k) => types_js_1.FRAMEWORK_SCHEMA[k] === reg.schema);
|
|
59
|
+
if (key === undefined)
|
|
60
|
+
throw leftover('schema');
|
|
61
|
+
out = { ...out, schema: nameFor(key) };
|
|
62
|
+
}
|
|
63
|
+
if (usesToken(subquerySql) && reg.readScope?.kind === 'subtreeSubquery') {
|
|
64
|
+
let sql = reg.readScope.subquerySql;
|
|
65
|
+
for (const key of SCHEMA_KEYS) {
|
|
66
|
+
if (sql.includes(types_js_1.FRAMEWORK_SCHEMA[key]))
|
|
67
|
+
sql = sql.split(types_js_1.FRAMEWORK_SCHEMA[key]).join(nameFor(key));
|
|
68
|
+
}
|
|
69
|
+
if (usesToken(sql))
|
|
70
|
+
throw leftover('readScope.subquerySql');
|
|
71
|
+
out = { ...out, readScope: { ...reg.readScope, subquerySql: sql } };
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
25
75
|
class TableRegistry {
|
|
76
|
+
schemas;
|
|
26
77
|
byType = new Map();
|
|
27
|
-
/**
|
|
28
|
-
|
|
78
|
+
/**
|
|
79
|
+
* @param schemas the resolved framework database names `FRAMEWORK_SCHEMA` tokens resolve to.
|
|
80
|
+
* Absent ⇒ a registration that uses a token fails (fail closed); others are unaffected.
|
|
81
|
+
*/
|
|
82
|
+
constructor(schemas) {
|
|
83
|
+
this.schemas = schemas;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Register a table. Resolves `FRAMEWORK_SCHEMA` tokens first (an unresolved token throws),
|
|
87
|
+
* then throws on a duplicate typeKey or an invalid registration.
|
|
88
|
+
*/
|
|
89
|
+
register(input) {
|
|
90
|
+
const reg = resolveObjectTypeSchema(input, this.schemas);
|
|
29
91
|
validate(reg);
|
|
30
92
|
if (this.byType.has(reg.typeKey)) {
|
|
31
93
|
throw new errors_js_1.RegistrationError(`duplicate table registration for typeKey '${reg.typeKey}'`);
|
|
@@ -178,6 +240,70 @@ function validate(reg) {
|
|
|
178
240
|
throw new errors_js_1.RegistrationError(`registration '${reg.typeKey}': composite primary key needs >= 2 columns`);
|
|
179
241
|
}
|
|
180
242
|
validateAccess(reg);
|
|
243
|
+
validateActions(reg);
|
|
244
|
+
}
|
|
245
|
+
/** The CRUD vocabulary, in the `IDENTITY_ACTIONS` order (guard `identity-actions.ts`). */
|
|
246
|
+
const CRUD_ACTIONS = ['read', 'create', 'update', 'delete'];
|
|
247
|
+
/** Names no type may declare: the wildcard and the bare `@Classified` default (ADR-0038). */
|
|
248
|
+
const RESERVED_ACTIONS = new Set(['*', 'access']);
|
|
249
|
+
/** The narrowest action column (`perm_action`, `rpr_action`, `rs_action`) is VARCHAR(64). */
|
|
250
|
+
const MAX_ACTION_LENGTH = 64;
|
|
251
|
+
/**
|
|
252
|
+
* Boot-time validation of the declared action vocabulary (ADR-0038). Pure, no IO. Every
|
|
253
|
+
* failure is a `RegistrationError` naming the type and the action, so a bad name fails boot
|
|
254
|
+
* instead of silently denying at request time (Shield #2).
|
|
255
|
+
*/
|
|
256
|
+
function validateActions(reg) {
|
|
257
|
+
const type = reg.typeKey ?? '(unnamed)';
|
|
258
|
+
const crud = reg.crudActions;
|
|
259
|
+
if (crud !== undefined && typeof crud !== 'boolean') {
|
|
260
|
+
throw new errors_js_1.RegistrationError(`registration '${type}': crudActions must be a boolean, got ${typeof crud} (${String(crud)}).`);
|
|
261
|
+
}
|
|
262
|
+
const actions = reg.actions;
|
|
263
|
+
if (actions !== undefined && !Array.isArray(actions)) {
|
|
264
|
+
throw new errors_js_1.RegistrationError(`registration '${type}': actions must be an array of action names, got ${typeof actions}.`);
|
|
265
|
+
}
|
|
266
|
+
const list = actions ?? [];
|
|
267
|
+
if (crud === false && list.length === 0) {
|
|
268
|
+
throw new errors_js_1.RegistrationError(`registration '${type}': crudActions:false requires a non-empty actions list ` +
|
|
269
|
+
`(a type with no actions could never be granted).`);
|
|
270
|
+
}
|
|
271
|
+
const seen = new Set();
|
|
272
|
+
for (const a of list) {
|
|
273
|
+
if (typeof a !== 'string') {
|
|
274
|
+
throw new errors_js_1.RegistrationError(`registration '${type}': action ${String(a)} must be a string, got ${typeof a}.`);
|
|
275
|
+
}
|
|
276
|
+
if (RESERVED_ACTIONS.has(a)) {
|
|
277
|
+
throw new errors_js_1.RegistrationError(`registration '${type}': action '${a}' is reserved ('*' is the wildcard, 'access' the ` +
|
|
278
|
+
`@Classified default).`);
|
|
279
|
+
}
|
|
280
|
+
if (a.length > MAX_ACTION_LENGTH) {
|
|
281
|
+
throw new errors_js_1.RegistrationError(`registration '${type}': action '${a}' is longer than ${MAX_ACTION_LENGTH} characters.`);
|
|
282
|
+
}
|
|
283
|
+
if (!core_1.SEGMENT_FORMAT.test(a)) {
|
|
284
|
+
throw new errors_js_1.RegistrationError(`registration '${type}': action '${a}' must be snake_case (${String(core_1.SEGMENT_FORMAT)}).`);
|
|
285
|
+
}
|
|
286
|
+
if (crud !== false && CRUD_ACTIONS.includes(a)) {
|
|
287
|
+
throw new errors_js_1.RegistrationError(`registration '${type}': action '${a}' is a CRUD verb, already granted by default; ` +
|
|
288
|
+
`list it only with crudActions:false.`);
|
|
289
|
+
}
|
|
290
|
+
if (seen.has(a)) {
|
|
291
|
+
throw new errors_js_1.RegistrationError(`registration '${type}': duplicate action '${a}'.`);
|
|
292
|
+
}
|
|
293
|
+
seen.add(a);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The type's action vocabulary (ADR-0038): `read, create, update, delete` then the declared
|
|
298
|
+
* extras in declared order, or exactly the declared list when `crudActions === false`.
|
|
299
|
+
* Validates first (throws `RegistrationError`), so a registration read without
|
|
300
|
+
* `TableRegistry.register` (e.g. identity `extraObjects`) is held to the same rules.
|
|
301
|
+
* Framework-facing: the guard registry bridge, identity lookups and `authorize()` read it.
|
|
302
|
+
*/
|
|
303
|
+
function effectiveActions(reg) {
|
|
304
|
+
validateActions(reg);
|
|
305
|
+
const declared = reg.actions ?? [];
|
|
306
|
+
return reg.crudActions === false ? [...declared] : [...CRUD_ACTIONS, ...declared];
|
|
181
307
|
}
|
|
182
308
|
/**
|
|
183
309
|
* Chokepoint 1 (§4.1 of the framework-owns-data-layer design). Every registered
|