Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b753a48a4 | ||
|
|
5c83c89bae | ||
|
|
2a223767b3 |
+3
-6
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -254,23 +254,6 @@ steps:
|
||||
depends_on:
|
||||
- typecheck
|
||||
|
||||
# Canonical verify:release stage `build` (#1445, P6): every PR proves the
|
||||
# full workspace build — including the SPA `vite build` — before merge,
|
||||
# instead of leaving build breakage to surface post-merge in publish.yml's
|
||||
# verify step. Same canonical command the publish pipeline's build step runs.
|
||||
build:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
depends_on:
|
||||
# after test, not typecheck: turbo gives `test` a ^build dependency, so
|
||||
# running this step concurrently with test would put two independent
|
||||
# turbo builds on the same shared-workspace dist/ and turbo cache with
|
||||
# no cross-process locking — the same serialization invariant
|
||||
# publish.yml documents for #1411.
|
||||
- test
|
||||
|
||||
services:
|
||||
ci-postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
|
||||
+44
-94
@@ -407,96 +407,6 @@ steps:
|
||||
- build
|
||||
- verify
|
||||
|
||||
# #1445 (P6): headless Playwright E2E gate on every trunk merge. Boots the
|
||||
# real gateway on the embedded PGlite path (no DATABASE_URL, no services)
|
||||
# serving the built SPA bundle via WEB_DIST_DIR — the exact serving path the
|
||||
# gateway image ships (docker/gateway.Dockerfile sets WEB_DIST_DIR to the
|
||||
# baked bundle), which keeps #1407's parity guarantee: the image build steps
|
||||
# below depend on this gate, so a bundle that fails E2E never publishes.
|
||||
#
|
||||
# Image pinned to the @playwright/test version in pnpm-lock.yaml so the
|
||||
# image's bundled browsers match the workspace driver exactly (bump the two
|
||||
# together). The step installs no workspace packages (corepack does fetch
|
||||
# the pinned pnpm itself): it reuses the workspace node_modules
|
||||
# from `install` and the dist outputs from `build` — the gateway's runtime
|
||||
# dependency path is pure JS/WASM (PGlite is WASM, postgres-js is pure JS),
|
||||
# so the alpine-installed modules run unchanged under this glibc image.
|
||||
# depends_on publish-next-npm per the #1411 serialization invariant: this
|
||||
# step reads the workspace and must never run inside the manifest-transform
|
||||
# window.
|
||||
e2e:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
environment:
|
||||
GATEWAY_PORT: '14242'
|
||||
PLAYWRIGHT_BASE_URL: http://localhost:14242
|
||||
# The database is seeded by Playwright's globalSetup in this step, so
|
||||
# login failures are real failures: without this flag the suite's
|
||||
# skip-when-login-fails guards (a live-environment affordance) could
|
||||
# skip every authenticated spec and go green while proving nothing.
|
||||
E2E_REQUIRE_SEEDED_AUTH: '1'
|
||||
commands:
|
||||
- corepack enable
|
||||
- |
|
||||
# Throwaway signing secret for this step's ephemeral embedded database
|
||||
# (the gateway refuses to boot without one). Generated per run so no
|
||||
# usable literal lives in the tree.
|
||||
export BETTER_AUTH_SECRET="$(head -c 32 /dev/urandom | base64)"
|
||||
export WEB_DIST_DIR="$(pwd)/apps/web/dist"
|
||||
if [ ! -f "$WEB_DIST_DIR/index.html" ]; then
|
||||
echo "[e2e] FATAL: $WEB_DIST_DIR/index.html missing — did the build step run?" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Boot the gateway from the built dist, cwd- AND HOME-isolated: the
|
||||
# local-tier PGlite database lives under $HOME/.config/mosaic/gateway/
|
||||
# (database.module.ts), not under cwd, so HOME must point at the
|
||||
# throwaway dir too or the run would share a database with anything
|
||||
# else in the container's home.
|
||||
GATEWAY_RUN_DIR="$(mktemp -d /tmp/e2e-gateway.XXXXXX)"
|
||||
(cd "$GATEWAY_RUN_DIR" && export HOME="$GATEWAY_RUN_DIR" && exec node "$OLDPWD/apps/gateway/dist/main.js") > /tmp/gateway.log 2>&1 &
|
||||
GATEWAY_PID=$!
|
||||
ready=0
|
||||
for i in $(seq 1 90); do
|
||||
if node -e "fetch('http://localhost:' + process.env.GATEWAY_PORT + '/health', { signal: AbortSignal.timeout(2000) }).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
|
||||
echo "[e2e] FATAL: gateway process exited during startup" >&2
|
||||
cat /tmp/gateway.log >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[e2e] waiting for gateway ($i/90)..."
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "[e2e] FATAL: gateway did not become ready in 90s" >&2
|
||||
cat /tmp/gateway.log >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[e2e] gateway ready; running Playwright suite"
|
||||
set +e
|
||||
pnpm --filter @mosaicstack/web exec playwright test
|
||||
E2E_EXIT=$?
|
||||
set -e
|
||||
kill "$GATEWAY_PID" 2>/dev/null || true
|
||||
if [ "$E2E_EXIT" -ne 0 ]; then
|
||||
echo "[e2e] FATAL: Playwright suite failed (exit $E2E_EXIT); gateway log follows" >&2
|
||||
tail -100 /tmp/gateway.log >&2
|
||||
echo "[e2e] browser-side traces/screenshots are under apps/web/test-results/ in the step workspace (not persisted past the pod)" >&2
|
||||
fi
|
||||
exit "$E2E_EXIT"
|
||||
# Same filter as the image builds it gates: a merge that publishes no
|
||||
# image (docs-only on main) pays no browser suite, and a skipped e2e does
|
||||
# not block anything (skipped-dependency semantics, same as
|
||||
# publish-next-npm on tag events).
|
||||
when: *image_build_when
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: never read the workspace inside publish-next-npm's
|
||||
# manifest-transform window.
|
||||
- publish-next-npm
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
@@ -556,8 +466,6 @@ steps:
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
|
||||
- e2e
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
@@ -602,5 +510,47 @@ steps:
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
|
||||
- e2e
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
REGISTRY_PASS:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next web publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||
# its transform window and restores them at step end. Any step that
|
||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||
# pipeline 2648 raced a COPY inside the window and failed
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@mariozechner/pi-ai": "^0.65.0",
|
||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { RequestMethod, type Type } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy route inventory (contract 1 §6.3).
|
||||
*
|
||||
* The hierarchy command family is a CLOSED enumeration asserted here, not a
|
||||
* prose claim: every hierarchy-flavored route the AppModule graph declares
|
||||
* must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or
|
||||
* removing a hierarchy route without updating this inventory (and its
|
||||
* witnesses) fails CI first. This replaces the M4-1b-i zero-routes
|
||||
* baseline.
|
||||
*/
|
||||
|
||||
interface RouteEntry {
|
||||
method: string;
|
||||
path: string;
|
||||
controller: string;
|
||||
}
|
||||
|
||||
/** Module-metadata entry: a module class or a DynamicModule-shaped object. */
|
||||
type ModuleEntry =
|
||||
| Type<unknown>
|
||||
| { module: Type<unknown>; imports?: unknown[]; controllers?: Type<unknown>[] };
|
||||
|
||||
function collectControllers(root: ModuleEntry): Type<unknown>[] {
|
||||
const visited = new Set<unknown>();
|
||||
const controllers: Type<unknown>[] = [];
|
||||
const walk = (entry: ModuleEntry | undefined | null): void => {
|
||||
if (!entry || visited.has(entry)) return;
|
||||
visited.add(entry);
|
||||
const moduleClass = typeof entry === 'function' ? entry : entry.module;
|
||||
// Entries with no resolvable class (forwardRef wrappers, async dynamic
|
||||
// modules) carry no decorator metadata to read here.
|
||||
if (typeof moduleClass !== 'function') return;
|
||||
if (visited.has(moduleClass) && typeof entry !== 'function') return;
|
||||
visited.add(moduleClass);
|
||||
// 'controllers' / 'imports' are the metadata keys the @Module decorator writes.
|
||||
const declared = (Reflect.getMetadata('controllers', moduleClass) ?? []) as Type<unknown>[];
|
||||
controllers.push(...declared);
|
||||
if (typeof entry !== 'function' && entry.controllers) controllers.push(...entry.controllers);
|
||||
const imports = [
|
||||
...((Reflect.getMetadata('imports', moduleClass) ?? []) as ModuleEntry[]),
|
||||
...(typeof entry !== 'function' ? ((entry.imports ?? []) as ModuleEntry[]) : []),
|
||||
];
|
||||
for (const imported of imports) walk(imported);
|
||||
};
|
||||
walk(root);
|
||||
return controllers;
|
||||
}
|
||||
|
||||
function routesOf(controller: Type<unknown>): RouteEntry[] {
|
||||
// 'path' on the class is the @Controller prefix; 'path'/'method' on a
|
||||
// handler are written by the @Get/@Post/... route decorators.
|
||||
const base = (Reflect.getMetadata('path', controller) ?? '') as string | string[];
|
||||
const bases = Array.isArray(base) ? base : [base];
|
||||
const routes: RouteEntry[] = [];
|
||||
const prototype = controller.prototype as Record<string, unknown>;
|
||||
for (const name of Object.getOwnPropertyNames(prototype)) {
|
||||
if (name === 'constructor') continue;
|
||||
const handler = Object.getOwnPropertyDescriptor(prototype, name)?.value;
|
||||
if (typeof handler !== 'function') continue;
|
||||
const method = Reflect.getMetadata('method', handler) as number | undefined;
|
||||
if (method === undefined) continue;
|
||||
const sub = (Reflect.getMetadata('path', handler) ?? '/') as string;
|
||||
for (const prefix of bases) {
|
||||
const path = `/${prefix}/${sub}`.replace(/\/+/g, '/').replace(/(.)\/$/, '$1');
|
||||
routes.push({
|
||||
method: RequestMethod[method] ?? String(method),
|
||||
path,
|
||||
controller: controller.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed command family (contract 1 §5, M4-1b-ii). Every entry is a
|
||||
* mutation audited via the M4-1b-i path or one of the two ratified reads
|
||||
* (granted companies, the §2.8 directory carve-out).
|
||||
*/
|
||||
const HIERARCHY_COMMAND_FAMILY = [
|
||||
'POST /api/hierarchy/companies',
|
||||
'GET /api/hierarchy/companies',
|
||||
'GET /api/hierarchy/companies/directory',
|
||||
'POST /api/hierarchy/companies/:id/rename',
|
||||
'POST /api/hierarchy/companies/:id/visibility',
|
||||
'DELETE /api/hierarchy/companies/:id',
|
||||
'POST /api/hierarchy/estates',
|
||||
'POST /api/hierarchy/estates/:id/rename',
|
||||
'POST /api/hierarchy/estates/:id/transfer',
|
||||
'DELETE /api/hierarchy/estates/:id',
|
||||
'POST /api/hierarchy/platform-projects',
|
||||
'POST /api/hierarchy/platform-projects/:id/rename',
|
||||
'POST /api/hierarchy/platform-projects/:id/transfer',
|
||||
'DELETE /api/hierarchy/platform-projects/:id',
|
||||
'POST /api/hierarchy/grants',
|
||||
'POST /api/hierarchy/grants/:id/change',
|
||||
'DELETE /api/hierarchy/grants/:id',
|
||||
] as const;
|
||||
|
||||
describe('hierarchy route inventory (§6.3)', () => {
|
||||
const inventory = collectControllers(AppModule).flatMap(routesOf);
|
||||
|
||||
it('control: the enumeration sees the known route surface', () => {
|
||||
const paths = inventory.map((r) => `${r.method} ${r.path}`);
|
||||
expect(paths).toContain('GET /health');
|
||||
expect(paths).toContain('POST /api/workspaces');
|
||||
expect(paths).toContain('GET /api/teams');
|
||||
expect(inventory.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('the hierarchy surface is exactly the declared command family', () => {
|
||||
const hierarchyRoutes = inventory
|
||||
.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path))
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
|
||||
});
|
||||
|
||||
it('every command-family route lives on HierarchyController inside HierarchyModule', () => {
|
||||
const controllers = collectControllers(HierarchyModule);
|
||||
expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']);
|
||||
const declared = controllers
|
||||
.flatMap(routesOf)
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ import { GCModule } from './gc/gc.module.js';
|
||||
import { HarnessModule } from './harness/harness.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
||||
import { QueueModule } from './queue/queue.module.js';
|
||||
import { FederationModule } from './federation/federation.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
@@ -66,7 +65,6 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
||||
QueueModule,
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
HierarchyModule,
|
||||
...(federationEnabled ? [FederationModule] : []),
|
||||
],
|
||||
controllers: [HealthController],
|
||||
|
||||
@@ -61,33 +61,6 @@ describe('CommandAuthorizationService', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies non-admin scopes to a platform admin (contract 2 §1.1 bypass retirement)', async (): Promise<void> => {
|
||||
const service = createService('admin');
|
||||
for (const scope of ['core', 'agent', 'skill', 'plugin'] as const) {
|
||||
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
|
||||
expect(
|
||||
(await service.authorize(command, { ...payload, command: command.name }, 'admin-1'))
|
||||
.allowed,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows member core/agent scopes and denies skill/plugin (deny-by-default)', async (): Promise<void> => {
|
||||
const service = createService('member');
|
||||
for (const [scope, allowed] of [
|
||||
['core', true],
|
||||
['agent', true],
|
||||
['skill', false],
|
||||
['plugin', false],
|
||||
] as const) {
|
||||
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
|
||||
expect(
|
||||
(await service.authorize(command, { ...payload, command: command.name }, 'member-1'))
|
||||
.allowed,
|
||||
).toBe(allowed);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
|
||||
const entries = new Map<string, string>();
|
||||
const action = {
|
||||
|
||||
@@ -154,15 +154,8 @@ export class CommandAuthorizationService {
|
||||
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract 2 §1.1: platform admin confers instance administration only —
|
||||
* the former admin-passes-every-scope short-circuit is retired. Admin
|
||||
* reaches exactly the admin scope; core/agent scopes belong to the member
|
||||
* role; skill/plugin scopes stay deny-for-all until a grant mapping names
|
||||
* them (§3.1 deny-by-default).
|
||||
*/
|
||||
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
|
||||
if (scope === 'admin') return role === 'admin';
|
||||
if (role === 'admin') return true;
|
||||
return role === 'member' && (scope === 'core' || scope === 'agent');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
companies,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyOutbox,
|
||||
platformProjects,
|
||||
runPgliteMigrations,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import {
|
||||
HierarchyAuditIdempotencyConflictError,
|
||||
HierarchyAuditRepository,
|
||||
HierarchyNodeNotFoundError,
|
||||
type AppendHierarchyEventInput,
|
||||
} from './hierarchy-audit.repository.js';
|
||||
|
||||
/**
|
||||
* Repository-level §6.4 witnesses for the hierarchy audit machinery
|
||||
* (contract 1 §5.2, REQ-AUD-001): same-transaction atomicity of state +
|
||||
* event + outbox, rollback leaving no residue, idempotent replay, snapshot
|
||||
* parent chains, events surviving target deletion, per-target ordering, and
|
||||
* the outbox claim/complete/release CAS. The schema-level constraints are
|
||||
* witnessed in packages/db/src/hierarchy-audit.witness.test.ts.
|
||||
*/
|
||||
describe('hierarchy audit repository integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let repo: HierarchyAuditRepository;
|
||||
|
||||
const input = (
|
||||
overrides: Partial<AppendHierarchyEventInput> = {},
|
||||
): AppendHierarchyEventInput => ({
|
||||
actorId: 'user-actor',
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: randomUUID(),
|
||||
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
|
||||
correlationId: 'corr-1',
|
||||
idempotencyKey: `key-${randomUUID()}`,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-audit-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [HierarchyAuditRepository, { provide: DB, useValue: handle.db }],
|
||||
}).compile();
|
||||
repo = moduleRef.get(HierarchyAuditRepository);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await moduleRef.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('commits state, event, and outbox record atomically in one transaction', async () => {
|
||||
const companyId = randomUUID();
|
||||
const key = `key-${randomUUID()}`;
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Atomic Co', slug: 'atomic-co' });
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
const result = await repo.append(tx, {
|
||||
...input({ targetId: companyId, idempotencyKey: key }),
|
||||
targetSnapshot: { ...snapshot },
|
||||
});
|
||||
expect(result.replayed).toBe(false);
|
||||
expect(result.event.idempotencyKey).toBe(key);
|
||||
});
|
||||
const events = await handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
|
||||
expect(events).toHaveLength(1);
|
||||
const outbox = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.eventId, events[0]!.id));
|
||||
expect(outbox).toHaveLength(1);
|
||||
expect(outbox[0]).toMatchObject({
|
||||
status: 'pending',
|
||||
idempotencyKey: key,
|
||||
correlationId: 'corr-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('a rolled-back transaction leaves no state, no event, and no outbox record', async () => {
|
||||
const companyId = randomUUID();
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Doomed Co', slug: 'doomed-co' });
|
||||
await repo.append(tx, input({ targetId: companyId, idempotencyKey: key }));
|
||||
throw new Error('deliberate rollback');
|
||||
}),
|
||||
).rejects.toThrow('deliberate rollback');
|
||||
const [companyRows, eventRows, outboxRows] = await Promise.all([
|
||||
handle.db.select().from(companies).where(eq(companies.id, companyId)),
|
||||
handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key)),
|
||||
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key)),
|
||||
]);
|
||||
expect(companyRows).toHaveLength(0);
|
||||
expect(eventRows).toHaveLength(0);
|
||||
expect(outboxRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('replays a duplicate idempotency key without inserting a second event or outbox record', async () => {
|
||||
const first = input();
|
||||
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(original.replayed).toBe(false);
|
||||
expect(replay.replayed).toBe(true);
|
||||
expect(replay.event.id).toBe(original.event.id);
|
||||
const outbox = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.eventId, original.event.id));
|
||||
expect(outbox).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws on a duplicate idempotency key carrying different event content', async () => {
|
||||
const first = input();
|
||||
await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) =>
|
||||
repo.append(tx, { ...first, verb: 'rename', targetId: randomUUID() }),
|
||||
),
|
||||
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
|
||||
});
|
||||
|
||||
it('throws on a duplicate idempotency key whose transfer destination differs', async () => {
|
||||
const from = { kind: 'company' as const, id: randomUUID(), slug: 'src-co' };
|
||||
const to = { kind: 'company' as const, id: randomUUID(), slug: 'dst-co' };
|
||||
const first = input({
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
transferFrom: from,
|
||||
transferTo: to,
|
||||
});
|
||||
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(original.replayed).toBe(false);
|
||||
// Identical retry replays; a retry re-routed to a different destination must conflict.
|
||||
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(replay.replayed).toBe(true);
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) =>
|
||||
repo.append(tx, { ...first, transferTo: { ...to, id: randomUUID() } }),
|
||||
),
|
||||
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
|
||||
});
|
||||
|
||||
it('builds root-first parent chains and rejects unknown nodes', async () => {
|
||||
const companyId = randomUUID();
|
||||
const estateId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Chain Co', slug: 'chain-co' });
|
||||
await tx
|
||||
.insert(estates)
|
||||
.values({ id: estateId, name: 'Chain Estate', slug: 'chain-estate', companyId });
|
||||
await tx
|
||||
.insert(platformProjects)
|
||||
.values({ id: projectId, name: 'Chain Project', slug: 'chain-project', estateId });
|
||||
});
|
||||
const snapshot = await repo.snapshot(handle.db, 'platform_project', projectId);
|
||||
expect(snapshot).toMatchObject({ id: projectId, slug: 'chain-project', name: 'Chain Project' });
|
||||
expect(snapshot.parentChain).toEqual([
|
||||
{ kind: 'company', id: companyId, slug: 'chain-co' },
|
||||
{ kind: 'estate', id: estateId, slug: 'chain-estate' },
|
||||
]);
|
||||
await expect(repo.snapshot(handle.db, 'estate', randomUUID())).rejects.toThrow(
|
||||
HierarchyNodeNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps events readable, in per-target seq order, after the target row is deleted', async () => {
|
||||
const companyId = randomUUID();
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Mortal Co', slug: 'mortal-co' });
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
await repo.append(tx, input({ targetId: companyId, targetSnapshot: { ...snapshot } }));
|
||||
});
|
||||
await handle.db.transaction(async (tx) => {
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
await repo.append(tx, {
|
||||
...input({ verb: 'delete', targetId: companyId }),
|
||||
targetSnapshot: { ...snapshot },
|
||||
});
|
||||
await tx.delete(companies).where(eq(companies.id, companyId));
|
||||
});
|
||||
const events = await repo.eventsForTarget(companyId);
|
||||
expect(events.map((e) => e.verb)).toEqual(['create', 'delete']);
|
||||
expect(events[1]!.seq).toBeGreaterThan(events[0]!.seq);
|
||||
expect((events[1]!.targetSnapshot as { id: string }).id).toBe(companyId);
|
||||
});
|
||||
|
||||
it('claims the oldest pending outbox record exactly once, completes and releases by CAS', async () => {
|
||||
// Drain records left pending by earlier cases so ordering is deterministic.
|
||||
for (;;) {
|
||||
const drained = await repo.claimPendingOutbox();
|
||||
if (!drained) break;
|
||||
await repo.completeOutbox(drained.id);
|
||||
}
|
||||
const older = await handle.db.transaction(async (tx) => repo.append(tx, input()));
|
||||
const newer = await handle.db.transaction(async (tx) => repo.append(tx, input()));
|
||||
|
||||
const claimed = await repo.claimPendingOutbox();
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.eventId).toBe(older.event.id);
|
||||
expect(claimed!.status).toBe('processing');
|
||||
|
||||
// Delivery fails: release returns it to pending and it is claimable again.
|
||||
await repo.releaseOutbox(claimed!.id);
|
||||
const reclaimed = await repo.claimPendingOutbox();
|
||||
expect(reclaimed!.id).toBe(claimed!.id);
|
||||
|
||||
await repo.completeOutbox(reclaimed!.id);
|
||||
const done = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.id, reclaimed!.id));
|
||||
expect(done[0]!.status).toBe('delivered');
|
||||
expect(done[0]!.deliveredAt).not.toBeNull();
|
||||
// completeOutbox is CAS-guarded on 'processing': completing again is a no-op.
|
||||
await repo.completeOutbox(reclaimed!.id);
|
||||
|
||||
const second = await repo.claimPendingOutbox();
|
||||
expect(second!.eventId).toBe(newer.event.id);
|
||||
await repo.completeOutbox(second!.id);
|
||||
expect(await repo.claimPendingOutbox()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,274 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyOutbox,
|
||||
platformProjects,
|
||||
type Db,
|
||||
type HIERARCHY_AUDIT_TARGET_KINDS,
|
||||
type HIERARCHY_AUDIT_VERBS,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy audit event + outbox machinery (contract 1 §5.2).
|
||||
*
|
||||
* Every hierarchy mutation writes its semantic audit event AND the event's
|
||||
* outbox record on the caller's transaction, so state, event, and outbox
|
||||
* commit or roll back together. Events reference their target by an
|
||||
* immutable snapshot (id, slug, parent chain at event time), never by a
|
||||
* foreign key into the class tables — append-only events survive the
|
||||
* deletion of their target. This module exposes no update or delete path
|
||||
* for events: append-only is a property of the code surface, witnessed by
|
||||
* the integration tests.
|
||||
*
|
||||
* This is NOT a class-table writer: it touches only the audit/outbox
|
||||
* tables, so it does not appear on the writer-coverage allowlist. The
|
||||
* hierarchy command repository (HierarchyRepository) is the allowlisted
|
||||
* writer and calls into this on its own transactions.
|
||||
*/
|
||||
|
||||
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
|
||||
export type HierarchyTargetKind = (typeof HIERARCHY_AUDIT_TARGET_KINDS)[number];
|
||||
export type HierarchyNodeKind = Exclude<HierarchyTargetKind, 'grant'>;
|
||||
|
||||
export interface ParentChainEntry {
|
||||
readonly kind: HierarchyNodeKind;
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
/** Immutable node snapshot at event time; parentChain is root-first. */
|
||||
export interface HierarchyNodeSnapshot {
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
readonly parentChain: readonly ParentChainEntry[];
|
||||
}
|
||||
|
||||
export interface AppendHierarchyEventInput {
|
||||
readonly actorId: string;
|
||||
readonly verb: HierarchyAuditVerb;
|
||||
readonly targetKind: HierarchyTargetKind;
|
||||
readonly targetId: string;
|
||||
/** Node events: HierarchyNodeSnapshot. Grant events: subject/target/role snapshot (contract 2 §4.4). */
|
||||
readonly targetSnapshot: Record<string, unknown>;
|
||||
/** Present exactly on transfers (CHECK-enforced): source/destination parent { kind, id, slug }. */
|
||||
readonly transferFrom?: ParentChainEntry;
|
||||
readonly transferTo?: ParentChainEntry;
|
||||
readonly correlationId: string;
|
||||
/** Prior event in the causal chain (e.g. the delete event causing cascaded grant_revoke events). */
|
||||
readonly causationId?: string;
|
||||
readonly idempotencyKey: string;
|
||||
}
|
||||
|
||||
export type HierarchyAuditEventRow = typeof hierarchyAuditEvents.$inferSelect;
|
||||
export type HierarchyOutboxRow = typeof hierarchyOutbox.$inferSelect;
|
||||
|
||||
export interface AppendHierarchyEventResult {
|
||||
readonly event: HierarchyAuditEventRow;
|
||||
/** True when the idempotency key had already committed an identical event (REQ-AUD-001 duplicate suppression). */
|
||||
readonly replayed: boolean;
|
||||
}
|
||||
|
||||
type Tx = Pick<Db, 'insert' | 'select'>;
|
||||
|
||||
export class HierarchyAuditIdempotencyConflictError extends Error {
|
||||
constructor(idempotencyKey: string) {
|
||||
super(
|
||||
`hierarchy audit idempotency key ${idempotencyKey} already exists with different event content`,
|
||||
);
|
||||
this.name = 'HierarchyAuditIdempotencyConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class HierarchyNodeNotFoundError extends Error {
|
||||
constructor(kind: HierarchyNodeKind, id: string) {
|
||||
super(`hierarchy node not found: ${kind} ${id}`);
|
||||
this.name = 'HierarchyNodeNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one audit event and its outbox record on the caller's transaction.
|
||||
* A duplicate idempotency key with identical semantic content returns the
|
||||
* prior event (replayed: true) without inserting anything; a duplicate key
|
||||
* with different content throws.
|
||||
*/
|
||||
export async function appendHierarchyEvent(
|
||||
tx: Tx,
|
||||
input: AppendHierarchyEventInput,
|
||||
): Promise<AppendHierarchyEventResult> {
|
||||
const inserted = await tx
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values({
|
||||
actorId: input.actorId,
|
||||
verb: input.verb,
|
||||
targetKind: input.targetKind,
|
||||
targetId: input.targetId,
|
||||
targetSnapshot: input.targetSnapshot,
|
||||
transferFrom: input.transferFrom ?? null,
|
||||
transferTo: input.transferTo ?? null,
|
||||
correlationId: input.correlationId,
|
||||
causationId: input.causationId ?? null,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const event = inserted[0];
|
||||
if (event) {
|
||||
await tx.insert(hierarchyOutbox).values({
|
||||
eventId: event.id,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
correlationId: input.correlationId,
|
||||
});
|
||||
return { event, replayed: false };
|
||||
}
|
||||
|
||||
const prior = await tx
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, input.idempotencyKey))
|
||||
.limit(1);
|
||||
const existing = prior[0];
|
||||
if (!existing || !sameEvent(existing, input)) {
|
||||
throw new HierarchyAuditIdempotencyConflictError(input.idempotencyKey);
|
||||
}
|
||||
// Event and outbox committed atomically the first time, so the outbox
|
||||
// record already exists; a replay inserts nothing.
|
||||
return { event: existing, replayed: true };
|
||||
}
|
||||
|
||||
/** Key-order-independent serialization: jsonb does not preserve key order. */
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
const body = Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(',');
|
||||
return `{${body}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function sameEvent(row: HierarchyAuditEventRow, input: AppendHierarchyEventInput): boolean {
|
||||
return (
|
||||
row.actorId === input.actorId &&
|
||||
row.verb === input.verb &&
|
||||
row.targetKind === input.targetKind &&
|
||||
row.targetId === input.targetId &&
|
||||
row.correlationId === input.correlationId &&
|
||||
(row.causationId ?? null) === (input.causationId ?? null) &&
|
||||
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot) &&
|
||||
// Transfer source/destination are semantic content (§5.2): a retry with a
|
||||
// different destination must conflict, never silently replay.
|
||||
canonicalJson(row.transferFrom ?? null) === canonicalJson(input.transferFrom ?? null) &&
|
||||
canonicalJson(row.transferTo ?? null) === canonicalJson(input.transferTo ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable snapshot for a node: its row plus the parent chain up
|
||||
* to the company root, root-first, read on the caller's transaction so the
|
||||
* snapshot is consistent with the mutation it audits.
|
||||
*/
|
||||
export async function buildNodeSnapshot(
|
||||
tx: Tx,
|
||||
kind: HierarchyNodeKind,
|
||||
id: string,
|
||||
): Promise<HierarchyNodeSnapshot> {
|
||||
if (kind === 'company') {
|
||||
const rows = await tx.select().from(companies).where(eq(companies.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
return { id: row.id, slug: row.slug, name: row.name, parentChain: [] };
|
||||
}
|
||||
if (kind === 'estate') {
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
const parent = await buildNodeSnapshot(tx, 'company', row.companyId);
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
parentChain: [...parent.parentChain, { kind: 'company', id: parent.id, slug: parent.slug }],
|
||||
};
|
||||
}
|
||||
const rows = await tx.select().from(platformProjects).where(eq(platformProjects.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
const parent = await buildNodeSnapshot(tx, 'estate', row.estateId);
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
parentChain: [...parent.parentChain, { kind: 'estate', id: parent.id, slug: parent.slug }],
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyAuditRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/** Compose an event+outbox append into a caller-owned transaction. */
|
||||
append(tx: Tx, input: AppendHierarchyEventInput): Promise<AppendHierarchyEventResult> {
|
||||
return appendHierarchyEvent(tx, input);
|
||||
}
|
||||
|
||||
snapshot(tx: Tx, kind: HierarchyNodeKind, id: string): Promise<HierarchyNodeSnapshot> {
|
||||
return buildNodeSnapshot(tx, kind, id);
|
||||
}
|
||||
|
||||
/** Per-target ordered event history (REQ-AUD-001 per-target ordering; read-only). */
|
||||
async eventsForTarget(targetId: string): Promise<HierarchyAuditEventRow[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.targetId, targetId))
|
||||
.orderBy(asc(hierarchyAuditEvents.seq));
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the oldest pending outbox record (claim-by-CAS: the UPDATE is
|
||||
* guarded on status so a lost race returns null and the caller retries).
|
||||
*/
|
||||
async claimPendingOutbox(): Promise<HierarchyOutboxRow | null> {
|
||||
const candidates = await this.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.status, 'pending'))
|
||||
.orderBy(asc(hierarchyOutbox.createdAt))
|
||||
.limit(1);
|
||||
const candidate = candidates[0];
|
||||
if (!candidate) return null;
|
||||
const claimed = await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'processing', updatedAt: new Date() })
|
||||
.where(and(eq(hierarchyOutbox.id, candidate.id), eq(hierarchyOutbox.status, 'pending')))
|
||||
.returning();
|
||||
return claimed[0] ?? null;
|
||||
}
|
||||
|
||||
async completeOutbox(id: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'delivered', deliveredAt: now, updatedAt: now })
|
||||
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
|
||||
}
|
||||
|
||||
/** Return a claimed record to pending (delivery failed; it stays replayable). */
|
||||
async releaseOutbox(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
|
||||
}
|
||||
}
|
||||
@@ -1,965 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
companies,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyGrants,
|
||||
hierarchyOutbox,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
workspaces,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { appendHierarchyEvent } from './hierarchy-audit.repository.js';
|
||||
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
|
||||
import { HierarchyRepository, type HierarchyResult } from './hierarchy.repository.js';
|
||||
|
||||
/**
|
||||
* Command-level witnesses for the hierarchy command family (M4-1b-ii):
|
||||
* contract 1 §6.4 (per-mutation-class commit + rollback), §6.5
|
||||
* (authorization outcomes), §6.7 (no existence oracle), §6.9 (visibility),
|
||||
* and contract 2 §3 grant-evaluation semantics (deny-by-default,
|
||||
* ancestor-chain inheritance, max-role, live revocation, suspended team
|
||||
* subjects). Schema-level constraints are witnessed in
|
||||
* packages/db/src/hierarchy-schema.witness.test.ts; the audit machinery's
|
||||
* own atomicity in hierarchy-audit.integration.test.ts.
|
||||
*
|
||||
* The rollback legs pre-seed an audit event under the command's idempotency
|
||||
* key with different content: the command's append then throws inside the
|
||||
* command transaction, so the whole mutation must roll back — the command
|
||||
* returns `conflict` and leaves no state change, no second event, and no
|
||||
* second outbox record.
|
||||
*/
|
||||
describe('hierarchy commands integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let repo: HierarchyRepository;
|
||||
let evaluation: HierarchyGrantEvaluationService;
|
||||
|
||||
const OWNER = 'hier-cmd-owner';
|
||||
const ADMIN = 'hier-cmd-admin';
|
||||
const STRANGER = 'hier-cmd-stranger';
|
||||
const SUBJECT = 'hier-cmd-subject';
|
||||
|
||||
/** Base fixture: OWNER's company (created through the command surface). */
|
||||
let companyId: string;
|
||||
|
||||
const slug = (prefix: string): string => `${prefix}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
function expectOk<T>(result: HierarchyResult<T>): { ok: true } & T {
|
||||
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const eventsForKey = (key: string) =>
|
||||
handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
|
||||
|
||||
const outboxForKey = (key: string) =>
|
||||
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key));
|
||||
|
||||
/** Occupy `key` with unrelated event content so a command reusing it must abort. */
|
||||
const seedConflictingKey = async (key: string): Promise<void> => {
|
||||
await handle.db.transaction(async (tx) =>
|
||||
appendHierarchyEvent(tx, {
|
||||
actorId: 'seed-actor',
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: randomUUID(),
|
||||
targetSnapshot: { seeded: true },
|
||||
correlationId: 'seed-correlation',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* §6.4 rollback leg: the command must return `conflict` and leave exactly
|
||||
* the seeded event/outbox pair under the key — nothing it wrote survives.
|
||||
*/
|
||||
const expectRolledBack = async <T>(
|
||||
key: string,
|
||||
command: () => Promise<HierarchyResult<T>>,
|
||||
assertUnchanged: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
await seedConflictingKey(key);
|
||||
const result = await command();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe('conflict');
|
||||
expect(await eventsForKey(key)).toHaveLength(1);
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
await assertUnchanged();
|
||||
};
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-commands-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
HierarchyRepository,
|
||||
HierarchyGrantEvaluationService,
|
||||
{ provide: DB, useValue: handle.db },
|
||||
],
|
||||
}).compile();
|
||||
repo = moduleRef.get(HierarchyRepository);
|
||||
evaluation = moduleRef.get(HierarchyGrantEvaluationService);
|
||||
|
||||
await handle.db.insert(users).values([
|
||||
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
|
||||
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
|
||||
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
|
||||
{ id: SUBJECT, name: 'Subject', email: `${SUBJECT}@example.com` },
|
||||
]);
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Base Co', slug: slug('base') }),
|
||||
);
|
||||
companyId = created.company.id;
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await moduleRef.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── §6.4 commit legs ───────────────────────────────────────────────────────
|
||||
|
||||
it('createCompany commits company, owner grant, causation-linked events, and outbox atomically', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
const result = expectOk(
|
||||
await repo.createCompany({
|
||||
actorId: OWNER,
|
||||
name: 'Atomic Co',
|
||||
slug: slug('atomic'),
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
expect(result.company.visibility).toBe('private');
|
||||
expect(result.grant.role).toBe('hierarchy:owner');
|
||||
expect(result.grant.userId).toBe(OWNER);
|
||||
expect(result.grant.grantedBy).toBe(OWNER);
|
||||
|
||||
const [createEvents, grantEvents] = await Promise.all([
|
||||
eventsForKey(key),
|
||||
eventsForKey(`${key}:grant`),
|
||||
]);
|
||||
expect(createEvents).toHaveLength(1);
|
||||
expect(createEvents[0]).toMatchObject({ verb: 'create', targetId: result.company.id });
|
||||
expect(grantEvents).toHaveLength(1);
|
||||
expect(grantEvents[0]).toMatchObject({ verb: 'grant_create', targetId: result.grant.id });
|
||||
// The grant event is caused by the create event, same correlation (§4.3).
|
||||
expect(grantEvents[0]!.causationId).toBe(createEvents[0]!.id);
|
||||
expect(grantEvents[0]!.correlationId).toBe(createEvents[0]!.correlationId);
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
expect(await outboxForKey(`${key}:grant`)).toHaveLength(1);
|
||||
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, result.company.id));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deleteCompany commits the delete with one audited grant_revoke per cascaded grant', async () => {
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Mortal Co', slug: slug('mortal') }),
|
||||
);
|
||||
const extraGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: created.company.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.deleteCompany({
|
||||
actorId: OWNER,
|
||||
companyId: created.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
const deleteEvents = await eventsForKey(key);
|
||||
expect(deleteEvents).toHaveLength(1);
|
||||
expect(deleteEvents[0]).toMatchObject({ verb: 'delete', targetId: created.company.id });
|
||||
for (const grantId of [created.grant.id, extraGrant.grant.id]) {
|
||||
const revokeEvents = await eventsForKey(`${key}:revoke:${grantId}`);
|
||||
expect(revokeEvents).toHaveLength(1);
|
||||
expect(revokeEvents[0]).toMatchObject({ verb: 'grant_revoke', targetId: grantId });
|
||||
expect(revokeEvents[0]!.causationId).toBe(deleteEvents[0]!.id);
|
||||
}
|
||||
expect(
|
||||
await handle.db.select().from(companies).where(eq(companies.id, created.company.id)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── §6.4 rollback legs (one per mutation class) ────────────────────────────
|
||||
|
||||
it('renameCompany commits the rename with an audited event carrying previousName', async () => {
|
||||
const created = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Old Name Co', slug: slug('rename') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
const renamed = expectOk(
|
||||
await repo.renameCompany({
|
||||
actorId: OWNER,
|
||||
companyId: created.company.id,
|
||||
name: 'New Name Co',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
expect(renamed.company.name).toBe('New Name Co');
|
||||
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'rename', targetId: created.company.id });
|
||||
// §6.4: the audited rename carries the old and new names.
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
name: 'New Name Co',
|
||||
previousName: 'Old Name Co',
|
||||
});
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, created.company.id));
|
||||
expect(rows[0]!.name).toBe('New Name Co');
|
||||
});
|
||||
|
||||
it('revokeGrant commits the row deletion with one audited grant_revoke event', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
const revoked = expectOk(
|
||||
await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: key }),
|
||||
);
|
||||
expect(revoked.revokedId).toBe(grant.grant.id);
|
||||
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'grant_revoke', targetId: grant.grant.id });
|
||||
expect(await outboxForKey(key)).toHaveLength(1);
|
||||
|
||||
// §6 revocation = row deletion: the grant row is gone.
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id));
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rolls back a create: no estate row survives the aborted transaction', async () => {
|
||||
const estateSlug = slug('rb-create');
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
name: 'Doomed Estate',
|
||||
slug: estateSlug,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db.select().from(estates).where(eq(estates.slug, estateSlug)),
|
||||
).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a rename: the company keeps its name', async () => {
|
||||
const before = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() => repo.renameCompany({ actorId: OWNER, companyId, name: 'Never', idempotencyKey: key }),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
expect(after.name).toBe(before.name);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a visibility change: the company stays private', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId,
|
||||
visibility: 'directory',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(companies).where(eq(companies.id, companyId))
|
||||
)[0]!;
|
||||
expect(after.visibility).toBe('private');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a transfer: the estate keeps its parent', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-T', slug: slug('rb-t') }),
|
||||
);
|
||||
const other = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'RB Dest', slug: slug('rb-dest') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: other.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const after = (
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
|
||||
)[0]!;
|
||||
expect(after.companyId).toBe(companyId);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a delete: the estate row survives', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-D', slug: slug('rb-d') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() => repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id, idempotencyKey: key }),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id)),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a grant create: no grant row survives', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
key,
|
||||
() =>
|
||||
repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: STRANGER,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
async () => {
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.userId, STRANGER));
|
||||
expect(rows.filter((r) => r.companyId === companyId)).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back a grant change and a grant revoke: the grant keeps its role and its row', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const changeKey = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
changeKey,
|
||||
() =>
|
||||
repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'member',
|
||||
idempotencyKey: changeKey,
|
||||
}),
|
||||
async () => {
|
||||
const row = (
|
||||
await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id))
|
||||
)[0]!;
|
||||
expect(row.role).toBe('viewer');
|
||||
},
|
||||
);
|
||||
const revokeKey = `key-${randomUUID()}`;
|
||||
await expectRolledBack(
|
||||
revokeKey,
|
||||
() =>
|
||||
repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: revokeKey }),
|
||||
async () => {
|
||||
expect(
|
||||
await handle.db
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, grant.grant.id)),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
|
||||
});
|
||||
|
||||
it('replays a completed command idempotently through the audit machinery', async () => {
|
||||
const key = `key-${randomUUID()}`;
|
||||
const input = { actorId: OWNER, companyId, name: 'Replayed Estate', slug: slug('replay') };
|
||||
const first = expectOk(await repo.createEstate({ ...input, idempotencyKey: key }));
|
||||
// The retry's insert no-ops on the slug conflict — the command surfaces
|
||||
// `conflict`, and crucially appends no second event under the key.
|
||||
const retry = await repo.createEstate({ ...input, idempotencyKey: key });
|
||||
expect(retry.ok).toBe(false);
|
||||
expect(await eventsForKey(key)).toHaveLength(1);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: first.estate.id }));
|
||||
});
|
||||
|
||||
// ── §6.5 authorization ─────────────────────────────────────────────────────
|
||||
|
||||
it('deny-by-default: a user with no grant cannot mutate and sees not_found (§3.1)', async () => {
|
||||
expect(await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
expect(
|
||||
await repo.createEstate({ actorId: STRANGER, companyId, name: 'x', slug: slug('deny') }),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.deleteCompany({ actorId: STRANGER, companyId })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('grant management requires effective owner: member and viewer are refused (§4.1)', async () => {
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'member',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await repo.createGrant({
|
||||
actorId: SUBJECT,
|
||||
userId: STRANGER,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.revokeGrant({ actorId: SUBJECT, grantId: grant.grant.id })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
// Member also cannot create children (owner-only, §4.1/§4.3).
|
||||
expect(
|
||||
await repo.createEstate({ actorId: SUBJECT, companyId, name: 'x', slug: slug('member') }),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
|
||||
});
|
||||
|
||||
it('platform admin confers no tenant content access (§1.1): ungrated admin is a stranger', async () => {
|
||||
expect(await repo.renameCompany({ actorId: ADMIN, companyId, name: 'x' })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
expect(
|
||||
await repo.createGrant({
|
||||
actorId: ADMIN,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
expect(await repo.listGrantedCompanies(ADMIN)).toEqual([]);
|
||||
expect(await evaluation.effectiveRole(ADMIN, 'company', companyId)).toBeNull();
|
||||
});
|
||||
|
||||
it('visibility change is platform-admin-only (§5.5): the owner is forbidden, the admin succeeds', async () => {
|
||||
const owned = await repo.changeCompanyVisibility({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
visibility: 'directory',
|
||||
});
|
||||
expect(owned).toEqual({
|
||||
ok: false,
|
||||
error: 'forbidden',
|
||||
message: 'visibility change is platform-admin-only',
|
||||
});
|
||||
const changed = expectOk(
|
||||
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'directory' }),
|
||||
);
|
||||
expect(changed.company.visibility).toBe('directory');
|
||||
// Restore for later witnesses.
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'private' }),
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.9 visibility ────────────────────────────────────────────────────────
|
||||
|
||||
it('directory lists exactly directory-class companies with closed fields (§2.8)', async () => {
|
||||
const listed = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Listed Co', slug: slug('listed') }),
|
||||
);
|
||||
const unlisted = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Unlisted Co', slug: slug('unlisted') }),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId: listed.company.id,
|
||||
visibility: 'directory',
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
|
||||
const directory = await repo.listDirectory();
|
||||
const ids = directory.map((entry) => entry.id);
|
||||
expect(ids).toContain(listed.company.id);
|
||||
expect(ids).not.toContain(unlisted.company.id);
|
||||
expect(ids).not.toContain(companyId);
|
||||
// Closed-field: existence, name, slug — nothing else (no visibility, no
|
||||
// timestamps, no grant or membership data).
|
||||
for (const entry of directory) {
|
||||
expect(Object.keys(entry).sort()).toEqual(['id', 'name', 'slug']);
|
||||
}
|
||||
|
||||
// §5.5: the audited event carries old and new values.
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'visibility_change', targetId: listed.company.id });
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
previousVisibility: 'private',
|
||||
visibility: 'directory',
|
||||
});
|
||||
});
|
||||
|
||||
it('directory disclosure confers no authority: a listed company still refuses non-granted callers (§6.9)', async () => {
|
||||
const listed = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Exposed Co', slug: slug('exposed') }),
|
||||
);
|
||||
expectOk(
|
||||
await repo.changeCompanyVisibility({
|
||||
actorId: ADMIN,
|
||||
companyId: listed.company.id,
|
||||
visibility: 'directory',
|
||||
}),
|
||||
);
|
||||
|
||||
// The company is directory-listed for the whole probe window...
|
||||
expect((await repo.listDirectory()).map((entry) => entry.id)).toContain(listed.company.id);
|
||||
|
||||
// ...but the non-granted reader's granted-read surface still excludes it:
|
||||
// directory disclosure adds existence/name/slug only, never content access.
|
||||
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
|
||||
|
||||
// A stranger mutation of the listed company is refused exactly like a
|
||||
// missing node — the §6.7 carve-out covers the listing, not commands.
|
||||
const realProbe = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: listed.company.id,
|
||||
name: 'x',
|
||||
});
|
||||
const missingProbe = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: randomUUID(),
|
||||
name: 'x',
|
||||
});
|
||||
expect(realProbe).toEqual(missingProbe);
|
||||
expect(await repo.deleteCompany({ actorId: STRANGER, companyId: listed.company.id })).toEqual({
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('granted companies are the reader control: owner sees them, a stranger sees nothing (§2.8)', async () => {
|
||||
const ownerCompanies = await repo.listGrantedCompanies(OWNER);
|
||||
expect(ownerCompanies.map((c) => c.id)).toContain(companyId);
|
||||
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
|
||||
});
|
||||
|
||||
// ── §6.7 no existence oracle ───────────────────────────────────────────────
|
||||
|
||||
it('an unauthorized probe of a real node is indistinguishable from a missing node', async () => {
|
||||
const realCompany = await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' });
|
||||
const missingCompany = await repo.renameCompany({
|
||||
actorId: STRANGER,
|
||||
companyId: randomUUID(),
|
||||
name: 'x',
|
||||
});
|
||||
expect(realCompany).toEqual(missingCompany);
|
||||
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId,
|
||||
name: 'Oracle E',
|
||||
slug: slug('oracle'),
|
||||
}),
|
||||
);
|
||||
const realEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: estate.estate.id });
|
||||
const missingEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: randomUUID() });
|
||||
expect(realEstate).toEqual(missingEstate);
|
||||
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'estate',
|
||||
targetId: estate.estate.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const realGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: grant.grant.id });
|
||||
const missingGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: randomUUID() });
|
||||
expect(realGrant).toEqual(missingGrant);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
|
||||
});
|
||||
|
||||
// ── contract 2 §3 grant evaluation ─────────────────────────────────────────
|
||||
|
||||
it('a company grant confers its role down the whole chain, workspace included (§3.2)', async () => {
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({ actorId: OWNER, companyId, name: 'Chain E', slug: slug('chain') }),
|
||||
);
|
||||
const project = expectOk(
|
||||
await repo.createPlatformProject({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
name: 'Chain P',
|
||||
slug: slug('chain-p'),
|
||||
}),
|
||||
);
|
||||
// Workspaces are evaluable but not hierarchy commands; seed one directly.
|
||||
const workspaceId = randomUUID();
|
||||
await handle.db.insert(workspaces).values({
|
||||
id: workspaceId,
|
||||
name: 'Chain W',
|
||||
slug: slug('chain-w'),
|
||||
platformProjectId: project.platformProject.id,
|
||||
});
|
||||
|
||||
for (const [kind, id] of [
|
||||
['company', companyId],
|
||||
['estate', estate.estate.id],
|
||||
['platform_project', project.platformProject.id],
|
||||
['workspace', workspaceId],
|
||||
] as const) {
|
||||
expect(await evaluation.effectiveRole(OWNER, kind, id)).toBe('owner');
|
||||
expect(await evaluation.effectiveRole(STRANGER, kind, id)).toBeNull();
|
||||
}
|
||||
|
||||
// Max-role (§3.3): viewer on the company + owner on the estate → owner at
|
||||
// and below the estate, viewer at the company.
|
||||
const viewerGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: companyId,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const ownerGrant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'estate',
|
||||
targetId: estate.estate.id,
|
||||
role: 'owner',
|
||||
}),
|
||||
);
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBe('viewer');
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('owner');
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'workspace', workspaceId)).toBe('owner');
|
||||
|
||||
// Revocation is row deletion and denies the very next evaluation (§6).
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: ownerGrant.grant.id }));
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('viewer');
|
||||
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: viewerGrant.grant.id }));
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
|
||||
|
||||
await handle.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
|
||||
expectOk(
|
||||
await repo.deletePlatformProject({
|
||||
actorId: OWNER,
|
||||
platformProjectId: project.platformProject.id,
|
||||
}),
|
||||
);
|
||||
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
|
||||
});
|
||||
|
||||
it('team grant subjects are suspended: a team row confers nothing and cannot be changed (§1.4)', async () => {
|
||||
const teamId = randomUUID();
|
||||
await handle.db.insert(teams).values({
|
||||
id: teamId,
|
||||
name: slug('team'),
|
||||
slug: slug('team'),
|
||||
ownerId: SUBJECT,
|
||||
managerId: SUBJECT,
|
||||
});
|
||||
// Out-of-band team row (the command surface cannot create one).
|
||||
const inserted = await handle.db
|
||||
.insert(hierarchyGrants)
|
||||
.values({ teamId, companyId, role: 'owner', grantedBy: OWNER })
|
||||
.returning();
|
||||
const teamGrantId = inserted[0]!.id;
|
||||
|
||||
// The team's own owner gains no effective role from it.
|
||||
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
|
||||
// changeGrant refuses the row.
|
||||
expect(
|
||||
await repo.changeGrant({ actorId: OWNER, grantId: teamGrantId, role: 'viewer' }),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: 'conflict',
|
||||
message: 'team grant subjects are suspended',
|
||||
});
|
||||
await handle.db.delete(hierarchyGrants).where(eq(hierarchyGrants.id, teamGrantId));
|
||||
await handle.db.delete(teams).where(eq(teams.id, teamId));
|
||||
});
|
||||
|
||||
// ── command conflict semantics ─────────────────────────────────────────────
|
||||
|
||||
it('transfer needs owner on both parents in its own transaction, and refuses no-op and colliding transfers (§5)', async () => {
|
||||
const source = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Src Co', slug: slug('src') }),
|
||||
);
|
||||
const destination = expectOk(
|
||||
await repo.createCompany({ actorId: SUBJECT, name: 'Dst Co', slug: slug('dst') }),
|
||||
);
|
||||
const estateSlug = slug('mv');
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: source.company.id,
|
||||
name: 'Mv E',
|
||||
slug: estateSlug,
|
||||
}),
|
||||
);
|
||||
|
||||
// OWNER owns the source but not the destination → not_found (§6.7-safe).
|
||||
expect(
|
||||
await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: destination.company.id,
|
||||
}),
|
||||
).toEqual({ ok: false, error: 'not_found' });
|
||||
|
||||
// Same-parent transfer is refused.
|
||||
const samePlace = await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: source.company.id,
|
||||
});
|
||||
expect(samePlace.ok).toBe(false);
|
||||
if (!samePlace.ok) expect(samePlace.error).toBe('conflict');
|
||||
|
||||
// Grant OWNER the destination; a slug collision there is refused.
|
||||
expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: SUBJECT,
|
||||
userId: OWNER,
|
||||
targetKind: 'company',
|
||||
targetId: destination.company.id,
|
||||
role: 'owner',
|
||||
}),
|
||||
);
|
||||
expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: destination.company.id,
|
||||
name: 'Collide',
|
||||
slug: estateSlug,
|
||||
}),
|
||||
);
|
||||
const collision = await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: destination.company.id,
|
||||
});
|
||||
expect(collision.ok).toBe(false);
|
||||
if (!collision.ok) expect(collision.error).toBe('conflict');
|
||||
});
|
||||
|
||||
it('a successful transfer records transfer_from and transfer_to (§6.4 three-leg witness)', async () => {
|
||||
const from = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'From Co', slug: slug('from') }),
|
||||
);
|
||||
const to = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'To Co', slug: slug('to') }),
|
||||
);
|
||||
const estate = expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: from.company.id,
|
||||
name: 'Moved E',
|
||||
slug: slug('moved'),
|
||||
}),
|
||||
);
|
||||
const key = `key-${randomUUID()}`;
|
||||
expectOk(
|
||||
await repo.transferEstate({
|
||||
actorId: OWNER,
|
||||
estateId: estate.estate.id,
|
||||
destinationCompanyId: to.company.id,
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
const moved = (
|
||||
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
|
||||
)[0]!;
|
||||
expect(moved.companyId).toBe(to.company.id);
|
||||
const events = await eventsForKey(key);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ verb: 'transfer', targetId: estate.estate.id });
|
||||
expect(events[0]!.transferFrom).toMatchObject({ kind: 'company', id: from.company.id });
|
||||
expect(events[0]!.transferTo).toMatchObject({ kind: 'company', id: to.company.id });
|
||||
// The post-transfer snapshot's parent chain names the destination.
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
parentChain: [{ kind: 'company', id: to.company.id, slug: to.company.slug }],
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses duplicate slugs, deletes with children, and degenerate grant commands as conflicts', async () => {
|
||||
const co = expectOk(
|
||||
await repo.createCompany({ actorId: OWNER, name: 'Conflict Co', slug: slug('conf') }),
|
||||
);
|
||||
const dupSlug = await repo.createCompany({ actorId: OWNER, name: 'x', slug: co.company.slug });
|
||||
expect(dupSlug.ok).toBe(false);
|
||||
if (!dupSlug.ok) expect(dupSlug.error).toBe('conflict');
|
||||
|
||||
expectOk(
|
||||
await repo.createEstate({
|
||||
actorId: OWNER,
|
||||
companyId: co.company.id,
|
||||
name: 'Child',
|
||||
slug: slug('child'),
|
||||
}),
|
||||
);
|
||||
const withChildren = await repo.deleteCompany({ actorId: OWNER, companyId: co.company.id });
|
||||
expect(withChildren.ok).toBe(false);
|
||||
if (!withChildren.ok) expect(withChildren.error).toBe('conflict');
|
||||
|
||||
// Grant to a nonexistent subject is refused (the caller already holds
|
||||
// owner, so the refusal discloses nothing new).
|
||||
const ghost = await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: `missing-${randomUUID()}`,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(ghost.ok).toBe(false);
|
||||
if (!ghost.ok) expect(ghost.error).toBe('conflict');
|
||||
|
||||
const grant = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
}),
|
||||
);
|
||||
const duplicate = await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(duplicate.ok).toBe(false);
|
||||
if (!duplicate.ok) expect(duplicate.error).toBe('conflict');
|
||||
|
||||
const sameRole = await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'viewer',
|
||||
});
|
||||
expect(sameRole.ok).toBe(false);
|
||||
if (!sameRole.ok) expect(sameRole.error).toBe('conflict');
|
||||
|
||||
// A second grant with another role exists → changing the first onto that
|
||||
// role would collide with the unique constraint; refused ahead of it.
|
||||
const second = expectOk(
|
||||
await repo.createGrant({
|
||||
actorId: OWNER,
|
||||
userId: SUBJECT,
|
||||
targetKind: 'company',
|
||||
targetId: co.company.id,
|
||||
role: 'member',
|
||||
}),
|
||||
);
|
||||
const collide = await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: grant.grant.id,
|
||||
role: 'member',
|
||||
});
|
||||
expect(collide.ok).toBe(false);
|
||||
if (!collide.ok) expect(collide.error).toBe('conflict');
|
||||
|
||||
// A clean change succeeds and records the previous role, namespaced (§4.5).
|
||||
const changeKey = `key-${randomUUID()}`;
|
||||
const changed = expectOk(
|
||||
await repo.changeGrant({
|
||||
actorId: OWNER,
|
||||
grantId: second.grant.id,
|
||||
role: 'owner',
|
||||
idempotencyKey: changeKey,
|
||||
}),
|
||||
);
|
||||
expect(changed.grant.role).toBe('hierarchy:owner');
|
||||
const events = await eventsForKey(changeKey);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.targetSnapshot).toMatchObject({
|
||||
role: 'hierarchy:owner',
|
||||
previousRole: 'hierarchy:member',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
inArray,
|
||||
or,
|
||||
platformProjects,
|
||||
workspaces,
|
||||
and,
|
||||
type Db,
|
||||
HIERARCHY_GRANT_ROLES,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy grant evaluation (contract 2 §3).
|
||||
*
|
||||
* Deny-by-default (§3.1): a user's effective role on a node is null unless a
|
||||
* grant row explicitly confers one. Grants apply down the chain only (§3.2):
|
||||
* the effective role on a node is the maximum role over grants targeting the
|
||||
* node itself or any of its ancestors, maximum per the total order
|
||||
* viewer ⊂ member ⊂ owner (§2). Evaluation is live and per-decision — no
|
||||
* caching — so revocation (row deletion, §6) denies the next decision
|
||||
* inherently. A missing node evaluates to null, indistinguishable from
|
||||
* no-grant, which keeps unauthorized probes oracle-safe (contract 1 §6.7).
|
||||
*
|
||||
* Team grant subjects are SUSPENDED (§1.4): the command surface refuses to
|
||||
* create them and this evaluator considers user-subject grants only, so a
|
||||
* team row could not confer access even if one existed.
|
||||
*
|
||||
* Read-only module: it selects from the class tables but never writes them,
|
||||
* so it does not appear on the writer-coverage allowlist.
|
||||
*/
|
||||
|
||||
export type HierarchyGrantRole = (typeof HIERARCHY_GRANT_ROLES)[number];
|
||||
|
||||
/** Node kinds a grant may target (§3.2; workspace is evaluable, not grantable). */
|
||||
export type GrantTargetKind = 'company' | 'estate' | 'platform_project';
|
||||
/** Node kinds an authorization decision may be evaluated at (§3.2: down to workspace). */
|
||||
export type EvaluableNodeKind = GrantTargetKind | 'workspace';
|
||||
|
||||
type Tx = Pick<Db, 'select'>;
|
||||
|
||||
/** Ancestor chain of a node, self included at its own level; ids only. */
|
||||
export interface AncestorChain {
|
||||
readonly companyId: string;
|
||||
readonly estateId?: string;
|
||||
readonly platformProjectId?: string;
|
||||
readonly workspaceId?: string;
|
||||
}
|
||||
|
||||
export function roleStrength(role: HierarchyGrantRole): number {
|
||||
return HIERARCHY_GRANT_ROLES.indexOf(role);
|
||||
}
|
||||
|
||||
export function roleAtLeast(
|
||||
role: HierarchyGrantRole | null,
|
||||
required: HierarchyGrantRole,
|
||||
): boolean {
|
||||
return role !== null && roleStrength(role) >= roleStrength(required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized role strings are namespaced (§4.5): audit events and API
|
||||
* responses carry `hierarchy:owner`, never a bare `owner`.
|
||||
*/
|
||||
export function namespacedHierarchyRole(role: HierarchyGrantRole): string {
|
||||
return `hierarchy:${role}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a node's ancestor chain (self included). Returns null when the
|
||||
* node does not exist — callers treat that exactly like no-grant (§3.1,
|
||||
* oracle-safe).
|
||||
*/
|
||||
export async function resolveAncestorChain(
|
||||
tx: Tx,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
): Promise<AncestorChain | null> {
|
||||
if (kind === 'company') {
|
||||
const rows = await tx
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? { companyId: row.id } : null;
|
||||
}
|
||||
if (kind === 'estate') {
|
||||
const rows = await tx
|
||||
.select({ id: estates.id, companyId: estates.companyId })
|
||||
.from(estates)
|
||||
.where(eq(estates.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? { companyId: row.companyId, estateId: row.id } : null;
|
||||
}
|
||||
if (kind === 'platform_project') {
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: platformProjects.id,
|
||||
estateId: platformProjects.estateId,
|
||||
companyId: estates.companyId,
|
||||
})
|
||||
.from(platformProjects)
|
||||
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
|
||||
.where(eq(platformProjects.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.id }
|
||||
: null;
|
||||
}
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: workspaces.id,
|
||||
platformProjectId: workspaces.platformProjectId,
|
||||
estateId: platformProjects.estateId,
|
||||
companyId: estates.companyId,
|
||||
})
|
||||
.from(workspaces)
|
||||
.innerJoin(platformProjects, eq(platformProjects.id, workspaces.platformProjectId))
|
||||
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
|
||||
.where(eq(workspaces.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? {
|
||||
companyId: row.companyId,
|
||||
estateId: row.estateId,
|
||||
platformProjectId: row.platformProjectId,
|
||||
workspaceId: row.id,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function maxRole(roles: readonly string[]): HierarchyGrantRole | null {
|
||||
let best: HierarchyGrantRole | null = null;
|
||||
for (const candidate of roles) {
|
||||
// Fail-closed: a value outside the vocabulary confers nothing.
|
||||
if (!(HIERARCHY_GRANT_ROLES as readonly string[]).includes(candidate)) continue;
|
||||
const role = candidate as HierarchyGrantRole;
|
||||
if (best === null || roleStrength(role) > roleStrength(best)) best = role;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective role of a user on a node: maximum over the user's grants whose
|
||||
* target is the node or any ancestor (§3.2); null = deny (§3.1). Missing
|
||||
* node → null.
|
||||
*/
|
||||
export async function evaluateEffectiveRole(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
): Promise<HierarchyGrantRole | null> {
|
||||
const chain = await resolveAncestorChain(tx, kind, id);
|
||||
if (!chain) return null;
|
||||
|
||||
const targetConditions = [eq(hierarchyGrants.companyId, chain.companyId)];
|
||||
if (chain.estateId) targetConditions.push(eq(hierarchyGrants.estateId, chain.estateId));
|
||||
if (chain.platformProjectId) {
|
||||
targetConditions.push(eq(hierarchyGrants.platformProjectId, chain.platformProjectId));
|
||||
}
|
||||
|
||||
const rows = await tx
|
||||
.select({ role: hierarchyGrants.role })
|
||||
.from(hierarchyGrants)
|
||||
.where(and(eq(hierarchyGrants.userId, userId), or(...targetConditions)));
|
||||
return maxRole(rows.map((r) => r.role));
|
||||
}
|
||||
|
||||
/**
|
||||
* All companies on which the user holds any effective role, i.e. companies
|
||||
* with a grant on the company itself or on any descendant (contract 1 §2.8:
|
||||
* a grant anywhere in the subtree discloses the company's chain upward).
|
||||
*/
|
||||
export async function grantedCompanyIds(tx: Tx, userId: string): Promise<string[]> {
|
||||
const grants = await tx
|
||||
.select({
|
||||
companyId: hierarchyGrants.companyId,
|
||||
estateId: hierarchyGrants.estateId,
|
||||
platformProjectId: hierarchyGrants.platformProjectId,
|
||||
})
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.userId, userId));
|
||||
|
||||
const companyIds = new Set<string>();
|
||||
const estateIds = new Set<string>();
|
||||
const platformProjectIds = new Set<string>();
|
||||
for (const grant of grants) {
|
||||
if (grant.companyId) companyIds.add(grant.companyId);
|
||||
else if (grant.estateId) estateIds.add(grant.estateId);
|
||||
else if (grant.platformProjectId) platformProjectIds.add(grant.platformProjectId);
|
||||
}
|
||||
|
||||
if (platformProjectIds.size > 0) {
|
||||
const rows = await tx
|
||||
.select({ estateId: platformProjects.estateId })
|
||||
.from(platformProjects)
|
||||
.where(inArray(platformProjects.id, [...platformProjectIds]));
|
||||
for (const row of rows) estateIds.add(row.estateId);
|
||||
}
|
||||
if (estateIds.size > 0) {
|
||||
const rows = await tx
|
||||
.select({ companyId: estates.companyId })
|
||||
.from(estates)
|
||||
.where(inArray(estates.id, [...estateIds]));
|
||||
for (const row of rows) companyIds.add(row.companyId);
|
||||
}
|
||||
return [...companyIds];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyGrantEvaluationService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/** Live per-decision evaluation; pass a tx to evaluate inside a command's transaction. */
|
||||
effectiveRole(
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
tx?: Tx,
|
||||
): Promise<HierarchyGrantRole | null> {
|
||||
return evaluateEffectiveRole(tx ?? this.db, userId, kind, id);
|
||||
}
|
||||
|
||||
async hasRole(
|
||||
userId: string,
|
||||
kind: EvaluableNodeKind,
|
||||
id: string,
|
||||
required: HierarchyGrantRole,
|
||||
tx?: Tx,
|
||||
): Promise<boolean> {
|
||||
return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required);
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import {
|
||||
ChangeCompanyVisibilityDto,
|
||||
ChangeGrantDto,
|
||||
CreateCompanyDto,
|
||||
CreateEstateDto,
|
||||
CreateGrantDto,
|
||||
CreatePlatformProjectDto,
|
||||
DeleteNodeDto,
|
||||
RenameNodeDto,
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} from './hierarchy.dto.js';
|
||||
import { HierarchyRepository } from './hierarchy.repository.js';
|
||||
import { HierarchyService } from './hierarchy.service.js';
|
||||
|
||||
/**
|
||||
* The hierarchy command family (contract 1 §5, §6.3). This controller is the
|
||||
* closed HTTP surface over the hierarchy class tables: the route-inventory
|
||||
* witness asserts these routes and no others exist. Delete commands take an
|
||||
* optional body (idempotency key) via POST-style DTOs; every mutation is
|
||||
* audited on its own transaction by the repository.
|
||||
*/
|
||||
@Controller('api/hierarchy')
|
||||
@UseGuards(AuthGuard)
|
||||
export class HierarchyController {
|
||||
constructor(
|
||||
private readonly repository: HierarchyRepository,
|
||||
private readonly service: HierarchyService,
|
||||
) {}
|
||||
|
||||
// ── companies ────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('companies')
|
||||
async createCompany(@CurrentUser() user: { id: string }, @Body() dto: CreateCompanyDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createCompany({
|
||||
actorId: user.id,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Companies the caller holds a grant on (directly or via a descendant). */
|
||||
@Get('companies')
|
||||
listGrantedCompanies(@CurrentUser() user: { id: string }) {
|
||||
return this.repository.listGrantedCompanies(user.id);
|
||||
}
|
||||
|
||||
/** Directory-class companies, closed-field (§2.8). */
|
||||
@Get('companies/directory')
|
||||
listDirectory() {
|
||||
return this.repository.listDirectory();
|
||||
}
|
||||
|
||||
@Post('companies/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renameCompany(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renameCompany({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('companies/:id/visibility')
|
||||
@HttpCode(200)
|
||||
async changeCompanyVisibility(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ChangeCompanyVisibilityDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.changeCompanyVisibility({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
visibility: dto.visibility,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('companies/:id')
|
||||
async deleteCompany(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deleteCompany({
|
||||
actorId: user.id,
|
||||
companyId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── estates ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('estates')
|
||||
async createEstate(@CurrentUser() user: { id: string }, @Body() dto: CreateEstateDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createEstate({
|
||||
actorId: user.id,
|
||||
companyId: dto.companyId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('estates/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renameEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renameEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('estates/:id/transfer')
|
||||
@HttpCode(200)
|
||||
async transferEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: TransferEstateDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.transferEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
destinationCompanyId: dto.destinationCompanyId,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('estates/:id')
|
||||
async deleteEstate(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deleteEstate({
|
||||
actorId: user.id,
|
||||
estateId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── platform projects ────────────────────────────────────────────────────
|
||||
|
||||
@Post('platform-projects')
|
||||
async createPlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body() dto: CreatePlatformProjectDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createPlatformProject({
|
||||
actorId: user.id,
|
||||
estateId: dto.estateId,
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('platform-projects/:id/rename')
|
||||
@HttpCode(200)
|
||||
async renamePlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RenameNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.renamePlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('platform-projects/:id/transfer')
|
||||
@HttpCode(200)
|
||||
async transferPlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: TransferPlatformProjectDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.transferPlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
destinationEstateId: dto.destinationEstateId,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('platform-projects/:id')
|
||||
async deletePlatformProject(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.deletePlatformProject({
|
||||
actorId: user.id,
|
||||
platformProjectId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── grants ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Post('grants')
|
||||
async createGrant(@CurrentUser() user: { id: string }, @Body() dto: CreateGrantDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.createGrant({
|
||||
actorId: user.id,
|
||||
userId: dto.userId,
|
||||
targetKind: dto.targetKind,
|
||||
targetId: dto.targetId,
|
||||
role: dto.role,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('grants/:id/change')
|
||||
@HttpCode(200)
|
||||
async changeGrant(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ChangeGrantDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.changeGrant({
|
||||
actorId: user.id,
|
||||
grantId: id,
|
||||
role: dto.role,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('grants/:id')
|
||||
async revokeGrant(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DeleteNodeDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.revokeGrant({
|
||||
actorId: user.id,
|
||||
grantId: id,
|
||||
idempotencyKey: dto?.idempotencyKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { COMPANY_VISIBILITY, HIERARCHY_GRANT_ROLES } from '@mosaicstack/db';
|
||||
import { IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Hierarchy command DTOs (contract 1 §5, contract 2 §4/§7).
|
||||
*
|
||||
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so a
|
||||
* payload field absent from these classes is a 400. That closure is itself
|
||||
* contract surface:
|
||||
* - CreateCompanyDto declares NO visibility field — creation is always
|
||||
* private (contract 1 §5.5); a visibility argument is refused by the pipe.
|
||||
* - CreateGrantDto declares NO teamId field — team grant subjects are
|
||||
* suspended (contract 2 §1.4/§7.5); a team subject is refused by the pipe.
|
||||
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
|
||||
* assertion proves the pipe sees the decorators.
|
||||
*/
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
||||
const SLUG_MESSAGE = 'slug must be lowercase alphanumeric with interior hyphens';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
||||
slug!: string;
|
||||
|
||||
/** Client-supplied idempotency key (REQ-AUD-001 replay); server-generated when absent. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class RenameNodeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class ChangeCompanyVisibilityDto {
|
||||
@IsIn(COMPANY_VISIBILITY)
|
||||
visibility!: (typeof COMPANY_VISIBILITY)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class DeleteNodeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class CreateEstateDto {
|
||||
@IsUUID()
|
||||
companyId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
||||
slug!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class CreatePlatformProjectDto {
|
||||
@IsUUID()
|
||||
estateId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
|
||||
slug!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class TransferEstateDto {
|
||||
@IsUUID()
|
||||
destinationCompanyId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class TransferPlatformProjectDto {
|
||||
@IsUUID()
|
||||
destinationEstateId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class CreateGrantDto {
|
||||
/** Subject user (better-auth text id). No teamId field — see module doc. */
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
userId!: string;
|
||||
|
||||
@IsIn(['company', 'estate', 'platform_project'])
|
||||
targetKind!: 'company' | 'estate' | 'platform_project';
|
||||
|
||||
@IsUUID()
|
||||
targetId!: string;
|
||||
|
||||
/** Bare vocabulary on requests; responses and audit events are namespaced (§4.5). */
|
||||
@IsIn(HIERARCHY_GRANT_ROLES)
|
||||
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export class ChangeGrantDto {
|
||||
@IsIn(HIERARCHY_GRANT_ROLES)
|
||||
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
|
||||
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
|
||||
import { HierarchyController } from './hierarchy.controller.js';
|
||||
import { HierarchyRepository } from './hierarchy.repository.js';
|
||||
import { HierarchyService } from './hierarchy.service.js';
|
||||
|
||||
/**
|
||||
* Hierarchy (tenancy/authorization structure) feature module.
|
||||
*
|
||||
* M4-1b-i shipped the audit event + outbox machinery (contract 1 §5.2);
|
||||
* M4-1b-ii adds the command family — the closed route surface asserted by
|
||||
* the route-inventory witness — plus grant evaluation (contract 2 §3).
|
||||
* HierarchyRepository is the sole class-table writer (writer-coverage
|
||||
* allowlist); every mutation runs authorize → mutate → audit in one
|
||||
* transaction.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HierarchyController],
|
||||
providers: [
|
||||
HierarchyAuditRepository,
|
||||
HierarchyGrantEvaluationService,
|
||||
HierarchyRepository,
|
||||
HierarchyService,
|
||||
],
|
||||
exports: [HierarchyAuditRepository, HierarchyGrantEvaluationService],
|
||||
})
|
||||
export class HierarchyModule {}
|
||||
@@ -1,935 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
inArray,
|
||||
platformProjects,
|
||||
users,
|
||||
workspaces,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import {
|
||||
appendHierarchyEvent,
|
||||
buildNodeSnapshot,
|
||||
HierarchyAuditIdempotencyConflictError,
|
||||
} from './hierarchy-audit.repository.js';
|
||||
import {
|
||||
evaluateEffectiveRole,
|
||||
grantedCompanyIds,
|
||||
namespacedHierarchyRole,
|
||||
roleAtLeast,
|
||||
type GrantTargetKind,
|
||||
type HierarchyGrantRole,
|
||||
} from './hierarchy-grant-evaluation.js';
|
||||
|
||||
/**
|
||||
* Hierarchy command repository (contract 1 §5, contract 2 §4).
|
||||
*
|
||||
* The ONLY writer of the hierarchy class tables (companies, estates,
|
||||
* platform_projects, hierarchy_grants) — it is the writer-coverage
|
||||
* allowlist's sole entry. Every command runs one transaction that
|
||||
* authorizes (live grant evaluation inside the same transaction), mutates,
|
||||
* and appends the semantic audit event + outbox record via the M4-1b-i
|
||||
* machinery, so state, event, and outbox commit or roll back together
|
||||
* (REQ-AUD-001).
|
||||
*
|
||||
* Authorization failure and target-not-found both return `not_found`
|
||||
* (contract 1 §6.7: no existence oracle — an unauthorized caller learns
|
||||
* nothing a stranger would not). `forbidden` appears only where the caller
|
||||
* already knows the surface exists independent of any node: the admin-only
|
||||
* visibility change (§5.5). Serialized role strings are namespaced (§4.5).
|
||||
*/
|
||||
|
||||
export type HierarchyCommandFailure =
|
||||
| { readonly ok: false; readonly error: 'not_found' }
|
||||
| { readonly ok: false; readonly error: 'forbidden'; readonly message: string }
|
||||
| { readonly ok: false; readonly error: 'conflict'; readonly message: string };
|
||||
|
||||
export type HierarchyResult<T> = ({ readonly ok: true } & T) | HierarchyCommandFailure;
|
||||
|
||||
export interface CompanyView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
readonly visibility: string;
|
||||
}
|
||||
|
||||
export interface NodeView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
export interface GrantView {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly targetKind: GrantTargetKind;
|
||||
readonly targetId: string;
|
||||
/** Namespaced (§4.5), e.g. `hierarchy:owner`. */
|
||||
readonly role: string;
|
||||
readonly grantedBy: string;
|
||||
}
|
||||
|
||||
/** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */
|
||||
export interface DirectoryEntry {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
|
||||
type GrantRow = typeof hierarchyGrants.$inferSelect;
|
||||
|
||||
const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' };
|
||||
|
||||
function conflict(message: string): HierarchyCommandFailure {
|
||||
return { ok: false, error: 'conflict', message };
|
||||
}
|
||||
|
||||
function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } {
|
||||
if (row.companyId) return { kind: 'company', id: row.companyId };
|
||||
if (row.estateId) return { kind: 'estate', id: row.estateId };
|
||||
return { kind: 'platform_project', id: row.platformProjectId as string };
|
||||
}
|
||||
|
||||
/** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */
|
||||
function grantSnapshot(row: GrantRow): Record<string, unknown> {
|
||||
const target = grantTarget(row);
|
||||
return {
|
||||
id: row.id,
|
||||
subject: { userId: row.userId },
|
||||
target: { kind: target.kind, id: target.id },
|
||||
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
|
||||
grantedBy: row.grantedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function grantView(row: GrantRow): GrantView {
|
||||
const target = grantTarget(row);
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId as string,
|
||||
targetKind: target.kind,
|
||||
targetId: target.id,
|
||||
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
|
||||
grantedBy: row.grantedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function companyView(row: typeof companies.$inferSelect): CompanyView {
|
||||
return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility };
|
||||
}
|
||||
|
||||
interface CommandContext {
|
||||
readonly actorId: string;
|
||||
readonly idempotencyKey: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
private async run<T>(
|
||||
idempotencyKey: string | undefined,
|
||||
actorId: string,
|
||||
body: (tx: Tx, ctx: CommandContext) => Promise<HierarchyResult<T>>,
|
||||
): Promise<HierarchyResult<T>> {
|
||||
const ctx: CommandContext = {
|
||||
actorId,
|
||||
idempotencyKey: idempotencyKey ?? randomUUID(),
|
||||
correlationId: randomUUID(),
|
||||
};
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => body(tx, ctx));
|
||||
} catch (error) {
|
||||
// A key replayed with different content aborts the whole command —
|
||||
// the transaction (state change included) has rolled back (§6.4).
|
||||
if (error instanceof HierarchyAuditIdempotencyConflictError) {
|
||||
return conflict(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async requireOwner(
|
||||
tx: Tx,
|
||||
actorId: string,
|
||||
kind: GrantTargetKind,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner');
|
||||
}
|
||||
|
||||
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
|
||||
const rows = await tx
|
||||
.select({ role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, actorId))
|
||||
.limit(1);
|
||||
return rows[0]?.role === 'admin';
|
||||
}
|
||||
|
||||
// ── companies ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Any authenticated user may create a company; the same audited operation
|
||||
* writes the creator's initial owner grant (§4.3), causation-linked to the
|
||||
* create event. Visibility is always 'private' — the command takes no
|
||||
* visibility input (§5.5).
|
||||
*/
|
||||
createCompany(input: {
|
||||
actorId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView; grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const inserted = await tx
|
||||
.insert(companies)
|
||||
.values({ name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const company = inserted[0];
|
||||
if (!company) return conflict('company slug already exists');
|
||||
|
||||
const grantRows = await tx
|
||||
.insert(hierarchyGrants)
|
||||
.values({
|
||||
userId: ctx.actorId,
|
||||
companyId: company.id,
|
||||
role: 'owner',
|
||||
grantedBy: ctx.actorId,
|
||||
})
|
||||
.returning();
|
||||
const grant = grantRows[0] as GrantRow;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
const created = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_create',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: created.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:grant`,
|
||||
});
|
||||
return { ok: true, company: companyView(company), grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
renameCompany(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ name: input.name, updatedAt: new Date() })
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.returning();
|
||||
const company = updated[0] as typeof companies.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, company: companyView(company) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only until the company-CRUD capability ratifies (§5.5) — the one
|
||||
* hierarchy mutation a platform admin performs without a grant. A
|
||||
* non-admin caller (owner included) gets `forbidden` before any company
|
||||
* read: the refusal reveals nothing about the target's existence.
|
||||
*/
|
||||
changeCompanyVisibility(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
visibility: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ company: CompanyView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.isPlatformAdmin(tx, ctx.actorId))) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'forbidden',
|
||||
message: 'visibility change is platform-admin-only',
|
||||
};
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ visibility: input.visibility, updatedAt: new Date() })
|
||||
.where(eq(companies.id, input.companyId))
|
||||
.returning();
|
||||
const company = updated[0] as typeof companies.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'visibility_change',
|
||||
targetKind: 'company',
|
||||
targetId: company.id,
|
||||
// Old and new values are event content (§5.5).
|
||||
targetSnapshot: {
|
||||
...snapshot,
|
||||
previousVisibility: previous.visibility,
|
||||
visibility: company.visibility,
|
||||
},
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, company: companyView(company) };
|
||||
});
|
||||
}
|
||||
|
||||
deleteCompany(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: estates.id })
|
||||
.from(estates)
|
||||
.where(eq(estates.companyId, input.companyId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('company still has estates');
|
||||
|
||||
// Snapshot and grants are read before the delete; target FKs cascade
|
||||
// the grant rows, and each cascaded deletion is audited (§5.2).
|
||||
const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.companyId, input.companyId));
|
||||
|
||||
await tx.delete(companies).where(eq(companies.id, input.companyId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'company',
|
||||
targetId: input.companyId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.companyId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── estates ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Child creation requires owner on the parent and confers no grant (§4.3). */
|
||||
createEstate(input: {
|
||||
actorId: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const inserted = await tx
|
||||
.insert(estates)
|
||||
.values({ companyId: input.companyId, name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const estate = inserted[0];
|
||||
if (!estate) return conflict('estate slug already exists in company');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'estate',
|
||||
targetId: estate.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
renameEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(estates)
|
||||
.set({ name: input.name })
|
||||
.where(eq(estates.id, input.estateId))
|
||||
.returning();
|
||||
const estate = updated[0] as typeof estates.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'estate',
|
||||
targetId: estate.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
/** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */
|
||||
transferEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
destinationCompanyId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ estate: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
|
||||
const estate = rows[0];
|
||||
if (!estate) return NOT_FOUND;
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (estate.companyId === input.destinationCompanyId) {
|
||||
return conflict('estate already belongs to the destination company');
|
||||
}
|
||||
const collision = await tx
|
||||
.select({ id: estates.id })
|
||||
.from(estates)
|
||||
.where(
|
||||
and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)),
|
||||
)
|
||||
.limit(1);
|
||||
if (collision.length > 0) return conflict('destination company already has that estate slug');
|
||||
|
||||
const parents = await tx
|
||||
.select({ id: companies.id, slug: companies.slug })
|
||||
.from(companies)
|
||||
.where(inArray(companies.id, [estate.companyId, input.destinationCompanyId]));
|
||||
const source = parents.find((p) => p.id === estate.companyId);
|
||||
const destination = parents.find((p) => p.id === input.destinationCompanyId);
|
||||
if (!source || !destination) return NOT_FOUND;
|
||||
|
||||
await tx
|
||||
.update(estates)
|
||||
.set({ companyId: input.destinationCompanyId })
|
||||
.where(eq(estates.id, input.estateId));
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
targetId: input.estateId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
transferFrom: { kind: 'company', id: source.id, slug: source.slug },
|
||||
transferTo: { kind: 'company', id: destination.id, slug: destination.slug },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
|
||||
});
|
||||
}
|
||||
|
||||
deleteEstate(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: platformProjects.id })
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.estateId, input.estateId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('estate still has platform projects');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.estateId, input.estateId));
|
||||
|
||||
await tx.delete(estates).where(eq(estates.id, input.estateId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'estate',
|
||||
targetId: input.estateId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.estateId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── platform projects ────────────────────────────────────────────────────
|
||||
|
||||
createPlatformProject(input: {
|
||||
actorId: string;
|
||||
estateId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const inserted = await tx
|
||||
.insert(platformProjects)
|
||||
.values({ estateId: input.estateId, name: input.name, slug: input.slug })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const project = inserted[0];
|
||||
if (!project) return conflict('platform project slug already exists in estate');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'create',
|
||||
targetKind: 'platform_project',
|
||||
targetId: project.id,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
renamePlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
name: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (
|
||||
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
|
||||
) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.limit(1);
|
||||
const previous = rows[0];
|
||||
if (!previous) return NOT_FOUND;
|
||||
|
||||
const updated = await tx
|
||||
.update(platformProjects)
|
||||
.set({ name: input.name })
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.returning();
|
||||
const project = updated[0] as typeof platformProjects.$inferSelect;
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'rename',
|
||||
targetKind: 'platform_project',
|
||||
targetId: project.id,
|
||||
targetSnapshot: { ...snapshot, previousName: previous.name },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
transferPlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
destinationEstateId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(platformProjects)
|
||||
.where(eq(platformProjects.id, input.platformProjectId))
|
||||
.limit(1);
|
||||
const project = rows[0];
|
||||
if (!project) return NOT_FOUND;
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
if (project.estateId === input.destinationEstateId) {
|
||||
return conflict('platform project already belongs to the destination estate');
|
||||
}
|
||||
const collision = await tx
|
||||
.select({ id: platformProjects.id })
|
||||
.from(platformProjects)
|
||||
.where(
|
||||
and(
|
||||
eq(platformProjects.estateId, input.destinationEstateId),
|
||||
eq(platformProjects.slug, project.slug),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (collision.length > 0) {
|
||||
return conflict('destination estate already has that platform project slug');
|
||||
}
|
||||
|
||||
const parents = await tx
|
||||
.select({ id: estates.id, slug: estates.slug })
|
||||
.from(estates)
|
||||
.where(inArray(estates.id, [project.estateId, input.destinationEstateId]));
|
||||
const source = parents.find((p) => p.id === project.estateId);
|
||||
const destination = parents.find((p) => p.id === input.destinationEstateId);
|
||||
if (!source || !destination) return NOT_FOUND;
|
||||
|
||||
await tx
|
||||
.update(platformProjects)
|
||||
.set({ estateId: input.destinationEstateId })
|
||||
.where(eq(platformProjects.id, input.platformProjectId));
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'transfer',
|
||||
targetKind: 'platform_project',
|
||||
targetId: input.platformProjectId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
transferFrom: { kind: 'estate', id: source.id, slug: source.slug },
|
||||
transferTo: { kind: 'estate', id: destination.id, slug: destination.slug },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
platformProject: { id: project.id, name: project.name, slug: project.slug },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
deletePlatformProject(input: {
|
||||
actorId: string;
|
||||
platformProjectId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ deletedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (
|
||||
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
|
||||
) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const children = await tx
|
||||
.select({ id: workspaces.id })
|
||||
.from(workspaces)
|
||||
.where(eq(workspaces.platformProjectId, input.platformProjectId))
|
||||
.limit(1);
|
||||
if (children.length > 0) return conflict('platform project still has workspaces');
|
||||
|
||||
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
|
||||
const grants = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.platformProjectId, input.platformProjectId));
|
||||
|
||||
await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId));
|
||||
|
||||
const deleted = await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'delete',
|
||||
targetKind: 'platform_project',
|
||||
targetId: input.platformProjectId,
|
||||
targetSnapshot: { ...snapshot },
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
for (const grant of grants) {
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
causationId: deleted.event.id,
|
||||
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
|
||||
});
|
||||
}
|
||||
return { ok: true, deletedId: input.platformProjectId };
|
||||
});
|
||||
}
|
||||
|
||||
// ── grants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Grant management requires effective owner on the target (§4.1). */
|
||||
createGrant(input: {
|
||||
actorId: string;
|
||||
userId: string;
|
||||
targetKind: GrantTargetKind;
|
||||
targetId: string;
|
||||
role: HierarchyGrantRole;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
const subject = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, input.userId))
|
||||
.limit(1);
|
||||
if (subject.length === 0) return conflict('subject user does not exist');
|
||||
|
||||
const inserted = await tx
|
||||
.insert(hierarchyGrants)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
companyId: input.targetKind === 'company' ? input.targetId : null,
|
||||
estateId: input.targetKind === 'estate' ? input.targetId : null,
|
||||
platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null,
|
||||
role: input.role,
|
||||
grantedBy: ctx.actorId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const grant = inserted[0];
|
||||
if (!grant) return conflict('grant already exists');
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_create',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: grantSnapshot(grant),
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
changeGrant(input: {
|
||||
actorId: string;
|
||||
grantId: string;
|
||||
role: HierarchyGrantRole;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ grant: GrantView }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.limit(1);
|
||||
const existing = rows[0];
|
||||
if (!existing) return NOT_FOUND;
|
||||
const target = grantTarget(existing);
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
// Team subjects are suspended (§1.4); the command surface never
|
||||
// creates them, so this only fires on out-of-band rows.
|
||||
if (!existing.userId) return conflict('team grant subjects are suspended');
|
||||
if (existing.role === input.role) return conflict('grant already holds that role');
|
||||
|
||||
const duplicate = await tx
|
||||
.select({ id: hierarchyGrants.id })
|
||||
.from(hierarchyGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(hierarchyGrants.userId, existing.userId),
|
||||
target.kind === 'company'
|
||||
? eq(hierarchyGrants.companyId, target.id)
|
||||
: target.kind === 'estate'
|
||||
? eq(hierarchyGrants.estateId, target.id)
|
||||
: eq(hierarchyGrants.platformProjectId, target.id),
|
||||
eq(hierarchyGrants.role, input.role),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (duplicate.length > 0) {
|
||||
return conflict('subject already holds that role on the target');
|
||||
}
|
||||
|
||||
const updated = await tx
|
||||
.update(hierarchyGrants)
|
||||
.set({ role: input.role })
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.returning();
|
||||
const grant = updated[0] as GrantRow;
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_change',
|
||||
targetKind: 'grant',
|
||||
targetId: grant.id,
|
||||
targetSnapshot: {
|
||||
...grantSnapshot(grant),
|
||||
previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole),
|
||||
},
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, grant: grantView(grant) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */
|
||||
revokeGrant(input: {
|
||||
actorId: string;
|
||||
grantId: string;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<HierarchyResult<{ revokedId: string }>> {
|
||||
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(hierarchyGrants)
|
||||
.where(eq(hierarchyGrants.id, input.grantId))
|
||||
.limit(1);
|
||||
const existing = rows[0];
|
||||
if (!existing) return NOT_FOUND;
|
||||
const target = grantTarget(existing);
|
||||
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
|
||||
return NOT_FOUND;
|
||||
}
|
||||
|
||||
await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId));
|
||||
|
||||
await appendHierarchyEvent(tx, {
|
||||
actorId: ctx.actorId,
|
||||
verb: 'grant_revoke',
|
||||
targetKind: 'grant',
|
||||
targetId: existing.id,
|
||||
targetSnapshot: grantSnapshot(existing),
|
||||
correlationId: ctx.correlationId,
|
||||
idempotencyKey: ctx.idempotencyKey,
|
||||
});
|
||||
return { ok: true, revokedId: existing.id };
|
||||
});
|
||||
}
|
||||
|
||||
// ── reads ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The directory: every directory-class company, closed-field (§2.8). The
|
||||
* sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2).
|
||||
*/
|
||||
async listDirectory(): Promise<DirectoryEntry[]> {
|
||||
return this.db
|
||||
.select({ id: companies.id, name: companies.name, slug: companies.slug })
|
||||
.from(companies)
|
||||
.where(eq(companies.visibility, 'directory'))
|
||||
.orderBy(asc(companies.name));
|
||||
}
|
||||
|
||||
/** Companies the user holds any grant on (company or descendant, §2.8). */
|
||||
async listGrantedCompanies(userId: string): Promise<CompanyView[]> {
|
||||
const ids = await grantedCompanyIds(this.db, userId);
|
||||
if (ids.length === 0) return [];
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(inArray(companies.id, ids))
|
||||
.orderBy(asc(companies.name));
|
||||
return rows.map(companyView);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { HierarchyCommandFailure, HierarchyResult } from './hierarchy.repository.js';
|
||||
|
||||
/**
|
||||
* Maps repository result unions onto HTTP exceptions. `not_found` carries
|
||||
* one fixed message for every cause — missing node and unauthorized caller
|
||||
* are indistinguishable on the wire (contract 1 §6.7).
|
||||
*/
|
||||
@Injectable()
|
||||
export class HierarchyService {
|
||||
unwrap<T>(result: HierarchyResult<T>): T {
|
||||
if (result.ok) return result;
|
||||
throw this.toException(result);
|
||||
}
|
||||
|
||||
private toException(failure: HierarchyCommandFailure): Error {
|
||||
switch (failure.error) {
|
||||
case 'not_found':
|
||||
return new NotFoundException('hierarchy node not found');
|
||||
case 'forbidden':
|
||||
return new ForbiddenException(failure.message);
|
||||
case 'conflict':
|
||||
return new ConflictException(failure.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { AppModule } from './app.module.js';
|
||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { mountSpaStatic } from './spa/serve-spa.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||
@@ -69,7 +68,6 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
mountAuthHandler(app);
|
||||
mountMcpHandler(app, app.get(McpService));
|
||||
await mountSpaStatic(app);
|
||||
|
||||
const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
|
||||
@@ -176,18 +176,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
|
||||
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
|
||||
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
|
||||
const scopes = deriveMcpToolScopesForUser({ role });
|
||||
expect([...scopes].sort()).toEqual([...memberScopes].sort());
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
|
||||
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
|
||||
}
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
|
||||
@@ -322,20 +311,14 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
|
||||
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
|
||||
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
|
||||
// ownership sees exactly what an unprivileged member with the same
|
||||
// ownership would see — here, only the one project they own, and nothing
|
||||
// tenant-wide or platform-wide.
|
||||
const fixtures = {
|
||||
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
|
||||
const { service } = makeService({
|
||||
projects: [
|
||||
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
|
||||
{
|
||||
id: 'project-tenant-a',
|
||||
ownerId: 'other-user-a',
|
||||
teamId: 'tenant-a',
|
||||
name: 'same tenant, unowned',
|
||||
name: 'same tenant',
|
||||
},
|
||||
{
|
||||
id: 'project-tenant-b',
|
||||
@@ -345,50 +328,39 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
},
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-owned', projectId: 'project-owned' },
|
||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
|
||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
||||
],
|
||||
};
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
|
||||
const actors = [
|
||||
makeAdminActor('role-bearing-user', 'tenant-a'),
|
||||
makePlatformAdminActor('role-bearing-user'),
|
||||
];
|
||||
service.registerTools(server, actor);
|
||||
|
||||
for (const actor of actors) {
|
||||
const { service } = makeService(fixtures);
|
||||
const { server, tools } = makeCapturingServer();
|
||||
service.registerTools(server, actor);
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
|
||||
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
|
||||
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
|
||||
}
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
|
||||
});
|
||||
|
||||
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
|
||||
const { service, brain } = makeService({
|
||||
projects: [
|
||||
// §1.1 retirement: content visibility comes from ownership, not the
|
||||
// tenant-admin role — the acting user owns the tenant-a project.
|
||||
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
|
||||
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
|
||||
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
|
||||
],
|
||||
missions: [
|
||||
@@ -401,29 +373,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
// Platform role no longer derives task-write scopes (§1.1 retirement):
|
||||
// a role-derived admin actor is scope-denied before any tenant logic.
|
||||
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
service.registerTools(server, roleDerivedAdmin);
|
||||
await expect(
|
||||
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
|
||||
).rejects.toThrow('MCP tool scope denied');
|
||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
||||
|
||||
// The tenant-scoping checks below sit behind the scope gate; exercise
|
||||
// them with explicitly granted task-write scopes (how grant-mapped
|
||||
// scopes will arrive), not with a platform role.
|
||||
tools.clear();
|
||||
const actor = createMcpActorContext({
|
||||
userId: 'tenant-admin-user',
|
||||
tenantId: 'tenant-a',
|
||||
role: 'member',
|
||||
scopes: [
|
||||
...deriveMcpToolScopesForUser({ role: 'member' }),
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
],
|
||||
});
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
@@ -466,7 +416,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
|
||||
it('keeps admin-only coordination tools on server-derived paths', async () => {
|
||||
const { service, coord } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const member = makeMemberActor('authenticated-user');
|
||||
@@ -483,25 +433,10 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
||||
|
||||
// §1.1 retirement: platform-admin no longer derives coord scopes either.
|
||||
tools.clear();
|
||||
service.registerTools(server, platformAdmin);
|
||||
const platformAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await expect(platformAdminTool.handler({})).rejects.toThrow(
|
||||
'MCP tool scope denied: coord:read',
|
||||
);
|
||||
|
||||
// An explicitly granted coord:read scope reaches the server-derived
|
||||
// path (caller-supplied projectPath is stripped by the schema).
|
||||
tools.clear();
|
||||
const grantedActor = createMcpActorContext({
|
||||
userId: 'granted-user',
|
||||
role: 'member',
|
||||
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
|
||||
});
|
||||
service.registerTools(server, grantedActor);
|
||||
const grantedTool = getTool(tools, 'coord_list_tasks');
|
||||
await grantedTool.handler({ projectPath: '/tmp/victim' });
|
||||
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
|
||||
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,20 @@ interface SessionEntry {
|
||||
actor: McpActorContext;
|
||||
}
|
||||
|
||||
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
|
||||
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
MCP_TOOL_SCOPES.brain_list_tasks,
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
MCP_TOOL_SCOPES.brain_list_missions,
|
||||
MCP_TOOL_SCOPES.brain_list_conversations,
|
||||
MCP_TOOL_SCOPES.memory_search,
|
||||
MCP_TOOL_SCOPES.memory_get_preferences,
|
||||
MCP_TOOL_SCOPES.memory_save_preference,
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
@@ -75,17 +89,15 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Contract 2 §1.1: platform role confers NO MCP scope elevation — the
|
||||
* former tenant-admin/global-admin scope sets keyed on users.role are
|
||||
* retired. Every authenticated user receives the base member set; task
|
||||
* writes and coordination scopes attach to explicit hierarchy grants when
|
||||
* the MCP grant mapping lands, never to a platform role. The role
|
||||
* parameter is kept for caller compatibility and deliberately ignored.
|
||||
*/
|
||||
export function deriveMcpToolScopesForUser(_input: {
|
||||
export function deriveMcpToolScopesForUser(input: {
|
||||
role?: string | null;
|
||||
}): ReadonlySet<McpToolScope> {
|
||||
if (input.role === 'platform-admin' || input.role === 'super-admin') {
|
||||
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
if (input.role === 'admin') {
|
||||
return new Set(TENANT_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
return new Set(MEMBER_MCP_SCOPES);
|
||||
}
|
||||
|
||||
@@ -156,22 +168,41 @@ type TaskLike = TenantScopedLike & {
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Contract 2 §1.1: `users.role` confers NO content visibility — the former
|
||||
* global-admin/tenant-admin filter short-circuits keyed on the platform role
|
||||
* are retired along with the role-derived scope sets. Content reaches an MCP
|
||||
* actor through ownership only; widened access arrives as explicit hierarchy
|
||||
* grants when the MCP grant mapping lands.
|
||||
*/
|
||||
function isGlobalAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'platform-admin' || actor.role === 'super-admin';
|
||||
}
|
||||
|
||||
function isTenantAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'admin';
|
||||
}
|
||||
|
||||
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
|
||||
return (
|
||||
record.tenantId === actor.tenantId ||
|
||||
record.organizationId === actor.tenantId ||
|
||||
record.teamId === actor.tenantId
|
||||
);
|
||||
}
|
||||
|
||||
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
|
||||
return projects.filter((project) => project.ownerId === actor.userId);
|
||||
if (isGlobalAdminActor(actor)) return projects;
|
||||
return projects.filter(
|
||||
(project) =>
|
||||
project.ownerId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
|
||||
);
|
||||
}
|
||||
|
||||
function filterMissionsByDirectActorScope<T extends MissionLike>(
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): T[] {
|
||||
return missions.filter((mission) => mission.userId === actor.userId);
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
return missions.filter(
|
||||
(mission) =>
|
||||
mission.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
|
||||
);
|
||||
}
|
||||
|
||||
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
|
||||
@@ -262,6 +293,7 @@ export class McpService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
|
||||
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
|
||||
}
|
||||
@@ -270,6 +302,8 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
|
||||
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
|
||||
const projectIds = new Set(
|
||||
filterProjectsForActor(actor, projects).map((project) => project.id),
|
||||
@@ -283,6 +317,7 @@ export class McpService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
|
||||
if (!mission) return false;
|
||||
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
|
||||
@@ -304,7 +339,7 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
refs: { projectId?: string | null; missionId?: string | null },
|
||||
): Promise<void> {
|
||||
if (!refs.projectId && !refs.missionId) {
|
||||
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
|
||||
throw new Error('MCP task scope denied');
|
||||
}
|
||||
await this.assertTaskReferencesAuthorized(actor, refs);
|
||||
@@ -314,6 +349,8 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
tasks: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return tasks;
|
||||
|
||||
const [projects, missions] = await Promise.all([
|
||||
this.brain.projects.findAll(),
|
||||
this.brain.missions.findAll(),
|
||||
@@ -330,6 +367,7 @@ export class McpService implements OnModuleDestroy {
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
task.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
|
||||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
|
||||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
|
||||
);
|
||||
|
||||
@@ -108,13 +108,11 @@ export class MissionsController {
|
||||
) {
|
||||
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
|
||||
if (!mission) throw new NotFoundException('Mission not found');
|
||||
// dto.status is deliberately not forwarded: mission_tasks.status is
|
||||
// write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1);
|
||||
// the repo strips it as well.
|
||||
return this.brain.missionTasks.create({
|
||||
missionId,
|
||||
taskId: dto.taskId,
|
||||
userId: user.id,
|
||||
status: dto.status,
|
||||
description: dto.description,
|
||||
notes: dto.notes,
|
||||
pr: dto.pr,
|
||||
|
||||
@@ -77,12 +77,6 @@ export class CreateMissionTaskDto {
|
||||
@IsUUID()
|
||||
taskId?: string;
|
||||
|
||||
/**
|
||||
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
|
||||
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
|
||||
* The field stays declared because the global ValidationPipe runs with
|
||||
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
@@ -108,12 +102,6 @@ export class UpdateMissionTaskDto {
|
||||
@IsUUID()
|
||||
taskId?: string;
|
||||
|
||||
/**
|
||||
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
|
||||
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
|
||||
* The field stays declared because the global ValidationPipe runs with
|
||||
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,18 +1,6 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
import {
|
||||
ChangeCompanyVisibilityDto,
|
||||
ChangeGrantDto,
|
||||
CreateCompanyDto,
|
||||
CreateEstateDto,
|
||||
CreateGrantDto,
|
||||
CreatePlatformProjectDto,
|
||||
DeleteNodeDto,
|
||||
RenameNodeDto,
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} from './hierarchy/hierarchy.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
@@ -55,56 +43,6 @@ export const PIPE_GUARDED_DTOS: Array<{
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
{
|
||||
name: 'CreateCompanyDto',
|
||||
target: CreateCompanyDto,
|
||||
properties: ['name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'RenameNodeDto',
|
||||
target: RenameNodeDto,
|
||||
properties: ['name', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'ChangeCompanyVisibilityDto',
|
||||
target: ChangeCompanyVisibilityDto,
|
||||
properties: ['visibility', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'DeleteNodeDto',
|
||||
target: DeleteNodeDto,
|
||||
properties: ['idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreateEstateDto',
|
||||
target: CreateEstateDto,
|
||||
properties: ['companyId', 'name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreatePlatformProjectDto',
|
||||
target: CreatePlatformProjectDto,
|
||||
properties: ['estateId', 'name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'TransferEstateDto',
|
||||
target: TransferEstateDto,
|
||||
properties: ['destinationCompanyId', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'TransferPlatformProjectDto',
|
||||
target: TransferPlatformProjectDto,
|
||||
properties: ['destinationEstateId', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreateGrantDto',
|
||||
target: CreateGrantDto,
|
||||
properties: ['userId', 'targetKind', 'targetId', 'role', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'ChangeGrantDto',
|
||||
target: ChangeGrantDto,
|
||||
properties: ['role', 'idempotencyKey'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
|
||||
+28
-20
@@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 }) => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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(() => {}));
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't have an account?{' '}
|
||||
<Link href="/register" className="text-blue-400 hover:text-blue-300">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
'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';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface UserDto {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
banned: boolean;
|
||||
banReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface UserListDto {
|
||||
users: UserDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ServiceStatusDto {
|
||||
status: 'ok' | 'error';
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ProviderStatusDto {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
modelCount: number;
|
||||
}
|
||||
|
||||
interface HealthStatusDto {
|
||||
status: 'ok' | 'degraded' | 'error';
|
||||
database: ServiceStatusDto;
|
||||
cache: ServiceStatusDto;
|
||||
agentPool: { activeSessions: number };
|
||||
providers: ProviderStatusDto[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
return (
|
||||
<AdminRoleGuard>
|
||||
<AdminContent />
|
||||
</AdminRoleGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminContent(): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b border-surface-border">
|
||||
{(['users', 'health'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium capitalize transition-colors',
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-blue-500 text-blue-400'
|
||||
: 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
{tab === 'users' ? 'User Management' : 'System Health'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Users Tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function UsersTab(): React.ReactElement {
|
||||
const [users, setUsers] = useState<UserDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api<UserListDto>('/api/admin/users');
|
||||
setUsers(data.users);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
async function handleRoleToggle(user: UserDto): Promise<void> {
|
||||
const newRole = user.role === 'admin' ? 'member' : 'admin';
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}/role`, {
|
||||
method: 'PATCH',
|
||||
body: { role: newRole },
|
||||
});
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to update role');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBanToggle(user: UserDto): Promise<void> {
|
||||
const endpoint = user.banned ? 'unban' : 'ban';
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' });
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to update ban status');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(user: UserDto): Promise<void> {
|
||||
if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}`, { method: 'DELETE' });
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete user');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-text-muted">Loading users...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadUsers()}
|
||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-text-muted">{users.length} user(s)</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
+ New User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateUserForm
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onCreated={() => {
|
||||
setShowCreate(false);
|
||||
void loadUsers();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
||||
<p className="text-sm text-text-muted">No users found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-surface-border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
||||
<th className="px-4 py-2 font-medium">Name / Email</th>
|
||||
<th className="px-4 py-2 font-medium">Role</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
|
||||
<th className="px-4 py-2 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-text-primary">{user.name}</div>
|
||||
<div className="text-xs text-text-muted">{user.email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: 'bg-surface-elevated text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 md:table-cell">
|
||||
{user.banned ? (
|
||||
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
||||
Banned
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRoleToggle(user)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
|
||||
>
|
||||
{user.role === 'admin' ? 'Demote' : 'Promote'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBanToggle(user)}
|
||||
className={cn(
|
||||
'text-xs',
|
||||
user.banned
|
||||
? 'text-green-400 hover:text-green-300'
|
||||
: 'text-yellow-400 hover:text-yellow-300',
|
||||
)}
|
||||
>
|
||||
{user.banned ? 'Unban' : 'Ban'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete(user)}
|
||||
className="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create User Form ──────────────────────────────────────────────────────────
|
||||
|
||||
interface CreateUserFormProps {
|
||||
onCancel: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('member');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api('/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: { name, email, password, role },
|
||||
});
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create user');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Role</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Health Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function HealthTab(): React.ReactElement {
|
||||
const [health, setHealth] = useState<HealthStatusDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api<HealthStatusDto>('/api/admin/health');
|
||||
setHealth(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load health');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHealth();
|
||||
}, [loadHealth]);
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-text-muted">Loading health status...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadHealth()}
|
||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!health) return <></>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={health.status} />
|
||||
<span className="text-sm text-text-muted">
|
||||
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadHealth()}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* Database */}
|
||||
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
|
||||
{health.database.latencyMs !== undefined && (
|
||||
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
|
||||
)}
|
||||
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
|
||||
</HealthCard>
|
||||
|
||||
{/* Cache */}
|
||||
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
|
||||
{health.cache.latencyMs !== undefined && (
|
||||
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
|
||||
)}
|
||||
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
|
||||
</HealthCard>
|
||||
|
||||
{/* Agent Pool */}
|
||||
<HealthCard title="Agent Pool" status="ok">
|
||||
<p className="text-xs text-text-muted">
|
||||
Active sessions: {health.agentPool.activeSessions}
|
||||
</p>
|
||||
</HealthCard>
|
||||
|
||||
{/* Providers */}
|
||||
<HealthCard
|
||||
title="LLM Providers"
|
||||
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
|
||||
>
|
||||
{health.providers.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">No providers configured</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{health.providers.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between text-xs">
|
||||
<span className="text-text-secondary">{p.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 py-0.5',
|
||||
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
|
||||
)}
|
||||
>
|
||||
{p.available ? `${p.modelCount} models` : 'unavailable'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</HealthCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helper Components ─────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
|
||||
const map = {
|
||||
ok: 'bg-green-500/20 text-green-400',
|
||||
degraded: 'bg-yellow-500/20 text-yellow-400',
|
||||
error: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
return (
|
||||
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface HealthCardProps {
|
||||
title: string;
|
||||
status: 'ok' | 'error';
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
|
||||
<span
|
||||
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, useSession } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
import { SsoProviderSection } from '@/components/settings/sso-provider-section';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
interface TestConnectionResult {
|
||||
providerId: string;
|
||||
reachable: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
discoveredModels?: string[];
|
||||
}
|
||||
|
||||
type TestState = 'idle' | 'testing' | 'success' | 'error';
|
||||
|
||||
interface ProviderTestStatus {
|
||||
state: TestState;
|
||||
result?: TestConnectionResult;
|
||||
}
|
||||
|
||||
interface Preference {
|
||||
key: string;
|
||||
value: unknown;
|
||||
category: string;
|
||||
}
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
||||
const p = prefs.find((x) => x.key === key);
|
||||
if (p === undefined) return fallback;
|
||||
return p.value as T;
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'profile', label: 'Profile' },
|
||||
{ id: 'appearance', label: 'Appearance' },
|
||||
{ id: 'notifications', label: 'Notifications' },
|
||||
{ id: 'providers', label: 'Providers' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-surface-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-b-2 border-accent text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'profile' && <ProfileTab session={session} />}
|
||||
{activeTab === 'appearance' && <AppearanceTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'providers' && <ProvidersTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Profile Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileTab({
|
||||
session,
|
||||
}: {
|
||||
session: { user: { id: string; name: string; email: string; image?: string | null } } | null;
|
||||
}): React.ReactElement {
|
||||
const [name, setName] = useState(session?.user.name ?? '');
|
||||
const [image, setImage] = useState(session?.user.image ?? '');
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Sync from session when it loads
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
setName(session.user.name ?? '');
|
||||
setImage(session.user.image ?? '');
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const result = await authClient.updateUser({ name, image: image || null });
|
||||
if (result.error) {
|
||||
setErrorMsg(result.error.message ?? 'Failed to update profile');
|
||||
setSaveState('error');
|
||||
return;
|
||||
}
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
||||
setErrorMsg(message);
|
||||
setSaveState('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
|
||||
<FormField label="Display Name" id="profile-name">
|
||||
<input
|
||||
id="profile-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
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-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Email" id="profile-email">
|
||||
<input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={session?.user.email ?? ''}
|
||||
disabled
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Avatar URL" id="profile-image">
|
||||
<input
|
||||
id="profile-image"
|
||||
type="url"
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
placeholder="https://example.com/avatar.png"
|
||||
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-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<SaveButton state={saveState} onClick={handleSave} />
|
||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Appearance Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function AppearanceTab(): React.ReactElement {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [theme, setTheme] = useState<Theme>('system');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [defaultModel, setDefaultModel] = useState('');
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api<Preference[]>('/api/memory/preferences?category=appearance')
|
||||
.catch(() => [] as Preference[])
|
||||
.then((p) => {
|
||||
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
|
||||
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
|
||||
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
await Promise.all([
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' },
|
||||
}),
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'ui.sidebar_collapsed',
|
||||
value: sidebarCollapsed,
|
||||
category: 'appearance',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
...(defaultModel
|
||||
? [
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'ui.default_model',
|
||||
value: defaultModel,
|
||||
category: 'appearance',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
setErrorMsg(message);
|
||||
setSaveState('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
|
||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
||||
{/* Theme */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
|
||||
<div className="flex gap-3">
|
||||
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTheme(t)}
|
||||
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
|
||||
theme === t
|
||||
? 'border-accent bg-accent/10 text-accent'
|
||||
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar collapsed default */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
|
||||
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
|
||||
</div>
|
||||
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
|
||||
</div>
|
||||
|
||||
{/* Default model */}
|
||||
<FormField label="Default Model" id="default-model">
|
||||
<input
|
||||
id="default-model"
|
||||
type="text"
|
||||
value={defaultModel}
|
||||
onChange={(e) => setDefaultModel(e.target.value)}
|
||||
placeholder="e.g. ollama/llama3.2"
|
||||
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-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Model ID to pre-select for new conversations.
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<SaveButton state={saveState} onClick={handleSave} />
|
||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Notifications Tab ────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationsTab(): React.ReactElement {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [emailAgentComplete, setEmailAgentComplete] = useState(false);
|
||||
const [emailMentions, setEmailMentions] = useState(true);
|
||||
const [emailDigest, setEmailDigest] = useState(false);
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api<Preference[]>('/api/memory/preferences?category=communication')
|
||||
.catch(() => [] as Preference[])
|
||||
.then((p) => {
|
||||
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
|
||||
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
|
||||
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setSaveState('saving');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
await Promise.all([
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'notify.email_agent_complete',
|
||||
value: emailAgentComplete,
|
||||
category: 'communication',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'notify.email_mentions',
|
||||
value: emailMentions,
|
||||
category: 'communication',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
api('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: 'notify.email_digest',
|
||||
value: emailDigest,
|
||||
category: 'communication',
|
||||
source: 'user',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
setErrorMsg(message);
|
||||
setSaveState('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
|
||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
||||
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
|
||||
|
||||
<NotifyRow
|
||||
label="Agent task completed"
|
||||
description="Email when an agent finishes a task"
|
||||
checked={emailAgentComplete}
|
||||
onChange={setEmailAgentComplete}
|
||||
/>
|
||||
<NotifyRow
|
||||
label="Mentions"
|
||||
description="Email when you are mentioned in a conversation"
|
||||
checked={emailMentions}
|
||||
onChange={setEmailMentions}
|
||||
/>
|
||||
<NotifyRow
|
||||
label="Weekly digest"
|
||||
description="Weekly summary of activity"
|
||||
checked={emailDigest}
|
||||
onChange={setEmailDigest}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<SaveButton state={saveState} onClick={handleSave} />
|
||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Providers Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function ProvidersTab(): React.ReactElement {
|
||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ssoLoading, setSsoLoading] = useState(true);
|
||||
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
||||
|
||||
useEffect(() => {
|
||||
api<ProviderInfo[]>('/api/providers')
|
||||
.catch(() => [] as ProviderInfo[])
|
||||
.then((p) => setProviders(p))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
||||
.catch(() => [] as SsoProviderDiscovery[])
|
||||
.then((providers) => setSsoProviders(providers))
|
||||
.finally(() => setSsoLoading(false));
|
||||
}, []);
|
||||
|
||||
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
||||
setTestStatuses((prev) => ({
|
||||
...prev,
|
||||
[providerId]: { state: 'testing' },
|
||||
}));
|
||||
try {
|
||||
const result = await api<TestConnectionResult>('/api/providers/test', {
|
||||
method: 'POST',
|
||||
body: { providerId },
|
||||
});
|
||||
setTestStatuses((prev) => ({
|
||||
...prev,
|
||||
[providerId]: { state: result.reachable ? 'success' : 'error', result },
|
||||
}));
|
||||
} catch {
|
||||
setTestStatuses((prev) => ({
|
||||
...prev,
|
||||
[providerId]: {
|
||||
state: 'error',
|
||||
result: { providerId, reachable: false, error: 'Request failed' },
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const defaultModel: ModelInfo | undefined = providers
|
||||
.flatMap((p) => p.models)
|
||||
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
|
||||
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Loading providers...</p>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
No providers configured. Set{' '}
|
||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||
OLLAMA_BASE_URL
|
||||
</code>{' '}
|
||||
or{' '}
|
||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||
MOSAIC_CUSTOM_PROVIDERS
|
||||
</code>{' '}
|
||||
to add providers.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
defaultModel={defaultModel}
|
||||
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
||||
onTest={() => void testConnection(provider.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared UI Components ─────────────────────────────────────────────────────
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
|
||||
checked ? 'bg-accent' : 'bg-surface-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
checked ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifyRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{label}</p>
|
||||
<p className="text-xs text-text-muted">{description}</p>
|
||||
</div>
|
||||
<Toggle checked={checked} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveButton({
|
||||
state,
|
||||
onClick,
|
||||
}: {
|
||||
state: SaveState;
|
||||
onClick: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={state === 'saving'}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Provider Card (from original page) ──────────────────────────────────────
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: ProviderInfo;
|
||||
defaultModel: ModelInfo | undefined;
|
||||
testStatus: ProviderTestStatus;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
function ProviderCard({
|
||||
provider,
|
||||
defaultModel,
|
||||
testStatus,
|
||||
onTest,
|
||||
}: ProviderCardProps): React.ReactElement {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderAvatar id={provider.id} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
|
||||
<ProviderStatusBadge available={provider.available} />
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TestConnectionButton status={testStatus} onTest={onTest} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Collapse models' : 'Expand models'}
|
||||
>
|
||||
{expanded ? '▲ Hide' : '▼ Models'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result banner */}
|
||||
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
|
||||
<TestResultBanner result={testStatus.result} />
|
||||
)}
|
||||
|
||||
{/* Model list */}
|
||||
{expanded && (
|
||||
<div className="border-t border-surface-border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
|
||||
<th className="px-4 py-2 font-medium">Model</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
||||
<th className="px-4 py-2 font-medium">Default</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{provider.models.map((model) => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
isDefault={
|
||||
defaultModel?.id === model.id && defaultModel?.provider === model.provider
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelRowProps {
|
||||
model: ModelInfo;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
|
||||
return (
|
||||
<tr className="border-t border-surface-border">
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-sm text-text-primary">{model.name}</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-2 md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<CapabilityBadge label="chat" />
|
||||
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
|
||||
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||
{formatContext(model.contextWindow)}
|
||||
</td>
|
||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||
{model.cost.input === 0 && model.cost.output === 0
|
||||
? 'free'
|
||||
: `$${model.cost.input} / $${model.cost.output}`}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{isDefault && (
|
||||
<span
|
||||
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
|
||||
title="Default model used for new sessions"
|
||||
>
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
|
||||
const letter = id.charAt(0).toUpperCase();
|
||||
return (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{available ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TestConnectionButtonProps {
|
||||
status: ProviderTestStatus;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
|
||||
const isTesting = status.state === 'testing';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title="Test connection"
|
||||
>
|
||||
{isTesting ? (
|
||||
<span className="text-text-muted">Testing…</span>
|
||||
) : status.state === 'success' ? (
|
||||
<span className="text-success">✓ Reachable</span>
|
||||
) : status.state === 'error' ? (
|
||||
<span className="text-error">✗ Unreachable</span>
|
||||
) : (
|
||||
<span className="text-text-muted">Test</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-2 text-xs ${
|
||||
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
|
||||
}`}
|
||||
>
|
||||
{result.reachable ? (
|
||||
<>
|
||||
Connected
|
||||
{result.latencyMs !== undefined && (
|
||||
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
|
||||
)}
|
||||
{result.discoveredModels && result.discoveredModels.length > 0 && (
|
||||
<span className="ml-2 opacity-70">
|
||||
— {result.discoveredModels.length} model
|
||||
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityBadge({
|
||||
label,
|
||||
color = 'default',
|
||||
}: {
|
||||
label: string;
|
||||
color?: 'default' | 'purple' | 'blue';
|
||||
}): React.ReactElement {
|
||||
const colorClass =
|
||||
color === 'purple'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: color === 'blue'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-surface-elevated text-text-muted';
|
||||
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function formatContext(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
||||
return String(tokens);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+16
-30
@@ -16,15 +16,6 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function GuestLayout(): ReactElement {
|
||||
return (
|
||||
@@ -53,28 +44,23 @@ 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: <SettingsPage /> },
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -57,16 +57,6 @@ 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 {
|
||||
@@ -121,7 +111,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 +131,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 +194,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 +239,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 +323,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 +369,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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
|
||||
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
|
||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||
| [`P0-MAP-CURRENCY-2026-08-29.md`](./P0-MAP-CURRENCY-2026-08-29.md) | REQ-MIG-001 lane-opening verification: SHARED-CONTRACT §5 field map re-verified byte-identical at `next` @ `abb0c936`; workspaces/audit-pattern refinements; measured `mission_tasks.status` writer inventory and the pre-expand stop-write work item |
|
||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
kind: verification
|
||||
status: active
|
||||
---
|
||||
|
||||
# P0 Field-Map Currency Verification — 2026-08-29
|
||||
|
||||
**Purpose:** REQ-MIG-001 (native-kanban-sot.md §5) accepts only when "P0 publishes
|
||||
the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract
|
||||
map before any schema lane starts." That map exists: [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md)
|
||||
§5, inspected at `packages/db/src/schema.ts` @ `e72388b2cbfe400842fe940fa6cabf984ed43711`
|
||||
(2026-07-13). The M4-3 schema lane (expand migration 0021+) now opens against the
|
||||
integration trunk `next`. This document re-verifies the map's currency at the
|
||||
lane-opening head and records the measured pre-expand writer inventory. It amends
|
||||
nothing normative in SHARED-CONTRACT.md; where the two disagree, SHARED-CONTRACT.md
|
||||
wins.
|
||||
|
||||
## 1. Currency verification (measured)
|
||||
|
||||
- Map pin: `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13, `main`).
|
||||
- Lane-opening head: `abb0c936011c7f6b8c0bcc90a20a865d5e8a40e9` (`origin/next`,
|
||||
2026-08-29).
|
||||
- Measurement: `git diff e72388b2 abb0c936 -- packages/db/src/schema.ts` reports
|
||||
**300 insertions, 0 deletions** — no existing declaration changed.
|
||||
- The additions: the new declarations `logicalAgentConnectorLeases`,
|
||||
`connectorLeaseAuditLog`, and the hierarchy layer (`companies`, `estates`,
|
||||
`platformProjects`, `workspaces`, `hierarchyGrants`, `hierarchyAuditEvents`,
|
||||
`hierarchyOutbox`, plus their enums and constant arrays); a nullable `issuer`
|
||||
column on the unmapped BetterAuth `accounts` table (shipped as
|
||||
`drizzle/0017_accounts_issuer.sql`); and expanded `drizzle-orm` imports
|
||||
(`sql`, `AnyPgColumn`, `unique`, `check`, `bigint`). None touch a mapped
|
||||
source.
|
||||
- Stronger literal fact: REQ-MIG-001's acceptance names `origin/main`. Measured
|
||||
pin → `origin/main` (`7102ccb9`, 2026-08-13): **63 insertions, 0 deletions**
|
||||
for `schema.ts`, and `origin/main` is an ancestor of `abb0c936`. The map is
|
||||
therefore current at `origin/main` itself, and at the trunk head beyond it.
|
||||
|
||||
**Consequence:** every source column mapped in SHARED-CONTRACT.md §5.4 —
|
||||
`teams`/`team_members`, `projects`, `missions`, `tasks`, `mission_tasks`,
|
||||
`agents`, fleet `backlog` — is byte-identical to the declaration the map
|
||||
inspected. The field map is current as written. No row changes.
|
||||
|
||||
## 2. Refinements available since the pin (context, not map changes)
|
||||
|
||||
1. **The `workspaces` table exists.** The map predates contract 1's hierarchy
|
||||
layer; its "bootstrap workspace" backfill step now has a shipped target:
|
||||
`workspaces` (uuid PK, chained under platform projects per
|
||||
`docs/requirements/hierarchy-schema.md`; hierarchy core in
|
||||
`drizzle/0018_clean_cobalt_man.sql`, audit/outbox in
|
||||
`0019_volatile_killraven.sql`, visibility in
|
||||
`0020_special_betty_brant.sql`). New `workspace_id` columns FK there.
|
||||
2. **The audit/outbox envelope pattern is shipped.** `hierarchyAuditEvents` +
|
||||
`hierarchyOutbox` implement same-transaction semantic event + outbox. The
|
||||
task lane's `task_events`/`task_outbox` mirror the pattern but are
|
||||
workspace-scoped with the composite `(workspace_id, id)` key required by
|
||||
§5.3 and REQ-SOT-004. The hierarchy tables are a pattern reference, never a
|
||||
shared store for task events.
|
||||
3. **Trunk designation.** The integration trunk is `next` (`.mosaic/repo.json`).
|
||||
§1 measures currency at both the literal `origin/main` REQ-MIG-001 names and
|
||||
the trunk head pinned above, so no reinterpretation of the acceptance text
|
||||
is needed.
|
||||
4. **Migration ownership.** SHARED-CONTRACT.md §6 assigns schema/migration
|
||||
ownership to the mission seat `coder2`. Seat identity is operational fleet
|
||||
state, not resolvable from this repository, and is outside this document's
|
||||
scope. The invariant §6 protects binds regardless of seat and is restated
|
||||
here as binding on the M4-3 schema lane: exactly one lane generates
|
||||
migrations at a time; expand is additive; no drop/rename/narrow; constraints
|
||||
validate before NOT NULL.
|
||||
|
||||
## 3. Pre-expand writer inventory (measured 2026-08-29 at `abb0c936`)
|
||||
|
||||
SHARED-CONTRACT.md §5.1 phase 1 requires an N-1 patch that stops
|
||||
`mission_tasks.status` as a write source, plus a writer inventory, before any
|
||||
expand DDL.
|
||||
|
||||
- **Sole authoring write path:** `packages/brain/src/mission-tasks.ts`
|
||||
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
|
||||
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
|
||||
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
|
||||
The same module also exposes `remove`/`removeByMission` DELETE paths —
|
||||
immaterial to `status` writes, listed for inventory completeness.
|
||||
- **Storage-layer surfaces that touch the column without authoring it**
|
||||
(added 2026-08-29 after independent review of the phase-1 patch):
|
||||
`packages/storage/src/migrate-tier.ts` copies whole `mission_tasks` rows
|
||||
between storage tiers and must preserve the stored `status` verbatim — row
|
||||
transport, exempt from the write prohibition (stripping there would corrupt
|
||||
data inside the N-1 window). The generic table-keyed storage adapters
|
||||
(`adapters/postgres.ts`, `adapters/pglite.ts`) register `mission_tasks` in
|
||||
their table maps but have no caller that targets it: measured at this head,
|
||||
every runtime adapter caller passes a fixed collection constant
|
||||
(preferences/insights). Neither surface authors a new `status` value.
|
||||
- **Read-only consumers of `mission_tasks`:** federation verb services
|
||||
(`get-query.service.ts`, `list-query.service.ts`) select only. The MCP
|
||||
`brain_*` tools do not touch `mission_tasks` at all; `brain_create_task` /
|
||||
`brain_update_task` write the separately mapped `tasks` table, a legitimate
|
||||
N-1 writer through the compatibility window.
|
||||
- The ratified contract 5 decision
|
||||
(`docs/requirements/tool-gateway-mapping.md` §3.2, ruled 2026-08-27) freezes
|
||||
the legacy endpoints — including MCP `brain_*` task mutations — for new
|
||||
consumers, while existing consumers keep working until each surface's owning
|
||||
contract retires it. It does not stop existing writes.
|
||||
|
||||
**Standing work item:** the phase-1 stop-write patch (reject or ignore `status`
|
||||
on `mission_tasks` create/update) MUST land before the expand DDL of migration
|
||||
lane M4-3a. It is N-1-safe per the §5.4 row for `mission_tasks.status` (linked
|
||||
status is ignored; the column stays declared and readable through the whole
|
||||
N-1 window; retirement only after no readers).
|
||||
|
||||
## 4. Lane opening
|
||||
|
||||
With this verification merged, REQ-MIG-001's P0-map precondition is satisfied
|
||||
for the M4-3 schema lane at pinned head `abb0c936`. The ordered phases (§5.1),
|
||||
mission candidate-key DDL order (§5.2), audit/proposal DDL order (§5.3), field
|
||||
map (§5.4), and required migration tests (§5.5) bind as written. External
|
||||
import machinery (jarvis-brain/Vikunja shadow import, REQ-MIG-001) and client
|
||||
cutover (REQ-MIG-002) remain out of scope for M4-3; the legacy surface stays
|
||||
frozen for new consumers meanwhile (`tool-gateway-mapping.md` §3.2 decision).
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 §§5–6. 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 2–3).** 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 1–6 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.
|
||||
@@ -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 F1–F7): 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 1–6 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.
|
||||
@@ -456,105 +456,3 @@ this line is weakened.
|
||||
- Negative tests prove roll-up endpoints cannot mutate state and that a
|
||||
reader sees aggregates only over workspaces they are authorized on
|
||||
(no cross-tenant existence oracles).
|
||||
|
||||
## 9. Amendment A2 — company visibility classes and the company directory
|
||||
|
||||
**Status:** amendment to Amendment A1, added by reviewed PR under Ruling 4b
|
||||
(operator ruling, 2026-08-27; decision owner Jason; recorded in the webui-audit
|
||||
lane RULINGS.md). Everything in §§1–8 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.
|
||||
|
||||
## 10. Amendment A3 — capability-holder existence disclosure
|
||||
|
||||
**Status:** amendment to Amendment A2, added by reviewed PR together with
|
||||
contract 2 Amendment 1 (`rbac-grant-model.md` §8, this PR), under that
|
||||
amendment's ruling request (decision owner Jason). It binds if and only if
|
||||
contract 2 Amendment 1 ratifies; until then §9.1.2's sole-disclosure rule
|
||||
stands unmodified — which is consistent, because until ratification the
|
||||
company-CRUD capability class is empty and the carve-out below has no
|
||||
holders. Everything in §§1–9 remains binding verbatim, with exactly the one
|
||||
express modification below. The detailed contract text lives in
|
||||
`rbac-grant-model.md` §8.1; this amendment changes only what A2 itself
|
||||
permits, so that contract does not stretch A2 by interpretation.
|
||||
|
||||
### 10.1 What A3 modifies in A2
|
||||
|
||||
1. **Capability-holder disclosure (narrows §9.1.2's sole-disclosure rule
|
||||
by one carve-out).** §9.1.2 makes the directory the sole permitted
|
||||
existence disclosure and keeps private companies undisclosed to
|
||||
non-granted subjects everywhere. A3 admits exactly one further
|
||||
disclosure channel: a subject holding the company-CRUD capability
|
||||
(contract 2 §8), when exercising the hierarchy schema §5.5 visibility
|
||||
command, learns the target company's existence and its old/new
|
||||
visibility values through the command's redacted actor receipt —
|
||||
success for an existing target (private or directory alike) versus
|
||||
`not_found` for a nonexistent id — bounded exactly as contract 2 §8.1
|
||||
states: no name, slug, structure, content, grant, or membership
|
||||
information, and no read command of any kind. To every other
|
||||
non-granted subject, private companies remain undisclosed everywhere,
|
||||
including the directory; the directory remains the sole
|
||||
existence-disclosure _listing_.
|
||||
|
||||
### 10.2 What A3 explicitly does not change
|
||||
|
||||
1. The directory itself is unchanged: read-only, directory-class companies
|
||||
only, existence/name/slug only (§9.1.2's enumeration is narrowed for
|
||||
capability holders' receipts, widened for nothing).
|
||||
2. No join-request surface, no curation listing, no read command of any
|
||||
family is authorized (§9.2.2 unchanged; a curation listing is a further
|
||||
amendment per contract 2 §8.1).
|
||||
3. The canonical audit event for visibility mutations is untouched — it
|
||||
keeps hierarchy schema §5.2's full immutable target snapshot; the
|
||||
capability confers no audit read (contract 2 §8.5).
|
||||
4. Every other constraint of A1 and A2 is untouched.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,478 +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.3–3.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".
|
||||
|
||||
Amendment 1 (company-CRUD capability): defines the capability class that
|
||||
contract 1 Amendment 1 (Ruling 4b, 2026-08-28) and hierarchy schema §5.5
|
||||
anticipate. §8 defines the capability as a platform-scoped, admin-assigned,
|
||||
audited delegation of exactly the hierarchy schema §5.5 company visibility
|
||||
command — no read command, no other company operation; the mutation's
|
||||
inherent existence disclosure is ratified as a bounded carve-out to
|
||||
hierarchy schema §6.7/§2.8 and to kanban SOT Amendment A2's
|
||||
sole-disclosure rule — SOT Amendment A3 (native-kanban-sot.md §10, this
|
||||
PR) expressly extends A2 by exactly this carve-out (§8.1). The holder
|
||||
sees only a redacted actor receipt; the canonical audit event keeps
|
||||
hierarchy schema §5.2's full immutable snapshot. The hierarchy role
|
||||
vocabulary (§2), every evaluation rule (§3), and grant management (§4) are
|
||||
untouched: the capability is not a `hierarchy_grants.role` value and
|
||||
evaluates outside the chain; capability-row deletion joins §6.1's
|
||||
revocation enumeration (§8.4). Until this amendment ratifies, the capability
|
||||
class is empty and
|
||||
the visibility command remains admin-only (hierarchy schema §5.5 states
|
||||
this fallback; the shipped gate at
|
||||
`apps/gateway/src/hierarchy/hierarchy.repository.ts` implements it).
|
||||
|
||||
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 2–3 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).
|
||||
|
||||
## 8. Company-CRUD capability (Amendment 1)
|
||||
|
||||
Hierarchy schema §5.5 authorizes the company visibility mutation for
|
||||
exactly two actor classes: platform admins and "subjects holding the
|
||||
company-CRUD capability that a follow-up amendment to contract 2 will
|
||||
define". This section is that definition. The name is historical — coined
|
||||
in contract 1 Amendment 1 before the capability's content was fixed — and
|
||||
confers nothing by connotation: the ratified content is exactly §8.1.
|
||||
Company _creation_ is already ruled open to active users and always
|
||||
private (contract 3 §5.2, Ruling 4); rename, delete, and transfer of
|
||||
companies remain hierarchy `owner` operations (§2.3, §5); none of those is
|
||||
part of this capability, and widening it to any other operation is a
|
||||
further amendment, not an implementation decision.
|
||||
|
||||
1. **Content: exactly one command, no read command, disclosure stated.**
|
||||
Holding the capability authorizes executing the hierarchy schema §5.5
|
||||
visibility command (`companies.visibility`, both directions:
|
||||
`private → directory` and `directory → private`) on any company in the
|
||||
deployment, and no other command of any family. It confers **no read
|
||||
command**: no company enumeration, no curation listing, no structure
|
||||
read. The practical flow this implies is deliberate: to publish a
|
||||
private company, the holder is given the target identifier by the
|
||||
requesting company `owner` out of band; to unpublish, the target is
|
||||
already directory-listed. A curation listing for capability holders,
|
||||
if ever wanted, is a further amendment with its own disclosure
|
||||
analysis under hierarchy schema §6.7.
|
||||
|
||||
**Existence disclosure carve-out, stated rather than pretended away:**
|
||||
exercising a mutation inherently discloses its target's existence.
|
||||
The command's result distinguishes an existing company (success, for
|
||||
private and directory targets alike) from a nonexistent id
|
||||
(`not_found`), so a holder presenting candidate ids learns existence —
|
||||
exactly as a platform admin already does through the same command.
|
||||
This amendment ratifies that disclosure as part of the §5.5 curation
|
||||
authority, bounded as follows. The holder-visible surface is the
|
||||
command's **actor receipt** — the mutation result payload, carrying
|
||||
exactly the target id, old visibility, and new visibility, and
|
||||
**nothing else**: no name, slug, structure, content, grant, or
|
||||
membership information. The actor receipt is a redacted projection
|
||||
distinct from the **canonical audit event**, which is unchanged by
|
||||
this amendment: it keeps hierarchy schema §5.2's deletion-safe
|
||||
immutable target snapshot (id, slug, and parent chain at event time)
|
||||
in full. The two never converge on the holder: the capability confers
|
||||
no audit read (§8.5), so the canonical event — and with it the slug
|
||||
and parent chain — is reachable only by subjects independently
|
||||
authorized to read audit data, never through this capability. A
|
||||
successful publish additionally makes the target directory-listed to
|
||||
every authenticated user; that is the command's ratified purpose
|
||||
(hierarchy schema §5.5), not a leak. Hierarchy schema §6.7's
|
||||
existence-oracle rule and §2.8's directory-only disclosure are amended
|
||||
by exactly this carve-out for capability holders, kanban SOT Amendment
|
||||
A3 (native-kanban-sot.md §10, this PR) expressly extends A2's
|
||||
sole-disclosure enumeration by the same carve-out, and all three are
|
||||
otherwise untouched. Witnessed in §8.6.3.
|
||||
|
||||
2. **Holding: platform-scoped assignment, user subjects only.** The
|
||||
capability is not a hierarchy grant: it attaches to no node, has no
|
||||
role, and never enters §3 chain evaluation. It is held via a
|
||||
`platform_capabilities` table whose column set is exactly (nothing
|
||||
else, per the contract 1 §2.7 exhaustiveness discipline):
|
||||
- `id` — uuid, primary key;
|
||||
- `user_id` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
|
||||
- `capability` — text, NOT NULL, constraint-checked against exactly
|
||||
`company_crud`;
|
||||
- `granted_by` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
|
||||
- `created_at` — timestamptz, NOT NULL;
|
||||
- UNIQUE (`user_id`, `capability`).
|
||||
|
||||
The user FKs are **text**, not uuid, because `users.id` is a BetterAuth
|
||||
text key (`packages/db/src/schema.ts`; custody schema records the same)
|
||||
— PostgreSQL cannot reference a text primary key with a uuid column.
|
||||
This matches the shipped `hierarchy_grants` shape exactly: uuid
|
||||
surrogate `id`, text FKs to `users`.
|
||||
|
||||
Both user FKs are RESTRICT for the same reason contract 1 §3.3 pins
|
||||
RESTRICT on principal FKs: the identity contract (§7.3) gates user
|
||||
deletion, and a cascade here could silently destroy a capability
|
||||
without its §8.3 revocation audit event. Revocation is row deletion
|
||||
through the §8.3 command — there is no other removal path, no expiry
|
||||
column, and no tombstone. A deactivated holder confers nothing while
|
||||
deactivated: identity contract §7.1 denies all authorization to
|
||||
deactivated accounts, and the §8.4 predicate evaluates on the
|
||||
authenticated live user. No team subjects (§1.4's suspension reasoning
|
||||
applies with more force here — a workspace-bound team holding
|
||||
deployment-wide curation authority has no ratified meaning).
|
||||
|
||||
3. **Assignment is instance administration on the normal admin surface.**
|
||||
Only platform admins (`users.role = 'admin'`) may assign or revoke the
|
||||
capability, through an ordinary admin command (the same command class
|
||||
`AdminGuard` governs, §1.1) — not through direct table writes.
|
||||
Assignment delegates a slice of instance administration and is itself
|
||||
an instance-administration act under §1.1. A capability holder as such
|
||||
may NOT assign or revoke it (no self-propagation). Every assignment
|
||||
and revocation is a semantic audit event carrying actor, verb, subject
|
||||
user, and capability; serialized capability strings are namespaced per
|
||||
§4.5 (`platform-capability:company-crud` — a bare `company_crud` in
|
||||
any serialized artifact is non-conformant).
|
||||
4. **Evaluation and revocation follow this contract's existing rules.**
|
||||
The hierarchy schema §5.5 command's authorization predicate is:
|
||||
`users.role = 'admin'` OR a live `platform_capabilities` row
|
||||
(`user_id`, `company_crud`). Both disjuncts are evaluated live and
|
||||
fail closed per §3.5 — **independently**: with capability state
|
||||
unreadable (fault), the capability disjunct denies, but a platform
|
||||
admin whose `users.role` is readable remains authorized through the
|
||||
admin disjunct; with role state unreadable, the admin disjunct denies
|
||||
likewise. A decision that can read neither denies. Capability-row
|
||||
deletion is hereby added to §6.1's enumerated revocation paths:
|
||||
it propagates identically, under §6.2's bound, on every transport —
|
||||
no new HTTP/MCP command authorized by the deleted row after the
|
||||
revoking transaction commits, and any cached authorization is
|
||||
invalidated in the revoking transaction (§3.5).
|
||||
5. **What it does not confer**, stated so implementing PRs cannot drift:
|
||||
no hierarchy grant or effective role at any node; no workspace
|
||||
authorization or membership; no content, structure, or roll-up read;
|
||||
no grant management (§4.1 unchanged); no MCP scope; no other instance
|
||||
administration (user management, system settings, provider
|
||||
configuration remain platform-admin-only); no company create, rename,
|
||||
delete, or transfer. Hierarchy schema §5.5's rule that a company
|
||||
`owner` as such may NOT change visibility is unchanged — `owner` and
|
||||
this capability are disjoint authorities that combine only by a
|
||||
subject holding both.
|
||||
6. **Verification requirements** (extends §7, binding on implementing
|
||||
PRs):
|
||||
1. Schema witnesses (real PostgreSQL, `ci-postgres` service in the
|
||||
`test` CI step): the `capability` CHECK constraint rejects any
|
||||
value outside `company_crud`; NOT NULL enforced on every declared
|
||||
NOT NULL column; UNIQUE (`user_id`, `capability`) rejects a
|
||||
duplicate; both user FKs reject a dangling reference AND deleting a
|
||||
referenced user is refused (RESTRICT witnessed in both directions);
|
||||
the table's column set is exactly the §8.2 declared set (contract 1
|
||||
§6.2 discipline).
|
||||
2. Capability-only command matrix — the witness that proves "exactly
|
||||
one command", not merely "at least one": a non-admin holder with no
|
||||
other grants succeeds on the visibility command in **both**
|
||||
directions with contract 1 §5.2's audit event (old and new values
|
||||
as semantic content), and the **same** actor is refused, case by
|
||||
enumerated case: every hierarchy mutation family (company/child
|
||||
create under another's node, rename, delete, transfer); grant
|
||||
create/change/revoke; the workspace read and write command
|
||||
families; roll-up reads; structure reads — including the
|
||||
not-found-indistinguishable refusal on a structure read of the very
|
||||
company they just mutated (hierarchy schema §6.7); every
|
||||
instance-administration surface other than the visibility command
|
||||
(user management, system settings, provider configuration, and
|
||||
capability assign/revoke itself); and MCP scope derivation yields
|
||||
nothing — the §7.4 deny-by-default matrix gains this row. Company
|
||||
creation compares against an eligible-user baseline: the holder's
|
||||
create behaves exactly as any active user's — always `private`,
|
||||
and a creation request carrying a visibility argument is refused
|
||||
for holder and baseline alike (contract 3 §5.2).
|
||||
3. Disclosure bound (§8.1 carve-out witnessed, receipt and canonical
|
||||
event separately): the mutation result for a private-valid target,
|
||||
a directory-valid target, and a nonexistent id is exactly {success,
|
||||
success, `not_found`}; the actor receipt for a success carries
|
||||
exactly {target id, old visibility, new visibility} and no result
|
||||
or error payload carries name, slug, structure, content, grant, or
|
||||
membership data; the canonical audit event for the same mutation —
|
||||
asserted directly against the hierarchy outbox, not through any
|
||||
holder-facing surface — carries hierarchy schema §5.2's full
|
||||
immutable snapshot (id, slug, parent chain); and the holder's
|
||||
attempt to read audit data is refused (no audit read conferred,
|
||||
§8.5), proving the receipt/event separation reaches the holder as
|
||||
a redaction, not a weakened event.
|
||||
4. Assignment path, both polarities: a platform admin assigns and
|
||||
revokes through the normal admin command (positive witnesses —
|
||||
assign then observe the §8.6.2 allow, revoke then observe deny); a
|
||||
non-admin — including a current capability holder — is refused
|
||||
assign and revoke; every assign/revoke produces its audit event
|
||||
with the namespaced string (§8.3); a direct-write path that skips
|
||||
the command surface is non-conformant (the §8.3 command is the only
|
||||
writer of `platform_capabilities`).
|
||||
5. Revocation joins the §7.6 matrix: assignment is decision-time-live
|
||||
(capability assigned → the holder's next visibility command allows,
|
||||
no re-login); after row deletion, the ex-holder's next visibility
|
||||
command is refused **on every exposed transport**, measured with
|
||||
the revocation and the decision on distinct physical connections; a
|
||||
cached-authorization implementation proves transactional
|
||||
invalidation (§3.5). Fail-closed fault witnesses, both disjuncts
|
||||
(§8.4): with `platform_capabilities` unreadable, a non-admin holder
|
||||
is denied while a platform admin remains authorized; with role
|
||||
state unreadable, the admin disjunct denies.
|
||||
6. Owner-as-such refusal re-witnessed: hierarchy schema §6.9's
|
||||
owner-cannot-publish witness re-asserted with the
|
||||
`platform_capabilities` table present and empty for that owner.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–7 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.
|
||||
|
||||
## Ruling request (Amendment 1)
|
||||
|
||||
Ratify §8, the Amendment 1 header note, and kanban SOT Amendment A3
|
||||
(native-kanban-sot.md §10 — the express A2 carve-out extension, which
|
||||
binds only with this ratification) as written, with one decision
|
||||
embedded:
|
||||
|
||||
- Decision: the company-CRUD capability is a platform-scoped,
|
||||
admin-assigned, audited delegation of exactly the hierarchy schema §5.5
|
||||
visibility command — no read command, no other company operation, with
|
||||
the mutation's inherent existence disclosure ratified as a bounded
|
||||
carve-out (§8.1). Say "agreed" or name the additional operations (or
|
||||
the curation listing) you want it to carry.
|
||||
@@ -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
|
||||
1–3.
|
||||
|
||||
Revision 2 (terra review F1–F7): 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 1–3: 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 1–3 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?
|
||||
@@ -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 F1–F5): 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 §§2–4, 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 1–6 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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user