Skip to content

Commit 045f764

Browse files
feat(spec): ISecurityService declares discardPermissionSetOverlay and contributeOwnershipFloorAlternates as optional, feature-detected members (#21781)
Fixes #21756 Clause-②: yes (widening) `ISecurityService` now declares the two members that the registered `security` service already served without a declaration: `discardPermissionSetOverlay` and `contributeOwnershipFloorAlternates`. Both are **optional**, documented as feature-detected, and pinned by a contract-test row each. A new test-only enumeration pin in `plugin-security` turns red, by name, when the registered service serves a member that is neither declared on the contract nor ledgered with a reason. This follows the direction triage set (`5981803299`) and the claim `5982111525`. No runtime source changes, and no behaviour changes. ## Measured first: what is served vs what is declared Read at base `a6a7547074`. `registeredSecurityService` in `packages/plugins/plugin-security/src/security-plugin.ts` (`:1881` typed literal + `:2106` `Object.assign` extension) serves 21 members. I measured them by enumerating the object the real plugin registers, not by reading the source: `canExport`, `canReadObject`, `checkAuthoredRowWrite`, `confirmAudienceBindingSuggestion`, `contributeOwnershipFloorAlternates`, `describeDelegableScope`, `describeDelegationNarrowing`, `discardPermissionSetOverlay`, `dismissAudienceBindingSuggestion`, `explain`, `getEffectiveObjectPermissions`, `getMetadataReadableFields`, `getQueryableFields`, `getReadFilter`, `getReadableFields`, `getWritableFields`, `hasWriteBypass`, `listAudienceBindingSuggestions`, `resolvePermissionSetNames`, `resolvePermissionSetsForContext`, `resolveWriteScope`. - **Served but not declared:** exactly the two this card names. Every other member is in the typed literal, so the compiler already holds it to the contract. No third member was found, so the pin's ledger is empty. - **Declared but not served:** none. All 19 previously declared members (11 required, 8 optional) are served. ### The two callers and what the members really refuse - `discardPermissionSetOverlay(callerContext, id)`: called by `packages/rest/src/rest-server.ts` (`POST …/security/permission-sets/:id/discard-overlay`, about `:12699`). The route feature-detects it and answers `501 NOT_IMPLEMENTED` when it is absent. The implementation (`permission-set-overlay-discard.ts`) refuses with `PERMISSION_DENIED` 403 (the caller is not a tenant-level admin, or no installed package declares the set), `NOT_FOUND` 404 (unknown row) and `INVALID_STATE` 409 (no active overlay). The last two are the `ERROR_CODE_LEDGER['@objectstack/plugin-security']` rows. A refused re-projection write still resolves (`healedObjectGrantCount` then equals the pre-discard count). The docblock says so. - `contributeOwnershipFloorAlternates(plugin, alternates)`: called at boot by `packages/services/service-storage/src/attachment-delete-floor-alternate.ts`, through a local seam interface and feature detection. The implementation (`ownership-floor-alternates.ts`) throws a plain `Error` with no registered code when: `plugin` is empty, the list is not an array, an alternate names no object or names `'*'`, its operation is not exactly `update` or `delete`, its `using` is missing, or the policy does not parse as `RowLevelSecurityPolicySchema`. ## What changed - `packages/spec/src/contracts/security-service.ts`: two optional members, documented in the `getMetadataReadableFields` pattern (what each does, what it refuses with which codes, that callers feature-detect, that the unguarded call does not compile). Two parameter and result types are plugin-internal (`PermissionSetOverlayDiscardResult` and `OwnershipFloorAlternate` in `plugin-security`), so the spec declares the **minimal contract shape** of each under the same name, and the docblocks say so. They are the only new public exports, both type-only (`api-surface/contracts.json` +2, `export-origins/contracts.json` +2, regenerated by `check:generated --fix`, not by hand). - `packages/spec/src/contracts/security-service.test.ts`: one contract-test row per member, appended after the existing rows. Each shows that absence is typed (the unguarded call is a `@ts-expect-error`), how the caller handles the absent branch, and that the present member is called as declared. The second row also shows the type rejects `operation: 'all'`. No existing title is edited. - `packages/plugins/plugin-security/src/registered-security-service-members.pin.test.ts` (new, test-only): the enumeration pin. - `.changeset/21756-security-service-declared-members.md`: `@objectstack/spec` `minor`, `Clause-②: yes (widening)`. ### The pin, and why the declared list is test-local The pin boots the real `SecurityPlugin` (`init` + `start`) and takes the object it passes to `registerService('security', …)`. It walks every own key along the prototype chain, so a class-backed service would not pass over zero members. It fails, by name, on any member that is in neither `DECLARED_MEMBERS` nor `SERVED_NOT_DECLARED`. Its non-vacuity control requires every required member to be among the enumerated ones. It needs a runtime list of declared members. I chose a **test-local** `DECLARED_MEMBERS` map held to the interface by `satisfies { readonly [K in keyof ISecurityService]-?: 'required' | 'optional' }`, computed per member. If the interface gains a member and the list does not, the list stops compiling. A name the interface lacks, or a wrong required/optional tag, also stops it compiling. The compile half runs in this package's `typecheck` (`tsconfig.test.json` compiles every test here, at zero debt). **No new spec export was needed.** A runtime list exported from `packages/spec` would have grown the published surface to serve one test, and would still need a clause like this one to keep it equal to the interface. The pin's boot fake has no engine write or read verb (`objectql` carries only `registerMiddleware` and `getSchema`), so it is not a double the `check:engine-double-contract` family scans. A third case is compile-only: it types a witness of each extension member's contract signature, delegating to the implementation function the registered member delegates to. If the contract and the implementation disagree, that case stops compiling. ## Proof the pin can fail (predicted first, run from committed state) Run from commit `ff69d4c4eb`. Mutations went through `node scripts/ablation-replace.mjs` (anchor must hit, blob must move, restore proven by blob == HEAD and an empty `git diff HEAD`). The pin imports `./security-plugin.js` by relative path, so no `dist/` sits between the mutation and the run. 1. **Runtime half.** Prediction: test 1 red, naming `zzScratchServedMember`; tests 2 and 3 green. I planted `zzScratchServedMember: () => undefined` in the `Object.assign` extension of `security-plugin.ts` (blob `bf796ff10c8d` -> `cd61be11613c`). Observed, as predicted: `AssertionError: served by the registered security service but neither declared on ISecurityService … expected [ 'zzScratchServedMember' ] to deeply equal []`, `Tests 1 failed | 2 passed (3)`. Restored to blob `bf796ff10c8d` == HEAD, `git diff HEAD` empty. (My first attempt used an anchor that the replacement still contained. The tool refused it before running anything, so it measured nothing. The second attempt is the measurement.) 2. **Compile half.** Prediction: `tsc -p tsconfig.test.json` red at the `satisfies` clause, in this file only. I dropped `contributeOwnershipFloorAlternates` from `DECLARED_MEMBERS`. Observed: `registered-security-service-members.pin.test.ts(77,12): error TS1360: … does not satisfy the expected type 'DeclaredOptionality'`, the only error in the program. Restored to blob == HEAD. This also proves the test program read the **rebuilt** spec `.d.ts`: against a stale one without the new member, the unmutated list would be the red one (an excess property). ## Verification, on the final tree All runs below are at `f2f466c4a9` (the branch merged with `origin/main` `ebfe658c72`, artifacts regenerated, changeset committed). - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 src/contracts/security-service.test.ts`: `Tests 23 passed (23)` (21 before + 2). - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2` (whole package): `Test Files 615 passed (615)`, `Tests 18360 passed | 1 todo`. - `pnpm --filter @objectstack/spec typecheck`: exit 0. The test layer compiles, and `security-service.test.ts` has no `test-typecheck-debt.json` entry, so its `@ts-expect-error` lines are live checks. - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2` (whole package): `Test Files 166 passed (166)`, `Tests 3582 passed | 45 skipped`. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 (`0 file(s) / 0 error(s)` in the test-layer ledger). - `pnpm --filter @objectstack/spec check:generated`: exit 1 before the fix (exactly `api-surface/` and `export-origins/` stale, +2 interfaces each). After `--fix` re-checked them: `✓ check:api-surface`, `✓ check:export-origins`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 91 commands from the merge-base change set (6 paths). I ran all 91 with their exit codes recorded before any pipe: 89 exited 0. `pnpm check:i18n` and `pnpm check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`, no `dist/`). Both were re-run after building their prerequisites (the closure `check:i18n` names, then a workspace `pnpm build`, all turbo cache hits) and exited 0: `check-i18n-bundles: OK (9 package(s) — all bundles in sync …)` and `✓ check:dual-build-cjs-loads — 106 published require entry point(s) across 66 package(s) load`. `dispatch-gates.mjs --ran` over the recorded codes: `91 derived famil(ies) accounted for — 91 run, 0 NOT-MEASURED`. The six artifact-roster gates whose roster sits under a touched directory (`check-changeset-fixed`, `check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) were also run, and all exited 0. - Lint (narrowed, a measurement rather than a skip): `pnpm exec eslint --no-inline-config --format json` over the three changed `.ts` files reports `files 3 errors 0 warnings 0`. All three are in the configured population (`eslint --print-config` resolves each). This repo's `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), so the diff cannot move the verdict on any untouched file. The full `pnpm lint` is CI's. Consumers: the public-surface change is two optional interface members plus two type-only exports. The two callers do not type their access against `ISecurityService`: `rest-server.ts` holds the service as `any` (its provider returns a Promise of `any`), and `service-storage` uses its own local seam interface. So their compiled verdicts cannot move, and they are not re-run here. Every other package that references `ISecurityService` reads it as a `Partial` of `ISecurityService` or calls only pre-existing members (`git grep` over `packages/**`). The full consumer sweep is left to CI's workspace type-check lane. ## Overlap #21763 (#20749 stage 13) rewrites tracker ids in test titles of `security-service.test.ts` at lines 258, 287, 309, 471, 494 and 518. When this PR opened it was still in the merge queue, not on `main`. This PR edits no existing title. It adds one import specifier (line 8) and two rows after the last existing row (after line 560), so no added line is next to a title #21763 changes. Neither new title carries a tracker id. A local trial merge of this branch with #21763's head (`git merge-tree --write-tree`; this file is not routed to the regen merge driver, so the text merge is the same one GitHub runs) is clean. Whichever lands later merges `origin/main` (no rebase). ## Acceptance notes Noted, not filed. These are observations, not defects or contract violations. Each is out of this card's scope: the dispatch forbids editing `security-plugin.ts`, `rest-server.ts` and `service-storage` source. - **Comments now stale.** These comments still describe the members as undeclared extensions whose spec seat is "a separate change": `security-plugin.ts` around `:2100`–`:2122` (the comments above the `Object.assign`), the header of `ownership-floor-alternates.ts` ("an EXTENSION of the published contract"), and the header of `attachment-delete-floor-alternate.ts`. Carrier: the next PR that edits those files. - **The registration log line drifts.** `security-plugin.ts:2137` prints a hand-written member list. It names the two extension members, but omits `hasWriteBypass`, `resolveWriteScope`, `describeDelegationNarrowing`, `getEffectiveObjectPermissions` and `describeDelegableScope`. Log text only. Carrier: the next editor of `security-plugin.ts`. Holder: none. - **Possible follow-up shape.** With both members now on the contract, they could move from the `Object.assign` extension into the typed literal, where the compiler holds their signatures directly. That would make the pin's compile witness redundant for them. It is a `plugin-security` source change, so it is not done here. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e27a7c0 commit 045f764

6 files changed

Lines changed: 422 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`ISecurityService` declares the two members the registered `security` service already served without a declaration: `discardPermissionSetOverlay` and `contributeOwnershipFloorAlternates`. Both are optional, and callers feature-detect them.
6+
7+
Clause-②: yes (widening)
8+
9+
- **`discardPermissionSetOverlay(callerContext, id)`** (`@objectstack/spec/contracts`). The audited operator action behind a permission set's "Discard Overlay" Setup action: it deletes the stale environment overlay that shadows a package-declared permission set, then re-projects the row from the declared artifact before it resolves. Its docblock names the refusals it throws and the codes they carry: `PERMISSION_DENIED` (403) when the caller is not a tenant-level administrator or no installed package declares the set, `NOT_FOUND` (404) for an unknown row, and `INVALID_STATE` (409) when there is no active overlay to discard. It resolves with the new `PermissionSetOverlayDiscardResult` type. The REST route answers `501 NOT_IMPLEMENTED` when the method is absent.
10+
- **`contributeOwnershipFloorAlternates(plugin, alternates)`**. The seam through which a plugin that installs a tighter row gate stops the platform's `created_by` write floor pre-empting that gate on one object and one limb. Its docblock names what it refuses (a wildcard object, an operation other than exactly `update` or `delete`, a missing or malformed policy), and says a second call replaces the same plugin's first and an empty list withdraws it. The new `OwnershipFloorAlternate` type is the minimal contract shape of one alternate.
11+
- **Optional, and absence is typed.** A security service without either member still satisfies the contract, and an unguarded call does not compile.
12+
13+
Nothing an author writes changes. An implementation typed as `ISecurityService` that serves either name must now serve it under the declared signature; `@objectstack/plugin-security` already does, and needs no change.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Every member of the `security` service this plugin registers is either
5+
* DECLARED on `ISecurityService` (`@objectstack/spec/contracts`) or listed in
6+
* this file's ledger with a reason. A served member in neither turns this pin
7+
* red, by name.
8+
*
9+
* ## Why this pin exists
10+
*
11+
* The registered object is two pieces. The literal is typed against
12+
* `ISecurityService`, so the compiler refuses any member the contract does not
13+
* declare. The extension is merged on with `Object.assign` beside it, and
14+
* nothing types that half against the contract. Two cross-package seams were
15+
* served from the extension while the contract did not declare them: the
16+
* overlay discard the REST route calls, and the ownership-floor alternate seam
17+
* `service-storage` calls at boot. A contract reader could not see that either
18+
* seam existed, what it refused, or that a caller must feature-detect it. Both
19+
* are declared now; this pin keeps the next one from going undeclared.
20+
*
21+
* ## How the declared list stays equal to the interface
22+
*
23+
* `DECLARED_MEMBERS` is a test-local list, not a spec export, held to
24+
* `keyof ISecurityService` by a `satisfies` clause. A member added to the
25+
* interface and not listed here fails to compile (missing property); a name
26+
* listed here that the interface does not declare fails to compile (excess
27+
* property); and each entry's `required` / `optional` tag must match the
28+
* interface. The compile half runs in this package's `typecheck`
29+
* (`tsconfig.test.json` compiles every test here); vitest does not type-check.
30+
* A runtime list exported from `packages/spec` would have grown the published
31+
* surface to serve one test, and it could still drift from the interface
32+
* unless something like this clause held it there.
33+
*
34+
* ## What it does NOT pin
35+
*
36+
* Declared-but-not-served is legal for an OPTIONAL member: the contract lets a
37+
* partial implementation omit one, and callers feature-detect. So the reverse
38+
* direction is used only as this pin's non-vacuity control: every REQUIRED
39+
* member must be among the enumerated members, which fails if the boot stopped
40+
* reaching the registration or the enumeration stopped seeing the members.
41+
*/
42+
43+
import { describe, it, expect, vi } from 'vitest';
44+
import type { ISecurityService } from '@objectstack/spec/contracts';
45+
46+
import { SecurityPlugin } from './security-plugin.js';
47+
import { discardPermissionSetOverlay } from './permission-set-overlay-discard.js';
48+
import { OwnershipFloorAlternates } from './ownership-floor-alternates.js';
49+
50+
/** `optional` exactly when the interface declares the member with `?`. */
51+
type DeclaredOptionality = {
52+
readonly [K in keyof ISecurityService]-?: object extends Pick<ISecurityService, K> ? 'optional' : 'required';
53+
};
54+
55+
/** Every member `ISecurityService` declares — held equal to the interface by the compiler (see the header). */
56+
const DECLARED_MEMBERS = {
57+
getReadFilter: 'required',
58+
getReadableFields: 'required',
59+
getMetadataReadableFields: 'optional',
60+
getQueryableFields: 'optional',
61+
getWritableFields: 'optional',
62+
resolvePermissionSetNames: 'required',
63+
resolvePermissionSetsForContext: 'optional',
64+
getEffectiveObjectPermissions: 'optional',
65+
canExport: 'required',
66+
canReadObject: 'optional',
67+
hasWriteBypass: 'required',
68+
resolveWriteScope: 'required',
69+
describeDelegationNarrowing: 'optional',
70+
checkAuthoredRowWrite: 'optional',
71+
explain: 'required',
72+
describeDelegableScope: 'required',
73+
listAudienceBindingSuggestions: 'required',
74+
confirmAudienceBindingSuggestion: 'required',
75+
dismissAudienceBindingSuggestion: 'required',
76+
discardPermissionSetOverlay: 'optional',
77+
contributeOwnershipFloorAlternates: 'optional',
78+
} as const satisfies DeclaredOptionality;
79+
80+
/**
81+
* Members the registered service serves that are deliberately NOT part of the
82+
* contract — plugin-internal, no caller outside this package — each with the
83+
* reason. Empty: every served member is declared. An entry here is a decision
84+
* that a member stays off the contract; a member another package calls is a
85+
* contract, and belongs on `ISecurityService` instead.
86+
*/
87+
const SERVED_NOT_DECLARED: Readonly<Record<string, string>> = {};
88+
89+
/**
90+
* Every member name the object exposes, along its whole prototype chain up to
91+
* (not including) `Object.prototype`, enumerable or not, symbols included. A
92+
* class-backed service keeps its methods on the prototype, where `Object.keys`
93+
* would see nothing and this pin would pass over zero members.
94+
*/
95+
function servedMembers(service: object): string[] {
96+
const names = new Set<string>();
97+
for (let o: object | null = service; o !== null && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
98+
for (const key of Reflect.ownKeys(o)) {
99+
if (key === 'constructor') continue;
100+
names.add(typeof key === 'symbol' ? key.toString() : key);
101+
}
102+
}
103+
return [...names].sort();
104+
}
105+
106+
/** Boot the real plugin far enough to register `security`, and return what it registered. */
107+
async function registeredSecurityService(): Promise<object> {
108+
const services: Record<string, unknown> = {
109+
manifest: { register: vi.fn() },
110+
objectql: {
111+
registerMiddleware: vi.fn(),
112+
getSchema: () => undefined,
113+
},
114+
metadata: {
115+
get: async () => undefined,
116+
list: async () => [],
117+
},
118+
};
119+
const registerService = vi.fn();
120+
const ctx: Record<string, unknown> = {
121+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
122+
registerService,
123+
getService: (name: string) => {
124+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
125+
return services[name];
126+
},
127+
};
128+
const plugin = new SecurityPlugin();
129+
await plugin.init(ctx as any);
130+
await plugin.start(ctx as any);
131+
const security = registerService.mock.calls.find((c: unknown[]) => c[0] === 'security')?.[1];
132+
if (security === null || typeof security !== 'object') {
133+
throw new Error('the plugin did not register a `security` service object');
134+
}
135+
return security;
136+
}
137+
138+
describe('the registered `security` service serves only what ISecurityService declares', () => {
139+
it('every served member is declared on ISecurityService or ledgered here with a reason', async () => {
140+
const served = servedMembers(await registeredSecurityService());
141+
142+
// Non-vacuity: the required members are served, so the enumeration below
143+
// is looking at the real object.
144+
const required = Object.entries(DECLARED_MEMBERS)
145+
.filter(([, optionality]) => optionality === 'required')
146+
.map(([name]) => name);
147+
expect(
148+
required.filter((name) => !served.includes(name)),
149+
'required ISecurityService members missing from the enumerated service',
150+
).toEqual([]);
151+
152+
const undeclared = served.filter(
153+
(name) => !Object.hasOwn(DECLARED_MEMBERS, name) && !Object.hasOwn(SERVED_NOT_DECLARED, name),
154+
);
155+
expect(
156+
undeclared,
157+
'served by the registered `security` service but neither declared on ISecurityService ' +
158+
'(packages/spec/src/contracts/security-service.ts) nor ledgered in SERVED_NOT_DECLARED with a reason',
159+
).toEqual([]);
160+
});
161+
162+
it('the ledger names only members that are served and not declared', async () => {
163+
const served = servedMembers(await registeredSecurityService());
164+
for (const [name, reason] of Object.entries(SERVED_NOT_DECLARED)) {
165+
expect(served, `ledger entry '${name}' is not served — delete it`).toContain(name);
166+
expect(Object.hasOwn(DECLARED_MEMBERS, name), `ledger entry '${name}' is declared — delete it`).toBe(false);
167+
expect(reason.trim().length, `ledger entry '${name}' carries no reason`).toBeGreaterThan(0);
168+
}
169+
});
170+
171+
it('the two extension members are served with the signatures the contract declares (compile-time)', () => {
172+
// The `Object.assign` half is not typed against the contract, so these two
173+
// witnesses are: each is the contract's member type, implemented by
174+
// delegating to the function the registered member delegates to. A
175+
// parameter the contract hands over that the implementation cannot take,
176+
// or a result the implementation returns that the contract does not
177+
// promise, stops this file compiling. Never invoked.
178+
const discard: NonNullable<ISecurityService['discardPermissionSetOverlay']> = (callerContext, id) =>
179+
discardPermissionSetOverlay(null as never, callerContext, id);
180+
const contribute: NonNullable<ISecurityService['contributeOwnershipFloorAlternates']> = (plugin, alternates) =>
181+
new OwnershipFloorAlternates().contribute(plugin, alternates);
182+
expect(typeof discard).toBe('function');
183+
expect(typeof contribute).toBe('function');
184+
});
185+
});

‎packages/spec/api-surface/contracts.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,10 @@
213213
"NotificationMessage (interface)",
214214
"NotificationResult (interface)",
215215
"ObjectDraft (interface)",
216+
"OwnershipFloorAlternate (interface)",
216217
"PendingActionRow (interface)",
217218
"PendingActionStatus (type)",
219+
"PermissionSetOverlayDiscardResult (interface)",
218220
"PlanUpgradeInput (interface)",
219221
"Plugin (interface)",
220222
"PluginStartupResult (type)",

‎packages/spec/export-origins/contracts.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,10 @@
213213
"NotificationMessage": "src/contracts/notification-service.ts#NotificationMessage (interface)",
214214
"NotificationResult": "src/contracts/notification-service.ts#NotificationResult (interface)",
215215
"ObjectDraft": "src/contracts/external-datasource-service.ts#ObjectDraft (interface)",
216+
"OwnershipFloorAlternate": "src/contracts/security-service.ts#OwnershipFloorAlternate (interface)",
216217
"PendingActionRow": "src/contracts/ai-service.ts#PendingActionRow (interface)",
217218
"PendingActionStatus": "src/contracts/ai-service.ts#PendingActionStatus (type)",
219+
"PermissionSetOverlayDiscardResult": "src/contracts/security-service.ts#PermissionSetOverlayDiscardResult (interface)",
218220
"PlanUpgradeInput": "src/contracts/package-service.ts#PlanUpgradeInput (interface)",
219221
"Plugin": "src/contracts/plugin-validator.ts#Plugin (interface)",
220222
"PluginStartupResult": "src/kernel/startup-orchestrator.zod.ts#PluginStartupResult (type)",

‎packages/spec/src/contracts/security-service.test.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
ISecurityService,
66
AuthoredRowWriteVerdict,
77
AuthoredRowWriteOperation,
8+
OwnershipFloorAlternate,
89
} from './security-service';
910

