Skip to content

Commit 76deca2

Browse files
os-zhuangclaude
andauthored
docs(plugin-audit): document the os serve opt-in, and rule out a config-derived audit options helper (#10450)
* docs(plugin-audit): document the `os serve` opt-in and rule out a config-derived audit helper (#9863) The docs page for record-view auditing told readers the capability had "no knob to turn it on" under `os serve`. That stopped being true when #9864 declared and pinned the duplicate-registration contract: a configured `AuditPlugin` in the stack's `plugins` array supersedes the CLI's option-less instance by name. Both the page and the published README now spell that path. #9863's open question — whether `os serve` should grow an `appAuditPluginOptions(config)` helper mirroring its `SecurityPlugin` sibling — is ruled NO, with the reasoning recorded at the registration site and pinned by `serve-audit-registration.contract.test.ts`. No runtime behaviour changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt * test(cli): name the cross-package gate by its script, not by a path the scan never reads `check:cross-package-test-inputs` takes any quoted path literal without parsing, so a decorative mention of its own filename in a JSDoc block demanded a declaration for a file this test never opens — which would have put cli's whole suite on every edit of that gate. The comment now names the runnable script and records why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt * test(cli): separate code from prose with the shared masker, and declare the read The first draft of `serve-audit-registration.contract.test.ts` carried a private two-regex `stripComments` copied from this directory's older parity scans, block pass first. That is the defect class #9367 named: `serve.ts` has the route wildcard `/api/v1/auth/*` in a line comment, whose `/*` opens a phantom block comment running to the next real terminator ten lines below — deleting the `hasAuthPlugin` computation and the auth gate this scan measures against (1895 code-bearing lines survive the naive strip, 2098 survive the masker). `scripts/js-comment-mask.mjs` is the repo's one answer to that question, and it blanks rather than deletes, so the ordering assertion compares offsets into the real file. Masker and naive strip were cross-checked to agree on all four anchor counts across both subjects, which is also what rules out #10427's open desync for this pair. The import escapes the package, so it is declared for `@objectstack/cli` in the cross-package roster and hashed by `@objectstack/cli#test`. The gate did not demand it — its literal collector does not recognise an escaping relative import specifier — and that blind spot is filed separately rather than relied on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt * fix(cli): drop the now-unused @ts-expect-error on the comment-mask import The suppression was real when this branch was cut: `scripts/js-comment-mask.mjs` had no type declarations, so importing it from cli's tsc program was TS7016. #10398 then landed `scripts/js-comment-mask.d.mts` on main -- adding types for the same import from `packages/spec/scripts/`, which #5475 had put inside a tsc program. Merged with main the import type-checks, the directive becomes unused, and `tsc --noEmit` fails with TS2578. That is why this PR's own CI was green while its merge-queue build was not: the two trees genuinely differed. Verified both ways: cli typecheck passes on the branch as-is, fails with `serve-audit-registration.contract.test.ts(55,1): error TS2578` once main is merged in, and passes again with the directive removed. The comment is rewritten rather than deleted so the next reader does not restore a directive that now breaks the build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 299b85e commit 76deca2

6 files changed

Lines changed: 396 additions & 17 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
**Docs (published README) + ruling:** record-view auditing now documents how to turn it on under `objectstack serve`, and the answer to "should `os serve` grow an `appAuditPluginOptions(config)` helper?" is **no** (#9863).
6+
7+
The README and `content/docs/permissions/record-view-auditing.mdx` both said the audited set is configured "where you compose the kernel", and the docs page went further: *"The CLI's `os serve` registers `AuditPlugin` with no options, so a stack served that way has record-view auditing off and no knob to turn it on."* That last clause stopped being true when #9864 declared and pinned the duplicate-registration contract. The knob is the stack's `plugins` array — a configured `new AuditPlugin({ readAudit: { objects: [...] } })` there supersedes the CLI's option-less instance by name, last-one-wins, on both kernels, with the displaced instance never reaching `init()`. Both pages now spell that path, and name the `Plugin superseded: 'com.objectstack.audit'` boot line as the opt-in working rather than a misconfiguration.
8+
9+
**No new configuration surface was added, deliberately.** A `config.audit` key read by an `appAuditPluginOptions(config)` helper would reproduce, in `objectstack.config.ts`, exactly the failure #8992's ruling refused for the object-metadata spelling: a declaration that survives in a deployment which never installs this package, reading as coverage while recording nothing. It would also be a *second* configuration surface that silently loses to the first, since an app's own `plugins` entry supersedes whatever the CLI constructed. The `#7001` symmetry argument does not carry it either — `@objectstack/verify`'s `bootStack` constructs no `AuditPlugin` and does not depend on this package, so there is no second boot path to disagree with.
10+
11+
No runtime behaviour changed. `packages/cli` gains only the reasoning at its registration site and `serve-audit-registration.contract.test.ts`, which pins the three facts the ruling rests on — including the load-bearing ordering (`AuditPlugin` registered above the stack `plugins` loop) that until now was asserted by a comment and nothing else.

content/docs/permissions/record-view-auditing.mdx

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,60 @@ that never installs this plugin, producing metadata that *reads* as audited and
6868
records nothing — and on a compliance surface, a declaration a reviewer mistakes
6969
for coverage is worse than an absent feature.
7070

71-
The practical consequence is that this is configured **where you compose the
72-
kernel**. The CLI's `os serve` registers `AuditPlugin` with no options, so a
73-
stack served that way has record-view auditing off and no knob to turn it on.
71+
The practical consequence is that this is configured **where the plugin is
72+
installed**. On the `os serve` boot path that place is your stack's `plugins`
73+
array — see [Under `os serve`](#under-os-serve) below.
74+
</Callout>
75+
76+
### Under `os serve`
77+
78+
`os serve` may auto-register `AuditPlugin` for you, and when it does it passes
79+
**no options** — that instance audits no views. To turn record-view auditing on,
80+
put your own configured instance in the stack's `plugins` array:
81+
82+
```typescript
83+
// objectstack.config.ts
84+
import { defineStack } from '@objectstack/spec';
85+
import { AuditPlugin } from '@objectstack/plugin-audit';
86+
87+
export default defineStack({
88+
manifest: { name: 'my-app', version: '1.0.0' },
89+
plugins: [
90+
new AuditPlugin({ readAudit: { objects: ['contact', 'account'] } }),
91+
],
92+
// objects, apps, views, …
93+
});
94+
```
95+
96+
That is a declared contract, not a lucky ordering. The CLI registers its
97+
option-less instance **before** it walks `plugins`, and registering a plugin
98+
whose `name` is already taken **overwrites** the earlier registration —
99+
last-one-wins, identically on both kernels, with a `warn` naming both versions.
100+
Your configured instance is the one that boots; the CLI's is discarded before it
101+
ever reaches `init()`, so nothing is started twice and nothing leaks. A line like
102+
103+
```text
104+
Plugin superseded: 'com.objectstack.audit' — the later registration (v1.0.0)
105+
REPLACED the earlier one (v1.0.0). Only the later instance is initialized and
106+
started; the earlier one is discarded without ever running init().
107+
```
108+
109+
in your boot log **is the opt-in working**, not a misconfiguration. (You will
110+
not always see it: the CLI's auto-registration is paired with its `AuthPlugin`
111+
bootstrap, so a stack that supplies its own `AuthPlugin` never gets a second
112+
audit instance to supersede in the first place.)
113+
114+
<Callout type="warn">
115+
**There is no stack-config key either.** No `audit:` block in
116+
`objectstack.config.ts` names the audited objects, deliberately, and for exactly
117+
the reason there is no object-metadata key: such a key would survive in a
118+
deployment that never installs `@objectstack/plugin-audit` at all, reading as
119+
coverage while recording nothing.
120+
121+
`requires: ['audit']` is not a substitute — it makes the plugin's presence a
122+
hard boot requirement, but it constructs the plugin with no options and offers
123+
nowhere to name objects. Naming audited objects means constructing the plugin
124+
yourself, as above.
74125
</Callout>
75126

76127
## What counts as a record view
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `objectstack serve`'s AuditPlugin registration — the facts the #9863 ruling
4+
// rests on, mechanised.
5+
//
6+
// ## The question, and the answer
7+
//
8+
// #9863 asked whether `os serve` should grow an `appAuditPluginOptions(config)`
9+
// helper mirroring the `appSecurityPluginOptions(config)` sibling six lines
10+
// above it, so that record-view auditing (`AuditPluginOptions.readAudit`) could
11+
// be turned on from `objectstack.config.ts` — rather than only by an app
12+
// putting its OWN configured `new AuditPlugin({ readAudit: … })` in the stack's
13+
// `plugins` array, where it supersedes the CLI's option-less instance under the
14+
// declared last-one-wins registration contract (#9864, maintainer ruling
15+
// 2026-08-19, option B).
16+
//
17+
// Ruled NO, on four measurements — the reasoning lives at the registration site
18+
// in `serve.ts` and in #9863's ruling comment; what lives HERE is the part that
19+
// has to keep being true:
20+
//
21+
// 1. The CLI constructs `AuditPlugin` exactly once, with NO options. That is
22+
// the ruling itself. Re-opening it means editing this file deliberately,
23+
// not discovering later that the shape drifted.
24+
// 2. That construction sits inside the auth-gated pair block and ABOVE the
25+
// stack `plugins` loop. The ORDER is load-bearing and was, until this file,
26+
// asserted by nothing: invert it and the CLI's option-less instance
27+
// supersedes the app's configured one, silently turning record-view
28+
// auditing back OFF for every deployment that had opted in. Nothing else
29+
// in the repo goes red on that edit.
30+
// 3. `@objectstack/verify`'s `bootStack` constructs no `AuditPlugin` at all.
31+
// This is why the #7001 argument for the security helper does not transfer:
32+
// that helper exists because TWO boot paths both built a `SecurityPlugin`
33+
// and silently disagreed about its options. Audit has exactly one boot path
34+
// with an opinion, so there is no disagreement for a shared helper to close
35+
// — and `@objectstack/verify` does not even depend on
36+
// `@objectstack/plugin-audit` (see its package.json), so it cannot grow one
37+
// by accident. If that changes, the ruling's basis changes with it, and
38+
// this assertion is what says so.
39+
//
40+
// ## Why a source scan rather than a boot
41+
//
42+
// Same reason as this directory's `serve-verify-security-parity.contract.test.ts`
43+
// and `serve-email-config-parity.contract.test.ts`: the failure mode is an EDIT
44+
// to these files, and every one of the three facts above is invisible to a
45+
// behavioural test. `serve.ts`'s audit block is reachable only from a live
46+
// `objectstack serve` boot with `@objectstack/plugin-auth` and
47+
// `@objectstack/plugin-audit` installed, an auth secret set and no app-supplied
48+
// AuthPlugin; a unit test that got there would be testing the fixture. The grep
49+
// that WOULD have caught each edit, mechanised, is the honest instrument.
50+
51+
import { describe, it, expect } from 'vitest';
52+
import { readFileSync } from 'node:fs';
53+
import path from 'node:path';
54+
import { fileURLToPath } from 'node:url';
55+
// The separator (#9367) is a plain `.mjs`, but it ships a hand-written `.d.mts`
56+
// declaration alongside it (#10398), so this import is typed and needs no
57+
// suppression. A `@ts-expect-error` here is an UNUSED directive, and cli's tsc
58+
// program does include this file, so tsc fails the build on one.
59+
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';
60+
61+
const HERE = path.dirname(fileURLToPath(import.meta.url));
62+
63+
/**
64+
* `packages/cli/src/commands/` → `packages/`. Reading verify's harness from
65+
* here is what makes fact 3 an assertion instead of a comment;
66+
* `@objectstack/verify` is a real dependency of this package and the read is
67+
* test-only (tests never ship — `files: ["dist"]`). The glob is already declared
68+
* for `@objectstack/cli` by `pnpm check:cross-package-test-inputs`, and hashed
69+
* by `@objectstack/cli#test` in turbo.json, for the sibling parity scan; this
70+
* file adds a second reader of the same path, not a new radius.
71+
*
72+
* That gate is named by its runnable script rather than by its file path on
73+
* purpose. Its literal collector takes any quoted path without parsing, so
74+
* spelling the path here — in prose, about a file this test never opens — would
75+
* demand a declaration for it and put cli's whole suite on every edit of the
76+
* gate. The reads this scan really makes are the two below.
77+
*/
78+
const PACKAGES_DIR = path.resolve(HERE, '../../..');
79+
80+
/**
81+
* Absence must be loud (AGENTS.md, Route & surface ownership §3). A scan that
82+
* reports success because it could not find its subject is worse than no scan:
83+
* every assertion below is of the form "this shape is present / is not present",
84+
* and an empty string satisfies half of them for free.
85+
*/
86+
function readBootPath(relative: string): string {
87+
const full = path.join(PACKAGES_DIR, relative);
88+
try {
89+
return readFileSync(full, 'utf8');
90+
} catch (e) {
91+
throw new Error(
92+
`#9863 audit-registration scan cannot read its subject '${relative}' (looked at ${full}). ` +
93+
'The file moved or was renamed — repoint this scan; do NOT delete it. The ruling it pins ' +
94+
`is still in force. (${(e as Error).message})`,
95+
);
96+
}
97+
}
98+
99+
/**
100+
* Comments MASKED, because this scan is about what the two files DO.
101+
*
102+
* Not optional here, and not caution copied from the sibling scans: `serve.ts`'s
103+
* audit block DESCRIBES the very construction being counted — it spells
104+
* `new AuditPlugin({ readAudit: … })` in prose to explain the app-side opt-in it
105+
* documents, and it names the helper by name to record that the helper was ruled
106+
* against. Over raw text this file would count two constructions where the code
107+
* has one, and its own ruling assertion would fail on the sentence stating the
108+
* ruling.
109+
*
110+
* ## Why the SHARED masker rather than a private `stripComments`
111+
*
112+
* Because the private ones are a measured defect class here (#9367), and this
113+
* file walked straight into it. Its first draft used the two-regex strip the two
114+
* older scans in this directory still carry, block pass first — and `serve.ts`
115+
* has a route wildcard in a line comment (`/api/v1/auth/*` in the `5d.` header).
116+
* A regex cannot tell that `/*` from a real opener, so it ran a phantom block
117+
* comment to the next real terminator ten lines below, inside
118+
* `import(/* webpackIgnore: true *\/ …)`, deleting the `hasAuthPlugin`
119+
* computation and the auth gate this file measures against. Measured on this
120+
* exact pair: the naive strip keeps 1895 code-bearing lines of `serve.ts`, the
121+
* masker keeps 2098.
122+
*
123+
* `maskComments` also BLANKS rather than deletes — spans become spaces, newlines
124+
* kept — so the offsets the ordering assertion below compares are offsets into
125+
* the real file rather than into a shrunken copy of it. Cross-checked on both
126+
* subjects: masker and naive-strip agree on all four anchor counts, and the
127+
* masker leaves the line count identical (4638 → 4638), which is the property
128+
* being bought.
129+
*
130+
* #10427 (open) has the masker desyncing on nested template literals in 16
131+
* files. Neither file read here is among them, and the cross-check above is what
132+
* says so rather than assuming it.
133+
*/
134+
135+
const SERVE = maskComments(readBootPath('cli/src/commands/serve.ts'));
136+
const HARNESS = maskComments(readBootPath('verify/src/harness.ts'));
137+
138+
/**
139+
* Every `new AuditPlugin(...)` construction in a file, with its argument text.
140+
*
141+
* Walks parentheses rather than matching `\(([^)]*)\)`, for the reason the
142+
* sibling parity scan measured: an options argument is itself brace- and
143+
* paren-bearing (`appAuditPluginOptions(config)`, `{ readAudit: { objects: [] } }`),
144+
* and a non-nesting match stops at the first inner `)` and silently reports a
145+
* truncation. The empty-argument case this file asserts today would be reported
146+
* identically by both forms, which is exactly how a scan that cannot read the
147+
* shape it guards passes until the day it matters.
148+
*/
149+
function auditPluginConstructions(source: string): string[] {
150+
const NEW = 'new AuditPlugin(';
151+
const found: string[] = [];
152+
for (let i = source.indexOf(NEW); i !== -1; i = source.indexOf(NEW, i + 1)) {
153+
let depth = 1;
154+
let j = i + NEW.length;
155+
for (; j < source.length && depth > 0; j++) {
156+
if (source[j] === '(') depth++;
157+
else if (source[j] === ')') depth--;
158+
}
159+
if (depth !== 0) throw new Error(`unbalanced \`${NEW}…\` at offset ${i} — the scan cannot read this file`);
160+
found.push(source.slice(i + NEW.length, j - 1).trim());
161+
}
162+
return found;
163+
}
164+
165+
/**
166+
* The offset of an anchor that must appear exactly once. Both "missing" and
167+
* "appeared twice" are reported as failures rather than folded into an offset
168+
* comparison, because an ordering assertion between two anchors is meaningless
169+
* if either is ambiguous — and a duplicated anchor is how a refactor most
170+
* plausibly arrives.
171+
*/
172+
function soleOffset(source: string, anchor: string, role: string): number {
173+
const first = source.indexOf(anchor);
174+
if (first === -1) {
175+
throw new Error(
176+
`#9863 audit-registration scan: the ${role} anchor \`${anchor}\` is gone from serve.ts. ` +
177+
'It was the landmark this scan measured the AuditPlugin registration against. ' +
178+
'Repoint the anchor at whatever replaced it — the invariant (the CLI registration ' +
179+
'stays inside the auth-gated pair block and ABOVE the stack `plugins` loop) is unchanged.',
180+
);
181+
}
182+
if (source.indexOf(anchor, first + 1) !== -1) {
183+
throw new Error(
184+
`#9863 audit-registration scan: the ${role} anchor \`${anchor}\` now appears more than once ` +
185+
'in serve.ts, so "before" and "after" no longer name one place. Give this scan an ' +
186+
'unambiguous landmark before trusting its verdict.',
187+
);
188+
}
189+
return first;
190+
}
191+
192+
/** The `if (!hasAuthPlugin && tierEnabled('auth'))` block the audit pair lives in. */
193+
const AUTH_GATE = "if (!hasAuthPlugin && tierEnabled('auth'))";
194+
/** The stack `plugins` loop, i.e. where an app's own configured instance is registered. */
195+
const PLUGINS_LOOP = 'for (const plugin of plugins)';
196+
197+
describe('os serve registers AuditPlugin bare, above the stack `plugins` loop (#9863)', () => {
198+
it('constructs AuditPlugin exactly once, with NO options — the ruling', () => {
199+
// The empty string is the whole point: `[]` would mean "never constructed"
200+
// and `['appAuditPluginOptions(config)']` would mean the ruling was reversed.
201+
expect(auditPluginConstructions(SERVE)).toEqual(['']);
202+
});
203+
204+
it('does not reach for a config-derived audit options helper', () => {
205+
// Re-opening #9863 is allowed; doing it by accident is not. A helper wired
206+
// in HERE would take effect only when the app supplies no AuthPlugin of its
207+
// own and an auth secret is set (see the gate below) — a declared config key
208+
// whose effect depends on unrelated auth conditions, on a compliance
209+
// surface. If the ruling is revisited, the capability resolver's
210+
// `CAPABILITY_PROVIDERS.audit` entry — which is NOT auth-gated and already
211+
// carries the `configKey` mechanism `analytics` uses — is the site to argue
212+
// about, and this assertion moves in the same edit as the ruling.
213+
expect(SERVE).not.toContain('appAuditPluginOptions');
214+
});
215+
216+
it('registers inside the auth-gated pair block and ABOVE the stack `plugins` loop', () => {
217+
const gate = soleOffset(SERVE, AUTH_GATE, 'auth-gate');
218+
const loop = soleOffset(SERVE, PLUGINS_LOOP, 'stack-plugins-loop');
219+
const audit = soleOffset(SERVE, 'new AuditPlugin(', 'audit-construction');
220+
221+
// ABOVE the loop: the half `serve.ts` calls load-bearing. Below it, the
222+
// CLI's option-less instance would supersede the app's configured one and
223+
// record-view auditing would be off wherever it had been opted in.
224+
expect(audit).toBeLessThan(loop);
225+
226+
// INSIDE the auth block: the half nothing had written down. The pair is
227+
// registered by the `5d. Auto-register AuthPlugin (and paired
228+
// Security/Audit)` branch, so an app that supplies its own AuthPlugin — or
229+
// a production boot with no auth secret — gets NO CLI AuditPlugin, and the
230+
// supersede this card is named after never happens there at all. Hoisting
231+
// the registration out of the block is a real change of meaning, not a
232+
// tidy-up, and it is exactly the edit an offset comparison against the gate
233+
// catches.
234+
expect(audit).toBeGreaterThan(gate);
235+
});
236+
237+
it("verify's bootStack has no AuditPlugin opinion — no #7001-shaped disagreement to close", () => {
238+
expect(auditPluginConstructions(HARNESS)).toEqual([]);
239+
});
240+
});

0 commit comments

Comments
 (0)