@feasibleone/blong-gogo 1.31.3 → 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,25 @@
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
+
10
+ ## [1.32.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.31.3...blong-gogo-v1.32.0) (2026-09-12)
11
+
12
+
13
+ ### Features
14
+
15
+ * blong-kukum scaffolding as api ([abb1e0b](https://github.com/feasibleone/blong/commit/abb1e0b4836f25e4a04b035cbc3f2b4bbab94a9b))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * small test fixes ([4adb7eb](https://github.com/feasibleone/blong/commit/4adb7eb1b899099fe71b05b96cd210af7d7ce51a))
21
+ * small test/lint fixes ([0f52e7a](https://github.com/feasibleone/blong/commit/0f52e7a30fa479c1a91f95ba5d8bb1ae4706856c))
22
+
3
23
  ## [1.31.3](https://github.com/feasibleone/blong/compare/blong-gogo-v1.31.2...blong-gogo-v1.31.3) (2026-09-10)
4
24
 
5
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.31.3",
3
+ "version": "1.33.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -10,7 +10,8 @@
10
10
  "browser": "./src/loadBrowser.ts",
11
11
  "import": "./src/loadServer.ts"
12
12
  },
13
- "./ConfigRuntime.js": "./src/ConfigRuntime.ts"
13
+ "./ConfigRuntime.js": "./src/ConfigRuntime.ts",
14
+ "./cli.ts": "./src/cli.ts"
14
15
  },
15
16
  "bin": {
16
17
  "blong": "./bin/blong.ts",
@@ -19,6 +20,7 @@
19
20
  },
20
21
  "dependencies": {
21
22
  "@apidevtools/swagger-parser": "^12.1.0",
23
+ "@modelcontextprotocol/sdk": "^1.0.0",
22
24
  "@aws-sdk/client-s3": "^3.1037.0",
23
25
  "@fastify/basic-auth": "^6.2.0",
24
26
  "@fastify/bearer-auth": "^10.1.2",
@@ -88,7 +90,7 @@
88
90
  "tap": "^21.6.3",
89
91
  "typescript": "^6.0.3",
90
92
  "vite": "^8.2.1",
91
- "@feasibleone/blong-dev": "1.2.2"
93
+ "@feasibleone/blong-dev": "1.4.0"
92
94
  },
93
95
  "scripts": {
94
96
  "browser-check": "node scripts/browser-compat-check.mjs",
@@ -87,6 +87,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
87
87
  log: unknown = null;
88
88
  importedMap?: Map<string, Record<string, (...args: unknown[]) => unknown>>;
89
89
  platform: IApi['platform'];
90
+ registry: IApi['registry'];
90
91
 
91
92
  // These are prefixed with _ rather than using # private class fields.
92
93
  // The adapter uses Object.setPrototypeOf(current, base) to set the base as
@@ -125,6 +126,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
125
126
  | 'attachCheckpoint'
126
127
  | 'render'
127
128
  | 'platform'
129
+ | 'registry'
128
130
  | 'attach'
129
131
  >,
130
132
  configBase: string,
@@ -137,6 +139,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
137
139
  this._methodId = api.methodId;
138
140
  this._getPath = api.getPath;
139
141
  this.platform = api.platform;
142
+ this.registry = api.registry;
140
143
  this._api = api;
141
144
  this._createLog = api.createLog;
142
145
  this._attachCheckpoint = api.attachCheckpoint;
package/src/Gateway.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  IErrorFactory,
6
6
  IErrorMap,
7
7
  IGateway,
8
+ IGatewayRoute,
8
9
  ILocal,
9
10
  ILog,
10
11
  IManifest,
@@ -194,6 +195,25 @@ export default class Gateway extends Internal implements IGateway {
194
195
  #rpcClient: IRpcClient;
195
196
  #routes: RouteOptions[] = [];
196
197
  #local: ILocal;
198
+
199
+ /**
200
+ * The registered routes as plain data.
201
+ *
202
+ * Derived from the same list that is handed to fastify, so callers (for
203
+ * example the MCP server) see exactly the surface that is served — including
204
+ * the per-route auth requirement, which is not inferable from the method name.
205
+ */
206
+ public describe(): IGatewayRoute[] {
207
+ return this.#routes.map(route => {
208
+ const config = route.config as {methodName?: string; auth?: unknown} | undefined;
209
+ return {
210
+ method: config?.methodName ?? '',
211
+ url: route.url,
212
+ httpMethod: String(route.method ?? 'POST'),
213
+ auth: config?.auth ?? 'jwt',
214
+ };
215
+ });
216
+ }
197
217
  #errorFields: [string, unknown][] = [];
198
218
  #plugins: {plugin: unknown; options: unknown}[] = [];
199
219
  #platform: IPlatformApi;
package/src/Mcp.ts ADDED
@@ -0,0 +1,227 @@
1
+ import type {IGateway, IGatewayRoute, ILog, IRegistry, IRemote} from '@feasibleone/blong/types';
2
+ import {Internal} from '@feasibleone/blong/types';
3
+ import type {FastifyInstance} from 'fastify';
4
+ import fp from 'fastify-plugin';
5
+
6
+ interface IConfig {
7
+ enabled: boolean;
8
+ routePrefix: string;
9
+ auth: false | 'jwt';
10
+ /** Server identity reported during the MCP initialise handshake. */
11
+ name: string;
12
+ version: string;
13
+ /**
14
+ * Method-name patterns to expose as tools. Defaults to the kukum API —
15
+ * widen this to expose other namespaces, or set `['.']` to expose every
16
+ * registered route.
17
+ */
18
+ tools: string[];
19
+ /** Free-text guidance sent to the client during initialise. */
20
+ instructions?: string;
21
+ logLevel?: string;
22
+ }
23
+
24
+ interface IApiRef {
25
+ log?: ILog;
26
+ gateway?: IGateway;
27
+ registry?: IRegistry;
28
+ remote?: IRemote;
29
+ }
30
+
31
+ /**
32
+ * MCP (Model Context Protocol) server for a Blong gateway, served over
33
+ * Streamable HTTP at `routePrefix` (default `/mcp`).
34
+ *
35
+ * Tools are derived from the gateway's own route table, so the MCP surface can
36
+ * never drift from the API: whatever `kukum.*` (or another configured namespace)
37
+ * route is registered becomes a callable tool, and invoking it dispatches
38
+ * through the same `remote` path an HTTP call would use.
39
+ *
40
+ * Off by default — this exposes an API to any MCP client that can reach the
41
+ * gateway, so it must be enabled deliberately (a suite can enable it in its own
42
+ * `dev` config without affecting anyone else). The SDK is imported lazily so the
43
+ * dependency is not loaded on the default path.
44
+ */
45
+ export default class Mcp extends Internal {
46
+ #config: IConfig = {
47
+ enabled: false,
48
+ routePrefix: '/mcp',
49
+ auth: false,
50
+ name: 'blong',
51
+ version: '0.0.0',
52
+ tools: ['^kukum\\.'],
53
+ };
54
+
55
+ #apiRef: IApiRef;
56
+ /** Sanitised tool name → gateway method name. */
57
+ #tools: Map<string, string> = new Map();
58
+
59
+ public constructor(config: IConfig, apiRef: IApiRef) {
60
+ super({log: apiRef.log});
61
+ this.merge(this.#config, config);
62
+ this.#apiRef = apiRef;
63
+ }
64
+
65
+ /** MCP tool names must be `[A-Za-z0-9_-]`; the dotted method maps 1:1. */
66
+ static toolName(method: string): string {
67
+ return method.replaceAll('.', '_').replaceAll('/', '_');
68
+ }
69
+
70
+ /**
71
+ * Routes to expose as tools.
72
+ *
73
+ * `tools` entries are regular expressions matched against the wired method
74
+ * name, so the default `^kukum\\.` selects the whole kukum API. They are used
75
+ * verbatim — anchoring is the caller's choice.
76
+ */
77
+ public routes(): IGatewayRoute[] {
78
+ const patterns = this.#config.tools.map(pattern => new RegExp(pattern));
79
+ return (this.#apiRef.gateway?.describe?.() ?? []).filter(
80
+ route => route.method && patterns.some(pattern => pattern.test(route.method)),
81
+ );
82
+ }
83
+
84
+ public async init(): Promise<void> {
85
+ if (!this.#config.enabled || !this.#apiRef.gateway) return;
86
+
87
+ const remote = this.#apiRef.remote;
88
+ if (!remote) {
89
+ this.log?.warn?.('mcp is enabled but no remote dispatcher is available; skipping');
90
+ return;
91
+ }
92
+
93
+ const prefix = this.#config.routePrefix;
94
+ const authConfig = this.#config.auth;
95
+ const logger = this.log;
96
+ const {name, version, instructions, tools: toolPatterns} = this.#config;
97
+
98
+ // Deferred into the plugin factory on purpose: fastify runs plugin
99
+ // factories during `gateway.start()`, which is AFTER `registry.start()`
100
+ // has published the validations. Reading `gateway.describe()` in
101
+ // `init()` would see an empty route table.
102
+ const plugin = fp(
103
+ async (instance: FastifyInstance) => {
104
+ const routes = this.routes();
105
+ if (!routes.length) {
106
+ logger?.warn?.(
107
+ `mcp is enabled but no routes match ${JSON.stringify(toolPatterns)}`,
108
+ );
109
+ }
110
+
111
+ // Lazy: nothing on the default path should pay for the SDK.
112
+ const {McpServer} = await import('@modelcontextprotocol/sdk/server/mcp.js');
113
+ const {StreamableHTTPServerTransport} =
114
+ await import('@modelcontextprotocol/sdk/server/streamableHttp.js');
115
+ const {randomUUID} = await import('node:crypto');
116
+
117
+ const server = new McpServer({name, version}, {instructions});
118
+ for (const route of routes) {
119
+ const tool = Mcp.toolName(route.method);
120
+ this.#tools.set(tool, route.method);
121
+ server.registerTool(
122
+ tool,
123
+ {
124
+ title: route.method,
125
+ description:
126
+ `${route.method} — POST ${route.url}` +
127
+ (route.auth === false ? ' (no auth required)' : ''),
128
+ },
129
+ async (args: unknown) => {
130
+ const params = (args ?? {}) as Record<string, unknown>;
131
+ const result = await remote.remote(route.method)(params, {
132
+ method: route.method,
133
+ });
134
+ return {
135
+ content: [
136
+ {type: 'text' as const, text: JSON.stringify(result, null, 2)},
137
+ ],
138
+ };
139
+ },
140
+ );
141
+ }
142
+
143
+ /**
144
+ * One transport per MCP session, keyed by `Mcp-Session-Id`.
145
+ *
146
+ * Sessions are stateful because the protocol requires
147
+ * `initialize` to precede other methods on the same session: a
148
+ * stateless per-request server answers every follow-up with
149
+ * "Method not found".
150
+ */
151
+ const sessions = new Map<
152
+ string,
153
+ InstanceType<typeof StreamableHTTPServerTransport>
154
+ >();
155
+
156
+ const sessionFor = async (sessionId?: string) => {
157
+ const existing = sessionId ? sessions.get(sessionId) : undefined;
158
+ if (existing) return existing;
159
+ const transport = new StreamableHTTPServerTransport({
160
+ sessionIdGenerator: () => randomUUID(),
161
+ onsessioninitialized: id => {
162
+ sessions.set(id, transport);
163
+ },
164
+ // Plain JSON responses rather than SSE streams: kukum
165
+ // calls are request/response with no server-initiated
166
+ // messages, which keeps the endpoint usable from curl and
167
+ // simple CLI clients.
168
+ enableJsonResponse: true,
169
+ });
170
+ transport.onclose = () => {
171
+ if (transport.sessionId) sessions.delete(transport.sessionId);
172
+ };
173
+ await server.connect(transport);
174
+ return transport;
175
+ };
176
+
177
+ const handle = async (
178
+ request: Parameters<Parameters<FastifyInstance['route']>[0]['handler']>[0],
179
+ reply: Parameters<Parameters<FastifyInstance['route']>[0]['handler']>[1],
180
+ ) => {
181
+ // The transport writes the response itself (including SSE
182
+ // streams), so hand it the raw Node objects and tell fastify
183
+ // not to touch the reply.
184
+ reply.hijack();
185
+ const sessionId = request.headers['mcp-session-id'] as string | undefined;
186
+ try {
187
+ const transport = await sessionFor(sessionId);
188
+ await transport.handleRequest(request.raw, reply.raw, request.body);
189
+ } catch (error) {
190
+ // `reply.hijack()` means fastify will not produce an error
191
+ // response — without this the client just sees an empty 500.
192
+ logger?.error?.({error}, 'mcp request failed');
193
+ if (!reply.raw.headersSent) {
194
+ reply.raw.writeHead(500, {'content-type': 'application/json'});
195
+ }
196
+ reply.raw.end(
197
+ JSON.stringify({
198
+ jsonrpc: '2.0',
199
+ error: {code: -32603, message: (error as Error).message},
200
+ id: null,
201
+ }),
202
+ );
203
+ }
204
+ };
205
+
206
+ instance.route({
207
+ method: ['GET', 'POST', 'DELETE'],
208
+ url: prefix,
209
+ config: {auth: authConfig},
210
+ handler: handle,
211
+ });
212
+ instance.addHook('onClose', async () => {
213
+ for (const transport of sessions.values()) await transport.close();
214
+ sessions.clear();
215
+ });
216
+
217
+ logger?.warn?.(
218
+ `mcp server enabled at ${prefix} — exposing ${routes.length} tool(s); ` +
219
+ 'do not enable in production',
220
+ );
221
+ },
222
+ {name: 'mcp'},
223
+ );
224
+
225
+ this.#apiRef.gateway.registerPlugin(plugin);
226
+ }
227
+ }
package/src/Registry.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  IObjectSchema,
15
15
  IPlatformApi,
16
16
  IRegistry,
17
+ IRegistryDescription,
17
18
  IRemote,
18
19
  IRpcServer,
19
20
  } from '@feasibleone/blong/types';
@@ -375,6 +376,19 @@ export default class Registry extends Internal implements IRegistry {
375
376
  renderAll(Array.isArray(what) ? merge(...what) : what, this.#platform.context),
376
377
  timing: this.#platform.timing,
377
378
  ...blongLib,
379
+ // The runtime services a `library()` function needs, mirroring the
380
+ // port API (`registry: this`, `platform: …`).
381
+ //
382
+ // A `library()` *factory* is not given the platform: `layerApi`
383
+ // carries only config/handler/errors/schema/gateway/apiSchema. And
384
+ // the lib proxy returns an attached function **raw**, so `this`
385
+ // inside it is this object — not the port, which is only bound in the
386
+ // proxy's not-yet-attached fallback path. Without these keys a
387
+ // library function therefore cannot reach the platform or registry at
388
+ // all, which is what a library needs to be reusable outside a
389
+ // request.
390
+ platform: this.#platform,
391
+ registry: this,
378
392
  };
379
393
  const local = {};
380
394
  const literals: object[] = [];
@@ -469,6 +483,31 @@ export default class Registry extends Internal implements IRegistry {
469
483
  return await this.#watch.test(framework);
470
484
  }
471
485
 
486
+ /**
487
+ * Structural snapshot of the loaded realm graph.
488
+ *
489
+ * Combines the live registry (realms, ports, registered method groups) with
490
+ * the on-disk view owned by `Watch` (handler folders and files). This is the
491
+ * runtime source of truth that tooling and agents should query instead of
492
+ * re-deriving layout from conventions.
493
+ */
494
+ public describe(): IRegistryDescription {
495
+ const disk = this.#watch?.describe?.();
496
+ return {
497
+ realms: Array.from(this.modules.keys()).filter(
498
+ (key): key is string => typeof key === 'string',
499
+ ),
500
+ ports: Array.from(this.ports.keys()),
501
+ groups: Array.from(this.methods.entries()).map(([name, handlers]) => ({
502
+ name,
503
+ handlerCount: handlers.length,
504
+ })),
505
+ folders: disk?.folders ?? [],
506
+ files: disk?.files ?? [],
507
+ layerFiles: disk?.layerFiles ?? [],
508
+ };
509
+ }
510
+
472
511
  public async stop(): Promise<IRegistry> {
473
512
  if (this.#stopped) return this;
474
513
  await this.#watch?.stop();
package/src/Watch.ts CHANGED
@@ -36,6 +36,18 @@ export interface IWatch {
36
36
  setConfigRuntime?(configRuntime: IConfigRuntime): void;
37
37
  /** Attach the registry objectSchema so hot-reload paths can pass it to layerProxy */
38
38
  setObjectSchema?(schema: IObjectSchema): void;
39
+ /**
40
+ * Structural snapshot of the handler folders/files discovered on disk.
41
+ * Data-only so it can be returned straight from an API endpoint.
42
+ */
43
+ describe(): IWatchDescription;
44
+ }
45
+
46
+ /** Handler folders/files discovered from disk, keyed by their owning realm. */
47
+ export interface IWatchDescription {
48
+ folders: Array<{group: string; realm: string; dir: string}>;
49
+ files: Array<{file: string; realm: string}>;
50
+ layerFiles: Array<{file: string; realm: string}>;
39
51
  }
40
52
 
41
53
  interface ITestContext {
@@ -97,6 +109,13 @@ export default class Watch extends Internal implements IWatch {
97
109
  new Map();
98
110
  #handlerFiles: Map<string, {name: string; pkg: IModuleConfig['pkg']; base: string}> = new Map();
99
111
  #layerFiles: Map<string, {name: string; pkg: IModuleConfig['pkg']; base: string}> = new Map();
112
+ /**
113
+ * Every code file discovered inside a handler folder, mapped to its realm.
114
+ * Kept separate from `#handlerFiles` (which drives hot-reload decisions and
115
+ * is deliberately populated only for single-file loads) so introspection can
116
+ * report the full file set without changing reload behaviour.
117
+ */
118
+ #discoveredFiles: Map<string, string> = new Map();
100
119
  #watchers: IWatcher[] = [];
101
120
  #port: () => unknown;
102
121
  #error: IErrorFactory;
@@ -138,6 +157,35 @@ export default class Watch extends Internal implements IWatch {
138
157
  this.#objectSchema = schema;
139
158
  }
140
159
 
160
+ /**
161
+ * Structural snapshot of what is loaded from disk.
162
+ *
163
+ * Group ids follow the same convention the loader registers with
164
+ * (`<realm>.<folder>`, see `_loadHandlers`). Paths are made relative to the
165
+ * process cwd so the result is stable across machines.
166
+ */
167
+ public describe(): IWatchDescription {
168
+ const relative = (file: string): string => this.#platform.relative('.', file);
169
+ const folderEntries = Array.from(this.#handlerFolders.entries()).map(([dir, config]) => ({
170
+ group: `${config.name}.${this.#platform.basename(dir)}`,
171
+ realm: config.name,
172
+ dir: relative(dir),
173
+ }));
174
+ const toFiles = (map: Map<string, {name: string}>): Array<{file: string; realm: string}> =>
175
+ Array.from(map.entries()).map(([file, config]) => ({
176
+ file: relative(file),
177
+ realm: config.name,
178
+ }));
179
+ return {
180
+ folders: folderEntries,
181
+ files: Array.from(this.#discoveredFiles.entries()).map(([file, realm]) => ({
182
+ file: relative(file),
183
+ realm,
184
+ })),
185
+ layerFiles: toFiles(this.#layerFiles),
186
+ };
187
+ }
188
+
141
189
  /** Attach a ConfigRuntime so config-file changes trigger in-process reloads */
142
190
  public setConfigRuntime(configRuntime: IConfigRuntime): void {
143
191
  this.#configRuntime = configRuntime;
@@ -327,6 +375,7 @@ export default class Watch extends Internal implements IWatch {
327
375
  filename,
328
376
  this.#platform.extname(filename),
329
377
  );
378
+ this.#discoveredFiles.set(filename, config.name);
330
379
  const name = expectedName;
331
380
  (kind(item) === 'validation'
332
381
  ? validations
@@ -416,6 +465,10 @@ export default class Watch extends Internal implements IWatch {
416
465
  this.#config.enabled ? filename + '?' + Date.now() : filename
417
466
  )
418
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;
419
472
  const expectedName = this.#platform
420
473
  .basename(filename, this.#platform.extname(filename))
421
474
  .match(prefixRE)?.[1];
@@ -659,13 +712,14 @@ export default class Watch extends Internal implements IWatch {
659
712
  remote: IRemote,
660
713
  configOverride: object,
661
714
  ): Promise<void> {
662
- this.log?.debug?.({
663
- $meta: {mtid: 'event', method: 'watch.start'},
664
- dir: Array.from(this.#handlerFolders.keys())
665
- .concat(Array.from(this.#handlerFiles.keys()))
666
- .concat(Array.from(this.#layerFiles.keys()))
667
- .map(folder => this.#platform.relative('.', folder)),
668
- });
715
+ if (this.#config.enabled)
716
+ this.log?.debug?.({
717
+ $meta: {mtid: 'event', method: 'watch.start'},
718
+ dir: Array.from(this.#handlerFolders.keys())
719
+ .concat(Array.from(this.#handlerFiles.keys()))
720
+ .concat(Array.from(this.#layerFiles.keys()))
721
+ .map(folder => this.#platform.relative('.', folder)),
722
+ });
669
723
  if (this.#config.test) {
670
724
  this.#emit.addEventListener('test', async (event: Event) => {
671
725
  const {done, test} = ((event as CustomEvent).detail ?? {}) as {
@@ -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