Skip to content

Commit bca935b

Browse files
authored
fix(spec,runtime): the slot→contract ledger extends past CoreServiceName (#4127) (#4202)
Batch 3 of the #4127 gate, after #4168 (`getService`) and #4176 (`resolveService`). Three slots — `security`, `shareLinks`, `objectql` — each had a written contract, a provider registering them, and call sites already inside the contract. The only missing link was that the slot name was not a `CoreServiceName` member, so nothing could connect them and all three sat behind `as any`. The ledger extends past the enum rather than the enum growing. The two answer different questions, and conflating them is what left these untyped: `CoreServiceName` answers "what happens at boot when this slot is empty?" — it sits beside `ServiceCriticality` and drives startup orchestration and discovery, so adding a member changes runtime behaviour and is effectively permanent. The ledger answers "what shape occupies this slot?" — pure type information. These three need only the second, so `ServiceSlotContracts extends CoreServiceContracts` adds them there and `resolveService` keys on `keyof ServiceSlotContracts`. Zero runtime effect. If one is later promoted to a genuine core service, its entry moves up and nothing else changes; a test asserts these keys are NOT enum members, so that migration cannot happen silently. Evidence before an entry, as always: `plugin-security` registers `security` and `ISecurityService`'s own doc names that registration; `plugin-sharing` registers `ShareLinkService`, which declares `implements IShareLinkService`; and `objectql` is an ALIAS of `data` — `packages/objectql`'s plugin registers the same instance under both names two lines apart, so one object was resolving as `IDataEngine` through one name and `any` through the other. `protocol` (22 call sites) and `mcp` have no written contract and stay unmapped. Turning it on found four things, all on the `/security` admin surface: 1. Request input reached the security service unvalidated. `?status=` was `String(query.status)` — any string — handed to a method whose contract declares exactly three values, and from there into the query's `where` clause. Not an injection (the `where` is structured, never interpolated), but `?status=garbage` matched no row and returned an empty list, which reads as "there are no suggestions" rather than "that is not a status". Now a 400. 2. A test pinned that bug as expected behaviour. The existing case asserted `status: 'open'` — not one of the three declared values — reached the service and returned 200. It proved the delegate carried A FILTER and nothing about that filter being a status. Same shape as batch 1's `auth.handler` mocks: coverage in appearance, a wrong contract in substance. 3. and 4. Two writes could not prove they had a caller. `confirmAudienceBindingSuggestion`/`dismissAudienceBindingSuggestion` declare `callerContext: SecurityContext` non-optionally — deliberately, since the read beside them declares it optional — and the domain passed a possibly-`undefined` execution context. This was NOT a live hole, and the distinction matters: with no execution context `shouldDenyAnonymous` already denied, because it sees no `userId`/`isSystem` and its allowlist arm needs a non-empty `path` this seam never passes, so it fell through to `return true`. What it never did was narrow `ec` itself — it only read `ec?.userId`. Checking `ec` directly is behaviour-preserving and makes the invariant legible to the compiler and the next reader. The `?status=` rejection is the one BEHAVIOUR CHANGE: an unknown status was a silent empty list and is now a 400 naming the accepted values. The accepted set is a `Record` keyed on the contract type, so adding a status to the contract leaves a key missing and renaming one leaves a key excess — either fails to compile, where a plain array would have drifted silently. Documented on the permission-sets page, where the endpoint is described. Refs #4127
1 parent 4e9fdfa commit bca935b

10 files changed

Lines changed: 305 additions & 22 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(spec,runtime): the slot→contract ledger extends past `CoreServiceName`, and `/security` stops passing unvalidated input to the security service (#4127)
7+
8+
Batch 3 of the #4127 gate, after #4168 (`getService`) and #4176 (`resolveService`).
9+
10+
Three slots — `security`, `shareLinks`, `objectql` — each had a written
11+
contract, a provider registering them, and call sites already inside the
12+
contract. The only missing link was that the slot name was not a
13+
`CoreServiceName` member, so nothing could connect them and all three sat behind
14+
`as any`.
15+
16+
**The ledger extends past the enum rather than the enum growing.** The two
17+
answer different questions, and conflating them is what left these untyped:
18+
`CoreServiceName` answers *"what happens at boot when this slot is empty?"* — it
19+
sits beside `ServiceCriticality` and drives startup orchestration and discovery,
20+
so adding a member changes runtime behaviour and is effectively permanent. The
21+
ledger answers *"what shape occupies this slot?"* — pure type information. These
22+
three need only the second, so `ServiceSlotContracts extends CoreServiceContracts`
23+
adds them there and `resolveService` keys on `keyof ServiceSlotContracts`. Zero
24+
runtime effect. If one is later promoted to a genuine core service, its entry
25+
moves up and nothing else changes.
26+
27+
Evidence, as always, before an entry: `plugin-security` registers `security` and
28+
`ISecurityService`'s own doc names that registration; `plugin-sharing` registers
29+
`ShareLinkService`, which declares `implements IShareLinkService`; and `objectql`
30+
is an **alias of `data`**`packages/objectql`'s plugin registers the *same
31+
instance* under both names two lines apart, so one object was resolving as
32+
`IDataEngine` through one name and `any` through the other. `protocol` (22 call
33+
sites) and `mcp` have no written contract and stay unmapped.
34+
35+
**Turning it on found four things, all on the `/security` admin surface:**
36+
37+
1. **Request input reached the security service unvalidated.** `?status=` was
38+
`String(query.status)` — any string — handed to a method whose contract
39+
declares exactly three values, and from there into the query's `where`
40+
clause. Not an injection (the `where` is structured, never interpolated), but
41+
`?status=garbage` matched no row and returned an empty list, which reads as
42+
"there are no suggestions" rather than "that is not a status". Now a 400.
43+
44+
2. **A test pinned that bug as expected behaviour.** The existing case asserted
45+
`status: 'open'` — not one of the three declared values — reached the service
46+
and returned 200. It proved the delegate carried *a filter* and nothing about
47+
that filter being a status. Same shape as batch 1's `auth.handler` mocks:
48+
coverage in appearance, a wrong contract in substance.
49+
50+
3. **and 4. Two writes could not prove they had a caller.**
51+
`confirmAudienceBindingSuggestion`/`dismissAudienceBindingSuggestion` declare
52+
`callerContext: SecurityContext` non-optionally — deliberately, since the
53+
read beside them declares it optional — and the domain passed a possibly-
54+
`undefined` execution context.
55+
56+
**This was not a live hole**, and the distinction matters: with no execution
57+
context `shouldDenyAnonymous` already denied, because it sees no
58+
`userId`/`isSystem` and its allowlist arm needs a non-empty `path` this seam
59+
never passes, so it fell through to `return true`. What it never did was
60+
narrow `ec` itself — it only read `ec?.userId`. Checking `ec` directly is
61+
behaviour-preserving and makes the invariant legible to the compiler and the
62+
next reader.
63+
64+
The `?status=` rejection is the one **behaviour change**: an unknown status was
65+
a silent empty list and is now a 400 naming the accepted values. The accepted
66+
set is a `Record` keyed on the contract type, so adding a status to the contract
67+
leaves a key missing and renaming one leaves a key excess — either fails to
68+
compile, where a plain array would have drifted silently.
69+
70+
Verified: `@objectstack/runtime` **945 tests / 66 files** (+8), `@objectstack/spec`
71+
**7141 / 274** (+29), plugin-security **677**, plugin-sharing **225**;
72+
`tsc --noEmit` on spec, runtime, downstream-contract and all four examples;
73+
`pnpm lint`; all nine `check:*` gates.

content/docs/permissions/permission-sets.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ POST /api/v1/security/suggested-bindings/:id/confirm # create the everyone
204204
POST /api/v1/security/suggested-bindings/:id/dismiss # decline the prompt
205205
```
206206

207+
`status` accepts `pending`, `confirmed` or `dismissed`, and may be omitted to
208+
list every suggestion. Any other value is rejected with a `400` naming the
209+
accepted set — it previously reached the query as a filter nothing matched, so
210+
a mistyped status returned an empty list that read as "no suggestions".
211+
207212
All three require a tenant-level administrator (the anchors are tenant-level
208213
only — D12). Confirm writes the `sys_position_permission_set` row **as the
209214
caller**, so the audience-anchor gate re-checks the forbidden-bits predicate

packages/runtime/src/domain-handler-registry.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,12 +227,49 @@ describe('HttpDispatcher extracted domains (PR-2)', () => {
227227
dismissAudienceBindingSuggestion: vi.fn(),
228228
};
229229
// Direct delegate call for the same reason as the notifications case.
230+
//
231+
// [#4127 batch 3] This asserted `status: 'open'` — not one of the three
232+
// values `AudienceBindingSuggestionFilter` declares. The test pinned the
233+
// unvalidated pass-through as EXPECTED, so it proved the delegate
234+
// carried a filter through and nothing about that filter being a status.
235+
// Same shape as the `auth.handler` mock in batch 1: coverage in
236+
// appearance, a wrong contract in substance. Now a real status.
237+
const context: any = { executionContext: { userId: 'admin-1' } };
238+
const result = await makeDispatcher({ security }).handleSecurity('/suggested-bindings', 'GET', undefined, { status: 'pending' }, context);
239+
expect(result.response?.status).toBe(200);
240+
expect(security.listAudienceBindingSuggestions).toHaveBeenCalledWith(
241+
expect.objectContaining({ userId: 'admin-1' }),
242+
expect.objectContaining({ status: 'pending' }),
243+
);
244+
});
245+
246+
it('/security rejects a status filter that is not a declared status, without calling the service', async () => {
247+
const security = {
248+
listAudienceBindingSuggestions: vi.fn().mockResolvedValue([{ id: 's1' }]),
249+
confirmAudienceBindingSuggestion: vi.fn(),
250+
dismissAudienceBindingSuggestion: vi.fn(),
251+
};
230252
const context: any = { executionContext: { userId: 'admin-1' } };
231253
const result = await makeDispatcher({ security }).handleSecurity('/suggested-bindings', 'GET', undefined, { status: 'open' }, context);
254+
// Previously this reached the service and became `where.status = 'open'`,
255+
// which matched no row — an empty list that reads as "no suggestions"
256+
// rather than "that is not a status".
257+
expect(result.response?.status).toBe(400);
258+
expect(security.listAudienceBindingSuggestions).not.toHaveBeenCalled();
259+
});
260+
261+
it('/security omits the status filter entirely when the query has none', async () => {
262+
const security = {
263+
listAudienceBindingSuggestions: vi.fn().mockResolvedValue([]),
264+
confirmAudienceBindingSuggestion: vi.fn(),
265+
dismissAudienceBindingSuggestion: vi.fn(),
266+
};
267+
const context: any = { executionContext: { userId: 'admin-1' } };
268+
const result = await makeDispatcher({ security }).handleSecurity('/suggested-bindings', 'GET', undefined, {}, context);
232269
expect(result.response?.status).toBe(200);
233270
expect(security.listAudienceBindingSuggestions).toHaveBeenCalledWith(
234271
expect.objectContaining({ userId: 'admin-1' }),
235-
expect.objectContaining({ status: 'open' }),
272+
expect.objectContaining({ status: undefined }),
236273
);
237274
});
238275

packages/runtime/src/domain-handler-registry.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
import type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatcher.js';
3737
import type { CoreServiceName } from '@objectstack/spec/system';
38-
import type { CoreServiceContract } from '@objectstack/spec/contracts';
38+
import type { CoreServiceContract, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts';
3939

4040
/**
4141
* The normalized request slice a domain handler receives. `path` is the
@@ -86,18 +86,24 @@ export interface DomainHandlerDeps {
8686
* its call sites already passed a `CoreServiceName`. This one is mixed, and
8787
* the overloads split it exactly where the evidence does:
8888
*
89-
* - A **`CoreServiceName`** — however it is written. 17 call sites address a
89+
* - An **evidenced slot** — however it is written. 17 call sites address a
9090
* core slot with a bare literal (`'metadata'` ×10, `'automation'` ×3,
9191
* `'auth'` ×3, `'ai'`) rather than `CoreServiceName.enum.*`, so the same
92-
* slot was being addressed two ways; both resolve to the contract now.
93-
* - **Anything else** — `protocol`, `objectql`, `mcp`, `kernel-resolver`,
94-
* `security`, `scope-manager`. These are real services with no
95-
* `CoreServiceName` entry and no written contract, so they keep today's
96-
* `any` rather than being given a shape here that nothing verifies.
97-
* Writing their contracts is the next batch; until then the `any` marks
98-
* where the ledger ends.
92+
* slot was being addressed two ways; both resolve to the contract.
93+
* - **Anything else** — `protocol`, `mcp`, `kernel-resolver`,
94+
* `scope-manager`. Real services with no written contract, so they keep
95+
* today's `any` rather than being given a shape here that nothing
96+
* verifies. That `any` is where the ledger honestly ends.
97+
*
98+
* [batch 3] The key is `keyof ServiceSlotContracts`, not `CoreServiceName`.
99+
* `security`, `shareLinks` and `objectql` each had a contract, a provider
100+
* registering them, and call sites already within the contract — the only
101+
* missing link was that the slot name was not in the enum, so nothing could
102+
* connect them. Widening the *enum* to fix that would have paid for a type
103+
* answer with a change to the boot/criticality vocabulary; the ledger
104+
* extends past the enum instead. See {@link ServiceSlotContracts}.
99105
*/
100-
resolveService<K extends CoreServiceName>(name: K, environmentId?: string): Promise<CoreServiceContract<K> | undefined>;
106+
resolveService<K extends keyof ServiceSlotContracts>(name: K, environmentId?: string): Promise<ServiceSlotContract<K> | undefined>;
101107
resolveService(name: string, environmentId?: string): any;
102108
/**
103109
* Unscoped service lookup on the current kernel, typed by the slot.

packages/runtime/src/domains/packages.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -623,13 +623,12 @@ names: string[],
623623
organizationId: string | undefined,
624624
_context: HttpProtocolContext,
625625
): Promise<{ success: boolean; inserted?: number; updated?: number; errors?: unknown[]; error?: string }> {
626+
// [#4127] `protocol` keeps its `any` — no written contract, so this is where
627+
// the ledger honestly ends. `metadata` and `ql` are both evidenced now,
628+
// `objectql` as of batch 3: it is the same instance the `data` slot holds.
626629
const protocol: any = await deps.resolveService('protocol');
627-
// [#4127] `metadata` was annotated `: any`, which erased the slot type even
628-
// after the lookup started returning `IMetadataService`. `protocol` and
629-
// `ql` keep theirs: neither is a `CoreServiceName` slot and neither has a
630-
// written contract, so their `any` is where the ledger honestly ends.
631630
const metadata = await deps.getService(CoreServiceName.enum.metadata);
632-
const ql: any = await deps.resolveService('objectql');
631+
const ql = await deps.resolveService('objectql');
633632
if (!protocol || typeof protocol.getMetaItem !== 'function' || !ql || !metadata) {
634633
return { success: false, error: 'seed apply: required services unavailable' };
635634
}

packages/runtime/src/domains/security.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,29 @@
2323
import {
2424
shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE,
2525
} from '@objectstack/core';
26+
import type { AudienceBindingSuggestionFilter } from '@objectstack/spec/contracts';
2627
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
2728
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
2829

30+
type SuggestionStatus = NonNullable<AudienceBindingSuggestionFilter['status']>;
31+
32+
/**
33+
* [#4127 batch 3] The accepted `?status=` values, keyed BY the contract type so
34+
* the correspondence is mechanical: adding a status to
35+
* `AudienceBindingSuggestionFilter` leaves a key missing here and renaming one
36+
* leaves a key excess, and either way this fails to compile. A plain
37+
* `['pending', …]` array would have silently drifted, which is the failure mode
38+
* this whole work line exists to remove.
39+
*/
40+
const SUGGESTION_STATUSES: Record<SuggestionStatus, true> = {
41+
pending: true,
42+
confirmed: true,
43+
dismissed: true,
44+
};
45+
46+
const isSuggestionStatus = (value: string): value is SuggestionStatus =>
47+
Object.prototype.hasOwnProperty.call(SUGGESTION_STATUSES, value);
48+
2949
export function createSecurityDomain(deps: DomainHandlerDeps): DomainRoute {
3050
return {
3151
prefix: '/security',
@@ -44,7 +64,11 @@ export async function handleSecurityRequest(
4464
query: any,
4565
context: HttpProtocolContext,
4666
): Promise<HttpDispatcherResult> {
47-
const service = await deps.resolveService('security', context.environmentId) as any;
67+
// [#4127 batch 3] The `as any` was the only thing between this call and
68+
// `ISecurityService`. The contract was written, `plugin-security` registers
69+
// the slot, and all three methods used below were already declared — the
70+
// slot name simply was not in the ledger, so nothing connected them.
71+
const service = await deps.resolveService('security', context.environmentId);
4872
if (!service || typeof service.listAudienceBindingSuggestions !== 'function') {
4973
return { handled: true, response: deps.error('Security service not available', 503) };
5074
}
@@ -54,7 +78,18 @@ export async function handleSecurityRequest(
5478
// even before the opt-out was retired this seam never honoured it, so an
5579
// anonymous caller could never list or confirm audience bindings. Shares
5680
// the decision + body with every other HTTP seam.
57-
if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem })) {
81+
//
82+
// [#4127 batch 3] The `!ec` arm is BEHAVIOUR-PRESERVING, not a new gate: with
83+
// no execution context, `shouldDenyAnonymous` already denied — it sees no
84+
// `userId`/`isSystem`, and its allowlist arm needs a non-empty `path` this
85+
// seam never passes, so it fell through to `return true`. What it did not do
86+
// is narrow `ec` itself, because it only ever read `ec?.userId`. So the
87+
// contract's requirement — `confirmAudienceBindingSuggestion(callerContext:
88+
// SecurityContext, …)`, non-optional precisely because a WRITE needs a
89+
// caller identity, unlike the optional one on the read — could not be seen
90+
// to hold even though it did. Checking `ec` directly makes the invariant
91+
// legible to the compiler and to the next reader.
92+
if (!ec || shouldDenyAnonymous({ userId: ec.userId, isSystem: ec.isSystem })) {
5893
return {
5994
handled: true,
6095
response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }),
@@ -70,9 +105,26 @@ export async function handleSecurityRequest(
70105
try {
71106
// GET /security/suggested-bindings
72107
if (parts.length === 1 && m === 'GET') {
73-
const status = query?.status ? String(query.status) : undefined;
108+
// [#4127 batch 3] `status` was `String(query.status)` — any request
109+
// string, handed to a service whose contract declares exactly three
110+
// values, and from there straight into the query's `where` clause.
111+
// Not an injection (the `where` is structured, never interpolated),
112+
// but `?status=garbage` matched no row and returned an empty list,
113+
// which reads as "there are no suggestions" rather than "your filter
114+
// was not a status". Rejecting is the honest answer, and the only
115+
// one that keeps the call inside the contract.
116+
const rawStatus = query?.status ? String(query.status) : undefined;
117+
if (rawStatus !== undefined && !isSuggestionStatus(rawStatus)) {
118+
return {
119+
handled: true,
120+
response: deps.error(
121+
`Unknown status filter '${rawStatus}' — expected one of: ${Object.keys(SUGGESTION_STATUSES).join(', ')}`,
122+
400,
123+
),
124+
};
125+
}
74126
const packageId = query?.packageId ? String(query.packageId) : undefined;
75-
const result = await service.listAudienceBindingSuggestions(ec, { status, packageId });
127+
const result = await service.listAudienceBindingSuggestions(ec, { status: rawStatus, packageId });
76128
return { handled: true, response: deps.success(result) };
77129
}
78130

packages/runtime/src/domains/share-links.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ export async function handleShareLinksRequest(
5656
query: any,
5757
context: HttpProtocolContext,
5858
): Promise<HttpDispatcherResult> {
59-
const svc: any = await deps.resolveService('shareLinks', context.environmentId);
59+
// [#4127 batch 3] `plugin-sharing` registers `ShareLinkService`, which
60+
// declares `implements IShareLinkService`; the four methods called below
61+
// were all already on that contract. Only the ledger entry was missing.
62+
const svc = await deps.resolveService('shareLinks', context.environmentId);
6063
if (!svc) {
6164
return { handled: true, response: deps.error('Sharing is not configured for this environment', 501) };
6265
}

packages/spec/api-surface.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3769,6 +3769,8 @@
37693769
"SendSmsInput (interface)",
37703770
"SendSmsResult (interface)",
37713771
"SendTemplateInput (interface)",
3772+
"ServiceSlotContract (type)",
3773+
"ServiceSlotContracts (interface)",
37723774
"ShareAccessLevel (type)",
37733775
"ShareLink (interface)",
37743776
"ShareLinkAudience (type)",

0 commit comments

Comments
 (0)