Skip to content

Commit 7c368e8

Browse files
claude[bot]claude
andauthored
test(auth): pin the human-user predicate agreement across the plugin-security boundary (#12512)
* test(auth): pin the human-user predicate agreement across the package boundary "Is this `sys_user` row a HUMAN?" is answered by two owners that decide two halves of one boot sequence on one population: plugin-auth's consolidated `isHumanUserRow` (audience-posture.ts) decides whether a sign-up is ADMITTED, and plugin-security's hand-spelled `isHumanUser` (bootstrap-platform-admin.ts) prints "no human users yet" and then PERFORMS the platform-admin promotion. Nothing gated their agreement. Divergence means a seed that decides to run and a gate that then refuses it -- a fresh-looking install locked out of itself, observable on any database still carrying the legacy `usr_system` service row. This pins the agreement rather than consolidating the copies. Moving the predicate into a package both plugins depend on expands a published surface, which is a separate and currently declined decision; the pin closes the contradiction risk with no new API. Reaching both predicates from one test is a package-boundary problem with exactly one solution that widens nothing: - `isHumanUserRow` is module-scope-exported but is NOT re-exported from plugin-auth's index.ts and is absent from its `exports` map, so nothing outside plugin-auth can import it (verified against the built dist: `isHumanUserRow` is `undefined` there). Pinning from plugin-security would require ADDING that export. - `isHumanUser` is a local closure and is not exported at all -- but its real call site, `bootstrapPlatformAdmin`, is already published. So the pin lives in plugin-auth, imports `isHumanUserRow` relative, and reads `isHumanUser` THROUGH the published entry point: one row in `sys_user` under the default `single` posture makes `adminPromoted` report the predicate's verdict on that row directly. The only new edge is a devDependency; no production dependency and no new export in either package. That edge is also what makes this a pin -- CI's affected-package computation walks the dependency graph, so without it a plugin-security-only change would never mark this package affected. `check:test-source-alias` requires the new cross-package specifier to resolve to source rather than `dist/`, so plugin-auth's vitest config gains one anchored alias entry. The registry it audits is shrink-only and aliasing is the remedy it names. The negative side asserts `reason === 'no_users'` because every other way `bootstrapPlatformAdmin` returns `adminPromoted: false` carries a different reason -- without it a harness that short-circuited early would read as a unanimous "not human" and pass vacuously. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157mMVAq9fjGe2kaSD2aJC8 * test(auth): hold the ObjectQL double contracts in the agreement pin's fake Two shrink-only ratchets judged the new fake and were right about both. `check:objectql-double-limit` read the `find` double as limit-blind: it answered every matching row while `bootstrapPlatformAdmin` really does pass a bound (1 for the permission-set probe, 50 for the user and grant reads). The bound is now applied AFTER the filter, by presence, the shape the gate prescribes. `check:engine-double-contract` flagged the double's `update()` as a fake write verb looser than `ObjectQL.update`. The verb is DELETED rather than pinned: its only caller is the `resync` branch, which this pin never asks for, so it was dead surface. Its absence also short-circuits `claimSeedOwnership` at that function's own `typeof ql.update !== 'function'` guard, one step earlier than the registry guard it used to stop at. Neither ratchet's baseline was touched. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157mMVAq9fjGe2kaSD2aJC8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e577445 commit 7c368e8

4 files changed

Lines changed: 272 additions & 0 deletions

File tree

packages/plugins/plugin-auth/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@objectstack/driver-sql": "workspace:*",
4242
"@objectstack/objectql": "workspace:*",
4343
"@objectstack/plugin-hono-server": "workspace:*",
44+
"@objectstack/plugin-security": "workspace:*",
4445
"@types/node": "^26.2.0",
4546
"hono": "^4.13.2",
4647
"typescript": "^6.0.3",
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* AGREEMENT PIN — plugin-security's `isHumanUser` vs plugin-auth's
5+
* `isHumanUserRow`, on the shared `sys_user` row corpus.
6+
*
7+
* ## What this pins and why it exists
8+
*
9+
* "Is this `sys_user` row a HUMAN?" is answered by two owners that decide two
10+
* halves of ONE boot sequence, on ONE population:
11+
*
12+
* - `isHumanUserRow` (this package, `audience-posture.ts`) — the consolidated
13+
* owner. The audience gate's bootstrap bypass and the dev-admin seed both
14+
* read it, and it decides whether a sign-up is ADMITTED at all.
15+
* - `isHumanUser` (`plugin-security/src/bootstrap-platform-admin.ts`) — a
16+
* third, hand-spelled copy. It is the one that prints `[security] no human
17+
* users yet — first sign-up will be promoted to platform admin` and then
18+
* PERFORMS that promotion.
19+
*
20+
* The consolidation that unified the first two deliberately left the third
21+
* where it is: `plugin-security` does not depend on `plugin-auth`, so sharing
22+
* the predicate across them would mean moving it into a package both depend on
23+
* (`@objectstack/spec` / `@objectstack/platform-objects`) — a published-surface
24+
* change that consolidation rightly refused to carry, and that stays declined.
25+
*
26+
* So the copies stay, and this pin gates the property that actually matters:
27+
* **they answer alike**. Divergence is not a tidiness complaint — the two
28+
* disagreeing means a seed that decides to run and a gate that then refuses
29+
* it, i.e. a fresh-looking install locked out of itself. The population where
30+
* that is observable is named in the corpus below: a database still carrying
31+
* the legacy `usr_system` service row (`SystemUserId.SYSTEM` — no longer
32+
* provisioned, but present in every DB an older runtime created).
33+
*
34+
* ## Why the pin lives in plugin-auth and not in plugin-security
35+
*
36+
* Reaching both predicates from one test is a package-boundary problem, and
37+
* only one direction solves it WITHOUT widening a published surface:
38+
*
39+
* - `isHumanUserRow` is module-scope-exported but is NOT re-exported from
40+
* this package's `index.ts` and is not in its `exports` map, so nothing
41+
* outside `plugin-auth` can import it. Pinning from `plugin-security` would
42+
* require ADDING that export.
43+
* - `isHumanUser` is a local closure inside `bootstrapPlatformAdmin` and is
44+
* not exported at all — but `bootstrapPlatformAdmin` itself IS part of
45+
* `@objectstack/plugin-security`'s published surface, and it is the real
46+
* call site of the predicate.
47+
*
48+
* Hence: import `isHumanUserRow` relative (in-package, no surface change), and
49+
* observe `isHumanUser` THROUGH the already-published entry point. The only
50+
* new edge is a **devDependency** `plugin-auth -> plugin-security`; no
51+
* production dependency, and no new export in either package.
52+
*
53+
* That edge is also what makes this a pin rather than decoration: CI's
54+
* affected-package computation walks the dependency graph, so without it a
55+
* `plugin-security`-only change would never mark this package affected and the
56+
* pin would sit green through the very edit that breaks it.
57+
*
58+
* ## How the security-side verdict is read
59+
*
60+
* `bootstrapPlatformAdmin` is driven with exactly ONE row in `sys_user` under
61+
* the default (`single`, non-walled) posture. Its own return then reports the
62+
* predicate's verdict on that row directly:
63+
*
64+
* `isHumanUser(row)` truthy => the row is the oldest human => promoted
65+
* => `adminPromoted: true`
66+
* `isHumanUser(row)` falsy => zero humans => the "no human users yet" log
67+
* => `adminPromoted: false, reason: 'no_users'`
68+
*
69+
* The `reason` is asserted on the negative side on purpose. Every other way
70+
* this function can return `adminPromoted: false` carries a DIFFERENT reason
71+
* (`objectql_unavailable`, `admin_permission_set_missing`, `already_have_admin`,
72+
* `walled_*`, `insert_failed`), so a harness that broke and short-circuited
73+
* early would otherwise read as a unanimous "not human" and let this file pass
74+
* vacuously. `'no_users'` is reachable only through the human filter.
75+
*/
76+
77+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
78+
import { bootstrapPlatformAdmin } from '@objectstack/plugin-security';
79+
import { SystemUserId } from '@objectstack/spec/system';
80+
import { isHumanUserRow } from './audience-posture.js';
81+
82+
/**
83+
* Minimal in-memory ql: three tables, `where` matched by equality. Enough for
84+
* `bootstrapPlatformAdmin`'s seed step, its existing-admin probe and the
85+
* first-user promotion.
86+
*
87+
* Two deliberate omissions, both load-bearing:
88+
*
89+
* - **No `update`.** The only caller is the `resync` branch, which this pin
90+
* never asks for. Declaring one anyway would be a fake write verb looser
91+
* than `ObjectQL.update` sitting on a path no assertion covers — so it is
92+
* absent rather than pinned. Its absence also short-circuits
93+
* `claimSeedOwnership` (best-effort on the promotion path) at that
94+
* function's own `typeof ql.update !== 'function'` guard.
95+
* - **`find` honours the caller's `limit`** — applied AFTER the filter, by
96+
* presence, so a bound the caller really passes is not silently ignored by
97+
* a double that answers with more rows than the real engine would.
98+
*/
99+
function makeQl(userRows: unknown[]) {
100+
const tables: Record<string, any[]> = {
101+
sys_permission_set: [],
102+
sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[],
103+
sys_user_permission_set: [],
104+
};
105+
return {
106+
tables,
107+
async find(object: string, q: any) {
108+
const rows = tables[object] ?? [];
109+
const where = q?.where ?? {};
110+
const matched = rows.filter((r) =>
111+
Object.entries(where).every(([k, v]) => {
112+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
113+
return (r as any)?.[k] === v;
114+
}),
115+
);
116+
return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched;
117+
},
118+
async insert(object: string, data: any) {
119+
(tables[object] ??= []).push({ ...data });
120+
return { id: data.id };
121+
},
122+
};
123+
}
124+
125+
/** The one set the promotion path needs to exist before it can grant anything. */
126+
const ADMIN_SET = { name: 'admin_full_access', label: 'Administrator' } as any;
127+
128+
/**
129+
* plugin-security's verdict on a single row, read through the published
130+
* `bootstrapPlatformAdmin` entry point.
131+
*/
132+
async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?: string }> {
133+
const ql = makeQl([row]);
134+
const report = await bootstrapPlatformAdmin(ql as any, [ADMIN_SET]);
135+
return { human: report.adminPromoted, reason: report.reason };
136+
}
137+
138+
/**
139+
* The shared corpus. Every entry is a shape a `sys_user` read can really
140+
* return, and each names the property it is here to hold.
141+
*/
142+
const CORPUS: { name: string; row: unknown }[] = [
143+
{
144+
name: 'an ordinary human account',
145+
row: { id: 'usr_alice', role: 'member', email: 'alice@example.test' },
146+
},
147+
{
148+
name: 'the legacy usr_system service row — the population the divergence is observable on',
149+
row: { id: SystemUserId.SYSTEM, role: 'system', email: 'system@internal.test' },
150+
},
151+
{
152+
name: 'the legacy usr_system id carrying a NON-system role',
153+
row: { id: SystemUserId.SYSTEM, role: 'admin', email: 'system@internal.test' },
154+
},
155+
{
156+
name: 'an ordinary id carrying role=system',
157+
row: { id: 'usr_robot', role: 'system', email: 'robot@example.test' },
158+
},
159+
{
160+
name: 'a human whose role is NULL — the three-valued-logic case the JS filter exists for',
161+
row: { id: 'usr_bob', role: null, email: 'bob@example.test' },
162+
},
163+
{
164+
name: 'a human with no role column at all',
165+
row: { id: 'usr_carol', email: 'carol@example.test' },
166+
},
167+
{
168+
name: 'a human with an empty-string role',
169+
row: { id: 'usr_dana', role: '', email: 'dana@example.test' },
170+
},
171+
{
172+
name: 'role "System" — case differs, so neither owner may treat it as the service account',
173+
row: { id: 'usr_erin', role: 'System', email: 'erin@example.test' },
174+
},
175+
{
176+
name: 'an id that merely CONTAINS the system id as a substring',
177+
row: { id: `${SystemUserId.SYSTEM}_2`, role: 'member', email: 'frank@example.test' },
178+
},
179+
{
180+
name: 'a row with neither id nor role',
181+
row: { email: 'ghost@example.test' },
182+
},
183+
{ name: 'a null row', row: null },
184+
{ name: 'an undefined row', row: undefined },
185+
];
186+
187+
describe('human-user predicate agreement — plugin-security `isHumanUser` vs plugin-auth `isHumanUserRow`', () => {
188+
const saved: Record<string, string | undefined> = {};
189+
const PINNED_ENV = ['OS_TENANCY_POSTURE', 'OS_PLATFORM_OWNER_EMAIL'];
190+
191+
beforeEach(() => {
192+
for (const key of PINNED_ENV) saved[key] = process.env[key];
193+
// Pin the posture: the first-human promotion path is `single`. Left to the
194+
// ambient env this file would silently change which branch it measures.
195+
process.env.OS_TENANCY_POSTURE = 'single';
196+
delete process.env.OS_PLATFORM_OWNER_EMAIL;
197+
});
198+
199+
afterEach(() => {
200+
for (const key of PINNED_ENV) {
201+
if (saved[key] === undefined) delete process.env[key];
202+
else process.env[key] = saved[key];
203+
}
204+
});
205+
206+
for (const { name, row } of CORPUS) {
207+
it(`agrees on ${name}`, async () => {
208+
const authSays = isHumanUserRow(row);
209+
const security = await securityVerdict(row);
210+
211+
expect(
212+
security.human,
213+
`plugin-security and plugin-auth disagree on this row.\n` +
214+
` row: ${JSON.stringify(row)}\n` +
215+
` plugin-auth isHumanUserRow -> ${authSays}\n` +
216+
` plugin-security isHumanUser -> ${security.human} (reason: ${security.reason ?? 'none'})\n` +
217+
`Do NOT resolve this by editing one of them until it has been decided which is right —\n` +
218+
`they gate two halves of one boot (admission vs promotion) on one population.`,
219+
).toBe(authSays);
220+
221+
// Prove which branch produced a negative: only the human filter reaches
222+
// `no_users`. Without this the pin would pass on a harness that never got
223+
// as far as the predicate.
224+
if (!security.human) {
225+
expect(security.reason, 'negative verdict did not come from the human filter').toBe(
226+
'no_users',
227+
);
228+
}
229+
});
230+
}
231+
232+
it('anti-vacuity: the corpus really exercises both answers, and the harness can say both', async () => {
233+
const verdicts = await Promise.all(CORPUS.map(({ row }) => securityVerdict(row)));
234+
expect(verdicts.some((v) => v.human), 'no row was judged human — harness is stuck').toBe(true);
235+
expect(verdicts.some((v) => !v.human), 'no row was judged non-human — harness is stuck').toBe(
236+
true,
237+
);
238+
expect(CORPUS.map(({ row }) => isHumanUserRow(row)).some(Boolean)).toBe(true);
239+
expect(CORPUS.map(({ row }) => isHumanUserRow(row)).some((v) => !v)).toBe(true);
240+
});
241+
242+
it('the legacy usr_system row alone leaves the install with NO admin and awaiting a human', async () => {
243+
// The card's harm model, stated as an outcome rather than a predicate call:
244+
// a DB carrying only the legacy service row must be "no humans yet" on BOTH
245+
// sides — security declines to promote it, auth declines to count it.
246+
const legacy = { id: SystemUserId.SYSTEM, role: 'system', email: 'system@internal.test' };
247+
expect(isHumanUserRow(legacy)).toBe(false);
248+
const security = await securityVerdict(legacy);
249+
expect(security.human).toBe(false);
250+
expect(security.reason).toBe('no_users');
251+
});
252+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
35
import { defineConfig } from 'vitest/config';
46

7+
const here = path.dirname(fileURLToPath(import.meta.url));
8+
59
export default defineConfig({
610
test: {
711
environment: 'node',
812
testTimeout: 10_000,
13+
alias: [
14+
// The human-user predicate agreement pin drives plugin-security's
15+
// `bootstrapPlatformAdmin` to read its hand-spelled `isHumanUser`. A pin
16+
// is a verdict about the SOURCE in this checkout, so the specifier
17+
// resolves to `src/` rather than to a `dist/` that may predate the edit
18+
// under test. Anchored (`^…$`, array form) so the entry cannot swallow
19+
// subpath specifiers.
20+
{
21+
find: /^@objectstack\/plugin-security$/,
22+
replacement: path.resolve(here, '../plugin-security/src/index.ts'),
23+
},
24+
],
925
},
1026
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)