@astram/engine 0.3.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.
Files changed (57) hide show
  1. package/dist/decorators/access-bundle.decorator.d.ts.map +1 -1
  2. package/dist/decorators/object-type.decorator.d.ts +2 -0
  3. package/dist/decorators/object-type.decorator.d.ts.map +1 -1
  4. package/dist/decorators/object-type.decorator.js +3 -0
  5. package/dist/index.d.ts +3 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/pipe/context.d.ts +17 -6
  8. package/dist/pipe/context.d.ts.map +1 -1
  9. package/dist/pipe/record-engine.d.ts +37 -5
  10. package/dist/pipe/record-engine.d.ts.map +1 -1
  11. package/dist/pipe/record-engine.js +206 -75
  12. package/dist/pipe/records-service.d.ts +18 -1
  13. package/dist/pipe/records-service.d.ts.map +1 -1
  14. package/dist/pipe/records-service.js +42 -6
  15. package/dist/pipe/stages/audit.d.ts.map +1 -1
  16. package/dist/pipe/stages/derive-write-context.d.ts.map +1 -1
  17. package/dist/pipe/stages/derive-write-context.js +6 -0
  18. package/dist/pipe/stages/fetch-record.d.ts +54 -0
  19. package/dist/pipe/stages/fetch-record.d.ts.map +1 -0
  20. package/dist/pipe/stages/fetch-record.js +101 -0
  21. package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
  22. package/dist/pipe/stages/permission-gate.js +17 -9
  23. package/dist/pipe/stages/write.d.ts.map +1 -1
  24. package/dist/pipe/stages/write.js +75 -52
  25. package/dist/pipe/where-by-id.d.ts +33 -0
  26. package/dist/pipe/where-by-id.d.ts.map +1 -0
  27. package/dist/pipe/where-by-id.js +69 -0
  28. package/dist/registry.d.ts +8 -1
  29. package/dist/registry.d.ts.map +1 -1
  30. package/dist/registry.js +25 -1
  31. package/dist/types.d.ts +59 -3
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/dist/adapters/mariadb-audit-sink.js.map +0 -1
  35. package/dist/adapters/mariadb-unit-of-work.js.map +0 -1
  36. package/dist/decorators/access-bundle.decorator.js.map +0 -1
  37. package/dist/decorators/object-type.decorator.js.map +0 -1
  38. package/dist/errors.js.map +0 -1
  39. package/dist/index.js.map +0 -1
  40. package/dist/module/engine.module.js.map +0 -1
  41. package/dist/module/tokens.js.map +0 -1
  42. package/dist/pipe/context.js.map +0 -1
  43. package/dist/pipe/record-engine.js.map +0 -1
  44. package/dist/pipe/records-service.js.map +0 -1
  45. package/dist/pipe/stages/audit.js.map +0 -1
  46. package/dist/pipe/stages/derive-write-context.js.map +0 -1
  47. package/dist/pipe/stages/fetch-owner.d.ts +0 -28
  48. package/dist/pipe/stages/fetch-owner.d.ts.map +0 -1
  49. package/dist/pipe/stages/fetch-owner.js +0 -99
  50. package/dist/pipe/stages/fetch-owner.js.map +0 -1
  51. package/dist/pipe/stages/junction-sync.js.map +0 -1
  52. package/dist/pipe/stages/permission-gate.js.map +0 -1
  53. package/dist/pipe/stages/validate.js.map +0 -1
  54. package/dist/pipe/stages/write.js.map +0 -1
  55. package/dist/ports.js.map +0 -1
  56. package/dist/registry.js.map +0 -1
  57. package/dist/types.js.map +0 -1
