Skip to content

Commit 12ab76f

Browse files
committed
refactor(core,plugin-audit,service-storage,plugin-reports): give the __ operation-private-key convention a single owner (#7284)
`withoutOperationPrivateKeys` and its `OPERATION_PRIVATE_KEY_PREFIX` had been hand-copied into three packages — plugin-audit's comment access hooks (#7141), service-storage's attachment access hooks (#7145) and plugin-reports' report service (#7204). All three were byte-equivalent in behaviour; their doc blocks had already diverged in prose. The rule now lives once in `@objectstack/core` (`security/operation-private-keys.ts`), beside `assemble-execution-context.ts` — that file owns where an ExecutionContext is BUILT at a transport entry point, this one owns where it is stripped before being forwarded to a question it was not resolved for. Home chosen by dependency measurement: all three consumers already depend on `@objectstack/core`, none depends on `plugin-security` (the producer, and the most honest owner, but a string-prefix filter does not justify three new dependency edges onto a plugin), and `@objectstack/spec` is fenced by Prime Directive #2. Core is the only zero-new-edge candidate. The reasoning moved with the code rather than being thinned; each consumer keeps only its own local half and points at the shared home. Pins: `operation-private-keys.test.ts` asserts the rule's own behaviour, which no package-level test had asserted directly, and `operation-private-keys.pin.test.ts` turns red if a fourth file declares its own copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AD2nx7MRuje3kLqoLBHPM
1 parent f3f855a commit 12ab76f

8 files changed

Lines changed: 452 additions & 109 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/core": patch
3+
"@objectstack/plugin-audit": patch
4+
"@objectstack/service-storage": patch
5+
"@objectstack/plugin-reports": patch
6+
---
7+
8+
refactor(core,plugin-audit,service-storage,plugin-reports): give the `__` operation-private-key convention a single owner (#7284)
9+
10+
`withoutOperationPrivateKeys` — the rule that a consumer forwarding a caller's
11+
execution envelope to a question about a DIFFERENT object must first drop the
12+
`__`-prefixed keys plugin-security stamped for the operation in flight — had been
13+
hand-copied into three packages: `plugin-audit`'s comment access hooks (#7141),
14+
`service-storage`'s attachment access hooks (#7145) and `plugin-reports`' report
15+
service (#7204). Each carried its own `OPERATION_PRIVATE_KEY_PREFIX` and its own
16+
doc block, and the prose had already diverged while the code still agreed — the
17+
shape that makes a later divergence in behaviour hard to notice.
18+
19+
The helper now lives once, in `@objectstack/core`
20+
(`security/operation-private-keys.ts`), exported from the package root. Core is
21+
the only candidate all three consumers already depend on: `plugin-security` is
22+
the producer of the convention and the most honest owner, but none of the three
23+
depends on it and a string-prefix filter does not justify three new dependency
24+
edges onto a plugin; `@objectstack/spec` is fenced off by Prime Directive #2. The
25+
new home sits beside `assemble-execution-context.ts`, which owns the other end of
26+
the same lifecycle — that file is where an `ExecutionContext` is built at a
27+
transport entry point, this one is where it is stripped back down before being
28+
forwarded.
29+
30+
The full reasoning moved with the code rather than being thinned: which keys the
31+
middleware stamps and why each is a widening input, why they are dropped by
32+
PREFIX and never by a name list, and why the fresh copy is load-bearing in both
33+
directions. Each consumer keeps only its own local half — which object *its*
34+
gates actually ask about — and points at the shared home.
35+
36+
No behaviour change: the three copies were byte-equivalent, and all three
37+
packages' suites pass unchanged. Two new pins at the home cover it — the rule's
38+
own behaviour, which no package-level test had ever asserted directly, and a
39+
repository-shape pin that turns red if a fourth file declares its own copy.

packages/core/src/security/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,11 @@ export {
131131

132132
// ADR-0091 D1/D2 — grant validity windows, the shared resolution-time predicate.
133133
export { isGrantActive, isGrantExpired, type GrantValidityWindow } from './grant-validity.js';
134+
135+
// #7284 — the `__` operation-private-key convention, the CONSUMER half of the
136+
// ExecutionContext lifecycle `assemble-execution-context.ts` opens. One owner
137+
// for the rule three packages had hand-copied (#7141 / #7145 / #7204).
138+
export {
139+
OPERATION_PRIVATE_KEY_PREFIX,
140+
withoutOperationPrivateKeys,
141+
} from './operation-private-keys.js';
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#7284] The `__` operation-private-key convention has exactly ONE owner.
5+
*
6+
* This is the pin the extraction is worth having. The three copies #7284 found
7+
* were byte-equivalent in behaviour and each was covered by its own package's
8+
* tests, so nothing in the repository went red while the rule was being copied
9+
* by hand a third time — the finding was made by a human reading three diffs
10+
* months apart. A fourth consumer is written the same way the first three were:
11+
* by opening the nearest existing one and copying the block out of it. Extracting
12+
* the helper without pinning it just resets that counter to one.
13+
*
14+
* So the assertion is about the SHAPE of the repository, not about behaviour: no
15+
* file outside this module may declare its own `OPERATION_PRIVATE_KEY_PREFIX` or
16+
* its own `withoutOperationPrivateKeys`. A fourth author who copies the block
17+
* turns this red the first time they run the suite, with a message naming the
18+
* import to use instead.
19+
*
20+
* ⛔ Scope, deliberately narrow — this pin does NOT try to detect "a consumer
21+
* that should have used the helper and did not". That is the interesting
22+
* question and it is not decidable by scanning: forwarding an envelope is
23+
* spelled a dozen ways, and a regex ambitious enough to catch them all would be
24+
* a false-red generator, which is worse than the gap (an inert or noisy gate
25+
* reads as a gate that is watching — `validate-security-posture.ts`'s hazard).
26+
* What IS decidable is redeclaration, which is exactly how all three copies got
27+
* here.
28+
*
29+
* Reworded freely: the pin matches DECLARATIONS, not mentions. Documentation,
30+
* comments and tests may name either symbol as much as they like.
31+
*/
32+
33+
import { readFileSync, readdirSync, statSync } from 'node:fs';
34+
import { dirname, join, relative, resolve } from 'node:path';
35+
import { fileURLToPath } from 'node:url';
36+
37+
import { describe, it, expect } from 'vitest';
38+
39+
const HERE = dirname(fileURLToPath(import.meta.url));
40+
/** …/packages/core/src/security → repo root */
41+
const REPO_ROOT = resolve(HERE, '../../../..');
42+
const PACKAGES = join(REPO_ROOT, 'packages');
43+
44+
/** The one file allowed to declare the convention. */
45+
const HOME = join(HERE, 'operation-private-keys.ts');
46+
47+
/**
48+
* A DECLARATION of either symbol — `const OPERATION_PRIVATE_KEY_PREFIX =` or
49+
* `function withoutOperationPrivateKeys(`, with or without `export`.
50+
*
51+
* Anchored at a statement start so that imports (`import { … }`), re-exports
52+
* (`export { … } from`), calls and prose never match. Both spellings a copy
53+
* could plausibly take are covered: a `function` declaration is what all three
54+
* copies used, and `const … =` catches the arrow-function rewrite.
55+
*/
56+
const DECLARATION =
57+
/^\s*(?:export\s+)?(?:const|let|var|function)\s+(OPERATION_PRIVATE_KEY_PREFIX|withoutOperationPrivateKeys)\b\s*[=(<]/gm;
58+
59+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.turbo', 'coverage', '.next']);
60+
61+
/** Every `.ts`/`.tsx` file under `packages/`, excluding build output. */
62+
function sourceFiles(dir: string, out: string[] = []): string[] {
63+
for (const entry of readdirSync(dir)) {
64+
if (SKIP_DIRS.has(entry)) continue;
65+
const full = join(dir, entry);
66+
if (statSync(full).isDirectory()) sourceFiles(full, out);
67+
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
68+
}
69+
return out;
70+
}
71+
72+
describe('the `__` operation-private-key convention has one owner (#7284)', () => {
73+
it('is declared in exactly one file, and that file is the shared home', () => {
74+
const offenders: string[] = [];
75+
76+
for (const file of sourceFiles(PACKAGES)) {
77+
if (file === HOME) continue;
78+
const text = readFileSync(file, 'utf8');
79+
DECLARATION.lastIndex = 0;
80+
if (DECLARATION.test(text)) offenders.push(relative(REPO_ROOT, file));
81+
}
82+
83+
expect(
84+
offenders,
85+
offenders.length === 0
86+
? ''
87+
: [
88+
'These files declare their own copy of the `__` operation-private-key convention:',
89+
...offenders.map((f) => ` - ${f}`),
90+
'',
91+
'That rule has a single owner since #7284. Import it instead:',
92+
'',
93+
" import { withoutOperationPrivateKeys } from '@objectstack/core';",
94+
'',
95+
'The reasoning — why a consumer must drop these keys, why by PREFIX and',
96+
'never by a name list, and why the copy is load-bearing in both',
97+
'directions — lives at packages/core/src/security/operation-private-keys.ts.',
98+
'If you are adding a consumer, add it to that header\'s "Known consumers"',
99+
'list rather than re-deriving the argument locally.',
100+
].join('\n'),
101+
).toEqual([]);
102+
});
103+
104+
it('the home really does declare both symbols — the scan cannot pass vacuously', () => {
105+
// #4690: a check that finds nothing because it is looking in the wrong place
106+
// reads exactly like a check that found no violations. Anchor it.
107+
const text = readFileSync(HOME, 'utf8');
108+
const found = [...text.matchAll(DECLARATION)].map((m) => m[1]).sort();
109+
110+
expect(found).toEqual(['OPERATION_PRIVATE_KEY_PREFIX', 'withoutOperationPrivateKeys']);
111+
});
112+
113+
it('the scan reaches the packages that used to hold the copies', () => {
114+
// The second half of the same anti-vacuity guard: prove the walker actually
115+
// descends into the three consumer packages, so a future refactor of
116+
// SKIP_DIRS or the walk cannot silently narrow the scan to `packages/core`.
117+
const scanned = sourceFiles(PACKAGES).map((f) => relative(REPO_ROOT, f));
118+
119+
for (const consumer of [
120+
'packages/plugins/plugin-audit/src/comment-access-hooks.ts',
121+
'packages/services/service-storage/src/attachment-access-hooks.ts',
122+
'packages/plugins/plugin-reports/src/report-service.ts',
123+
]) {
124+
expect(scanned).toContain(consumer);
125+
}
126+
});
127+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#7284] Behaviour of the `__` operation-private-key convention at its home.
5+
*
6+
* The three packages that hand-copied this helper each covered it only through
7+
* their own gates — `sys_comment`'s access hooks, `sys_attachment`'s, the report
8+
* runner's — so the RULE itself was never asserted anywhere, only its effect on
9+
* three particular call sites. These are the assertions that belong to the rule.
10+
*/
11+
12+
import { describe, it, expect } from 'vitest';
13+
14+
import {
15+
OPERATION_PRIVATE_KEY_PREFIX,
16+
withoutOperationPrivateKeys,
17+
} from './operation-private-keys.js';
18+
19+
describe('withoutOperationPrivateKeys', () => {
20+
it('drops every key carrying the operation-private prefix', () => {
21+
const out = withoutOperationPrivateKeys({
22+
userId: 'u1',
23+
tenantId: 't1',
24+
__readScope: 'org',
25+
__writeScope: 'org',
26+
__delegatorReadScope: 'unit',
27+
__delegatorWriteScope: 'unit',
28+
__expandRead: true,
29+
__referentialFieldClear: true,
30+
});
31+
32+
expect(out).toEqual({ userId: 'u1', tenantId: 't1' });
33+
});
34+
35+
it('preserves every principal field — it strips, it does not project', () => {
36+
// The defect half of the five-field projections this helper replaced
37+
// (#7141 / #7145 / #7204): these decide the verdict the gate then trusts.
38+
const envelope = {
39+
userId: 'u1',
40+
tenantId: 't1',
41+
positions: ['p1'],
42+
permissions: ['read'],
43+
isSystem: false,
44+
onBehalfOf: { userId: 'agent-owner' },
45+
principalKind: 'agent',
46+
systemPermissions: ['x'],
47+
accessible_org_ids: ['o1', 'o2'],
48+
org_user_ids: ['u1'],
49+
posture: 'group',
50+
audience: 'api',
51+
rlsMembership: { unit: 'u' },
52+
timezone: 'Asia/Shanghai',
53+
__readScope: 'org',
54+
};
55+
56+
const out = withoutOperationPrivateKeys(envelope) as Record<string, unknown>;
57+
58+
const { __readScope: _dropped, ...everythingElse } = envelope;
59+
expect(out).toEqual(everythingElse);
60+
});
61+
62+
it('returns a FRESH object even when there is nothing to strip', () => {
63+
// ⛔ The copy is the point, not an optimisation to skip on a clean envelope:
64+
// a callee that stamps its own `__writeScope` onto what it receives must not
65+
// be able to write back into the caller's operation context.
66+
const envelope = { userId: 'u1' };
67+
const out = withoutOperationPrivateKeys(envelope) as Record<string, unknown>;
68+
69+
expect(out).not.toBe(envelope);
70+
expect(out).toEqual(envelope);
71+
72+
out.__writeScope = 'org';
73+
expect(envelope).toEqual({ userId: 'u1' });
74+
});
75+
76+
it('is a SHALLOW copy — nested values are forwarded by reference', () => {
77+
// Stated so the boundary is a decision rather than an accident: the hazard
78+
// this closes is a callee stamping TOP-LEVEL keys, which is all the
79+
// middleware ever does.
80+
const nested = { userId: 'agent-owner' };
81+
const out = withoutOperationPrivateKeys({ onBehalfOf: nested }) as Record<string, unknown>;
82+
83+
expect(out.onBehalfOf).toBe(nested);
84+
});
85+
86+
it('drops a key the middleware has not stamped yet, by prefix alone', () => {
87+
// The whole reason the rule is a prefix and not a name list: a seventh
88+
// operation-private key must be dropped by every consumer on the day it is
89+
// stamped, with no consumer edited.
90+
const out = withoutOperationPrivateKeys({ userId: 'u1', __someFutureMarker: true });
91+
92+
expect(out).toEqual({ userId: 'u1' });
93+
});
94+
95+
it('leaves keys that merely CONTAIN the prefix, and single-underscore keys', () => {
96+
const out = withoutOperationPrivateKeys({
97+
_private: 1,
98+
'field__with__dunders': 2,
99+
org_user_ids: ['o1'],
100+
});
101+
102+
expect(out).toEqual({ _private: 1, 'field__with__dunders': 2, org_user_ids: ['o1'] });
103+
});
104+
105+
it('tolerates an empty envelope', () => {
106+
expect(withoutOperationPrivateKeys({})).toEqual({});
107+
});
108+
109+
it('pins the prefix itself — consumers and the middleware agree on `__`', () => {
110+
expect(OPERATION_PRIVATE_KEY_PREFIX).toBe('__');
111+
});
112+
});

0 commit comments

Comments
 (0)