Compare commits

..
Author SHA1 Message Date
code-be-02 2451cb4f98 fix(git-tools): sanitize WP5b for the public-package gates (review identity scrub + tool index)
ci/woodpecker/pr/ci Pipeline was successful
verify-sanitized denylist: an operator-identity token in a ci-queue-wait
comment (spec finding references genericized - the framework-PR
firewall applies to shipped files). check-tools-index: repo-decl.sh
shipped means documented in the resident index in the SAME commit, and
executable. Full sanitization CI step replayed green locally
(verify-sanitized, resident budget x2, enumeration guard, tools index
x2).
2026-08-25 07:34:44 -05:00
code-be-02 87c08d25d4 feat(git-tools): consume .mosaic/repo.json declarations in compat mode (T51 WP5b, closes #1413)
Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md
(brain repo) sections 4 (consumption contract), 5.1/5.3/5.4, 1.2a.

New shared lib repo-decl.sh: one consumption surface for the wrappers.
Loads .mosaic/repo.json, classifies absent/invalid/valid via the WP1
validator (5.1: ALL consumers invoke the same script; 5.4 point 1:
invalid = ABSENT + loud error naming file/key/reason), extracts the
consumed fields, normalizes origin for 5.3 comparisons, validates
transitions per 4.2 (a CLI flag is input, not authority), and resolves
host:/ paths FAIL-CLOSED while MOSAIC_HOST_ROOT is unset (1.2a — no
WP5b consumer resolves a path today; the helper exists so the first
that needs one cannot guess). v1 declarations validate but carry no
consumable fields: legacy behavior with a note.

pr-create.sh: base precedence -B (validated as an allowed transition)
-> declared integration_trunk -> legacy WP5a forge-default floor
(unmanaged/absent/v1 per 4.3 reversible class, warn + legacy). Remote
mismatch vs canonical_remote refuses (write path, 5.3).

pr-merge.sh: transition validation per declared flow; the hardcoded
main/next target check survives only for undeclared repos during the
rollout window (4.3 irreversible class, loud warning). Remote mismatch
refuses.

ci-queue-wait.sh: ROUTE CONTEXT only (4.1, C4/jarvis F8/DR2 R9) —
branch-selection semantics untouched, absence silent, invalid reported
per 5.4.

mosaic-worktree.sh: staged rule 4.4 — invalid declaration fails
branch-creation loud, absent warns and proceeds, valid contributes
policy ADVICE only (4.5: placement stays derived; the advisory
worktree_root comparison runs only when MOSAIC_HOST_ROOT is set, per
1.2a warn-and-omit). Consuming via a self-located source line and
set -u-safe env access.

mutate-push-guard.sh: NO change — spec 4.1 names it for push-to-trunk
protection, but the tool as shipped is a mutation-coverage meta-tool
for push-guard.sh with no trunk-protection logic to consult; the
disposition is documented in #1413 rather than force-feeding a fake
consumption.

All wrappers degrade SILENTLY to legacy behavior when repo-decl.sh is
absent from a copied tool subset (a legal deployment shape; a note
there broke single-line diagnostic contracts in
test-pr-merge-message-field).

test-repo-decl-consumption.sh: 67 assertions, green x2, hermetic; runs
RED against pre-change tools via WP5B_TOOLS (49 red there — red-first
evidence). Covers every 5.4 hostile-input class applicable to consumed
fields (missing, malformed, unknown schema_version, v1, unknown key,
bad refs, cross-field, userinfo URL, remote mismatch) plus transition
validation, base precedence, absence policies, staged worktree rule,
route context, and 1.2a fail-closed. Enumerated on the S1 surface
(enumeration guard green: population 71, enumerated 57).

Neighbor suites green: WP5a fallback suite, all six pr-merge suites,
worktree large-repo, help/login/interactive suites. S1 chain failures
(fleet-units systemd bus, invariant_r host Pi version, pr-edit
credential-helper env) reproduce identically at origin/next —
environmental, untouched by this diff. No TS/vitest lane touched
(shell tools only).
2026-08-25 07:34:44 -05:00
185 changed files with 4009 additions and 29848 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/
+4 -23
View File
@@ -38,12 +38,10 @@ when:
- event: push
branch: main
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
# deployment — never pull_request). This PR pipeline deliberately gets no
# remote-cache credentials: an untrusted PR must not be able to write to (or
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
# local cache only, which is the intended behavior here.
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
# This avoids from_secret which is blocked on pull_request events.
# If the env vars aren't set, turbo falls back to local cache only.
steps:
install:
@@ -254,23 +252,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
+47 -124
View File
@@ -32,11 +32,6 @@ variables:
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
# #1407: ONE shared anchor for all three image steps. A second main-only
# anchor previously gated build-web/build-appservice, so next-lane pushes
# published gateway sha images with no web/appservice counterpart — no
# sha-parity set existed for next-lane containerized deploys. Every image
# step now builds on next too (sha-only destinations, enforced per step).
- &image_build_when
- event: tag
- event: [push, manual]
@@ -49,6 +44,16 @@ variables:
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main, next]
@@ -68,13 +73,6 @@ steps:
# being empty) and on any incomplete verification.
verify:
image: *node_image
environment:
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
TURBO_API: https://turbo.mosaicstack.dev
TURBO_TEAM: mosaic
TURBO_TOKEN:
from_secret: turbo_token
commands:
- *enable_pnpm
# (a) Commit identity: the provider's claimed SHA must equal the actual
@@ -110,13 +108,6 @@ steps:
build:
image: *node_image
environment:
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
TURBO_API: https://turbo.mosaicstack.dev
TURBO_TEAM: mosaic
TURBO_TOKEN:
from_secret: turbo_token
commands:
- *enable_pnpm
- pnpm build
@@ -407,96 +398,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,12 +457,10 @@ 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
when: *image_build_when
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -575,17 +474,8 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next appservice publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: appservice 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/appservice:$CI_COMMIT_TAG"
@@ -602,5 +492,38 @@ 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: *main_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" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
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,106 +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 baseline (contract 1 §6.3(a)).
*
* M4-1b-i ships the audit event + outbox machinery with NO mutation routes:
* the hierarchy command family (controllers + DTOs) lands in M4-1b-ii once
* contract 2 merges. This witness enumerates every route the AppModule graph
* declares and pins that baseline, so a hierarchy route appearing before its
* command-family witnesses exist fails here first. When M4-1b-ii lands, this
* baseline is replaced by an exact inventory of the command family.
*/
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;
}
describe('hierarchy route-inventory baseline (§6.3(a))', () => {
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('declares zero hierarchy mutation routes before M4-1b-ii', () => {
const hierarchyRoutes = inventory.filter((r) =>
/hierarch|compan|estate|platform[-_]?project/i.test(r.path),
);
expect(
hierarchyRoutes,
'a hierarchy route landed without replacing the §6.3(a) baseline with a command-family inventory',
).toEqual([]);
});
it('HierarchyModule itself declares no controllers', () => {
expect((Reflect.getMetadata('controllers', HierarchyModule) ?? []) as unknown[]).toEqual([]);
const hierarchyControllers = collectControllers(HierarchyModule);
expect(hierarchyControllers).toEqual([]);
});
});
-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],
@@ -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 repositories (M4-1b-ii) are the allowlisted writers and
* call into this on their 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,17 +0,0 @@
import { Module } from '@nestjs/common';
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
/**
* Hierarchy (tenancy/authorization structure) feature module.
*
* M4-1b-i ships the audit event + outbox machinery only (contract 1 §5.2).
* The hierarchy command family — controllers, DTOs, and the allowlisted
* class-table repositories — lands in M4-1b-ii once contract 2 (RBAC grant
* model) merges; until then this module exposes no routes, which the
* route-inventory witness asserts.
*/
@Module({
providers: [HierarchyAuditRepository],
exports: [HierarchyAuditRepository],
})
export class HierarchyModule {}
-8
View File
@@ -12,19 +12,12 @@ 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';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
// every request body into a 400 at first use; this surfaces it at boot.
assertValidationPipeSeesDtoDecorators();
if (!process.env['BETTER_AUTH_SECRET']) {
throw new Error('BETTER_AUTH_SECRET is required');
}
@@ -69,7 +62,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');
-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}`);
}
@@ -1,104 +0,0 @@
/**
* Boot-time ValidationPipe metatype self-check (#1391).
*
* The check exists to fail loud at boot when the global pipe cannot see a
* guarded DTO's decorated properties — the #436 class-erasure signature and
* its dependency-graph cousins. Red/green arms:
*
* GREEN real module state: BootstrapSetupDto's three properties are
* decorated and visible through the globalThis-shared storage.
* RED a control class with NO decorators (the erasure shape): the
* check throws PipeMetatypeCheckError naming every property.
* RED-2 a control where one property is decorated and two are not: the
* error names exactly the missing two — the miss list is precise,
* not a blanket failure.
*/
import { describe, expect, it } from 'vitest';
import { IsString } from 'class-validator';
import {
assertValidationPipeSeesDtoDecorators,
PipeMetatypeCheckError,
} from './validation-pipe-check.js';
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
});
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
// Simulate metatype erasure: an undecorated class standing where a
// decorated DTO should be. Redefine the guard table for the test by
// importing the module and pointing its table at the eroded class —
// the check reads the table at call time, so a fresh module instance
// with a swapped table reproduces the boot failure deterministically.
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
class ErodedDto {
name?: string;
email?: string;
password?: string;
}
const original = PIPE_GUARDED_DTOS[0];
expect(original).toBeDefined();
// Swap in the eroded target (same declared properties, zero decorators).
(
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
).splice(0, PIPE_GUARDED_DTOS.length, {
name: 'ErodedDto',
target: ErodedDto,
properties: ['name', 'email', 'password'],
});
try {
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
try {
assertValidationPipeSeesDtoDecorators();
} catch (err) {
const message = err instanceof Error ? err.message : '';
expect(message).toContain('ErodedDto.name');
expect(message).toContain('ErodedDto.email');
expect(message).toContain('ErodedDto.password');
}
} finally {
// Restore real module state for any later test in this file.
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
}
// And confirm the restore is real.
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
});
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
class HalfErodedDto {
@IsString()
name?: string;
email?: string;
password?: string;
}
const original = PIPE_GUARDED_DTOS[0];
(
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
).splice(0, PIPE_GUARDED_DTOS.length, {
name: 'HalfErodedDto',
target: HalfErodedDto,
properties: ['name', 'email', 'password'],
});
try {
try {
assertValidationPipeSeesDtoDecorators();
expect.unreachable('partially decorated DTO must fail the boot check');
} catch (err) {
const message = err instanceof Error ? err.message : '';
expect(message).toContain('HalfErodedDto.email');
expect(message).toContain('HalfErodedDto.password');
expect(message).not.toContain('HalfErodedDto.name has no');
}
} finally {
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
}
});
});
-94
View File
@@ -1,94 +0,0 @@
import 'reflect-metadata';
import { getMetadataStorage } from 'class-validator';
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
/**
* Boot-time self-check: the global ValidationPipe must be able to SEE the
* decorated properties of the DTOs it guards (#1391, #436 class).
*
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
* `import type` class erasure (#436), or a dependency graph where the
* controller's decorators and the application's route enhancers disagree
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
* ValidationPipe's whitelist treats every property as forbidden. The first
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
* worst place to discover wiring damage: the operator cannot tell a broken
* payload from a broken daemon.
*
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
* decorated properties, the gateway refuses to start with a named cause.
* It catches the whole class — erasure, decorator metadata loss — on every
* host, at the moment the damage exists rather than at first use.
*
* Storage sharing note: class-validator keys its metadata storage on
* globalThis, so duplicate package copies do NOT hide metadata (measured,
* #1391 diagnosis). What hides it is losing the metatype itself, which is
* what this asserts against.
*/
/**
* DTOs the global pipe guards, mapped to the properties the whitelist must
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
* decorators register metadata keyed on the constructor, and its executor
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
* through `prototype` returns zero. Extend when adding DTOs to the app.
*/
export const PIPE_GUARDED_DTOS: Array<{
name: string;
target: abstract new (...args: never[]) => unknown;
properties: string[];
}> = [
{
name: 'BootstrapSetupDto',
target: BootstrapSetupDto,
properties: ['name', 'email', 'password'],
},
];
export class PipeMetatypeCheckError extends Error {
constructor(missing: string[]) {
super(
'ValidationPipe metatype check failed: ' +
missing.join('; ') +
'. The global ValidationPipe cannot see decorated DTO properties — ' +
'every request body would be rejected as non-whitelisted. ' +
'Check for import-type erasure or decorator metadata loss in the ' +
'dependency graph (see issues #436, #1391).',
);
this.name = 'PipeMetatypeCheckError';
}
}
/**
* Assert the pipe's whitelist can see every guarded DTO's decorated
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
* each miss. Pure function of module state: no I/O, safe to call twice.
*/
export function assertValidationPipeSeesDtoDecorators(): void {
const storage = getMetadataStorage();
const missing: string[] = [];
for (const dto of PIPE_GUARDED_DTOS) {
// class-validator records constraints keyed on the DTO's constructor
// (decorators run on the class), and its executor resolves them via
// object.constructor. A property with no recorded metadata is invisible
// to the whitelist — whatever the cause — and fails here.
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
// strictGroups, groups?). No schema, always=true, no groups: every
// constraint regardless of grouping, which is what the whitelist sees.
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
const decorated = new Set(metadatas.map((m) => m.propertyName));
for (const property of dto.properties) {
if (!decorated.has(property)) {
missing.push(
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
);
}
}
}
if (missing.length > 0) {
throw new PipeMetatypeCheckError(missing);
}
}
@@ -1,123 +0,0 @@
import 'reflect-metadata';
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { TeamsController } from './teams.controller.js';
import { TeamsService } from './teams.service.js';
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
const teamBeta = { id: 'team-beta', name: 'Beta' };
// user-1 is a member of team-alpha only; admin-1 has role admin.
let currentUser: { id: string; role?: string } = { id: 'user-1' };
const teamsServiceMock = {
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
findAllForUser: vi.fn((userId: string) =>
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
),
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
isMember: vi.fn((teamId: string, userId: string) =>
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
),
};
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context
.switchToHttp()
.getRequest<{ user?: { id: string; role?: string } }>();
requestContext.user = currentUser;
return true;
},
};
describe('teams endpoints are scoped to membership', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [TeamsController],
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
beforeEach(() => {
currentUser = { id: 'user-1' };
vi.clearAllMocks();
});
afterAll(async () => {
await app.close();
});
it('GET /api/teams returns only the teams the user belongs to', async () => {
const response = await request(app.getHttpServer()).get('/api/teams');
expect(response.status).toBe(200);
expect(response.body).toEqual([teamAlpha]);
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
});
it('GET /api/teams returns every team for an admin', async () => {
currentUser = { id: 'admin-1', role: 'admin' };
const response = await request(app.getHttpServer()).get('/api/teams');
expect(response.status).toBe(200);
expect(response.body).toEqual([teamAlpha, teamBeta]);
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
});
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
expect(response.status).toBe(403);
});
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
expect(response.status).toBe(404);
});
it('GET /api/teams/:teamId returns the team for a member', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
expect(response.status).toBe(200);
expect(response.body).toEqual(teamAlpha);
});
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
expect(denied.status).toBe(403);
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
expect(allowed.status).toBe(200);
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
});
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
expect(response.status).toBe(200);
expect(response.body).toEqual({ isMember: false });
});
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
expect(response.status).toBe(403);
});
it('an admin can look up any membership', async () => {
currentUser = { id: 'admin-1', role: 'admin' };
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
expect(response.status).toBe(200);
expect(response.body).toEqual({ isMember: true });
});
});
+7 -45
View File
@@ -1,68 +1,30 @@
import {
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { TeamsService } from './teams.service.js';
type RequestUser = { id: string; role?: string };
@Controller('api/teams')
@UseGuards(AuthGuard)
export class TeamsController {
constructor(private readonly teams: TeamsService) {}
@Get()
async list(@CurrentUser() user: RequestUser) {
if (user.role === 'admin') {
return this.teams.findAll();
}
return this.teams.findAllForUser(user.id);
async list() {
return this.teams.findAll();
}
@Get(':teamId')
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
return this.getAccessibleTeam(teamId, user);
async findOne(@Param('teamId') teamId: string) {
return this.teams.findById(teamId);
}
@Get(':teamId/members')
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
await this.getAccessibleTeam(teamId, user);
async listMembers(@Param('teamId') teamId: string) {
return this.teams.listMembers(teamId);
}
@Get(':teamId/members/:userId')
async checkMembership(
@Param('teamId') teamId: string,
@Param('userId') userId: string,
@CurrentUser() user: RequestUser,
) {
// A user may always ask about their own membership; anything else is
// team-scoped like the other routes.
if (userId !== user.id) {
await this.getAccessibleTeam(teamId, user);
}
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
const isMember = await this.teams.isMember(teamId, userId);
return { isMember };
}
/**
* Team-scoped access: admins see any team; everyone else only teams they
* are a member of. NotFoundException when the team does not exist and
* ForbiddenException when the user lacks access (same convention as the
* projects controller).
*/
private async getAccessibleTeam(teamId: string, user: RequestUser) {
const team = await this.teams.findById(teamId);
if (!team) throw new NotFoundException('Team not found');
if (user.role === 'admin') return team;
const isMember = await this.teams.isMember(teamId, user.id);
if (!isMember) throw new ForbiddenException('Not a member of this team');
return team;
}
}
+1 -16
View File
@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
@Injectable()
@@ -56,21 +56,6 @@ export class TeamsService {
return this.db.select().from(teams);
}
/**
* List only the teams the user is a member of.
*/
async findAllForUser(userId: string) {
const memberRows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, userId));
const teamIds = memberRows.map((r) => r.teamId);
if (teamIds.length === 0) return [];
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
}
/**
* Find a team by ID.
*/
+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"]
+1014 -251
View File
File diff suppressed because it is too large Load Diff
-79
View File
@@ -1,79 +0,0 @@
---
kind: spec
status: active
---
# Mosaic Stack Roadmap
Companion to [docs/PRD.md](./PRD.md). Governed by the D11 rule: **every planned
phase appears here from day one, even as a placeholder** — nothing exists only
in heads. A phase marked _placeholder_ is a commitment to design it, not a
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
Part II contracts in the PRD) run as parallel tracks under their own issues
and are prerequisites where noted.
| Phase | Scope | Status |
| ----- | ------------------------------------------------------------------------------ | ----------------------------------------- |
| P0 | Current state on `next`: read-only dashboard, chat, auth/SSO login, admin tabs | shipped, evolving |
| P1 | **v1 slice** (PRD Part I §9) | next up |
| P2 | Connectors + comms + wizard expansion | placeholder |
| P3 | Full onboarding profile + M365 | placeholder |
| P4 | Enterprise mode + one-way conversion | placeholder |
| P5 | Federation | placeholder (deliberately undesigned, D3) |
## P0 — current state
What exists on `next` today: web dashboard (login/register/SSO, chat,
read-only projects/tasks, settings, admin user/system-health tabs), the
Gateway, the CLI-first framework tooling, and the fleet control plane. The
webUI audit (USC estate, webui-audit lane) measures the gap between this and
P1.
## P1 — v1 slice (D11)
1. Standalone onboarding wizard: system/company name, component choices,
initial user, initial estate + project, seeded examples, re-runnable.
2. Hierarchy core: company → estate → project → workspace → kanban, read-only
task bubble-up (kanban SOT Amendment A1 is the schema contract).
3. Basic RBAC on the hierarchy.
4. Minimal agent enrollment: one harness, API key, name/persona.
Prerequisites: KBN-100/101 schema foundation; the D8 tool inventory and
webUI→tool mapping (any missing tool is built first, D12).
## P2 — connectors + comms + wizard expansion (placeholder)
Email and drive connectors (Gmail/IMAP, Google Drive/OneDrive/Dropbox) with
granular agentic-access consent; comms integrations (Matrix/Discord/Slack)
including agent auto-enroll. Wizard gains the corresponding tabs (D4), plus
the D4 capabilities deferred out of P1's minimal slice: expanded agent
enrollment (OAuth login, multi-account, model choice with recommendation,
account assignment, comms auto-enroll) and the Standalone SSO/OIDC
configuration tab.
## P3 — full onboarding profile + M365 (placeholder)
Complete user onboarding profile (communication-style capture, optional
voice-matching interview) under the D14 custody rule; M365 connectors,
available to both deployment modes as ordinary connectors (same consent model
as the P2 connector class). The Enterprise install flow's M365 prominence
(D4) arrives with the Enterprise phase, P4.
## P4 — Enterprise mode + conversion (placeholder)
Enterprise install flow (org chart, RBAC focus, immediate OIDC, SSO
prominent); per-user brains with architectural isolation (D14); Vault
required; the one-way Standalone → Enterprise conversion (D3).
## P5 — federation (placeholder)
Connecting deployments: system-level config, assigned users, rights and
data-access control, trusts with boundaries, strict data access, exfiltration
monitoring. Explicitly not designed yet (D3); nothing in earlier phases may
foreclose it. Requires its own PRD + threat model before any scoping.
-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
File diff suppressed because it is too large Load Diff
-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.
File diff suppressed because it is too large Load Diff
-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.
-142
View File
@@ -372,145 +372,3 @@ The P0P3 canon does not authorize:
## 7. Global release evidence
P0P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
## 8. Amendment A1 — hierarchy parentage and RBAC chain above workspaces
**Status:** amendment to the ratified canon, added by reviewed PR under
decision D13 (operator ruling, 2026-08-25; decision owner Jason). It adds
parent structure ABOVE workspaces. Sections 17, every invariant in §3, and
every REQ above remain binding verbatim, with exactly one express modification:
the narrow portfolio-analytics carve-out stated in §8.2.4. Nothing else below
this line is weakened.
### 8.1 What is added
1. A platform hierarchy exists above workspaces:
**company/organization → estate → platform-project → workspace**. Each
workspace belongs to exactly one platform-project, each platform-project to
exactly one estate, each estate to exactly one company.
2. **Record class.** Hierarchy records (company, estate, platform-project,
their parentage edges, and hierarchy-level access grants) are a new,
explicitly named record class: **tenancy/authorization structure records**.
They are not business or orchestration records, so §3 invariant 10 and
REQ-TEN-001 do not apply to them and are not weakened by them — those two
requirements bind business/orchestration rows exactly as before.
Constraints on the new class:
- Hierarchy tables MUST NOT carry task, plan, or any other
business/orchestration payload — parentage, naming, and grant data only.
- A hierarchy record can never be the subject of work: it cannot be
claimed, ordered, gated, or referenced as a dependency by any
business/orchestration row.
- Hierarchy mutations flow through the same sole-writable-SOT, fail-closed,
audited mutation path as everything else (§8.2.3).
3. The hierarchy serves exactly two runtime functions, plus audited
maintenance of its own structure:
- **RBAC evaluation:** access grants are declared per company, estate, or
platform-project and evaluate down the chain to workspace-scoped
authorization. Tenant context continues to be derived from authenticated
authority (REQ-TEN-001); the chain adds where grants can be declared,
not a bypass of workspace authorization.
- **Read-only roll-ups:** task and status visualization bubbles up the
hierarchy as aggregation over workspaces the reader is authorized on.
- **Chain maintenance (not a third runtime function):** re-parenting an
asset — moving a workspace to another platform-project, a
platform-project to another estate, and so on ("assets are transferable
subject to the structure", PRD Part I §4) — is an audited edit of the
hierarchy records themselves under §8.3. It never modifies
business/orchestration rows and never crosses a workspace boundary for
them; the workspace's contents move with the workspace untouched.
4. Naming: this amendment says **platform-project** for the hierarchy level
above workspaces, because §5 REQ-PLAN-001 already defines `projects` as
planning entities INSIDE a workspace. The two are different objects. Final
terminology (rename of one or the other) is an implementation-PR decision
under this amendment's review; the schema MUST NOT merge them.
### 8.2 What is explicitly unchanged
1. `workspace_id` remains the hard mechanical isolation unit (§2 D2,
REQ-TEN-001). Hierarchy tables carry parentage; they do not create
cross-workspace relationships between business/orchestration rows, which
remain rejected (§3 invariant 10).
2. Roll-up is **never a write**: no aggregation path may mutate, claim, order,
or gate work in any workspace. Bubble-up views are generated projections in
the sense of §3 invariant 5 — non-authoritative and never import sources.
3. Fail-closed mutation health (§3 invariants 34), sole writable PostgreSQL
SOT, fencing, audit, and the Coordinator/Certifier authority rules are
untouched.
4. No §6 non-goal is authorized, with one express, narrow carve-out that this
amendment makes to the "portfolio analytics" non-goal: the read-only
roll-up of §8.1 — per-workspace task counts and statuses aggregated up the
parent chain, over workspaces the reader is authorized on — is in scope.
Everything beyond that boundary (metrics, trends, forecasting, scoring,
dashboards computed across workspaces, any derived analytic that is not a
direct count/status aggregation) remains a non-goal. This is an explicit
narrowing by amendment, not a claim that §6 is unchanged; every other §6
non-goal is untouched.
### 8.3 Acceptance (binding on the implementing PRs)
- Schema tests prove each workspace resolves to exactly one
platform-project/estate/company chain and that chain edits are audited.
- Authorization tests prove a grant at each hierarchy level yields exactly the
workspace permissions the chain implies, and that revocation up the chain
propagates.
- 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.
-351
View File
@@ -1,351 +0,0 @@
# Roll-up Projection Contract (S2 contract 8)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 8 of 9).
Authority: `native-kanban-sot.md` §8 (A1 amendment) — "task and status
visualization bubbles up the hierarchy as aggregation over workspaces
the reader is authorized on" (§8.1.3); roll-up is never a write and
bubble-up views are generated projections, non-authoritative and never
import sources (§8.2.2); the express, narrow carve-out from the
portfolio-analytics non-goal covers per-workspace task counts and
statuses aggregated up the parent chain over readable workspaces, and
nothing beyond that boundary (§8.2.4); acceptance requires that roll-up
endpoints cannot mutate state and that a reader sees aggregates only
over workspaces they are authorized on, with no cross-tenant existence
oracles (§8.3). A5 rank 5 names the deliverable: an authorized
read-only roll-up query over only readable workspaces, at every
hierarchy level, as its own non-mutating query tool, dependent on ranks
13.
Revision 2 (terra review F1F7): membership-only readability is now
workspace-local — it contributes at the workspace node only and never
promotes ancestor visibility; upward aggregation requires an effective
chain role, and §1 defines direct vs effective grants in contract 2's
terms (F1). The no-oracle rule gains a defined equivalence predicate
(normalized byte equality with an enumerated volatile-field set) and a
partial-scope hidden-sibling witness (F2). §2.5 enumerates the closed
semantic result and denial schemas field-by-field, including the
explicit-zero representation (F3). Cache invalidation, when a cache
exists, is witnessed per invalidator class (F4). Non-authoritative and
never-gate rules gain an import-graph/data-flow witness, and the
mutation check is aligned to contract 1 §6.7's both-table zero-write
assertion (F5). The fixture gains a second estate with distinct counts
and explicit company-, estate-, project-grant, and membership cases
(F6). The §5.3 legacy-row exclusion and pre-rank no-obligation rules
are disclosed as drafting additions (F7).
Revision 3 (terra re-review residuals): the partial-scope witnesses are
reconstructed at levels where chain grants can actually differ —
platform-project siblings under one estate and estate siblings under
one company — because contract 1 §3.1/§3.4 defines no workspace-level
grant target, so no reader can hold a chain grant on two of three
sibling workspaces (F2). §2.5 now defines one field-exact recursive
record — every node, including the queried node and every leaf, is the
same five-field shape with a required, deterministically ordered
`children` array that is empty at workspaces — and the whole-result
rules (no optional fields, denial envelope, wire faithfulness) are
their own §2.6 at section scope (F3). The fixture assigns workspaces
to named platform-projects, and §6.1's grant-level cases are the three
levels contract 1 defines, with workspace-level access covered by the
membership case and stated as having no direct chain grant (F6).
This contract binds the projection semantics (§2), reader authorization
semantics (§3), read-only enforcement (§4), dependencies and phase
timing (§5), witnesses (§6), and disclosed drafting additions (§7). It
defines the roll-up only: hierarchy shape stays with contract 1
(`hierarchy-schema.md`), grant vocabulary and evaluation with contract 2
(`rbac-grant-model.md`), the task lifecycle and status taxonomy with
`native-kanban-sot.md`'s typed surface, and the tool↔Gateway mapping
row with contract 5 (`tool-gateway-mapping.md`).
## 1. Definitions
1. **Roll-up**: the read-only projection of per-workspace task counts
by status, aggregated up the contract 1 parent chain (workspace →
platform-project → estate → company).
2. **Effective chain role** (at a node, for a reader): the role
contract 2 §3 evaluation yields at that node — from a grant on the
node itself (a **direct grant**) or from a grant on an ancestor
whose domain covers it (an **inherited grant**, contract 2 §3.2).
The role vocabulary is contract 2 §2's; this contract adds no role
and no new authority source.
3. **Chain-readable workspace** (for a reader): a workspace where the
reader's effective chain role permits reading task state.
4. **Member-readable workspace** (for a reader): a workspace readable
only through workspace membership under the SOT's own membership
rules (REQ-ID-001), with no effective chain role. Membership
confers workspace-local semantics only (contract 2 §3.1, §7.4): it
never contributes authority, visibility, or aggregation upward.
5. **Aggregation scope** (of a hierarchy node, for a reader): the set
of chain-readable workspaces in that node's descendant subtree;
plus, when the node is itself a workspace, that workspace if it is
chain-readable or member-readable. A member-readable workspace
therefore contributes to exactly one node's aggregation scope: its
own.
6. **Projection**: a generated, non-authoritative view in the sense of
`native-kanban-sot.md` §3 invariant 5 — derived from SOT rows,
never an import source, never authoritative.
## 2. Projection semantics
1. **Aggregate content.** The roll-up for a node reports, per
workspace in the reader's aggregation scope and as subtree totals:
task counts keyed by the typed lifecycle's status values (owned by
`native-kanban-sot.md`; this contract introduces no status), and
nothing else. Direct count/status aggregation is the entire
surface.
2. **Every level.** The roll-up is queryable at workspace,
platform-project, estate, and company level. A node's totals equal
the sum over its aggregation scope; chain resolution is contract 1
§2.5's (every workspace resolves to exactly one chain), so no
workspace is counted twice and none is orphaned.
3. **Carve-out boundary.** Everything beyond direct count/status
aggregation — metrics, trends, forecasting, scoring, velocity,
cross-workspace derived analytics, dashboards computed across
workspaces — remains a `native-kanban-sot.md` §6 non-goal
(§8.2.4). The response schema is closed (§2.5; §6.7 witness):
adding any field is an amendment to this contract.
4. **Non-authoritative.** No consumer may treat roll-up output as a
source of record; it is recomputable at any time from SOT rows and
is never imported, persisted as authoritative state, or used to
gate or deny work (witness §6.8 — both the write-path and the
decision-path prohibitions are witnessed).
5. **Closed semantic schema.** The successful result is exactly one
**roll-up node record**, a single recursive shape used at every
depth. A roll-up node record consists of exactly these five
fields, and no others:
- `id`: the node's identifier.
- `type`: one of the four contract 1 levels.
- `name`: the node's name.
- `totals`: one entry per status value of the typed lifecycle —
every status key present, a count of zero represented explicitly
as `0`, never by key absence. At a workspace node, `totals` is
that workspace's own counts; at any other node, `totals` is the
sum over the node's aggregation scope (§2.2). This is how §2.1's
"per workspace and as subtree totals" content is carried:
per-workspace counts are the leaf records' `totals`, subtree
totals are the interior records' `totals`.
- `children`: a required array, present on EVERY node record. Its
elements are the reader-visible (§3.2) child nodes of this node,
each itself a complete roll-up node record, recursing down to
the workspaces in the reader's aggregation scope. At a workspace
node the array is exactly `[]` — a workspace record never has
children. The array is ordered deterministically, ascending by
`id`; the implementing PR asserts that ordering. A node outside
§3.2 visibility never appears at any depth.
The queried node's record IS the whole result — there is no
wrapper field around it.
6. **Whole-result rules.** There are no optional result fields at any
depth. The denial/nonexistent response is the contract 5 §4.2
not-found-class error envelope with no fields beyond that
envelope. The wire DTO is expressed under contract 5 §4.1, and
MUST be a faithful serialization of exactly the §2.5 recursive
record: a wire field with no corresponding semantic field is a
conformance defect.
## 3. Reader authorization semantics
1. **Scope rule.** A reader's roll-up over any node aggregates ONLY
the reader's aggregation scope (§1.5). An unreadable workspace
contributes nothing to any total — not a count, not a row, not a
presence marker. A member-readable workspace contributes only at
the workspace node itself (§1.4–§1.5): querying it directly
succeeds; it never appears in, and never adds to, any ancestor's
response for that reader.
2. **Node visibility.** A node appears in a roll-up response iff the
reader's aggregation scope at that node is non-empty, or the
reader holds an effective chain role at the node (§1.2 — direct or
inherited; contract 2 §3.2 makes a grant's domain the node and its
subtree, so an ancestor grant makes empty descendants visible per
the ruling). Per the ruling below, a node with an effective chain
role but an empty aggregation scope appears with zero counts.
Workspace membership alone never makes any non-workspace node
visible. A node where the reader has neither an effective chain
role nor a non-empty aggregation scope does not appear at all.
3. **No existence oracle.** The response MUST NOT disclose the
existence, count, name, or any property of unreadable workspaces
or of nodes outside §3.2 visibility — no "N workspaces hidden"
fields, no total-vs-visible discrepancy fields. A query naming a
node outside §3.2 visibility MUST satisfy the §3.4 response
equivalence with a query naming a nonexistent node (fail closed,
`rbac-grant-model.md` §3.5 pattern: a decision path that cannot
read grant state denies).
4. **Response equivalence predicate.** Two responses are equivalent
when they carry the identical HTTP status, the identical contract
5 §4.2 error code, and byte-identical bodies after normalizing
exactly the declared volatile envelope fields — correlation id and
response timestamp, and nothing else. The implementing PR declares
that volatile-field list in the witness; any additional
normalization is a conformance defect. This is contract 5 §4.2's
same code/status/shape rule made executable.
5. **Live evaluation.** Readability is evaluated per contract 2 §3.5
(live rows or transactionally-invalidated cache). Revocation
propagates per contract 2 §6: the next roll-up query decided after
the revoking transaction commits excludes the revoked scope.
## 4. Read-only enforcement
1. **Never a write.** No roll-up path may mutate, claim, order, or
gate work in any workspace (§8.2.2). The roll-up ships as a
non-mutating query tool (A5 rank 5) — a query surface with no
command counterpart.
2. **Mechanical enforcement.** The implementing PR executes roll-up
database work inside read-only transactions (or an equivalently
privilege-restricted path), so a mutation attempt fails at the
database boundary, not only by convention.
3. **Freshness.** v1 computes the roll-up live from SOT rows at query
time. A cache is an implementation option only if it is
invalidated in the same transaction as any task, hierarchy, grant,
or membership mutation that affects it (each invalidator class
witnessed, §6.5), and it is never authoritative (§1.6).
## 5. Dependencies and phase timing
1. The roll-up depends on A5 ranks 13: contract 1's hierarchy tables
(the parent chain), contract 2's evaluator (readability), and the
typed Kanban lifecycle (the task state being counted). It ships
after them and reads their surfaces; it defines none of them.
2. The roll-up query is one tool with one Gateway mapping row under
contract 5's regime (request/result/error/audit contracts there);
this contract binds its semantics (§2.5 defines the semantic
fields the contract 5 §4.1 DTO serializes), not its wire encoding.
3. Legacy task rows outside the typed lifecycle are not aggregated;
the roll-up begins counting a workspace's tasks when they exist in
the typed surface. No roll-up obligation attaches to v1 before
ranks 13 exist. Both rules are drafting additions disclosed in §7
(they trace to no §8 sentence).
## 6. Verification requirements
Binding on the implementing PRs. Every witness names, in its
implementation, the exact endpoints/tools, tables, and fixtures it
exercises. The base fixture seeds two companies; under company A **two
estates with distinct, non-identical count profiles**: estate A1 with
two platform-projects — P1 holding workspaces W1 and W2, P2 holding
workspace W3 — and estate A2 with one platform-project P3 holding one
workspace W4, all with known task counts across at least three
statuses; under company B one workspace.
1. **Correctness witnesses:** for a reader holding a direct company-A
grant, roll-up totals at every level equal the seeded sums — each
workspace, each platform-project, estate A1 and estate A2
separately (their distinct profiles asserted distinct), and the
company total equal to A1+A2 — keyed by the typed status values,
with no double count across the chain. For a reader holding a
direct estate-A1 grant, the estate-A1 result equals the A1 sum and
a company-A query returns company A with exactly A1's contribution
(estate A2 invisible). Each of the three chain grant levels
contract 1 §3.1 defines — company, estate, platform-project
(below, §6.2) — has an explicit direct-grant case, none simulated
by unioning lower access. Workspace-level access has NO direct
chain grant (contract 1 §3.1/§3.4 define no workspace grant
target) and is covered by the §6.2 membership case.
2. **Scope witnesses:** a reader with a direct grant on
platform-project P1 only sees exactly P1's subtree counts
(W1+W2): a P1 query returns W1+W2; an estate-A1 query returns the
estate node with exactly P1's contribution, sibling project P2 and
its workspace W3 absent at every depth; a company-A query likewise
carries only P1's contribution. An estate-sibling case: a reader
with a direct grant on estate A1 only queries company A and
receives exactly A1's contribution, estate A2 absent. (Chain
grants exist only at company, estate, and platform-project —
contract 1 §3.1 — so partial scope among SIBLING WORKSPACES of
one project is not constructible by grants and is not witnessed;
the constructible partial-scope cases are the project- and
estate-sibling ones above.) **Membership locality (§1.4):** a member-only reader queries
the workspace directly and receives its counts; the same reader
querying the workspace's parent (or any ancestor) receives the
§3.4-equivalent nonexistent-node response, and no ancestor
response for any other reader changes because of that membership.
3. **No-oracle witnesses:** the P1-only reader's estate-A1 response
above contains no field disclosing P2's or W3's existence
(closed-schema comparison against an estate-A1-granted reader's
response: identical field set, differing only in counts and
visible nodes). **Partial-scope hidden node:** the P1-only reader
— who sees estate A1 and the P1 subtree — queries hidden sibling
project P2 by its real id, and separately hidden workspace W3 by
its real id; each response satisfies the §3.4 equivalence
predicate against the same query naming a nonexistent id, under
one fixed request context with the declared volatile-field
normalization. **Cross-tenant:** an unauthorized reader naming company B receives
a response §3.4-equivalent to naming a nonexistent id. Each
equivalence check is executable byte comparison after the declared
normalization, not a shape judgment.
4. **Empty-vs-hidden witness (ruling):** a reader granted (direct
chain grant) on an empty platform-project receives it with zero
counts — every status key present at `0` (§2.5); with the grant
deleted, the same query returns the §3.4-equivalent
nonexistent-node response. An inherited-grant case: a company
grant makes an empty descendant platform-project visible with zero
counts.
5. **Cache-invalidation witnesses (conditional):** bound only if the
implementation caches — for EACH invalidator class, prime the
cache, commit one mutation of that class, and assert the next
query reflects it: a task status change, a task creation, a
membership removal (the member-readable workspace disappears from
its own node's next query), a workspace reparenting (both old and
new parent totals correct), and a grant revocation. A live
(cacheless) v1 implementation records that fact and the witnesses
bind at the PR that introduces a cache.
6. **Mutation witnesses:** the roll-up surface rejects every mutating
verb/command; a crafted attempt to issue a write through the
roll-up's database path fails at the read-only boundary (§4.2);
after any roll-up query, the row diff is empty across BOTH the
workspace tables and the hierarchy tables (contract 1 §6.7's
both-table zero-write assertion).
7. **Closed-schema witness:** the response is asserted field-exact
against the §2.5 recursive record at every depth — exactly
`id`/`type`/`name`/`totals`/`children` on every node, every typed
status present with explicit zeros, `children: []` at every
workspace record, the declared ascending-`id` ordering, no
wrapper field — and a response carrying any field outside the
record at any depth fails the assertion (carve-out boundary,
§2.3). The denial envelope is asserted field-exact against
contract 5 §4.2's envelope (§2.6).
8. **Non-authoritative and never-gate witnesses:** (a) a static
production import-graph inventory (hierarchy contract §6.3 style,
production code over `apps/` and `packages/`, tests excluded)
shows no production module imports the roll-up query module or its
result DTO into any SOT write path, any authorization/gating
decision path, or any persistence beyond the response lifetime —
asserted in both directions (the roll-up module's consumers are
enumerated and each is a presentation surface); (b) a behavioral
probe: with roll-up output artificially perturbed (test double),
no authorization outcome and no work-gating decision anywhere in
the fixture suite changes — proving no gate consumes it.
9. **Revocation witness:** after revoking the grant that made a
subtree readable, the next roll-up query excludes it (contract 2
§6.2 bound).
## 7. Drafting additions (PRD §12.1 disclosure)
Proposed drafting additions, visible here for ratification, each
severable; the aggregation itself, its authorization scope, its
read-only nature, and the no-oracle acceptance are traced to
`native-kanban-sot.md` §8 and are not additions:
1. The §3.2 node-visibility rule and the granted-but-empty behavior
(the ruling below).
2. The §3.3–§3.4 nonexistent-node response equivalence, with its
normalized-byte-equality predicate, as the concrete no-oracle
mechanism.
3. The §4.2 read-only-transaction mechanical enforcement.
4. The §4.3 cache option with transactional invalidation and the
§6.5 per-invalidator witnesses.
5. The §2.5 closed response schema as an amendment boundary.
6. The §1.4 membership-locality rule — membership-only readability
contributes at the workspace node only (this contract's
reconciliation of `native-kanban-sot.md` §8.1.3 "authorized on"
with contract 2 §3.1/§7.4's workspace-local membership).
7. The §5.3 legacy-row exclusion and the §5.3 pre-rank no-obligation
rule.
## Ruling request
Ruling requested (one decision): shall a node the reader holds an
effective chain role on (direct or inherited, §1.2) but whose
aggregation scope is empty appear in the roll-up with zero counts
(recommended — it lets the UI show a granted-but-empty subtree
honestly) — or, as the alternative, be indistinguishable from a
nonexistent node until it contains a readable workspace?
-197
View File
@@ -1,197 +0,0 @@
# Tool↔Gateway Mapping Contract (D8)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 5 of 9).
Authority: PRD D8/D12 (Part I §8) — the webUI sits OVER official tooling:
every webUI operation goes through the Gateway API backed by the same
official framework tooling the CLI uses, and a webUI operation with no
backing tool is scored **blocked on tooling** and the tool is built
first. Measured input: the webui-audit A5 tooling baseline
(operation-by-operation inventory of the current Gateway surface and the
P1 gaps, cross-reviewed; `fleet/lanes/webui-audit/findings/
A5-tooling-baseline.md` in the estate brain). The T10 ruling adopted the
targeted-update plan including building the D8 tools in A5's rank order.
Revision 2 (GLM review F1F5): the §2 table completed against an
independent re-measurement of the live `apps/web` surface (mission
reads, coordination status, capability-gated `turn:send` added); rank-6
composition corrected to ranks 1 and 4; SOT citations corrected to §3
invariant 11 / REQ-TASK-001 / §5+A1; the §3.2 retirement clause
softened to match what the owning contracts actually schedule; §6.1
scoped to outbound calls with an extractability lint, and §6.3 given
static companions for §4.1 and §4.3.
This contract binds three things: the operation→tool mapping itself
(§2–§3), the command envelope every mapped operation satisfies
(§4), and the process rule that keeps the mapping closed (§5). Domain
semantics stay with their owning contracts — hierarchy (contract 1,
`hierarchy-schema.md`), grants (contract 2, `rbac-grant-model.md`),
wizard (contract 3, `onboarding-wizard.md`), identity
(`identity-lifecycle.md`), kanban lifecycle (`native-kanban-sot.md`
§5 and Amendment A1), roll-up (contract 8), API artifact format
(contract 9).
## 1. Definitions
1. **Official tool**: a command implemented in the framework packages and
exposed through the Gateway API; the CLI remains the primary execution
method for the same command (D8). The webUI is a Gateway client only.
2. **Mapped operation**: a webUI operation with a named official path in
§2 or §3. Anything else the webUI wants to do is unmapped and follows
§5.
3. **Legacy non-substitute**: an existing endpoint that resembles a P1
need but is contractually barred from backing it (§3.2).
## 2. P0 mapping (current operations, ratified as-is)
This table is the complete measured P0 surface: every Gateway call the
web app's production sources make at this revision's head appears as a
row (independently re-measured at review; the three calls the first
measurement missed — mission reads, coordination status, and the
capability-gated `turn:send` emit — are rows below). The surface stays
bound to these paths:
| WebUI operation | Official path |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Register / log in / log out / OIDC callback | better-auth mount `/api/auth/*`; `GET /api/sso/providers` |
| List/show projects (legacy read) | `GET /api/projects`, `GET /api/projects/:id` |
| List tasks / task detail (legacy read) | `GET /api/tasks`, `GET /api/tasks/:id` — with the filtered legacy project/mission reads the same surfaces use |
| Mission list (legacy read) | `GET /api/missions` |
| Coordination status (legacy read) | `GET /api/coord/status` |
| Conversation CRUD/search/messages | `/api/conversations*` |
| Chat turn / stop / thinking / command execute+approve / streaming | `/chat` socket events `message`, `abort`, `set:thinking`, `command:execute`, `command:approve`; `turn:send` (capability-gated — emitted only when the server advertises the pi turn-runtime capability, which the current Gateway does not) |
| Harness/model selection | `GET /api/harnesses*`, `GET/PUT /api/chat/preferences/selection` |
| Preferences; provider inspect/test | `/api/memory/preferences`, `GET /api/providers`, `POST /api/providers/test` |
| Admin users / roles / ban / health | `/api/admin/users*`, `/api/admin/health` |
P0 rows inherit §4 obligations as their backing controllers are next
touched; they are not required to be retrofitted in one sweep.
## 3. P1 mapping (bound to the build-first tools)
1. Every P1 operation maps to exactly one build-first command family, in
the T10-ruled rank order:
| Rank | Command family (owning contract) | P1 webUI operations it backs |
| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Hierarchy command family (contract 1 §5; grants attach per contract 2) | Company/estate/platform-project/workspace CRUD, parentage and reparenting, hierarchy reads; the wizard's initial-hierarchy step (contract 3 §3.4) |
| 2 | Hierarchy RBAC command/evaluator (contract 2) | Grant create/change/revoke at company/estate/platform-project; inherited evaluation down to workspace; authorization-safe hierarchy queries |
| 3 | Typed kanban command/query surface (SOT §5, Amendment A1) | Workspace task lifecycle (create/edit/cancel/archive/move), board rank, typed queries |
| 4 | Agent enrollment command | Enroll one agent: harness, credential reference/API-key intake (values never echoed), name/persona, assignment scope (contract 3 §3.5) |
| 5 | Authorized roll-up query (contract 8) | Read-only aggregated task counts/statuses at every hierarchy level over readable workspaces only |
| 6 | Onboarding orchestration (contract 3) | The re-runnable wizard flow, composing ranks 1 and 4 (its only grant write rides inside the rank-1 company-create command, contract 2 §4.3) |
2. **Legacy non-substitutes.** The following MUST NOT back any P1
operation, matching the audit findings: legacy `/api/projects` and
`/api/tasks` CRUD (planning-data records, not hierarchy nodes and not
the typed kanban boundary); `POST /api/workspaces` (filesystem
bootstrap, not audited hierarchy parentage); `/api/teams` reads (no
grants, no inheritance); `POST /api/bootstrap/setup` (one-shot
epoch transition, identity §3 — not the re-runnable wizard); the MCP
`brain_*` task mutations (legacy Brain writes, not the typed kanban
commands). These stay serving their existing P0/host consumers until
the owning contract (or a successor amendment) schedules each
retirement — no such migration is scheduled at this revision; the
freeze stands on its own.
3. New P1 mapping rows (operations this table does not list) are added by
amending this contract, not ad hoc (§5).
## 4. Command envelope (request / result / error / audit)
Binding on every mapped operation the build-first families expose:
1. **Typed request and result.** Each command and query has an explicit
request DTO and result DTO in the shared types package, validated at
the Gateway boundary; unvalidated pass-through and `any`-typed
payloads are non-conformant. Mutations on records with an
expected-version rule in their owning contract carry the expected
version in the request and fail on mismatch with the conflict error
class (SOT §3 invariant 11 and REQ-TASK-001's concurrent-update
conflict acceptance; hierarchy per contract 1).
2. **Error taxonomy.** Every error result carries a stable
machine-readable code from a closed per-family enum plus an HTTP
status mapping, distinguishing at minimum: validation failure,
authentication failure, authorization refusal, not-found, conflict
(version/uniqueness), precondition/state refusal (e.g. bootstrap
epoch, suspended team subjects), and internal fault. Where contract
2's no-existence-oracle rule applies, authorization refusal and
not-found are indistinguishable on the wire for unauthorized readers
— same code, same status, same shape.
3. **Audit linkage.** A mutating mapped operation emits exactly the
audit events its owning contract defines (contract 1 §5.2, contract 2
§4.4, identity §§24, SOT audit rules); the envelope contributes the
correlation: every request accepts/generates a correlation id,
carried into the audit events and returned in the result, so a UI
action is traceable end to end. The mapping layer itself adds no
second audit stream.
4. **Fail-closed.** A mapped operation that cannot evaluate its
authorization or reach its owning tool refuses (contract 2 §3.5); the
envelope never degrades to an unauthorized fallback read or a direct
data access.
5. **CLI parity.** Each build-first family is invocable through the
official CLI against the same Gateway commands with the same
request/result/error contracts. No webUI-only command exists; a
Gateway command without CLI exposure is a conformance gap tracked at
the family's implementing issue.
## 5. Closure rule (blocked on tooling)
1. A webUI change that needs an operation with no mapping row is
**blocked on tooling**: the backing tool is built and mapped first
(D8). Scoring a gap "blocked on tooling" is mandatory, not
discretionary; working around it in the UI (direct DB or filesystem
access, calling a legacy non-substitute, embedding domain logic in
the web app) is non-conformant.
2. The mapping is enforced closed by §6.1's inventory witness: the web
app's network surface must be a subset of the mapped paths.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Network-surface inventory witness:** a CI assertion extracting the
web app's outbound Gateway calls — route literals at request call
sites and outbound socket emits in `apps/web` sources (inbound
handler registrations are not calls and are out of scope) — and
failing on any call outside the §2/§3 mapped paths. The inventory is
closed like contract 1 §6.3's allowlist: a new call fails until a
mapping row exists in the same PR. Dynamic route construction that
evades extraction is resolved toward the witness, enforced by an
extractability lint: every request call site takes a literal or
template-literal path, and a call site that does not fails the
assertion itself (the web-side analogue of contract 1's
raw-execution prong), never an exemption for the caller.
2. **Non-substitute witness:** the P1 surfaces (hierarchy, RBAC, kanban,
enrollment, roll-up, wizard UI) make zero calls to the §3.2 legacy
endpoints — asserted by the same inventory, scoped per surface.
3. **Envelope witnesses per family:** for each build-first family — a
request with an invalid DTO is refused with the validation code; a
version-mismatch mutation returns the conflict code; an unauthorized
read of an existing node and a read of a nonexistent node return
indistinguishable results where the no-existence-oracle rule applies;
a correlation id submitted on a mutation appears in its audit
event(s) and result. Two static companions: a type-level assertion
that the family's boundary accepts no `any`-typed or unvalidated
pass-through payload (§4.1), and a single-emitter assertion that the
mapped operation's audit events originate only from the owning
contract's audit emitter (§4.3's no-second-audit-stream, made
checkable).
4. **CLI-parity witness:** for each family, a CLI smoke invocation of at
least one command and one query against the Gateway succeeds with the
same typed result the web client receives.
5. **Fail-closed witness:** with the owning tool or grant state
unreachable (fault injection), the mapped operation returns the
internal-fault or authorization-refusal class and performs no
fallback read/write (extends contract 2 §7.6 to the mapping layer).
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§3.2): the legacy endpoints named there are **frozen for new
consumers** as of ratification — existing P0/host consumers keep
working, new UI or tool code may not call them, and each is retired by
the migration its owning contract schedules. Alternative if rejected:
allow P1 surfaces to reuse legacy endpoints as interim backends —
rejected by the audit's finding that they cannot satisfy the
hierarchy/kanban/RBAC contracts, so the interim would ship
non-conformant semantics.
+1
View File
@@ -7,6 +7,7 @@ export default tseslint.config(
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/.next/**',
'**/coverage/**',
'**/drizzle.config.ts',
'**/framework/**',
+1
View File
@@ -7,6 +7,7 @@
"dev": "turbo run dev",
"lint": "turbo run lint",
"preflight": "node scripts/preflight.mjs",
"clean:generated": "node scripts/clean-generated.mjs",
"typecheck": "pnpm preflight && turbo run typecheck",
"verify:release": "node scripts/verify-release.mjs",
"test:checkout": "node --test scripts/*.test.mjs",

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