1011
/**
@@ -558,4 +559,87 @@ describe('Security Service Contract', () => {
558559
);
559560
expect(seen[0]).toEqual({ object: 'deal', operation: 'update', userId: 'u2', recordId: 'r1' });
560561
});
562+
563+
it('discardPermissionSetOverlay is OPTIONAL — absence is typed, and the caller answers it as not implemented', async () => {
564+
// A security service without the operator action still satisfies the
565+
// contract, and a caller cannot reach the action without handling the
566+
// absent case first.
567+
const withoutIt: ISecurityService = makeService();
568+
expect(typeof withoutIt.discardPermissionSetOverlay).toBe('undefined');
569+
570+
// The unguarded call does not compile. Never invoked: its only job is to
571+
// make the COMPILER prove the point.
572+
const mustNotCompileWithoutAGuard = () =>
573+
// @ts-expect-error possibly undefined — a caller must feature-detect first
574+
withoutIt.discardPermissionSetOverlay({ userId: 'admin' }, 'ps_1');
575+
expect(typeof mustNotCompileWithoutAGuard).toBe('function');
576+
577+
// The shape the REST route writes: an absent method is a 501, never a
578+
// pretended success.
579+
const route = async (svc: ISecurityService) =>
580+
typeof svc.discardPermissionSetOverlay === 'function'
581+
? { status: 200, data: await svc.discardPermissionSetOverlay({ userId: 'admin' }, 'ps_1') }
582+
: { status: 501, data: undefined };
583+
await expect(route(withoutIt)).resolves.toEqual({ status: 501, data: undefined });
584+
585+
const withIt = makeService({
586+
discardPermissionSetOverlay: async (_context, id) => ({
587+
permissionSet: { id, name: 'sales_user' },
588+
healedObjectGrantCount: 3,
589+
overlaysDiscarded: 1,
590+
}),
591+
});
592+
await expect(route(withIt)).resolves.toEqual({
593+
status: 200,
594+
data: { permissionSet: { id: 'ps_1', name: 'sales_user' }, healedObjectGrantCount: 3, overlaysDiscarded: 1 },
595+
});
596+
});
597+
598+
it('contributeOwnershipFloorAlternates is OPTIONAL — absence is typed, and an alternate names exactly one floor limb', () => {
599+
// A security service without the seam still satisfies the contract.
600+
// Absence leaves the floor in force, which is the fail-closed direction:
601+
// the contributor's wider rule stays unreachable and nothing is widened.
602+
const withoutIt: ISecurityService = makeService();
603+
expect(typeof withoutIt.contributeOwnershipFloorAlternates).toBe('undefined');
604+
605+
const alternate: OwnershipFloorAlternate = {
606+
name: 'sys_attachment_parent_editor_delete',
607+
object: 'sys_attachment',
608+
operation: 'delete',
609+
using: 'id != null',
610+
};
611+
612+
// The unguarded call does not compile. Never invoked.
613+
const mustNotCompileWithoutAGuard = () =>
614+
// @ts-expect-error possibly undefined — a contributor must feature-detect first
615+
withoutIt.contributeOwnershipFloorAlternates('com.example.plugin', [alternate]);
616+
expect(typeof mustNotCompileWithoutAGuard).toBe('function');
617+
618+
// The shape a contributor writes: feature-detect, then contribute; the
619+
// absent branch is a defined outcome rather than a crash.
620+
const contribute = (svc: ISecurityService) => {
621+
if (typeof svc.contributeOwnershipFloorAlternates !== 'function') return 'no-seam';
622+
svc.contributeOwnershipFloorAlternates('com.example.plugin', [alternate]);
623+
return 'contributed';
624+
};
625+
expect(contribute(withoutIt)).toBe('no-seam');
626+
627+
const received: unknown[] = [];
628+
const withIt = makeService({
629+
contributeOwnershipFloorAlternates: (plugin, alternates) => {
630+
received.push([plugin, alternates]);
631+
},
632+
});
633+
expect(contribute(withIt)).toBe('contributed');
634+
expect(received).toEqual([['com.example.plugin', [alternate]]]);
635+
636+
// The operation is ONE floor limb. `all` would relieve both limbs at once,
637+
// and each limb is its own decision, so the type does not admit it.
638+
const notOneLimb: OwnershipFloorAlternate = {
639+
...alternate,
640+
// @ts-expect-error `all` is not one floor limb — contribute `update` or `delete`
641+
operation: 'all',
642+
};
643+
expect(notOneLimb.operation).toBe('all');
644+
});
561645
});

0 commit comments

Comments
 (0)