@@ -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
- /** The `.as(...)` bound view — same 5 ops, fixed actor. */
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,EAAE,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAG1G,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;IACS,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC;gBAFvC,MAAM,EAAE,YAAY,EAC1B,GAAG,EAAE;QAAE,SAAS,CAAC,EAAE,SAAS,CAAA;KAAE,EACD,eAAe,CAAC,EAAE,sBAAsB,YAAA;IAWnG,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;IAI1D;;;;OAIG;IACH,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY;IAKzC,OAAO,CAAC,KAAK;IAWb,OAAO,CAAC,qBAAqB;CAS9B;AAED,2DAA2D;AAC3D,qBAAa,YAAY;IAErB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS;IAEnC,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAC9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAC9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAC5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IACxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;CACrD"}
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
- /** The `.as(...)` bound view — same 5 ops, fixed actor. */
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
- create(typeKey, input) { return this.engine.create(typeKey, input, this.actor); }
103
- get(typeKey, id) { return this.engine.get(typeKey, id, this.actor); }
104
- list(typeKey, query) { return this.engine.list(typeKey, query, this.actor); }
105
- update(typeKey, id, input) { return this.engine.update(typeKey, id, input, this.actor); }
106
- delete(typeKey, id) { return this.engine.delete(typeKey, id, this.actor); }
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,CA0BlD"}
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,CA4B/C"}
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,54 @@
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.
4
+ *
5
+ * Core's decision authority is zero-dep (no DB), so it cannot read the record it judges. This
6
+ * stage reads the EXISTING row, inside the pipe's unit of work, with
7
+ *
8
+ * SELECT * FROM <table> WHERE <whereById> LIMIT 1 FOR UPDATE
9
+ *
10
+ * through the SAME `whereById` the write stage uses (pk + actor-tenant clause + ADR-0030 write
11
+ * scope), and stashes:
12
+ * - `ctx.existing` — the row projected to registered field keys (the PRE-image, D9),
13
+ * - `ctx.ownerId` — the `trusted:'actorId'` column, when it is a string,
14
+ * - `ctx.recordTenantId` — the `trusted:'tenantId'` column, when it is a string.
15
+ * The gate passes `attributes = { ...existing, ownerId }` and `resource.tenantId =
16
+ * recordTenantId ?? actor.tenantId` to `decide()`, so field-conditioned rules (e.g. deny
17
+ * `status eq locked`) fire on update/delete exactly as they do in `list()`.
18
+ *
19
+ * ── SECURITY (fail-closed, no existence oracle):
20
+ * - No row ⇒ `AuthorizationDeniedError('no_record_permit')`, thrown inside the transaction
21
+ * (rollback: no write, no junction sync, no audit). A missing id and a foreign-tenant or
22
+ * foreign-subtree id both match zero rows here, so they are the same error. It is also the
23
+ * reason core returns for an owned record the actor does not own, which is what the
24
+ * previous owner pre-fetch produced for a missing owned row.
25
+ * - `FOR UPDATE` row-locks the pre-image until commit, so the row the gate judged is the row
26
+ * the write changes (no TOCTOU between gate and write).
27
+ * - The row is never returned to the caller; only the gate sees it.
28
+ * - `create` no-ops: there is no existing row (the create wall is ADR-0034, in `write.ts`).
29
+ */
30
+ import { AuthorizationDeniedError } from '../../errors.js';
31
+ import type { ObjectType } from '../../types.js';
32
+ import type { Stage } from '../context.js';
33
+ /** The decision-relevant facts of one fetched row. */
34
+ export interface RecordFacts {
35
+ /** Registered fields present in the row, keyed by FIELD KEY (never the column). */
36
+ readonly fields: Record<string, unknown>;
37
+ /** The `trusted:'actorId'` column value, when the type has one and it is a string. */
38
+ readonly ownerId?: string;
39
+ /** The `trusted:'tenantId'` column value, when the type has one and it is a string. */
40
+ readonly tenantId?: string;
41
+ }
42
+ /** Project a raw row to its decision facts (shared by the write pre-fetch and `get()`). */
43
+ export declare function recordFacts(reg: ObjectType, row: Record<string, unknown>): RecordFacts;
44
+ /**
45
+ * The `context.attributes` bag for `decide()`: every registered field by key, plus `ownerId`
46
+ * (the owner baseline's input). When the type has an owner column, `ownerId` is that column's
47
+ * string value or absent: a null/non-string owner is never passed, so core's strict `===` owner
48
+ * match can never spuriously succeed.
49
+ */
50
+ export declare function decisionAttributes(reg: ObjectType, facts: Pick<RecordFacts, 'fields' | 'ownerId'>): Record<string, unknown>;
51
+ /** The ONE refusal for a record the actor cannot reach (missing ≡ foreign; no ids). */
52
+ export declare function recordNotReachable(reg: ObjectType): AuthorizationDeniedError;
53
+ export declare function fetchRecordStage(): Stage;
54
+ //# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;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"}
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ /**
3
+ * Stage: fetch-record (update/delete on EVERY type — runs BEFORE the permission gate).
4
+ * Security hardening block D (#10); replaces the owner-only `fetch-owner` stage.
5
+ *
6
+ * Core's decision authority is zero-dep (no DB), so it cannot read the record it judges. This
7
+ * stage reads the EXISTING row, inside the pipe's unit of work, with
8
+ *
9
+ * SELECT * FROM <table> WHERE <whereById> LIMIT 1 FOR UPDATE
10
+ *
11
+ * through the SAME `whereById` the write stage uses (pk + actor-tenant clause + ADR-0030 write
12
+ * scope), and stashes:
13
+ * - `ctx.existing` — the row projected to registered field keys (the PRE-image, D9),
14
+ * - `ctx.ownerId` — the `trusted:'actorId'` column, when it is a string,
15
+ * - `ctx.recordTenantId` — the `trusted:'tenantId'` column, when it is a string.
16
+ * The gate passes `attributes = { ...existing, ownerId }` and `resource.tenantId =
17
+ * recordTenantId ?? actor.tenantId` to `decide()`, so field-conditioned rules (e.g. deny
18
+ * `status eq locked`) fire on update/delete exactly as they do in `list()`.
19
+ *
20
+ * ── SECURITY (fail-closed, no existence oracle):
21
+ * - No row ⇒ `AuthorizationDeniedError('no_record_permit')`, thrown inside the transaction
22
+ * (rollback: no write, no junction sync, no audit). A missing id and a foreign-tenant or
23
+ * foreign-subtree id both match zero rows here, so they are the same error. It is also the
24
+ * reason core returns for an owned record the actor does not own, which is what the
25
+ * previous owner pre-fetch produced for a missing owned row.
26
+ * - `FOR UPDATE` row-locks the pre-image until commit, so the row the gate judged is the row
27
+ * the write changes (no TOCTOU between gate and write).
28
+ * - The row is never returned to the caller; only the gate sees it.
29
+ * - `create` no-ops: there is no existing row (the create wall is ADR-0034, in `write.ts`).
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.recordFacts = recordFacts;
33
+ exports.decisionAttributes = decisionAttributes;
34
+ exports.recordNotReachable = recordNotReachable;
35
+ exports.fetchRecordStage = fetchRecordStage;
36
+ const errors_js_1 = require("../../errors.js");
37
+ const registry_js_1 = require("../../registry.js");
38
+ const where_by_id_js_1 = require("../where-by-id.js");
39
+ /** Project a raw row to its decision facts (shared by the write pre-fetch and `get()`). */
40
+ function recordFacts(reg, row) {
41
+ const fields = {};
42
+ for (const f of reg.fields) {
43
+ const col = (0, registry_js_1.columnOf)(f);
44
+ if (Object.prototype.hasOwnProperty.call(row, col))
45
+ fields[f.key] = row[col];
46
+ }
47
+ const ownerCol = (0, registry_js_1.ownerColumn)(reg);
48
+ const owner = ownerCol ? row[ownerCol] : undefined;
49
+ const tenantCol = (0, registry_js_1.tenantColumnOf)(reg);
50
+ const tenant = tenantCol ? row[tenantCol] : undefined;
51
+ return {
52
+ fields,
53
+ ...(typeof owner === 'string' ? { ownerId: owner } : {}),
54
+ ...(typeof tenant === 'string' ? { tenantId: tenant } : {}),
55
+ };
56
+ }
57
+ /**
58
+ * The `context.attributes` bag for `decide()`: every registered field by key, plus `ownerId`
59
+ * (the owner baseline's input). When the type has an owner column, `ownerId` is that column's
60
+ * string value or absent: a null/non-string owner is never passed, so core's strict `===` owner
61
+ * match can never spuriously succeed.
62
+ */
63
+ function decisionAttributes(reg, facts) {
64
+ const attrs = { ...facts.fields };
65
+ if ((0, registry_js_1.ownerColumn)(reg) !== null) {
66
+ if (facts.ownerId !== undefined)
67
+ attrs.ownerId = facts.ownerId;
68
+ else
69
+ delete attrs.ownerId;
70
+ }
71
+ return attrs;
72
+ }
73
+ /** The ONE refusal for a record the actor cannot reach (missing ≡ foreign; no ids). */
74
+ function recordNotReachable(reg) {
75
+ return new errors_js_1.AuthorizationDeniedError('no_record_permit', `no reachable '${reg.typeKey}' record for this operation`);
76
+ }
77
+ function fetchRecordStage() {
78
+ return {
79
+ name: 'fetch-record',
80
+ async run(ctx) {
81
+ if (ctx.op === 'create')
82
+ return;
83
+ if (ctx.id === undefined)
84
+ throw new errors_js_1.RegistrationError(`${ctx.op} requires an id`);
85
+ const reg = ctx.registration;
86
+ const where = (0, where_by_id_js_1.whereById)(reg, ctx.actor, ctx.id);
87
+ const rows = (await ctx.query(`SELECT * FROM ${(0, where_by_id_js_1.qualifiedTable)(reg)} WHERE ${where.sql} LIMIT 1 FOR UPDATE`, where.params));
88
+ const first = Array.isArray(rows)
89
+ ? rows[0]
90
+ : undefined;
91
+ if (first === undefined || first === null)
92
+ throw recordNotReachable(reg);
93
+ const facts = recordFacts(reg, first);
94
+ ctx.existing = facts.fields;
95
+ if (facts.ownerId !== undefined)
96
+ ctx.ownerId = facts.ownerId;
97
+ if (facts.tenantId !== undefined)
98
+ ctx.recordTenantId = facts.tenantId;
99
+ },
100
+ };
101
+ }
@@ -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;AAEnD,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAOlE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,YAAY,GAAG,KAAK,CAwCjE"}
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;AAEnD,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAQlE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,YAAY,GAAG,KAAK,CA+CjE"}
@@ -9,6 +9,7 @@
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.permissionGateStage = permissionGateStage;
11
11
  const errors_js_1 = require("../../errors.js");
