Compare commits

..
Author SHA1 Message Date
fred 8a03d99e31 contract(custody-schema): revision 13 — withdraw wrong §4.5 repair attribution (sol r12 F20 residual)
ci/woodpecker/pr/ci Pipeline was successful
Sol's r12 confirm closed F18/F19 and found one MINOR residual on F20:
rev 12 assigned the terminated ordinary writer's committed-but-unpointed
content to §4.5 repair, but §4.5 enumerates pointers and repairs the
opposite state (pointer present, content absent) — unpointed content is
outside its input, so the §7.10 assertion had no conforming transition.

Per the prescribed correction: the §4.5 repair assertion is removed from
§3.7 and §7.10; the no-upsert assertion stays; the state is what §3.7
and §7.16 already define — inert unpointed content (no pointer, no read
path, no repair transition; a §4.5 cycle run against it changes
nothing), never acknowledged, converging only through the client's
retry as a fresh lock acquisition under a new token that recommits and
completes the pointer upsert. Preamble Revision 13 paragraph added.
2026-08-27 04:48:07 -05:00
fred 8085982396 contract(custody-schema): revision 12 — exact token comparison base case, split stale database-half boundary
ci/woodpecker/pr/ci Pipeline was successful
sol r11 APPROVE minors:
- F19: strictly-less-than refusal made explicit; absent recorded token
  compares lower than every minted token, first acceptance creates the
  record; §7.10 virgin-locator control + delayed-first-grant race.
- F18 residual closed: §7.10 managed-deletion fault predicate restated
  as the exact §4.7 predicate (advance's epoch mint and token record
  permitted).
- F20: pre-advance linearization boundary distinguishes database
  halves — pair-conditioned step 2 for deletion/repair; terminated
  ordinary writer performs no pointer upsert, retry is a fresh
  acquisition; §7.10 control runs both branches.
