From 3b1bbd8905dd3a098b8273595ef3f21d0641debe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:19:15 +0000 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_0157mMVAq9fjGe2kaSD2aJC8 --- packages/plugins/plugin-auth/package.json | 1 + ...human-user-predicate-agreement.pin.test.ts | 244 ++++++++++++++++++ packages/plugins/plugin-auth/vitest.config.ts | 16 ++ pnpm-lock.yaml | 3 + 4 files changed, 264 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index e24276c68b..2342f57894 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -41,6 +41,7 @@ "@objectstack/driver-sql": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", + "@objectstack/plugin-security": "workspace:*", "@types/node": "^26.2.0", "hono": "^4.13.2", "typescript": "^6.0.3", diff --git a/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts b/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts new file mode 100644 index 0000000000..9824f91b84 --- /dev/null +++ b/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * AGREEMENT PIN — plugin-security's `isHumanUser` vs plugin-auth's + * `isHumanUserRow`, on the shared `sys_user` row corpus. + * + * ## What this pins and why it exists + * + * "Is this `sys_user` row a HUMAN?" is answered by two owners that decide two + * halves of ONE boot sequence, on ONE population: + * + * - `isHumanUserRow` (this package, `audience-posture.ts`) — the consolidated + * owner. The audience gate's bootstrap bypass and the dev-admin seed both + * read it, and it decides whether a sign-up is ADMITTED at all. + * - `isHumanUser` (`plugin-security/src/bootstrap-platform-admin.ts`) — a + * third, hand-spelled copy. It is the one that prints `[security] no human + * users yet — first sign-up will be promoted to platform admin` and then + * PERFORMS that promotion. + * + * The consolidation that unified the first two deliberately left the third + * where it is: `plugin-security` does not depend on `plugin-auth`, so sharing + * the predicate across them would mean moving it into a package both depend on + * (`@objectstack/spec` / `@objectstack/platform-objects`) — a published-surface + * change that consolidation rightly refused to carry, and that stays declined. + * + * So the copies stay, and this pin gates the property that actually matters: + * **they answer alike**. Divergence is not a tidiness complaint — the two + * disagreeing means a seed that decides to run and a gate that then refuses + * it, i.e. a fresh-looking install locked out of itself. The population where + * that is observable is named in the corpus below: a database still carrying + * the legacy `usr_system` service row (`SystemUserId.SYSTEM` — no longer + * provisioned, but present in every DB an older runtime created). + * + * ## Why the pin lives in plugin-auth and not in plugin-security + * + * Reaching both predicates from one test is a package-boundary problem, and + * only one direction solves it WITHOUT widening a published surface: + * + * - `isHumanUserRow` is module-scope-exported but is NOT re-exported from + * this package's `index.ts` and is not in its `exports` map, so nothing + * outside `plugin-auth` can import it. Pinning from `plugin-security` would + * require ADDING that export. + * - `isHumanUser` is a local closure inside `bootstrapPlatformAdmin` and is + * not exported at all — but `bootstrapPlatformAdmin` itself IS part of + * `@objectstack/plugin-security`'s published surface, and it is the real + * call site of the predicate. + * + * Hence: import `isHumanUserRow` relative (in-package, no surface change), and + * observe `isHumanUser` THROUGH the already-published entry point. The only + * new edge is a **devDependency** `plugin-auth -> plugin-security`; no + * production dependency, and no new export in either package. + * + * That edge is also what makes this a pin rather than decoration: CI's + * affected-package computation walks the dependency graph, so without it a + * `plugin-security`-only change would never mark this package affected and the + * pin would sit green through the very edit that breaks it. + * + * ## How the security-side verdict is read + * + * `bootstrapPlatformAdmin` is driven with exactly ONE row in `sys_user` under + * the default (`single`, non-walled) posture. Its own return then reports the + * predicate's verdict on that row directly: + * + * `isHumanUser(row)` truthy => the row is the oldest human => promoted + * => `adminPromoted: true` + * `isHumanUser(row)` falsy => zero humans => the "no human users yet" log + * => `adminPromoted: false, reason: 'no_users'` + * + * The `reason` is asserted on the negative side on purpose. Every other way + * this function can return `adminPromoted: false` carries a DIFFERENT reason + * (`objectql_unavailable`, `admin_permission_set_missing`, `already_have_admin`, + * `walled_*`, `insert_failed`), so a harness that broke and short-circuited + * early would otherwise read as a unanimous "not human" and let this file pass + * vacuously. `'no_users'` is reachable only through the human filter. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { bootstrapPlatformAdmin } from '@objectstack/plugin-security'; +import { SystemUserId } from '@objectstack/spec/system'; +import { isHumanUserRow } from './audience-posture.js'; + +/** + * Minimal in-memory ql: three tables, `where` matched by equality. Enough for + * `bootstrapPlatformAdmin`'s seed step, its existing-admin probe and the + * first-user promotion. `claimSeedOwnership` (best-effort, on the promotion + * path) short-circuits because this object exposes no `registry`. + */ +function makeQl(userRows: unknown[]) { + const tables: Record = { + sys_permission_set: [], + sys_user: userRows.map((r) => (r && typeof r === 'object' ? { ...(r as object) } : r)) as any[], + sys_user_permission_set: [], + }; + return { + tables, + async find(object: string, q: any) { + const rows = tables[object] ?? []; + const where = q?.where ?? {}; + return rows.filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return (r as any)?.[k] === v; + }), + ); + }, + async insert(object: string, data: any) { + (tables[object] ??= []).push({ ...data }); + return { id: data.id }; + }, + async update(object: string, data: any) { + const row = (tables[object] ?? []).find((r) => (r as any)?.id === data?.id); + if (row) Object.assign(row as object, data); + }, + }; +} + +/** The one set the promotion path needs to exist before it can grant anything. */ +const ADMIN_SET = { name: 'admin_full_access', label: 'Administrator' } as any; + +/** + * plugin-security's verdict on a single row, read through the published + * `bootstrapPlatformAdmin` entry point. + */ +async function securityVerdict(row: unknown): Promise<{ human: boolean; reason?: string }> { + const ql = makeQl([row]); + const report = await bootstrapPlatformAdmin(ql as any, [ADMIN_SET]); + return { human: report.adminPromoted, reason: report.reason }; +} + +/** + * The shared corpus. Every entry is a shape a `sys_user` read can really + * return, and each names the property it is here to hold. + */ +const CORPUS: { name: string; row: unknown }[] = [ + { + name: 'an ordinary human account', + row: { id: 'usr_alice', role: 'member', email: 'alice@example.test' }, + }, + { + name: 'the legacy usr_system service row — the population the divergence is observable on', + row: { id: SystemUserId.SYSTEM, role: 'system', email: 'system@internal.test' }, + }, + { + name: 'the legacy usr_system id carrying a NON-system role', + row: { id: SystemUserId.SYSTEM, role: 'admin', email: 'system@internal.test' }, + }, + { + name: 'an ordinary id carrying role=system', + row: { id: 'usr_robot', role: 'system', email: 'robot@example.test' }, + }, + { + name: 'a human whose role is NULL — the three-valued-logic case the JS filter exists for', + row: { id: 'usr_bob', role: null, email: 'bob@example.test' }, + }, + { + name: 'a human with no role column at all', + row: { id: 'usr_carol', email: 'carol@example.test' }, + }, + { + name: 'a human with an empty-string role', + row: { id: 'usr_dana', role: '', email: 'dana@example.test' }, + }, + { + name: 'role "System" — case differs, so neither owner may treat it as the service account', + row: { id: 'usr_erin', role: 'System', email: 'erin@example.test' }, + }, + { + name: 'an id that merely CONTAINS the system id as a substring', + row: { id: `${SystemUserId.SYSTEM}_2`, role: 'member', email: 'frank@example.test' }, + }, + { + name: 'a row with neither id nor role', + row: { email: 'ghost@example.test' }, + }, + { name: 'a null row', row: null }, + { name: 'an undefined row', row: undefined }, +]; + +describe('human-user predicate agreement — plugin-security `isHumanUser` vs plugin-auth `isHumanUserRow`', () => { + const saved: Record = {}; + const PINNED_ENV = ['OS_TENANCY_POSTURE', 'OS_PLATFORM_OWNER_EMAIL']; + + beforeEach(() => { + for (const key of PINNED_ENV) saved[key] = process.env[key]; + // Pin the posture: the first-human promotion path is `single`. Left to the + // ambient env this file would silently change which branch it measures. + process.env.OS_TENANCY_POSTURE = 'single'; + delete process.env.OS_PLATFORM_OWNER_EMAIL; + }); + + afterEach(() => { + for (const key of PINNED_ENV) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + }); + + for (const { name, row } of CORPUS) { + it(`agrees on ${name}`, async () => { + const authSays = isHumanUserRow(row); + const security = await securityVerdict(row); + + expect( + security.human, + `plugin-security and plugin-auth disagree on this row.\n` + + ` row: ${JSON.stringify(row)}\n` + + ` plugin-auth isHumanUserRow -> ${authSays}\n` + + ` plugin-security isHumanUser -> ${security.human} (reason: ${security.reason ?? 'none'})\n` + + `Do NOT resolve this by editing one of them until it has been decided which is right —\n` + + `they gate two halves of one boot (admission vs promotion) on one population.`, + ).toBe(authSays); + + // Prove which branch produced a negative: only the human filter reaches + // `no_users`. Without this the pin would pass on a harness that never got + // as far as the predicate. + if (!security.human) { + expect(security.reason, 'negative verdict did not come from the human filter').toBe( + 'no_users', + ); + } + }); + } + + it('anti-vacuity: the corpus really exercises both answers, and the harness can say both', async () => { + const verdicts = await Promise.all(CORPUS.map(({ row }) => securityVerdict(row))); + expect(verdicts.some((v) => v.human), 'no row was judged human — harness is stuck').toBe(true); + expect(verdicts.some((v) => !v.human), 'no row was judged non-human — harness is stuck').toBe( + true, + ); + expect(CORPUS.map(({ row }) => isHumanUserRow(row)).some(Boolean)).toBe(true); + expect(CORPUS.map(({ row }) => isHumanUserRow(row)).some((v) => !v)).toBe(true); + }); + + it('the legacy usr_system row alone leaves the install with NO admin and awaiting a human', async () => { + // The card's harm model, stated as an outcome rather than a predicate call: + // a DB carrying only the legacy service row must be "no humans yet" on BOTH + // sides — security declines to promote it, auth declines to count it. + const legacy = { id: SystemUserId.SYSTEM, role: 'system', email: 'system@internal.test' }; + expect(isHumanUserRow(legacy)).toBe(false); + const security = await securityVerdict(legacy); + expect(security.human).toBe(false); + expect(security.reason).toBe('no_users'); + }); +}); diff --git a/packages/plugins/plugin-auth/vitest.config.ts b/packages/plugins/plugin-auth/vitest.config.ts index 9032042826..017b1e591c 100644 --- a/packages/plugins/plugin-auth/vitest.config.ts +++ b/packages/plugins/plugin-auth/vitest.config.ts @@ -1,10 +1,26 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +const here = path.dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ test: { environment: 'node', testTimeout: 10_000, + alias: [ + // The human-user predicate agreement pin drives plugin-security's + // `bootstrapPlatformAdmin` to read its hand-spelled `isHumanUser`. A pin + // is a verdict about the SOURCE in this checkout, so the specifier + // resolves to `src/` rather than to a `dist/` that may predate the edit + // under test. Anchored (`^…$`, array form) so the entry cannot swallow + // subpath specifiers. + { + find: /^@objectstack\/plugin-security$/, + replacement: path.resolve(here, '../plugin-security/src/index.ts'), + }, + ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c92abf8061..0273e4d8c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1539,6 +1539,9 @@ importers: '@objectstack/plugin-hono-server': specifier: workspace:* version: link:../plugin-hono-server + '@objectstack/plugin-security': + specifier: workspace:* + version: link:../plugin-security '@types/node': specifier: ^26.2.0 version: 26.2.0 From fc38b082bb2ca706e9349e6359edd1507c3ef1c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 06:40:31 +0000 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_0157mMVAq9fjGe2kaSD2aJC8 --- ...human-user-predicate-agreement.pin.test.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts b/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts index 9824f91b84..a01053d6dd 100644 --- a/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts +++ b/packages/plugins/plugin-auth/src/human-user-predicate-agreement.pin.test.ts @@ -82,8 +82,19 @@ import { isHumanUserRow } from './audience-posture.js'; /** * Minimal in-memory ql: three tables, `where` matched by equality. Enough for * `bootstrapPlatformAdmin`'s seed step, its existing-admin probe and the - * first-user promotion. `claimSeedOwnership` (best-effort, on the promotion - * path) short-circuits because this object exposes no `registry`. + * first-user promotion. + * + * Two deliberate omissions, both load-bearing: + * + * - **No `update`.** The only caller is the `resync` branch, which this pin + * never asks for. Declaring one anyway would be a fake write verb looser + * than `ObjectQL.update` sitting on a path no assertion covers — so it is + * absent rather than pinned. Its absence also short-circuits + * `claimSeedOwnership` (best-effort on the promotion path) at that + * function's own `typeof ql.update !== 'function'` guard. + * - **`find` honours the caller's `limit`** — applied AFTER the filter, by + * presence, so a bound the caller really passes is not silently ignored by + * a double that answers with more rows than the real engine would. */ function makeQl(userRows: unknown[]) { const tables: Record = { @@ -96,21 +107,18 @@ function makeQl(userRows: unknown[]) { async find(object: string, q: any) { const rows = tables[object] ?? []; const where = q?.where ?? {}; - return rows.filter((r) => + const matched = rows.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return (r as any)?.[k] === v; }), ); + return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; }, async insert(object: string, data: any) { (tables[object] ??= []).push({ ...data }); return { id: data.id }; }, - async update(object: string, data: any) { - const row = (tables[object] ?? []).find((r) => (r as any)?.id === data?.id); - if (row) Object.assign(row as object, data); - }, }; }