12
+ const fetch_record_js_1 = require("./fetch-record.js");
12
13
  /** Map the pipe op to the stable authorization action key. */
13
14
  function actionKey(op) {
14
15
  return op; // 'create' | 'update' | 'delete' — stable action keys (core §5.2)
@@ -21,19 +22,26 @@ function permissionGateStage(decision) {
21
22
  const resource = {
22
23
  level: ctx.id !== undefined ? 'record' : 'object_type',
23
24
  typeKey: ctx.registration.typeKey,
24
- // The resource's owning tenant is the ACTOR's tenant for a write — a write
25
- // into another tenant is denied by the tenant gate downstream in decide().
26
- tenantId: ctx.actor.tenantId,
25
+ // Block D (#10): an existing record is judged at ITS tenant (read from the row by
26
+ // fetch-record). A create, or a type with no tenant column, uses the actor's tenant — a
27
+ // write into another tenant is denied by the tenant gate downstream in decide().
28
+ tenantId: ctx.recordTenantId ?? ctx.actor.tenantId,
27
29
  ...(typeof ctx.id === 'string' ? { recordId: ctx.id } : {}),
28
30
  };
29
- // For an OWNED update/delete, the fetch-owner stage (which ran BEFORE this one)
30
- // read the existing row's owner tenant-scoped and stashed it on ctx.ownerId. Pass
31
- // it to decide() so core's owner baseline can compare it to the actor. Absent (not
32
- // owned, or the row is missing/other-tenant) ⇒ no attribute ⇒ the baseline finds
33
- // no owner match ⇒ deny (the missing row never leaks).
31
+ // Block D (#10): for update/delete, fetch-record (which ran BEFORE this one) read the
32
+ // existing row. Every registered field (the PRE-image, D9) plus `ownerId` reaches
33
+ // decide(), so field-conditioned rules (e.g. deny `status eq locked`) fire here exactly
34
+ // as they do in list(). A missing row never reaches this stage: fetch-record threw.
34
35
  const context = {
35
36
  isAuthenticated: true,
36
- ...(ctx.ownerId !== undefined ? { attributes: { ownerId: ctx.ownerId } } : {}),
37
+ ...(ctx.existing !== undefined
38
+ ? {
39
+ attributes: (0, fetch_record_js_1.decisionAttributes)(ctx.registration, {
40
+ fields: ctx.existing,
41
+ ...(ctx.ownerId !== undefined ? { ownerId: ctx.ownerId } : {}),
42
+ }),
43
+ }
44
+ : {}),
37
45
  };
38
46
  let outcome;
39
47
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/write.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAkEzD,wBAAgB,UAAU,IAAI,KAAK,CAkDlC"}
1
+ {"version":3,"file":"write.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/write.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAsEzD,wBAAgB,UAAU,IAAI,KAAK,CAsElC"}
@@ -13,62 +13,65 @@ exports.writeStage = writeStage;
13
13
  const node_crypto_1 = require("node:crypto");
14
14
  const errors_js_1 = require("../../errors.js");
15
15
  const registry_js_1 = require("../../registry.js");
16
+ const where_by_id_js_1 = require("../where-by-id.js");
17
+ const fetch_record_js_1 = require("./fetch-record.js");
16
18
  function qualified(ctx) {
17
- const schema = ctx.registration.schema;
18
- const t = ctx.registration.table;
19
- return schema ? `\`${schema}\`.\`${t}\`` : `\`${t}\``;
20
- }
21
- /** The physical column of a `tenantId`-trusted field, if the table has one. */
22
- function tenantColumn(ctx) {
23
- const f = ctx.registration.fields.find((x) => x.trusted === 'tenantId');
24
- return f ? (0, registry_js_1.columnOf)(f) : null;
19
+ return (0, where_by_id_js_1.qualifiedTable)(ctx.registration);
25
20
  }
26
21
  /**
27
- * Build a WHERE clause + params for a record id (uuid or composite). CRITICAL
28
- * cross-tenant safety: when the table is tenant-owned (has a `tenantId`-trusted
29
- * column), the WHERE is ALSO constrained to the ACTOR's tenant — so an update/
30
- * delete can NEVER touch another tenant's row even if the id is known and the gate
31
- * (which sees the actor's own tenant on a record-scoped resource) would allow it.
32
- * This is the write-path twin of the read-path scoping (defense-in-depth).
33
- *
34
- * ADR-0030 — write-scope wall: additionally, for a type declaring `readScope` (the identity
35
- * `system` types company_membership / membership_posting, which have NO tenant column so the
36
- * clause above never fires for them), a NON-ROOT actor's WHERE is constrained to rows within the
37
- * actor's reachable subtree (`writeScopeClause`). A targeted update/delete of a foreign-subtree
38
- * row then matches ZERO rows (fail-closed no-op), closing the read-wall-only asymmetry. Root
39
- * spans all. This is a FILTER, never a stamp (create is unaffected — the ADR-0027 decoupling
40
- * that lets an admin add a user to a SUB-company is preserved).
22
+ * WHERE for a record id: the ONE shared helper (`where-by-id.ts`) — pk + the ACTOR's tenant
23
+ * (when the type has a tenant column) + the ADR-0030 write-scope wall (readScope types,
24
+ * non-root). The fetch-record pre-fetch, UPDATE, DELETE and the ADR-0034 create wall all use it,
25
+ * so the row the gate judged is the row the write touches.
41
26
  */
42
27
  function whereById(ctx, id) {
43
- const pk = ctx.registration.primaryKey;
44
- let sql;
45
- let params;
46
- if (pk.kind === 'uuid') {
47
- if (typeof id !== 'string') {
48
- throw new errors_js_1.RegistrationError(`uuid PK table '${ctx.registration.typeKey}' needs a string id`);
49
- }
50
- sql = `\`${pk.column}\` = ?`;
51
- params = [id];
52
- }
53
- else {
54
- if (typeof id === 'string') {
55
- throw new errors_js_1.RegistrationError(`composite PK table '${ctx.registration.typeKey}' needs a key map, not a string id`);
56
- }
57
- sql = pk.columns.map((c) => `\`${c}\` = ?`).join(' AND ');
58
- params = pk.columns.map((c) => id[c]);
59
- }
60
- const tenantCol = tenantColumn(ctx);
61
- if (tenantCol) {
62
- sql += ` AND \`${tenantCol}\` = ?`;
63
- params = [...params, ctx.actor.tenantId];
64
- }
65
- // ADR-0030 write-scope wall (readScope types, non-root only; null otherwise).
66
- const scope = (0, registry_js_1.writeScopeClause)(ctx.registration, ctx.actor);
67
- if (scope) {
68
- sql += ` AND ${scope.sql}`;
69
- params = [...params, ...scope.params];
28
+ return (0, where_by_id_js_1.whereById)(ctx.registration, ctx.actor, id);
29
+ }
30
+ /** `affectedRows` of a driver OK packet, or `undefined` when the result carries none. */
31
+ function affectedRowsOf(result) {
32
+ if (typeof result !== 'object' || result === null)
33
+ return undefined;
34
+ const n = result.affectedRows;
35
+ if (typeof n === 'number')
36
+ return n;
37
+ if (typeof n === 'bigint')
38
+ return Number(n);
39
+ return undefined;
40
+ }
41
+ /**
42
+ * ADR-0034 — the CREATE-time write-scope wall (amends ADR-0030, which walled update/delete only).
43
+ * For a `readScope` type that has not opted out (`create: 'unscoped'`), a NON-ROOT create is
44
+ * re-read inside the SAME unit of work right after the INSERT, through the SAME `whereById`
45
+ * (pk + tenant clause + `writeScopeClause`) that update/delete use. Zero rows ⇒ the new row lies
46
+ * outside the actor's reachable subtree ⇒ `AuthorizationDeniedError`, and the pipe's transaction
47
+ * rolls the INSERT back (no row, no audit). A post-insert check — rather than inspecting input
48
+ * values — covers both `nodeColumn` and `subtreeSubquery` scopes with one predicate and sees the
49
+ * row exactly as stored. Root (`writeScopeClause` ⇒ null) is not checked. The error carries no
50
+ * ids (no existence leak, Invariant #10).
51
+ */
52
+ async function assertCreateInsideWriteScope(ctx, table) {
53
+ if (!(0, registry_js_1.isCreateWalled)(ctx.registration))
54
+ return;
55
+ if ((0, registry_js_1.writeScopeClause)(ctx.registration, ctx.actor) === null)
56
+ return; // root spans all
57
+ if (ctx.resultId === undefined) {
58
+ throw new errors_js_1.RegistrationError(`create '${ctx.registration.typeKey}': no id to wall-check`);
70
59
  }
71
- return { sql, params };
60
+ const where = whereById(ctx, ctx.resultId);
61
+ const rows = (await ctx.query(`SELECT 1 AS in_scope FROM ${table} WHERE ${where.sql} LIMIT 1`, where.params));
62
+ if (!Array.isArray(rows) || rows.length === 0)
63
+ throw outsideWriteScope(ctx);
64
+ }
65
+ /** The ONE create-wall refusal (same reason, same message for every cause — no oracle). */
66
+ function outsideWriteScope(ctx) {
67
+ return new errors_js_1.AuthorizationDeniedError('create_outside_write_scope', `a new '${ctx.registration.typeKey}' must lie inside the actor's reachable subtree`);
68
+ }
69
+ /** A MariaDB/MySQL "referenced row does not exist" foreign-key violation (errno 1452). */
70
+ function isMissingReferenceError(err) {
71
+ if (typeof err !== 'object' || err === null)
72
+ return false;
73
+ const e = err;
74
+ return (e.errno === 1452 || e.code === 'ER_NO_REFERENCED_ROW_2' || e.code === 'ER_NO_REFERENCED_ROW');
72
75
  }
73
76
  function writeStage() {
74
77
  return {
@@ -96,7 +99,22 @@ function writeStage() {
96
99
  }
97
100
  const placeholders = cols.map(() => '?').join(', ');
98
101
  const sql = `INSERT INTO ${table} (${cols.map((c) => `\`${c}\``).join(', ')}) VALUES (${placeholders})`;
99
- await ctx.query(sql, cols.map((c) => ctx.columns[c]));
102
+ try {
103
+ await ctx.query(sql, cols.map((c) => ctx.columns[c]));
104
+ }
105
+ catch (err) {
106
+ // Existence oracle (security review): on a WALLED non-root create, a reference to a
107
+ // node that does not exist fails the foreign key BEFORE the wall check runs. Answer it
108
+ // exactly like a node outside the subtree, so "missing" and "foreign" look the same.
109
+ // Root and unwalled types keep the driver error.
110
+ if (isMissingReferenceError(err) &&
111
+ (0, registry_js_1.isCreateWalled)(ctx.registration) &&
112
+ (0, registry_js_1.writeScopeClause)(ctx.registration, ctx.actor) !== null) {
113
+ throw outsideWriteScope(ctx);
114
+ }
115
+ throw err;
116
+ }
117
+ await assertCreateInsideWriteScope(ctx, table);
100
118
  return;
101
119
  }
102
120
  if (ctx.op === 'update') {
@@ -116,7 +134,12 @@ function writeStage() {
116
134
  if (ctx.id === undefined)
117
135
  throw new errors_js_1.RegistrationError('delete requires an id');
118
136
  const where = whereById(ctx, ctx.id);
119
- await ctx.query(`DELETE FROM ${table} WHERE ${where.sql}`, where.params);
137
+ const result = await ctx.query(`DELETE FROM ${table} WHERE ${where.sql}`, where.params);
138
+ // Review H2: a delete that removed nothing must not go on to junction sync or audit. The
139
+ // fetch-record stage already row-locked the target, so this is defence in depth; it throws
140
+ // the same no-id refusal as a missing row (rollback).
141
+ if (affectedRowsOf(result) === 0)
142
+ throw (0, fetch_record_js_1.recordNotReachable)(ctx.registration);
120
143
  ctx.resultId = ctx.id;
121
144
  },
122
145
  };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The ONE definition of "this record, as the actor may write it" (security hardening block D).
3
+ *
4
+ * Before this file the fetch-owner stage and the write stage each carried a private copy of
5
+ * `whereById`. The record pre-fetch (`fetch-record.ts`), the UPDATE/DELETE and the ADR-0034
6
+ * post-insert create wall now all build their WHERE here, so the row the gate judges is exactly
7
+ * the row the write touches.
8
+ *
9
+ * WHERE = primary key
10
+ * + `tenant_col = actor.tenantId` when the type has a `trusted:'tenantId'` column
11
+ * + `writeScopeClause` (ADR-0030) for a `readScope` type and a non-root actor.
12
+ *
13
+ * Every identifier is the type's own registered column; every value is `?`-bound.
14
+ */
15
+ import type { ObjectType, Principal, RecordId } from '../types.js';
16
+ /** A parameterized SQL fragment. */
17
+ export interface SqlFragment {
18
+ readonly sql: string;
19
+ readonly params: unknown[];
20
+ }
21
+ /** The `schema`.`table` (or `table`) identifier of a registered type. */
22
+ export declare function qualifiedTable(reg: ObjectType): string;
23
+ /** The primary-key predicate for a record id (uuid or composite). Throws on a shape mismatch. */
24
+ export declare function pkClause(reg: ObjectType, id: RecordId): SqlFragment;
25
+ /** The primary-key column(s) of a type, in declaration order (the list tiebreaker). */
26
+ export declare function pkColumns(reg: ObjectType): readonly string[];
27
+ /**
28
+ * WHERE (pk + actor-tenant clause + ADR-0030 write-scope wall) for a WRITE-path record id. A
29
+ * foreign-tenant or foreign-subtree id matches zero rows, exactly like a missing one, so the two
30
+ * are indistinguishable to the caller.
31
+ */
32
+ export declare function whereById(reg: ObjectType, actor: Principal, id: RecordId): SqlFragment;
33
+ //# sourceMappingURL=where-by-id.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"where-by-id.d.ts","sourceRoot":"","sources":["../../src/pipe/where-by-id.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEnE,oCAAoC;AACpC,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;CAC5B;AAED,yEAAyE;AACzE,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,CAEtD;AAED,iGAAiG;AACjG,wBAAgB,QAAQ,CAAC,GAAG,EAAE,UAAU,EAAE,EAAE,EAAE,QAAQ,GAAG,WAAW,CAiBnE;AAED,uFAAuF;AACvF,wBAAgB,SAAS,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,MAAM,EAAE,CAG5D;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,UAAU,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,QAAQ,GAAG,WAAW,CAetF"}
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /**
3
+ * The ONE definition of "this record, as the actor may write it" (security hardening block D).
4
+ *
5
+ * Before this file the fetch-owner stage and the write stage each carried a private copy of
6
+ * `whereById`. The record pre-fetch (`fetch-record.ts`), the UPDATE/DELETE and the ADR-0034
7
+ * post-insert create wall now all build their WHERE here, so the row the gate judges is exactly
8
+ * the row the write touches.
9
+ *
10
+ * WHERE = primary key
11
+ * + `tenant_col = actor.tenantId` when the type has a `trusted:'tenantId'` column
12
+ * + `writeScopeClause` (ADR-0030) for a `readScope` type and a non-root actor.
13
+ *
14
+ * Every identifier is the type's own registered column; every value is `?`-bound.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.qualifiedTable = qualifiedTable;
18
+ exports.pkClause = pkClause;
19
+ exports.pkColumns = pkColumns;
20
+ exports.whereById = whereById;
21
+ const errors_js_1 = require("../errors.js");
22
+ const registry_js_1 = require("../registry.js");
23
+ /** The `schema`.`table` (or `table`) identifier of a registered type. */
24
+ function qualifiedTable(reg) {
25
+ return reg.schema ? `\`${reg.schema}\`.\`${reg.table}\`` : `\`${reg.table}\``;
26
+ }
27
+ /** The primary-key predicate for a record id (uuid or composite). Throws on a shape mismatch. */
28
+ function pkClause(reg, id) {
29
+ const pk = reg.primaryKey;
30
+ if (pk.kind === 'uuid') {
31
+ if (typeof id !== 'string') {
32
+ throw new errors_js_1.RegistrationError(`uuid PK table '${reg.typeKey}' needs a string id`);
33
+ }
34
+ return { sql: `\`${pk.column}\` = ?`, params: [id] };
35
+ }
36
+ if (typeof id === 'string') {
37
+ throw new errors_js_1.RegistrationError(`composite PK table '${reg.typeKey}' needs a key map, not a string id`);
38
+ }
39
+ return {
40
+ sql: pk.columns.map((c) => `\`${c}\` = ?`).join(' AND '),
41
+ params: pk.columns.map((c) => id[c]),
42
+ };
43
+ }
44
+ /** The primary-key column(s) of a type, in declaration order (the list tiebreaker). */
45
+ function pkColumns(reg) {
46
+ const pk = reg.primaryKey;
47
+ return pk.kind === 'uuid' ? [pk.column] : pk.columns;
48
+ }
49
+ /**
50
+ * WHERE (pk + actor-tenant clause + ADR-0030 write-scope wall) for a WRITE-path record id. A
51
+ * foreign-tenant or foreign-subtree id matches zero rows, exactly like a missing one, so the two
52
+ * are indistinguishable to the caller.
53
+ */
54
+ function whereById(reg, actor, id) {
55
+ const pk = pkClause(reg, id);
56
+ let sql = pk.sql;
57
+ let params = pk.params;
58
+ const tenantCol = (0, registry_js_1.tenantColumnOf)(reg);
59
+ if (tenantCol) {
60
+ sql += ` AND \`${tenantCol}\` = ?`;
61
+ params = [...params, actor.tenantId];
62
+ }
63
+ const scope = (0, registry_js_1.writeScopeClause)(reg, actor);
64
+ if (scope) {
65
+ sql += ` AND ${scope.sql}`;
66
+ params = [...params, ...scope.params];
67
+ }
68
+ return { sql, params };
69
+ }