- §8 item 16 revision-12 clause; preamble Revision 12 paragraph.
2026-08-27 04:19:19 -05:00
fred 2c02631324 custody contract revision 11: acquisition-token guard on the fence-advance (sol r10 F16 residual, F18)
ci/woodpecker/pr/ci Pipeline was successful
The fence-advance is itself fenced against an older acquisition: a
per-locator acquisition token minted by the lock authority at every
lock grant, carried unchanged across the acquisition's brain
operations and swap-loop retries, compared and recorded by the brain
store atomically with every brain mutation (advance included). A
superseded acquisition's delayed advance is refused, mints nothing,
and yields no usable fence (mandatory abort-release-surface,
expected on deadline supersession). The 'refused unconditionally'
claim is precisified to the exact linearization boundary; the
pre-step-1 interruption predicates are restated over exact
content/database state, expressly permitting the advance's
epoch/token change (F18). New §7.10 stale-advance refusal and
pre-advance linearization controls; token invariants added to the
fence static controls (§7.10, §7.16); §8 item 16 and preamble
extended.
2026-08-27 03:47:21 -05:00
fred b10cfb1e69 docs: custody-schema contract revision 10 (sol r9 F16 residual + F17: ABA-resistant per-locator fence epoch — fresh mint on every mutation incl. byte-identical and absence tombstone, fence-advance at lock acquisition invalidating stale tails before first mutation, repair advances before observing; distinct-locator no-contention narrowed to locator locks)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-27 03:04:52 -05:00
fred c6a2237dde docs: custody-schema contract revision 9 (sol r8 F15 residual + F16: independent deadline supervisor, brain fence via conditional application, reservation-at-skip, computed fixed cycle maximum with explicit exceedance carry-over)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-27 02:22:17 -05:00
fred f8ac64f58a docs: custody-schema contract revision 8 (sol r7 residual F15: declared hold deadline + arrival-order grants make the one-cycle bound wall-clock provable)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-26 23:08:12 -05:00
fred 482400d93c docs: custody-schema contract revision 7 (sol r6 F15: within-cycle blocking retry preserves the one-cycle orphan bound under lock contention)
ci/woodpecker/pr/ci Pipeline failed
2026-08-26 22:38:20 -05:00
fred 8ca4564b5e docs: custody-schema contract revision 6 (sol r5 residuals F9/F12 + F14)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-26 22:06:00 -05:00
fred a8111998c7 docs: custody-schema contract revision 5 (sol r4 residuals F9/F11 + F12/F13)
ci/woodpecker/pr/ci Pipeline failed
2026-08-26 21:25:53 -05:00
fred 269aeda193 docs: custody-schema contract revision 4 (sol r3 residuals F1/F2/F4/F6/F7/F9 + F10/F11)
ci/woodpecker/pr/ci Pipeline failed
2026-08-26 21:02:12 -05:00
fred 671b872ec9 docs: custody-schema contract revision 3 (sol re-review residuals F1-F9)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-26 20:33:21 -05:00
fred 8079d8dc91 docs(custody): revision 2 — classifier registry, grantee model, exact schemas, witness closure (sol r1 F1-F6)
ci/woodpecker/pr/ci Pipeline is running
2026-08-26 19:39:04 -05:00
fred caa5b04096 docs: custody pointer and consent schema contract (S2 contract 7)
ci/woodpecker/pr/ci Pipeline is running
2026-08-26 19:10:12 -05:00
125 changed files with 2874 additions and 30251 deletions
+3 -6
View File
@@ -40,12 +40,9 @@ BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
BETTER_AUTH_URL=http://localhost:14242
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
# Directory holding the built SPA bundle (vite build output). When set, the
# gateway serves the SPA same-origin; when unset (dev), run the Vite dev
# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway.
# safe-default: unset in dev — SPA serving is an opt-in production concern
#WEB_DIST_DIR=apps/web/dist
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
# Public gateway URL — accessible from the browser, not just the server.
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
-4
View File
@@ -23,7 +23,3 @@ infra/step-ca/dev-password
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
# file vanished mid-scan. Ignoring them removes the race.
*.timestamp-*.mjs
# Playwright run artifacts (#1445, P6 E2E gate)
apps/web/test-results/
apps/web/playwright-report/
-17
View File
@@ -254,23 +254,6 @@ steps:
depends_on:
- typecheck
# Canonical verify:release stage `build` (#1445, P6): every PR proves the
# full workspace build — including the SPA `vite build` — before merge,
# instead of leaving build breakage to surface post-merge in publish.yml's
# verify step. Same canonical command the publish pipeline's build step runs.
build:
image: *node_image
commands:
- *enable_pnpm
- pnpm build
depends_on:
# after test, not typecheck: turbo gives `test` a ^build dependency, so
# running this step concurrently with test would put two independent
# turbo builds on the same shared-workspace dist/ and turbo cache with
# no cross-process locking — the same serialization invariant
# publish.yml documents for #1411.
- test
services:
ci-postgres:
image: pgvector/pgvector:pg17
+44 -94
View File
@@ -407,96 +407,6 @@ steps:
- build
- verify
# #1445 (P6): headless Playwright E2E gate on every trunk merge. Boots the
# real gateway on the embedded PGlite path (no DATABASE_URL, no services)
# serving the built SPA bundle via WEB_DIST_DIR — the exact serving path the
# gateway image ships (docker/gateway.Dockerfile sets WEB_DIST_DIR to the
# baked bundle), which keeps #1407's parity guarantee: the image build steps
# below depend on this gate, so a bundle that fails E2E never publishes.
#
# Image pinned to the @playwright/test version in pnpm-lock.yaml so the
# image's bundled browsers match the workspace driver exactly (bump the two
# together). The step installs no workspace packages (corepack does fetch
# the pinned pnpm itself): it reuses the workspace node_modules
# from `install` and the dist outputs from `build` — the gateway's runtime
# dependency path is pure JS/WASM (PGlite is WASM, postgres-js is pure JS),
# so the alpine-installed modules run unchanged under this glibc image.
# depends_on publish-next-npm per the #1411 serialization invariant: this
# step reads the workspace and must never run inside the manifest-transform
# window.
e2e:
image: mcr.microsoft.com/playwright:v1.58.2-noble
environment:
GATEWAY_PORT: '14242'
PLAYWRIGHT_BASE_URL: http://localhost:14242
# The database is seeded by Playwright's globalSetup in this step, so
# login failures are real failures: without this flag the suite's
# skip-when-login-fails guards (a live-environment affordance) could
# skip every authenticated spec and go green while proving nothing.
E2E_REQUIRE_SEEDED_AUTH: '1'
commands:
- corepack enable
- |
# Throwaway signing secret for this step's ephemeral embedded database
# (the gateway refuses to boot without one). Generated per run so no
# usable literal lives in the tree.
export BETTER_AUTH_SECRET="$(head -c 32 /dev/urandom | base64)"
export WEB_DIST_DIR="$(pwd)/apps/web/dist"
if [ ! -f "$WEB_DIST_DIR/index.html" ]; then
echo "[e2e] FATAL: $WEB_DIST_DIR/index.html missing — did the build step run?" >&2
exit 1
fi
# Boot the gateway from the built dist, cwd- AND HOME-isolated: the
# local-tier PGlite database lives under $HOME/.config/mosaic/gateway/
# (database.module.ts), not under cwd, so HOME must point at the
# throwaway dir too or the run would share a database with anything
# else in the container's home.
GATEWAY_RUN_DIR="$(mktemp -d /tmp/e2e-gateway.XXXXXX)"
(cd "$GATEWAY_RUN_DIR" && export HOME="$GATEWAY_RUN_DIR" && exec node "$OLDPWD/apps/gateway/dist/main.js") > /tmp/gateway.log 2>&1 &
GATEWAY_PID=$!
ready=0
for i in $(seq 1 90); do
if node -e "fetch('http://localhost:' + process.env.GATEWAY_PORT + '/health', { signal: AbortSignal.timeout(2000) }).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"; then
ready=1
break
fi
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
echo "[e2e] FATAL: gateway process exited during startup" >&2
cat /tmp/gateway.log >&2
exit 1
fi
echo "[e2e] waiting for gateway ($i/90)..."
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "[e2e] FATAL: gateway did not become ready in 90s" >&2
cat /tmp/gateway.log >&2
exit 1
fi
echo "[e2e] gateway ready; running Playwright suite"
set +e
pnpm --filter @mosaicstack/web exec playwright test
E2E_EXIT=$?
set -e
kill "$GATEWAY_PID" 2>/dev/null || true
if [ "$E2E_EXIT" -ne 0 ]; then
echo "[e2e] FATAL: Playwright suite failed (exit $E2E_EXIT); gateway log follows" >&2
tail -100 /tmp/gateway.log >&2
echo "[e2e] browser-side traces/screenshots are under apps/web/test-results/ in the step workspace (not persisted past the pod)" >&2
fi
exit "$E2E_EXIT"
# Same filter as the image builds it gates: a merge that publishes no
# image (docs-only on main) pays no browser suite, and a skipped e2e does
# not block anything (skipped-dependency semantics, same as
# publish-next-npm on tag events).
when: *image_build_when
depends_on:
- build
- verify
# #1411: never read the workspace inside publish-next-npm's
# manifest-transform window.
- publish-next-npm
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
# image: *node_image
@@ -556,8 +466,6 @@ steps:
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
# serialization invariant; add it to every new workspace consumer.
- publish-next-npm
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
- e2e
build-appservice:
image: gcr.io/kaniko-project/executor:debug
@@ -602,5 +510,47 @@ steps:
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
# serialization invariant; add it to every new workspace consumer.
- publish-next-npm
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
- e2e
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
REGISTRY_PASS:
from_secret: REGISTRY_PASSWORD
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next web publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
depends_on:
- build
- verify
# #1411: publish-next-npm mutates workspace manifests in place during
# its transform window and restores them at step end. Any step that
# reads the pipeline workspace (kaniko COPY of manifests, later
# installs) must run AFTER publish-next-npm, never concurrently —
# pipeline 2648 raced a COPY inside the window and failed
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
# serialization invariant; add it to every new workspace consumer.
- publish-next-npm
-1
View File
@@ -28,7 +28,6 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@fastify/helmet": "^13.0.2",
"@fastify/static": "^8.3.0",
"@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1",
@@ -1,133 +0,0 @@
import { RequestMethod, type Type } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { AppModule } from '../app.module.js';
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
/**
* Hierarchy route inventory (contract 1 §6.3).
*
* The hierarchy command family is a CLOSED enumeration asserted here, not a
* prose claim: every hierarchy-flavored route the AppModule graph declares
* must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or
* removing a hierarchy route without updating this inventory (and its
* witnesses) fails CI first. This replaces the M4-1b-i zero-routes
* baseline.
*/
interface RouteEntry {
method: string;
path: string;
controller: string;
}
/** Module-metadata entry: a module class or a DynamicModule-shaped object. */
type ModuleEntry =
| Type<unknown>
| { module: Type<unknown>; imports?: unknown[]; controllers?: Type<unknown>[] };
function collectControllers(root: ModuleEntry): Type<unknown>[] {
const visited = new Set<unknown>();
const controllers: Type<unknown>[] = [];
const walk = (entry: ModuleEntry | undefined | null): void => {
if (!entry || visited.has(entry)) return;
visited.add(entry);
const moduleClass = typeof entry === 'function' ? entry : entry.module;
// Entries with no resolvable class (forwardRef wrappers, async dynamic
// modules) carry no decorator metadata to read here.
if (typeof moduleClass !== 'function') return;
if (visited.has(moduleClass) && typeof entry !== 'function') return;
visited.add(moduleClass);
// 'controllers' / 'imports' are the metadata keys the @Module decorator writes.
const declared = (Reflect.getMetadata('controllers', moduleClass) ?? []) as Type<unknown>[];
controllers.push(...declared);
if (typeof entry !== 'function' && entry.controllers) controllers.push(...entry.controllers);
const imports = [
...((Reflect.getMetadata('imports', moduleClass) ?? []) as ModuleEntry[]),
...(typeof entry !== 'function' ? ((entry.imports ?? []) as ModuleEntry[]) : []),
];
for (const imported of imports) walk(imported);
};
walk(root);
return controllers;
}
function routesOf(controller: Type<unknown>): RouteEntry[] {
// 'path' on the class is the @Controller prefix; 'path'/'method' on a
// handler are written by the @Get/@Post/... route decorators.
const base = (Reflect.getMetadata('path', controller) ?? '') as string | string[];
const bases = Array.isArray(base) ? base : [base];
const routes: RouteEntry[] = [];
const prototype = controller.prototype as Record<string, unknown>;
for (const name of Object.getOwnPropertyNames(prototype)) {
if (name === 'constructor') continue;
const handler = Object.getOwnPropertyDescriptor(prototype, name)?.value;
if (typeof handler !== 'function') continue;
const method = Reflect.getMetadata('method', handler) as number | undefined;
if (method === undefined) continue;
const sub = (Reflect.getMetadata('path', handler) ?? '/') as string;
for (const prefix of bases) {
const path = `/${prefix}/${sub}`.replace(/\/+/g, '/').replace(/(.)\/$/, '$1');
routes.push({
method: RequestMethod[method] ?? String(method),
path,
controller: controller.name,
});
}
}
return routes;
}
/**
* The closed command family (contract 1 §5, M4-1b-ii). Every entry is a
* mutation audited via the M4-1b-i path or one of the two ratified reads
* (granted companies, the §2.8 directory carve-out).
*/
const HIERARCHY_COMMAND_FAMILY = [
'POST /api/hierarchy/companies',
'GET /api/hierarchy/companies',
'GET /api/hierarchy/companies/directory',
'POST /api/hierarchy/companies/:id/rename',
'POST /api/hierarchy/companies/:id/visibility',
'DELETE /api/hierarchy/companies/:id',
'POST /api/hierarchy/estates',
'POST /api/hierarchy/estates/:id/rename',
'POST /api/hierarchy/estates/:id/transfer',
'DELETE /api/hierarchy/estates/:id',
'POST /api/hierarchy/platform-projects',
'POST /api/hierarchy/platform-projects/:id/rename',
'POST /api/hierarchy/platform-projects/:id/transfer',
'DELETE /api/hierarchy/platform-projects/:id',
'POST /api/hierarchy/grants',
'POST /api/hierarchy/grants/:id/change',
'DELETE /api/hierarchy/grants/:id',
] as const;
describe('hierarchy route inventory (§6.3)', () => {
const inventory = collectControllers(AppModule).flatMap(routesOf);
it('control: the enumeration sees the known route surface', () => {
const paths = inventory.map((r) => `${r.method} ${r.path}`);
expect(paths).toContain('GET /health');
expect(paths).toContain('POST /api/workspaces');
expect(paths).toContain('GET /api/teams');
expect(inventory.length).toBeGreaterThan(20);
});
it('the hierarchy surface is exactly the declared command family', () => {
const hierarchyRoutes = inventory
.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path))
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
});
it('every command-family route lives on HierarchyController inside HierarchyModule', () => {
const controllers = collectControllers(HierarchyModule);
expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']);
const declared = controllers
.flatMap(routesOf)
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
});
});
-2
View File
@@ -24,7 +24,6 @@ import { GCModule } from './gc/gc.module.js';
import { HarnessModule } from './harness/harness.module.js';
import { ReloadModule } from './reload/reload.module.js';
import { WorkspaceModule } from './workspace/workspace.module.js';
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@@ -66,7 +65,6 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
QueueModule,
ReloadModule,
WorkspaceModule,
HierarchyModule,
...(federationEnabled ? [FederationModule] : []),
],
controllers: [HealthController],
@@ -61,33 +61,6 @@ describe('CommandAuthorizationService', () => {
).toBe(false);
});
it('denies non-admin scopes to a platform admin (contract 2 §1.1 bypass retirement)', async (): Promise<void> => {
const service = createService('admin');
for (const scope of ['core', 'agent', 'skill', 'plugin'] as const) {
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
expect(
(await service.authorize(command, { ...payload, command: command.name }, 'admin-1'))
.allowed,
).toBe(false);
}
});
it('allows member core/agent scopes and denies skill/plugin (deny-by-default)', async (): Promise<void> => {
const service = createService('member');
for (const [scope, allowed] of [
['core', true],
['agent', true],
['skill', false],
['plugin', false],
] as const) {
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
expect(
(await service.authorize(command, { ...payload, command: command.name }, 'member-1'))
.allowed,
).toBe(allowed);
}
});
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
@@ -154,15 +154,8 @@ export class CommandAuthorizationService {
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
}
/**
* Contract 2 §1.1: platform admin confers instance administration only —
* the former admin-passes-every-scope short-circuit is retired. Admin
* reaches exactly the admin scope; core/agent scopes belong to the member
* role; skill/plugin scopes stay deny-for-all until a grant mapping names
* them (§3.1 deny-by-default).
*/
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
if (scope === 'admin') return role === 'admin';
if (role === 'admin') return true;
return role === 'member' && (scope === 'core' || scope === 'agent');
}
@@ -1,247 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import {
companies,
createPgliteDb,
eq,
estates,
hierarchyAuditEvents,
hierarchyOutbox,
platformProjects,
runPgliteMigrations,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import {
HierarchyAuditIdempotencyConflictError,
HierarchyAuditRepository,
HierarchyNodeNotFoundError,
type AppendHierarchyEventInput,
} from './hierarchy-audit.repository.js';
/**
* Repository-level §6.4 witnesses for the hierarchy audit machinery
* (contract 1 §5.2, REQ-AUD-001): same-transaction atomicity of state +
* event + outbox, rollback leaving no residue, idempotent replay, snapshot
* parent chains, events surviving target deletion, per-target ordering, and
* the outbox claim/complete/release CAS. The schema-level constraints are
* witnessed in packages/db/src/hierarchy-audit.witness.test.ts.
*/
describe('hierarchy audit repository integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let repo: HierarchyAuditRepository;
const input = (
overrides: Partial<AppendHierarchyEventInput> = {},
): AppendHierarchyEventInput => ({
actorId: 'user-actor',
verb: 'create',
targetKind: 'company',
targetId: randomUUID(),
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
correlationId: 'corr-1',
idempotencyKey: `key-${randomUUID()}`,
...overrides,
});
beforeAll(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-audit-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
moduleRef = await Test.createTestingModule({
providers: [HierarchyAuditRepository, { provide: DB, useValue: handle.db }],
}).compile();
repo = moduleRef.get(HierarchyAuditRepository);
});
afterAll(async (): Promise<void> => {
await moduleRef.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
it('commits state, event, and outbox record atomically in one transaction', async () => {
const companyId = randomUUID();
const key = `key-${randomUUID()}`;
await handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Atomic Co', slug: 'atomic-co' });
const snapshot = await repo.snapshot(tx, 'company', companyId);
const result = await repo.append(tx, {
...input({ targetId: companyId, idempotencyKey: key }),
targetSnapshot: { ...snapshot },
});
expect(result.replayed).toBe(false);
expect(result.event.idempotencyKey).toBe(key);
});
const events = await handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
expect(events).toHaveLength(1);
const outbox = await handle.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.eventId, events[0]!.id));
expect(outbox).toHaveLength(1);
expect(outbox[0]).toMatchObject({
status: 'pending',
idempotencyKey: key,
correlationId: 'corr-1',
});
});
it('a rolled-back transaction leaves no state, no event, and no outbox record', async () => {
const companyId = randomUUID();
const key = `key-${randomUUID()}`;
await expect(
handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Doomed Co', slug: 'doomed-co' });
await repo.append(tx, input({ targetId: companyId, idempotencyKey: key }));
throw new Error('deliberate rollback');
}),
).rejects.toThrow('deliberate rollback');
const [companyRows, eventRows, outboxRows] = await Promise.all([
handle.db.select().from(companies).where(eq(companies.id, companyId)),
handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key)),
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key)),
]);
expect(companyRows).toHaveLength(0);
expect(eventRows).toHaveLength(0);
expect(outboxRows).toHaveLength(0);
});
it('replays a duplicate idempotency key without inserting a second event or outbox record', async () => {
const first = input();
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(original.replayed).toBe(false);
expect(replay.replayed).toBe(true);
expect(replay.event.id).toBe(original.event.id);
const outbox = await handle.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.eventId, original.event.id));
expect(outbox).toHaveLength(1);
});
it('throws on a duplicate idempotency key carrying different event content', async () => {
const first = input();
await handle.db.transaction(async (tx) => repo.append(tx, first));
await expect(
handle.db.transaction(async (tx) =>
repo.append(tx, { ...first, verb: 'rename', targetId: randomUUID() }),
),
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
});
it('throws on a duplicate idempotency key whose transfer destination differs', async () => {
const from = { kind: 'company' as const, id: randomUUID(), slug: 'src-co' };
const to = { kind: 'company' as const, id: randomUUID(), slug: 'dst-co' };
const first = input({
verb: 'transfer',
targetKind: 'estate',
transferFrom: from,
transferTo: to,
});
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(original.replayed).toBe(false);
// Identical retry replays; a retry re-routed to a different destination must conflict.
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(replay.replayed).toBe(true);
await expect(
handle.db.transaction(async (tx) =>
repo.append(tx, { ...first, transferTo: { ...to, id: randomUUID() } }),
),
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
});
it('builds root-first parent chains and rejects unknown nodes', async () => {
const companyId = randomUUID();
const estateId = randomUUID();
const projectId = randomUUID();
await handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Chain Co', slug: 'chain-co' });
await tx
.insert(estates)
.values({ id: estateId, name: 'Chain Estate', slug: 'chain-estate', companyId });
await tx
.insert(platformProjects)
.values({ id: projectId, name: 'Chain Project', slug: 'chain-project', estateId });
});
const snapshot = await repo.snapshot(handle.db, 'platform_project', projectId);
expect(snapshot).toMatchObject({ id: projectId, slug: 'chain-project', name: 'Chain Project' });
expect(snapshot.parentChain).toEqual([
{ kind: 'company', id: companyId, slug: 'chain-co' },
{ kind: 'estate', id: estateId, slug: 'chain-estate' },
]);
await expect(repo.snapshot(handle.db, 'estate', randomUUID())).rejects.toThrow(
HierarchyNodeNotFoundError,
);
});
it('keeps events readable, in per-target seq order, after the target row is deleted', async () => {
const companyId = randomUUID();
await handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Mortal Co', slug: 'mortal-co' });
const snapshot = await repo.snapshot(tx, 'company', companyId);
await repo.append(tx, input({ targetId: companyId, targetSnapshot: { ...snapshot } }));
});
await handle.db.transaction(async (tx) => {
const snapshot = await repo.snapshot(tx, 'company', companyId);
await repo.append(tx, {
...input({ verb: 'delete', targetId: companyId }),
targetSnapshot: { ...snapshot },
});
await tx.delete(companies).where(eq(companies.id, companyId));
});
const events = await repo.eventsForTarget(companyId);
expect(events.map((e) => e.verb)).toEqual(['create', 'delete']);
expect(events[1]!.seq).toBeGreaterThan(events[0]!.seq);
expect((events[1]!.targetSnapshot as { id: string }).id).toBe(companyId);
});
it('claims the oldest pending outbox record exactly once, completes and releases by CAS', async () => {
// Drain records left pending by earlier cases so ordering is deterministic.
for (;;) {
const drained = await repo.claimPendingOutbox();
if (!drained) break;
await repo.completeOutbox(drained.id);
}
const older = await handle.db.transaction(async (tx) => repo.append(tx, input()));
const newer = await handle.db.transaction(async (tx) => repo.append(tx, input()));
const claimed = await repo.claimPendingOutbox();
expect(claimed).not.toBeNull();
expect(claimed!.eventId).toBe(older.event.id);
expect(claimed!.status).toBe('processing');
// Delivery fails: release returns it to pending and it is claimable again.
await repo.releaseOutbox(claimed!.id);
const reclaimed = await repo.claimPendingOutbox();
expect(reclaimed!.id).toBe(claimed!.id);
await repo.completeOutbox(reclaimed!.id);
const done = await handle.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.id, reclaimed!.id));
expect(done[0]!.status).toBe('delivered');
expect(done[0]!.deliveredAt).not.toBeNull();
// completeOutbox is CAS-guarded on 'processing': completing again is a no-op.
await repo.completeOutbox(reclaimed!.id);
const second = await repo.claimPendingOutbox();
expect(second!.eventId).toBe(newer.event.id);
await repo.completeOutbox(second!.id);
expect(await repo.claimPendingOutbox()).toBeNull();
});
});
@@ -1,274 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
companies,
eq,
estates,
hierarchyAuditEvents,
hierarchyOutbox,
platformProjects,
type Db,
type HIERARCHY_AUDIT_TARGET_KINDS,
type HIERARCHY_AUDIT_VERBS,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
/**
* Hierarchy audit event + outbox machinery (contract 1 §5.2).
*
* Every hierarchy mutation writes its semantic audit event AND the event's
* outbox record on the caller's transaction, so state, event, and outbox
* commit or roll back together. Events reference their target by an
* immutable snapshot (id, slug, parent chain at event time), never by a
* foreign key into the class tables — append-only events survive the
* deletion of their target. This module exposes no update or delete path
* for events: append-only is a property of the code surface, witnessed by
* the integration tests.
*
* This is NOT a class-table writer: it touches only the audit/outbox
* tables, so it does not appear on the writer-coverage allowlist. The
* hierarchy command repository (HierarchyRepository) is the allowlisted
* writer and calls into this on its own transactions.
*/
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
export type HierarchyTargetKind = (typeof HIERARCHY_AUDIT_TARGET_KINDS)[number];
export type HierarchyNodeKind = Exclude<HierarchyTargetKind, 'grant'>;
export interface ParentChainEntry {
readonly kind: HierarchyNodeKind;
readonly id: string;
readonly slug: string;
}
/** Immutable node snapshot at event time; parentChain is root-first. */
export interface HierarchyNodeSnapshot {
readonly id: string;
readonly slug: string;
readonly name: string;
readonly parentChain: readonly ParentChainEntry[];
}
export interface AppendHierarchyEventInput {
readonly actorId: string;
readonly verb: HierarchyAuditVerb;
readonly targetKind: HierarchyTargetKind;
readonly targetId: string;
/** Node events: HierarchyNodeSnapshot. Grant events: subject/target/role snapshot (contract 2 §4.4). */
readonly targetSnapshot: Record<string, unknown>;
/** Present exactly on transfers (CHECK-enforced): source/destination parent { kind, id, slug }. */
readonly transferFrom?: ParentChainEntry;
readonly transferTo?: ParentChainEntry;
readonly correlationId: string;
/** Prior event in the causal chain (e.g. the delete event causing cascaded grant_revoke events). */
readonly causationId?: string;
readonly idempotencyKey: string;
}
export type HierarchyAuditEventRow = typeof hierarchyAuditEvents.$inferSelect;
export type HierarchyOutboxRow = typeof hierarchyOutbox.$inferSelect;
export interface AppendHierarchyEventResult {
readonly event: HierarchyAuditEventRow;
/** True when the idempotency key had already committed an identical event (REQ-AUD-001 duplicate suppression). */
readonly replayed: boolean;
}
type Tx = Pick<Db, 'insert' | 'select'>;
export class HierarchyAuditIdempotencyConflictError extends Error {
constructor(idempotencyKey: string) {
super(
`hierarchy audit idempotency key ${idempotencyKey} already exists with different event content`,
);
this.name = 'HierarchyAuditIdempotencyConflictError';
}
}
export class HierarchyNodeNotFoundError extends Error {
constructor(kind: HierarchyNodeKind, id: string) {
super(`hierarchy node not found: ${kind} ${id}`);
this.name = 'HierarchyNodeNotFoundError';
}
}
/**
* Append one audit event and its outbox record on the caller's transaction.
* A duplicate idempotency key with identical semantic content returns the
* prior event (replayed: true) without inserting anything; a duplicate key
* with different content throws.
*/
export async function appendHierarchyEvent(
tx: Tx,
input: AppendHierarchyEventInput,
): Promise<AppendHierarchyEventResult> {
const inserted = await tx
.insert(hierarchyAuditEvents)
.values({
actorId: input.actorId,
verb: input.verb,
targetKind: input.targetKind,
targetId: input.targetId,
targetSnapshot: input.targetSnapshot,
transferFrom: input.transferFrom ?? null,
transferTo: input.transferTo ?? null,
correlationId: input.correlationId,
causationId: input.causationId ?? null,
idempotencyKey: input.idempotencyKey,
})
.onConflictDoNothing()
.returning();
const event = inserted[0];
if (event) {
await tx.insert(hierarchyOutbox).values({
eventId: event.id,
idempotencyKey: input.idempotencyKey,
correlationId: input.correlationId,
});
return { event, replayed: false };
}
const prior = await tx
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, input.idempotencyKey))
.limit(1);
const existing = prior[0];
if (!existing || !sameEvent(existing, input)) {
throw new HierarchyAuditIdempotencyConflictError(input.idempotencyKey);
}
// Event and outbox committed atomically the first time, so the outbox
// record already exists; a replay inserts nothing.
return { event: existing, replayed: true };
}
/** Key-order-independent serialization: jsonb does not preserve key order. */
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>;
const body = Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(',');
return `{${body}}`;
}
return JSON.stringify(value);
}
function sameEvent(row: HierarchyAuditEventRow, input: AppendHierarchyEventInput): boolean {
return (
row.actorId === input.actorId &&
row.verb === input.verb &&
row.targetKind === input.targetKind &&
row.targetId === input.targetId &&
row.correlationId === input.correlationId &&
(row.causationId ?? null) === (input.causationId ?? null) &&
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot) &&
// Transfer source/destination are semantic content (§5.2): a retry with a
// different destination must conflict, never silently replay.
canonicalJson(row.transferFrom ?? null) === canonicalJson(input.transferFrom ?? null) &&
canonicalJson(row.transferTo ?? null) === canonicalJson(input.transferTo ?? null)
);
}
/**
* Build the immutable snapshot for a node: its row plus the parent chain up
* to the company root, root-first, read on the caller's transaction so the
* snapshot is consistent with the mutation it audits.
*/
export async function buildNodeSnapshot(
tx: Tx,
kind: HierarchyNodeKind,
id: string,
): Promise<HierarchyNodeSnapshot> {
if (kind === 'company') {
const rows = await tx.select().from(companies).where(eq(companies.id, id)).limit(1);
const row = rows[0];
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
return { id: row.id, slug: row.slug, name: row.name, parentChain: [] };
}
if (kind === 'estate') {
const rows = await tx.select().from(estates).where(eq(estates.id, id)).limit(1);
const row = rows[0];
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
const parent = await buildNodeSnapshot(tx, 'company', row.companyId);
return {
id: row.id,
slug: row.slug,
name: row.name,
parentChain: [...parent.parentChain, { kind: 'company', id: parent.id, slug: parent.slug }],
};
}
const rows = await tx.select().from(platformProjects).where(eq(platformProjects.id, id)).limit(1);
const row = rows[0];
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
const parent = await buildNodeSnapshot(tx, 'estate', row.estateId);
return {
id: row.id,
slug: row.slug,
name: row.name,
parentChain: [...parent.parentChain, { kind: 'estate', id: parent.id, slug: parent.slug }],
};
}
@Injectable()
export class HierarchyAuditRepository {
constructor(@Inject(DB) private readonly db: Db) {}
/** Compose an event+outbox append into a caller-owned transaction. */
append(tx: Tx, input: AppendHierarchyEventInput): Promise<AppendHierarchyEventResult> {
return appendHierarchyEvent(tx, input);
}
snapshot(tx: Tx, kind: HierarchyNodeKind, id: string): Promise<HierarchyNodeSnapshot> {
return buildNodeSnapshot(tx, kind, id);
}
/** Per-target ordered event history (REQ-AUD-001 per-target ordering; read-only). */
async eventsForTarget(targetId: string): Promise<HierarchyAuditEventRow[]> {
return this.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.targetId, targetId))
.orderBy(asc(hierarchyAuditEvents.seq));
}
/**
* Claim the oldest pending outbox record (claim-by-CAS: the UPDATE is
* guarded on status so a lost race returns null and the caller retries).
*/
async claimPendingOutbox(): Promise<HierarchyOutboxRow | null> {
const candidates = await this.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.status, 'pending'))
.orderBy(asc(hierarchyOutbox.createdAt))
.limit(1);
const candidate = candidates[0];
if (!candidate) return null;
const claimed = await this.db
.update(hierarchyOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(hierarchyOutbox.id, candidate.id), eq(hierarchyOutbox.status, 'pending')))
.returning();
return claimed[0] ?? null;
}
async completeOutbox(id: string): Promise<void> {
const now = new Date();
await this.db
.update(hierarchyOutbox)
.set({ status: 'delivered', deliveredAt: now, updatedAt: now })
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
}
/** Return a claimed record to pending (delivery failed; it stays replayable). */
async releaseOutbox(id: string): Promise<void> {
await this.db
.update(hierarchyOutbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
}
}
@@ -1,965 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import {
companies,
createPgliteDb,
eq,
estates,
hierarchyAuditEvents,
hierarchyGrants,
hierarchyOutbox,
runPgliteMigrations,
teams,
users,
workspaces,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { appendHierarchyEvent } from './hierarchy-audit.repository.js';
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
import { HierarchyRepository, type HierarchyResult } from './hierarchy.repository.js';
/**
* Command-level witnesses for the hierarchy command family (M4-1b-ii):
* contract 1 §6.4 (per-mutation-class commit + rollback), §6.5
* (authorization outcomes), §6.7 (no existence oracle), §6.9 (visibility),
* and contract 2 §3 grant-evaluation semantics (deny-by-default,
* ancestor-chain inheritance, max-role, live revocation, suspended team
* subjects). Schema-level constraints are witnessed in
* packages/db/src/hierarchy-schema.witness.test.ts; the audit machinery's
* own atomicity in hierarchy-audit.integration.test.ts.
*
* The rollback legs pre-seed an audit event under the command's idempotency
* key with different content: the command's append then throws inside the
* command transaction, so the whole mutation must roll back — the command
* returns `conflict` and leaves no state change, no second event, and no
* second outbox record.
*/
describe('hierarchy commands integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let repo: HierarchyRepository;
let evaluation: HierarchyGrantEvaluationService;
const OWNER = 'hier-cmd-owner';
const ADMIN = 'hier-cmd-admin';
const STRANGER = 'hier-cmd-stranger';
const SUBJECT = 'hier-cmd-subject';
/** Base fixture: OWNER's company (created through the command surface). */
let companyId: string;
const slug = (prefix: string): string => `${prefix}-${randomUUID().slice(0, 8)}`;
function expectOk<T>(result: HierarchyResult<T>): { ok: true } & T {
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
return result;
}
const eventsForKey = (key: string) =>
handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
const outboxForKey = (key: string) =>
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key));
/** Occupy `key` with unrelated event content so a command reusing it must abort. */
const seedConflictingKey = async (key: string): Promise<void> => {
await handle.db.transaction(async (tx) =>
appendHierarchyEvent(tx, {
actorId: 'seed-actor',
verb: 'create',
targetKind: 'company',
targetId: randomUUID(),
targetSnapshot: { seeded: true },
correlationId: 'seed-correlation',
idempotencyKey: key,
}),
);
};
/**
* §6.4 rollback leg: the command must return `conflict` and leave exactly
* the seeded event/outbox pair under the key — nothing it wrote survives.
*/
const expectRolledBack = async <T>(
key: string,
command: () => Promise<HierarchyResult<T>>,
assertUnchanged: () => Promise<void>,
): Promise<void> => {
await seedConflictingKey(key);
const result = await command();
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe('conflict');
expect(await eventsForKey(key)).toHaveLength(1);
expect(await outboxForKey(key)).toHaveLength(1);
await assertUnchanged();
};
beforeAll(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-commands-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
moduleRef = await Test.createTestingModule({
providers: [
HierarchyRepository,
HierarchyGrantEvaluationService,
{ provide: DB, useValue: handle.db },
],
}).compile();
repo = moduleRef.get(HierarchyRepository);
evaluation = moduleRef.get(HierarchyGrantEvaluationService);
await handle.db.insert(users).values([
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
{ id: SUBJECT, name: 'Subject', email: `${SUBJECT}@example.com` },
]);
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Base Co', slug: slug('base') }),
);
companyId = created.company.id;
});
afterAll(async (): Promise<void> => {
await moduleRef.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
// ── §6.4 commit legs ───────────────────────────────────────────────────────
it('createCompany commits company, owner grant, causation-linked events, and outbox atomically', async () => {
const key = `key-${randomUUID()}`;
const result = expectOk(
await repo.createCompany({
actorId: OWNER,
name: 'Atomic Co',
slug: slug('atomic'),
idempotencyKey: key,
}),
);
expect(result.company.visibility).toBe('private');
expect(result.grant.role).toBe('hierarchy:owner');
expect(result.grant.userId).toBe(OWNER);
expect(result.grant.grantedBy).toBe(OWNER);
const [createEvents, grantEvents] = await Promise.all([
eventsForKey(key),
eventsForKey(`${key}:grant`),
]);
expect(createEvents).toHaveLength(1);
expect(createEvents[0]).toMatchObject({ verb: 'create', targetId: result.company.id });
expect(grantEvents).toHaveLength(1);
expect(grantEvents[0]).toMatchObject({ verb: 'grant_create', targetId: result.grant.id });
// The grant event is caused by the create event, same correlation (§4.3).
expect(grantEvents[0]!.causationId).toBe(createEvents[0]!.id);
expect(grantEvents[0]!.correlationId).toBe(createEvents[0]!.correlationId);
expect(await outboxForKey(key)).toHaveLength(1);
expect(await outboxForKey(`${key}:grant`)).toHaveLength(1);
const rows = await handle.db
.select()
.from(companies)
.where(eq(companies.id, result.company.id));
expect(rows).toHaveLength(1);
});
it('deleteCompany commits the delete with one audited grant_revoke per cascaded grant', async () => {
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Mortal Co', slug: slug('mortal') }),
);
const extraGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: created.company.id,
role: 'viewer',
}),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.deleteCompany({
actorId: OWNER,
companyId: created.company.id,
idempotencyKey: key,
}),
);
const deleteEvents = await eventsForKey(key);
expect(deleteEvents).toHaveLength(1);
expect(deleteEvents[0]).toMatchObject({ verb: 'delete', targetId: created.company.id });
for (const grantId of [created.grant.id, extraGrant.grant.id]) {
const revokeEvents = await eventsForKey(`${key}:revoke:${grantId}`);
expect(revokeEvents).toHaveLength(1);
expect(revokeEvents[0]).toMatchObject({ verb: 'grant_revoke', targetId: grantId });
expect(revokeEvents[0]!.causationId).toBe(deleteEvents[0]!.id);
}
expect(
await handle.db.select().from(companies).where(eq(companies.id, created.company.id)),
).toHaveLength(0);
});
// ── §6.4 rollback legs (one per mutation class) ────────────────────────────
it('renameCompany commits the rename with an audited event carrying previousName', async () => {
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Old Name Co', slug: slug('rename') }),
);
const key = `key-${randomUUID()}`;
const renamed = expectOk(
await repo.renameCompany({
actorId: OWNER,
companyId: created.company.id,
name: 'New Name Co',
idempotencyKey: key,
}),
);
expect(renamed.company.name).toBe('New Name Co');
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'rename', targetId: created.company.id });
// §6.4: the audited rename carries the old and new names.
expect(events[0]!.targetSnapshot).toMatchObject({
name: 'New Name Co',
previousName: 'Old Name Co',
});
expect(await outboxForKey(key)).toHaveLength(1);
const rows = await handle.db
.select()
.from(companies)
.where(eq(companies.id, created.company.id));
expect(rows[0]!.name).toBe('New Name Co');
});
it('revokeGrant commits the row deletion with one audited grant_revoke event', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const key = `key-${randomUUID()}`;
const revoked = expectOk(
await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: key }),
);
expect(revoked.revokedId).toBe(grant.grant.id);
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'grant_revoke', targetId: grant.grant.id });
expect(await outboxForKey(key)).toHaveLength(1);
// §6 revocation = row deletion: the grant row is gone.
const rows = await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id));
expect(rows).toHaveLength(0);
});
it('rolls back a create: no estate row survives the aborted transaction', async () => {
const estateSlug = slug('rb-create');
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.createEstate({
actorId: OWNER,
companyId,
name: 'Doomed Estate',
slug: estateSlug,
idempotencyKey: key,
}),
async () => {
expect(
await handle.db.select().from(estates).where(eq(estates.slug, estateSlug)),
).toHaveLength(0);
},
);
});
it('rolls back a rename: the company keeps its name', async () => {
const before = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() => repo.renameCompany({ actorId: OWNER, companyId, name: 'Never', idempotencyKey: key }),
async () => {
const after = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
expect(after.name).toBe(before.name);
},
);
});
it('rolls back a visibility change: the company stays private', async () => {
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.changeCompanyVisibility({
actorId: ADMIN,
companyId,
visibility: 'directory',
idempotencyKey: key,
}),
async () => {
const after = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
expect(after.visibility).toBe('private');
},
);
});
it('rolls back a transfer: the estate keeps its parent', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-T', slug: slug('rb-t') }),
);
const other = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'RB Dest', slug: slug('rb-dest') }),
);
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: other.company.id,
idempotencyKey: key,
}),
async () => {
const after = (
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
)[0]!;
expect(after.companyId).toBe(companyId);
},
);
});
it('rolls back a delete: the estate row survives', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-D', slug: slug('rb-d') }),
);
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() => repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id, idempotencyKey: key }),
async () => {
expect(
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id)),
).toHaveLength(1);
},
);
});
it('rolls back a grant create: no grant row survives', async () => {
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.createGrant({
actorId: OWNER,
userId: STRANGER,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
idempotencyKey: key,
}),
async () => {
const rows = await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.userId, STRANGER));
expect(rows.filter((r) => r.companyId === companyId)).toHaveLength(0);
},
);
});
it('rolls back a grant change and a grant revoke: the grant keeps its role and its row', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const changeKey = `key-${randomUUID()}`;
await expectRolledBack(
changeKey,
() =>
repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'member',
idempotencyKey: changeKey,
}),
async () => {
const row = (
await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id))
)[0]!;
expect(row.role).toBe('viewer');
},
);
const revokeKey = `key-${randomUUID()}`;
await expectRolledBack(
revokeKey,
() =>
repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: revokeKey }),
async () => {
expect(
await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id)),
).toHaveLength(1);
},
);
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
});
it('replays a completed command idempotently through the audit machinery', async () => {
const key = `key-${randomUUID()}`;
const input = { actorId: OWNER, companyId, name: 'Replayed Estate', slug: slug('replay') };
const first = expectOk(await repo.createEstate({ ...input, idempotencyKey: key }));
// The retry's insert no-ops on the slug conflict — the command surfaces
// `conflict`, and crucially appends no second event under the key.
const retry = await repo.createEstate({ ...input, idempotencyKey: key });
expect(retry.ok).toBe(false);
expect(await eventsForKey(key)).toHaveLength(1);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: first.estate.id }));
});
// ── §6.5 authorization ─────────────────────────────────────────────────────
it('deny-by-default: a user with no grant cannot mutate and sees not_found (§3.1)', async () => {
expect(await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' })).toEqual({
ok: false,
error: 'not_found',
});
expect(
await repo.createEstate({ actorId: STRANGER, companyId, name: 'x', slug: slug('deny') }),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.deleteCompany({ actorId: STRANGER, companyId })).toEqual({
ok: false,
error: 'not_found',
});
});
it('grant management requires effective owner: member and viewer are refused (§4.1)', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'member',
}),
);
expect(
await repo.createGrant({
actorId: SUBJECT,
userId: STRANGER,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.revokeGrant({ actorId: SUBJECT, grantId: grant.grant.id })).toEqual({
ok: false,
error: 'not_found',
});
// Member also cannot create children (owner-only, §4.1/§4.3).
expect(
await repo.createEstate({ actorId: SUBJECT, companyId, name: 'x', slug: slug('member') }),
).toEqual({ ok: false, error: 'not_found' });
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
});
it('platform admin confers no tenant content access (§1.1): ungrated admin is a stranger', async () => {
expect(await repo.renameCompany({ actorId: ADMIN, companyId, name: 'x' })).toEqual({
ok: false,
error: 'not_found',
});
expect(
await repo.createGrant({
actorId: ADMIN,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.listGrantedCompanies(ADMIN)).toEqual([]);
expect(await evaluation.effectiveRole(ADMIN, 'company', companyId)).toBeNull();
});
it('visibility change is platform-admin-only (§5.5): the owner is forbidden, the admin succeeds', async () => {
const owned = await repo.changeCompanyVisibility({
actorId: OWNER,
companyId,
visibility: 'directory',
});
expect(owned).toEqual({
ok: false,
error: 'forbidden',
message: 'visibility change is platform-admin-only',
});
const changed = expectOk(
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'directory' }),
);
expect(changed.company.visibility).toBe('directory');
// Restore for later witnesses.
expectOk(
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'private' }),
);
});
// ── §6.9 visibility ────────────────────────────────────────────────────────
it('directory lists exactly directory-class companies with closed fields (§2.8)', async () => {
const listed = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Listed Co', slug: slug('listed') }),
);
const unlisted = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Unlisted Co', slug: slug('unlisted') }),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.changeCompanyVisibility({
actorId: ADMIN,
companyId: listed.company.id,
visibility: 'directory',
idempotencyKey: key,
}),
);
const directory = await repo.listDirectory();
const ids = directory.map((entry) => entry.id);
expect(ids).toContain(listed.company.id);
expect(ids).not.toContain(unlisted.company.id);
expect(ids).not.toContain(companyId);
// Closed-field: existence, name, slug — nothing else (no visibility, no
// timestamps, no grant or membership data).
for (const entry of directory) {
expect(Object.keys(entry).sort()).toEqual(['id', 'name', 'slug']);
}
// §5.5: the audited event carries old and new values.
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'visibility_change', targetId: listed.company.id });
expect(events[0]!.targetSnapshot).toMatchObject({
previousVisibility: 'private',
visibility: 'directory',
});
});
it('directory disclosure confers no authority: a listed company still refuses non-granted callers (§6.9)', async () => {
const listed = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Exposed Co', slug: slug('exposed') }),
);
expectOk(
await repo.changeCompanyVisibility({
actorId: ADMIN,
companyId: listed.company.id,
visibility: 'directory',
}),
);
// The company is directory-listed for the whole probe window...
expect((await repo.listDirectory()).map((entry) => entry.id)).toContain(listed.company.id);
// ...but the non-granted reader's granted-read surface still excludes it:
// directory disclosure adds existence/name/slug only, never content access.
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
// A stranger mutation of the listed company is refused exactly like a
// missing node — the §6.7 carve-out covers the listing, not commands.
const realProbe = await repo.renameCompany({
actorId: STRANGER,
companyId: listed.company.id,
name: 'x',
});
const missingProbe = await repo.renameCompany({
actorId: STRANGER,
companyId: randomUUID(),
name: 'x',
});
expect(realProbe).toEqual(missingProbe);
expect(await repo.deleteCompany({ actorId: STRANGER, companyId: listed.company.id })).toEqual({
ok: false,
error: 'not_found',
});
});
it('granted companies are the reader control: owner sees them, a stranger sees nothing (§2.8)', async () => {
const ownerCompanies = await repo.listGrantedCompanies(OWNER);
expect(ownerCompanies.map((c) => c.id)).toContain(companyId);
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
});
// ── §6.7 no existence oracle ───────────────────────────────────────────────
it('an unauthorized probe of a real node is indistinguishable from a missing node', async () => {
const realCompany = await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' });
const missingCompany = await repo.renameCompany({
actorId: STRANGER,
companyId: randomUUID(),
name: 'x',
});
expect(realCompany).toEqual(missingCompany);
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId,
name: 'Oracle E',
slug: slug('oracle'),
}),
);
const realEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: estate.estate.id });
const missingEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: randomUUID() });
expect(realEstate).toEqual(missingEstate);
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'estate',
targetId: estate.estate.id,
role: 'viewer',
}),
);
const realGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: grant.grant.id });
const missingGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: randomUUID() });
expect(realGrant).toEqual(missingGrant);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
});
// ── contract 2 §3 grant evaluation ─────────────────────────────────────────
it('a company grant confers its role down the whole chain, workspace included (§3.2)', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'Chain E', slug: slug('chain') }),
);
const project = expectOk(
await repo.createPlatformProject({
actorId: OWNER,
estateId: estate.estate.id,
name: 'Chain P',
slug: slug('chain-p'),
}),
);
// Workspaces are evaluable but not hierarchy commands; seed one directly.
const workspaceId = randomUUID();
await handle.db.insert(workspaces).values({
id: workspaceId,
name: 'Chain W',
slug: slug('chain-w'),
platformProjectId: project.platformProject.id,
});
for (const [kind, id] of [
['company', companyId],
['estate', estate.estate.id],
['platform_project', project.platformProject.id],
['workspace', workspaceId],
] as const) {
expect(await evaluation.effectiveRole(OWNER, kind, id)).toBe('owner');
expect(await evaluation.effectiveRole(STRANGER, kind, id)).toBeNull();
}
// Max-role (§3.3): viewer on the company + owner on the estate → owner at
// and below the estate, viewer at the company.
const viewerGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const ownerGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'estate',
targetId: estate.estate.id,
role: 'owner',
}),
);
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBe('viewer');
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('owner');
expect(await evaluation.effectiveRole(SUBJECT, 'workspace', workspaceId)).toBe('owner');
// Revocation is row deletion and denies the very next evaluation (§6).
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: ownerGrant.grant.id }));
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('viewer');
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: viewerGrant.grant.id }));
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
await handle.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
expectOk(
await repo.deletePlatformProject({
actorId: OWNER,
platformProjectId: project.platformProject.id,
}),
);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
});
it('team grant subjects are suspended: a team row confers nothing and cannot be changed (§1.4)', async () => {
const teamId = randomUUID();
await handle.db.insert(teams).values({
id: teamId,
name: slug('team'),
slug: slug('team'),
ownerId: SUBJECT,
managerId: SUBJECT,
});
// Out-of-band team row (the command surface cannot create one).
const inserted = await handle.db
.insert(hierarchyGrants)
.values({ teamId, companyId, role: 'owner', grantedBy: OWNER })
.returning();
const teamGrantId = inserted[0]!.id;
// The team's own owner gains no effective role from it.
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
// changeGrant refuses the row.
expect(
await repo.changeGrant({ actorId: OWNER, grantId: teamGrantId, role: 'viewer' }),
).toEqual({
ok: false,
error: 'conflict',
message: 'team grant subjects are suspended',
});
await handle.db.delete(hierarchyGrants).where(eq(hierarchyGrants.id, teamGrantId));
await handle.db.delete(teams).where(eq(teams.id, teamId));
});
// ── command conflict semantics ─────────────────────────────────────────────
it('transfer needs owner on both parents in its own transaction, and refuses no-op and colliding transfers (§5)', async () => {
const source = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Src Co', slug: slug('src') }),
);
const destination = expectOk(
await repo.createCompany({ actorId: SUBJECT, name: 'Dst Co', slug: slug('dst') }),
);
const estateSlug = slug('mv');
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: source.company.id,
name: 'Mv E',
slug: estateSlug,
}),
);
// OWNER owns the source but not the destination → not_found (§6.7-safe).
expect(
await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: destination.company.id,
}),
).toEqual({ ok: false, error: 'not_found' });
// Same-parent transfer is refused.
const samePlace = await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: source.company.id,
});
expect(samePlace.ok).toBe(false);
if (!samePlace.ok) expect(samePlace.error).toBe('conflict');
// Grant OWNER the destination; a slug collision there is refused.
expectOk(
await repo.createGrant({
actorId: SUBJECT,
userId: OWNER,
targetKind: 'company',
targetId: destination.company.id,
role: 'owner',
}),
);
expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: destination.company.id,
name: 'Collide',
slug: estateSlug,
}),
);
const collision = await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: destination.company.id,
});
expect(collision.ok).toBe(false);
if (!collision.ok) expect(collision.error).toBe('conflict');
});
it('a successful transfer records transfer_from and transfer_to (§6.4 three-leg witness)', async () => {
const from = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'From Co', slug: slug('from') }),
);
const to = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'To Co', slug: slug('to') }),
);
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: from.company.id,
name: 'Moved E',
slug: slug('moved'),
}),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: to.company.id,
idempotencyKey: key,
}),
);
const moved = (
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
)[0]!;
expect(moved.companyId).toBe(to.company.id);
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'transfer', targetId: estate.estate.id });
expect(events[0]!.transferFrom).toMatchObject({ kind: 'company', id: from.company.id });
expect(events[0]!.transferTo).toMatchObject({ kind: 'company', id: to.company.id });
// The post-transfer snapshot's parent chain names the destination.
expect(events[0]!.targetSnapshot).toMatchObject({
parentChain: [{ kind: 'company', id: to.company.id, slug: to.company.slug }],
});
});
it('refuses duplicate slugs, deletes with children, and degenerate grant commands as conflicts', async () => {
const co = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Conflict Co', slug: slug('conf') }),
);
const dupSlug = await repo.createCompany({ actorId: OWNER, name: 'x', slug: co.company.slug });
expect(dupSlug.ok).toBe(false);
if (!dupSlug.ok) expect(dupSlug.error).toBe('conflict');
expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: co.company.id,
name: 'Child',
slug: slug('child'),
}),
);
const withChildren = await repo.deleteCompany({ actorId: OWNER, companyId: co.company.id });
expect(withChildren.ok).toBe(false);
if (!withChildren.ok) expect(withChildren.error).toBe('conflict');
// Grant to a nonexistent subject is refused (the caller already holds
// owner, so the refusal discloses nothing new).
const ghost = await repo.createGrant({
actorId: OWNER,
userId: `missing-${randomUUID()}`,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
});
expect(ghost.ok).toBe(false);
if (!ghost.ok) expect(ghost.error).toBe('conflict');
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
}),
);
const duplicate = await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
});
expect(duplicate.ok).toBe(false);
if (!duplicate.ok) expect(duplicate.error).toBe('conflict');
const sameRole = await repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'viewer',
});
expect(sameRole.ok).toBe(false);
if (!sameRole.ok) expect(sameRole.error).toBe('conflict');
// A second grant with another role exists → changing the first onto that
// role would collide with the unique constraint; refused ahead of it.
const second = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'member',
}),
);
const collide = await repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'member',
});
expect(collide.ok).toBe(false);
if (!collide.ok) expect(collide.error).toBe('conflict');
// A clean change succeeds and records the previous role, namespaced (§4.5).
const changeKey = `key-${randomUUID()}`;
const changed = expectOk(
await repo.changeGrant({
actorId: OWNER,
grantId: second.grant.id,
role: 'owner',
idempotencyKey: changeKey,
}),
);
expect(changed.grant.role).toBe('hierarchy:owner');
const events = await eventsForKey(changeKey);
expect(events).toHaveLength(1);
expect(events[0]!.targetSnapshot).toMatchObject({
role: 'hierarchy:owner',
previousRole: 'hierarchy:member',
});
});
});
@@ -1,242 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
companies,
eq,
estates,
hierarchyGrants,
inArray,
or,
platformProjects,
workspaces,
and,
type Db,
HIERARCHY_GRANT_ROLES,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
/**
* Hierarchy grant evaluation (contract 2 §3).
*
* Deny-by-default (§3.1): a user's effective role on a node is null unless a
* grant row explicitly confers one. Grants apply down the chain only (§3.2):
* the effective role on a node is the maximum role over grants targeting the
* node itself or any of its ancestors, maximum per the total order
* viewer ⊂ member ⊂ owner (§2). Evaluation is live and per-decision — no
* caching — so revocation (row deletion, §6) denies the next decision
* inherently. A missing node evaluates to null, indistinguishable from
* no-grant, which keeps unauthorized probes oracle-safe (contract 1 §6.7).
*
* Team grant subjects are SUSPENDED (§1.4): the command surface refuses to
* create them and this evaluator considers user-subject grants only, so a
* team row could not confer access even if one existed.
*
* Read-only module: it selects from the class tables but never writes them,
* so it does not appear on the writer-coverage allowlist.
*/
export type HierarchyGrantRole = (typeof HIERARCHY_GRANT_ROLES)[number];
/** Node kinds a grant may target (§3.2; workspace is evaluable, not grantable). */
export type GrantTargetKind = 'company' | 'estate' | 'platform_project';
/** Node kinds an authorization decision may be evaluated at (§3.2: down to workspace). */
export type EvaluableNodeKind = GrantTargetKind | 'workspace';
type Tx = Pick<Db, 'select'>;
/** Ancestor chain of a node, self included at its own level; ids only. */
export interface AncestorChain {
readonly companyId: string;
readonly estateId?: string;
readonly platformProjectId?: string;
readonly workspaceId?: string;
}
export function roleStrength(role: HierarchyGrantRole): number {
return HIERARCHY_GRANT_ROLES.indexOf(role);
}
export function roleAtLeast(
role: HierarchyGrantRole | null,
required: HierarchyGrantRole,
): boolean {
return role !== null && roleStrength(role) >= roleStrength(required);
}
/**
* Serialized role strings are namespaced (§4.5): audit events and API
* responses carry `hierarchy:owner`, never a bare `owner`.
*/
export function namespacedHierarchyRole(role: HierarchyGrantRole): string {
return `hierarchy:${role}`;
}
/**
* Resolve a node's ancestor chain (self included). Returns null when the
* node does not exist — callers treat that exactly like no-grant (§3.1,
* oracle-safe).
*/
export async function resolveAncestorChain(
tx: Tx,
kind: EvaluableNodeKind,
id: string,
): Promise<AncestorChain | null> {
if (kind === 'company') {
const rows = await tx
.select({ id: companies.id })
.from(companies)
.where(eq(companies.id, id))
.limit(1);
const row = rows[0];
return row ? { companyId: row.id } : null;
}
if (kind === 'estate') {
const rows = await tx
.select({ id: estates.id, companyId: estates.companyId })
.from(estates)
.where(eq(estates.id, id))
.limit(1);
const row = rows[0];
return row ? { companyId: row.companyId, estateId: row.id } : null;
}
if (kind === 'platform_project') {
const rows = await tx
.select({
id: platformProjects.id,
estateId: platformProjects.estateId,
companyId: estates.companyId,
})
.from(platformProjects)
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
.where(eq(platformProjects.id, id))
.limit(1);
const row = rows[0];
return row
? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.id }
: null;
}
const rows = await tx
.select({
id: workspaces.id,
platformProjectId: workspaces.platformProjectId,
estateId: platformProjects.estateId,
companyId: estates.companyId,
})
.from(workspaces)
.innerJoin(platformProjects, eq(platformProjects.id, workspaces.platformProjectId))
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
.where(eq(workspaces.id, id))
.limit(1);
const row = rows[0];
return row
? {
companyId: row.companyId,
estateId: row.estateId,
platformProjectId: row.platformProjectId,
workspaceId: row.id,
}
: null;
}
function maxRole(roles: readonly string[]): HierarchyGrantRole | null {
let best: HierarchyGrantRole | null = null;
for (const candidate of roles) {
// Fail-closed: a value outside the vocabulary confers nothing.
if (!(HIERARCHY_GRANT_ROLES as readonly string[]).includes(candidate)) continue;
const role = candidate as HierarchyGrantRole;
if (best === null || roleStrength(role) > roleStrength(best)) best = role;
}
return best;
}
/**
* Effective role of a user on a node: maximum over the user's grants whose
* target is the node or any ancestor (§3.2); null = deny (§3.1). Missing
* node → null.
*/
export async function evaluateEffectiveRole(
tx: Tx,
userId: string,
kind: EvaluableNodeKind,
id: string,
): Promise<HierarchyGrantRole | null> {
const chain = await resolveAncestorChain(tx, kind, id);
if (!chain) return null;
const targetConditions = [eq(hierarchyGrants.companyId, chain.companyId)];
if (chain.estateId) targetConditions.push(eq(hierarchyGrants.estateId, chain.estateId));
if (chain.platformProjectId) {
targetConditions.push(eq(hierarchyGrants.platformProjectId, chain.platformProjectId));
}
const rows = await tx
.select({ role: hierarchyGrants.role })
.from(hierarchyGrants)
.where(and(eq(hierarchyGrants.userId, userId), or(...targetConditions)));
return maxRole(rows.map((r) => r.role));
}
/**
* All companies on which the user holds any effective role, i.e. companies
* with a grant on the company itself or on any descendant (contract 1 §2.8:
* a grant anywhere in the subtree discloses the company's chain upward).
*/
export async function grantedCompanyIds(tx: Tx, userId: string): Promise<string[]> {
const grants = await tx
.select({
companyId: hierarchyGrants.companyId,
estateId: hierarchyGrants.estateId,
platformProjectId: hierarchyGrants.platformProjectId,
})
.from(hierarchyGrants)
.where(eq(hierarchyGrants.userId, userId));
const companyIds = new Set<string>();
const estateIds = new Set<string>();
const platformProjectIds = new Set<string>();
for (const grant of grants) {
if (grant.companyId) companyIds.add(grant.companyId);
else if (grant.estateId) estateIds.add(grant.estateId);
else if (grant.platformProjectId) platformProjectIds.add(grant.platformProjectId);
}
if (platformProjectIds.size > 0) {
const rows = await tx
.select({ estateId: platformProjects.estateId })
.from(platformProjects)
.where(inArray(platformProjects.id, [...platformProjectIds]));
for (const row of rows) estateIds.add(row.estateId);
}
if (estateIds.size > 0) {
const rows = await tx
.select({ companyId: estates.companyId })
.from(estates)
.where(inArray(estates.id, [...estateIds]));
for (const row of rows) companyIds.add(row.companyId);
}
return [...companyIds];
}
@Injectable()
export class HierarchyGrantEvaluationService {
constructor(@Inject(DB) private readonly db: Db) {}
/** Live per-decision evaluation; pass a tx to evaluate inside a command's transaction. */
effectiveRole(
userId: string,
kind: EvaluableNodeKind,
id: string,
tx?: Tx,
): Promise<HierarchyGrantRole | null> {
return evaluateEffectiveRole(tx ?? this.db, userId, kind, id);
}
async hasRole(
userId: string,
kind: EvaluableNodeKind,
id: string,
required: HierarchyGrantRole,
tx?: Tx,
): Promise<boolean> {
return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required);
}
}
@@ -1,297 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import {
ChangeCompanyVisibilityDto,
ChangeGrantDto,
CreateCompanyDto,
CreateEstateDto,
CreateGrantDto,
CreatePlatformProjectDto,
DeleteNodeDto,
RenameNodeDto,
TransferEstateDto,
TransferPlatformProjectDto,
} from './hierarchy.dto.js';
import { HierarchyRepository } from './hierarchy.repository.js';
import { HierarchyService } from './hierarchy.service.js';
/**
* The hierarchy command family (contract 1 §5, §6.3). This controller is the
* closed HTTP surface over the hierarchy class tables: the route-inventory
* witness asserts these routes and no others exist. Delete commands take an
* optional body (idempotency key) via POST-style DTOs; every mutation is
* audited on its own transaction by the repository.
*/
@Controller('api/hierarchy')
@UseGuards(AuthGuard)
export class HierarchyController {
constructor(
private readonly repository: HierarchyRepository,
private readonly service: HierarchyService,
) {}
// ── companies ────────────────────────────────────────────────────────────
@Post('companies')
async createCompany(@CurrentUser() user: { id: string }, @Body() dto: CreateCompanyDto) {
return this.service.unwrap(
await this.repository.createCompany({
actorId: user.id,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
/** Companies the caller holds a grant on (directly or via a descendant). */
@Get('companies')
listGrantedCompanies(@CurrentUser() user: { id: string }) {
return this.repository.listGrantedCompanies(user.id);
}
/** Directory-class companies, closed-field (§2.8). */
@Get('companies/directory')
listDirectory() {
return this.repository.listDirectory();
}
@Post('companies/:id/rename')
@HttpCode(200)
async renameCompany(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renameCompany({
actorId: user.id,
companyId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('companies/:id/visibility')
@HttpCode(200)
async changeCompanyVisibility(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeCompanyVisibilityDto,
) {
return this.service.unwrap(
await this.repository.changeCompanyVisibility({
actorId: user.id,
companyId: id,
visibility: dto.visibility,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('companies/:id')
async deleteCompany(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deleteCompany({
actorId: user.id,
companyId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── estates ──────────────────────────────────────────────────────────────
@Post('estates')
async createEstate(@CurrentUser() user: { id: string }, @Body() dto: CreateEstateDto) {
return this.service.unwrap(
await this.repository.createEstate({
actorId: user.id,
companyId: dto.companyId,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('estates/:id/rename')
@HttpCode(200)
async renameEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renameEstate({
actorId: user.id,
estateId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('estates/:id/transfer')
@HttpCode(200)
async transferEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TransferEstateDto,
) {
return this.service.unwrap(
await this.repository.transferEstate({
actorId: user.id,
estateId: id,
destinationCompanyId: dto.destinationCompanyId,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('estates/:id')
async deleteEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deleteEstate({
actorId: user.id,
estateId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── platform projects ────────────────────────────────────────────────────
@Post('platform-projects')
async createPlatformProject(
@CurrentUser() user: { id: string },
@Body() dto: CreatePlatformProjectDto,
) {
return this.service.unwrap(
await this.repository.createPlatformProject({
actorId: user.id,
estateId: dto.estateId,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('platform-projects/:id/rename')
@HttpCode(200)
async renamePlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renamePlatformProject({
actorId: user.id,
platformProjectId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('platform-projects/:id/transfer')
@HttpCode(200)
async transferPlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TransferPlatformProjectDto,
) {
return this.service.unwrap(
await this.repository.transferPlatformProject({
actorId: user.id,
platformProjectId: id,
destinationEstateId: dto.destinationEstateId,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('platform-projects/:id')
async deletePlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deletePlatformProject({
actorId: user.id,
platformProjectId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── grants ───────────────────────────────────────────────────────────────
@Post('grants')
async createGrant(@CurrentUser() user: { id: string }, @Body() dto: CreateGrantDto) {
return this.service.unwrap(
await this.repository.createGrant({
actorId: user.id,
userId: dto.userId,
targetKind: dto.targetKind,
targetId: dto.targetId,
role: dto.role,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('grants/:id/change')
@HttpCode(200)
async changeGrant(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeGrantDto,
) {
return this.service.unwrap(
await this.repository.changeGrant({
actorId: user.id,
grantId: id,
role: dto.role,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('grants/:id')
async revokeGrant(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.revokeGrant({
actorId: user.id,
grantId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
}
-169
View File
@@ -1,169 +0,0 @@
import { COMPANY_VISIBILITY, HIERARCHY_GRANT_ROLES } from '@mosaicstack/db';
import { IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
/**
* Hierarchy command DTOs (contract 1 §5, contract 2 §4/§7).
*
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so a
* payload field absent from these classes is a 400. That closure is itself
* contract surface:
* - CreateCompanyDto declares NO visibility field — creation is always
* private (contract 1 §5.5); a visibility argument is refused by the pipe.
* - CreateGrantDto declares NO teamId field — team grant subjects are
* suspended (contract 2 §1.4/§7.5); a team subject is refused by the pipe.
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
* assertion proves the pipe sees the decorators.
*/
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
const SLUG_MESSAGE = 'slug must be lowercase alphanumeric with interior hyphens';
export class CreateCompanyDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
/** Client-supplied idempotency key (REQ-AUD-001 replay); server-generated when absent. */
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class RenameNodeDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class ChangeCompanyVisibilityDto {
@IsIn(COMPANY_VISIBILITY)
visibility!: (typeof COMPANY_VISIBILITY)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class DeleteNodeDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreateEstateDto {
@IsUUID()
companyId!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreatePlatformProjectDto {
@IsUUID()
estateId!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class TransferEstateDto {
@IsUUID()
destinationCompanyId!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class TransferPlatformProjectDto {
@IsUUID()
destinationEstateId!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreateGrantDto {
/** Subject user (better-auth text id). No teamId field — see module doc. */
@IsString()
@MinLength(1)
@MaxLength(255)
userId!: string;
@IsIn(['company', 'estate', 'platform_project'])
targetKind!: 'company' | 'estate' | 'platform_project';
@IsUUID()
targetId!: string;
/** Bare vocabulary on requests; responses and audit events are namespaced (§4.5). */
@IsIn(HIERARCHY_GRANT_ROLES)
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class ChangeGrantDto {
@IsIn(HIERARCHY_GRANT_ROLES)
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
@@ -1,28 +0,0 @@
import { Module } from '@nestjs/common';
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
import { HierarchyController } from './hierarchy.controller.js';
import { HierarchyRepository } from './hierarchy.repository.js';
import { HierarchyService } from './hierarchy.service.js';
/**
* Hierarchy (tenancy/authorization structure) feature module.
*
* M4-1b-i shipped the audit event + outbox machinery (contract 1 §5.2);
* M4-1b-ii adds the command family — the closed route surface asserted by
* the route-inventory witness — plus grant evaluation (contract 2 §3).
* HierarchyRepository is the sole class-table writer (writer-coverage
* allowlist); every mutation runs authorize → mutate → audit in one
* transaction.
*/
@Module({
controllers: [HierarchyController],
providers: [
HierarchyAuditRepository,
HierarchyGrantEvaluationService,
HierarchyRepository,
HierarchyService,
],
exports: [HierarchyAuditRepository, HierarchyGrantEvaluationService],
})
export class HierarchyModule {}
@@ -1,935 +0,0 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
companies,
eq,
estates,
hierarchyGrants,
inArray,
platformProjects,
users,
workspaces,
type Db,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import {
appendHierarchyEvent,
buildNodeSnapshot,
HierarchyAuditIdempotencyConflictError,
} from './hierarchy-audit.repository.js';
import {
evaluateEffectiveRole,
grantedCompanyIds,
namespacedHierarchyRole,
roleAtLeast,
type GrantTargetKind,
type HierarchyGrantRole,
} from './hierarchy-grant-evaluation.js';
/**
* Hierarchy command repository (contract 1 §5, contract 2 §4).
*
* The ONLY writer of the hierarchy class tables (companies, estates,
* platform_projects, hierarchy_grants) — it is the writer-coverage
* allowlist's sole entry. Every command runs one transaction that
* authorizes (live grant evaluation inside the same transaction), mutates,
* and appends the semantic audit event + outbox record via the M4-1b-i
* machinery, so state, event, and outbox commit or roll back together
* (REQ-AUD-001).
*
* Authorization failure and target-not-found both return `not_found`
* (contract 1 §6.7: no existence oracle — an unauthorized caller learns
* nothing a stranger would not). `forbidden` appears only where the caller
* already knows the surface exists independent of any node: the admin-only
* visibility change (§5.5). Serialized role strings are namespaced (§4.5).
*/
export type HierarchyCommandFailure =
| { readonly ok: false; readonly error: 'not_found' }
| { readonly ok: false; readonly error: 'forbidden'; readonly message: string }
| { readonly ok: false; readonly error: 'conflict'; readonly message: string };
export type HierarchyResult<T> = ({ readonly ok: true } & T) | HierarchyCommandFailure;
export interface CompanyView {
readonly id: string;
readonly name: string;
readonly slug: string;
readonly visibility: string;
}
export interface NodeView {
readonly id: string;
readonly name: string;
readonly slug: string;
}
export interface GrantView {
readonly id: string;
readonly userId: string;
readonly targetKind: GrantTargetKind;
readonly targetId: string;
/** Namespaced (§4.5), e.g. `hierarchy:owner`. */
readonly role: string;
readonly grantedBy: string;
}
/** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */
export interface DirectoryEntry {
readonly id: string;
readonly name: string;
readonly slug: string;
}
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
type GrantRow = typeof hierarchyGrants.$inferSelect;
const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' };
function conflict(message: string): HierarchyCommandFailure {
return { ok: false, error: 'conflict', message };
}
function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } {
if (row.companyId) return { kind: 'company', id: row.companyId };
if (row.estateId) return { kind: 'estate', id: row.estateId };
return { kind: 'platform_project', id: row.platformProjectId as string };
}
/** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */
function grantSnapshot(row: GrantRow): Record<string, unknown> {
const target = grantTarget(row);
return {
id: row.id,
subject: { userId: row.userId },
target: { kind: target.kind, id: target.id },
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
grantedBy: row.grantedBy,
};
}
function grantView(row: GrantRow): GrantView {
const target = grantTarget(row);
return {
id: row.id,
userId: row.userId as string,
targetKind: target.kind,
targetId: target.id,
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
grantedBy: row.grantedBy,
};
}
function companyView(row: typeof companies.$inferSelect): CompanyView {
return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility };
}
interface CommandContext {
readonly actorId: string;
readonly idempotencyKey: string;
readonly correlationId: string;
}
@Injectable()
export class HierarchyRepository {
constructor(@Inject(DB) private readonly db: Db) {}
private async run<T>(
idempotencyKey: string | undefined,
actorId: string,
body: (tx: Tx, ctx: CommandContext) => Promise<HierarchyResult<T>>,
): Promise<HierarchyResult<T>> {
const ctx: CommandContext = {
actorId,
idempotencyKey: idempotencyKey ?? randomUUID(),
correlationId: randomUUID(),
};
try {
return await this.db.transaction(async (tx) => body(tx, ctx));
} catch (error) {
// A key replayed with different content aborts the whole command —
// the transaction (state change included) has rolled back (§6.4).
if (error instanceof HierarchyAuditIdempotencyConflictError) {
return conflict(error.message);
}
throw error;
}
}
private async requireOwner(
tx: Tx,
actorId: string,
kind: GrantTargetKind,
id: string,
): Promise<boolean> {
return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner');
}
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
const rows = await tx
.select({ role: users.role })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
return rows[0]?.role === 'admin';
}
// ── companies ────────────────────────────────────────────────────────────
/**
* Any authenticated user may create a company; the same audited operation
* writes the creator's initial owner grant (§4.3), causation-linked to the
* create event. Visibility is always 'private' — the command takes no
* visibility input (§5.5).
*/
createCompany(input: {
actorId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView; grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const inserted = await tx
.insert(companies)
.values({ name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const company = inserted[0];
if (!company) return conflict('company slug already exists');
const grantRows = await tx
.insert(hierarchyGrants)
.values({
userId: ctx.actorId,
companyId: company.id,
role: 'owner',
grantedBy: ctx.actorId,
})
.returning();
const grant = grantRows[0] as GrantRow;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
const created = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'company',
targetId: company.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_create',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: created.event.id,
idempotencyKey: `${ctx.idempotencyKey}:grant`,
});
return { ok: true, company: companyView(company), grant: grantView(grant) };
});
}
renameCompany(input: {
actorId: string;
companyId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const rows = await tx
.select()
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(companies)
.set({ name: input.name, updatedAt: new Date() })
.where(eq(companies.id, input.companyId))
.returning();
const company = updated[0] as typeof companies.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'company',
targetId: company.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, company: companyView(company) };
});
}
/**
* Admin-only until the company-CRUD capability ratifies (§5.5) — the one
* hierarchy mutation a platform admin performs without a grant. A
* non-admin caller (owner included) gets `forbidden` before any company
* read: the refusal reveals nothing about the target's existence.
*/
changeCompanyVisibility(input: {
actorId: string;
companyId: string;
visibility: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.isPlatformAdmin(tx, ctx.actorId))) {
return {
ok: false,
error: 'forbidden',
message: 'visibility change is platform-admin-only',
};
}
const rows = await tx
.select()
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(companies)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(eq(companies.id, input.companyId))
.returning();
const company = updated[0] as typeof companies.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'visibility_change',
targetKind: 'company',
targetId: company.id,
// Old and new values are event content (§5.5).
targetSnapshot: {
...snapshot,
previousVisibility: previous.visibility,
visibility: company.visibility,
},
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, company: companyView(company) };
});
}
deleteCompany(input: {
actorId: string;
companyId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const children = await tx
.select({ id: estates.id })
.from(estates)
.where(eq(estates.companyId, input.companyId))
.limit(1);
if (children.length > 0) return conflict('company still has estates');
// Snapshot and grants are read before the delete; target FKs cascade
// the grant rows, and each cascaded deletion is audited (§5.2).
const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.companyId, input.companyId));
await tx.delete(companies).where(eq(companies.id, input.companyId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'company',
targetId: input.companyId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.companyId };
});
}
// ── estates ──────────────────────────────────────────────────────────────
/** Child creation requires owner on the parent and confers no grant (§4.3). */
createEstate(input: {
actorId: string;
companyId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const inserted = await tx
.insert(estates)
.values({ companyId: input.companyId, name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const estate = inserted[0];
if (!estate) return conflict('estate slug already exists in company');
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'estate',
targetId: estate.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
renameEstate(input: {
actorId: string;
estateId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(estates)
.set({ name: input.name })
.where(eq(estates.id, input.estateId))
.returning();
const estate = updated[0] as typeof estates.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'estate',
targetId: estate.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
/** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */
transferEstate(input: {
actorId: string;
estateId: string;
destinationCompanyId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
const estate = rows[0];
if (!estate) return NOT_FOUND;
if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) {
return NOT_FOUND;
}
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) {
return NOT_FOUND;
}
if (estate.companyId === input.destinationCompanyId) {
return conflict('estate already belongs to the destination company');
}
const collision = await tx
.select({ id: estates.id })
.from(estates)
.where(
and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)),
)
.limit(1);
if (collision.length > 0) return conflict('destination company already has that estate slug');
const parents = await tx
.select({ id: companies.id, slug: companies.slug })
.from(companies)
.where(inArray(companies.id, [estate.companyId, input.destinationCompanyId]));
const source = parents.find((p) => p.id === estate.companyId);
const destination = parents.find((p) => p.id === input.destinationCompanyId);
if (!source || !destination) return NOT_FOUND;
await tx
.update(estates)
.set({ companyId: input.destinationCompanyId })
.where(eq(estates.id, input.estateId));
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'transfer',
targetKind: 'estate',
targetId: input.estateId,
targetSnapshot: { ...snapshot },
transferFrom: { kind: 'company', id: source.id, slug: source.slug },
transferTo: { kind: 'company', id: destination.id, slug: destination.slug },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
deleteEstate(input: {
actorId: string;
estateId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const children = await tx
.select({ id: platformProjects.id })
.from(platformProjects)
.where(eq(platformProjects.estateId, input.estateId))
.limit(1);
if (children.length > 0) return conflict('estate still has platform projects');
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.estateId, input.estateId));
await tx.delete(estates).where(eq(estates.id, input.estateId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'estate',
targetId: input.estateId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.estateId };
});
}
// ── platform projects ────────────────────────────────────────────────────
createPlatformProject(input: {
actorId: string;
estateId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const inserted = await tx
.insert(platformProjects)
.values({ estateId: input.estateId, name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const project = inserted[0];
if (!project) return conflict('platform project slug already exists in estate');
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'platform_project',
targetId: project.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
renamePlatformProject(input: {
actorId: string;
platformProjectId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
) {
return NOT_FOUND;
}
const rows = await tx
.select()
.from(platformProjects)
.where(eq(platformProjects.id, input.platformProjectId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(platformProjects)
.set({ name: input.name })
.where(eq(platformProjects.id, input.platformProjectId))
.returning();
const project = updated[0] as typeof platformProjects.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'platform_project',
targetId: project.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
transferPlatformProject(input: {
actorId: string;
platformProjectId: string;
destinationEstateId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(platformProjects)
.where(eq(platformProjects.id, input.platformProjectId))
.limit(1);
const project = rows[0];
if (!project) return NOT_FOUND;
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) {
return NOT_FOUND;
}
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) {
return NOT_FOUND;
}
if (project.estateId === input.destinationEstateId) {
return conflict('platform project already belongs to the destination estate');
}
const collision = await tx
.select({ id: platformProjects.id })
.from(platformProjects)
.where(
and(
eq(platformProjects.estateId, input.destinationEstateId),
eq(platformProjects.slug, project.slug),
),
)
.limit(1);
if (collision.length > 0) {
return conflict('destination estate already has that platform project slug');
}
const parents = await tx
.select({ id: estates.id, slug: estates.slug })
.from(estates)
.where(inArray(estates.id, [project.estateId, input.destinationEstateId]));
const source = parents.find((p) => p.id === project.estateId);
const destination = parents.find((p) => p.id === input.destinationEstateId);
if (!source || !destination) return NOT_FOUND;
await tx
.update(platformProjects)
.set({ estateId: input.destinationEstateId })
.where(eq(platformProjects.id, input.platformProjectId));
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'transfer',
targetKind: 'platform_project',
targetId: input.platformProjectId,
targetSnapshot: { ...snapshot },
transferFrom: { kind: 'estate', id: source.id, slug: source.slug },
transferTo: { kind: 'estate', id: destination.id, slug: destination.slug },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
deletePlatformProject(input: {
actorId: string;
platformProjectId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
) {
return NOT_FOUND;
}
const children = await tx
.select({ id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.platformProjectId, input.platformProjectId))
.limit(1);
if (children.length > 0) return conflict('platform project still has workspaces');
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.platformProjectId, input.platformProjectId));
await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'platform_project',
targetId: input.platformProjectId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.platformProjectId };
});
}
// ── grants ───────────────────────────────────────────────────────────────
/** Grant management requires effective owner on the target (§4.1). */
createGrant(input: {
actorId: string;
userId: string;
targetKind: GrantTargetKind;
targetId: string;
role: HierarchyGrantRole;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) {
return NOT_FOUND;
}
const subject = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, input.userId))
.limit(1);
if (subject.length === 0) return conflict('subject user does not exist');
const inserted = await tx
.insert(hierarchyGrants)
.values({
userId: input.userId,
companyId: input.targetKind === 'company' ? input.targetId : null,
estateId: input.targetKind === 'estate' ? input.targetId : null,
platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null,
role: input.role,
grantedBy: ctx.actorId,
})
.onConflictDoNothing()
.returning();
const grant = inserted[0];
if (!grant) return conflict('grant already exists');
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_create',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, grant: grantView(grant) };
});
}
changeGrant(input: {
actorId: string;
grantId: string;
role: HierarchyGrantRole;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, input.grantId))
.limit(1);
const existing = rows[0];
if (!existing) return NOT_FOUND;
const target = grantTarget(existing);
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
return NOT_FOUND;
}
// Team subjects are suspended (§1.4); the command surface never
// creates them, so this only fires on out-of-band rows.
if (!existing.userId) return conflict('team grant subjects are suspended');
if (existing.role === input.role) return conflict('grant already holds that role');
const duplicate = await tx
.select({ id: hierarchyGrants.id })
.from(hierarchyGrants)
.where(
and(
eq(hierarchyGrants.userId, existing.userId),
target.kind === 'company'
? eq(hierarchyGrants.companyId, target.id)
: target.kind === 'estate'
? eq(hierarchyGrants.estateId, target.id)
: eq(hierarchyGrants.platformProjectId, target.id),
eq(hierarchyGrants.role, input.role),
),
)
.limit(1);
if (duplicate.length > 0) {
return conflict('subject already holds that role on the target');
}
const updated = await tx
.update(hierarchyGrants)
.set({ role: input.role })
.where(eq(hierarchyGrants.id, input.grantId))
.returning();
const grant = updated[0] as GrantRow;
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_change',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: {
...grantSnapshot(grant),
previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole),
},
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, grant: grantView(grant) };
});
}
/** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */
revokeGrant(input: {
actorId: string;
grantId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ revokedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, input.grantId))
.limit(1);
const existing = rows[0];
if (!existing) return NOT_FOUND;
const target = grantTarget(existing);
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
return NOT_FOUND;
}
await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId));
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: existing.id,
targetSnapshot: grantSnapshot(existing),
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, revokedId: existing.id };
});
}
// ── reads ────────────────────────────────────────────────────────────────
/**
* The directory: every directory-class company, closed-field (§2.8). The
* sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2).
*/
async listDirectory(): Promise<DirectoryEntry[]> {
return this.db
.select({ id: companies.id, name: companies.name, slug: companies.slug })
.from(companies)
.where(eq(companies.visibility, 'directory'))
.orderBy(asc(companies.name));
}
/** Companies the user holds any grant on (company or descendant, §2.8). */
async listGrantedCompanies(userId: string): Promise<CompanyView[]> {
const ids = await grantedCompanyIds(this.db, userId);
if (ids.length === 0) return [];
const rows = await this.db
.select()
.from(companies)
.where(inArray(companies.id, ids))
.orderBy(asc(companies.name));
return rows.map(companyView);
}
}
@@ -1,31 +0,0 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { HierarchyCommandFailure, HierarchyResult } from './hierarchy.repository.js';
/**
* Maps repository result unions onto HTTP exceptions. `not_found` carries
* one fixed message for every cause — missing node and unauthorized caller
* are indistinguishable on the wire (contract 1 §6.7).
*/
@Injectable()
export class HierarchyService {
unwrap<T>(result: HierarchyResult<T>): T {
if (result.ok) return result;
throw this.toException(result);
}
private toException(failure: HierarchyCommandFailure): Error {
switch (failure.error) {
case 'not_found':
return new NotFoundException('hierarchy node not found');
case 'forbidden':
return new ForbiddenException(failure.message);
case 'conflict':
return new ConflictException(failure.message);
}
}
}
-2
View File
@@ -12,7 +12,6 @@ import { AppModule } from './app.module.js';
import { mountAuthHandler } from './auth/auth.controller.js';
import { mountMcpHandler } from './mcp/mcp.controller.js';
import { McpService } from './mcp/mcp.service.js';
import { mountSpaStatic } from './spa/serve-spa.js';
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
import { resolveGatewayConfigPath } from './env.js';
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
@@ -69,7 +68,6 @@ async function bootstrap(): Promise<void> {
mountAuthHandler(app);
mountMcpHandler(app, app.get(McpService));
await mountSpaStatic(app);
const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
await app.listen(port, '0.0.0.0');
+24 -89
View File
@@ -176,18 +176,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(false);
});
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
const scopes = deriveMcpToolScopesForUser({ role });
expect([...scopes].sort()).toEqual([...memberScopes].sort());
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
}
).toBe(true);
});
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
@@ -322,20 +311,14 @@ describe('MCP actor identity and tool scope enforcement', () => {
]);
});
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
// ownership sees exactly what an unprivileged member with the same
// ownership would see — here, only the one project they own, and nothing
// tenant-wide or platform-wide.
const fixtures = {
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
const { service } = makeService({
projects: [
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
{
id: 'project-tenant-a',
ownerId: 'other-user-a',
teamId: 'tenant-a',
name: 'same tenant, unowned',
name: 'same tenant',
},
{
id: 'project-tenant-b',
@@ -345,50 +328,39 @@ describe('MCP actor identity and tool scope enforcement', () => {
},
],
missions: [
{ id: 'mission-owned', projectId: 'project-owned' },
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
};
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
const actors = [
makeAdminActor('role-bearing-user', 'tenant-a'),
makePlatformAdminActor('role-bearing-user'),
];
service.registerTools(server, actor);
for (const actor of actors) {
const { service } = makeService(fixtures);
const { server, tools } = makeCapturingServer();
service.registerTools(server, actor);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
}
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
});
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
const { service, brain } = makeService({
projects: [
// §1.1 retirement: content visibility comes from ownership, not the
// tenant-admin role — the acting user owns the tenant-a project.
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
],
missions: [
@@ -401,29 +373,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
],
});
const { server, tools } = makeCapturingServer();
// Platform role no longer derives task-write scopes (§1.1 retirement):
// a role-derived admin actor is scope-denied before any tenant logic.
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, roleDerivedAdmin);
await expect(
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
).rejects.toThrow('MCP tool scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
// The tenant-scoping checks below sit behind the scope gate; exercise
// them with explicitly granted task-write scopes (how grant-mapped
// scopes will arrive), not with a platform role.
tools.clear();
const actor = createMcpActorContext({
userId: 'tenant-admin-user',
tenantId: 'tenant-a',
role: 'member',
scopes: [
...deriveMcpToolScopesForUser({ role: 'member' }),
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
],
});
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, actor);
@@ -466,7 +416,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
);
});
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
it('keeps admin-only coordination tools on server-derived paths', async () => {
const { service, coord } = makeService();
const { server, tools } = makeCapturingServer();
const member = makeMemberActor('authenticated-user');
@@ -483,25 +433,10 @@ describe('MCP actor identity and tool scope enforcement', () => {
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
// §1.1 retirement: platform-admin no longer derives coord scopes either.
tools.clear();
service.registerTools(server, platformAdmin);
const platformAdminTool = getTool(tools, 'coord_list_tasks');
await expect(platformAdminTool.handler({})).rejects.toThrow(
'MCP tool scope denied: coord:read',
);
// An explicitly granted coord:read scope reaches the server-derived
// path (caller-supplied projectPath is stripped by the schema).
tools.clear();
const grantedActor = createMcpActorContext({
userId: 'granted-user',
role: 'member',
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
});
service.registerTools(server, grantedActor);
const grantedTool = getTool(tools, 'coord_list_tasks');
await grantedTool.handler({ projectPath: '/tmp/victim' });
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
});
+57 -19
View File
@@ -63,6 +63,20 @@ interface SessionEntry {
actor: McpActorContext;
}
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
@@ -75,17 +89,15 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.memory_save_insight,
]);
/**
* Contract 2 §1.1: platform role confers NO MCP scope elevation — the
* former tenant-admin/global-admin scope sets keyed on users.role are
* retired. Every authenticated user receives the base member set; task
* writes and coordination scopes attach to explicit hierarchy grants when
* the MCP grant mapping lands, never to a platform role. The role
* parameter is kept for caller compatibility and deliberately ignored.
*/
export function deriveMcpToolScopesForUser(_input: {
export function deriveMcpToolScopesForUser(input: {
role?: string | null;
}): ReadonlySet<McpToolScope> {
if (input.role === 'platform-admin' || input.role === 'super-admin') {
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
}
if (input.role === 'admin') {
return new Set(TENANT_ADMIN_MCP_SCOPES);
}
return new Set(MEMBER_MCP_SCOPES);
}
@@ -156,22 +168,41 @@ type TaskLike = TenantScopedLike & {
userId?: string | null;
};
/**
* Contract 2 §1.1: `users.role` confers NO content visibility — the former
* global-admin/tenant-admin filter short-circuits keyed on the platform role
* are retired along with the role-derived scope sets. Content reaches an MCP
* actor through ownership only; widened access arrives as explicit hierarchy
* grants when the MCP grant mapping lands.
*/
function isGlobalAdminActor(actor: McpActorContext): boolean {
return actor.role === 'platform-admin' || actor.role === 'super-admin';
}
function isTenantAdminActor(actor: McpActorContext): boolean {
return actor.role === 'admin';
}
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
return (
record.tenantId === actor.tenantId ||
record.organizationId === actor.tenantId ||
record.teamId === actor.tenantId
);
}
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
return projects.filter((project) => project.ownerId === actor.userId);
if (isGlobalAdminActor(actor)) return projects;
return projects.filter(
(project) =>
project.ownerId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
);
}
function filterMissionsByDirectActorScope<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): T[] {
return missions.filter((mission) => mission.userId === actor.userId);
if (isGlobalAdminActor(actor)) return missions;
return missions.filter(
(mission) =>
mission.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
);
}
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
@@ -262,6 +293,7 @@ export class McpService implements OnModuleDestroy {
}
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
}
@@ -270,6 +302,8 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
missions: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return missions;
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
const projectIds = new Set(
filterProjectsForActor(actor, projects).map((project) => project.id),
@@ -283,6 +317,7 @@ export class McpService implements OnModuleDestroy {
}
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
if (!mission) return false;
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
@@ -304,7 +339,7 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (!refs.projectId && !refs.missionId) {
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
throw new Error('MCP task scope denied');
}
await this.assertTaskReferencesAuthorized(actor, refs);
@@ -314,6 +349,8 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
tasks: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return tasks;
const [projects, missions] = await Promise.all([
this.brain.projects.findAll(),
this.brain.missions.findAll(),
@@ -330,6 +367,7 @@ export class McpService implements OnModuleDestroy {
return tasks.filter(
(task) =>
task.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
);
-192
View File
@@ -1,192 +0,0 @@
/**
* E2E integration test — SPA static serving (Phase P5 cutover, #1444; tests
* added in P6, #1445, review follow-up SF1 on PR #1453).
*
* Boots a real Nest+Fastify app the way main.ts does (mountSpaStatic after the
* controllers) against a fixture dist directory, and pins the serving
* contract:
*
* 1. `/` and client-side deep links fall back to index.html.
* 2. Declared API routes win over the catch-all.
* 3. Unknown backend paths (/api, /mcp, /socket.io) are JSON 404s, never the
* SPA page — including with a query string (`/api?x=1`).
* 4. Static files are served exactly; hashed /assets/ files get immutable
* cache headers, everything else revalidates (max-age=0), and a missing
* /assets/ file is a 404 — never the SPA fallback.
* 5. WEB_DIST_DIR unset disables SPA serving entirely.
* 6. WEB_DIST_DIR pointing at a directory without index.html fails at boot.
*/
import 'reflect-metadata';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import { Test } from '@nestjs/testing';
import { Controller, Get, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { mountSpaStatic } from './serve-spa.js';
const INDEX_HTML = '<!doctype html><html><body>mosaic spa fixture</body></html>\n';
const ASSET_JS = 'console.log("hashed asset");\n';
@Controller('api/spa-test')
class SpaTestController {
@Get('ping')
ping(): { ok: boolean } {
return { ok: true };
}
}
async function createApp(): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
controllers: [SpaTestController],
}).compile();
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
// Mirror main.ts ordering: SPA mounting happens after the app (and its
// controllers) exist, before listen.
await mountSpaStatic(app as NestFastifyApplication);
await (app as NestFastifyApplication).getHttpAdapter().getInstance().ready();
return app;
}
describe('SPA static serving — fixture dist dir', () => {
let app: INestApplication;
let distDir: string;
let previousWebDistDir: string | undefined;
beforeAll(async () => {
distDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-fixture-'));
await writeFile(path.join(distDir, 'index.html'), INDEX_HTML);
await writeFile(path.join(distDir, 'favicon.svg'), '<svg></svg>\n');
await mkdir(path.join(distDir, 'assets'), { recursive: true });
await writeFile(path.join(distDir, 'assets', 'app-abc123.js'), ASSET_JS);
previousWebDistDir = process.env['WEB_DIST_DIR'];
process.env['WEB_DIST_DIR'] = distDir;
app = await createApp();
});
afterAll(async () => {
if (previousWebDistDir === undefined) {
delete process.env['WEB_DIST_DIR'];
} else {
process.env['WEB_DIST_DIR'] = previousWebDistDir;
}
await app.close();
await rm(distDir, { recursive: true, force: true });
});
it('serves index.html at /', async () => {
const res = await request(app.getHttpServer()).get('/');
expect(res.status).toBe(200);
expect(res.text).toBe(INDEX_HTML);
expect(res.headers['content-type']).toContain('text/html');
});
it('falls back to index.html for client-side deep links', async () => {
for (const deepLink of ['/chat', '/projects/42', '/settings']) {
const res = await request(app.getHttpServer()).get(deepLink);
expect(res.status, deepLink).toBe(200);
expect(res.text, deepLink).toBe(INDEX_HTML);
}
});
it('declared API routes win over the SPA catch-all', async () => {
const res = await request(app.getHttpServer()).get('/api/spa-test/ping');
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
it('unknown backend paths are JSON 404s, never the SPA page', async () => {
for (const backendPath of ['/api/nope', '/api', '/mcp/nope', '/socket.io/nope']) {
const res = await request(app.getHttpServer()).get(backendPath);
expect(res.status, backendPath).toBe(404);
expect(res.headers['content-type'], backendPath).toContain('application/json');
expect(res.body, backendPath).toMatchObject({ error: 'Not Found', statusCode: 404 });
}
});
it('a backend path with a query string is still a backend 404 (/api?x=1)', async () => {
const res = await request(app.getHttpServer()).get('/api?x=1');
expect(res.status).toBe(404);
expect(res.headers['content-type']).toContain('application/json');
});
it('serves static files exactly', async () => {
const res = await request(app.getHttpServer()).get('/favicon.svg');
expect(res.status).toBe(200);
// supertest buffers image/svg+xml as a Buffer body, not res.text.
const body = res.text || (res.body as Buffer).toString('utf8');
expect(body).toBe('<svg></svg>\n');
});
it('hashed /assets/ files get immutable cache headers', async () => {
const res = await request(app.getHttpServer()).get('/assets/app-abc123.js');
expect(res.status).toBe(200);
expect(res.text).toBe(ASSET_JS);
expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable');
});
it('missing /assets/ files are 404s, never the SPA page with an immutable header', async () => {
// The exact request a browser with a stale index.html makes after a
// deploy: the old hashed filename. Serving index.html here would poison
// caches with a year-long immutable entry whose body is HTML.
for (const missingAsset of ['/assets/app-old999.js', '/assets/app-old999.js?v=1']) {
const res = await request(app.getHttpServer()).get(missingAsset);
expect(res.status, missingAsset).toBe(404);
expect(res.text, missingAsset).not.toContain('mosaic spa fixture');
// The 404 carries no cache-control at all; ?? '' keeps the assertion valid.
expect(res.headers['cache-control'] ?? '', missingAsset).not.toContain('immutable');
}
});
it('index.html and non-asset files revalidate (no immutable caching)', async () => {
for (const revalidating of ['/', '/chat', '/favicon.svg']) {
const res = await request(app.getHttpServer()).get(revalidating);
expect(res.headers['cache-control'], revalidating).not.toContain('immutable');
}
});
it('non-GET unmatched requests keep the stock 404 (catch-all is GET/HEAD only)', async () => {
const res = await request(app.getHttpServer()).post('/chat');
expect(res.status).toBe(404);
expect(res.text).not.toContain('mosaic spa fixture');
});
});
describe('SPA static serving — configuration edges', () => {
it('WEB_DIST_DIR unset disables SPA serving', async () => {
const previous = process.env['WEB_DIST_DIR'];
delete process.env['WEB_DIST_DIR'];
try {
const app = await createApp();
const res = await request(app.getHttpServer()).get('/chat');
expect(res.status).toBe(404);
await app.close();
} finally {
if (previous !== undefined) {
process.env['WEB_DIST_DIR'] = previous;
}
}
});
it('WEB_DIST_DIR without index.html fails at boot', async () => {
const emptyDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-empty-'));
const previous = process.env['WEB_DIST_DIR'];
process.env['WEB_DIST_DIR'] = emptyDir;
try {
await expect(createApp()).rejects.toThrow(/index\.html.*does not exist/);
} finally {
if (previous === undefined) {
delete process.env['WEB_DIST_DIR'];
} else {
process.env['WEB_DIST_DIR'] = previous;
}
await rm(emptyDir, { recursive: true, force: true });
}
});
});
-106
View File
@@ -1,106 +0,0 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Logger } from '@nestjs/common';
import fastifyStatic from '@fastify/static';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
/** Request paths that belong to the backend, never to the SPA fallback. */
const BACKEND_PREFIXES = ['/api', '/mcp', '/socket.io'] as const;
function isBackendPath(url: string): boolean {
// Match on the path only: `/api?x=1` is a backend request, and the query
// string must never turn it into an SPA fallback.
const pathOnly = url.split('?', 1)[0] ?? url;
return BACKEND_PREFIXES.some(
(prefix) => pathOnly === prefix || pathOnly.startsWith(`${prefix}/`),
);
}
/**
* Serve the built web SPA bundle (Phase P5 cutover, #1444).
*
* WEB_DIST_DIR unset: SPA serving is disabled — dev runs the Vite dev server,
* which proxies /api and /socket.io here. WEB_DIST_DIR set but not holding a
* built bundle: fail at boot, because a gateway configured to serve the UI
* silently serving 404s is an outage, not a degraded mode.
*
* Static files get exact routes (wildcard: false, so nothing shadows the API
* routes); every other GET/HEAD outside the backend prefixes falls back to
* index.html so client-side routes deep-link correctly.
*/
export async function mountSpaStatic(app: NestFastifyApplication): Promise<void> {
const logger = new Logger('SpaStatic');
const distDir = process.env['WEB_DIST_DIR'];
if (!distDir) {
logger.log('WEB_DIST_DIR not set; SPA serving disabled (dev mode uses the Vite dev server)');
return;
}
const root = path.resolve(distDir);
const indexFile = path.join(root, 'index.html');
if (!existsSync(indexFile)) {
throw new Error(`WEB_DIST_DIR is '${distDir}' but '${indexFile}' does not exist`);
}
// Default cache semantics: public, max-age=0 with ETag/Last-Modified, so
// every response revalidates (304 when unchanged). Always correct, including
// for index.html after a deploy.
await app.register(
fastifyStatic as never,
{
root,
wildcard: false,
index: false,
} as never,
);
const fastify = app.getHttpAdapter().getInstance();
// Files under /assets/ carry a content hash in their name (Vite emits them
// that way), so they get long-lived immutable caching: a changed file is a
// new URL, never a stale cache hit. An onSend hook rather than the plugin's
// `setHeaders` option, because @fastify/static applies its own computed
// cache-control (reply.headers) after calling setHeaders, overriding it.
fastify.addHook('onSend', (req, reply, payload, done) => {
const pathOnly = (req.raw.url ?? '').split('?', 1)[0] ?? '';
if (reply.statusCode === 200 && pathOnly.startsWith('/assets/')) {
void reply.header('cache-control', 'public, max-age=31536000, immutable');
}
done(null, payload);
});
// A wildcard route, not setNotFoundHandler: Nest installs its own not-found
// handler during init and Fastify allows only one. find-my-way matches
// most-specific-first, so every declared route (API, static files) wins over
// this catch-all; non-GET unmatched requests keep Fastify's stock 404.
fastify.get('/*', (req, reply) => {
const url = req.raw.url ?? '';
const pathOnly = url.split('?', 1)[0] ?? url;
if (isBackendPath(url)) {
// An unknown backend path is an API 404, never the SPA page.
void reply.code(404).send({
message: `Route ${req.raw.method ?? 'GET'}:${url} not found`,
error: 'Not Found',
statusCode: 404,
});
return;
}
if (pathOnly === '/assets' || pathOnly.startsWith('/assets/')) {
// A missing hashed asset — typically a browser holding a stale
// index.html after a deploy — must 404. Falling through to the SPA
// fallback would return index.html as the asset body, and the onSend
// hook above would stamp it with a year-long immutable cache-control.
void reply.code(404).send({
message: `Asset ${pathOnly} not found`,
error: 'Not Found',
statusCode: 404,
});
return;
}
// sendFile is decorated by @fastify/static; its type augmentation targets
// a different fastify copy in the pnpm tree than the Nest adapter's.
(reply as unknown as { sendFile: (file: string) => unknown }).sendFile('index.html');
});
logger.log(`Serving SPA bundle from ${root}`);
}
-62
View File
@@ -1,18 +1,6 @@
import 'reflect-metadata';
import { getMetadataStorage } from 'class-validator';
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
import {
ChangeCompanyVisibilityDto,
ChangeGrantDto,
CreateCompanyDto,
CreateEstateDto,
CreateGrantDto,
CreatePlatformProjectDto,
DeleteNodeDto,
RenameNodeDto,
TransferEstateDto,
TransferPlatformProjectDto,
} from './hierarchy/hierarchy.dto.js';
/**
* Boot-time self-check: the global ValidationPipe must be able to SEE the
@@ -55,56 +43,6 @@ export const PIPE_GUARDED_DTOS: Array<{
target: BootstrapSetupDto,
properties: ['name', 'email', 'password'],
},
{
name: 'CreateCompanyDto',
target: CreateCompanyDto,
properties: ['name', 'slug', 'idempotencyKey'],
},
{
name: 'RenameNodeDto',
target: RenameNodeDto,
properties: ['name', 'idempotencyKey'],
},
{
name: 'ChangeCompanyVisibilityDto',
target: ChangeCompanyVisibilityDto,
properties: ['visibility', 'idempotencyKey'],
},
{
name: 'DeleteNodeDto',
target: DeleteNodeDto,
properties: ['idempotencyKey'],
},
{
name: 'CreateEstateDto',
target: CreateEstateDto,
properties: ['companyId', 'name', 'slug', 'idempotencyKey'],
},
{
name: 'CreatePlatformProjectDto',
target: CreatePlatformProjectDto,
properties: ['estateId', 'name', 'slug', 'idempotencyKey'],
},
{
name: 'TransferEstateDto',
target: TransferEstateDto,
properties: ['destinationCompanyId', 'idempotencyKey'],
},
{
name: 'TransferPlatformProjectDto',
target: TransferPlatformProjectDto,
properties: ['destinationEstateId', 'idempotencyKey'],
},
{
name: 'CreateGrantDto',
target: CreateGrantDto,
properties: ['userId', 'targetKind', 'targetId', 'role', 'idempotencyKey'],
},
{
name: 'ChangeGrantDto',
target: ChangeGrantDto,
properties: ['role', 'idempotencyKey'],
},
];
export class PipeMetatypeCheckError extends Error {
+28 -20
View File
@@ -1,14 +1,11 @@
import { test, expect } from '@playwright/test';
import { loginAs, ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, ADMIN_USER, TEST_USER } from './helpers/auth.js';
test.describe('Admin page — admin user', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, ADMIN_USER.email, ADMIN_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded admin user — skipping admin tests',
);
test.skip(!url.includes('/chat'), 'No seeded admin user — skipping admin tests');
});
test('admin page loads with the Admin Panel heading', async ({ page }) => {
@@ -34,11 +31,15 @@ test.describe('Admin page — admin user', () => {
await page.goto('/admin');
await page.getByRole('button', { name: /system health/i }).click();
// Health cards or loading indicator should appear
const loadingOrCard = page
const hasLoading = await page
.getByText(/loading health/i)
.or(page.getByText(/database/i))
.first();
await expect(loadingOrCard).toBeVisible({ timeout: 10_000 });
.isVisible()
.catch(() => false);
const hasCard = await page
.getByText(/database/i)
.isVisible()
.catch(() => false);
expect(hasLoading || hasCard).toBe(true);
});
});
@@ -46,19 +47,26 @@ test.describe('Admin page — non-admin user', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping non-admin tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping non-admin tests');
});
test('non-admin visiting /admin never sees the admin panel', async ({ page }) => {
test('non-admin visiting /admin sees access denied or is redirected', async ({ page }) => {
await page.goto('/admin');
// Wait for the app shell to render (redirect and access-denied views both
// keep the sidebar), then assert the panel itself is absent. globalSetup
// seeds TEST_USER with role 'member', so this is a real authorization
// assertion, not environment-dependent.
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('heading', { name: /admin panel/i })).not.toBeVisible();
// Either redirected away or shown an access-denied message
const onAdmin = page.url().includes('/admin');
if (onAdmin) {
// Should show some access-denied content rather than the full admin panel
const hasPanel = await page
.getByRole('heading', { name: /admin panel/i })
.isVisible()
.catch(() => false);
// If heading is visible, the guard allowed access (user may have admin role in this env)
// — not a failure, just informational
if (!hasPanel) {
// access denied message, redirect, or guard placeholder
const url = page.url();
expect(url).toBeTruthy(); // environment-dependent — no hard assertion
}
}
});
});
+9 -5
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { TEST_USER } from './helpers/auth.js';
// ── Login page ────────────────────────────────────────────────────────────────
@@ -49,14 +49,18 @@ test.describe('Login page', () => {
});
test('redirects to /chat after successful login', async ({ page }) => {
// Only meaningful with known-good credentials; against a live environment
// this would just probe someone else's user table.
test.skip(!REQUIRE_SEEDED_AUTH, 'needs seeded credentials (E2E_REQUIRE_SEEDED_AUTH=1)');
await page.goto('/login');
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password').fill(TEST_USER.password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
// Either reaches /chat or shows an error (if credentials are wrong in this env).
// We assert a navigation away from /login, or the alert is shown.
await Promise.race([
expect(page).toHaveURL(/\/chat/, { timeout: 10_000 }),
expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 }),
]).catch(() => {
// Acceptable — environment may not have seeded credentials
});
});
});
+26 -19
View File
@@ -1,38 +1,45 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Chat page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
// If login failed (no seeded user in env) we may be on /login — skip
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('chat page loads and shows the conversation area', async ({ page }) => {
test('chat page loads and shows the welcome message or conversation list', async ({ page }) => {
await page.goto('/chat');
await expect(page.getByRole('heading', { level: 1, name: /chat/i })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByRole('log', { name: /conversation/i })).toBeVisible();
// Either there are conversations listed or the welcome empty-state is shown
const hasWelcome = await page
.getByRole('heading', { name: /welcome to mosaic chat/i })
.isVisible()
.catch(() => false);
const hasConversationPanel = await page
.locator('[data-testid="conversation-list"], nav, aside')
.first()
.isVisible()
.catch(() => false);
expect(hasWelcome || hasConversationPanel).toBe(true);
});
test('message composer input is visible', async ({ page }) => {
test('new conversation button is visible', async ({ page }) => {
await page.goto('/chat');
await expect(page.getByLabel('Message')).toBeVisible({ timeout: 10_000 });
// "Start new conversation" button or a "+" button in the sidebar
const newConvButton = page.getByRole('button', { name: /new conversation|start new/i }).first();
await expect(newConvButton).toBeVisible({ timeout: 10_000 });
});
test('command panel lists /new and exposes the run controls', async ({ page }) => {
test('clicking new conversation shows a chat input area', async ({ page }) => {
await page.goto('/chat');
// Conversations are command-driven: /new starts one via the commands panel.
const commandList = page.getByRole('list', { name: /available commands/i });
await expect(commandList).toBeVisible({ timeout: 10_000 });
await expect(commandList.getByText('/new', { exact: true })).toBeVisible();
await expect(page.getByLabel('Command name')).toBeVisible();
await expect(page.getByRole('button', { name: /run command/i })).toBeVisible();
// Find any button that creates a new conversation
const newBtn = page.getByRole('button', { name: /new conversation|start new/i }).first();
await newBtn.click();
// After creating, a text input for sending messages should appear
const chatInput = page.getByRole('textbox').or(page.locator('textarea')).first();
await expect(chatInput).toBeVisible({ timeout: 10_000 });
});
test('sidebar navigation is present on chat page', async ({ page }) => {
-95
View File
@@ -1,95 +0,0 @@
import type { FullConfig } from '@playwright/test';
import { ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
/**
* Seed the E2E users through the gateway's real APIs (#1445, P6).
*
* On a fresh database (CI boots the gateway on the embedded PGlite path):
* 1. POST /api/bootstrap/setup creates ADMIN_USER as the first admin.
* 2. The admin signs in and creates TEST_USER via the better-auth admin API.
*
* Against an environment that already has users (needsSetup=false), seeding is
* skipped entirely: the specs keep their own skip-when-login-fails guards, so
* a live environment stays usable as a test target without mutation. Under
* E2E_REQUIRE_SEEDED_AUTH=1 (CI) that state is instead a hard failure and the
* guards are disabled — see helpers/auth.ts.
*
* On a fresh database, any seeding failure throws and fails the whole run: an
* E2E gate whose authenticated suites silently skip would pass while proving
* nothing.
*/
export default async function globalSetup(config: FullConfig): Promise<void> {
const baseURL = config.projects[0]?.use?.baseURL ?? 'http://localhost:14242';
const statusRes = await fetch(`${baseURL}/api/bootstrap/status`);
if (!statusRes.ok) {
throw new Error(`GET /api/bootstrap/status returned ${statusRes.status} — is the gateway up?`);
}
const status = (await statusRes.json()) as { needsSetup: boolean };
if (!status.needsSetup) {
if (REQUIRE_SEEDED_AUTH) {
// CI boots the gateway on a fresh HOME-isolated database, so an
// already-populated one means the isolation regressed — refuse to run
// against unknown data rather than skip-and-pass.
throw new Error(
'E2E_REQUIRE_SEEDED_AUTH=1 but the database already has users — gateway HOME isolation regressed?',
);
}
console.info('[e2e setup] users already exist; skipping seed');
return;
}
const setupRes = await fetch(`${baseURL}/api/bootstrap/setup`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: ADMIN_USER.name,
email: ADMIN_USER.email,
password: ADMIN_USER.password,
}),
});
if (!setupRes.ok) {
throw new Error(
`POST /api/bootstrap/setup failed (${setupRes.status}): ${await setupRes.text()}`,
);
}
console.info(`[e2e setup] bootstrap admin created: ${ADMIN_USER.email}`);
// better-auth's CSRF protection rejects requests without an Origin header
// (403 MISSING_OR_NULL_ORIGIN), so the server-side fetches here send the
// gateway's own origin — the same value a browser tab on the SPA would send.
const authHeaders = { 'content-type': 'application/json', origin: baseURL };
const signInRes = await fetch(`${baseURL}/api/auth/sign-in/email`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ email: ADMIN_USER.email, password: ADMIN_USER.password }),
});
if (!signInRes.ok) {
throw new Error(`admin sign-in failed (${signInRes.status}): ${await signInRes.text()}`);
}
const cookies = signInRes.headers
.getSetCookie()
.map((cookie) => cookie.split(';', 1)[0])
.join('; ');
if (!cookies) {
throw new Error('admin sign-in returned no session cookie');
}
const createRes = await fetch(`${baseURL}/api/auth/admin/create-user`, {
method: 'POST',
headers: { ...authHeaders, cookie: cookies },
body: JSON.stringify({
name: TEST_USER.name,
email: TEST_USER.email,
password: TEST_USER.password,
role: 'member',
}),
});
if (!createRes.ok) {
throw new Error(
`POST /api/auth/admin/create-user failed (${createRes.status}): ${await createRes.text()}`,
);
}
console.info(`[e2e setup] test user created: ${TEST_USER.email}`);
}
+1 -18
View File
@@ -13,28 +13,11 @@ export const ADMIN_USER = {
};
/**
* Set when the database was seeded by global-setup (CI sets it in the
* publish.yml e2e step). Seeded credentials MUST work, so login failures are
* hard failures and the skip-when-login-fails guards are disabled — otherwise
* a login regression would skip every authenticated suite and the gate would
* pass while proving nothing. Unset (a live environment used as a test
* target), the guards stay on and unseeded credentials skip their suites.
*/
export const REQUIRE_SEEDED_AUTH = process.env['E2E_REQUIRE_SEEDED_AUTH'] === '1';
/**
* Fill the login form and submit, then wait for the post-login redirect to
* /chat. Under REQUIRE_SEEDED_AUTH a missed redirect throws (failing the
* test). Otherwise the timeout is swallowed: the page stays on /login and the
* callers' `test.skip(...)` guards see that. Without this wait, every guard
* read page.url() before the redirect happened and skipped its suite even
* when login succeeded (#1445).
* Fill the login form and submit. Waits for navigation after success.
*/
export async function loginAs(page: Page, email: string, password: string): Promise<void> {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
const redirect = page.waitForURL(/\/chat/, { timeout: 10_000 });
await (REQUIRE_SEEDED_AUTH ? redirect : redirect.catch(() => {}));
}
+11 -23
View File
@@ -1,22 +1,16 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Sidebar navigation', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('sidebar shows the Mosaic brand', async ({ page }) => {
test('sidebar shows Mosaic brand link', async ({ page }) => {
await page.goto('/chat');
// The brand block is a logo image plus "Mosaic / Mission Control" text,
// not a link.
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible();
await expect(page.getByText('Mission Control')).toBeVisible();
await expect(page.getByRole('link', { name: /mosaic/i }).first()).toBeVisible();
});
test('Chat nav link navigates to /chat', async ({ page }) => {
@@ -54,12 +48,11 @@ test.describe('Sidebar navigation', () => {
test('active link is visually highlighted', async ({ page }) => {
await page.goto('/chat');
// The sidebar marks the active item with `font-medium` (plus an inline
// primary-color style); inactive items get the hover class instead.
// The active link should have a distinct class — check that the Chat link
// has the active style class (bg-blue-600/20 text-blue-400)
const chatLink = page.getByRole('link', { name: /^chat$/i }).first();
const projectsLink = page.getByRole('link', { name: /^projects$/i }).first();
await expect(chatLink).toHaveClass(/font-medium/);
await expect(projectsLink).not.toHaveClass(/font-medium/);
const cls = await chatLink.getAttribute('class');
expect(cls).toContain('blue');
});
});
@@ -67,23 +60,18 @@ test.describe('Route transitions', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('navigating chat → projects → settings → chat works without errors', async ({ page }) => {
await page.goto('/chat');
await expect(page).toHaveURL(/\/chat/);
// level: 1 — empty-state h2s ("No projects yet") also match the loose
// patterns, and a two-element match is a strict-mode violation.
await page.goto('/projects');
await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
await page.goto('/settings');
await expect(page.getByRole('heading', { level: 1, name: /settings/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
await page.goto('/chat');
await expect(page).toHaveURL(/\/chat/);
+19 -14
View File
@@ -1,23 +1,16 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Projects page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('projects page loads with heading', async ({ page }) => {
await page.goto('/projects');
// level: 1 — the "No projects yet" empty-state h2 also matches /projects/i
// and a two-element match is a strict-mode violation.
await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible({ timeout: 10_000 });
});
test('shows empty state or project cards when loaded', async ({ page }) => {
@@ -25,11 +18,23 @@ test.describe('Projects page', () => {
// Wait for loading state to clear
await expect(page.getByText(/loading projects/i)).not.toBeVisible({ timeout: 10_000 });
const cardsOrEmpty = page
const hasProjects = await page
.locator('[class*="grid"]')
.or(page.getByText(/no projects yet/i))
.first();
await expect(cardsOrEmpty).toBeVisible({ timeout: 10_000 });
.isVisible()
.catch(() => false);
const hasEmpty = await page
.getByText(/no projects yet/i)
.isVisible()
.catch(() => false);
expect(hasProjects || hasEmpty).toBe(true);
});
test('shows Active Mission section', async ({ page }) => {
await page.goto('/projects');
await expect(page.getByRole('heading', { name: /active mission/i })).toBeVisible({
timeout: 10_000,
});
});
test('sidebar navigation is present', async ({ page }) => {
+2 -5
View File
@@ -1,14 +1,11 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Settings page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('settings page loads with heading', async ({ page }) => {
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+32
View File
@@ -0,0 +1,32 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@mosaicstack/design-tokens'],
// Enable gzip/brotli compression for all responses.
compress: true,
// Reduce bundle size: disable source maps in production builds.
productionBrowserSourceMaps: false,
// Image optimisation: allow the gateway origin as an external image source.
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
// Experimental: enable React compiler for automatic memoisation (Next 15+).
// Falls back gracefully if the compiler plugin is not installed.
experimental: {
// Turbopack is the default in dev for Next 15; keep it opt-in for now.
// turbo: {},
},
};
export default nextConfig;
+7 -4
View File
@@ -3,19 +3,22 @@
"version": "0.0.2",
"private": true,
"scripts": {
"build": "vite build",
"dev": "vite",
"preview": "vite preview",
"build": "node ../../scripts/build-web.mjs",
"build:vite": "vite build",
"dev": "next dev -p 3101",
"dev:vite": "vite",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"start": "next start -p 3101"
},
"dependencies": {
"@mosaicstack/design-tokens": "workspace:^",
"@mosaicstack/types": "workspace:^",
"better-auth": "^1.5.5",
"clsx": "^2.1.0",
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
+7 -14
View File
@@ -1,30 +1,23 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright E2E configuration for the Mosaic web SPA.
* Playwright E2E configuration for Mosaic web app.
*
* Assumes the NestJS gateway is already running on http://localhost:14242 and
* serving the built SPA bundle (WEB_DIST_DIR pointing at apps/web/dist) — the
* same serving path production uses (Phase P5, #1444). Override the target
* with PLAYWRIGHT_BASE_URL.
*
* global-setup seeds the E2E users through the real bootstrap and admin APIs
* when the database is empty; against an already-populated environment it
* seeds nothing.
* Assumes:
* - Next.js web app running on http://localhost:3000
* - NestJS gateway running on http://localhost:14242
*
* Run with: pnpm --filter @mosaicstack/web test:e2e
*/
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
// CI needs the verdict in the step log; the html report is a local tool.
reporter: process.env['CI'] ? 'list' : 'html',
reporter: 'html',
use: {
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:14242',
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
@@ -34,6 +27,6 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
],
// Do NOT auto-start a server — tests assume the gateway is already running.
// Do NOT auto-start the dev server — tests assume it is already running.
// webServer is intentionally omitted so tests can run against a live env.
});
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { GuestGuard } from '@/components/guest-guard';
export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<GuestGuard>
<div className="flex min-h-screen items-center justify-center bg-surface-bg">
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
{children}
</div>
</div>
</GuestGuard>
);
}
+139
View File
@@ -0,0 +1,139 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { api } from '@/lib/api';
import { authClient, signIn } from '@/lib/auth-client';
import type { SsoProviderDiscovery } from '@/lib/sso';
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
export default function LoginPage(): React.ReactElement {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
SsoProviderDiscovery['id'] | null
>(null);
useEffect(() => {
api<SsoProviderDiscovery[]>('/api/sso/providers')
.catch(() => [] as SsoProviderDiscovery[])
.then((providers) => setSsoProviders(providers.filter((provider) => provider.configured)));
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setLoading(true);
const form = new FormData(e.currentTarget);
const email = form.get('email') as string;
const password = form.get('password') as string;
const result = await signIn.email({ email, password });
if (result.error) {
setError(result.error.message ?? 'Sign in failed');
setLoading(false);
return;
}
router.push('/chat');
}
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
setError(null);
setSsoLoadingProviderId(providerId);
try {
const result = await authClient.signIn.oauth2({
providerId,
callbackURL: '/chat',
newUserCallbackURL: '/chat',
});
if (result.error) {
setError(result.error.message ?? `Sign in with ${providerId} failed`);
setSsoLoadingProviderId(null);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : `Sign in with ${providerId} failed`);
setSsoLoadingProviderId(null);
}
}
return (
<div>
<h1 className="text-2xl font-semibold">Sign in</h1>
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
{error && (
<div
role="alert"
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
{error}
</div>
)}
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="[email protected]"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<SsoProviderButtons
providers={ssoProviders}
loadingProviderId={ssoLoadingProviderId}
onOidcSignIn={(providerId) => {
void handleSsoSignIn(providerId);
}}
/>
<p className="mt-4 text-center text-sm text-text-muted">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-blue-400 hover:text-blue-300">
Sign up
</Link>
</p>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signUp } from '@/lib/auth-client';
export default function RegisterPage(): React.ReactElement {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setLoading(true);
const form = new FormData(e.currentTarget);
const name = form.get('name') as string;
const email = form.get('email') as string;
const password = form.get('password') as string;
const result = await signUp.email({ name, email, password });
if (result.error) {
setError(result.error.message ?? 'Registration failed');
setLoading(false);
return;
}
router.push('/chat');
}
return (
<div>
<h1 className="text-2xl font-semibold">Create account</h1>
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
{error && (
<div
role="alert"
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
{error}
</div>
)}
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
Name
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="[email protected]"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
<p className="mt-4 text-center text-sm text-text-muted">
Already have an account?{' '}
<Link href="/login" className="text-blue-400 hover:text-blue-300">
Sign in
</Link>
</p>
</div>
);
}
@@ -1,4 +1,7 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { AdminRoleGuard } from '@/components/admin-role-guard';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
@@ -44,9 +47,15 @@ interface HealthStatusDto {
// ── Admin Page ─────────────────────────────────────────────────────────────────
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
// assumes an authenticated admin session.
export function AdminPage(): React.ReactElement {
export default function AdminPage(): React.ReactElement {
return (
<AdminRoleGuard>
<AdminContent />
</AdminRoleGuard>
);
}
function AdminContent(): React.ReactElement {
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
return (
+365
View File
@@ -0,0 +1,365 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api';
import { destroySocket, getSocket } from '@/lib/socket';
import type { Conversation, Message } from '@/lib/types';
import {
ConversationSidebar,
type ConversationSidebarRef,
} from '@/components/chat/conversation-sidebar';
import { MessageBubble } from '@/components/chat/message-bubble';
import { ChatInput } from '@/components/chat/chat-input';
import { StreamingMessage } from '@/components/chat/streaming-message';
interface ModelInfo {
id: string;
provider: string;
name: string;
reasoning: boolean;
contextWindow: number;
maxTokens: number;
inputTypes: ('text' | 'image')[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}
interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: ModelInfo[];
}
export default function ChatPage(): React.ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModelId, setSelectedModelId] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<ConversationSidebarRef>(null);
// Track the active conversation ID in a ref so socket event handlers always
// see the current value without needing to be re-registered.
const activeIdRef = useRef<string | null>(null);
activeIdRef.current = activeId;
// Accumulate streamed text in a ref so agent:end can read the full content
// without stale-closure issues.
const streamingTextRef = useRef('');
useEffect(() => {
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
if (savedState !== null) {
setIsSidebarOpen(savedState === 'true');
}
}, []);
useEffect(() => {
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
}, [isSidebarOpen]);
useEffect(() => {
api<ProviderInfo[]>('/api/providers')
.then((providers) => {
const availableModels = providers
.filter((provider) => provider.available)
.flatMap((provider) => provider.models);
setModels(availableModels);
setSelectedModelId((current) => current || availableModels[0]?.id || '');
})
.catch(() => {
setModels([]);
setSelectedModelId('');
});
}, []);
// Load messages when active conversation changes
useEffect(() => {
if (!activeId) {
setMessages([]);
return;
}
// Clear streaming state when switching conversations
setIsStreaming(false);
setStreamingText('');
streamingTextRef.current = '';
api<Message[]>(`/api/conversations/${activeId}/messages`)
.then(setMessages)
.catch(() => {});
}, [activeId]);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingText]);
// Socket.io setup — connect once for the page lifetime
useEffect(() => {
const socket = getSocket();
function onAgentStart(data: { conversationId: string }): void {
// Only update state if the event belongs to the currently viewed conversation
if (activeIdRef.current !== data.conversationId) return;
setIsStreaming(true);
setStreamingText('');
streamingTextRef.current = '';
}
function onAgentText(data: { conversationId: string; text: string }): void {
if (activeIdRef.current !== data.conversationId) return;
streamingTextRef.current += data.text;
setStreamingText((prev) => prev + data.text);
}
function onAgentEnd(data: { conversationId: string }): void {
if (activeIdRef.current !== data.conversationId) return;
const finalText = streamingTextRef.current;
setIsStreaming(false);
setStreamingText('');
streamingTextRef.current = '';
// Append the completed assistant message to the local message list.
// The Pi agent session is in-memory so the assistant response is not
// persisted to the DB — we build the local UI state instead.
if (finalText) {
setMessages((prev) => [
...prev,
{
id: `assistant-${Date.now()}`,
conversationId: data.conversationId,
role: 'assistant' as const,
content: finalText,
createdAt: new Date().toISOString(),
},
]);
sidebarRef.current?.refresh();
}
}
function onError(data: { error: string; conversationId?: string }): void {
setIsStreaming(false);
setStreamingText('');
streamingTextRef.current = '';
setMessages((prev) => [
...prev,
{
id: `error-${Date.now()}`,
conversationId: data.conversationId ?? '',
role: 'system' as const,
content: `Error: ${data.error}`,
createdAt: new Date().toISOString(),
},
]);
}
socket.on('agent:start', onAgentStart);
socket.on('agent:text', onAgentText);
socket.on('agent:end', onAgentEnd);
socket.on('error', onError);
// Connect if not already connected
if (!socket.connected) {
socket.connect();
}
return () => {
socket.off('agent:start', onAgentStart);
socket.off('agent:text', onAgentText);
socket.off('agent:end', onAgentEnd);
socket.off('error', onError);
// Fully tear down the socket when the chat page unmounts so we get a
// fresh authenticated connection next time the page is visited.
destroySocket();
};
}, []);
const handleNewConversation = useCallback(async (projectId?: string | null) => {
const conv = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: 'New conversation', projectId: projectId ?? null },
});
sidebarRef.current?.addConversation({
id: conv.id,
title: conv.title,
projectId: conv.projectId,
updatedAt: conv.updatedAt,
archived: conv.archived,
});
setActiveId(conv.id);
setMessages([]);
setIsSidebarOpen(true);
}, []);
const handleSend = useCallback(
async (content: string, options?: { modelId?: string }) => {
let convId = activeId;
// Auto-create conversation if none selected
if (!convId) {
const autoTitle = content.slice(0, 60);
const conv = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: autoTitle },
});
sidebarRef.current?.addConversation({
id: conv.id,
title: conv.title,
projectId: conv.projectId,
updatedAt: conv.updatedAt,
archived: conv.archived,
});
setActiveId(conv.id);
convId = conv.id;
} else if (messages.length === 0) {
// Auto-title the initial placeholder conversation from the first user message.
const autoTitle = content.slice(0, 60);
api<Conversation>(`/api/conversations/${convId}`, {
method: 'PATCH',
body: { title: autoTitle },
})
.then(() => sidebarRef.current?.refresh())
.catch(() => {});
}
// Optimistic user message in local UI state
setMessages((prev) => [
...prev,
{
id: `user-${Date.now()}`,
conversationId: convId,
role: 'user' as const,
content,
createdAt: new Date().toISOString(),
},
]);
// Persist the user message to the DB so conversation history is
// available when the page is reloaded or a new session starts.
api<Message>(`/api/conversations/${convId}/messages`, {
method: 'POST',
body: { role: 'user', content },
}).catch(() => {
// Non-fatal: the agent can still process the message even if
// REST persistence fails.
});
// Send to WebSocket — gateway creates/resumes the agent session and
// streams the response back via agent:start / agent:text / agent:end.
const socket = getSocket();
if (!socket.connected) {
socket.connect();
}
socket.emit('message', {
conversationId: convId,
content,
modelId: (options?.modelId ?? selectedModelId) || undefined,
});
},
[activeId, messages, selectedModelId],
);
return (
<div
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
>
<ConversationSidebar
ref={sidebarRef}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
currentConversationId={activeId}
onSelectConversation={(conversationId) => {
setActiveId(conversationId);
setMessages([]);
if (conversationId && window.innerWidth < 768) {
setIsSidebarOpen(false);
}
}}
onNewConversation={(projectId) => {
void handleNewConversation(projectId);
}}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div
className="flex items-center gap-3 border-b px-4 py-3"
style={{ borderColor: 'var(--border)' }}
>
<button
type="button"
onClick={() => setIsSidebarOpen((open) => !open)}
className="rounded-lg border p-2 transition-colors"
style={{
borderColor: 'var(--border)',
background: 'var(--surface)',
color: 'var(--text)',
}}
aria-label={isSidebarOpen ? 'Close conversation sidebar' : 'Open conversation sidebar'}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
<path strokeWidth="2" strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
<div>
<h1 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
Mosaic Chat
</h1>
<p className="text-xs" style={{ color: 'var(--muted)' }}>
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
</p>
</div>
</div>
{activeId ? (
<>
<div className="flex-1 space-y-4 overflow-y-auto p-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{isStreaming && <StreamingMessage text={streamingText} />}
<div ref={messagesEndRef} />
</div>
<ChatInput
onSend={handleSend}
isStreaming={isStreaming}
models={models}
selectedModelId={selectedModelId}
onModelChange={setSelectedModelId}
/>
</>
) : (
<div className="flex flex-1 items-center justify-center px-6">
<div
className="max-w-md rounded-2xl border px-8 py-10 text-center"
style={{
borderColor: 'var(--border)',
background: 'var(--surface)',
}}
>
<h2 className="text-lg font-medium" style={{ color: 'var(--text)' }}>
Welcome to Mosaic Chat
</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Select a conversation or start a new one
</p>
<button
type="button"
onClick={() => {
void handleNewConversation();
}}
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ background: 'var(--primary)' }}
>
Start new conversation
</button>
</div>
</div>
)}
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
import { AppShell } from '@/components/layout/app-shell';
import { AuthGuard } from '@/components/auth-guard';
export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<AuthGuard>
<AppShell>{children}</AppShell>
</AuthGuard>
);
}
@@ -0,0 +1,338 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
import { MissionTimeline } from '@/components/projects/mission-timeline';
import { PrdViewer } from '@/components/projects/prd-viewer';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
const statusColors: Record<string, string> = {
active: 'bg-success/20 text-success',
paused: 'bg-warning/20 text-warning',
completed: 'bg-blue-600/20 text-blue-400',
archived: 'bg-gray-600/20 text-gray-400',
};
interface TabButtonProps {
id: Tab;
label: string;
activeTab: Tab;
onClick: (tab: Tab) => void;
}
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): React.ReactElement {
return (
<button
type="button"
onClick={() => onClick(id)}
className={cn(
'border-b-2 px-4 py-2 text-sm transition-colors',
activeTab === id
? 'border-text-primary text-text-primary'
: 'border-transparent text-text-muted hover:text-text-secondary',
)}
>
{label}
</button>
);
}
export default function ProjectDetailPage(): React.ReactElement {
const params = useParams();
const router = useRouter();
const id = typeof params['id'] === 'string' ? params['id'] : '';
const [project, setProject] = useState<Project | null>(null);
const [missions, setMissions] = useState<Mission[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
useEffect(() => {
if (!id) return;
setLoading(true);
setError(null);
Promise.all([
api<Project>(`/api/projects/${id}`),
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
api<Task[]>(`/api/tasks?projectId=${id}`).catch(() => [] as Task[]),
])
.then(([proj, allMissions, tks]) => {
setProject(proj);
setMissions(allMissions.filter((m) => m.projectId === id));
setTasks(tks);
})
.catch((err: Error) => {
setError(err.message ?? 'Failed to load project');
})
.finally(() => setLoading(false));
}, [id]);
const handleTaskClick = useCallback((task: Task) => {
setSelectedTask(task);
}, []);
const handleCloseTaskModal = useCallback(() => {
setSelectedTask(null);
}, []);
if (loading) {
return (
<div className="py-16 text-center">
<p className="text-sm text-text-muted">Loading project...</p>
</div>
);
}
if (error || !project) {
return (
<div className="py-16 text-center">
<p className="text-sm text-error">{error ?? 'Project not found'}</p>
<button
type="button"
onClick={() => router.push('/projects')}
className="mt-4 text-sm text-text-muted underline hover:text-text-secondary"
>
Back to projects
</button>
</div>
);
}
const filteredTasks = taskFilter === 'all' ? tasks : tasks.filter((t) => t.status === taskFilter);
const prdContent = getPrdContent(project);
const hasPrd = Boolean(prdContent);
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'tasks', label: `Tasks (${tasks.length})` },
{ id: 'missions', label: `Missions (${missions.length})` },
...(hasPrd ? [{ id: 'prd' as Tab, label: 'PRD' }] : []),
];
return (
<div>
{/* Breadcrumb */}
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
<button
type="button"
onClick={() => router.push('/projects')}
className="hover:text-text-secondary"
>
Projects
</button>
<span>/</span>
<span className="text-text-primary">{project.name}</span>
</nav>
{/* Project header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
statusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{project.status}
</span>
</div>
{project.description && (
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
)}
<p className="mt-2 text-xs text-text-muted">
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.updatedAt).toLocaleDateString()}
</p>
</div>
</div>
{/* Stats bar */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Tasks" value={String(tasks.length)} />
<StatCard
label="Done"
value={String(tasks.filter((t) => t.status === 'done').length)}
valueClass="text-success"
/>
<StatCard
label="In Progress"
value={String(tasks.filter((t) => t.status === 'in-progress').length)}
valueClass="text-blue-400"
/>
<StatCard
label="Blocked"
value={String(tasks.filter((t) => t.status === 'blocked').length)}
valueClass={tasks.some((t) => t.status === 'blocked') ? 'text-error' : undefined}
/>
</div>
{/* Tabs */}
<div className="mb-6 flex gap-0 border-b border-surface-border">
{tabs.map((tab) => (
<TabButton
key={tab.id}
id={tab.id}
label={tab.label}
activeTab={activeTab}
onClick={setActiveTab}
/>
))}
</div>
{/* Tab content */}
{activeTab === 'overview' && (
<OverviewTab project={project} missions={missions} tasks={tasks} />
)}
{activeTab === 'tasks' && (
<div>
<div className="mb-4">
<TaskStatusSummary
tasks={tasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
/>
</div>
<TaskListView tasks={filteredTasks} onTaskClick={handleTaskClick} />
</div>
)}
{activeTab === 'missions' && <MissionTimeline missions={missions} />}
{activeTab === 'prd' && prdContent && (
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
<PrdViewer content={prdContent} />
</div>
)}
{/* Task detail modal */}
{selectedTask && <TaskDetailModal task={selectedTask} onClose={handleCloseTaskModal} />}
</div>
);
}
interface OverviewTabProps {
project: Project;
missions: Mission[];
tasks: Task[];
}
function OverviewTab({ project, missions, tasks }: OverviewTabProps): React.ReactElement {
const recentTasks = [...tasks]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 5);
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* Recent tasks */}
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
{recentTasks.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No tasks yet</p>
</div>
) : (
<div className="space-y-2">
{recentTasks.map((task) => (
<TaskSummaryRow key={task.id} task={task} />
))}
</div>
)}
</section>
{/* Mission summary */}
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
{missions.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No missions yet</p>
</div>
) : (
<MissionTimeline missions={missions.slice(0, 4)} />
)}
</section>
{/* Metadata */}
{project.metadata && Object.keys(project.metadata).length > 0 && (
<section className="lg:col-span-2">
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<pre className="overflow-x-auto text-xs text-text-muted">
{JSON.stringify(project.metadata, null, 2)}
</pre>
</div>
</section>
)}
</div>
);
}
const taskStatusColors: Record<string, string> = {
'not-started': 'bg-gray-600/20 text-gray-300',
'in-progress': 'bg-blue-600/20 text-blue-400',
blocked: 'bg-error/20 text-error',
done: 'bg-success/20 text-success',
cancelled: 'bg-gray-600/20 text-gray-500',
};
function TaskSummaryRow({ task }: { task: Task }): React.ReactElement {
return (
<div className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2">
<span className="truncate text-sm text-text-primary">{task.title}</span>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-xs',
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{task.status}
</span>
</div>
);
}
function StatCard({
label,
value,
valueClass,
}: {
label: string;
value: string;
valueClass?: string;
}): React.ReactElement {
return (
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
<p className="text-xs text-text-muted">{label}</p>
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
</div>
);
}
function getPrdContent(project: Project): string | null {
if (!project.metadata) return null;
const prd = project.metadata['prd'];
if (typeof prd === 'string' && prd.trim().length > 0) return prd;
const prdContent = project.metadata['prdContent'];
if (typeof prdContent === 'string' && prdContent.trim().length > 0) return prdContent;
return null;
}
@@ -0,0 +1,101 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import type { Project } from '@/lib/types';
import { ProjectCard } from '@/components/projects/project-card';
export default function ProjectsPage(): React.ReactElement {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
api<Project[]>('/api/projects')
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleProjectClick = useCallback(
(project: Project) => {
router.push(`/projects/${project.id}`);
},
[router],
);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold">Projects</h1>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
) : projects.length === 0 ? (
<div className="py-12 text-center">
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
<p className="mt-1 text-sm text-text-muted">
Projects will appear here when created via the gateway API
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} onClick={handleProjectClick} />
))}
</div>
)}
{/* Mission status section */}
<MissionStatus />
</div>
);
}
function MissionStatus(): React.ReactElement {
const [mission, setMission] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Record<string, unknown>>('/api/coord/status')
.then(setMission)
.catch(() => setMission(null))
.finally(() => setLoading(false));
}, []);
return (
<section className="mt-8">
<h2 className="mb-4 text-lg font-semibold">Active Mission</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading mission status...</p>
) : !mission ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
<p className="text-sm text-text-muted">No active mission detected</p>
</div>
) : (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Mission" value={String(mission['missionId'] ?? 'Unknown')} />
<StatCard label="Phase" value={String(mission['currentPhase'] ?? '—')} />
<StatCard
label="Tasks"
value={`${mission['completedTasks'] ?? 0} / ${mission['totalTasks'] ?? 0}`}
/>
<StatCard label="Status" value={String(mission['status'] ?? '—')} />
</div>
</div>
)}
</section>
);
}
function StatCard({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="rounded-lg bg-surface-elevated p-3">
<p className="text-xs text-text-muted">{label}</p>
<p className="mt-1 text-sm font-medium text-text-primary">{value}</p>
</div>
);
}
@@ -1,3 +1,5 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { authClient, useSession } from '@/lib/auth-client';
@@ -57,19 +59,9 @@ function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
return p.value as T;
}
// The reset must not outlive the tab: an uncleared setTimeout fires into a
// torn-down environment (unmount, or jsdom teardown under vitest).
function useSavedBadgeReset(saveState: SaveState, setSaveState: (s: SaveState) => void): void {
useEffect(() => {
if (saveState !== 'saved') return undefined;
const timer = setTimeout(() => setSaveState('idle'), 2000);
return () => clearTimeout(timer);
}, [saveState, setSaveState]);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function SettingsPage(): React.ReactElement {
export default function SettingsPage(): React.ReactElement {
const { data: session } = useSession();
const [activeTab, setActiveTab] = useState<Tab>('profile');
@@ -121,7 +113,6 @@ function ProfileTab({
const [image, setImage] = useState(session?.user.image ?? '');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useSavedBadgeReset(saveState, setSaveState);
// Sync from session when it loads
useEffect(() => {
@@ -142,6 +133,7 @@ function ProfileTab({
return;
}
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update profile';
setErrorMsg(message);
@@ -204,7 +196,6 @@ function AppearanceTab(): React.ReactElement {
const [defaultModel, setDefaultModel] = useState('');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useSavedBadgeReset(saveState, setSaveState);
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=appearance')
@@ -250,6 +241,7 @@ function AppearanceTab(): React.ReactElement {
: []),
]);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
setErrorMsg(message);
@@ -333,7 +325,6 @@ function NotificationsTab(): React.ReactElement {
const [emailDigest, setEmailDigest] = useState(false);
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useSavedBadgeReset(saveState, setSaveState);
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=communication')
@@ -380,6 +371,7 @@ function NotificationsTab(): React.ReactElement {
}),
]);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
setErrorMsg(message);
@@ -0,0 +1,72 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskListView } from '@/components/tasks/task-list-view';
type ViewMode = 'list' | 'kanban';
export default function TasksPage(): React.ReactElement {
const [tasks, setTasks] = useState<Task[]>([]);
const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Task[]>('/api/tasks')
.then(setTasks)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleTaskClick = useCallback((task: Task) => {
// Task detail view will be added in future iteration
console.log('Task clicked:', task.id);
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-surface-border">
<button
type="button"
onClick={() => setView('list')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'list'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
List
</button>
<button
type="button"
onClick={() => setView('kanban')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'kanban'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
Kanban
</button>
</div>
</div>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : view === 'kanban' ? (
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
) : (
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
)}
</div>
);
}
@@ -0,0 +1,95 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { api } from '@/lib/api';
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
import { signIn } from '@/lib/auth-client';
import type { SsoProviderDiscovery } from '@/lib/sso';
export default function AuthProviderRedirectPage(): React.ReactElement {
const params = useParams<{ provider: string }>();
const searchParams = useSearchParams();
const providerId = typeof params.provider === 'string' ? params.provider : '';
const requestedCallbackURL = searchParams.get('callbackURL');
const [providerName, setProviderName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function redirectToProvider(): Promise<void> {
try {
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
if (cancelled) return;
const provider = providers.find((candidate) => candidate.id === providerId);
if (!provider) {
setError('Unknown SSO provider.');
return;
}
setProviderName(provider.name);
if (!provider.configured) {
setError(`${provider.name} is not enabled in this deployment.`);
return;
}
if (provider.loginMode !== 'oidc') {
setError(`${provider.name} is not available for OIDC sign in.`);
return;
}
const result = await signIn.oauth2({
providerId: provider.id,
callbackURL,
});
if (!cancelled && result?.error) {
setError(result.error.message ?? `${provider.name} sign in failed.`);
}
} catch (caught: unknown) {
if (!cancelled) {
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
}
}
}
void redirectToProvider();
return () => {
cancelled = true;
};
}, [providerId, requestedCallbackURL]);
return (
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
<p className="mt-2 text-sm text-text-secondary">
{providerName
? `Redirecting you to ${providerName}...`
: 'Preparing your sign-in request...'}
</p>
{error ? (
<div
role="alert"
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
<p>{error}</p>
<Link
href="/login"
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
>
Return to login
</Link>
</div>
) : (
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
If the redirect does not start automatically, return to the login page and try again.
</div>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { ThemeProvider } from '@/providers/theme-provider';
import './globals.css';
export const metadata: Metadata = {
title: 'Mosaic',
description: 'Mosaic Stack Dashboard',
};
function themeScript(): string {
return `
(function () {
try {
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
} catch (error) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
`;
}
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
/>
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function HomePage(): never {
redirect('/chat');
}
@@ -0,0 +1,40 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSession } from '@/lib/auth-client';
interface AdminRoleGuardProps {
children: React.ReactNode;
}
export function AdminRoleGuard({ children }: AdminRoleGuardProps): React.ReactElement | null {
const { data: session, isPending } = useSession();
const router = useRouter();
const user = session?.user as
| (NonNullable<typeof session>['user'] & { role?: string })
| undefined;
useEffect(() => {
if (!isPending && !session) {
router.replace('/login');
} else if (!isPending && session && user?.role !== 'admin') {
router.replace('/');
}
}, [isPending, session, user?.role, router]);
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (!session || user?.role !== 'admin') {
return null;
}
return <>{children}</>;
}
+34
View File
@@ -0,0 +1,34 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSession } from '@/lib/auth-client';
interface AuthGuardProps {
children: React.ReactNode;
}
export function AuthGuard({ children }: AuthGuardProps): React.ReactElement | null {
const { data: session, isPending } = useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && !session) {
router.replace('/login');
}
}, [isPending, session, router]);
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (!session) {
return null;
}
return <>{children}</>;
}
@@ -1,3 +1,5 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ModelInfo } from '@/lib/types';
@@ -1,3 +1,5 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
import type { Conversation } from '@/lib/types';
@@ -1,3 +1,5 @@
'use client';
import {
forwardRef,
useCallback,
@@ -1,3 +1,5 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { cn } from '@/lib/cn';
@@ -1,3 +1,5 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
interface StreamingMessageProps {
@@ -1,3 +1,5 @@
'use client';
import type { ReactElement } from 'react';
import { formatAge, type FreshnessLabel } from '@/lib/freshness/model';
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSession } from '@/lib/auth-client';
interface GuestGuardProps {
children: React.ReactNode;
}
/** Redirects authenticated users away from auth pages. */
export function GuestGuard({ children }: GuestGuardProps): React.ReactElement | null {
const { data: session, isPending } = useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && session) {
router.replace('/chat');
}
}, [isPending, session, router]);
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (session) {
return null;
}
return <>{children}</>;
}
@@ -0,0 +1,239 @@
'use client';
import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { signOut, useSession } from '@/lib/auth-client';
interface AppHeaderProps {
conversationTitle?: string | null;
isSidebarOpen: boolean;
onToggleSidebar: () => void;
}
type ThemeMode = 'dark' | 'light';
const THEME_STORAGE_KEY = 'mosaic-chat-theme';
export function AppHeader({
conversationTitle,
isSidebarOpen,
onToggleSidebar,
}: AppHeaderProps): React.ReactElement {
const { data: session } = useSession();
const [currentTime, setCurrentTime] = useState('');
const [version, setVersion] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [theme, setTheme] = useState<ThemeMode>('dark');
useEffect(() => {
function updateTime(): void {
setCurrentTime(
new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
);
}
updateTime();
const interval = window.setInterval(updateTime, 60_000);
return () => window.clearInterval(interval);
}, []);
useEffect(() => {
fetch('/version.json')
.then(async (res) => res.json() as Promise<{ version?: string; commit?: string }>)
.then((data) => {
if (data.version) {
setVersion(data.commit ? `${data.version}+${data.commit}` : data.version);
}
})
.catch(() => setVersion(null));
}, []);
useEffect(() => {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
const nextTheme = storedTheme === 'light' ? 'light' : 'dark';
applyTheme(nextTheme);
setTheme(nextTheme);
}, []);
const handleThemeToggle = useCallback(() => {
const nextTheme = theme === 'dark' ? 'light' : 'dark';
applyTheme(nextTheme);
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
setTheme(nextTheme);
}, [theme]);
const handleSignOut = useCallback(async (): Promise<void> => {
await signOut();
window.location.href = '/login';
}, []);
const userLabel = session?.user.name ?? session?.user.email ?? 'Mosaic User';
const initials = useMemo(() => getInitials(userLabel), [userLabel]);
return (
<header
className="sticky top-0 z-20 border-b backdrop-blur-xl"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
borderColor: 'var(--color-border)',
}}
>
<div className="flex items-center justify-between gap-3 px-4 py-3 md:px-6">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
aria-label="Toggle conversation sidebar"
aria-expanded={isSidebarOpen}
>
</button>
<Link href="/chat" className="flex min-w-0 items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[var(--shadow-ms-md)]"
style={{
background:
'linear-gradient(135deg, var(--color-ms-blue-500), var(--color-ms-teal-500))',
}}
>
M
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="text-sm font-semibold text-[var(--color-text)]">Mosaic</div>
<div className="hidden h-5 w-px bg-[var(--color-border)] md:block" />
<div className="hidden items-center gap-2 md:flex">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-ms-teal-500)] opacity-60" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-ms-teal-500)]" />
</span>
<span className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
Online
</span>
</div>
</div>
</Link>
</div>
<div className="hidden min-w-0 items-center gap-3 md:flex">
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-2)]">
{currentTime || '--:--'}
</div>
<div className="max-w-[24rem] truncate text-sm font-medium text-[var(--color-text)]">
{conversationTitle?.trim() || 'New Session'}
</div>
{version ? (
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
v{version}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<div className="hidden items-center gap-2 lg:flex">
<ShortcutHint label="⌘/" text="focus" />
<ShortcutHint label="⌘K" text="focus" />
</div>
<button
type="button"
onClick={handleThemeToggle}
className="inline-flex h-10 items-center justify-center rounded-2xl border px-3 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
aria-label="Toggle theme"
>
{theme === 'dark' ? '☀︎' : '☾'}
</button>
<div className="relative">
<button
type="button"
onClick={() => setMenuOpen((prev) => !prev)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors hover:bg-white/5"
style={{
backgroundColor: 'var(--color-surface-2)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
aria-expanded={menuOpen}
aria-label="Open user menu"
>
{session?.user.image ? (
<img
src={session.user.image}
alt={userLabel}
className="h-full w-full rounded-full object-cover"
/>
) : (
initials
)}
</button>
{menuOpen ? (
<div
className="absolute right-0 top-12 min-w-56 rounded-3xl border p-2 shadow-[var(--shadow-ms-lg)]"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
}}
>
<div className="border-b px-3 py-2" style={{ borderColor: 'var(--color-border)' }}>
<div className="text-sm font-medium text-[var(--color-text)]">{userLabel}</div>
{session?.user.email ? (
<div className="text-xs text-[var(--color-muted)]">{session.user.email}</div>
) : null}
</div>
<div className="p-1">
<Link
href="/settings"
className="flex rounded-2xl px-3 py-2 text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
onClick={() => setMenuOpen(false)}
>
Settings
</Link>
<button
type="button"
onClick={() => void handleSignOut()}
className="flex w-full rounded-2xl px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
>
Sign out
</button>
</div>
</div>
) : null}
</div>
</div>
</div>
</header>
);
}
function ShortcutHint({ label, text }: { label: string; text: string }): React.ReactElement {
return (
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
<span className="font-medium text-[var(--color-text-2)]">{label}</span>
<span>{text}</span>
</span>
);
}
function getInitials(label: string): string {
const words = label.split(/\s+/).filter(Boolean).slice(0, 2);
if (words.length === 0) return 'M';
return words.map((word) => word.charAt(0).toUpperCase()).join('');
}
function applyTheme(theme: ThemeMode): void {
const root = document.documentElement;
if (theme === 'light') {
root.setAttribute('data-theme', 'light');
root.classList.remove('dark');
} else {
root.removeAttribute('data-theme');
root.classList.add('dark');
}
}
@@ -1,3 +1,5 @@
'use client';
import type { ReactNode } from 'react';
import { SidebarProvider, useSidebar } from './sidebar-context';
import { Sidebar } from './sidebar';
@@ -1,3 +1,5 @@
'use client';
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
interface SidebarContextValue {
+6 -3
View File
@@ -1,4 +1,7 @@
import { Link, useLocation } from 'react-router-dom';
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/cn';
import { MosaicLogo } from '@/components/ui/mosaic-logo';
import { useSidebar } from './sidebar-context';
@@ -96,7 +99,7 @@ const navItems: NavItem[] = [
];
export function Sidebar(): React.ReactElement {
const { pathname } = useLocation();
const pathname = usePathname();
const { mobileOpen, setMobileOpen } = useSidebar();
return (
@@ -134,7 +137,7 @@ export function Sidebar(): React.ReactElement {
return (
<Link
key={item.href}
to={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all duration-150',
@@ -1,3 +1,5 @@
'use client';
import { useTheme } from '@/providers/theme-provider';
interface ThemeToggleProps {
+5 -3
View File
@@ -1,4 +1,6 @@
import { useNavigate } from 'react-router-dom';
'use client';
import { useRouter } from 'next/navigation';
import { signOut, useSession } from '@/lib/auth-client';
import { ThemeToggle } from './theme-toggle';
import { useSidebar } from './sidebar-context';
@@ -20,12 +22,12 @@ function MenuIcon(): React.JSX.Element {
export function Topbar(): React.ReactElement {
const { data: session } = useSession();
const navigate = useNavigate();
const router = useRouter();
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
async function handleSignOut(): Promise<void> {
await signOut();
navigate('/login', { replace: true });
router.replace('/login');
}
function handleSidebarToggle(): void {
@@ -1,3 +1,5 @@
'use client';
import { cn } from '@/lib/cn';
import type { Mission, MissionStatus } from '@/lib/types';
@@ -1,3 +1,5 @@
'use client';
interface PrdViewerProps {
content: string;
}
@@ -1,3 +1,5 @@
'use client';
import { cn } from '@/lib/cn';
import type { Project } from '@/lib/types';
@@ -1,3 +1,5 @@
'use client';
import type { Task, TaskStatus } from '@/lib/types';
import { TaskCard } from './task-card';
@@ -1,3 +1,5 @@
'use client';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
@@ -1,3 +1,5 @@
'use client';
import { useEffect, useRef } from 'react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
@@ -1,3 +1,5 @@
'use client';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
@@ -1,3 +1,5 @@
'use client';
import { cn } from '@/lib/cn';
import type { Task, TaskStatus } from '@/lib/types';
@@ -1,3 +1,5 @@
'use client';
import type { CSSProperties } from 'react';
export interface MosaicLogoProps {
+1 -1
View File
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { ThemeProvider } from '@/providers/theme-provider';
import { createAppRouter } from '@/routes';
import '@/globals.css';
import '@/app/globals.css';
const container = document.getElementById('root');
if (!container) {
@@ -1,3 +1,5 @@
'use client';
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
export type Theme = 'dark' | 'light';
+15 -33
View File
@@ -13,18 +13,8 @@ import {
TasksRouteErrorBoundary,
} from '@/spa/pages/resource-route-error-boundaries';
import { TasksPage } from '@/spa/pages/tasks';
import { SettingsPage } from '@/spa/pages/settings';
import { AdminPage } from '@/spa/pages/admin';
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
import { AppShell } from '@/components/layout/app-shell';
function DashboardLayout(): ReactElement {
return (
<AppShell>
<Outlet />
</AppShell>
);
}
import { AuthGuard, GuestGuard } from '@/spa/guards';
import { Placeholder } from '@/spa/placeholder';
function GuestLayout(): ReactElement {
return (
@@ -53,29 +43,21 @@ export const routes: RouteObject[] = [
{
element: <AuthGuard />,
children: [
{ path: '/', element: <Navigate to="/chat" replace /> },
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
{
element: <DashboardLayout />,
children: [
{ path: '/', element: <Navigate to="/chat" replace /> },
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
{
path: '/projects',
element: <ProjectsPage />,
errorElement: <ProjectsRouteErrorBoundary />,
},
{
path: '/projects/:id',
element: <ProjectDetailPage />,
errorElement: <ProjectDetailRouteErrorBoundary />,
},
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
{ path: '/settings', element: <SettingsPage /> },
{
element: <AdminGuard />,
children: [{ path: '/admin', element: <AdminPage /> }],
},
],
path: '/projects',
element: <ProjectsPage />,
errorElement: <ProjectsRouteErrorBoundary />,
},
{
path: '/projects/:id',
element: <ProjectDetailPage />,
errorElement: <ProjectDetailRouteErrorBoundary />,
},
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
{ path: '/settings', element: <Placeholder title="Settings" /> },
{ path: '/admin', element: <Placeholder title="Admin" /> },
],
},
];
-20
View File
@@ -21,23 +21,3 @@ export function AuthGuard(): ReactElement {
return session ? <Outlet /> : <Navigate to="/login" replace />;
}
export function AdminGuard(): ReactElement {
const { data: session, isPending } = useSession();
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (!session) {
return <Navigate to="/login" replace />;
}
const user = session.user as typeof session.user & { role?: string };
return user.role === 'admin' ? <Outlet /> : <Navigate to="/" replace />;
}
-206
View File
@@ -1,206 +0,0 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
const { apiMock, useSessionMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
useSessionMock: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
api: apiMock,
}));
vi.mock('@/lib/auth-client', () => ({
useSession: useSessionMock,
authClient: {},
}));
import { AdminPage } from './admin';
import { AdminGuard } from '@/spa/guards';
const userFixtures = {
users: [
{
id: 'u-admin',
name: 'Ada Admin',
email: '[email protected]',
role: 'admin',
banned: false,
banReason: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
{
id: 'u-member',
name: 'Mel Member',
email: '[email protected]',
role: 'member',
banned: true,
banReason: 'spam',
createdAt: '2026-08-02T00:00:00.000Z',
updatedAt: '2026-08-02T00:00:00.000Z',
},
],
total: 2,
};
let root: Root | null = null;
let container: HTMLDivElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
apiMock.mockReset();
useSessionMock.mockReset();
});
async function renderAdminRoute(): Promise<void> {
const routes: RouteObject[] = [
{
element: <AdminGuard />,
children: [{ path: '/admin', element: <AdminPage /> }],
},
{ path: '/', element: <div>home page</div> },
{ path: '/login', element: <div>login page</div> },
];
const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<RouterProvider router={router} />);
});
}
function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
return {
data: { user: { id: 'u-1', name: 'Test', email: '[email protected]', role } },
isPending: false,
};
}
describe('AdminGuard', () => {
it('redirects unauthenticated visitors to /login', async () => {
useSessionMock.mockReturnValue({ data: null, isPending: false });
await renderAdminRoute();
expect(container.textContent).toContain('login page');
expect(apiMock).not.toHaveBeenCalled();
});
it('redirects non-admin users to /', async () => {
useSessionMock.mockReturnValue(sessionWithRole('member'));
await renderAdminRoute();
expect(container.textContent).toContain('home page');
expect(apiMock).not.toHaveBeenCalled();
});
it('renders the admin page for admin users', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValueOnce(userFixtures);
await renderAdminRoute();
expect(container.textContent).toContain('Admin Panel');
});
});
describe('AdminPage users tab', () => {
it('lists users with role and ban status after load', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValueOnce(userFixtures);
await renderAdminRoute();
expect(apiMock).toHaveBeenCalledWith('/api/admin/users');
expect(container.textContent).toContain('Ada Admin');
expect(container.textContent).toContain('Mel Member');
expect(container.textContent).toContain('Banned');
expect(container.textContent).toContain('2 user(s)');
});
it('shows the load error with a retry control', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockRejectedValueOnce(new Error('gateway unavailable'));
await renderAdminRoute();
expect(container.textContent).toContain('gateway unavailable');
apiMock.mockResolvedValueOnce(userFixtures);
const retry = [...container.querySelectorAll('button')].find((b) =>
b.textContent?.includes('Retry'),
);
expect(retry).toBeTruthy();
await act(async () => {
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.textContent).toContain('Ada Admin');
});
it('posts to the ban endpoint and reloads on Ban', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValue(userFixtures);
await renderAdminRoute();
const banButton = [...container.querySelectorAll('button')].find(
(b) => b.textContent === 'Ban',
);
expect(banButton).toBeTruthy();
await act(async () => {
banButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(apiMock).toHaveBeenCalledWith('/api/admin/users/u-admin/ban', { method: 'POST' });
});
});
describe('AdminPage health tab', () => {
it('loads health status when the tab is opened', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValueOnce(userFixtures).mockResolvedValueOnce({
status: 'ok',
database: { status: 'ok', latencyMs: 3 },
cache: { status: 'ok', latencyMs: 1 },
agentPool: { activeSessions: 2 },
providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 4 }],
checkedAt: '2026-08-26T00:00:00.000Z',
});
await renderAdminRoute();
const healthTab = [...container.querySelectorAll('button')].find((b) =>
b.textContent?.includes('System Health'),
);
await act(async () => {
healthTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(apiMock).toHaveBeenCalledWith('/api/admin/health');
expect(container.textContent).toContain('Database (PostgreSQL)');
expect(container.textContent).toContain('Active sessions: 2');
expect(container.textContent).toContain('4 models');
});
});
@@ -11,7 +11,6 @@ vi.mock('@/lib/auth-client', () => ({
useSession: useSessionMock,
}));
import { ThemeProvider } from '@/providers/theme-provider';
import { routes } from '@/routes';
beforeAll(() => {
@@ -74,11 +73,7 @@ describe('ChatRouteErrorBoundary', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root?.render(
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>,
);
root?.render(<RouterProvider router={router} />);
});
expect(consoleErrorSpy).toHaveBeenCalled();
@@ -11,7 +11,6 @@ vi.mock('@/lib/auth-client', () => ({
useSession: useSessionMock,
}));
import { ThemeProvider } from '@/providers/theme-provider';
import { routes } from '@/routes';
function Boom(): never {
@@ -72,11 +71,7 @@ describe('resource route error boundaries', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await act(async () => {
root?.render(
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>,
);
root?.render(<RouterProvider router={router} />);
});
expect(consoleErrorSpy).toHaveBeenCalled();
-178
View File
@@ -1,178 +0,0 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
useSessionMock: vi.fn(),
updateUserMock: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
api: apiMock,
}));
vi.mock('@/lib/auth-client', () => ({
useSession: useSessionMock,
authClient: { updateUser: updateUserMock },
}));
import { SettingsPage } from './settings';
let root: Root | null = null;
let container: HTMLDivElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
apiMock.mockReset();
useSessionMock.mockReset();
updateUserMock.mockReset();
});
async function renderSettingsPage(): Promise<void> {
const routes: RouteObject[] = [{ path: '/settings', element: <SettingsPage /> }];
const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<RouterProvider router={router} />);
});
}
function clickButtonByText(text: string): Promise<void> {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) {
throw new Error(`Button containing "${text}" not found`);
}
return act(async () => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
const session = {
user: { id: 'u-1', name: 'Test User', email: '[email protected]', image: null },
};
describe('SettingsPage profile tab', () => {
it('renders the profile form from the session and saves via authClient', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
updateUserMock.mockResolvedValue({});
await renderSettingsPage();
const nameInput = container.querySelector<HTMLInputElement>('#profile-name');
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
expect(nameInput?.value).toBe('Test User');
expect(emailInput?.value).toBe('[email protected]');
expect(emailInput?.disabled).toBe(true);
await clickButtonByText('Save changes');
expect(updateUserMock).toHaveBeenCalledWith({ name: 'Test User', image: null });
expect(container.textContent).toContain('Saved!');
});
it('surfaces an update failure without clearing the form', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
updateUserMock.mockResolvedValue({ error: { message: 'name rejected' } });
await renderSettingsPage();
await clickButtonByText('Save changes');
expect(container.textContent).toContain('name rejected');
expect(container.querySelector<HTMLInputElement>('#profile-name')?.value).toBe('Test User');
});
});
describe('SettingsPage appearance tab', () => {
it('loads preferences and posts each changed preference on save', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
apiMock.mockImplementation((path: string) =>
path.startsWith('/api/memory/preferences?')
? Promise.resolve([{ key: 'ui.theme', value: 'dark', category: 'appearance' }])
: Promise.resolve({}),
);
await renderSettingsPage();
await clickButtonByText('Appearance');
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences?category=appearance');
await clickButtonByText('Save changes');
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences', {
method: 'POST',
body: { key: 'ui.theme', value: 'dark', category: 'appearance', source: 'user' },
});
});
});
describe('SettingsPage providers tab', () => {
it('loads LLM and SSO providers and runs a connection test', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
apiMock.mockImplementation((path: string, opts?: { method?: string }) => {
if (path === '/api/providers' && opts === undefined) {
return Promise.resolve([
{
id: 'ollama',
name: 'Ollama',
available: true,
models: [
{
id: 'llama3.2',
provider: 'ollama',
name: 'Llama 3.2',
reasoning: false,
contextWindow: 128_000,
maxTokens: 4096,
inputTypes: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
},
]);
}
if (path === '/api/sso/providers') {
return Promise.resolve([]);
}
if (path === '/api/providers/test') {
return Promise.resolve({ providerId: 'ollama', reachable: true, latencyMs: 12 });
}
return Promise.resolve([]);
});
await renderSettingsPage();
await clickButtonByText('Providers');
expect(container.textContent).toContain('Ollama');
expect(container.textContent).toContain('1 model');
await clickButtonByText('Test');
expect(apiMock).toHaveBeenCalledWith('/api/providers/test', {
method: 'POST',
body: { providerId: 'ollama' },
});
expect(container.textContent).toContain('Reachable');
});
});
-20
View File
@@ -21,23 +21,3 @@ for (const target of [globalThis, window]) {
},
});
}
// jsdom (v29) does not implement window.matchMedia; the sidebar layout uses it
// for its mobile breakpoint. Minimal always-desktop stub.
if (typeof window.matchMedia !== 'function') {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string): MediaQueryList =>
({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
});
}
+4 -4
View File
@@ -1,16 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"target": "ES2017",
"lib": ["dom", "dom.iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts", "vitest.config.ts"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
}
+4
View File
@@ -7,6 +7,10 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
esbuild: {
jsx: 'automatic',
},
test: {
globals: true,
environment: 'jsdom',
-2
View File
@@ -10,8 +10,6 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/appservice/package.json ./apps/appservice/
COPY packages/ ./packages/
COPY plugins/ ./plugins/
# the root prepare script runs scripts/install-hooks.mjs on install
COPY scripts/ ./scripts/
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
+2 -7
View File
@@ -8,16 +8,14 @@ WORKDIR /app
# Copy workspace manifests first for layer-cached install
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/gateway/package.json ./apps/gateway/
COPY apps/web/package.json ./apps/web/
COPY packages/ ./packages/
COPY plugins/ ./plugins/
# the root prepare script runs scripts/install-hooks.mjs on install
COPY scripts/ ./scripts/
RUN pnpm install --frozen-lockfile
COPY . .
# Build gateway, the web SPA bundle it serves (#1444), and all of their
# workspace dependencies via the turbo dependency graph
RUN pnpm turbo run build --filter @mosaicstack/gateway... --filter @mosaicstack/web...
# Build gateway and all of its workspace dependencies via turbo dependency graph
RUN pnpm turbo run build --filter @mosaicstack/gateway...
# Produce a self-contained deploy artifact: flat node_modules, no pnpm symlinks
# --legacy is required for pnpm v10 when inject-workspace-packages is not set
RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
@@ -40,9 +38,6 @@ COPY --chown=node:node --from=builder /deploy/package.json ./package.json
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
# copy from builder explicitly as belt-and-suspenders
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
# The built web SPA bundle; served by the gateway (apps/gateway/src/spa/serve-spa.ts)
COPY --chown=node:node --from=builder /app/apps/web/dist ./web-dist
ENV WEB_DIST_DIR=/app/web-dist
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
EXPOSE 14242
USER node
+24
View File
@@ -0,0 +1,24 @@
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS builder
WORKDIR /app
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/ ./packages/
# the root prepare script runs scripts/install-hooks.mjs on install
COPY scripts/ ./scripts/
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm --filter @mosaicstack/web build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["node", "apps/web/server.js"]
-9
View File
@@ -882,12 +882,3 @@ Objective: for alpha 0.0.50, the release cannot publish, report, or display work
### Out of scope
The canonical dispatcher/control-plane vertical slice (work graph, execution attempts, fenced leases, typed check-in, independent verifier dispatch) is decided post-alpha (SDLC-D-033, option B). Multi-pipeline verification certificates (SDLC-D-034 option B) are post-alpha. Full AF-1..AF-4 objective matrices and Mission Control portfolio surfaces are post-alpha.
## Official CLI Capability and Tool Migration Workstream (T78)
Normative contract on integration trunk `next`:
[docs/requirements/cli-capability-migration.md](./requirements/cli-capability-migration.md):
migrates agent-facing operations from directly invoked scripts into documented, first-class
`mosaic` CLI command groups, together with the central-registry resolver, capability catalog,
adapter boundary, and phased legacy-tool-tree decommission the migration requires. The contract
carries its own implementation hold and delivery stages.
+1 -3
View File
@@ -12,9 +12,7 @@ design; scoping one requires its own PRD section or requirements doc plus
review.
Phases are product phases. The in-flight platform workstreams (KBN-100/101
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, T78 CLI
capability migration
([requirements](./requirements/cli-capability-migration.md)), and the other
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
Part II contracts in the PRD) run as parallel tracks under their own issues
and are prerequisites where noted.
-1
View File
@@ -18,7 +18,6 @@
- [Active task rollup](TASKS.md) — orchestrator-owned work state; workers do not modify it.
- [MVP mission manifest](MISSION-MANIFEST.md) — control-plane mission rollup; activity and status remain under its authorized owner.
- [Documentation catalog and truth audit](reports/documentation/2026-08-10-docs-catalog-audit.md) — complete baseline inventory, evidence labels, broken-link clusters, and migration recommendations.
- [CLI capability migration requirements](requirements/cli-capability-migration.md): T78 official CLI capability and tool migration contract, normative contract with implementation hold (M0).
## Protected current authority and executable books
-14
View File
@@ -212,20 +212,6 @@ Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
### E2E Gate (#1445, P6)
Trunk publish pipelines run a headless Playwright suite (`e2e` step) before any image publishes: the built gateway `dist` boots on a throwaway embedded PGlite database (isolated via a fresh `HOME`), serves the built SPA bundle through `WEB_DIST_DIR` — the same serving path the gateway image ships — and the suite runs against it inside the pinned `mcr.microsoft.com/playwright` image. `E2E_REQUIRE_SEEDED_AUTH=1` makes login failures hard failures (the skip-when-login-fails guards are a live-environment affordance only). Both image build steps depend on this gate.
Reproduce locally (Ubuntu-based environments; Fedora's headless-shell rendering is broken):
```bash
pnpm build
BETTER_AUTH_SECRET="$(head -c 32 /dev/urandom | base64)" GATEWAY_PORT=14242 \
WEB_DIST_DIR="$PWD/apps/web/dist" HOME="$(mktemp -d)" node apps/gateway/dist/main.js &
E2E_REQUIRE_SEEDED_AUTH=1 PLAYWRIGHT_BASE_URL=http://localhost:14242 \
pnpm --filter @mosaicstack/web exec playwright test
```
---
## Adding New Agent Tools
File diff suppressed because it is too large Load Diff
@@ -1,748 +0,0 @@
---
kind: spec
status: active
source_of_truth: true
---
# Official Mosaic CLI Capability and Tool Migration
- **Workstream:** T78
- **Status:** active requirements contract, implementation held by the M0 gates
- **Decision authority:** Jason Woltje
- **Design owner:** Vision
- **Integration trunk:** `next`
This contract is authoritative only on the integration trunk `next`. Branch copies are proposals.
Publication does not authorize implementation until the M0 milestone, task-graph, interface, and
partition gates pass.
## 1. Purpose
Migrate agent-facing operations from directly invoked scripts into documented, first-class command
groups in the existing TypeScript and Node.js `mosaic` CLI. The CLI becomes the stable interface
for operators, agents, the webUI, future seat containers, and future `mosaicd` execution.
The mission also phases out the installed `~/.config/mosaic/tools` script surface. Existing scripts
may remain private compatibility adapters only while measured consumers still require them.
## 2. Product alignment
Items 1 through 3 implement PRD D8 and D12:
1. The CLI is the primary execution surface.
2. The webUI uses Gateway APIs backed by the same official capability contracts.
3. A missing official capability is built before a webUI bypass is accepted.
This contract adds one explicit extension beyond D8 and D12: no harness, skill, or agent receives a
separate business-logic path around the CLI and Gateway capability contract.
This contract does not replace the fleet north star, issue `#1382`, the fleet configuration
contract `#758`, the exact fleet communications contract `#766`, or future container and `mosaicd`
specifications. It defines the interfaces those tracks consume.
## 3. Fixed decisions
| ID | Decision |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| T78-D1 | Extend the existing official TypeScript and Node.js `mosaic` CLI. A second Python or shell entrypoint is forbidden. |
| T78-D2 | Expose documented groups such as `mosaic git`, `mosaic comms`, and `mosaic ci`. A generic public `mosaic tools` passthrough is forbidden. |
| T78-D3 | Resolve homes, endpoints, sockets, tool locations, and runtime paths through the central registry and one typed resolver. Commands do not hard-code them. |
| T78-D4 | One rootless container per seat is the target sandbox. It has a read-only root filesystem, no container-runtime socket, and lifecycle through future `mosaicd`. |
| T78-D5 | Dispatch is per-site. Localhost `orch-01` alone dispatches USC-seat implementation. Homelab `orch-01` alone dispatches homelab-seat implementation and homelab-owned surfaces. |
| T78-D6 | Tmux and fleet-comms remain temporary communications adapters behind a transport-neutral CLI contract. |
| T78-D7 | Decommissioning is phased and mechanically enforced. Removal requires zero measured consumers and a discriminating planted-reference control. |
Derived security boundary:
- `~/.mosaic/tools` is canonical working source during migration. It is not automatically trusted
runtime installation state.
- Reviewed source is promoted into installed or packaged runtime artifacts.
- A multi-writer brain-repository push must not silently replace credential-bearing executable code
used by every seat.
## 4. Explicitly rejected alternatives
| Alternative | Rejection reason |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Separate Python CLI | Creates a second contract, release path, and policy surface. |
| Public `mosaic tools <script>` passthrough | Preserves script names and paths as the API instead of defining capabilities. |
| CLI allowlists as the sandbox | Parser allowlists do not isolate files, credentials, processes, networks, or container control. |
| Execute the synced working tree as the final runtime | A brain push would become host-wide code execution authority. |
| Big-bang script rewrite and deletion | Mature queue, identity, credential, and uncertainty behavior would be changed without parity evidence. |
| Tmux-shaped communications API | It would force future Matrix or native transports to preserve tmux concepts. |
## 5. Terminology
- **Central registry:** the schema-v1 `config.json` authority filed in issue `#1382`.
- **Registry resolver:** the typed reader that validates and resolves central-registry values.
- **Capability catalog:** the typed inventory of public capability identifiers and behavior. It is
not the central registry.
- **Capability policy:** data that maps verified actor and lane identity to allowed capabilities and
scopes.
- **Local adapter:** a temporary in-process or private-script implementation used before `mosaicd`
is available.
- **Broker adapter:** the future client transport to `mosaicd` outside the seat container.
- **Installed legacy tree:** `~/.config/mosaic/tools`.
- **Canonical working source:** `~/.mosaic/tools` during the migration period.
- **Runtime artifact:** reviewed package or installed bytes actually executed by a seat.
## 6. Public CLI grammar
### CLI-REQ-001: First-class command groups
The official help surface MUST register domain groups directly:
```text
mosaic git ...
mosaic comms ...
mosaic ci ...
```
Future domains MAY include `infra`, `identity`, and other reviewed capability families. They MUST
NOT appear through a generic script dispatcher.
### CLI-REQ-002: Stable command shape
New capability commands use this grammar:
```text
mosaic <domain> <resource> <verb> [target] [options]
```
The first pilot freezes these paths:
```text
mosaic git issue list
mosaic git issue view <number>
mosaic git issue comment <number> --input <path|->
```
Capability identifiers are independent from display text:
| Command | Capability ID | Class |
| -------------------------- | ------------------- | ---------------- |
| `mosaic git issue list` | `git.issue.list` | read |
| `mosaic git issue view` | `git.issue.view` | read |
| `mosaic git issue comment` | `git.issue.comment` | bounded mutation |
Renaming a command path does not silently rename its capability identifier. Either change requires a
versioned compatibility decision.
### CLI-REQ-003: Common targeting options
The pilot supports:
- `--instance <name>` for the configured provider instance.
- `--repo <owner/name>` for the provider repository.
- `--format <table|json>` for output selection.
- `--correlation-id <id>` for a caller-supplied valid identifier. Omission generates one.
- `--idempotency-key <key>` for mutations. Omission generates one and returns it.
An instance may be inferred only when the registry has exactly one valid instance for that domain.
A repository may be inferred only from a validated current repository declaration and an
unambiguous canonical remote. Ambiguity fails closed and names the missing field.
No public option forces local compatibility mode when policy selected broker mode. A caller cannot
downgrade the execution boundary.
### CLI-REQ-004: Mutation input
`git.issue.comment` reads its body from `--input <path>` or stdin with `--input -`. The CLI MUST:
1. reject a missing or empty body.
2. apply a documented byte limit before provider access.
3. never place the body in process arguments, diagnostics, or audit metadata.
4. compute a body digest for read-back verification without exposing the body.
5. avoid automatic retry after an uncertain provider mutation.
### CLI-REQ-005: Structured result envelope
JSON output uses one versioned envelope:
```ts
interface CapabilityResultV1<T> {
schemaVersion: 1;
capabilityId: string;
status: 'succeeded' | 'invalid' | 'denied' | 'failed' | 'uncertain' | 'unavailable';
executionMode: 'local-adapter' | 'mosaicd';
identityTrust: 'local-asserted' | 'runtime-verified';
correlationId: string;
idempotencyKey?: string;
target: Record<string, string | number | boolean | null>;
data?: T;
diagnostics: Array<{
code: string;
message: string;
field?: string;
retryable: boolean;
}>;
audit:
| { authority: 'mosaicd'; recorded: true; eventId: string }
| { authority: 'none'; recorded: false; localEventId?: string };
}
```
`target` and `diagnostics` contain no credentials or unbounded provider body. Table output is a
human view of the same result and cannot carry a different verdict.
### CLI-REQ-006: Exit behavior
| Exit | Meaning |
| ---: | --------------------------------------------------------------------------- |
| 0 | `succeeded` |
| 2 | `invalid`: invalid input, invalid configuration, or unsupported schema |
| 3 | `denied` by capability or scope policy |
| 4 | `failed` with a confirmed non-success outcome |
| 5 | `uncertain`, including a mutation whose provider result cannot be confirmed |
| 6 | `unavailable`, including missing broker, credentials, or required adapter |
A provider HTTP success alone is insufficient. The adapter validates the expected response shape.
A mutation that may have landed but lacks confirmation returns exit 5 and is never described as
failed or safe to retry. For a provider-native idempotent mutation, manual reconciliation MAY retry
the same key. For `uncertain-no-retry`, help directs the caller to a read-back check and forbids
mutation retry.
### CLI-REQ-007: Help and discovery
The capability catalog generates or validates:
- `mosaic --help` command-group listing.
- group and command help.
- stable capability identifiers.
- machine-readable capability discovery.
- documentation tables.
- policy-generation inputs.
- tests that reject undocumented public commands and orphaned capabilities.
## 7. Central registry resolver
### CFG-REQ-001: One distinct resolver
Implement one exported resolver named `MosaicRegistryResolver` or another name explicitly approved
in the contract review. It MUST NOT be named `ConfigService`. The existing
`packages/mosaic/src/config/config-service.ts` exports `ConfigService` for SOUL, USER, and TOOLS
content and remains a separate concern.
### CFG-REQ-002: Frozen schema consumption
The resolver consumes schema v1 from issue `#1382` without creating parallel keys. Every key is
optional. The exact v1 surface is:
- `$schema`, with the known marker `mosaic-config-v1`.
- `mosaicHome`, reserved, null, and without a v1 consumer.
- `brainHome`, default `~/.mosaic`.
- `instances.gitea.<name>.url`.
- `fleet.socket`.
- `harnessConfig.pi.agentDir`.
- `harnessConfig.claude.configDir`.
- `harnessConfig.claude.secureStorageDir`.
Credential values, model and effort defaults, and `fleet.rosterPath` are forbidden. A non-null
`mosaicHome` value fails validation because v1 reserves the field without implementing relocation.
An absent or null `$schema` is interpreted as v1, the exact `mosaic-config-v1` marker is accepted,
and every other non-null marker fails before value resolution.
Absent or null values select the framework default. A `~` path prefix expands at read time and is
never rewritten into the user file. Unknown top-level and nested keys warn loudly and are ignored
for rolling-version compatibility. Every warning and machine-readable diagnostic names the full
ignored key path, so a typo is visible at every read.
Fail-closed read behavior applies to invalid JSON, a failed C1 version check, a known key with an
invalid type or value, and a present but empty or invalid override. An optional
`mosaic registry validate` lint mode MAY reject unknown keys for operator validation, but the normal
resolver read path does not. This top-level group is separate from the existing `mosaic config`
commands backed by `ConfigService`.
### CFG-REQ-003: Resolution precedence
For each supported value, resolution follows exactly:
1. the schema-defined `MOSAIC_<KEY>_OVERRIDE` environment override.
2. validated `config.json` value.
3. one centralized framework default, when the key defines a default.
A present override always wins. An empty or invalid override fails and does not fall through to the
file or default. `$schema` and reserved `mosaicHome` have no environment override. Consumed values
use this collision-free mapping:
| Registry key | Environment override |
| --------------------------------------- | ---------------------------------------------------------- |
| `brainHome` | `MOSAIC_BRAIN_HOME_OVERRIDE` |
| `fleet.socket` | `MOSAIC_FLEET_SOCKET_OVERRIDE` |
| `harnessConfig.pi.agentDir` | `MOSAIC_HARNESS_CONFIG_PI_AGENT_DIR_OVERRIDE` |
| `harnessConfig.claude.configDir` | `MOSAIC_HARNESS_CONFIG_CLAUDE_CONFIG_DIR_OVERRIDE` |
| `harnessConfig.claude.secureStorageDir` | `MOSAIC_HARNESS_CONFIG_CLAUDE_SECURE_STORAGE_DIR_OVERRIDE` |
| `instances.gitea.<name>.url` | `MOSAIC_INSTANCES_GITEA_<NAME>_URL_OVERRIDE` |
Gitea instance names match `[a-z][a-z0-9-]*`. The override name uppercases the instance name and
maps hyphen to underscore. Underscores are not valid in source instance names, so two valid names
cannot flatten to the same override.
A value without a valid result fails before adapter or provider access. Invalid known URLs, socket
names, paths, and value types fail closed. Unknown keys follow CFG-REQ-002.
### CFG-REQ-004: Typed provenance
Every resolved value carries non-secret provenance:
```ts
type RegistryValueSource = 'override' | 'registry' | 'framework-default';
interface ResolvedRegistryValue<T> {
key: string;
value: T;
source: RegistryValueSource;
schemaVersion: 1;
}
```
Machine-readable diagnostics include the full path of every ignored unknown key. Diagnostics may
name a known key and source class. They do not emit credential values or unrelated configuration.
### CFG-REQ-005: Bootstrap and path safety
Registry discovery is the fixed path `~/.config/mosaic/config.json`. It has no v1 search path and no
alternate location. The file is the one user-updatable path inside `~/.config/mosaic` and is
protected by a deny-wins upgrade carve-out. Upgrades never overwrite user edits.
This fixed bootstrap avoids circular dependence on reserved `mosaicHome`. A seat container reads its
own internal `~/.config/mosaic/config.json`, supplied by the container topology, rather than a host
path or a relocation flag. Path values are expanded, normalized, validated, and tested under at
least two distinct home roots.
No command embeds home directories, script locations, provider endpoints, seat paths, or tmux socket
names outside the resolver and its reviewed defaults.
### CFG-REQ-006: Schema evolution
A new key requires:
1. a named consumer.
2. a `#1382` schema amendment.
3. joint ACK from the frozen-schema and resolver-contract custodians until handoff, recorded by
custodian-authored commits rather than relayed tokens alone.
4. parser, invalid-input, default, and two-root tests.
5. documentation in the same reviewed change.
Speculative keys are forbidden.
### CFG-REQ-007: Joint freeze evidence
The v1 resolver contract is jointly frozen:
- Fred, frozen-schema custodian, accepted C1 and C3 through token
`CLI-T78-REGISTRY-FREEZE ACCEPT`, then accepted amended C2 through token
`CLI-T78-REGISTRY-C2 ACCEPT`.
- Homelab `orch-01`, issue and resolver-contract custodian, accepted C1 and C3 and supplied the
adopted C2 rolling-version amendment in the fleet-comms repository, message
`sites/usc/20260827T004509Z__to-vision__from-homelab.orch-01__683e4c.md`, blob
`35a7c4c1e54eb9196abeef3135d211ee1dfc46db`.
Durable lane provenance is recorded in the Mosaic brain repository at
`fleet/lanes/cli-migration/registry-freeze-evidence.md` and the independent custodian-authored
`fleet/lanes/cli-migration/registry-freeze-fred-ack.md`. Schema evolution after this freeze still
follows CFG-REQ-006.
## 8. Capability catalog and policy
### CAP-REQ-001: One typed catalog
Each capability definition records:
```ts
type CapabilityEffect = 'read' | 'bounded-mutation' | 'privileged-mutation';
interface CapabilityDefinitionV1 {
id: string;
commandPath: readonly string[];
effect: CapabilityEffect;
targetSchema: string;
inputSchema: string;
outputSchema: string;
credentialClass: string | null;
requiredScopes: readonly string[];
auditRequired: boolean;
timeoutMs: number;
idempotency: 'read' | 'required-key' | 'provider-native' | 'uncertain-no-retry';
adapterId: string;
deprecation: 'active' | 'deprecated' | 'removed';
}
```
The catalog is data consumed by the parser, help, policy, documentation, and tests. Command handlers
must not maintain independent copies of these facts.
### CAP-REQ-002: Policy is not parser logic
Capability grants map verified actor identity and lane to capability IDs and resource scopes. They
are data. A named seat receives no authority from its name alone.
The user-editable central registry is placement and endpoint configuration, not authorization
policy. It MUST NOT contain lane grants or let a seat self-grant capability scope. Target authority
lives in the `mosaicd` control-plane store outside seat containers and returns a policy revision and
digest with every decision.
Before `mosaicd`, local compatibility mode may evaluate a package-owned policy for behavior and test
parity, but it reports locally asserted identity and makes no broker-grade authorization claim. Mode
selection is declared by topology and policy, never inferred from broker availability. A missing or
unhealthy required broker returns `unavailable`. It never falls back to local mode.
A capability using a shared, service, operator, or admin credential is broker-only. Local mode may
use only the acting seat's own credential against a registry endpoint. Privileged infrastructure,
merge, deployment, identity, authorization, and secret-management cutover requires `mosaicd`. A
future policy-store key or broker endpoint still requires CFG-REQ-006 and the `mosaicd` topology
contract.
### CAP-REQ-003: Identity trust
CLI arguments and ordinary environment variables are actor hints, not authorization identity. The
local adapter reports that identity is locally asserted and MUST NOT claim broker-grade
authorization. `mosaicd` derives or verifies actor identity from the authenticated seat runtime.
### CAP-REQ-004: Positive and denied controls
Every capability test includes:
1. an allowed request with expected result.
2. a denied request differing only in the relevant lane or scope.
3. a malformed target or configuration denial.
4. a credential-redaction assertion.
5. a verdict-discrimination control that proves the test can fail.
## 9. Adapter and broker contract
### EXE-REQ-001: One capability request
```ts
interface CapabilityRequestV1 {
schemaVersion: 1;
capabilityId: string;
actorHint?: { seat?: string; lane?: string };
target: Record<string, string | number | boolean | null>;
arguments: Record<string, string | number | boolean | null>;
correlationId: string;
idempotencyKey?: string;
}
```
Credential values and unbounded comment bodies are not serialized into audit-safe request metadata.
Body content travels through a bounded private input channel appropriate to the adapter.
### EXE-REQ-002: Local compatibility adapter
The local adapter MAY call a reviewed in-process implementation or a private script adapter. It
MUST preserve existing queue guards, wrapper-first behavior, credential resolution, response
validation, and mutation uncertainty. It reports `executionMode: local-adapter` and
`identityTrust: local-asserted`.
Private child adapters receive bodies and credentials only through stdin, owner-only temporary
files, or inherited file descriptors, never child-process arguments. Captured child stderr, shell
trace, and diagnostics are inside the redaction boundary. Local results always use
`audit: { authority: 'none', recorded: false }`. A local event identifier is not authoritative
audit evidence.
The local adapter is compatibility, not a sandbox or authorization claim.
### EXE-REQ-003: `mosaicd` broker adapter
The broker adapter sends the same logical request to `mosaicd` outside the seat container.
`mosaicd` owns:
- authoritative seat identity, recorded in audit from the derived runtime identity rather than
`actorHint`.
- capability and scope authorization.
- credential resolution.
- operation execution.
- output sanitization.
- audit persistence.
- bounded timeout and cancellation behavior.
A contradictory `actorHint` produces a diagnostic and never replaces the derived actor. Broker
results report `identityTrust: runtime-verified`. `audit.recorded: true` is valid only after
`mosaicd` confirms persistence and returns its event ID. Consumers verify authoritative evidence
against the broker trail, not the seat-produced envelope alone.
The transport and endpoint are supplied by immutable container topology and the reviewed central
registry contract. No command hard-codes a daemon socket.
### EXE-REQ-004: Packaged implementation boundary
The initial TypeScript layout is:
- `packages/mosaic/src/central-registry/` for `MosaicRegistryResolver`, schema, and provenance.
- `packages/mosaic/src/capabilities/` for catalog, request, result, policy interfaces, and tests.
- `packages/mosaic/src/capabilities/adapters/local/` for temporary local adapter modules.
- `packages/mosaic/src/capabilities/adapters/mosaicd/` for the broker client seam.
- `packages/mosaic/src/commands/git.ts`, with later first-class domain files following the same
command pattern.
Remaining script implementations may be promoted under `packages/mosaic/framework/tools/` as
private packaged adapters during transition. Their installed paths are resolver-owned and are not
public command contracts. No production adapter imports or executes source from the brain working
tree as the final path.
### EXE-REQ-005: Container boundary
The representative seat container has:
- one seat identity.
- rootless execution.
- read-only root filesystem, with explicit bounded writable mounts.
- a read-only internal `~/.config/mosaic/config.json` supplied by topology.
- no host credential tree.
- no shared host or fleet tmux socket.
- a dedicated per-seat tmux socket only for one named, reviewed temporary adapter with a stated
removal stage.
- no Docker, Podman, or other container-runtime socket.
- no installed legacy tool tree mount.
- network access limited to declared capability paths.
Container implementation is outside this mission. Contract and compatibility tests are inside it.
## 10. Communications portability
### COM-REQ-001: Transport-neutral public contract
Public communications capabilities use logical addresses, messages, correlation IDs, delivery
status, and adapter diagnostics. Tmux pane, socket, retry, and draft details stay below the public
contract.
### COM-REQ-002: Transitional semantics
The tmux adapter preserves the measured `rc=2` behavior: content reached a pane as a draft, so the
operation is not retried automatically. Fleet-comms preserves durable cross-site message identity
and acknowledgment behavior.
### COM-REQ-003: Future transport replacement
A Matrix or native transport implementation passes the same contract tests. Callers do not change
command paths, capability IDs, or result interpretation when the adapter changes.
## 11. Canonical source and runtime integrity
### SRC-REQ-001: Reviewed baseline
The F11 baseline is commit `5be5825`. Inventory report `585f214`, code review `3fe8de7`, and
security review `e270098` are the M0 evidence. Both reviews found no blocker.
### SRC-REQ-002: Required M1 corrections
Before expanding direct execution from the working tree:
1. fix the `check-helper-drift.sh` environment assignment that suppresses version diagnostics.
2. strip 20 dangling Excalidraw `node_modules` symlinks.
3. add `tools/**/node_modules/` to the brain `.gitignore`.
4. keep the reviewed `package-lock.json` as the reproducible dependency contract.
5. correct the baseline report's misleading path-count headline.
6. move `ci-publish-watch.sh` credential headers from process arguments to curl stdin
configuration when that suite is changed.
### SRC-REQ-003: Source is not installation
Runtime code is loaded from reviewed package or installed artifacts, not directly from a mutable
multi-writer checkout as the final design. Any transitional direct execution requires:
- a protected-path review rule.
- an accepted digest anchored outside the synced tree in reviewed package metadata or Stack source.
- verification before execution, including every credential-helper invocation.
- a periodic verifier whose mismatch alert reaches a human.
- a stated removal point.
### SRC-REQ-004: Credential helper integrity
The host-wide git credential helper and its accepted pin cannot be replaceable by the same synced
commit. Transition requires an independently anchored verifier and alert. Final state moves the
helper into the reviewed runtime installation or another explicitly protected location.
## 12. Migration and decommission
### MIG-REQ-001: Consumer census
Inventory every direct caller of `~/.config/mosaic/tools`, grouped as:
- skills and guides.
- hooks and generated harness configuration.
- systemd units and timers.
- launchers and provisioning.
- tests and CI.
- direct agent commands.
- private tool-to-tool calls.
- production consumers.
Each census run creates a fresh randomized planted legacy reference at a unique path and is valid
only when the detector reports that run's exact plant. Every host at every site still running the
installed legacy tree is censused independently. An empty result without the fresh control, or a
zero from only one host, is not evidence.
### MIG-REQ-002: Risk-ordered waves
Migrate in this order:
1. read-only status, health, list, and view.
2. bounded CI and communications.
3. issue, pull-request, and milestone mutation.
4. credentialed infrastructure.
5. merge, deployment, identity, authorization, and secret management.
Each wave proves contract parity before consumer cutover. Waves 1 through 3 may use local mode with
acting-seat credentials. Wave 4 cutover is broker-only when it uses a shared or service credential.
Wave 5 cutover is always broker-only and begins only after the M6 `mosaicd` boundary gate passes.
### MIG-REQ-003: Protected consumers
- M365 credentials, AD status, and six production consumers remain Peggy-owned until exact signoff
and timer-aware tests.
- Fleet-doctor, seat-service, Woodpecker extras, and their units remain Veronica-owned until exact
replacement proof and named handoff.
- Brain guards are excluded from wholesale removal.
- The active A2 hold applies to `tools/seat-service/` and
`fleet/bin/launch-seat-claude.sh` only.
- Fleet configuration issue `#758` retains its own normative contract and delivery DAG. T78 does
not re-scope or absorb its missing `inspect` and `validate` verbs. T78 measures and consumes the
stable fleet surface only after `#758` completion or an explicit owner handoff.
### MIG-REQ-004: Compatibility and deprecation
Compatibility shims are private and time-bounded. Each shim:
- names its public replacement.
- preserves existing safety behavior.
- emits a machine-detectable deprecation diagnostic without corrupting JSON output.
- has a measured consumer and removal issue.
- cannot be used to add new direct callers.
### MIG-REQ-005: Final removal
The installed `~/.config/mosaic/tools` script surface is removed only after:
1. all active consumers use official capabilities.
2. the census reports zero with a firing planted control.
3. Constitution and wrapper-first gates are mechanically enforced by the CLI path.
4. systemd units are regenerated, daemon-reloaded, re-enabled, and behavior-tested.
5. fleet-doctor state is preserved.
6. clean install, upgrade, rollback, and stale-install tests pass.
7. user, admin, developer, API, and migration documentation is current.
## 13. Testing requirements
### TST-REQ-001: Resolver
- exact schema-v1 valid fixture.
- absent, null, exact-v1, and unknown-non-null `$schema` cases.
- unknown top-level and nested key warnings with full-path diagnostics.
- `mosaic registry validate` lint rejection of the same unknown-key fixture.
- invalid URL, path, socket, and type failures.
- every precedence branch, including present-empty and present-invalid override denial without
fallback.
- two valid roots.
- container topology with a read-only internal registry and no host registry path.
- no credential value accepted or emitted.
- control proving the invalid fixture fails.
### TST-REQ-002: Capability catalog
- command and capability ID uniqueness.
- every public command documented.
- no orphan catalog record.
- parser, policy, help, and docs consume the same definition.
- unauthorized lane and scope denial.
- unknown capability denial.
- topology-selected mode never falls back when the required broker is unavailable.
- shared, service, operator, and admin credential classes reject local mode.
### TST-REQ-003: Pilot
- issue list and view against a valid configured instance.
- invalid instance and repository denial.
- comment success with provider response-shape and body-digest confirmation.
- comment denial before provider access.
- post-request uncertainty without retry, plus provider-native same-key and
`uncertain-no-retry` read-back reconciliation cases.
- credential, cookie, token, comment-body, child-argv, captured-stderr, and shell-trace redaction.
- local results prove `identityTrust: local-asserted` and `audit.recorded: false`.
- broker-stub results prove derived-identity precedence and reject unconfirmed
`audit.recorded: true`.
- user-editable endpoint changes cannot redirect a shared or service credential.
- local-adapter and broker-stub request/result seam parity at M3.
- live local-adapter and `mosaicd` contract parity at M6.
### TST-REQ-004: Migration
- fresh randomized consumer-census plant detected independently on every affected host and site.
- compatibility diagnostics in table and JSON modes.
- systemd timer and restart behavior.
- production M365/AD consumer probes.
- fleet-doctor digest-state preservation.
- clean install, upgrade, rollback, stale install, and greenfield operation.
- representative container without legacy tools mounted.
- representative container mounts no shared or fleet tmux socket, and any temporary tmux exception
uses only the named adapter's dedicated per-seat socket.
### TST-REQ-005: Delivery gates
Every source card requires focused tests, repository quality gates, independent code review,
security review for authorization, credentials, transport, or integrity surfaces, reviewed squash
PR to `next`, terminal-green CI, and linked-issue closure.
## 14. Documentation requirements
The workstream updates in the same delivery sequence:
- official CLI help.
- `docs/PRD.md` workstream pointer.
- `docs/ROADMAP.md` parallel-track entry.
- `docs/SITEMAP.md` requirements link.
- user guide commands and deprecation behavior.
- administrator configuration, migration, and recovery.
- developer architecture, capability authoring, schemas, and adapter contracts.
- API and machine-readable result schemas.
- release notes.
- T78 program-map and unified-roadmap records.
No command is public until its help, structured output, authorization behavior, and documentation
are present.
## 15. Delivery stages
| Stage | Scope | Exit gate |
| ----- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| M0 | Register mission, reconcile ownership, establish and review source baseline, publish requirements and tracking | Reviewed contract merged, dedicated milestone and task graph present |
| M1 | Inventory consumers, normalize baseline, freeze registry resolver, capability catalog, policy, and runtime-integrity contracts | Typed interfaces and migration census reviewed |
| M2 | Implement resolver, catalog, common result envelope, and adapter interface | Contract and two-root tests green |
| M3 | Deliver pilot issue list, view, and comment | Allowed and denied controls, uncertainty behavior, docs, review, CI |
| M4 | Migrate waves 1 through 3, prepare wave 4 private adapters without shared-credential cutover | Per-suite owner handoff and parity evidence |
| M5 | Cut eligible consumers and generate harness policy | No new direct references, compatibility callers measured |
| M6 | Prove representative container and `mosaicd` seam, then cut over shared-credential wave 4 and all wave 5 capabilities | Boundary, authorization, audit, and parity tests green |
| M7 | Remove installed legacy script tree | Zero callers, migration and rollback evidence, docs and release gates complete |
## 16. Workstream acceptance
T78 completes only when:
1. the official TypeScript CLI exposes documented first-class capability groups.
2. the central registry resolver and capability catalog are single typed authorities.
3. two-root and container-topology tests prove no command-path hard-coding.
4. authorization has allowed and denied situational evidence.
5. agent-visible output, logs, and process arguments contain no credential values.
6. local and `mosaicd` modes share one request and result contract and report their mode honestly.
7. a representative rootless seat container performs granted operations without legacy tools,
host credentials, or a container-runtime socket.
8. tmux and fleet-comms can be replaced without changing public communications callers.
9. the legacy consumer census reaches zero with a discriminating control.
10. the installed `~/.config/mosaic/tools` script surface is removed.
11. independent review passes for every source partition.
12. all PRs are squash-merged to `next`, terminal CI is green, and linked issues are closed.
## 17. Contract-freeze status
The architecture inputs are frozen for independent review:
1. The central-registry resolver has joint C1, amended C2, and C3 approval.
2. User-editable `config.json` is not authorization policy. Target grant authority belongs to
`mosaicd`. Local mode is explicitly non-authoritative.
3. Registry, capability, and adapter source boundaries are packaged TypeScript modules. Brain tools
remain working source and temporary private adapters, not the final runtime contract.
4. Issue `#758` remains an independent dependency and is not re-scoped into T78.
Provider tracking remains operationally blocked until the `orch-01` Mosaic Stack credential slot is
minted. This does not weaken the contract or authorize implementation before reviewed publication.
-484
View File
@@ -1,484 +0,0 @@
# Hierarchy Schema Contract (D2)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 1 of 9).
Authority: PRD D2/D9/D13 (Part I §4) and the native-kanban SOT Amendment A1
(`docs/requirements/native-kanban-sot.md` §8, ratified 2026-08-25). This
document turns the ratified hierarchy into a concrete schema contract:
tables, cardinalities, constraints, and ownership/transfer semantics. It is
the prerequisite for the hierarchy command family and for the RBAC grant
model (contract 2, `docs/requirements/rbac-grant-model.md`).
Revision 2 (independent review, GPT-5.6 terra): tenancy-FK exemption made
explicit (§1.1); record class extended to include `hierarchy_grants`
(§1.1); provenance corrections on legacy tables and the planning `projects`
table (§1.3, §2 naming note); NOT NULL and `NULLS NOT DISTINCT` grant
uniqueness (§2.6, §3.2); grant FK delete actions split cascade/restrict
(§3.3); transfer transaction includes its audit write (§4.3); ownership
invariant completed via contract 2 with the both-sides rule marked as new
policy (§4.2, §4.4); hierarchy audit brought under REQ-AUD-001-equivalent
guarantees with deletion-safe linkage (§5.2); roll-up never-a-write restored
to full A1 strength (§5.4); §6 rebuilt with bounded observables for every
MUST (allowlist, command surface, audit, corrected cardinality witness).
Revision 3 (terra re-review residuals): §4.3 transfer write inventory
reconciled with §5.2 — the transaction's writes are the single class-row
mutation plus that mutation's §5.2 audit writes (event + outbox record),
not "exactly two writes"; §6.3 extended with a closed writer-coverage
witness so an unregistered internal writer cannot pass a registered-route
inventory. (Terra's finding-8 residual — a stale contract 2 §7.8 backlink
to contract 1 §6.2 — was already fixed in contract 2 revision 2, which
cites §6.5; measured against `origin/contract/rbac-grants` head
`501112d2`.)
Revision 4 (terra r3 residual F7): the §6.3(b) writer-coverage assertion
extended to raw SQL — it now also fails on class-table name literals
inside SQL strings or tagged SQL templates outside the allowlist, so a
raw-SQL writer that touches no schema symbol is still caught.
Revision 5 (terra r4 residual F7): §6.3(b) gains a third prong — any
raw-SQL execution primitive outside the allowlist fails the assertion
regardless of its SQL content, closing the evasion where a
dynamically constructed table name carries neither a schema symbol nor
a class-table literal. The detection claim is now coextensive with
what the three prongs statically see.
Revision 6 (terra r5 residual F7 + new F8): the "two prongs" wording
corrected to three (F8); §6.3(b) gains the allowlist composition rules
(no generic raw-SQL helper is allowlisted; an allowlisted module may
not export caller-supplied-SQL execution) and fails outright on
runtime code-construction primitives; the detection claim is scoped
honestly to the stated syntactic forms, with evasions beyond static
reach assigned to §5.1 review/audit rather than claimed for CI.
Revision 7 (terra r6 new F9): the false-positive remedy no longer
contradicts the composition rules — legitimate non-hierarchy raw
execution (e.g. the db package's migration runner) is dispositioned
onto a second closed enumerated list, the infrastructure register,
exempt from prong (iii) only, still bound by prongs (i)/(ii), barred
from the writer allowlist, and importable only by registered modules
or the operational entry points.
Revision 8 (terra r7 residual F9): the register's import rule made
satisfiable by the live tree — imports are checked re-export-aware
(package barrels followed), and each registered module carries its own
closed importer enumeration, which may name operational entry points
such as the Gateway's startup migration hook; named importers stay
subject to prongs (i)/(ii) and gain no writer standing.
Revision 9 (terra r8 F10): revision 8 called the Gateway database
module the runner's "one live importer today". That was false — the
measured production importer set has four members. The enumeration
example now lists the complete measured set, and the import analysis
is extended to resolve literal dynamic `import()` routes, which two of
the four members use.
Amendment 1 (Ruling 4b, 2026-08-28): company visibility classes. The
directory exists so one shared company can serve many users instead of
each user creating a duplicate private company (Ruling 4b, webui-audit
lane, ruled 2026-08-27). §2.1 gains a `visibility` column; §2.8 defines
the two classes (`private`/`directory`), the directory's existence-only
disclosure, and the pre-binding invariants for the deferred
see-and-ask-to-join flow (no join-request surface is authorized here —
its flow is a follow-up contract); §5.2's mutation
enumeration gains the visibility change; §5.5 defines who may change
visibility (platform admins, plus a company-CRUD capability whose
definition is a follow-up amendment to contract 2 — until it ratifies,
admin-only); §6.1 and §6.9 add the witnesses; §6.7's existence-oracle
rule is scoped around the ratified directory carve-out. Top-level
creation (contract 3 §5.2) is unchanged and always yields a private
company. Upstream, SOT Amendment A2 (native-kanban-sot.md §9, this PR)
expressly extends A1 §8.1.2 to admit the visibility column and A1
§8.1.3 to admit the directory function — this contract relies on that
amendment, not on a reinterpretation of A1.
Scope: the tenancy/authorization structure record class — companies,
estates, platform-projects, workspaces, hierarchy grants, their parentage,
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
semantics (contract 2), roll-up projection semantics (contract 8), kanban
planning entities inside workspaces (SOT §5), migration or retirement of
legacy flat data (future work; see §1.3).
## 1. Record class and placement
1. The **tenancy/authorization structure record class** defined by
Amendment A1 §8.1.2 comprises five tables: the four node tables of §2
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
in the class. Every rule addressed to "the class" in this contract
(payload prohibition, mutation path, audit) binds all five tables. Class
rows carry parentage, naming, grant, audit-linkage, and visibility-class
data only — never task, plan, or any business/orchestration payload.
Visibility (`companies.visibility`, §2.8) is admitted into that
enumeration by SOT Amendment A2 §9.1.1, which expressly extends A1
§8.1.2 for exactly this one column: it is disclosure data about the
class's own nodes — not a payload field, carries no business content,
and widens the payload prohibition for nothing else.
References from business/orchestration rows into the class are limited
to exactly one form: the canonical `workspace_id` tenancy column that
REQ-TEN-001 requires on every canonical row, referencing
`workspaces.id`. No business/orchestration row may reference a company,
estate, platform-project, or grant id in any position, and no
business/orchestration row may reference a workspace id in any
non-tenancy position (dependency, claim target, work subject).
2. Hierarchy records are NOT workspace-scoped rows: REQ-TEN-001's
`workspace_id` obligation binds business/orchestration rows and does not
apply to this class (A1 §8.1.2). The `workspaces` table itself is the
anchor the obligation points at.
3. The legacy flat tables (`teams`, and the Brain planning `projects` table
in `packages/db/src/schema.ts`) are not part of this class. What A1
§8.1.4 pins is narrower: the planning `projects` table and
`platform_projects` stay distinct tables. This contract adds, as new
policy ratified here: neither `teams` nor `projects` is repurposed as a
hierarchy table. Their eventual migration or retirement is future work
that no existing REQ assigns; it is out of scope here.
## 2. Tables and cardinalities
Naming: the level above workspaces is `platform_projects`, per A1 §8.1.4.
The existing `projects` table is Brain planning data (so labeled in
`packages/db/src/schema.ts`; it carries no `workspace_id`), and the schema
MUST NOT merge the two. (A rename of either remains an implementation-PR
decision under A1; this contract pins only that they stay distinct tables.)
1. `companies` — id (uuid pk), name, slug (unique per deployment),
`visibility` (text NOT NULL, DEFAULT `private`, CHECK constrained to
exactly `private` | `directory`; semantics §2.8), created_at,
updated_at. N per deployment (D2).
2. `estates` — id, name, slug, `company_id` NOT NULL →
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
company holds any number of estates.
3. `platform_projects` — id, name, slug, `estate_id` NOT NULL →
`estates.id` ON DELETE RESTRICT. Exactly one estate per
platform-project; an estate holds any number of platform-projects.
4. `workspaces` — id, name, slug, `platform_project_id` NOT NULL →
`platform_projects.id` ON DELETE RESTRICT. Exactly one platform-project
per workspace. This table is the referent of every `workspace_id` column
the SOT requires on canonical rows.
5. **Chain resolution is by construction.** Because every parent FK is NOT
NULL and single-valued (one FK column, no parentage edge tables, no
multi-parent forms, no nullable "detached" states), each workspace
resolves to exactly one platform-project → estate → company chain (A1
§8.3 acceptance 1). One-parent-per-child is the constrained direction;
many children per parent is valid data.
6. **Slug scoping.** All `name` and `slug` columns are NOT NULL.
`estates.slug` is unique within its company, `platform_projects.slug`
within its estate, `workspaces.slug` within its platform-project
(composite unique constraints). Display names are unconstrained beyond
NOT NULL.
7. No hierarchy table carries a `metadata` jsonb column or any
free-form payload field. The columns declared in this section and §3
are exhaustive: a class table's column set is exactly its declared set
(verified per §6.2) — nothing else (A1 §8.1.2).
8. **Company visibility classes (Ruling 4b).** Every company is exactly
one of two classes, carried by `visibility`:
- `private` (the default): the company is disclosed only to subjects
holding a grant on it or on a descendant — the resting state every
company is created in. Open creation under contract 3 §5.2
(Ruling 4) survives unchanged: it creates private companies.
- `directory`: the company is listed in the deployment-wide company
directory. Directory listing discloses **existence, name, and slug
to every authenticated user — nothing else**: no subtree structure,
no roll-up aggregates, no workspace content, no grant or membership
information.
Visibility is disclosure, not authority. Content and structure access
to a directory-listed company still require explicit grants —
contract 2 §3.1 deny-by-default is unchanged, and the ownership model
(§4.4, contract 2 §4.3) is unchanged. Ruling 4b decision 5 wants a
see-and-ask-to-join flow for directory-listed companies. **This
contract authorizes no join-request runtime surface**: the flow in
its entirety — the ability to submit a request, its transport,
storage, and request lifecycle — is a follow-up contract, and until
that contract ratifies, the directory's only function is the
read-only listing above (A2 §9.1.2 admits nothing more). Two
invariants pre-bind that future contract now:
a join request confers no authority of any kind, and approval is
ordinary grant creation by an effective `owner` under contract 2 §4.1
— there is no other acceptance path.
## 3. Grant attachment points
The grant vocabulary (which roles exist, what each permits, how evaluation
and revocation work) is contract 2. This contract pins only the schema
shape contract 2 attaches to:
1. `hierarchy_grants` — id, subject (exactly one of `user_id``users.id`,
`team_id``teams.id`; CHECK-enforced exactly-one-of), target (exactly
one of `company_id`, `estate_id`, `platform_project_id`;
CHECK-enforced exactly-one-of), `role` (text NOT NULL; vocabulary and
its CHECK constraint owned by contract 2 §2), `granted_by` NOT NULL →
`users.id`, created_at.
2. Uniqueness: at most one grant row per (subject, target, role). Because
the subject and target columns are nullable by design, ordinary
PostgreSQL composite uniqueness treats NULLs as distinct and would not
enforce this. The implementation MUST use a single
`UNIQUE NULLS NOT DISTINCT` constraint across (`user_id`, `team_id`,
`company_id`, `estate_id`, `platform_project_id`, `role`) or six
equivalent partial unique indexes (one per subject×target form). The
pinned Drizzle ORM supports `nullsNotDistinct()`.
3. Delete actions are split by column class:
- Target FKs (`company_id`, `estate_id`, `platform_project_id`):
ON DELETE CASCADE — the one permitted cascade in this class. A grant
on a deleted node is meaningless and fail-open if retained. Cascaded
grant deletions are audited per §5.2.
- Principal FKs (`user_id`, `team_id`, `granted_by`): ON DELETE
RESTRICT. The identity contract (§7.3) gates user deletion today and
defines no team-deletion rule; this contract does not invent one.
These FKs stay RESTRICT until an explicit deletion-and-retention
contract ratifies otherwise.
4. Workspace-level access is evaluated, not stored here: a grant at any of
the three levels evaluates down the chain to workspace-scoped
authorization (A1 §8.1.3). No `workspace_id` column exists on
`hierarchy_grants` — workspace membership (REQ-ID-001) remains its own
mechanism inside the SOT schema, and the chain adds where grants can be
declared, never a bypass.
## 4. Ownership and transfer
"Assets are transferable subject to the structure" (PRD Part I §4):
1. A transfer changes exactly one parent FK on exactly one hierarchy row:
workspace → new platform-project, platform-project → new estate, estate
→ new company. Nothing else in the class or the SOT changes: business
and orchestration rows inside affected workspaces are untouched, keep
their `workspace_id`, and never cross a workspace boundary (A1 §8.1.3
"chain maintenance").
2. Transfer authorization requires authority over BOTH the source and the
destination parent. This both-sides predicate is **new policy
introduced by this contract pair** (D2/A1 do not state it); its
evaluation semantics are contract 2 §5. The structural half — that the
transfer command evaluates it before mutating — binds here.
3. A transfer transaction mutates exactly one class-table row — the
single-row parent-FK update — and contains, beyond that, only the
§5.2 audit writes for that mutation (the audit event and its
hierarchy-outbox record, committing in the same transaction). No other
class, business, or orchestration row changes. There are no multi-row
transfer batches at the schema level; bulk moves are N audited
transfers.
4. Hierarchy records have no `owner_id`. Ownership in the hierarchy IS the
grant structure: a "company owner" is a subject with an `owner` grant
on that company or an ancestor (contract 2 §2), not a column. The
ownership invariant across the contract pair: a node may hold zero
direct owner grants (authority can derive from an ancestor grant); node
creation names the initial `owner` grant in the same audited operation
and the wizard seeds the first company's owner the same way (contract 2
§4.3); transfer and revocation semantics are contract 2 §§56. This
avoids column-encoded authority of the kind the legacy schema carries
(`teams.owner_id` and `teams.manager_id` are required user FKs, and
`team_members.role` is a further authority field — none of them
evaluable under a grant model).
## 5. Mutation path, audit, and deletion
1. All hierarchy mutations flow through the same sole-writable-SOT,
fail-closed, audited Gateway command path as everything else (A1 §8.2.3,
REQ-API-001). No direct-DB writers, no raw CRUD endpoints.
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
substitute. Concretely:
- Every create, rename, transfer, visibility change (§5.5), grant
create/change/revoke, and
delete — including every grant deletion cascaded by a node delete —
emits a semantic audit event carrying actor, verb, target, and (for
transfers) source and destination parents, with the correlation,
causation, idempotency, and per-target ordering guarantees REQ-AUD-001
defines.
- The state change and its audit event(s) commit in the same
transaction, delivered through a transactional outbox. Hierarchy
events are not workspace-scoped rows and do not ride the workspace
outbox; they get an equivalent hierarchy outbox under the same
append-only, same-transaction rules.
- **Deletion-safe linkage:** audit events reference their target by an
immutable snapshot (id, slug, and parent chain at event time), never
by a foreign key into the class tables, so append-only events survive
the deletion of their target.
3. Deletion is fail-closed bottom-up: a hierarchy record with children
cannot be deleted (RESTRICT FKs, §2). Deleting a workspace is a SOT-side
operation subject to the kanban SOT's own rules and is not granted any
new semantics by this contract.
4. **Roll-up is never a write** (A1 §8.2.2, preserved at full strength). A
roll-up read mutates nothing — not hierarchy state, and not business or
orchestration state: it must not mutate, claim, order, or gate
workspace work. Contract 8 owns projection details but cannot narrow
this rule. This contract additionally guarantees the chain roll-ups
aggregate over is unique and non-null (§2.5).
5. **Visibility administration (Ruling 4b decisions 23).** Changing
`companies.visibility` is a hierarchy mutation through the §5.1
command path, audited per §5.2 (the event carries the old and new
visibility values as its semantic content). It is authorized for
exactly two actor classes: platform admins (`users.role = 'admin'`)
and subjects holding the company-CRUD capability that a follow-up
amendment to contract 2 will define — until that amendment ratifies,
the capability class is empty and the command is admin-only.
A company `owner` as such may NOT change visibility: standard users
cannot publish a company into the directory. This is the one
hierarchy mutation a platform admin performs without holding a
hierarchy grant, and it is ratified here as instance administration
(directory curation) in contract 2 §1.1's sense, not tenant access:
the command mutates the single `visibility` column, reads no tenant
content, and confers no grant — contract 2 §1.1's
no-implicit-tenant-access rule is otherwise untouched. Top-level
company creation (contract 3 §5.2) always creates
`visibility = 'private'`; the creation command cannot set or change
visibility.
## 6. Verification requirements
Binding on the implementing PRs (extends A1 §8.3):
1. Schema witnesses (real PostgreSQL, §6.8): chain construction — insert
with a null parent FK refused; insert with one valid parent accepted;
two siblings under one parent accepted (the control proving the
constraint rejects only what §2.5 forbids); catalog assertion that each
child table has exactly one parent-FK column and no parentage edge
table exists. Composite slug uniqueness per parent (duplicate slug
under same parent refused; same slug under different parents accepted).
Grant CHECKs: exactly-one-of subject and exactly-one-of target each
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
(subject, target, role) row refused for each of the six subject×target
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
`granted_by`, and all `name`/`slug` columns witnessed. Company
visibility (§2.8): a value outside `private`/`directory` refused with
both valid values accepted as the control; an insert omitting the
column defaults to `private`.
2. Column allowlist: an information_schema assertion that each class
table's column set is exactly the set declared in §2/§3 — the bounded
observable for no-payload (§2.7) and no-`owner_id` (§4.4).
3. Command surface: two witnesses, both required (§5.1). (a) Route
inventory: an assertion over the Gateway's registered hierarchy
routes/commands proving the registered mutation surface is exactly the
declared hierarchy command family — no generic CRUD endpoint. (b)
Writer coverage — the closed allowlist a route inventory cannot
provide: a static CI assertion over the Gateway and package sources
with three prongs, each bound to one explicitly enumerated allowlist
of hierarchy command/repository modules. (i) Symbol prong: write
references to the class-table schema symbols (insert, update, delete)
occur only in allowlisted modules. (ii) Literal prong: a class-table
name appearing inside a SQL string or tagged SQL template outside the
allowlist fails the assertion — this is what catches a raw-SQL writer
that references no schema symbol. (iii) Raw-execution prong: any call
to a raw-SQL execution primitive (the ORM's raw/unsafe constructors,
driver-level query/execute) outside the allowlist fails the
assertion, regardless of what the SQL string contains or how it is
constructed — the call site is statically detectable even when a
dynamically assembled table name is not, so a raw writer with a
runtime-built identifier is caught by its primitive, not its
payload. Two composition rules keep prong (iii) meaningful: the
allowlist names hierarchy command/repository modules only — a
generic raw-SQL helper or database-utility module is never
allowlisted; and an allowlisted module MUST NOT export a function
that executes caller-supplied SQL (such an export is itself a
raw-execution primitive, and the exporting module is treated as
unallowlisted for prong (iii) if it does). Legitimate raw execution
that is not a hierarchy writer — e.g. the migration runner in the
db package — lives on a second, separately enumerated
**infrastructure register**, distinct from the writer allowlist and
equally closed. A registered module is exempt from prong (iii) only:
prongs (i) and (ii) apply to it with no exemption, so it can hold no
class-table schema symbol or class-table SQL literal, and it can
never appear on the writer allowlist. To close the laundering path,
the same assertion checks imports, and the import analysis is
**re-export-aware**: it follows package barrels and re-exports, so a
route hidden behind an index module is still a route — and it
resolves literal dynamic imports the same way: an
`await import('<literal specifier>')` is an import edge like any
static import, not an evasion of the analysis (a dynamic import of
the db package whose specifier is not a literal fails the assertion
outright, because it makes the import graph unanalyzable). A
registered module may be imported only by other registered modules
or by importers named on that module's own closed importer
enumeration in the register — operational entry points such as the
migration/bootstrap CLI or the Gateway's startup migration hook.
The enumeration names the complete permitted production consumer
set, and completeness is measured, not asserted: the migration
runner's measured production importer set today has four members —
the Gateway database module (reached through the db package
barrel), the storage package's Postgres adapter, and two mosaic CLI
commands, the fleet-backlog command and the gateway verify command,
both routed through literal dynamic imports of the db package — so
its enumeration names those four. A module that only receives the
runner's functions by parameter injection (the gateway schema-check
module takes them as arguments from the verify command) has no
import edge of its own and is not enumerated. Any import route
outside the enumeration fails the assertion. Being a
named importer confers nothing else: the importer stays fully
subject to prongs (i) and (ii), gains no writer-allowlist standing,
and whether it uses the registered module beyond its operational
purpose is a §5.1 review question, not a static claim. Runtime code-construction
primitives (`eval`, `new Function`) anywhere in the scanned sources
fail the assertion outright, allowlist or not. Schema definitions
and generated migrations are excluded from the literal prong; a
false positive is resolved in the same PR by adding the module to
the one enumerated list its role permits — the writer allowlist for
a hierarchy command/repository module, the infrastructure register
for non-hierarchy raw execution — never by weakening the assertion,
and neither list may take a module the composition rules bar from
it. Both lists are closed, and the assertion's detection
claim is exactly its prongs: it statically surfaces every writer
expressed as a schema-symbol reference, a class-table SQL literal, a
raw-execution call site, or runtime code construction. An evasion
engineered outside those syntactic forms is a §5.1 violation that
review and audit own — the witness does not claim to catch what
static analysis cannot see, and any such evasion found later is
corrected as a conformance defect, not grandfathered.
4. Audit witnesses: for each mutation class (create, rename, transfer,
visibility change, grant create/change/revoke, delete) — the event
exists after commit
with actor/verb/target and same-transaction atomicity, and the
event's outbox record exists after the same commit — state row,
audit event, and outbox record are witnessed as one transaction
(REQ-AUD-001); a rolled-back
mutation leaves no event, no outbox record, AND no state effect —
a rolled-back create leaves no row, a rolled-back rename, transfer,
or visibility change leaves the prior values in place, and a
rolled-back delete or grant revoke leaves the row present
(rollback witness on all three legs, per REQ-AUD-001's
commit-or-roll-back-together acceptance); a
node delete's cascaded
grant deletions are each covered by events; events survive deletion of
their target (query the events of a deleted node).
5. Transfer tests: parent-FK update moves the subtree resolution and
modifies zero business/orchestration rows (row-count and content
assertions on workspace contents before/after); transfer without
authority on the source or on the destination side is refused (with
contract 2 §7.8).
6. Deletion tests: delete with children refused at the database level;
delete of a leaf cascades its grants and nothing else; deleting a user
or team that is a grant subject (or `granted_by` referent) is refused
(RESTRICT witnesses for §3.3).
7. Negative tests: no business/orchestration table accepts a company,
estate, platform-project, or grant id in any reference position, and
none accepts a workspace id in any non-tenancy position; the canonical
tenancy FK control — a business row inserted with a valid
`workspace_id` succeeds, with an invalid one is refused; roll-up
endpoints mutate no canonical state anywhere (assert zero writes across
hierarchy AND workspace tables, not hierarchy only); readers see
aggregates only over workspaces they are authorized on, with no
cross-tenant existence oracles (A1 §8.3 acceptance 3, as narrowed by
A2 §9.1.2) beyond the one
ratified carve-out — the §2.8 company directory, witnessed in §6.9.
8. Real-PostgreSQL coverage for every constraint witness (unique/CHECK/
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
in the `test` CI step; mocked specs cannot witness database constraints.
9. Visibility witnesses (§2.8, §5.5): the directory read returns exactly
the `visibility = 'directory'` companies to any authenticated user,
disclosing existence, name, and slug only (closed-field assertion on
the response shape); a private company never appears in the directory
for a reader without a grant on it (with the control: it appears in
that reader's granted-structure reads); a directory-listed company's
subtree, aggregates, and content remain refused for a non-granted
reader (disclosure ≠ authority); the visibility command is refused
for a non-admin actor — including an effective `owner` of the target
company — with the platform-admin accept control; top-level creation
yields `visibility = 'private'` and accepts no visibility argument;
each visibility change emits its §5.2 audit event carrying old and
new values — the full audit pattern for the mutation class
(same-transaction atomicity of state row, audit event, and outbox
record; rollback leaving no state effect, no event, and no outbox
record; actor/verb/target) is §6.4's, which enumerates
visibility change; this item adds only the old/new-value payload
assertion.
## Ruling request
Ratify sections 16 as written, with one decision embedded: hierarchy
records carry no owner column — ownership is expressed solely through
grants (§4.4) — say "agreed" or name the ownership model you want.
-328
View File
@@ -1,328 +0,0 @@
# Identity Account-Lifecycle Contract
Status: DRAFT — awaiting ratification (webui-audit S2, contract 4 of 9).
Authority: PRD D10 (better-auth is the account system of record), Q1 ruled O1
by Jason 2026-08-26 (webui-audit T10). This document turns that ruling into
enforceable policy. It also carries the bootstrap/first-admin invariant from
issue #1430, folded in here after PR #1431's independent review showed the
quick-fix approach was insufficient.
Revision 2: addresses the 9 findings of the independent review
(`fleet/lanes/webui-audit/findings/pr1433-review.md`) — epoch enforcement
tightened (§3), canonical email split from provider claims (§5), external
principal keyed by issuer+subject with DB uniqueness and link step-up (§6),
JIT default precedence and first-admin SSO path defined (§2, §4),
deactivation made measurable (§7.1), deletion kept in scope and the existing
hard-delete endpoint required to fail closed (§7.3), workspace identity
reconciled with the native-kanban SOT (§1.4), verification matrix expanded
(§8), factual labels corrected (§7.3, §8.1).
Revision 3: addresses the residuals and new findings of the revision-2
re-review (`fleet/lanes/webui-audit/findings/pr1433-review-r2.md`) —
`users.emailVerified` added to the canonical set with a defined reset rule on
email change (§5.15.2), external-principal uniqueness moved to
(issuer, subject) (§6.1), "can actually use" defined (§6.5), the shipped
delete affordances (web admin page, `mosaic auth users delete`) required to
be removed or disabled with a defined user-visible state (§7.3), the
admin-creation switch removed in favor of plain admin authorization (§2.3),
and §8 extended with observables for IdP removal, forward-auth non-use,
first-admin SSO, wizard-recorded JIT choice, the admin-guide statement, and
positive/expiry-bound step-up cases.
Scope: account creation, bootstrap, federated login, account linking, claim
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema
(contract 1), sensitive-data custody (contract 7 / D14).
## 1. System of record
1. better-auth's tables (`users`, `accounts`, `sessions`, `verifications`) are
the only account system of record. All foreign keys reference `users.id`.
2. External IdPs (Authentik or any OIDC provider) are login methods, attached
through better-auth's generic-OAuth plugin (`packages/auth/src/sso.ts`).
They never own accounts. Removing an IdP removes a login method, not users.
3. The forward-auth perimeter shim is a deployment measure. Once in-app OIDC
is configured for a deployment, the shim is demoted: it may stay as network
perimeter, but no application code may read identity from its headers.
4. **Account ≠ workspace membership.** Creating an account (by any path:
bootstrap, sign-up, invite, JIT, admin creation) creates no workspace, no
hierarchy grant, and no workspace-scoped authority (native-kanban SOT
REQ-TEN-001 / REQ-ID-001). The better-auth `role` field is a platform/auth
role (`member` | `admin`), not workspace membership. Workspace grants are
defined by contract 2; until then a fresh account can authenticate and
holds no workspace authority.
## 2. Registration gating
Measured current state on `next`: `emailAndPassword.enabled: true` with no
gating — anyone who can reach the Gateway can create an account via
`POST /api/auth/sign-up/email` and receives role `member`.
Contract:
1. A single server-side setting `registration_mode` with values
`open | invite | closed`. It lives in the database (admin-mutable at
runtime), not in env config.
2. Default after bootstrap: `closed`. The wizard (contract 3) may set a
different mode during setup, recorded as an explicit operator choice.
While the bootstrap epoch is open (§3), the effective mode is `closed`
regardless of any stored value: the setting takes effect only after the
epoch completes.
3. `closed` blocks self-service email/password sign-up. It does not block
admin-created users or OIDC JIT (§4). Post-bootstrap admin creation is
gated by admin authorization alone — there is no separate switch for it.
JIT is gated by its per-provider flag (§4.1). All user-creating paths are
closed while the bootstrap epoch is open (§3).
4. `invite` requires a single-use, expiring invite token bound to an email
address. Invite issuance is an admin operation and is audit-logged.
5. Enforcement point: a better-auth hook (or equivalent middleware executed
inside the auth handler path), not a Gateway route guard in front of it —
the raw `/api/auth/*` handler must be incapable of bypassing the gate.
## 3. Bootstrap / first-admin invariant (from #1430)
Invariant: **the system transitions from zero users to one admin user exactly
once per bootstrap epoch, atomically, regardless of concurrency or which code
path writes users.**
Constraints any implementation MUST satisfy (each traces to a verified defect
in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`):
1. **Durable fail-closed epoch state, obeyed by every writer.** The epoch
lives in a constraint-backed one-row `bootstrap_state` table. While the
epoch is open, every non-bootstrap user-creating writer — better-auth
sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside
the writer's own path (better-auth hook for the raw handler; guard for
admin routes). A partial unique index or a winning epoch-transition row is
necessary but not sufficient on its own: neither stops an untagged insert
from a writer that never consulted the epoch. Both layers are required:
database-level transition safety (the epoch-completing write races safely
and at most one wins) and writer-level refusal (no path can create a user
without reading epoch state).
2. **Atomic first-admin transition.** The admin user, its credential account,
the initial admin token, and the epoch-completed transition commit in one
database transaction or not at all. A better-auth call through
`drizzleAdapter(db)` runs on the root pool and is NOT part of any caller
transaction; it may be used inside the bootstrap transition only if the
adapter is explicitly bound to the transaction handle. Otherwise the
bootstrap writer must create the user rows itself within the transaction.
3. **Pool safety.** No design may hold a pooled connection inside a
transaction while awaiting a write that acquires a second connection from
the same pool (`DB_POOL_MAX=1` is a supported configuration).
4. **Re-runnability (D4).** Bootstrap is not a one-shot: after the first-admin
epoch completes, re-running the wizard reconfigures the system but never
re-opens the zero-user transition. "Setup already completed" is a stable,
testable state, and factory-reset (a future, explicitly destructive
operation) is the only way to open a new epoch.
5. **No stranded partial outcome.** A failure at any point in the transition
leaves nothing observable (no admin user without its token, no completed
epoch without an admin) and setup remains retryable — this follows from
§3.2 and is stated separately because it is the pre-existing failure mode
the #1431 review verified.
6. **First-admin via SSO (D4).** When the operator chooses SSO for the
initial user, the wizard executes the OIDC login as part of the bootstrap
transition itself: the bootstrap writer creates the account from the
asserted identity inside the §3.2 transaction. This path is the bootstrap
writer, not JIT — §4's JIT gate stays closed during the epoch and is not
an obstacle to D4.
## 4. JIT provisioning (OIDC first login)
1. A successful OIDC login with no matching account creates a user
just-in-time only when `jit_provisioning` is enabled for that provider.
The flag is per-provider and defaults off, always. There is no
mode-implied default: Enterprise setup enables JIT only when the wizard
records it as an explicit operator choice for a named provider (this
replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT
enabled", which contradicted the per-provider default).
2. JIT users receive platform role `member`, never an elevated role,
regardless of IdP claims (§5), and no workspace authority (§1.4).
3. An optional per-provider email-domain allowlist constrains JIT. The
allowlist matches only when the IdP asserts the email with
`email_verified: true`; an unverified address never satisfies the
allowlist. Empty allowlist with JIT on means any authenticated subject at
that IdP gets an account — permitted, but the wizard must present it as an
explicit choice.
4. JIT is disabled while the bootstrap epoch is open (§3.1). The first-admin
SSO path is §3.6, not JIT.
## 5. Claim mapping
1. **Two stores, not one.** Provider-observed claims (`email`,
`email_verified`, display name, avatar) are recorded per external
principal — keyed by issuer + subject (§6.1) — at first login and
refreshed at each login. The canonical account fields (`users.email`,
`users.emailVerified`, `users.name`, `users.image`) are set exactly once
at account creation and are never silently overwritten by a later login.
For SSO-created accounts (JIT or first-admin SSO), `users.emailVerified`
is set from the provider's `email_verified` claim at creation; for
password-created accounts it is false until the address completes
verification.
2. **Canonical email changes only through an explicit workflow.** Either the
user-initiated email change (with verification of the new address) or an
admin edit. Any canonical email change — user- or admin-initiated — sets
`users.emailVerified` to false until the new address completes
verification; an admin may instead explicitly attest the address as
verified in the same operation, and that attestation is audit-logged. A
provider-claim refresh never rebinds `users.email` or
`users.emailVerified`; a divergence between canonical email and the latest
provider-observed email is surfaced per §6.4.
3. Never mapped from IdP claims: `role` and any future authorization
attribute. Authorization lives in the system of record and in the RBAC
layer (contract 2). An IdP group/role claim may at most be recorded for
audit; it grants nothing.
## 6. Account linking trust
1. **External principal identity is issuer + subject.** A linked identity is
keyed by the OIDC issuer and subject claims, not by an unqualified
provider subject id and not by email. The linked-identity row stores the
issuer, and the database enforces at most one local account per
**(issuer, subject)** with a unique constraint on those stored columns —
uniqueness on (provider, subject) is insufficient because provider →
issuer is not one-to-one: two provider configurations can point at the
same issuer, and the identity must not alias across them. The current
non-unique `(provider_id, account_id)` index satisfies neither;
application-level checks without a uniqueness witness lose
concurrent-callback races. Each configured provider additionally binds to
exactly one issuer, immutable after creation (changing the issuer means
creating a new provider).
2. Linking an OIDC identity to an existing account happens only in one of two
ways: (a) explicit link initiated by the logged-in user from settings,
which requires step-up: a fresh reauthentication (password or existing
linked method) no older than a short bound the implementation defines
(≤ 10 minutes) — a session cookie alone is insufficient, so a stolen
session cannot quietly attach a durable login method; or (b) automatic
link when the IdP asserts a verified email exactly matching an existing
account **and** the provider is marked `trusted_for_linking`
(per-provider flag, default off).
3. Untrusted-provider email collision produces a login error naming the
conflict, not an auto-link and not a duplicate account.
4. A linked identity whose IdP-observed email later diverges from the
canonical account email keeps working (the link is by issuer + subject,
§6.1) but the divergence is surfaced in the user's settings and audit log
(the per-principal claim store in §5.1 is what makes the divergence
representable).
5. Unlinking a login method is refused when it would leave the account with
no **usable** login method. Usable means: a set password, or a linked
identity whose provider is currently configured and enabled on this
deployment. A linked identity whose provider has been removed or disabled
(§1.2) is not usable and does not count; setting a password first lifts
the refusal.
## 7. Deactivation propagation
1. **Deactivation (better-auth admin ban) is authoritative and bounded.**
Concretely:
- Ban and session revocation are one operation: the ban commit revokes all
better-auth sessions for the user. If revocation partially fails, the
ban itself must already be committed and every guard denies from that
point (fail closed); the operation is retryable.
- Every authenticated entry path checks banned state: HTTP session guards,
the admin bearer-token path (which today does not test `banned` — an
implementation defect this contract makes non-conformant), MCP, and
Socket.IO.
- Active socket connections are terminated or denied within 30 seconds of
the ban commit, or at the next inbound message on that socket, whichever
comes first (socket auth at connect-time only, as today, does not
satisfy this).
- The current admin ban route updates only the user row; it does not
conform to this section until revocation and guard coverage land.
- Admin tokens owned by the banned user are revoked in the same operation.
2. Deactivation at an external IdP does not propagate automatically in this
contract's scope (no SCIM). Operational rule: removing a user from the IdP
without banning them in Mosaic leaves any password or other linked login
method usable — the admin guide must state this. SCIM/webhook-driven
propagation is future work and out of scope here.
3. **Deletion is not deactivation, and deletion is gated here.** Account
deletion semantics (FK fan-out across the 21 foreign-key constraints to
`users.id`, spread over 19 referencing tables) require their own
deletion-and-retention contract, chartered as an addition to the S2 list —
contract 7 is the D14 sensitive-data custody contract and does not cover
account deletion. Until that deletion contract is ratified: the existing
hard-delete endpoint (`DELETE /api/admin/users/:id`) is disabled and fails
closed, and deactivation is the only supported removal operation. A
contract that merely declared deactivation "the only supported removal"
while the endpoint stayed live would be false on its face.
Disabling the endpoint alone is insufficient — its shipped callers must
not be left as advertised operations that now fail generically:
- The admin web UI delete action (`apps/web/src/app/(dashboard)/admin/page.tsx`
and any SPA port of it) is removed, or replaced by a disabled control
whose visible text states that deletion is unavailable pending the
deletion-and-retention contract and points at deactivation.
- The CLI command `mosaic auth users delete`
(`packages/mosaic/src/commands/auth.ts`) is removed, or exits non-zero
with a message stating the same and naming the deactivation command.
- Both surfaces expose deactivation as the supported operation.
## 8. Verification requirements
Every MUST above needs a bounded observable. The matrix:
1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two
distinct physical connections (pattern:
`apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`,
which runs in the `test` CI step against the `ci-postgres` PostgreSQL
service — note that pattern multiplexes one pooled handle, so the tests
here must explicitly open separate connections). Races to cover:
setup-vs-setup, setup-vs-raw-sign-up, setup-vs-JIT, setup-vs-admin-create.
Plus: liveness under `DB_POOL_MAX=1`; fault injection after each write in
the transition (user, credential, token, epoch) proving nothing observable
leaks and setup retries; wizard re-run after completion proving the
zero-user transition never re-opens. Mocked-transaction specs are
supplementary; they cannot prove serialization.
2. **Registration gating (§2).** Spec coverage of all three modes against the
raw `/api/auth/` handler path, not only Gateway controllers; invite
lifecycle (single-use, expiry, email binding); effective-`closed` while
the epoch is open regardless of stored mode.
3. **JIT (§4).** Provider flag off → no account on first OIDC login; on →
account with platform role `member` and no workspace grant; domain
allowlist rejects an unverified email claim even when the domain matches;
JIT refused while the epoch is open.
4. **Claim mapping (§5).** Login refresh updates the per-principal claim
store and touches none of the canonical fields (`users.email`,
`users.emailVerified`, name, image); explicit email-change workflow is the
only path that rebinds canonical email; every canonical email change
resets `users.emailVerified` to false unless the admin attestation path
is taken, and that attestation appears in the audit log.
5. **Linking (§6).** Unique-constraint witness: concurrent first-login
callbacks for the same (issuer, subject) yield exactly one account, and
two provider configurations sharing one issuer cannot create two accounts
for the same subject; trusted auto-link; untrusted collision error;
step-up both ways: an explicit link succeeds immediately after a fresh
reauthentication and is refused once the implementation's chosen bound
(≤ 10 minutes) has elapsed, and refused with no reauthentication at all;
unlink refusal when no remaining method is usable per §6.5, including the
removed-provider case, and acceptance after a password is set; divergence
surfaced after IdP email change.
6. **Deactivation (§7).** Ban revokes sessions atomically or fails closed
(partial-failure injection); guard denial post-ban on each transport:
HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated
within the 30-second/next-message bound; banned user's admin tokens
unusable; hard-delete endpoint returns a fail-closed error while the
deletion contract is unratified; the admin web UI renders no live delete
action (absent, or disabled with the §7.3 text) and `mosaic auth users
delete` exits non-zero with the §7.3 message — both asserted by spec.
7. **System of record and bootstrap edges (§1, §3.6, §4.3).** IdP removal:
deleting a provider configuration leaves every user row intact and every
other login method working (spec over the provider-config removal path).
Forward-auth non-use: with in-app OIDC configured, a request carrying
forward-auth identity headers and no session is treated as anonymous —
no code path derives identity from those headers (negative spec at the
Gateway entry). First-admin SSO: the §3.6 transition commits account,
token, and epoch atomically from the asserted identity, and fault
injection mid-transition leaves nothing observable (same harness as §8.1).
Wizard-recorded JIT choice: enabling JIT for a provider writes an
explicit per-provider operator-choice record, and no mode selection
enables it implicitly (assert the stored record, not UI behavior).
8. **Documentation observable (§7.2).** The admin guide contains the
IdP-removal-does-not-deactivate statement; verified by a docs assertion
(content check in CI or an enumerated review-checklist item on the
implementing PR) — a MUST about documentation needs a checkable artifact,
not intent.
## Ruling request
Ratify sections 18 as written, with one decision embedded: registration
defaults to `closed` after bootstrap (§2.2) — say "agreed" or name the mode
you want as the default.
-279
View File
@@ -1,279 +0,0 @@
# Deployment Mode and Conversion Contract (D3)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
Standalone and Enterprise, with the mode table (brains, user-data
isolation, secrets, conversion); Standalone → Enterprise conversion is
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
— the per-user brain split is optional in Standalone and keeping it is
the recommended default because it preserves forward-compatibility with
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
flow only; Enterprise conversion is explicitly deferred. PRD D3
federation clause — federation is intentionally not fully designed,
deferred, and nothing in v1 may foreclose it.
Revision 2 (luna review F1F7): the identity precondition restated in
identity-contract terms with a conversion-local acknowledgment record
this contract owns (F1); a durable, keyed preparation state with a
Standalone-safe representation rule, an in-transaction re-check fence,
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
directly without the contradictory non-exhaustiveness clause (F3); the
D14 boundary bound here with a stable column-allowlist witness instead
of delegated to an unratified layout (F4); the conversion witness
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
writer coverage imported concretely from contract 1 §6.3 with a named
schema, closed writer set, crafted-write probe, and mode-resolution
assertion (F6); the mode read command flagged as a §12.1 drafting
addition rather than a D8 mandate (F7). Ownership language aligned
with contract 3 revision 2: mode is recorded at bootstrap and read by
the wizard as input.
This contract binds the mode as a canonical platform property (§2), the
per-mode obligations and which contract owns each (§3), the conversion
transition (§4), the v1 non-foreclosure obligations (§5), and their
witnesses (§6). Domain semantics stay with their owning contracts:
wizard branching (contract 3 §2), identity/SSO
(`identity-lifecycle.md`), custody and per-user brain mechanics
(contract 7, `custody-schema.md`), tool mapping
(`tool-gateway-mapping.md`).
## 1. Definitions
1. **Mode**: the platform-wide deployment mode, exactly one of
`standalone` or `enterprise`. The vocabulary is closed in v1;
extension (e.g. a federation mode) is by amendment to this contract,
never ad hoc.
2. **Conversion**: the one-way transition `standalone → enterprise`.
No other mode transition exists.
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
must all hold before the mode record may change.
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
the migration of one secret to the Vault backend, or the partition
of one user's brain content (§4.3).
## 2. Mode is a canonical recorded property
1. Mode is recorded canonically in the platform database at bootstrap
as the operator's install-time choice (D3: modes are "chosen at
install time"). The record is a single-row keyed record
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
reference); this contract owns it, the bootstrap writer performs the
one v1 write (§6.2), and the wizard reads it as input (contract 3
§2.3). Mode is never derived from feature state (presence of Vault,
count of brains, count of users), and no component may infer a
different mode than the record states.
2. The record is readable by any authenticated user through a Gateway
command with CLI exposure. This read command is a **drafting
addition** ratified with this contract (PRD §12.1), not a D8
mandate: D8 binds only that any surface exposing the value goes
through official tooling. When a webUI surface consumes the read, a
mapping row is added to `tool-gateway-mapping.md` by amendment —
the same route §4.4 already binds for the conversion command.
Components branch on the read value only.
3. The record is immutable except by the §4 conversion transition.
Editing it by direct database access, config file, environment
variable, or wizard re-run is non-conformant (contract 3 §2.3:
changing mode later is conversion, not a wizard re-run).
## 3. Per-mode obligations (owner map)
The PRD mode table binds four rows; this contract assigns each an
owning contract so no obligation is unowned and none is bound twice:
| Obligation | Standalone | Enterprise | Owner |
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
The Standalone brains row states the default layout, not the only
valid one: the D14 per-user split is a MAY in Standalone with keeping
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
secrets are equally valid Standalone configuration. Both prepared
states are therefore themselves valid Standalone states — the fact
§4.3 relies on.
In Enterprise steady state, a flat-file secrets backend is
non-conformant; the platform refuses to start Enterprise-mode
components against a flat-file secrets configuration (fail-closed, not
warn-and-run).
## 4. Conversion transition
1. **Direction and terminality.** The only transition is
`standalone → enterprise`. `enterprise → standalone` does not exist:
there is no command, no admin override, and no support path. An
attempt is refused with the precondition/state error class of the
command envelope (`tool-gateway-mapping.md` §4.2).
2. **Preconditions (all verified before the record changes):**
- Secrets: OpenBao/Vault is configured and reachable, and every
required secret is served from the Vault backend — none from a
flat-file backend. Secret migration completes before conversion;
this contract does not define the migration tooling, only the
gate.
- Brains: the per-user brain split required by the Enterprise row of
§3 is established for **every** existing user (or the deployment
already kept the split, the D14 recommended default). Brain
partitioning mechanics are contract 7; this contract binds only
that the split is complete before the mode flips.
- Identity: at least one platform administrator account exists that
is active in identity-contract terms — authenticated capability,
not banned, not deactivated (identity §2, §5). And the conversion
request carries a **configuration acknowledgment**: the current
canonical values of registration mode and per-provider JIT
enablement (identity §2.2, §4.1), echoed back in the request. A
mismatch between the echoed values and the canonical values at
verification refuses the conversion. This acknowledgment record
is conversion-local, owned by this contract, and stored with the
§4.4 audit event as the precondition evidence; it adds no
identity-contract obligation and no mode-specific identity
default — identity's own defaults remain valid states.
3. **Preparation state and the flip boundary.** Preparatory work is
tracked durably: each preparation unit (§1.4) records its
completion in a preparation table keyed by (bootstrap epoch, unit
identity — the secret's path, the user's id), written in the same
transaction as the unit's own effect where the unit's backend
allows it, and reconciled from the backend's actual state where it
does not (a secret already served by Vault, a brain already split,
is complete regardless of the table). Units are at-most-once per
key and re-runnable across attempts. **Standalone-safe
representation:** every preparation unit moves the deployment into
a state that is itself valid Standalone configuration (§3 note), so
an interrupted preparation leaves a fully operational Standalone
deployment reading its state through the ordinary contracts — no
rollback, fencing, or special Standalone read path is needed, and
no component behavior may key on "preparation in progress".
**The flip:** one transaction that (a) locks the mode record, (b)
re-verifies every §4.2 precondition after acquiring the lock, and
(c) writes the mode record and the §4.4 audit event. Any re-check
failure aborts with no write. External state that changes after the
re-check but before commit is bounded by the transaction window;
an external backend (Vault) failing after conversion is an
Enterprise runtime fault handled by §3's fail-closed steady-state
rule, not a conversion defect. An interrupted or failed conversion
leaves the record `standalone` and the platform fully operational;
there is no intermediate mode and no half-converted state
observable through the record.
4. **Authority and audit.** Conversion is a platform-administrator
command carrying an explicit irreversibility acknowledgment in its
request (distinct from the §4.2 configuration acknowledgment). It
is an official Gateway/CLI command (D8): when built, it is added to
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
§3.3). The transition emits an audit event (actor, prior mode, new
mode, precondition evidence reference including the configuration
acknowledgment) in the same transaction as the record change; the
event survives indefinitely. A refused attempt emits a refusal
event naming the failed precondition class and actor, with no
mode-change event.
## 5. v1 obligations (non-foreclosure)
v1 ships Standalone only (D11); the conversion command is deferred
work. v1 still MUST:
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
refused value for bootstrap — v1 bootstrap accepts `standalone`
only. The wizard reads the record (contract 3 §2.3); nothing in v1
writes it after bootstrap.
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
record.
3. Not foreclose conversion: the v1 platform database holds no
sensitive user content — sensitive categories live in the owning
user's brain, and postgres holds structure, consent records, and
pointers only (the D14 boundary, PRD §7). Custody mechanics are
contract 7's; this contract binds the boundary itself here so v1
cannot ship a layout that makes the §4.2 brain precondition
unsatisfiable, and §6.3 gives it a stable witness that does not
depend on contract 7's internals. Conversion implementation
additionally requires contract 7 ratified.
4. Not foreclose federation: v1 components accept exactly the two §1.1
values wherever a mode value is parsed and refuse any other value
**before side effects** — a refused configuration, not undefined
behavior and not a crash mid-operation. Forward compatibility lives
in storage and architecture, not in parser speculation: the mode
record's storage is not structurally locked to two values (no
database-level two-value enum), and any future value (e.g. a
federation mode) is defined by a versioned amendment to this
contract before any component accepts it. The PRD defers
federation's shape entirely; this contract does not presume it
arrives as a third mode value.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Mode-record witness (v1):** after bootstrap the mode is readable
via the Gateway command and CLI and equals the bootstrap-recorded
choice; bootstrap with mode `enterprise` is refused; bootstrap with
any unknown mode value is refused before side effects (§5.4).
2. **Writer-coverage witness (v1):** the mode record's writer set is
closed by the same three-prong static assertion contract 1 §6.3(b)
defines — symbol, class-table literal, and raw-execution prongs
with its allowlist composition rules — scoped to the
`platform_mode` table, with a writer allowlist containing exactly
the bootstrap writer in v1 (and exactly plus the conversion command
at the conversion milestone). Companions: a crafted direct write
attempted in a test fails and leaves the record unchanged; a
mode-resolution assertion that no shipped component derives mode
from feature state (mode reads occur only through the §2.2 read
surface — static assertion over Gateway, CLI, bootstrap, and
repository sources).
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
style of contract 1 §6.2 that the platform database schema contains
no sensitive-content column — the §5.3 boundary — stable regardless
of contract 7's internals (contract 7 §7 carries the full custody
witnesses).
4. **No-downgrade witness (conversion milestone):** with mode
`enterprise`, a conversion request to `standalone` (and any crafted
mode-write) is refused with the precondition/state error class and
no record change.
5. **Precondition witnesses (conversion milestone),** each refused
with no record change and no partial mode effect, parameterized
over both OpenBao and Vault where secrets are involved:
(a) secrets backend unreachable; (b) one required secret still
flat-file backed (migration incomplete); (c) one unpartitioned user
brain in a **multi-user** deployment where every other user is
partitioned; (d) no active platform administrator (the only admin
banned or deactivated); (e) configuration acknowledgment missing or
mismatching the canonical registration/JIT values; (f) actor not a
platform administrator (authorization refusal); (g) irreversibility
acknowledgment absent. And the steady-state rule: an
Enterprise-mode component started against a flat-file secrets
configuration refuses to start (§3).
6. **Interruption and fence witnesses (conversion milestone):** fault
injection aborting conversion after each preparation unit and
between preparation and flip leaves the record `standalone` and the
platform operational in Standalone semantics (§4.3
Standalone-safety), and a re-attempt completes without duplicating
prepared state (at-most-once keys); a precondition invalidated
after preparation but before the flip (a secret reverted to
flat-file) is caught by the in-transaction re-check and refused.
7. **Audit witnesses (conversion milestone):** a completed conversion
has exactly one mode-change audit event, same-transaction with the
record change (transaction linkage asserted), carrying actor, prior
mode, new mode, and the precondition evidence reference including
the configuration acknowledgment; a failed attempt has a refusal
event naming the failed precondition class and no mode-change
event; the mode-change event remains queryable after subsequent
unrelated audit activity (retention probe).
8. **Mapping witness (conversion milestone):** the conversion command
and the mode read command each have their
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
before the commands ship.
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§5): v1 implements the **mode record and its immutability
only** — bootstrap records `standalone`, the `enterprise` value is
reserved and refused, and the conversion command itself is deferred
to the Enterprise milestone, consistent with D11's deferred list.
v1 carries three obligations beyond the record: the closed writer
assertion, the D14 column boundary, and the unknown-value refusal
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
conversion work. Alternative if rejected: build the conversion
command inside v1 — rejected because D11 scopes v1 to the Standalone
slice and conversion depends on contract 7 custody mechanics that
are themselves not in the v1 slice.
-58
View File
@@ -456,61 +456,3 @@ this line is weakened.
- Negative tests prove roll-up endpoints cannot mutate state and that a
reader sees aggregates only over workspaces they are authorized on
(no cross-tenant existence oracles).
## 9. Amendment A2 — company visibility classes and the company directory
**Status:** amendment to Amendment A1, added by reviewed PR under Ruling 4b
(operator ruling, 2026-08-27; decision owner Jason; recorded in the webui-audit
lane RULINGS.md). Everything in §§18 remains binding verbatim, with exactly
the two express modifications below. Nothing else is weakened. The detailed
contract text lives in the hierarchy schema contract
(`hierarchy-schema.md` §2.8, §5.5, §6.9); this amendment changes only what A1
itself permits, so that contract does not stretch A1 by interpretation.
### 9.1 What A2 modifies in A1
1. **Class data (extends §8.1.2's first constraint).** The tenancy/authorization
structure record class additionally carries **visibility-class data**: the
single column `companies.visibility`, values `private` | `directory`
(hierarchy schema §2.8). Visibility is disclosure data about the class's own
nodes — what a company row reveals about its own existence — and is part of
the class's tenancy/authorization purpose. It is not business or
orchestration payload. §8.1.2's payload prohibition is widened for nothing
else: hierarchy tables still MUST NOT carry task, plan, or any other
business/orchestration payload, and this amendment admits exactly this one
column.
2. **The company directory (extends §8.1.3's function enumeration).** The
hierarchy serves one additional, express, narrow runtime function: the
**company directory** — a read-only disclosure listing of exactly the
companies whose `visibility = 'directory'`, revealing existence, name, and
slug to every authenticated user of the deployment and nothing else. It
mutates nothing, confers no authority, evaluates no grant down the chain,
and aggregates nothing (it is not a roll-up). §8.3's
no-cross-tenant-existence-oracle acceptance is narrowed by exactly this one
ratified carve-out: the directory is the sole permitted existence
disclosure, and it discloses only directory-class companies (witnessed in
hierarchy schema §6.7 and §6.9). Private companies remain undisclosed to
non-granted subjects everywhere, including the directory.
### 9.2 What A2 explicitly does not change
1. Content access stays grant-only under the RBAC grant model contract:
directory listing discloses existence, never content, membership, or any
authority (Ruling 3 unchanged; hierarchy schema §2.8).
2. **No join-request surface is authorized.** Ruling 4b decision 5's
see-and-ask-to-join flow is a follow-up contract in its entirety —
including the ability to submit a request. A2 admits exactly the
read-only listing of §9.1.2 and nothing more; hierarchy schema §2.8
states the invariants that pre-bind the future flow contract, and that
contract must itself amend this enumeration before any join-request
runtime surface exists.
3. Visibility changes are hierarchy mutations on the existing §8.2.3 audited
mutation path — audited maintenance of the class's own structure in
§8.1.3's sense, not a further runtime function. Authorization for them is
defined in hierarchy schema §5.5 (platform admins plus the future
company-CRUD capability; owner-as-such cannot publish).
4. Company creation is unchanged and always yields `visibility = 'private'`
(onboarding wizard §5.2); this amendment adds no creation path and no
default-open disclosure.
5. Every other constraint of A1 — §8.1.2's remaining bullets, §8.2 in full,
and §8.3's other acceptance criteria — is untouched.
File diff suppressed because it is too large Load Diff
-259
View File
@@ -1,259 +0,0 @@
# RBAC Grant Model Contract
Status: DRAFT — awaiting ratification (webui-audit S2, contract 2 of 9).
Authority: PRD Part I §4 ("Granular RBAC: admins restrict access per company,
estate, and project; grants are evaluated down the chain") and the
native-kanban SOT Amendment A1 (§8.1.3 RBAC evaluation, §8.3 acceptance 2).
This document defines the grant vocabulary, evaluation semantics, and
revocation propagation that the hierarchy schema contract
(`docs/requirements/hierarchy-schema.md`, contract 1) attaches to. Contract 1
pins the `hierarchy_grants` table shape and defers the `role` vocabulary and
the meaning of "authority" here; the identity contract
(`docs/requirements/identity-lifecycle.md` §1.4) pins that account creation
grants nothing.
Revision 2 (independent review, GLM 5.3): §1.1 consequence analysis
completed — the two existing platform-admin bypass code paths are named as
non-conformant and §7.4 retires them; team grant subjects suspended pending
a team contract (§1.4, §3.33.4, §7.5); no-self-escalation restated with
its true rationale and a constructible observable (§4.2, §7.7);
node-creation seeding scoped to the bootstrap path, resolving the §7.7/§4.3
contradiction; A1 quotation corrected; audit-field provenance corrected;
principal-position consequence named (§1.3); membership-row,
fail-closed-fault, and existence-oracle observables added (§7);
role-string namespacing rule added (§4.5); ruling request now names the
interpretive resolution of PRD "admins".
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
each hierarchy level confers, how grants evaluate down the chain, how
revocation propagates, and who may manage grants. Out of scope: the hierarchy
tables themselves (contract 1), workspace-internal membership and its
role/capability vocabulary (native-kanban SOT REQ-ID-001 and its implementing
schema), roll-up projection semantics (contract 8), wizard seeding
(contract 3), the team model (suspended here; see §1.4).
## 1. Three authority layers, none substitutable
1. **Platform role** (`users.role`, better-auth: `member` | `admin`) governs
instance administration — user management, system settings, provider
configuration. It is not tenancy authority: holding platform `admin`
confers **no implicit hierarchy grant and no workspace authorization**.
An operator who should see tenant content holds an explicit, audited
grant like anyone else. This is the deny-by-default consequence of A1
§8.1.3 ("not a bypass of workspace authorization"). `AdminGuard`'s
`role === 'admin'` check on admin endpoints stays the platform role's
only meaning. **Two shipped code paths violate this rule today and are
implementation defects this contract makes non-conformant:** (a) the
command authorization service short-circuits every command scope to
allowed for platform admins
(`apps/gateway/src/commands/command-authorization.service.ts`,
`hasScope` returning true when `role === 'admin'`), and (b) the MCP
scope derivation maps platform `admin` to tenant-admin MCP scopes
including task create/update
(`apps/gateway/src/mcp/mcp.service.ts`,
`deriveMcpToolScopesForUser`). Ratifying this contract revokes both;
§7.4 names them as the surfaces the deny-by-default test retires.
2. **Hierarchy grants** (`hierarchy_grants`, contract 1 §3) declare tenancy
authority at company, estate, or platform-project scope and evaluate down
the chain to workspace-scoped authorization (§3 below).
3. **Workspace membership** (SOT REQ-ID-001) remains its own mechanism.
A chain grant confers command authorization over descendant workspaces;
it does not create membership rows, and row-level principal positions
(task owner, proposer, decision actor) still require ACTIVE workspace
membership exactly as REQ-TEN-001/REQ-ID-001 acceptance states.
Consequence, stated so implementing PRs do not weaken REQ-TEN-001 to
remove the friction: a chain-granted actor who is not a workspace member
may issue the write commands their role implies but cannot occupy a
principal position — any command taking a principal argument must name
an ACTIVE member of the target workspace (§7.2 enumerates this cell).
4. **Team grant subjects are suspended.** Contract 1 §3.1 reserves a
`team_id` attachment point, but no ratified contract yet defines the
team it would bind: the only existing `teams` table is the legacy global
Brain table (own authority columns, no workspace binding, not
repurposed per contract 1 §1.3), while the SOT's teams are
workspace-bound (REQ-ID-001) — and a workspace-bound team holding a
company-level grant would be a cross-workspace authority group nothing
has ratified. Until a team contract defines the subject (which table,
which membership rows, and its relation to D2/REQ-ID-001), creating a
grant with a team subject MUST be refused at the command surface (the
schema column remains, per contract 1). §3's evaluation semantics for
team-conferred grants are specified now so the team contract activates
them without amending this one.
## 2. Role vocabulary
One vocabulary at every hierarchy level, totally ordered — a higher role
includes everything below it:
1. `viewer` — read: sees the node, its subtree structure, and the roll-up
aggregates over descendant workspaces (within contract 8's carve-out
bounds); read access to descendant workspace content per the SOT's read
command families. No mutation of anything.
2. `member` — work: everything `viewer` has, plus write authorization for
business/orchestration command families in descendant workspaces (the
concrete command-family mapping is implementation work under SOT
REQ-ID-001; this contract pins that `member` maps to the workspace write
families and nothing structural).
3. `owner` — structure: everything `member` has, plus hierarchy mutations on
the subtree (create/rename/delete child nodes, transfers per §5), and
grant management on the node and its subtree (§4).
No other value is valid in `hierarchy_grants.role`; the column is
constraint-checked against exactly these three. Extending the vocabulary is a
contract amendment, not an implementation decision.
## 3. Evaluation semantics
1. **Deny by default.** No grant on any ancestor → no authority. There are
no implicit grants: not from platform role (§1.1), not from creating a
node (§4.3), not from workspace membership (membership without a chain
grant confers exactly what the SOT's own membership rules confer inside
that workspace, nothing up the chain).
2. **Down-the-chain only.** A grant on a node applies to that node and its
entire descendant subtree. Nothing evaluates upward or sideways: a grant
on an estate says nothing about the parent company or sibling estates.
3. **Effective role = maximum.** A subject's effective role at any node is
the highest role among grants held directly by the subject's user on
that node or any ancestor — and, once the team contract activates team
subjects (§1.4), grants held by any team the user is a member of on that
node or any ancestor. Roles never subtract — there is no negative/deny
grant in this model; revocation is deletion (§6).
4. **Team grants follow live membership** (specified now, active only per
§1.4). A team grant confers its role on the team's current members,
evaluated at decision time. Leaving the team is loss of the grant with
§6's propagation bound.
5. **Live evaluation, fail closed.** Authorization decisions derive from the
live grant and team-membership rows (or from a cache that is invalidated
in the same transaction as any grant/membership/hierarchy mutation). A
decision path that cannot read grant state denies. No materialized ACL is
ever authoritative.
6. **Tenant context stays derived from authenticated authority**
(REQ-TEN-001). The chain adds where grants can be declared; a workspace
request is still authorized against that workspace, with the chain
contributing the effective role — never letting the chain become what A1
§8.1.3 forbids: "a bypass of workspace authorization".
## 4. Grant management
1. Creating, changing, or revoking a grant on a node requires effective
`owner` on that node (directly or via any ancestor).
2. **No self-escalation.** A grant manager cannot create a grant with a role
higher than their own effective role on the target node. Under the §2
vocabulary this rule is currently implied by §4.1 (managers are `owner`,
the top role — no constructible grant exceeds it); it is stated
explicitly so it survives any future amendment that decouples
grant-management authority from role height. Its observable is the §7.7
audit invariant, not a refusal test.
3. **Bootstrap of authority is explicit; inheritance covers the rest.**
Creating the first company (the wizard path, contract 3) and any
top-level company creation MUST name the initial `owner` grant in the
same audited operation — a top-level node has no ancestor to inherit
from, so without this the node would be unownable. Creating a child node
(estate, platform-project, workspace) requires effective `owner` on the
parent (§2.3) and confers no automatic grant; the creator's authority
over the new node already follows from §3.2 down-the-chain evaluation.
The creating command MAY additionally name an explicit initial grant for
a child node; it is not required to.
4. Every grant mutation is a semantic audit event under contract 1 §5.2's
guarantees, extended by this contract with two further fields: the event
carries actor, verb, target, **subject, and role** (subject and role are
this contract's addition; contract 1 §5.2 does not enumerate them).
5. **Role strings are namespaced.** `viewer`/`member` exist at hierarchy
level, `member`/`admin` on `users.role`, and the current command layer
uses a third `viewer|member|admin` vocabulary — same strings, different
meanings. Any serialized role string (audit events per §4.4, API
responses, logs) MUST identify its layer (e.g. `hierarchy:owner`,
`platform:admin`); a bare role string in a serialized artifact is
non-conformant.
## 5. Transfer authority (completes contract 1 §4.2)
"Authority over BOTH the source and the destination parent" means: effective
`owner` on the current parent node (or an ancestor) AND effective `owner` on
the destination parent node (or an ancestor), evaluated at transfer time in
the transfer's own transaction. One subject must hold both; two cooperating
half-authorized subjects are not a transfer protocol this contract defines.
## 6. Revocation propagation
1. Revoking a grant (deleting the row), removing a user from a team that
carries a grant (once team subjects activate, §1.4), or the cascade
deletion of a node's grants during node deletion (contract 1 §3.3) all
propagate identically: the authority derived from that grant is gone for
every descendant workspace.
2. **Bound:** the next authorization decision on any affected transport
decides against the revoked grant. Concretely: no new HTTP/MCP command
authorized by the revoked grant after the revoking transaction commits;
an open Socket.IO connection whose subscriptions depend on the revoked
grant is re-evaluated within 30 seconds or at its next inbound message,
whichever comes first (same bound as the identity contract's §7.1
deactivation rule; same mechanism may serve both).
3. Revocation is subtractive only in effect, not in representation: the
evaluator never needs tombstones; deletion of the row is the revocation.
## 7. Verification requirements
Binding on the implementing PRs (extends A1 §8.3 acceptance 23 and
contract 1 §6):
1. Vocabulary: the role CHECK constraint rejects any value outside
`viewer|member|owner` (real-PostgreSQL witness, `ci-postgres` service in
the `test` CI step).
2. Per-level conferral: for each of the three levels × three roles, a grant
yields exactly the implied workspace authorization in a descendant
workspace and nothing in a non-descendant workspace (the A1 §8.3
"exactly the permissions the chain implies" matrix, enumerated). The
matrix includes: a chain grant creates zero workspace-membership rows
(assert row counts); a chain-granted non-member is refused as the
principal argument of any principal-taking command while their
non-principal writes succeed (§1.3); structure reads leak no existence
of nodes the reader holds no grant on (no cross-tenant existence
oracle, A1 §8.3 acceptance 3).
3. Ordering: `owner``member``viewer` behaviorally — each higher role
passes every lower role's positive cases.
4. Deny-by-default: platform `admin` with no grant reaches no tenant
content — asserted against the two §1.1 non-conformant surfaces after
their retirement: the command-authorization admin short-circuit and the
MCP tenant-admin scope derivation both gone (a platform admin with no
grant is refused workspace commands and receives no tenant MCP scopes);
workspace member with no chain grant gains nothing outside SOT
membership semantics; fresh account reaches nothing (identity contract
§1.4 cross-check).
5. Team subjects: while suspended (§1.4), creating a team-subject grant is
refused at the command surface. On activation by the team contract:
user-direct and team-conferred grants combine to the maximum; team-leave
drops authority within the §6.2 bound; decision-time evaluation
witnessed (grant added → next decision allows; no restart or re-login
required).
6. Revocation: each revocation path in §6.1 denies the next command on
every transport; the socket bound is measured; a cached-authorization
implementation proves transactional invalidation (grant revoked and
decision made on two distinct physical connections). Fail-closed fault
witness for §3.5: with grant state unreadable (fault injection), the
decision denies.
7. Grant management: non-`owner` cannot mutate grants; top-level company
creation without the named initial `owner` grant is refused, while child
node creation under ancestor authority succeeds without one (§4.3 both
directions); every mutation produces its audit event with the §4.4
fields. Self-escalation observable: over the audit event stream, every
grant-create/change event's role is ≤ the acting user's effective role
on the target at event time (reconstructable invariant, not a refusal
test — see §4.2).
8. Transfer: both-sides `owner` accepted, each single-side case refused
(completing contract 1 §6.5).
## Ruling request
Ratify sections 17 as written, with one decision embedded and one
interpretive resolution named:
- Decision: platform `admin` confers no implicit tenant access — operators
see tenant content only through explicit, audited grants (§1.1), which
retires the two existing admin bypass paths named there. Say "agreed" or
name the implicit access you want platform admins to keep.
- Interpretive resolution (for visibility, not a separate question): PRD
Part I §4 says "admins restrict access per company, estate, and project";
this contract resolves "admins" as hierarchy `owner`s (§4.1), not
platform admins. A1 §8.1.3 does not attribute grant declaration to
platform admins, and the §1.1 decision above is what makes this reading
binding.

Some files were not shown because too many files have changed in this diff Show More