@feasibleone/blong-gogo 1.32.0 → 1.33.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.33.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.32.0...blong-gogo-v1.33.0) (2026-09-15)
4
+
5
+
6
+ ### Features
7
+
8
+ * **blong-dev:** keep frictions, todos and decisions as verified memory files ([4ca7a3b](https://github.com/feasibleone/blong/commit/4ca7a3be1f8919e0dc121974d3d5ad713883c456))
9
+
3
10
  ## [1.32.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.31.3...blong-gogo-v1.32.0) (2026-09-12)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -90,7 +90,7 @@
90
90
  "tap": "^21.6.3",
91
91
  "typescript": "^6.0.3",
92
92
  "vite": "^8.2.1",
93
- "@feasibleone/blong-dev": "1.3.0"
93
+ "@feasibleone/blong-dev": "1.4.0"
94
94
  },
95
95
  "scripts": {
96
96
  "browser-check": "node scripts/browser-compat-check.mjs",
package/src/Watch.ts CHANGED
@@ -465,6 +465,10 @@ export default class Watch extends Internal implements IWatch {
465
465
  this.#config.enabled ? filename + '?' + Date.now() : filename
466
466
  )
467
467
  ).default;
468
+ // A module without a default export is a plain module shared by
469
+ // the group's handlers (e.g. `account.ts`, `accessModel.ts`), not
470
+ // a handler — the loader ignores it.
471
+ if (!item) return (api: T) => api;
468
472
  const expectedName = this.#platform
469
473
  .basename(filename, this.#platform.extname(filename))
470
474
  .match(prefixRE)?.[1];
@@ -78,6 +78,82 @@ export interface IEdgeBinding {
78
78
  reverse?: boolean;
79
79
  }
80
80
 
81
+ /**
82
+ * Resource-backed display-name override.
83
+ *
84
+ * By default the display name of a resource-backed row is the virtual
85
+ * `${object}Name` field. A table whose real display column is named differently
86
+ * (e.g. `party_organization.legalName`) declares it here: the `core_resource` row
87
+ * is then named from that column — and renamed when the column changes — and no
88
+ * virtual `${object}Name` field is synthesised (the real column *is* the label).
89
+ */
90
+ export interface IResourceTableSpec {
91
+ /** Real column of the entity table that supplies `core_resource.resourceName`. */
92
+ nameColumn?: string;
93
+ }
94
+
95
+ /**
96
+ * Record-level (ACL) guard for a table.
97
+ *
98
+ * The ACL lives in `access_acl` rows — `(principalId, actionId, targetId,
99
+ * targetKind, effect)` — where `principalId` is a user, role, unit or capability,
100
+ * `targetId` is the guarded record (or a scope node) and `effect` is `allow` /
101
+ * `deny` (deny always wins).
102
+ */
103
+ export interface IAclTableSpec {
104
+ /**
105
+ * - `none` (default) — the entity is not record-guarded; nothing changes.
106
+ * - `scoped` — a grant may target a scope; it covers the records linked to
107
+ * that scope through one of the declared `scopes` predicates.
108
+ * - `explicit` — only per-record grants count (`scopes` is not consulted).
109
+ */
110
+ mode?: 'none' | 'scoped' | 'explicit';
111
+ /**
112
+ * Predicates linking a record of this table to the scope(s) it belongs to
113
+ * (e.g. `belongsTo`, `isPartOf`). Consulted in `scoped` mode.
114
+ */
115
+ scopes?: string[];
116
+ /**
117
+ * The record is *its own* scope — for a table whose scopes point at it
118
+ * rather than the other way round (`party.organization`, which its units
119
+ * belong to). Its scope set is the record itself plus its parent scopes,
120
+ * and `scopes` is ignored. There is no unscoped fallback: a record is only
121
+ * reachable through a grant that covers it or one of its parents.
122
+ */
123
+ selfScope?: boolean;
124
+ /**
125
+ * Where the scope of a NEW record comes from. `add` has no record key yet,
126
+ * so the check runs against the scope named in the payload (`column`, e.g.
127
+ * `unitId`) and/or carried by a declared scope edge (`predicate`, e.g.
128
+ * `belongsTo`).
129
+ */
130
+ addScope?: {
131
+ /** Payload column holding the scope's resource id. */
132
+ column?: string;
133
+ /** Scope predicate whose payload array holds the scope ids. */
134
+ predicate?: string;
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Adapter-level ACL rules shared by every guarded table (see `ISchemaTable.acl`).
140
+ * The defaults match `realm/blong-access` and its `access_pathRefresh()` procedure.
141
+ */
142
+ export interface IAclAdapterConfig {
143
+ /** The ACL table (default `access_acl`). */
144
+ table?: string;
145
+ /** The action entity table whose PK is the action resource (default `access_action`). */
146
+ actionTable?: string;
147
+ /** `core.path` pathType whose destinations are the caller's roles (default `access.effectiveRole`). */
148
+ rolePathType?: string;
149
+ /** `core.path` pathType whose destinations are the caller's unit scopes (default `access.effectiveScope`). */
150
+ scopePathType?: string;
151
+ /** `core_triple` predicate linking a principal to a unit (default `belongsTo`). */
152
+ unitPredicate?: string;
153
+ /** `core_triple` predicate linking a principal to a scope directly (default `hasScope`). */
154
+ scopePredicate?: string;
155
+ }
156
+
81
157
  export interface ISchemaTable {
82
158
  /**
83
159
  * The TypeBox `TObject` for the table. Optional when the definition is
@@ -95,8 +171,17 @@ export interface ISchemaTable {
95
171
  * - `find`/`get` join `resourceName` as `${object}Name`;
96
172
  * - `edit` renames `core_resource.resourceName` from `${object}Name`;
97
173
  * - `remove` deletes the `core_resource` row (and the declared `edges`).
174
+ *
175
+ * Pass `{nameColumn}` when the table's real display column is not
176
+ * `${object}Name` (see `IResourceTableSpec`).
177
+ */
178
+ resource?: boolean | IResourceTableSpec;
179
+ /**
180
+ * Record-level (ACL) guard: which predicates are narrowed to the records the
181
+ * caller may act on, and where a new record's scope comes from. Defaults to
182
+ * `mode: 'none'` (unguarded) when omitted.
98
183
  */
99
- resource?: boolean;
184
+ acl?: IAclTableSpec;
100
185
  /**
101
186
  * Declarative graph-edge master-detail bindings (`core_triple` edges such
102
187
  * as `hasRole` / `hasCapability`). `get` attaches the edge rows as a
@@ -224,6 +309,12 @@ export interface IConfig {
224
309
  */
225
310
  procedurePaths?: string[];
226
311
  accessPathRefresh?: boolean; // When `true`, calls `access_pathRefresh()` after schema sync/seed (default: `false`).
312
+ /**
313
+ * Record-level (ACL) rules shared by every table declaring
314
+ * `ISchemaTable.acl` — the ACL table name and the graph predicates/path
315
+ * types used to resolve the caller's principals and a record's scopes.
316
+ */
317
+ acl?: IAclAdapterConfig;
227
318
  };
228
319
  /**
229
320
  * When `true`, mocks some handlers
@@ -0,0 +1,440 @@
1
+ import {crockfordDecode} from '@feasibleone/blong-lib';
2
+ import type {IObjectSchema, Knex} from '@feasibleone/blong/types';
3
+ import {methodParts} from '../../lib.ts';
4
+ import type {IAclAdapterConfig} from '../schema/knex/types.ts';
5
+
6
+ /**
7
+ * Record-level (ACL) SQL helpers shared by the knex adapter's generic CRUD and
8
+ * by realm handlers (through the `acl` library function in `blong-access`).
9
+ *
10
+ * The ACL itself is a single table (`access_acl` unless configured otherwise):
11
+ *
12
+ * ```
13
+ * access_acl(aclId, principalId, actionId, targetId, targetKind, effect, isActive)
14
+ * ```
15
+ *
16
+ * - `principalId` — a user, role, unit or capability resource id;
17
+ * - `actionId` — the `access_action` resource of the guarded method;
18
+ * - `targetId` — the guarded record, or a **scope** node (`targetKind`);
19
+ * - `effect` — `allow` / `deny`, where **deny always wins**.
20
+ *
21
+ * Two things grant access, and both narrow RBAC (a method the caller's action
22
+ * list does not contain is refused by the gateway before this ever runs):
23
+ *
24
+ * - **implicit** — a `<principal> --hasScope--> <scope>` graph edge: the
25
+ * organizational/hierarchical grant, covering every action the principal
26
+ * holds. The caller inherits the edges of its roles and units.
27
+ * - **explicit** — an `access_acl` row targeting a scope or a single record,
28
+ * optionally turning an implicit allow off with `effect: 'deny'`.
29
+ *
30
+ * The record side is derived from the graph too: a record belongs to a scope
31
+ * through one of the table's declared `scopes` predicates (`belongsTo`,
32
+ * `isPartOf`, …), and a scope may itself sit under a parent scope — a grant on a
33
+ * parent covers its descendants.
34
+ *
35
+ * All checks are plain SQL predicates evaluated by MySQL (no materialization of
36
+ * the effective ACL), so they can be used both for a single-record check and as
37
+ * a `WHERE` clause that filters a whole result set before paging.
38
+ */
39
+
40
+ /** Resolved ACL rules (defaults match `realm/blong-access`). */
41
+ export interface IResolvedAclConfig {
42
+ /** The ACL table. */
43
+ table: string;
44
+ /** The action entity table whose PK is the action resource. */
45
+ actionTable: string;
46
+ /** `core_path` pathType whose destinations are the caller's roles. */
47
+ rolePathType: string;
48
+ /** `core_path` pathType whose destinations are a node's scope ancestors (including itself). */
49
+ scopePathType: string;
50
+ /** `core_triple` predicate linking a principal to a unit. */
51
+ unitPredicate: string;
52
+ /** `core_triple` predicate linking a principal to a scope. */
53
+ scopePredicate: string;
54
+ }
55
+
56
+ const ACL_DEFAULTS: IResolvedAclConfig = {
57
+ table: 'access_acl',
58
+ actionTable: 'access_action',
59
+ rolePathType: 'access.effectiveRole',
60
+ scopePathType: 'access.effectiveScope',
61
+ unitPredicate: 'belongsTo',
62
+ scopePredicate: 'hasScope',
63
+ };
64
+
65
+ /**
66
+ * Merge the adapter's `schema.acl` block over the defaults, ignoring `undefined`
67
+ * values so a partially-filled config cannot blank a default.
68
+ */
69
+ export function aclConfig(config?: IAclAdapterConfig): IResolvedAclConfig {
70
+ const defined = Object.fromEntries(
71
+ Object.entries(config ?? {}).filter(([, value]) => value !== undefined && value !== null),
72
+ ) as Partial<IResolvedAclConfig>;
73
+ return {...ACL_DEFAULTS, ...defined};
74
+ }
75
+
76
+ /**
77
+ * Binary resource id of the JWT `sub` claim.
78
+ *
79
+ * `sub` is crockford-encoded and `crockfordEncode` **reverses** the bytes, so
80
+ * decoding round-trips to the DB byte order used by `core_path.originId` and
81
+ * `core_triple.subjectId`. Dashed UUIDs, hex and base64 strings are accepted
82
+ * too (tests and internal callers).
83
+ */
84
+ export function aclActorId(actorId: string): Buffer {
85
+ const dashed = actorId.replaceAll('-', '');
86
+ if (/^[0-9a-f]{32}$/i.test(dashed)) return Buffer.from(dashed, 'hex');
87
+ try {
88
+ const raw = crockfordDecode(actorId);
89
+ if (raw.length === 16) return Buffer.from(raw);
90
+ } catch {
91
+ // Not crockford — fall through to the base64 attempt.
92
+ }
93
+ const base64 = Buffer.from(actorId, 'base64');
94
+ if (base64.length === 16) return base64;
95
+ throw new Error(`Cannot resolve a resource id from the actor id "${actorId}"`);
96
+ }
97
+
98
+ /**
99
+ * The actor id of the caller (`$meta.auth.actorId`) — undefined when the call is
100
+ * unauthenticated (e.g. a seed merge or an internal dispatch).
101
+ */
102
+ export function aclActorOf($meta?: unknown): string | undefined {
103
+ return ($meta as {auth?: {actorId?: string}} | undefined)?.auth?.actorId;
104
+ }
105
+
106
+ /**
107
+ * The guarded entity (`subject.object`) a method belongs to — `accessUserEdit`
108
+ * and `access.user.edit` both resolve to `access.user`.
109
+ */
110
+ export function aclEntityOf(method: string): string | undefined {
111
+ const bare = method.split('/').pop() ?? method;
112
+ const parts = methodParts(bare).split('.');
113
+ return parts.length >= 2 ? `${parts[0]}.${parts[1]}` : undefined;
114
+ }
115
+
116
+ /** The record key column of a table — its PK, defaulting to `${object}Id`. */
117
+ export function aclKeyColumn(objectSchema: IObjectSchema, subject: string, object: string): string {
118
+ return (
119
+ (objectSchema[subject]?.[object] as {constraints?: {primaryKey?: string}} | undefined)
120
+ ?.constraints?.primaryKey ?? `${object}Id`
121
+ );
122
+ }
123
+
124
+ /** Guard against a predicate/pathType config value breaking out of its SQL literal. */
125
+ function sqlLiteral(value: string, what: string): string {
126
+ if (!/^[A-Za-z0-9_.-]+$/.test(value)) {
127
+ throw new Error(
128
+ `Invalid ACL ${what} "${value}" — only word characters, dot and dash are allowed`,
129
+ );
130
+ }
131
+ return `'${value}'`;
132
+ }
133
+
134
+ function sqlList(values: string[], what: string): string {
135
+ if (!values.length) throw new Error(`The ACL ${what} list is empty`);
136
+ return values.map(value => sqlLiteral(value, what)).join(', ');
137
+ }
138
+
139
+ /**
140
+ * Action resource id of a method. The action name is matched the same way the
141
+ * gateway matches method ids (dots stripped, lowercased) so `access.user.edit`
142
+ * and `accessUserEdit` resolve to the same action.
143
+ *
144
+ * An `undefined` result means the method has **no `access_action` row at all** —
145
+ * the realm does not manage it as an RBAC action (e.g. a plain table method), so
146
+ * there is nothing for the ACL to match and the caller is allowed. An action
147
+ * that *does* exist but has no ACL row for the caller is a **denial**: opting a
148
+ * table into the ACL (`mode: 'scoped' | 'explicit'`) is deny-by-default, with
149
+ * the implicit `hasScope` grants and the explicit `allow` rows opening it up.
150
+ */
151
+ export async function aclActionId(
152
+ qb: Knex,
153
+ cfg: IResolvedAclConfig,
154
+ method: string,
155
+ ): Promise<Buffer | undefined> {
156
+ const row = (await qb
157
+ .select('act.actionId')
158
+ .from(`${cfg.actionTable} as act`)
159
+ .join('core_resource as r', 'r.resourceId', 'act.actionId')
160
+ .whereRaw("LOWER(REPLACE(r.resourceName, '.', '')) = ?", [
161
+ method.replaceAll('.', '').toLowerCase(),
162
+ ])
163
+ .first()) as {actionId: Buffer} | undefined;
164
+ return row?.actionId;
165
+ }
166
+
167
+ export interface IAclCheck {
168
+ /** Raw SQL boolean expression — usable as a `SELECT` item or a `WHERE` clause. */
169
+ sql: string;
170
+ /** Positional bindings, in `?` order. */
171
+ bindings: unknown[];
172
+ }
173
+
174
+ /**
175
+ * The action resource name of a CRUD method following the repo-wide convention
176
+ * (`subject` + `Object` + `Predicate`, e.g. `access` + `user` + `find` →
177
+ * `accessUserFind`) — the same shape the gateway's `methodId` normalisation
178
+ * makes equivalent to `access.user.find`.
179
+ */
180
+ export function aclActionName(subject: string, object: string, predicate: string): string {
181
+ const capitalise = (value: string): string => value[0].toUpperCase() + value.slice(1);
182
+ return `${subject}${capitalise(object)}${capitalise(predicate)}`;
183
+ }
184
+
185
+ export interface IAclCheckOptions {
186
+ cfg: IResolvedAclConfig;
187
+ /** Binary resource id of the caller. */
188
+ actorId: Buffer;
189
+ /**
190
+ * Binary resource id of the guarded action. When `undefined` the action has
191
+ * no ACL rows and the caller is allowed (RBAC already decided the verb).
192
+ */
193
+ actionId: Buffer | undefined;
194
+ /**
195
+ * How the guarded record is referenced: `'?'` (bind its binary id through
196
+ * `recordId`) for a single-record check, or a column reference such as
197
+ * `party_organization.organizationId` for a correlated list filter. Omit it
198
+ * for `add` — the record does not exist yet and only scopes can be targeted.
199
+ */
200
+ recordRef?: string;
201
+ /** Binary record id — required when `recordRef` is `'?'`. */
202
+ recordId?: Buffer;
203
+ /** The guarded table's declared scope predicates (`belongsTo`, `isPartOf`, …). */
204
+ scopes?: string[];
205
+ /**
206
+ * The record is its own scope (see `IAclTableSpec.selfScope`) — its scope set
207
+ * is the record plus the parent scopes reachable from it, and any declared
208
+ * `scopes` predicates are ignored.
209
+ */
210
+ selfScope?: boolean;
211
+ /**
212
+ * Explicit scope ids, replacing the record-derived scope set. Used by `add`,
213
+ * where the scope comes from the payload (`addScope`).
214
+ */
215
+ scopeIds?: Buffer[];
216
+ /** `explicit` drops the implicit `<principal> --hasScope--> <scope>` grant. */
217
+ mode: 'scoped' | 'explicit';
218
+ }
219
+
220
+ /**
221
+ * The caller's principals: itself, its roles (from `core_path`), the units it
222
+ * belongs to and those units' ancestors.
223
+ *
224
+ * `subjectColumn` names the principal column of the surrounding table — it is
225
+ * `principalId` in the ACL table and `subjectId` in `core_triple`.
226
+ */
227
+ function principalFilter(
228
+ cfg: IResolvedAclConfig,
229
+ actorId: Buffer,
230
+ alias: string,
231
+ subjectColumn: string,
232
+ ): {sql: string; bindings: unknown[]} {
233
+ const unitPredicate = sqlLiteral(cfg.unitPredicate, 'unit predicate');
234
+ const units = `SELECT tu.objectId FROM core_triple tu WHERE tu.subjectId = ? AND tu.predicateName = ${unitPredicate}`;
235
+ return {
236
+ sql: `${alias}.${subjectColumn} = ? OR ${alias}.${subjectColumn} IN (
237
+ SELECT pr.destinationId FROM core_path pr WHERE pr.originId = ? AND pr.pathType = ?
238
+ UNION SELECT t.objectId FROM core_triple t WHERE t.subjectId = ? AND t.predicateName = ${unitPredicate}
239
+ UNION SELECT pa.destinationId FROM core_path pa
240
+ WHERE pa.pathType = ? AND pa.originId IN (${units})
241
+ )`,
242
+ bindings: [actorId, actorId, cfg.rolePathType, actorId, cfg.scopePathType, actorId],
243
+ };
244
+ }
245
+
246
+ /**
247
+ * The scopes a record participates in: its own scope edges (`belongsTo`,
248
+ * `isPartOf`, …) plus every ancestor of those scopes — the materialized
249
+ * `<scope> --isPartOf--> <parent>` chain, which includes each scope itself.
250
+ *
251
+ * `recordRef` is `'?'` (then the record id is bound twice) or a column reference
252
+ * for a correlated list filter (then nothing is bound).
253
+ */
254
+ function recordScopesSql(
255
+ cfg: IResolvedAclConfig,
256
+ scopes: string[],
257
+ recordRef: string,
258
+ recordId: Buffer | undefined,
259
+ ): {sql: string; bindings: unknown[]} {
260
+ const predicateList = sqlList(scopes, 'scope predicate');
261
+ const refs = recordRef === '?' ? [recordId, recordId] : [];
262
+ return {
263
+ sql: `SELECT ts.objectId FROM core_triple ts
264
+ WHERE ts.subjectId = ${recordRef} AND ts.predicateName IN (${predicateList})
265
+ UNION SELECT pa.destinationId FROM core_path pa
266
+ WHERE pa.pathType = ? AND pa.originId IN (
267
+ SELECT ps.objectId FROM core_triple ps
268
+ WHERE ps.subjectId = ${recordRef} AND ps.predicateName IN (${predicateList})
269
+ )`,
270
+ bindings: [...refs.slice(0, 1), cfg.scopePathType, ...refs.slice(1)],
271
+ };
272
+ }
273
+
274
+ /**
275
+ * The scope set of a record that *is* its own scope: the record plus its parent
276
+ * scopes (the materialized scope path). Used by tables whose scopes point at
277
+ * them — `party.organization`, whose units carry `belongsTo` towards it — where
278
+ * the record has no scope edge of its own to follow.
279
+ */
280
+ function ownScopeSql(
281
+ cfg: IResolvedAclConfig,
282
+ recordRef: string,
283
+ recordId: Buffer | undefined,
284
+ ): {sql: string; bindings: unknown[]} {
285
+ const refs = recordRef === '?' ? [recordId, recordId] : [];
286
+ return {
287
+ sql: `SELECT ${recordRef} AS objectId
288
+ UNION SELECT pa.destinationId FROM core_path pa
289
+ WHERE pa.pathType = ? AND pa.originId = ${recordRef}`,
290
+ bindings: [...refs.slice(0, 1), cfg.scopePathType, ...refs.slice(1)],
291
+ };
292
+ }
293
+
294
+ /** The scope set of an explicit list of scope ids: the ids themselves plus their ancestors. */
295
+ function explicitScopesSql(
296
+ cfg: IResolvedAclConfig,
297
+ scopeIds: Buffer[],
298
+ ): {sql: string; bindings: unknown[]} {
299
+ const ids = scopeIds.map(() => 'SELECT ? AS id').join(' UNION ');
300
+ const placeholders = scopeIds.map(() => '?').join(', ');
301
+ return {
302
+ sql: `${ids}
303
+ UNION SELECT pa.destinationId FROM core_path pa
304
+ WHERE pa.pathType = ? AND pa.originId IN (${placeholders})`,
305
+ bindings: [...scopeIds, cfg.scopePathType, ...scopeIds],
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Build the boolean expression that decides whether the caller may act on the
311
+ * record with the given action:
312
+ *
313
+ * ```sql
314
+ * (implicit OR explicit-allow) AND NOT explicit-deny
315
+ * ```
316
+ *
317
+ * `implicit` is a `hasScope` edge from one of the caller's principals to a scope
318
+ * the record participates in; the explicit halves are `access_acl` rows
319
+ * targeting the record itself or one of those scopes. An unknown action (no
320
+ * `actionId`) short-circuits to `TRUE` — RBAC already decided the verb.
321
+ *
322
+ * The SQL and its bindings are assembled strictly in placeholder order; every
323
+ * fragment's bindings are spliced in exactly where its SQL text is spliced.
324
+ */
325
+ export function aclCheckSql(options: IAclCheckOptions): IAclCheck {
326
+ const {cfg, actorId, actionId, recordRef, mode} = options;
327
+ if (!actionId) return {sql: 'TRUE', bindings: []};
328
+
329
+ const scopeIds = options.scopeIds?.length ? options.scopeIds : undefined;
330
+ const scopes = options.scopes ?? [];
331
+ // Nothing to evaluate against: no record key and no scope. Nothing can
332
+ // match, so the answer is "denied" — a caller that forgot the target fails
333
+ // here rather than being let through (see `access.session.verify`, which
334
+ // turns this into a `recordRequired` refusal).
335
+ if (!recordRef && !scopeIds) return {sql: 'FALSE', bindings: []};
336
+ const scopeSet = scopeIds
337
+ ? explicitScopesSql(cfg, scopeIds)
338
+ : options.selfScope && recordRef
339
+ ? ownScopeSql(cfg, recordRef, options.recordId)
340
+ : mode === 'scoped' && recordRef && scopes.length
341
+ ? recordScopesSql(cfg, scopes, recordRef, options.recordId)
342
+ : undefined;
343
+
344
+ // The target test: the record itself (when there is one) and/or the scopes
345
+ // it participates in. `add` has no record key yet, so only scopes. A
346
+ // wildcard rule (`targetKind: 'all'`, anchored on a sentinel node) matches
347
+ // every record in every scope shape. Built per alias so the allow (`a`) and
348
+ // deny (`d`) sub-queries each refer to their own row.
349
+ const targetOf = (alias: string): {sql: string; bindings: unknown[]} =>
350
+ scopeSet
351
+ ? {
352
+ sql: recordRef
353
+ ? `(${alias}.targetKind = 'all'
354
+ OR (${alias}.targetKind = 'record' AND ${alias}.targetId = ${recordRef})
355
+ OR (${alias}.targetKind = 'scope' AND ${alias}.targetId IN (${scopeSet.sql})))`
356
+ : `(${alias}.targetKind = 'all'
357
+ OR (${alias}.targetKind = 'scope' AND ${alias}.targetId IN (${scopeSet.sql})))`,
358
+ bindings: [
359
+ ...(recordRef === '?' ? [options.recordId] : []),
360
+ ...scopeSet.bindings,
361
+ ],
362
+ }
363
+ : {
364
+ sql: `(${alias}.targetKind = 'all'
365
+ OR (${alias}.targetKind = 'record' AND ${alias}.targetId = ${recordRef ?? '?'}))`,
366
+ bindings: recordRef ? [options.recordId] : [],
367
+ };
368
+
369
+ const explicit = (effect: 'allow' | 'deny', alias: string): IAclCheck => {
370
+ const principals = principalFilter(cfg, actorId, alias, 'principalId');
371
+ const target = targetOf(alias);
372
+ return {
373
+ sql: `EXISTS (SELECT 1 FROM ${cfg.table} ${alias}
374
+ WHERE ${alias}.isActive = 1 AND ${alias}.effect = ${sqlLiteral(effect, 'effect')}
375
+ AND ${alias}.actionId = ?
376
+ AND (${principals.sql})
377
+ AND ${target.sql})`,
378
+ bindings: [actionId, ...principals.bindings, ...target.bindings],
379
+ };
380
+ };
381
+
382
+ const allow = explicit('allow', 'a');
383
+ const deny = explicit('deny', 'd');
384
+
385
+ // Implicit grant: <principal> --hasScope--> <scope of the record>.
386
+ let implicit: IAclCheck | undefined;
387
+ if (scopeSet && mode === 'scoped') {
388
+ const principals = principalFilter(cfg, actorId, 'hs', 'subjectId');
389
+ implicit = {
390
+ sql: `EXISTS (SELECT 1 FROM core_triple hs
391
+ WHERE hs.predicateName = ${sqlLiteral(cfg.scopePredicate, 'scope predicate')}
392
+ AND (${principals.sql})
393
+ AND hs.objectId IN (${scopeSet.sql}))`,
394
+ bindings: [...principals.bindings, ...scopeSet.bindings],
395
+ };
396
+ }
397
+
398
+ // A record that participates in no scope has nothing to narrow it against,
399
+ // so RBAC alone decides it — the ACL restricts records *within* scopes.
400
+ // This is what makes opting a table in safe for existing, unsorted data.
401
+ const unscoped =
402
+ recordRef && mode === 'scoped' && scopes.length
403
+ ? {
404
+ sql: `NOT EXISTS (SELECT 1 FROM core_triple us
405
+ WHERE us.subjectId = ${recordRef}
406
+ AND us.predicateName IN (${sqlList(scopes, 'scope predicate')}))`,
407
+ bindings: recordRef === '?' ? [options.recordId] : [],
408
+ }
409
+ : undefined;
410
+
411
+ const grant = implicit ? `(${implicit.sql}) OR (${allow.sql})` : `(${allow.sql})`;
412
+ const guarded = `((${grant}) AND NOT (${deny.sql}))`;
413
+ const sql = unscoped ? `((${unscoped.sql}) OR ${guarded})` : guarded;
414
+ const bindings = [
415
+ ...(unscoped?.bindings ?? []),
416
+ ...(implicit?.bindings ?? []),
417
+ ...allow.bindings,
418
+ ...deny.bindings,
419
+ ];
420
+ const expected = sql.split('?').length - 1;
421
+ if (expected !== bindings.length) {
422
+ throw new Error(
423
+ `ACL SQL placeholder mismatch: ${expected} placeholders, ${bindings.length} bindings`,
424
+ );
425
+ }
426
+ return {sql, bindings};
427
+ }
428
+
429
+ /**
430
+ * Evaluate the ACL for a single record (or a single scope, for `add`) in one
431
+ * round trip. Returns `true` when the caller may proceed.
432
+ */
433
+ export async function aclAllowed(qb: Knex, options: IAclCheckOptions): Promise<boolean> {
434
+ const {sql, bindings} = aclCheckSql(options);
435
+ if (sql === 'TRUE') return true;
436
+ const row = (await qb.select(qb.raw(`${sql} AS allowed`, bindings as never[])).first()) as
437
+ | {allowed?: number | boolean | null}
438
+ | undefined;
439
+ return Boolean(row?.allowed);
440
+ }
@@ -15,6 +15,17 @@ import {type TFunction, type TObject} from 'typebox';
15
15
  import {v4} from 'uuid';
16
16
  import yaml from 'yaml';
17
17
  import {methodParts} from '../../lib.ts';
18
+ import {
19
+ aclActionId,
20
+ aclActionName,
21
+ aclActorId,
22
+ aclActorOf,
23
+ aclAllowed,
24
+ aclCheckSql,
25
+ aclConfig,
26
+ aclEntityOf,
27
+ aclKeyColumn,
28
+ } from './acl.ts';
18
29
  import {
19
30
  binaryToStr,
20
31
  discoverBinaryColumns,
@@ -37,6 +48,7 @@ import {
37
48
  schemaTableSyncImpl,
38
49
  } from '../schema/knex/schemaTable.ts';
39
50
  import {
51
+ type IAclTableSpec,
40
52
  type IConfig,
41
53
  type IEdgeBinding,
42
54
  type IKnexConfig,
@@ -71,6 +83,16 @@ const errorMap: IErrorMap = {
71
83
  'knex.exists': 'Knex Exists',
72
84
  'knex.unique': 'Knex Unique',
73
85
  'knex.missingKey': 'Missing key value for {key}',
86
+ // Record-level (ACL) refusals from the generic CRUD — the same `acl.*` family
87
+ // the access realm declares, so a caller sees one error name wherever the
88
+ // check runs (adapter or realm handler).
89
+ 'acl.denied': {message: 'Not allowed to access this record', statusCode: 403},
90
+ 'acl.scopeDenied': {
91
+ message: 'Not allowed to create a record in this scope',
92
+ statusCode: 403,
93
+ },
94
+ // A single-record read refuses with "not found" so it does not leak existence.
95
+ 'acl.notFound': {message: 'Record not found', statusCode: 404},
74
96
  };
75
97
 
76
98
  let _errors: Errors<typeof errorMap>;
@@ -404,8 +426,10 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
404
426
  // resource-backed table of the subject (see `_dropdownList`).
405
427
  if (object === 'dropdown' && operation === 'list') {
406
428
  return (
407
- this as unknown as {_dropdownList(s: string): Promise<unknown>}
408
- )._dropdownList(subject);
429
+ this as unknown as {
430
+ _dropdownList(s: string, meta?: IMeta): Promise<unknown>;
431
+ }
432
+ )._dropdownList(subject, $meta);
409
433
  }
410
434
  // Structure discovery for the commander explorer:
411
435
  // `{ns}.schema.list` — databases/schemas on the server
@@ -453,6 +477,149 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
453
477
  };
454
478
  }
455
479
  const table = `${subject}_${object}`;
480
+ // ── Record-level (ACL) gates ──────────────────────────────────────
481
+ // A table opts into record-level authorization with
482
+ // `acl: {mode, scopes, addScope}`; everything below is a no-op for a
483
+ // table that does not (`mode: 'none'`, the default) and for system
484
+ // calls, which carry no `$meta.auth` (seed merges, internal dispatches).
485
+ const aclCfg = aclConfig(this.config.schema?.acl);
486
+ const aclActor = ($meta?.auth as {actorId?: string} | undefined)?.actorId;
487
+ /** The guarded table's PK column (the record key). */
488
+ const aclKeyName = (): string => aclKeyColumn(objectSchema, subject, object);
489
+
490
+ /**
491
+ * Gate a single-record operation (`get` / `edit` / `remove`).
492
+ * Writes are refused with 403; a read refuses with 404 so it does not
493
+ * leak the record's existence.
494
+ */
495
+ const aclCheckRecord = async (
496
+ opts: IResolvedTableOptions,
497
+ operation: string,
498
+ recordId: Buffer | undefined,
499
+ ): Promise<void> => {
500
+ const mode = opts.acl.mode ?? 'none';
501
+ if (mode === 'none' || !aclActor || !recordId) return;
502
+ const actionId = await aclActionId(this.config.context.queryBuilder!, aclCfg, method!);
503
+ if (!actionId) return; // the method is not an RBAC-managed action
504
+ const allowed = await aclAllowed(this.config.context.queryBuilder!, {
505
+ cfg: aclCfg,
506
+ actorId: aclActorId(aclActor),
507
+ actionId,
508
+ recordRef: '?',
509
+ recordId,
510
+ scopes: opts.acl.scopes,
511
+ selfScope: opts.acl.selfScope,
512
+ mode,
513
+ });
514
+ if (allowed) return;
515
+ throw this.error(
516
+ operation === 'get'
517
+ ? _errors['acl.notFound']({params: {reason: 'notFound'}})
518
+ : _errors['acl.denied']({params: {reason: 'denied'}}),
519
+ $meta,
520
+ );
521
+ };
522
+
523
+ /**
524
+ * Release the ACL rules that reference a guarded record about to be
525
+ * removed.
526
+ *
527
+ * `access_acl` keeps foreign keys to `core_resource` on
528
+ * `principalId`, `actionId` and `targetId`, so a rule left behind
529
+ * makes the resource delete fail *after* the entity row is already
530
+ * gone. What remains is a resource-only ghost, and because
531
+ * `core.resource.ensure` matches a record by name it then skips the
532
+ * entity insert — the entity can never be created again. A record
533
+ * that is being removed can no longer be a principal, a target or an
534
+ * action, so its rules go first.
535
+ */
536
+ const aclReleaseResource = async (
537
+ opts: IResolvedTableOptions,
538
+ recordId: Buffer,
539
+ ): Promise<void> => {
540
+ const mode = opts.acl.mode ?? 'none';
541
+ if (mode === 'none') return;
542
+ await this.config.context
543
+ .queryBuilder!(aclCfg.table)
544
+ .where('principalId', recordId)
545
+ .orWhere('targetId', recordId)
546
+ .orWhere('actionId', recordId)
547
+ .del();
548
+ };
549
+
550
+ /**
551
+ * The ACL predicate for a multi-record read, correlated on the guarded
552
+ * table's key — applied to the query **before** paging so the result
553
+ * set (and its total) only contains records the caller may see.
554
+ */
555
+ const aclReadFilter = async (
556
+ opts: IResolvedTableOptions,
557
+ ): Promise<{sql: string; bindings: unknown[]} | undefined> => {
558
+ const mode = opts.acl.mode ?? 'none';
559
+ if (mode === 'none' || !aclActor) return undefined;
560
+ const actionId = await aclActionId(this.config.context.queryBuilder!, aclCfg, method!);
561
+ if (!actionId) return undefined;
562
+ return aclCheckSql({
563
+ cfg: aclCfg,
564
+ actorId: aclActorId(aclActor),
565
+ actionId,
566
+ recordRef: `${table}.${aclKeyName()}`,
567
+ scopes: opts.acl.scopes,
568
+ selfScope: opts.acl.selfScope,
569
+ mode,
570
+ });
571
+ };
572
+
573
+ /**
574
+ * Gate `add`, which has no record key yet: the check runs against the
575
+ * scope named by the declaration (`addScope.column` on the payload, or
576
+ * `addScope.predicate` reading the ids of the submitted scope edges).
577
+ */
578
+ const aclCheckAdd = async (
579
+ opts: IResolvedTableOptions,
580
+ cols: Record<string, unknown>,
581
+ rest: Record<string, unknown>,
582
+ ): Promise<void> => {
583
+ const mode = opts.acl.mode ?? 'none';
584
+ const addScope = opts.acl.addScope;
585
+ if (mode === 'none' || !aclActor || !addScope) return;
586
+ const ids: string[] = [];
587
+ if (addScope.column && typeof cols[addScope.column] === 'string') {
588
+ ids.push(cols[addScope.column] as string);
589
+ }
590
+ if (addScope.predicate) {
591
+ // The payload array is keyed by the edge's object name
592
+ // (`unit`), which the declared binding owns.
593
+ const binding = opts.edges.find(e => e.predicate === addScope.predicate);
594
+ const detailObject =
595
+ binding?.object ?? addScope.predicate.replace(/^has/, '').toLowerCase();
596
+ for (const row of (rest[detailObject] as Array<Record<string, unknown>>) ?? []) {
597
+ // The scope id is the object's key in the edge row —
598
+ // `{unitId: '<base64>'}` for a `belongsTo` edge.
599
+ const found = Object.entries(row ?? {}).find(
600
+ ([key, value]) => key.endsWith('Id') && typeof value === 'string',
601
+ );
602
+ if (found) ids.push(found[1] as string);
603
+ }
604
+ }
605
+ if (!ids.length) return;
606
+ const actionId = await aclActionId(this.config.context.queryBuilder!, aclCfg, method!);
607
+ if (!actionId) return;
608
+ const allowed = await aclAllowed(this.config.context.queryBuilder!, {
609
+ cfg: aclCfg,
610
+ actorId: aclActorId(aclActor),
611
+ actionId,
612
+ scopeIds: ids.map(id => strToBinary(id)),
613
+ mode,
614
+ });
615
+ if (!allowed) {
616
+ throw this.error(
617
+ _errors['acl.scopeDenied']({params: {reason: 'scopeDenied'}}),
618
+ $meta,
619
+ );
620
+ }
621
+ };
622
+
456
623
  switch (operation) {
457
624
  case 'get': {
458
625
  const {select: _select, ...where} = params;
@@ -478,13 +645,25 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
478
645
  // the row in place (Buffers → base64 strings). The edge
479
646
  // attachment needs the binary master key.
480
647
  const masterKey = row?.[keyName];
648
+ // Record-level ACL: checked after the fetch so a `where` that
649
+ // does not carry the key (e.g. by email address) is covered too.
650
+ await aclCheckRecord(
651
+ opts,
652
+ 'get',
653
+ Buffer.isBuffer(masterKey) ? masterKey : undefined,
654
+ );
481
655
  const result: Record<string, unknown> = {
482
656
  [object]: prepareResultRow(row, binaryCols, table),
483
657
  };
484
658
  const masterRow = result[object] as Record<string, unknown> | undefined;
485
659
  // Resource-backed: join the display name from
486
660
  // `core_resource.resourceName` as `${object}Name`.
487
- if (opts.resource && masterRow && typeof masterRow[keyName] === 'string') {
661
+ if (
662
+ opts.resource &&
663
+ !opts.nameColumn &&
664
+ masterRow &&
665
+ typeof masterRow[keyName] === 'string'
666
+ ) {
488
667
  const [joined] = await joinResourceNames(
489
668
  qb,
490
669
  [masterRow],
@@ -560,13 +739,19 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
560
739
  });
561
740
  }
562
741
  if (order) query = query.orderBy(order);
742
+ // Record-level ACL: the filter is applied before paging so both
743
+ // the page and its total only contain readable records.
744
+ const aclFilter = await aclReadFilter(opts);
745
+ if (aclFilter) query = query.whereRaw(aclFilter.sql, aclFilter.bindings as never[]);
563
746
  if (limit) query = query.limit(limit);
564
747
  if (offset) query = query.offset(offset);
565
748
  const rows = (await query.select(select)) as Record<string, unknown>[];
566
749
  const prepared = prepareResultRows(rows, binaryCols, table);
567
750
  // Resource-backed: join the display name from
568
- // `core_resource.resourceName` as `${object}Name`.
569
- if (opts.resource) {
751
+ // `core_resource.resourceName` as `${object}Name`. A table
752
+ // declaring `resource: {nameColumn}` keeps its real display
753
+ // column — no virtual field is synthesised.
754
+ if (opts.resource && !opts.nameColumn) {
570
755
  return joinResourceNames(qb, prepared, `${object}Id`, `${object}Name`);
571
756
  }
572
757
  return prepared;
@@ -601,13 +786,18 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
601
786
  const binaryCols = getBinaryCols(this.config.context);
602
787
  const opts = tableOptions(objectSchema, this.config, subject, object);
603
788
  const cols = columns as Record<string, unknown>;
604
- // Resource-backed: `${object}Name` is a virtual display field
605
- // (the name lives in `core_resource.resourceName`) — capture
606
- // it for the resource row and exclude it from the table insert.
789
+ // Resource-backed: the display name lives in
790
+ // `core_resource.resourceName`. By default it is the virtual
791
+ // `${object}Name` field — captured for the resource row and
792
+ // excluded from the table insert. A table declaring
793
+ // `resource: {nameColumn}` names the resource from that real
794
+ // column instead, which therefore stays in the insert.
607
795
  const nameColValue = opts.resource
608
- ? (cols[`${object}Name`] as string | undefined)
796
+ ? ((opts.nameColumn ? cols[opts.nameColumn] : cols[`${object}Name`]) as
797
+ | string
798
+ | undefined)
609
799
  : undefined;
610
- if (opts.resource) {
800
+ if (opts.resource && !opts.nameColumn) {
611
801
  delete cols[`${object}Name`];
612
802
  }
613
803
  // Generate real PKs server-side for id columns that carry a
@@ -677,6 +867,9 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
677
867
  await ensureResourceRow(generatedKey, keyName);
678
868
  }
679
869
  }
870
+ // Record-level ACL: `add` has no record key yet, so the scope
871
+ // declared by `addScope` decides whether it may be created.
872
+ await aclCheckAdd(opts, cols, rest);
680
873
  // Convert any string values for binary columns to Buffer
681
874
  const insertCols = prepareInputParams(cols, binaryCols, table);
682
875
  const inserted = await qb(table).insert(insertCols);
@@ -756,7 +949,7 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
756
949
  }
757
950
  // Resource-backed: join the display name onto the master so
758
951
  // the caller sees `${object}Name` in the created row.
759
- if (opts.resource && Buffer.isBuffer(masterKey)) {
952
+ if (opts.resource && !opts.nameColumn && Buffer.isBuffer(masterKey)) {
760
953
  const masterRow = result[object] as Record<string, unknown> | undefined;
761
954
  if (masterRow && typeof masterRow[`${object}Id`] === 'string') {
762
955
  const [joined] = await joinResourceNames(
@@ -779,15 +972,20 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
779
972
  const {[keyName]: key, ...update} = cols as Record<string, unknown>;
780
973
  const isBinaryKey =
781
974
  isBinaryColumn(binaryCols, table, keyName) && typeof key === 'string';
782
- // Resource-backed: `${object}Name` is a virtual field — the
783
- // display name lives in `core_resource.resourceName`, so
784
- // rename that row instead of updating a table column.
975
+ // Resource-backed: the display name lives in
976
+ // `core_resource.resourceName`, so a change to the display
977
+ // field renames that row. With `resource: {nameColumn}` the
978
+ // real column is updated as well (it stays in the update).
979
+ const nameField = opts.nameColumn ?? `${object}Name`;
785
980
  const resourceName = opts.resource
786
- ? (update[`${object}Name`] as string | undefined)
981
+ ? (update[nameField] as string | undefined)
787
982
  : undefined;
788
- if (opts.resource) {
983
+ if (opts.resource && !opts.nameColumn) {
789
984
  delete update[`${object}Name`];
790
985
  }
986
+ // Record-level ACL: refuse an edit of a record the caller may
987
+ // not act on, before anything is written.
988
+ await aclCheckRecord(opts, 'edit', isBinaryKey ? strToBinary(key) : undefined);
791
989
  // Convert any string values for binary columns to Buffer (the
792
990
  // form round-trips them as base64 strings returned by `get`).
793
991
  const preparedUpdate = prepareInputParams(update, binaryCols, table);
@@ -891,6 +1089,16 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
891
1089
  throw this.error(_errors['knex.missingKey']({key: keyName}), $meta);
892
1090
  }
893
1091
  const qb = this.config.context.queryBuilder!;
1092
+ // Record-level ACL: refuse to delete a record the caller may
1093
+ // not act on before anything is touched.
1094
+ await aclCheckRecord(
1095
+ opts,
1096
+ 'remove',
1097
+ Buffer.isBuffer(masterKey) ? masterKey : undefined,
1098
+ );
1099
+ // …then release the rules that reference the record, before
1100
+ // the entity row is deleted (see `aclReleaseResource`).
1101
+ if (Buffer.isBuffer(masterKey)) await aclReleaseResource(opts, masterKey);
894
1102
  // Master-detail: delete each FK-constrained detail table's
895
1103
  // rows for this master BEFORE deleting the master row, so a
896
1104
  // non-cascading FK does not block the delete.
@@ -1020,6 +1228,129 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
1020
1228
  }
1021
1229
  throw this.error(_errors['knex.generic']({}), $meta);
1022
1230
  },
1231
+ /**
1232
+ * `{subject}.dropdown.list` — produce `{value, label}` pairs for every
1233
+ * resource-backed table of the subject. A table is resource-backed when
1234
+ * its PK (or any column) is a FK to `core.resource.resourceId`.
1235
+ *
1236
+ * Entries are resolved directly from `core_resource` (joined with
1237
+ * `core_type` by `typeAlias = ${subject}.${object}`), so every realm gets
1238
+ * dropdowns for free, matching the `blong-mock` `{subject}.dropdown.list`
1239
+ * shape (`{value: base64, label: resourceName}`).
1240
+ *
1241
+ * Per-table overrides are declared via the `dropdown` option on the table
1242
+ * spec (see `ISchemaTable`): `typeAlias`, `joinTable`, `joinColumn`,
1243
+ * `labelColumn`.
1244
+ */
1245
+ /**
1246
+ * Evaluate the record-level ACL for one record — or for one or more
1247
+ * scopes, which is what `add` needs (the record has no key yet).
1248
+ *
1249
+ * Realm handlers cannot import this module (the runtime is not a library
1250
+ * dependency of a realm), so they reach it through the port —
1251
+ * `await this.aclCheck({recordId}, $meta)` — while the generic CRUD calls
1252
+ * the same helper directly.
1253
+ *
1254
+ * `guarded: false` means the table does not opt into the ACL, or the
1255
+ * method is not an RBAC-managed action at all: the caller is allowed.
1256
+ * `actionId` (a raw Buffer — this call never crosses a realm boundary)
1257
+ * and `predicate` are returned even for an unguarded entity, so a caller
1258
+ * can reuse them for its own live-permission check.
1259
+ */
1260
+ async aclCheck(
1261
+ params: {
1262
+ /** Guarded entity as `subject.object` — defaults to the entity of `method`. */
1263
+ entity?: string;
1264
+ /** The method whose action is checked — defaults to `$meta.method`. */
1265
+ method?: string;
1266
+ /** Binary record key as a base64/hex string. */
1267
+ recordId?: string;
1268
+ /** Scope ids (used by `add`) as base64/hex strings. */
1269
+ scopeIds?: string[];
1270
+ /** Actor id override — defaults to `$meta.auth.actorId`. */
1271
+ actorId?: string;
1272
+ },
1273
+ $meta?: IMeta,
1274
+ ): Promise<{
1275
+ guarded: boolean;
1276
+ allowed: boolean;
1277
+ entity?: string;
1278
+ predicate?: string;
1279
+ actionId?: Buffer;
1280
+ }> {
1281
+ const qb = this.config?.context?.queryBuilder;
1282
+ const method = params.method ?? $meta?.method;
1283
+ const actor = params.actorId ?? aclActorOf($meta);
1284
+ if (!qb || !method) return {guarded: false, allowed: true};
1285
+ const entity = params.entity ?? aclEntityOf(method);
1286
+ const predicate = methodParts(method).split('.').pop();
1287
+ const [subject, object] = entity ? entity.split('.') : [undefined, undefined];
1288
+ const spec =
1289
+ subject && object
1290
+ ? resolveTableSpec(
1291
+ objectSchema,
1292
+ this.config?.schema?.tables?.[`${subject}.${object}`],
1293
+ subject,
1294
+ object,
1295
+ ).acl
1296
+ : undefined;
1297
+ const cfg = aclConfig(this.config?.schema?.acl);
1298
+ const actionId = await aclActionId(qb, cfg, method);
1299
+ const mode = spec?.mode ?? 'none';
1300
+ const verdict = {entity, predicate, actionId};
1301
+ if (!actionId || mode === 'none' || !actor) {
1302
+ return {guarded: false, allowed: true, ...verdict};
1303
+ }
1304
+ const allowed = await aclAllowed(qb, {
1305
+ cfg,
1306
+ actionId,
1307
+ actorId: aclActorId(actor),
1308
+ recordRef: params.recordId ? '?' : undefined,
1309
+ recordId: params.recordId ? strToBinary(params.recordId) : undefined,
1310
+ scopeIds: params.scopeIds?.map(id => strToBinary(id)),
1311
+ scopes: spec?.scopes,
1312
+ selfScope: spec?.selfScope,
1313
+ mode,
1314
+ });
1315
+ return {guarded: true, allowed, ...verdict};
1316
+ },
1317
+
1318
+ /**
1319
+ * The ACL predicate that filters a whole result set, correlated on the
1320
+ * guarded table's key column — realm handlers apply it with
1321
+ * `query.whereRaw(sql, bindings)` before paging. Returns `undefined`
1322
+ * when the entity is not guarded.
1323
+ */
1324
+ async aclFilter(
1325
+ params: {entity: string; method?: string; actorId?: string},
1326
+ $meta?: IMeta,
1327
+ ): Promise<{sql: string; bindings: unknown[]} | undefined> {
1328
+ const method = params.method ?? $meta?.method;
1329
+ const [subject, object] = params.entity.split('.');
1330
+ const actor = params.actorId ?? aclActorOf($meta);
1331
+ const qb = this.config?.context?.queryBuilder;
1332
+ const spec = resolveTableSpec(
1333
+ objectSchema,
1334
+ this.config?.schema?.tables?.[params.entity],
1335
+ subject,
1336
+ object,
1337
+ ).acl;
1338
+ const mode = spec?.mode ?? 'none';
1339
+ if (mode === 'none' || !actor || !method || !qb) return undefined;
1340
+ const cfg = aclConfig(this.config?.schema?.acl);
1341
+ const actionId = await aclActionId(qb, cfg, method);
1342
+ if (!actionId) return undefined;
1343
+ return aclCheckSql({
1344
+ cfg,
1345
+ actionId,
1346
+ actorId: aclActorId(actor),
1347
+ recordRef: `${subject}_${object}.${aclKeyColumn(objectSchema, subject, object)}`,
1348
+ scopes: spec?.scopes,
1349
+ selfScope: spec?.selfScope,
1350
+ mode,
1351
+ });
1352
+ },
1353
+
1023
1354
  /**
1024
1355
  * `{subject}.dropdown.list` — produce `{value, label}` pairs for every
1025
1356
  * resource-backed table of the subject. A table is resource-backed when
@@ -1037,6 +1368,7 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
1037
1368
  async _dropdownList(
1038
1369
  this: Adapter<IConfig>,
1039
1370
  subject: string,
1371
+ $meta?: IMeta,
1040
1372
  ): Promise<Record<string, Array<{value: string; label: string}>>> {
1041
1373
  const qb = this.config.context?.queryBuilder;
1042
1374
  if (!qb) return {};
@@ -1078,6 +1410,31 @@ export default adapter<IConfig>(({utError, schema: objectSchema}) => {
1078
1410
  'r.resourceId',
1079
1411
  );
1080
1412
  }
1413
+ // Record-level ACL: a dropdown is a read path, so a guarded entity
1414
+ // must not offer records the caller may not read.
1415
+ const aclSpec = resolveTableSpec(objectSchema, tableConfig, s, object).acl;
1416
+ const aclMode = aclSpec?.mode ?? 'none';
1417
+ const dropdownActor = ($meta?.auth as {actorId?: string} | undefined)?.actorId;
1418
+ if (aclMode !== 'none' && dropdownActor) {
1419
+ const dropdownAcl = aclConfig(this.config.schema?.acl);
1420
+ const actionId = await aclActionId(
1421
+ qb,
1422
+ dropdownAcl,
1423
+ aclActionName(s, object, 'find'),
1424
+ );
1425
+ if (actionId) {
1426
+ const filter = aclCheckSql({
1427
+ cfg: dropdownAcl,
1428
+ actorId: aclActorId(dropdownActor),
1429
+ actionId,
1430
+ recordRef: 'r.resourceId',
1431
+ scopes: aclSpec?.scopes,
1432
+ selfScope: aclSpec?.selfScope,
1433
+ mode: aclMode,
1434
+ });
1435
+ query = query.whereRaw(filter.sql, filter.bindings as never[]);
1436
+ }
1437
+ }
1081
1438
  const rows = (await query) as Array<{resourceId: Buffer; label: string}>;
1082
1439
  result[`${s}.${object}`] = rows.map(r => ({
1083
1440
  value: r.resourceId.toString('base64'),
@@ -1238,8 +1595,16 @@ export {attachHandlers, methodId, snakeToCamel};
1238
1595
  export interface IResolvedTableOptions {
1239
1596
  /** Whether the table is resource-backed (PK → `core.resource`). */
1240
1597
  resource: boolean;
1598
+ /**
1599
+ * Real display-name column when it is not the virtual `${object}Name`
1600
+ * (declared as `resource: {nameColumn}`) — the resource row is named from it
1601
+ * and the column stays a normal table column.
1602
+ */
1603
+ nameColumn?: string;
1241
1604
  /** Declarative graph-edge master-detail bindings. */
1242
1605
  edges: IEdgeBinding[];
1606
+ /** Record-level ACL guard (`mode` defaults to `none`). */
1607
+ acl: IAclTableSpec;
1243
1608
  }
1244
1609
 
1245
1610
  /**
@@ -1260,7 +1625,9 @@ function resolveTableSpec(
1260
1625
  definition?: TObject;
1261
1626
  dropdown?: ISchemaTable['dropdown'];
1262
1627
  resource?: boolean;
1628
+ nameColumn?: string;
1263
1629
  edges?: IEdgeBinding[];
1630
+ acl?: IAclTableSpec;
1264
1631
  } {
1265
1632
  if (tableConfig === undefined) return {};
1266
1633
  if (typeof tableConfig === 'number') {
@@ -1275,14 +1642,21 @@ function resolveTableSpec(
1275
1642
  'order' in tableConfig ||
1276
1643
  'dropdown' in tableConfig ||
1277
1644
  'resource' in tableConfig ||
1278
- 'edges' in tableConfig
1645
+ 'edges' in tableConfig ||
1646
+ 'acl' in tableConfig
1279
1647
  ) {
1280
1648
  const spec = tableConfig as ISchemaTable;
1649
+ const resourceSpec =
1650
+ typeof spec.resource === 'object' && spec.resource !== null
1651
+ ? spec.resource
1652
+ : undefined;
1281
1653
  return {
1282
1654
  definition: spec.definition ?? objectSchema[subject]?.[object],
1283
1655
  dropdown: spec.dropdown,
1284
- resource: spec.resource,
1656
+ resource: resourceSpec ? true : spec.resource === true,
1657
+ nameColumn: resourceSpec?.nameColumn,
1285
1658
  edges: spec.edges,
1659
+ acl: spec.acl,
1286
1660
  };
1287
1661
  }
1288
1662
  return {definition: tableConfig as TObject};
@@ -1308,8 +1682,13 @@ function tableOptions(
1308
1682
  : {};
1309
1683
  const edges = spec.edges ?? [];
1310
1684
  return {
1311
- resource: spec.resource === true || edges.length > 0,
1685
+ // `resource` is declared explicitly — an `edges` binding alone does NOT
1686
+ // switch on the resource-backed name machinery (a table may carry edges
1687
+ // while keeping its own display-name column).
1688
+ resource: spec.resource === true,
1689
+ nameColumn: spec.nameColumn,
1312
1690
  edges,
1691
+ acl: spec.acl ?? {},
1313
1692
  };
1314
1693
  }
1315
1694