Skip to content

Commit 83480c6

Browse files
fix(security,service-analytics)!: the security contract publishes which fields a caller may query on, and the analytics field gate refuses a masked field as a group or filter member (#20935) (#20955)
Closes #20935 Clause-②: yes (narrowing) A field whose `maskingRule` applies to the caller is SERVED, with its value replaced, so the published read projection `ISecurityService.getReadableFields` counts it readable. It is not queryable: the engine refuses it as a group key, an aggregate input, a filter or a sort key, because each of those gives back what the mask hides. The analytics field gate from #20917 was exactly as wide as the read projection. So a masked-for-this-caller field could be grouped or filtered by on the native-SQL strategy, which compiles its own statement. The engine and the ObjectQL strategy refuse the same query. This PR publishes the missing answer on the security contract, implements it from the one decision the engine already makes, and has the analytics gate ask it. The masking rule itself is unchanged, and the analytics layer does not re-derive it. ## What changed, per surface - **Contract (`packages/spec/src/contracts/security-service.ts`).** `ISecurityService` gains an optional `getQueryableFields(object, context)`. It answers the fields the caller may filter, sort, group or aggregate by: the exact complement of what the engine's field guards refuse. It is a subset of `getReadableFields`, and the two differ by exactly the fields the caller is served masked. It has the same two empty answers as its siblings: `undefined` is no answer, `[]` is none. A system context gets every field. - Its absence is **not** soft. A consumer that cannot get this answer (the method is missing, or it answered `undefined`) must treat every field that declares a `maskingRule` as not queryable, whoever the caller is. Falling back to the read projection alone would admit exactly the masked fields. - **Implementation (`packages/plugins/plugin-security/src/security-plugin.ts`).** - The engine's predicate guard (step 2.9) and aggregate-input guard (step 2.5b) each built their "not queryable" map inline. They now call one private derivation, `computeQueryGuardFieldPerms`: the evaluator map, the `requiredPermissions` fold, the on-behalf-of delegator intersection, then every masked-for-this-caller field folded in as non-queryable. - `getQueryableFields` reads that same map, so a field is in the answer if and only if a query naming it passes both guards. The guards refuse exactly what they refused before. - It is registered on the `security` service beside `getReadableFields`. - **Gate (`packages/services/service-analytics/src/field-read-admission.ts` + minimal wiring in `analytics-service.ts` and `plugin.ts`).** - `AnalyticsServiceConfig` gains `getQueryableFields`. The door's one field gate asks it beside `getReadableFields` for the same objects. A member is admitted only when both answers carry its field. There is no second gate and no per-strategy copy. - A refused member answers `403 PERMISSION_DENIED` in the engine's own words for that field. Those are the words the engine answers a masked field with. - A queryable reader that throws refuses the query, fail-closed and logged at `error`, the same way the read reader does. - `AnalyticsServicePlugin` bridges the hook to the `security` service with the same absent / unusable / usable resolutions as the read half, plus one more state. When a usable service predates the method, or answers `undefined`, the bridge **fails closed**: the read projection less every field whose declaration carries a `maskingRule`, for every caller. That over-refuses a caller for whom the rule is lifted, which is the safe direction. It is also the only one available, because deciding for whom a rule is lifted would be a second copy of the masking rule. - A host that constructs `AnalyticsService` itself with `getReadableFields` and without `getQueryableFields` is warned once at construction. ## Why the PR line reads `yes (widening)`, and the analytics changeset declares a narrowing The line above is the claim's, as corrected under the seat's review adoption `5921385686` (`scripts/pm/clause2-line.mjs:93`: a diff that widens one surface and narrows another is spelled `yes (narrowing)`). The diff does widen the public surface: one optional contract member, its implementation on the service, and one optional config hook. It also narrows one accept set. Analytics queries that grouped, aggregated, filtered or sorted by a field the caller sees masked were answered on the native-SQL strategy (and printed by the SQL echo on both strategies), and they are now refused. That narrowing is declared where the level and ADR-0087 gates read it: in `.changeset/20935-analytics-masked-field-not-queryable.md`, which carries the `**BREAKING**` banner, its own `Clause-②: yes (narrowing)` line and an ADR-0087 `not-required (no-migration-prescription)` disposition, as #20917's changeset did. `@objectstack/spec` and `@objectstack/plugin-security` ship `minor` (widening). Every bumped package is `minor`, which is what `yes` requires. `check-changeset-no-major` and `check-adr-0087-registration` both exit 0. ## Pins - `packages/rest/src/analytics-masked-field-gate.test.ts` covers both compositions over the real `SecurityPlugin`, `ObjectQL` and `SqlDriver` (native-SQL first, and ObjectQL only), with synthetic fixtures. - Two callers read the same fields: a member for whom each field's masking rule applies, and an unmasker who holds the capability that lifts it. - For the member, a masked field as a group member and as a filter member answers the engine's live refusal for the same field and caller (code, status and message equal). This holds on the base object, through an authored alias and through a joined object, on the cube read, the SQL echo and the dataset door. Neither the engine's aggregate nor its raw statement runs. - Controls: the unmasker is answered exactly as the system caller is, and the member is answered for fields it may query on. - `packages/plugins/plugin-security/src/get-queryable-fields.test.ts` is an equivalence. For every field and four positions (filter, sort key, group key, aggregate input), "the real middleware admitted it" equals "the field is in `getQueryableFields`". The cases cover a member, the capability holder, an agent alone and the same agent on behalf of a delegator. It also pins the contract's answers: masked means readable and not queryable, the system and no-sets answers, the unresolvable object, the dangling delegator, and registration on the service. - `packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts` runs the masked positions its cases name (six) through both strategy paths with nothing executed. It also covers the throwing reader, each reader judged on its own, the construction warning, and the bridge: service answer, rule lifted, the fail-closed fallback for a service that predates the method and for an `undefined` answer, and no security service. - `packages/spec/src/contracts/security-service.test.ts` pins the member's optionality (an unguarded call does not compile), the masked-readable-not-queryable relation and the two empty answers. ## Ablations, predicted before running, at `20c56c9810` Each ablation followed the same legs: 1. Mutate through `scripts/ablation-replace.mjs`: the anchor hits once and the blob moves. 2. Where the suite reads `dist/`, rebuild the package and confirm with `ablation-dist-preflight` that the marker is in 2 built files. 3. Run the suites. 4. Restore, and prove the blob equals HEAD with an empty `git diff HEAD`. 5. Rebuild, and confirm with the `--absent` preflight that the marker is gone from all 6 built files and the tree is clean. - **A: the gate ignores the queryable answer** (`field-read-admission.ts`, service-analytics rebuilt). - Predicted and observed: route pins 4 red and 18 green. - Red, on each composition: the cube read and SQL echo, and the dataset door. Native answered the rows. ObjectQL printed the statement on the echo and reached the engine on the dataset door. - Green: the 6 references and controls, and all 12 of #20917's route pins. - Unit pins: 19 red and 53 green. The 5 greens are the "no answer", construction-warning, rule-lifted and no-security cases, plus all 48 of #20917's unit pins. - **B: plugin-security stops registering `getQueryableFields` on the service** (plugin-security rebuilt). - Predicted and observed: route pins 2 red and 8 green. Every member refusal stays green through the bridge's fail-closed fallback. The unmasker control goes red on both compositions, because the fallback over-refuses by design. - plugin-security: 1 red (registration on the service) and 9 green. - **C: the shared derivation stops folding masked fields in** (src-resolved suite, so no build leg). Observed 8 red and 38 green. - The 6 predicted `get-queryable-fields` reds. - The two pre-existing engine pins in `field-masking-rule.test.ts` (a filter and an aggregate over a masked field). This shows both engine guards now read the one derivation. Commits after `20c56c9810` touched only the bridge's fallback. It no longer exempts a system caller, which kept a new elevation read site out of the system-context census. None of the three ablations reaches that branch. ## Verification, at `fc844c433e` (main merged at `95fed33a20`) - `@objectstack/service-analytics`: `test` 147 files, 3398 passed, and `typecheck` 0 (the new test file is in the tsc program: it reported a tuple error before its fix). This run was taken at `e84882a09f`. The later edits were re-run: the two gate unit files, 72 passed, plus `typecheck` 0. - `@objectstack/plugin-security`: `test` 150 files, 3237 passed and 23 skipped, and `typecheck` 0 including the test layer. - `@objectstack/spec`: `typecheck` 0, `src/contracts` 45 files and 434 passed, and `check:generated` shows all 15 artifacts up to date. - `@objectstack/rest`: every `analytics-*` route file (13 files, 175 passed and 3 skipped), and `typecheck` 0 including the test layer. Both field-gate route files re-ran at `03153e00f1` after the second merge of main: 22 passed. - `dispatch-gates --commands` derived 87 commands at `fc844c433e`, with no stale tree. - Commands 1 to 59 exit 0. - `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: 44 packages have no `dist/` in this worktree). That is NOT MEASURED, not a pass. - The remaining commands, the lint narrowing and the `--ran` reconciliation are recorded in the os-dev-report on #20935, because this body is written once. ## Acceptance notes - NOT MEASURED: a live PostgreSQL cell. The gate answers before a strategy is chosen and before any statement is compiled. - The ObjectQL strategy serves no cross-object filter to anyone. So in the unmasker control, that one case is answered by the strategy's own refusal, identically for the system caller. That limitation is unchanged here. - `MemoryAnalyticsService` (driver-memory's cube face) is neither touched nor measured. - The bridge's fallback reads masking declarations from the data engine's registered schema, the registry the security service reads first. An object only the metadata service knows contributes no declarations to the fallback. This affects only a security service that predates the member. - Other consumers of `getReadableFields` are not changed here. Only the analytics door uses it to admit query positions. - #20933 (relationship-path object admission) is not addressed here. - PR #20916 also edits `analytics-service.ts`. The wiring here is kept to the config member, the stored provider, the construction warning and one extra argument at the one gate call site. --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1aee33 commit 83480c6

12 files changed

Lines changed: 1243 additions & 47 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics)!: a field the caller is served masked is refused as a group key, an aggregate input, a filter or a sort key on every analytics face, whichever strategy serves the cube (#20935)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or stored shape is removed or renamed. The change refuses analytics queries that group, aggregate, filter or sort by a field the caller may only see masked, which the engine already refuses on the data API and on the ObjectQL strategy, so there is nothing for `objectstack migrate meta` to rewrite. The one public-surface addition is a new optional service hook. -->
10+
11+
**BREAKING for analytics queries on a SQL deployment that group, aggregate, filter or sort by a field the caller may only see masked.**
12+
13+
**What changed.** The field-level gate on `POST /api/v1/analytics/query`,
14+
`POST /api/v1/analytics/sql` and `POST /api/v1/analytics/dataset/query` judged
15+
each member by the caller's readable fields. A field whose `maskingRule` applies
16+
to the caller is readable (its values are served masked), so the gate admitted
17+
it, and the native-SQL strategy then grouped or filtered by the stored value.
18+
The gate now also asks which fields the caller may query on, and refuses a
19+
member naming a masked field with `403 PERMISSION_DENIED`, in the words the
20+
engine uses for the same field. The ObjectQL strategy and the data API already
21+
refused these queries.
22+
23+
**What is not affected.** A caller who holds the capability that lifts a
24+
field's masking rule queries the field as before. A system context is
25+
unaffected. A query that names no masked field answers as before.
26+
27+
**New hook.** `AnalyticsServiceConfig.getQueryableFields(object, context)`
28+
supplies the answer. `AnalyticsServicePlugin` wires it to the `security`
29+
service's `getQueryableFields`. When that service predates the method, or
30+
answers "no answer", the plugin treats every field that declares a
31+
`maskingRule` as not queryable, for every caller. A host that
32+
constructs `AnalyticsService` itself with `getReadableFields` and without
33+
`getQueryableFields` is warned once at construction.
34+
35+
**If a widget stopped answering for some users,** it groups or filters by a
36+
field those users see masked. Give the users who need it the capability the
37+
field's `requiredPermissions` names, or build the widget on fields they can query.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
The `security` service implements `getQueryableFields(object, context)` (#20935). A field is in the answer exactly when a query naming it as a filter, a sort key, a group key or an aggregate input passes the engine's field guards: the answer is read from the one field map the predicate guard and the aggregate-input guard now share (permission sets, field grants, the `requiredPermissions` check, the on-behalf-of delegator intersection, and every field whose masking rule applies to the caller). The two guards refuse exactly what they refused before.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`ISecurityService` (`@objectstack/spec/contracts`) gains an optional `getQueryableFields(object, context)`: the field names field-level security lets the caller filter, sort, group or aggregate by on the object, the query-side twin of `getReadableFields` (#20935).
6+
7+
Clause-②: yes (widening)
8+
9+
- It is the exact complement of the fields the engine's field guards refuse when a query names them as a filter, a sort key, a group key or an aggregate input. It is a subset of `getReadableFields`, and the two differ by exactly the fields the caller is served masked: a field whose `maskingRule` applies to the caller is readable (served, its value replaced) and not queryable.
10+
- It fails soft like `getReadableFields`: `undefined` means no answer, `[]` means no field is queryable. A system context gets every field.
11+
- It is optional. A consumer checks `typeof svc.getQueryableFields === 'function'`. When the method is missing, or answers `undefined`, the consumer must treat every field that declares a `maskingRule` as not queryable, whoever the caller is. Falling back to `getReadableFields` alone would admit exactly the masked fields.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20935] `getQueryableFields` — the query-side twin of `getReadableFields`.
5+
*
6+
* The first block is an EQUIVALENCE, not a table of expected lists: for every
7+
* field of the object it drives the REAL registered middleware with a query
8+
* naming only that field — as a filter, as a sort key, as a group key and as an
9+
* aggregate input — and requires "the middleware admitted it" to equal "the
10+
* field is in `getQueryableFields`". The published answer and the engine's two
11+
* query guards come from one derivation; this is what keeps them one.
12+
*
13+
* The second block pins the answers the contract names, above all the one that
14+
* makes this method necessary: a field the caller is served MASKED is in the
15+
* read projection and NOT in the query one.
16+
*
17+
* Harness mirrors `get-writable-fields.test.ts`.
18+
*/
19+
20+
import { describe, it, expect, vi } from 'vitest';
21+
import type { PermissionSet } from '@objectstack/spec/security';
22+
import { SecurityPlugin } from './security-plugin.js';
23+
24+
const CRUD = { allowRead: true, allowCreate: true, allowEdit: true };
25+
26+
/**
27+
* The baseline every authenticated caller resolves: reads the object, and two
28+
* fields not at all — one of them declares a masking rule, which never widens
29+
* an explicit deny.
30+
*/
31+
const MEMBER_SET = {
32+
name: 'member_default',
33+
label: 'Reader',
34+
objects: { ledger: CRUD },
35+
fields: {
36+
'ledger.secret': { readable: false, editable: false },
37+
'ledger.denied_masked': { readable: false, editable: false },
38+
},
39+
} as unknown as PermissionSet;
40+
41+
/** The same grant plus the capability that lifts `gated_masked`'s rule. */
42+
const UNMASK_SET = {
43+
...(MEMBER_SET as object),
44+
label: 'Reader who may see the gated field unmasked',
45+
systemPermissions: ['view_gated'],
46+
} as unknown as PermissionSet;
47+
48+
/** An agent's own set, holding the capability: the D10 case turns on the intersection alone. */
49+
const AGENT_SET = {
50+
name: 'agent_reader',
51+
label: 'Agent',
52+
objects: { ledger: CRUD },
53+
systemPermissions: ['view_gated'],
54+
} as unknown as PermissionSet;
55+
56+
const SCHEMAS: Record<string, unknown> = {
57+
ledger: {
58+
name: 'ledger',
59+
fields: {
60+
title: { type: 'text', label: 'Title' },
61+
// Masked unless the caller holds `view_gated` (the rule's unmask gate).
62+
gated_masked: { type: 'text', label: 'Gated', maskingRule: { keepHead: 1, keepTail: 1 }, requiredPermissions: ['view_gated'] },
63+
// A rule with no gate: masked for every non-system caller.
64+
always_masked: { type: 'text', label: 'Always', maskingRule: 'name' },
65+
secret: { type: 'text', label: 'Secret' },
66+
denied_masked: { type: 'text', label: 'Denied', maskingRule: 'name' },
67+
},
68+
},
69+
};
70+
/** The field universe the plugin resolves: the schema's fields plus `id`. */
71+
const FIELDS = ['id', 'title', 'gated_masked', 'always_masked', 'secret', 'denied_masked'];
72+
73+
const MEMBER_CTX = { userId: 'u_member', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' };
74+
const LIVE_DELEGATOR = 'u_boss';
75+
const AGENT_CTX = { userId: 'u_agent', tenantId: 'org-1', positions: [], permissions: ['agent_reader'], posture: 'MEMBER' };
76+
const DELEGATED_AGENT_CTX = { ...AGENT_CTX, onBehalfOf: { userId: LIVE_DELEGATOR } };
77+
78+
async function boot(sets: PermissionSet[], opts: { noBaseline?: boolean } = {}) {
79+
const middlewares: Array<(opCtx: any, next: () => Promise<void>) => Promise<void>> = [];
80+
const services: Record<string, unknown> = {
81+
manifest: { register: vi.fn() },
82+
objectql: {
83+
registerMiddleware: (mw: any) => middlewares.push(mw),
84+
getSchema: (name: string) => SCHEMAS[name],
85+
findOne: vi.fn(async (_object: string, query: any) =>
86+
(query?.where?.id === LIVE_DELEGATOR ? { id: LIVE_DELEGATOR, email: 'boss@example.test' } : null)),
87+
},
88+
metadata: {
89+
get: async (_type: string, name: string) => SCHEMAS[name],
90+
list: async () => sets,
91+
},
92+
};
93+
const registerService = vi.fn();
94+
const ctx: Record<string, unknown> = {
95+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
96+
registerService,
97+
getService: (name: string) => {
98+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
99+
return services[name];
100+
},
101+
};
102+
const plugin = new SecurityPlugin(
103+
opts.noBaseline
104+
? { defaultPermissionSets: [], fallbackPermissionSet: null }
105+
: { fallbackPermissionSet: 'member_default' },
106+
);
107+
await plugin.init(ctx as any);
108+
await plugin.start(ctx as any);
109+
if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware');
110+
return { plugin, middleware: middlewares[0], registerService };
111+
}
112+
113+
/** Each way a query can name one field: the four positions the engine's two query guards judge. */
114+
const PROBES: ReadonlyArray<readonly [string, 'find' | 'aggregate', (field: string) => Record<string, unknown>]> = [
115+
['a filter', 'find', (field) => ({ where: { [field]: 'v' } })],
116+
['a sort key', 'find', (field) => ({ where: {}, orderBy: [{ field, order: 'asc' }] })],
117+
['a group key', 'aggregate', (field) => ({ groupBy: [field], aggregations: [{ function: 'count', alias: 'n' }] })],
118+
['an aggregate input', 'aggregate', (field) => ({ aggregations: [{ function: 'max', field, alias: 'm' }] })],
119+
];
120+
121+
async function middlewareAdmits(
122+
middleware: (opCtx: any, next: () => Promise<void>) => Promise<void>,
123+
operation: 'find' | 'aggregate',
124+
context: Record<string, unknown>,
125+
ast: Record<string, unknown>,
126+
): Promise<{ admitted: true } | { admitted: false; code?: unknown; status?: unknown }> {
127+
try {
128+
await middleware({ object: 'ledger', operation, context: { ...context }, options: {}, ast }, async () => {});
129+
return { admitted: true };
130+
} catch (e) {
131+
const err = e as { code?: unknown; status?: unknown; statusCode?: unknown };
132+
return { admitted: false, code: err.code, status: err.status ?? err.statusCode };
133+
}
134+
}
135+
136+
describe('[#20935] getQueryableFields agrees with the middleware\'s query guards, field for field', () => {
137+
const CASES: Array<{ label: string; sets: PermissionSet[]; context: Record<string, unknown>; queryable: string[] }> = [
138+
{ label: 'a member: two fields served masked, two hidden', sets: [MEMBER_SET], context: MEMBER_CTX, queryable: ['id', 'title'] },
139+
{ label: 'a member holding the capability that lifts one rule', sets: [UNMASK_SET], context: MEMBER_CTX, queryable: ['id', 'title', 'gated_masked'] },
140+
{ label: 'an agent holding that capability, acting for nobody', sets: [AGENT_SET, MEMBER_SET], context: AGENT_CTX, queryable: ['id', 'title', 'gated_masked'] },
141+
{ label: 'the same agent acting for a delegator who does not hold it', sets: [AGENT_SET, MEMBER_SET], context: DELEGATED_AGENT_CTX, queryable: ['id', 'title'] },
142+
];
143+
144+
for (const c of CASES) {
145+
it(c.label, async () => {
146+
const { plugin, middleware } = await boot(c.sets);
147+
const queryable = await plugin.getQueryableFields('ledger', c.context);
148+
// The expected list keeps each case honest about what it exercises; the
149+
// equivalence below is the pin.
150+
expect(queryable).toEqual(c.queryable);
151+
for (const field of FIELDS) {
152+
for (const [position, operation, ast] of PROBES) {
153+
const verdict = await middlewareAdmits(middleware, operation, c.context, ast(field));
154+
if (queryable!.includes(field)) {
155+
expect(verdict, `${field} as ${position}`).toEqual({ admitted: true });
156+
} else {
157+
expect(verdict, `${field} as ${position}`).toMatchObject({ admitted: false, code: 'PERMISSION_DENIED', status: 403 });
158+
}
159+
}
160+
}
161+
});
162+
}
163+
});
164+
165+
describe('[#20935] getQueryableFields — the answers the contract names', () => {
166+
it('a field the caller is served MASKED is readable and NOT queryable; the difference is exactly the masked fields', async () => {
167+
const { plugin } = await boot([MEMBER_SET]);
168+
const readable = await plugin.getReadableFields('ledger', MEMBER_CTX);
169+
const queryable = await plugin.getQueryableFields('ledger', MEMBER_CTX);
170+
expect(readable).toEqual(['id', 'title', 'gated_masked', 'always_masked']);
171+
expect(queryable).toEqual(['id', 'title']);
172+
expect(queryable!.every((f) => readable!.includes(f))).toBe(true);
173+
expect(readable!.filter((f) => !queryable!.includes(f))).toEqual(['gated_masked', 'always_masked']);
174+
});
175+
176+
it('a system context bypasses: the full field set', async () => {
177+
const { plugin } = await boot([MEMBER_SET]);
178+
expect(await plugin.getQueryableFields('ledger', { isSystem: true })).toEqual(FIELDS);
179+
});
180+
181+
it('no permission sets resolved: the full field set, as the middleware skips both guards', async () => {
182+
const { plugin } = await boot([], { noBaseline: true });
183+
expect(await plugin.getQueryableFields('ledger', MEMBER_CTX)).toEqual(FIELDS);
184+
});
185+
186+
it('an unresolvable object is no answer (undefined), not an empty one', async () => {
187+
const { plugin } = await boot([MEMBER_SET]);
188+
expect(await plugin.getQueryableFields('no_such_object', MEMBER_CTX)).toBeUndefined();
189+
});
190+
191+
it('a delegator that does not exist fails closed: []', async () => {
192+
const { plugin } = await boot([AGENT_SET, MEMBER_SET]);
193+
expect(await plugin.getQueryableFields('ledger', { ...AGENT_CTX, onBehalfOf: { userId: 'u_ghost' } })).toEqual([]);
194+
});
195+
196+
it('is exposed on the registered "security" service', async () => {
197+
const { registerService } = await boot([MEMBER_SET]);
198+
const svc = registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1];
199+
expect(typeof svc?.getQueryableFields).toBe('function');
200+
expect(await svc.getQueryableFields('ledger', MEMBER_CTX)).toEqual(['id', 'title']);
201+
});
202+
});

0 commit comments

Comments
 (0)