From 7f8bb9c7ecb79611a3a954e16c3df85fac1daf32 Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Date: Wed, 21 Oct 2026 14:56:25 -0700 Subject: [PATCH 01/63] LIM-2424: L0: tenancy core (Mode L): workspace ids, store, sessions Multi-workspace spec rev 2, section 12 "in the cut", L0a. Additive in the cut: POST /api/scenario, /setup and reset-to-Northstar are unchanged; custom workspaces are added alongside (deviation from rev 2's "replaces", ruled by Mini S0 for PR 3). - lib/workspace.ts: CustomWsId/WsId, parseCustomWs, atomic create (.tmp- then rename) under one creation lock with the workspace cap, locked delete with the strict-child check, 80-bit passcodes hashed with async scrypt, auth.json read sync with an mtime cache, _events.jsonl. - lib/store.ts: readState(ws) (Northstar unchanged, including the reseed), mutateWs and replaceWorkspaceState. Custom reads never fall back (404 / 500, nothing written), writes never create directories, 1 MB cap excluding audit, audit trimmed to 1000 rows into audit-archive.jsonl. - lib/auth.ts (spec section 5 only): custom token {actor,sid,ws,ep,exp}; Northstar token, cookie and passcodes unchanged. requireSession returns 403 for custom tokens; requireWsSession returns a ctx whose mutate/read are bound to its workspace; a signed custom token that is expired, revoked or for a deleted workspace is 401 Session ended. - lib/config.ts, instrumentation.ts, lib/startup.ts: env-only config, ALIGN_PUBLIC=1 refused, data/.pid single-server guard. - scripts/serve.sh: next build && next start on 127.0.0.1 with open create. - lib/errors.ts and lib/locks.ts: HttpError moved (still exported from store) and the per-key lock, so workspace.ts and store.ts don't cycle. No route uses the new code yet. test-all passes every step (snapshot, test-flow, test-scenario 37/37, mcp, capture, mapper); Northstar UI 72/72. --- .gitignore | 4 + instrumentation.ts | 7 ++ lib/auth.ts | 117 +++++++++++++++++++++++++---- lib/config.ts | 39 ++++++++++ lib/errors.ts | 5 ++ lib/locks.ts | 18 +++++ lib/startup.ts | 32 ++++++++ lib/store.ts | 71 ++++++++++++++++-- lib/workspace.ts | 181 +++++++++++++++++++++++++++++++++++++++++++++ scripts/serve.sh | 10 +++ 10 files changed, 464 insertions(+), 20 deletions(-) create mode 100644 instrumentation.ts create mode 100644 lib/config.ts create mode 100644 lib/errors.ts create mode 100644 lib/locks.ts create mode 100644 lib/startup.ts create mode 100644 lib/workspace.ts create mode 100755 scripts/serve.sh diff --git a/.gitignore b/.gitignore index 7548c723..7a87b3c0 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ data/.secret data/state.json data/*.tmp +# Custom workspaces and the single-server guard +data/workspaces/ +data/.pid + # Sparta local runtime (worlds, dashboard state) .worlds/ .worlds-dashboard/ diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 00000000..bcd61df5 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,7 @@ +// Runs once when the server starts, before it handles requests. +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { startup } = await import("./lib/startup"); + startup(); + } +} diff --git a/lib/auth.ts b/lib/auth.ts index a5e0505d..60c0f85a 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,8 +1,9 @@ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import type { Actor } from "./types"; -import { HttpError } from "./store"; +import type { Actor, AppState } from "./types"; +import { HttpError, mutate, mutateWs, readState } from "./store"; +import { parseCustomWs, readAuth, verifyPasscode, type CustomWsId, type WsId } from "./workspace"; export const COOKIE = "align_session"; @@ -33,6 +34,33 @@ export interface Session { sid: string; } +/** A custom workspace session. Northstar sessions keep the plain {actor, sid} shape and token. */ +export interface WsSession extends Session { + ws: WsId; +} + +/** Scoped access for workspace-aware routes: state reads and writes are bound to the session's workspace. */ +export interface WsContext extends WsSession { + mutate(fn: (s: AppState) => T | Promise): Promise; + read(): Promise; +} + +const SESSION_MS = 12 * 60 * 60 * 1000; +const ACTORS = new Set(["product", "security", "leadership"]); + +// Custom token payload: {actor, sid, ws, ep, exp}. ep is the workspace epoch (bumped to revoke), exp is ms since epoch. +interface CustomPayload extends Session { + ws: string; + ep: number; + exp: number; +} + +type TokenRead = + | { kind: "none" } // no token, or one that fails the HMAC: anonymous, exactly as before + | { kind: "northstar"; session: Session } + | { kind: "custom"; session: WsSession } + | { kind: "ended" }; // signed by us, but expired, revoked or for a workspace that is gone + function sign(payload: string) { return createHmac("sha256", secret()).update(payload).digest("base64url"); } @@ -49,28 +77,91 @@ export function login(role: string, passcode: string): { token: string; session: return { token: `${payload}.${sign(payload)}`, session }; } -export function readSession(token: string | undefined): Session | null { - if (!token) return null; +const WRONG = "Wrong passcode for this role"; + +/** + * Sign in to a custom workspace. Invalid id, unknown workspace, unknown role and wrong passcode all get the same 401. + * Compares with async scrypt and timingSafeEqual. + */ +export async function loginWorkspace(workspace: unknown, role: unknown, passcode: unknown): Promise<{ token: string; session: WsSession }> { + const ws = parseCustomWs(workspace); + if (!ws || typeof role !== "string" || !ACTORS.has(role) || typeof passcode !== "string") throw new HttpError(401, WRONG); + const auth = readAuth(ws); + if (!auth || !(await verifyPasscode(auth.roles[role as Actor], passcode))) throw new HttpError(401, WRONG); + const body: CustomPayload = { actor: role as Actor, sid: randomUUID().slice(0, 8), ws, ep: auth.epoch, exp: Date.now() + SESSION_MS }; + const payload = Buffer.from(JSON.stringify(body)).toString("base64url"); + return { token: `${payload}.${sign(payload)}`, session: { actor: body.actor, sid: body.sid, ws } }; +} + +function readToken(token: string | undefined): TokenRead { + if (!token) return { kind: "none" }; const [payload, mac] = token.split("."); - if (!payload || !mac) return null; + if (!payload || !mac) return { kind: "none" }; const good = sign(payload); - if (good.length !== mac.length || !timingSafeEqual(Buffer.from(good), Buffer.from(mac))) return null; + if (good.length !== mac.length || !timingSafeEqual(Buffer.from(good), Buffer.from(mac))) return { kind: "none" }; + let p: Partial; try { - const s = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Session; - return s.actor === "product" || s.actor === "security" || s.actor === "leadership" ? s : null; + p = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Partial; } catch { - return null; + return { kind: "none" }; } + if (!p || typeof p.actor !== "string" || !ACTORS.has(p.actor)) return { kind: "none" }; + if (p.ws === undefined) return { kind: "northstar", session: p as Session }; + const ws = parseCustomWs(p.ws); + if (!ws || typeof p.exp !== "number" || p.exp <= Date.now()) return { kind: "ended" }; + const auth = readAuth(ws); + if (!auth || auth.epoch !== p.ep) return { kind: "ended" }; + return { kind: "custom", session: { actor: p.actor as Actor, sid: String(p.sid), ws } }; } -export function sessionFrom(req: Request): Session | null { +export function readSession(token: string | undefined): Session | null { + const t = readToken(token); + return t.kind === "northstar" ? t.session : null; +} + +function tokenFrom(req: Request): TokenRead { const cookie = req.headers.get("cookie") || ""; const match = cookie.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`)); - return readSession(match ? decodeURIComponent(match.slice(COOKIE.length + 1)) : undefined); + return readToken(match ? decodeURIComponent(match.slice(COOKIE.length + 1)) : undefined); } +/** Northstar session or null. A custom token is not a Northstar session. */ +export function sessionFrom(req: Request): Session | null { + const t = tokenFrom(req); + return t.kind === "northstar" ? t.session : null; +} + +export const SESSION_ENDED = "Your session ended. Sign in again."; + +/** Any session, with its workspace. Throws 401 Session ended for a signed custom token that no longer verifies. */ +export function wsSessionFrom(req: Request): WsSession | null { + const t = tokenFrom(req); + if (t.kind === "ended") throw new HttpError(401, SESSION_ENDED); + if (t.kind === "northstar") return { ...t.session, ws: "northstar" }; + return t.kind === "custom" ? t.session : null; +} + +/** For Northstar-only routes. A custom workspace session gets 403; it can never reach Northstar state here. */ export function requireSession(req: Request): Session { - const s = sessionFrom(req); + const t = tokenFrom(req); + if (t.kind === "ended") throw new HttpError(401, SESSION_ENDED); + if (t.kind === "custom") throw new HttpError(403, "This isn’t available in a custom workspace"); + if (t.kind !== "northstar") throw new HttpError(401, "Sign in as Product or Security first"); + return t.session; +} + +/** For workspace-aware routes: the only way a route reaches custom state. ctx.mutate and ctx.read are bound to ctx.ws. */ +export function requireWsSession(req: Request): WsContext { + const s = wsSessionFrom(req); if (!s) throw new HttpError(401, "Sign in as Product or Security first"); - return s; + return bindWs(s); +} + +export function bindWs(s: WsSession): WsContext { + if (s.ws === "northstar") { + // Flow functions read only actor and sid, so the extra fields never reach Northstar state. + return { ...s, mutate: (fn) => mutate(fn), read: () => readState() }; + } + const ws: CustomWsId = s.ws; + return { ...s, mutate: (fn) => mutateWs(ws, fn), read: () => readState(ws) }; } diff --git a/lib/config.ts b/lib/config.ts new file mode 100644 index 00000000..128f2bab --- /dev/null +++ b/lib/config.ts @@ -0,0 +1,39 @@ +import path from "node:path"; + +// Every multi-workspace setting, read from the environment only (never from request headers). +// This build ships Mode L (loopback) only: ALIGN_PUBLIC=1 is refused at startup. + +export interface Config { + dataDir: string; // custom workspaces live under /workspaces; Northstar always stays in /data + workspacesRoot: string; + maxWorkspaces: number; + signupCode: string | null; + allowOpenCreate: boolean; + allowedHosts: Set; // hostnames without port + trustProxy: boolean; +} + +const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"]; + +export function config(): Config { + const dataDir = path.resolve(process.env.ALIGN_DATA_DIR || path.join(process.cwd(), "data")); + const max = Number(process.env.ALIGN_MAX_WORKSPACES || 25); + const hosts = (process.env.ALIGN_ALLOWED_HOSTS || "") + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + return { + dataDir, + workspacesRoot: path.join(dataDir, "workspaces"), + maxWorkspaces: Number.isInteger(max) && max > 0 ? max : 25, + signupCode: process.env.ALIGN_SIGNUP_CODE || null, + allowOpenCreate: process.env.ALIGN_ALLOW_OPEN_CREATE === "1", + allowedHosts: new Set(hosts.length ? hosts : LOOPBACK), + trustProxy: process.env.ALIGN_TRUST_PROXY === "1", + }; +} + +/** Startup check (instrumentation.ts). Mode P needs TLS, a signup code and more; none of that is in this build. */ +export function assertStartupConfig() { + if (process.env.ALIGN_PUBLIC === "1") throw new Error("Public mode is not supported in this build"); +} diff --git a/lib/errors.ts b/lib/errors.ts new file mode 100644 index 00000000..a41a5408 --- /dev/null +++ b/lib/errors.ts @@ -0,0 +1,5 @@ +export class HttpError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} diff --git a/lib/locks.ts b/lib/locks.ts new file mode 100644 index 00000000..432251d6 --- /dev/null +++ b/lib/locks.ts @@ -0,0 +1,18 @@ +// Per-key promise queues shared across route bundles in one Node process (like store.ts's __alignLock). +const g = globalThis as unknown as { __alignLocks?: Map> }; +const locks = (g.__alignLocks ??= new Map()); + +export async function withLock(key: string, fn: () => Promise): Promise { + const prev = locks.get(key) ?? Promise.resolve(); + let release!: () => void; + const next = new Promise((r) => (release = r)); + const tail = prev.then(() => next); + locks.set(key, tail); + await prev.catch(() => undefined); + try { + return await fn(); + } finally { + release(); + if (locks.get(key) === tail) locks.delete(key); // nothing queued behind us + } +} diff --git a/lib/startup.ts b/lib/startup.ts new file mode 100644 index 00000000..bca4103d --- /dev/null +++ b/lib/startup.ts @@ -0,0 +1,32 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { assertStartupConfig } from "./config"; + +// State locks live in this process's memory, so two servers on one data directory would corrupt it. +// data/.pid records the live server; a second one refuses to start. +const PID_FILE = path.join(process.cwd(), "data", ".pid"); + +function alive(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return (e as NodeJS.ErrnoException).code === "EPERM"; + } +} + +export function startup() { + assertStartupConfig(); + if (process.env.NEXT_PHASE === "phase-production-build") return; + const held = existsSync(PID_FILE) ? Number(readFileSync(PID_FILE, "utf8").trim()) : 0; + if (held && held !== process.pid && alive(held)) { + throw new Error(`Another Align server (pid ${held}) is using ${path.dirname(PID_FILE)}. Stop it first.`); + } + mkdirSync(path.dirname(PID_FILE), { recursive: true }); + writeFileSync(PID_FILE, String(process.pid)); + process.once("exit", () => { + try { + if (readFileSync(PID_FILE, "utf8").trim() === String(process.pid)) unlinkSync(PID_FILE); + } catch {} + }); +} diff --git a/lib/store.ts b/lib/store.ts index 3c61ebad..ff372e6d 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -3,6 +3,11 @@ import { promises as fs, readFileSync, existsSync } from "node:fs"; import path from "node:path"; import type { AppState, Plan } from "./types"; import { registryVersion } from "./registry"; +import { HttpError } from "./errors"; +import { withLock } from "./locks"; +import { wsDir, workspaceExists, type CustomWsId, type WsId } from "./workspace"; + +export { HttpError }; export const ROOT = process.cwd(); const DATA = path.join(ROOT, "data"); @@ -10,12 +15,6 @@ const STATE_FILE = path.join(DATA, "state.json"); const FIXTURE_FILE = path.join(DATA, "fixture.json"); export const PLANS_DIR = path.join(ROOT, "agent", "plans"); -export class HttpError extends Error { - constructor(public status: number, message: string) { - super(message); - } -} - export function sha256(text: string | Buffer) { return createHash("sha256").update(text).digest("hex"); } @@ -46,7 +45,8 @@ export function initialState(): AppState { return withDefaults(fixture); } -export async function readState(): Promise { +export async function readState(ws: WsId = "northstar"): Promise { + if (ws !== "northstar") return readCustom(ws); try { return withDefaults(JSON.parse(await fs.readFile(STATE_FILE, "utf8")) as AppState); } catch { @@ -85,6 +85,63 @@ export async function mutate(fn: (s: AppState) => T | Promise): Promise } } +// ---- custom workspaces ---- +// No fallback: a missing or unreadable custom state is an error, never a reseed (only Northstar reseeds). + +const MAX_WS_BYTES = 1024 * 1024; +const AUDIT_KEEP = 1000; + +async function readCustom(ws: CustomWsId): Promise { + let raw: string; + try { + raw = await fs.readFile(path.join(wsDir(ws), "state.json"), "utf8"); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") throw new HttpError(404, "This workspace no longer exists"); + throw new HttpError(500, "This workspace could not be read"); + } + try { + return withDefaults(JSON.parse(raw) as AppState); + } catch { + throw new HttpError(500, "This workspace could not be read"); + } +} + +/** Never creates directories. Trims audit to the last 1000 rows (older rows go to audit-archive.jsonl), then caps size. */ +async function writeCustom(ws: CustomWsId, s: AppState) { + const dir = wsDir(ws); + if (!workspaceExists(ws)) throw new HttpError(404, "This workspace no longer exists"); + if (s.audit.length > AUDIT_KEEP) { + const old = s.audit.slice(0, s.audit.length - AUDIT_KEEP); + await fs.appendFile(path.join(dir, "audit-archive.jsonl"), old.map((r) => JSON.stringify(r)).join("\n") + "\n"); + s.audit = s.audit.slice(-AUDIT_KEEP); + } + const text = JSON.stringify(s, null, 2); + if (Buffer.byteLength(JSON.stringify({ ...s, audit: [] }, null, 2)) > MAX_WS_BYTES) throw new HttpError(413, "This workspace is full"); + const file = path.join(dir, "state.json"); + const tmp = `${file}.${randomUUID()}.tmp`; + await fs.writeFile(tmp, text); + await fs.rename(tmp, file); +} + +/** mutate() for one custom workspace, under that workspace's own lock. */ +export async function mutateWs(ws: CustomWsId, fn: (s: AppState) => T | Promise): Promise { + return withLock(ws, async () => { + const s = await readCustom(ws); + const before = JSON.stringify(s); + const result = await fn(s); + if (JSON.stringify(s) !== before) await writeCustom(ws, s); + return result; + }); +} + +/** Replace a custom workspace's whole state (Restart). Built by lib/scenario.ts; the store never imports it. */ +export async function replaceWorkspaceState(ws: CustomWsId, next: AppState) { + return mutateWs(ws, (s) => { + for (const k of Object.keys(s)) delete (s as unknown as Record)[k]; + Object.assign(s, next); + }); +} + /** * Reset to the Northstar fixture. `fresh` drops carried-over Sparta evidence and the operator-captured Alike * reference (used when leaving a custom scenario). Sessions and the audit log are kept either way. diff --git a/lib/workspace.ts b/lib/workspace.ts new file mode 100644 index 00000000..b6b9aeeb --- /dev/null +++ b/lib/workspace.ts @@ -0,0 +1,181 @@ +import { randomBytes, randomInt, scrypt, timingSafeEqual } from "node:crypto"; +import { existsSync, promises as fs, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { config } from "./config"; +import { HttpError } from "./errors"; +import { withLock } from "./locks"; +import type { Actor, AppState } from "./types"; + +// Custom workspaces: ids, directories, passcodes, create and delete. Northstar is not a directory here: +// it stays in data/ and is handled only by store.ts. Every function that touches a custom directory +// takes a CustomWsId and checks it again before building a path. + +export type CustomWsId = `w_${string}`; // ^w_[a-z2-7]{12}$, 60 random bits +export type WsId = "northstar" | CustomWsId; + +const ID_RE = /^w_[a-z2-7]{12}$/; +const B32 = "abcdefghijklmnopqrstuvwxyz234567"; +const ROLES: Actor[] = ["leadership", "product", "security"]; + +/** The only way client input becomes a workspace id. Rejects "northstar" and anything not exactly w_ + 12 base32. */ +export function parseCustomWs(x: unknown): CustomWsId | null { + return typeof x === "string" && ID_RE.test(x) ? (x as CustomWsId) : null; +} + +function assertCustomWs(ws: CustomWsId): CustomWsId { + if (!ID_RE.test(ws)) throw new HttpError(400, "Invalid workspace"); + return ws; +} + +export function wsDir(ws: CustomWsId): string { + return path.join(config().workspacesRoot, assertCustomWs(ws)); +} + +/** A workspace exists once its directory has been renamed into place with a workspace.json. */ +export function workspaceExists(ws: CustomWsId): boolean { + return existsSync(path.join(wsDir(ws), "workspace.json")); +} + +export function newWorkspaceId(): CustomWsId { + let s = "w_"; + for (let i = 0; i < 12; i++) s += B32[randomInt(32)]; + return s as CustomWsId; +} + +// ---- passcodes ---- + +/** 16 base32 characters (80 bits), shown as xxxx-xxxx-xxxx-xxxx. */ +export function newPasscode(): string { + const raw = Array.from({ length: 16 }, () => B32[randomInt(32)]).join(""); + return raw.match(/.{4}/g)!.join("-"); +} + +const normalizePasscode = (p: string) => p.trim().toLowerCase().replace(/[\s-]/g, ""); + +function scryptAsync(passcode: string, salt: Buffer): Promise { + return new Promise((resolve, reject) => + scrypt(normalizePasscode(passcode), salt, 32, { N: 16384, r: 8, p: 1 }, (err, key) => (err ? reject(err) : resolve(key))), + ); +} + +interface RoleHash { + salt: string; + hash: string; +} + +export interface WorkspaceAuth { + epoch: number; + roles: Record; +} + +async function hashPasscode(passcode: string): Promise { + const salt = randomBytes(16); + return { salt: salt.toString("base64"), hash: (await scryptAsync(passcode, salt)).toString("base64") }; +} + +export async function verifyPasscode(h: RoleHash, passcode: string): Promise { + const expected = Buffer.from(h.hash, "base64"); + const given = await scryptAsync(passcode, Buffer.from(h.salt, "base64")); + return expected.length === given.length && timingSafeEqual(expected, given); +} + +// Sessions are checked on every poll, so auth.json is read synchronously and cached by mtime. +const g = globalThis as unknown as { __alignAuthCache?: Map }; +const authCache = (g.__alignAuthCache ??= new Map()); + +/** auth.json for a workspace, or null if the workspace does not exist (or is being deleted). */ +export function readAuth(ws: CustomWsId): WorkspaceAuth | null { + const file = path.join(wsDir(ws), "auth.json"); + try { + const { mtimeMs } = statSync(file); + const hit = authCache.get(ws); + if (hit && hit.mtimeMs === mtimeMs) return hit.auth; + const auth = JSON.parse(readFileSync(file, "utf8")) as WorkspaceAuth; + authCache.set(ws, { mtimeMs, auth }); + return auth; + } catch { + authCache.delete(ws); + return null; + } +} + +// ---- lifecycle ---- + +async function appendEvent(event: "created" | "deleted", id: CustomWsId) { + const root = config().workspacesRoot; + await fs.mkdir(root, { recursive: true }); + await fs.appendFile(path.join(root, "_events.jsonl"), JSON.stringify({ ts: new Date().toISOString(), event, id }) + "\n"); +} + +/** Live workspaces: directories named like an id. Skips _events.jsonl, .tmp-* and .deleting-*. */ +async function countWorkspaces(): Promise { + try { + const names = await fs.readdir(config().workspacesRoot); + return names.filter((n) => ID_RE.test(n)).length; + } catch { + return 0; + } +} + +export interface CreatedWorkspace { + id: CustomWsId; + name: string; + passcodes: Record; +} + +/** + * Create a workspace atomically: everything is written to /.tmp-/ and renamed into place, so a + * workspace can be signed into only once it is complete. The cap check and the create share one lock. + */ +export async function createWorkspace(args: { name: string; state: AppState; scenario: unknown }): Promise { + return withLock("_create", async () => { + const cfg = config(); + if ((await countWorkspaces()) >= cfg.maxWorkspaces) throw new HttpError(409, "The workspace limit for this server has been reached"); + const id = newWorkspaceId(); + const root = cfg.workspacesRoot; + const final = wsDir(id); + const tmp = path.join(root, `.tmp-${id}`); + const passcodes = Object.fromEntries(ROLES.map((r) => [r, newPasscode()])) as Record; + const roles = Object.fromEntries(await Promise.all(ROLES.map(async (r) => [r, await hashPasscode(passcodes[r])]))) as Record; + const auth: WorkspaceAuth = { epoch: 1, roles }; + const meta = { id, name: args.name, created_at: new Date().toISOString(), schema_version: 1 }; + await fs.mkdir(root, { recursive: true }); + try { + await fs.mkdir(tmp); + await fs.writeFile(path.join(tmp, "state.json"), JSON.stringify(args.state, null, 2)); + await fs.writeFile(path.join(tmp, "scenario.json"), JSON.stringify(args.scenario, null, 2), { mode: 0o600 }); + await fs.writeFile(path.join(tmp, "auth.json"), JSON.stringify(auth, null, 2), { mode: 0o600 }); + await fs.writeFile(path.join(tmp, "workspace.json"), JSON.stringify(meta, null, 2)); + await fs.rename(tmp, final); + } catch (e) { + await fs.rm(tmp, { recursive: true, force: true }); + throw e; + } + await appendEvent("created", id); + return { id, name: args.name, passcodes }; + }); +} + +/** Saved setup input, the rebuild source for Restart. Never served. */ +export async function readScenarioFile(ws: CustomWsId): Promise { + try { + return JSON.parse(await fs.readFile(path.join(wsDir(ws), "scenario.json"), "utf8")); + } catch { + throw new HttpError(404, "This workspace no longer exists"); + } +} + +/** Delete under the workspace lock: rename aside, then remove. A racing request sees 404, never a new directory. */ +export async function deleteWorkspace(ws: CustomWsId) { + const dir = wsDir(ws); + const root = path.resolve(config().workspacesRoot); + if (path.dirname(path.resolve(dir)) !== root) throw new HttpError(400, "Invalid workspace"); + await withLock(ws, async () => { + if (!workspaceExists(ws)) throw new HttpError(404, "This workspace no longer exists"); + const aside = path.join(root, `.deleting-${ws}`); + await fs.rename(dir, aside); + authCache.delete(ws); + await fs.rm(aside, { recursive: true, force: true }); + }); + await appendEvent("deleted", ws); +} diff --git a/scripts/serve.sh b/scripts/serve.sh new file mode 100755 index 00000000..90ccb718 --- /dev/null +++ b/scripts/serve.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Mode L (loopback) server for the multi-workspace test: production build, bound to 127.0.0.1 only, +# with open workspace creation (synthetic data only). Usage: scripts/serve.sh +set -euo pipefail +PORT="${1:?usage: scripts/serve.sh }" +[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "serve.sh: port must be a number" >&2; exit 2; } +[ "${ALIGN_PUBLIC:-}" = 1 ] && { echo "serve.sh: public mode is not supported in this build" >&2; exit 2; } +cd "$(dirname "$0")/.." +npx next build +ALIGN_ALLOW_OPEN_CREATE=1 exec npx next start -H 127.0.0.1 -p "$PORT" From 419114b7bf76eba4f533cd366b31093ee00401e1 Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Date: Wed, 21 Oct 2026 15:02:41 -0700 Subject: [PATCH 02/63] LIM-2424: L0: workspace-aware routes, create/login, Host guard (Mode L) Multi-workspace spec rev 2, section 12 "in the cut", L0b plus the L2 create routes. Additive in the cut: /api/scenario, /setup and reset-to-Northstar are unchanged. - session: login with {workspace} is JSON-only, uses async scrypt, returns one uniform 401, and has a soft per-workspace backoff (429). GET/POST add `workspace` and the team lead's name for custom sessions only. - state: anonymous is still Northstar; a custom session reads its own workspace (no evidence ingest, prep exclusion or registry refresh); an ended custom session is 401, never the Northstar view. - proposals (5), timer, reset: requireWsSession + ctx.mutate. Custom bodies are capped at 64 KB and per field (300, text 4000); Northstar bodies parse exactly as before. emitAuthority only for Northstar. Custom reset is leadership-only and rebuilds from scenario.json. - POST /api/workspaces (201, no-store, passcodes shown once; creation only with ALIGN_ALLOW_OPEN_CREATE=1 or a matching signup code; cap under the creation lock; global bucket), GET /api/workspaces/config, GET /api/workspaces/[id]/login-info (company and team names only). - proxy.ts: 421 for a Host outside ALIGN_ALLOWED_HOSTS (default loopback). - lib/workspace-setup.ts reuses lib/scenario.ts's schema and builder without editing it (scenario.ts is pending the optional-fields change). - test-scenario S12 now accepts 421 (proxy) or 403 (requireJson). - Not in the spec: ALIGN_CREATE_PER_HOUR overrides the 5/hour bucket so the tenancy tests can create more than five workspaces. test-all passes every step (test-scenario 38/38); Northstar UI 72/72. --- app/api/proposals/[id]/accept/route.ts | 15 +++-- app/api/proposals/[id]/acknowledge/route.ts | 11 ++-- app/api/proposals/[id]/counter/route.ts | 11 ++-- app/api/proposals/[id]/reject/route.ts | 11 ++-- app/api/proposals/route.ts | 11 ++-- app/api/reset/route.ts | 20 +++++-- app/api/session/route.ts | 33 ++++++++++- app/api/state/route.ts | 13 +++-- app/api/timer/route.ts | 12 ++-- app/api/workspaces/[id]/login-info/route.ts | 24 ++++++++ app/api/workspaces/config/route.ts | 12 ++++ app/api/workspaces/route.ts | 41 ++++++++++++++ lib/auth.ts | 7 +++ lib/http.ts | 62 +++++++++++++++++++++ lib/ratelimit.ts | 61 ++++++++++++++++++++ lib/store.ts | 5 +- lib/workspace-setup.ts | 49 ++++++++++++++++ lib/workspace.ts | 2 +- 18 files changed, 346 insertions(+), 54 deletions(-) create mode 100644 app/api/workspaces/[id]/login-info/route.ts create mode 100644 app/api/workspaces/config/route.ts create mode 100644 app/api/workspaces/route.ts create mode 100644 lib/ratelimit.ts create mode 100644 lib/workspace-setup.ts diff --git a/app/api/proposals/[id]/accept/route.ts b/app/api/proposals/[id]/accept/route.ts index 8438bd90..f67031c4 100644 --- a/app/api/proposals/[id]/accept/route.ts +++ b/app/api/proposals/[id]/accept/route.ts @@ -1,20 +1,19 @@ -import { requireSession } from "@/lib/auth"; +import { requireWsSession } from "@/lib/auth"; import { ratifyProposal } from "@/lib/flow"; -import { body, handle } from "@/lib/http"; +import { bodyFor, handle } from "@/lib/http"; import { scenarioOf } from "@/lib/labels"; -import { mutate } from "@/lib/store"; import { emitAuthority } from "@/lib/events"; export async function POST(req: Request, ctx: RouteContext<"/api/proposals/[id]/accept">) { return handle(async () => { - const who = requireSession(req); + const who = requireWsSession(req); const { id } = await ctx.params; - const b = await body(req); + const b = await bodyFor(req, who.ws); void b; - const { out, scenario } = await mutate((s) => ({ out: ratifyProposal(s, who, id, b), scenario: scenarioOf(s) })); - // The authority stream is Northstar's (agent replan); a custom approval emits nothing. - if (scenario !== "custom") emitAuthority({ event_id: `accept:${id}`, kind: "ratify", at: new Date().toISOString() }); + const { out, scenario } = await who.mutate((s) => ({ out: ratifyProposal(s, who, id, b), scenario: scenarioOf(s) })); + // The authority stream is Northstar's (agent replan): a custom approval, in a workspace or a /setup scenario, emits nothing. + if (who.ws === "northstar" && scenario !== "custom") emitAuthority({ event_id: `accept:${id}`, kind: "ratify", at: new Date().toISOString() }); return out; }); } diff --git a/app/api/proposals/[id]/acknowledge/route.ts b/app/api/proposals/[id]/acknowledge/route.ts index ad0c09ec..5c3ee3d7 100644 --- a/app/api/proposals/[id]/acknowledge/route.ts +++ b/app/api/proposals/[id]/acknowledge/route.ts @@ -1,15 +1,14 @@ -import { requireSession } from "@/lib/auth"; +import { requireWsSession } from "@/lib/auth"; import { acknowledgeProposal } from "@/lib/flow"; -import { body, handle } from "@/lib/http"; -import { mutate } from "@/lib/store"; +import { bodyFor, handle } from "@/lib/http"; export async function POST(req: Request, ctx: RouteContext<"/api/proposals/[id]/acknowledge">) { return handle(async () => { - const who = requireSession(req); + const who = requireWsSession(req); const { id } = await ctx.params; - const b = await body(req); + const b = await bodyFor(req, who.ws); void b; - return mutate((s) => acknowledgeProposal(s, who, id)); + return who.mutate((s) => acknowledgeProposal(s, who, id)); }); } diff --git a/app/api/proposals/[id]/counter/route.ts b/app/api/proposals/[id]/counter/route.ts index cf9eefda..634d606d 100644 --- a/app/api/proposals/[id]/counter/route.ts +++ b/app/api/proposals/[id]/counter/route.ts @@ -1,15 +1,14 @@ -import { requireSession } from "@/lib/auth"; +import { requireWsSession } from "@/lib/auth"; import { counterProposal } from "@/lib/flow"; -import { body, handle } from "@/lib/http"; -import { mutate } from "@/lib/store"; +import { bodyFor, handle } from "@/lib/http"; export async function POST(req: Request, ctx: RouteContext<"/api/proposals/[id]/counter">) { return handle(async () => { - const who = requireSession(req); + const who = requireWsSession(req); const { id } = await ctx.params; - const b = await body(req); + const b = await bodyFor(req, who.ws); void b; - return mutate((s) => counterProposal(s, who, id, b)); + return who.mutate((s) => counterProposal(s, who, id, b)); }); } diff --git a/app/api/proposals/[id]/reject/route.ts b/app/api/proposals/[id]/reject/route.ts index 69119fc7..7f1b6988 100644 --- a/app/api/proposals/[id]/reject/route.ts +++ b/app/api/proposals/[id]/reject/route.ts @@ -1,15 +1,14 @@ -import { requireSession } from "@/lib/auth"; +import { requireWsSession } from "@/lib/auth"; import { rejectProposal } from "@/lib/flow"; -import { body, handle } from "@/lib/http"; -import { mutate } from "@/lib/store"; +import { bodyFor, handle } from "@/lib/http"; export async function POST(req: Request, ctx: RouteContext<"/api/proposals/[id]/reject">) { return handle(async () => { - const who = requireSession(req); + const who = requireWsSession(req); const { id } = await ctx.params; - const b = await body(req); + const b = await bodyFor(req, who.ws); void b; - return mutate((s) => rejectProposal(s, who, id)); + return who.mutate((s) => rejectProposal(s, who, id)); }); } diff --git a/app/api/proposals/route.ts b/app/api/proposals/route.ts index 42600962..82a79177 100644 --- a/app/api/proposals/route.ts +++ b/app/api/proposals/route.ts @@ -1,13 +1,12 @@ -import { requireSession } from "@/lib/auth"; +import { requireWsSession } from "@/lib/auth"; import { createProposal } from "@/lib/flow"; -import { body, handle } from "@/lib/http"; -import { mutate } from "@/lib/store"; +import { bodyFor, handle } from "@/lib/http"; export async function POST(req: Request) { return handle(async () => { - const who = requireSession(req); - const b = await body(req); - return mutate((s) => createProposal(s, who, b)); + const who = requireWsSession(req); + const b = await bodyFor(req, who.ws); + return who.mutate((s) => createProposal(s, who, b)); }); } diff --git a/app/api/reset/route.ts b/app/api/reset/route.ts index 5f4ae0f2..5d259673 100644 --- a/app/api/reset/route.ts +++ b/app/api/reset/route.ts @@ -1,17 +1,25 @@ -import { requireSession } from "@/lib/auth"; -import { body, handle } from "@/lib/http"; +import { requireWsSession } from "@/lib/auth"; +import { bodyFor, handle } from "@/lib/http"; import { teamOf } from "@/lib/labels"; -import { HttpError, readState, resetState } from "@/lib/store"; +import { HttpError, resetState } from "@/lib/store"; +import { resetWorkspace } from "@/lib/workspace-setup"; // Demo reset for rehearsals: restores goals/proposals/timer from the fixture, keeps ingested Sparta evidence. export async function POST(req: Request) { return handle(async () => { - const who = requireSession(req); - const { confirm } = await body<{ confirm?: string }>(req); + const who = requireWsSession(req); + const { confirm } = await bodyFor<{ confirm?: string }>(req, who.ws); if (confirm !== "reset") throw new HttpError(400, 'Send {"confirm":"reset"}'); + if (who.ws !== "northstar") { + // A custom workspace restarts its own conflict from the saved setup input. Leadership only. + const s = await who.read(); + if (who.actor !== "leadership") throw new HttpError(403, `Only ${teamOf(s, "ceo")} can restart this scenario`); + await resetWorkspace(who.ws, who); + return { ok: true }; + } // A custom scenario is leadership's workspace: only leadership may reset it. Northstar keeps the any-role rule. - const s = await readState(); + const s = await who.read(); if (s.scenario === "custom") { if (who.actor !== "leadership") throw new HttpError(403, `Only ${teamOf(s, "ceo")} can reset this scenario`); await resetState(who, { fresh: true }); diff --git a/app/api/session/route.ts b/app/api/session/route.ts index 4bc5ee31..35476d90 100644 --- a/app/api/session/route.ts +++ b/app/api/session/route.ts @@ -1,9 +1,19 @@ -import { COOKIE, DISPLAY, login, sessionFrom } from "@/lib/auth"; -import { body, handle } from "@/lib/http"; +import { bindWs, COOKIE, DISPLAY, login, loginWorkspace, sessionFrom, wsSessionFrom, type WsSession } from "@/lib/auth"; +import { body, handle, requireJson } from "@/lib/http"; +import { slotOf } from "@/lib/labels"; +import { checkLoginBackoff, clearLoginFailures, recordLoginFailure } from "@/lib/ratelimit"; +import { HttpError } from "@/lib/store"; +/** A custom workspace shows the person named for the role's team at setup. */ +async function customName(w: WsSession) { + const s = await bindWs(w).read(); + return s.teams?.[slotOf(w.actor)]?.lead ?? w.actor; +} export async function GET(req: Request) { return handle(async () => { + const w = wsSessionFrom(req); + if (w && w.ws !== "northstar") return { actor: w.actor, name: await customName(w), sid: w.sid, workspace: w.ws }; const s = sessionFrom(req); return { actor: s?.actor ?? null, name: s ? DISPLAY[s.actor] : null, sid: s?.sid ?? null }; }); @@ -11,7 +21,24 @@ export async function GET(req: Request) { export async function POST(req: Request) { return handle(async () => { - const b = await body<{ role?: string; passcode?: string }>(req); + const b = await body<{ role?: string; passcode?: string; workspace?: unknown }>(req); + if ("workspace" in b) { + // Custom workspace login: JSON only (login CSRF), soft backoff per workspace, one uniform 401. + requireJson(req); + const key = `ws:${String(b.workspace).slice(0, 40)}`; + checkLoginBackoff(key); + let out: Awaited>; + try { + out = await loginWorkspace(b.workspace, b.role, b.passcode); + } catch (e) { + if (e instanceof HttpError && e.status === 401) recordLoginFailure(key); + throw e; + } + clearLoginFailures(key); + const res = Response.json({ actor: out.session.actor, name: await customName(out.session), sid: out.session.sid, workspace: out.session.ws }); + res.headers.append("set-cookie", `${COOKIE}=${encodeURIComponent(out.token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`); + return res; + } const { token, session } = login(String(b.role), String(b.passcode ?? "")); const res = Response.json({ actor: session.actor, name: DISPLAY[session.actor], sid: session.sid }); res.headers.append("set-cookie", `${COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`); diff --git a/app/api/state/route.ts b/app/api/state/route.ts index f3eb5e2e..4b58501e 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -1,15 +1,20 @@ -import { sessionFrom } from "@/lib/auth"; +import { stateContext } from "@/lib/auth"; import { ingestEvidence } from "@/lib/evidence"; import { recordExcludedPrep, viewFor } from "@/lib/flow"; import { handle } from "@/lib/http"; import { ensureRegistryFresh } from "@/lib/mapper"; -import { mutate } from "@/lib/store"; export async function GET(req: Request) { return handle(async () => { - const who = sessionFrom(req); - const s = await mutate(async (st) => { + // Anonymous is Northstar, as before. A custom session ended by expiry or revocation gets 401, never Northstar. + const ctx = stateContext(req); + const who = ctx.who; + if (who && who.ws !== "northstar") { + // Custom workspaces have no Sparta evidence, prep exclusions or agent registry. + return viewFor(await ctx.read(), who.actor); + } + const s = await ctx.mutate(async (st) => { try { await ingestEvidence(st); st.ingest_errors = []; diff --git a/app/api/timer/route.ts b/app/api/timer/route.ts index 2552a192..799303b3 100644 --- a/app/api/timer/route.ts +++ b/app/api/timer/route.ts @@ -1,16 +1,16 @@ -import { requireSession } from "@/lib/auth"; +import { requireWsSession } from "@/lib/auth"; import { audit, seeFinal, touch } from "@/lib/flow"; -import { body, handle } from "@/lib/http"; -import { HttpError, mutate } from "@/lib/store"; +import { bodyFor, handle } from "@/lib/http"; +import { HttpError } from "@/lib/store"; const ACTIONS = new Set(["ceo_change_viewed", "brief_reviewed", "scenario_reviewed", "evidence_opened", "saw_accepted_version"]); export async function POST(req: Request) { return handle(async () => { - const who = requireSession(req); - const { action } = await body<{ action?: string }>(req); + const who = requireWsSession(req); + const { action } = await bodyFor<{ action?: string }>(req, who.ws); if (!action || !ACTIONS.has(action)) throw new HttpError(400, "Unknown timer action"); - return mutate((s) => { + return who.mutate((s) => { if (action === "saw_accepted_version") seeFinal(s, who); else { const before = s.timer.touchpoints.length; diff --git a/app/api/workspaces/[id]/login-info/route.ts b/app/api/workspaces/[id]/login-info/route.ts new file mode 100644 index 00000000..bb4d2251 --- /dev/null +++ b/app/api/workspaces/[id]/login-info/route.ts @@ -0,0 +1,24 @@ +import { connection } from "next/server"; +import { bindWs } from "@/lib/auth"; +import { handle } from "@/lib/http"; +import { HttpError } from "@/lib/store"; +import type { Actor, Owner } from "@/lib/types"; +import { parseCustomWs, workspaceExists } from "@/lib/workspace"; + +const ROLES: { id: Actor; slot: Owner; blurb: string }[] = [ + { id: "leadership", slot: "ceo", blurb: "sets the company goal" }, + { id: "product", slot: "product", blurb: "asks for the change" }, + { id: "security", slot: "security", blurb: "owns the goal being changed" }, +]; + +// Public by design (the sign-in screen needs it): company and team names only, never person names or constraints. +export async function GET(_req: Request, ctx: RouteContext<"/api/workspaces/[id]/login-info">) { + await connection(); + return handle(async () => { + const ws = parseCustomWs((await ctx.params).id); + if (!ws || !workspaceExists(ws)) throw new HttpError(404, "This workspace no longer exists"); + // A read, with no session: bound to this workspace only, never Northstar. + const s = await bindWs({ actor: "leadership", sid: "", ws }).read(); + return { name: s.company, roles: ROLES.map((r) => ({ id: r.id, team: s.teams?.[r.slot]?.team ?? "", blurb: r.blurb })) }; + }); +} diff --git a/app/api/workspaces/config/route.ts b/app/api/workspaces/config/route.ts new file mode 100644 index 00000000..8fbdf073 --- /dev/null +++ b/app/api/workspaces/config/route.ts @@ -0,0 +1,12 @@ +import { connection } from "next/server"; +import { config } from "@/lib/config"; +import { handle } from "@/lib/http"; +import { countWorkspaces } from "@/lib/workspace"; + +export async function GET() { + await connection(); + return handle(async () => { + const cfg = config(); + return { signup_code_required: !cfg.allowOpenCreate, max_reached: (await countWorkspaces()) >= cfg.maxWorkspaces }; + }); +} diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts new file mode 100644 index 00000000..20ccb420 --- /dev/null +++ b/app/api/workspaces/route.ts @@ -0,0 +1,41 @@ +import { timingSafeEqual } from "node:crypto"; +import { config } from "@/lib/config"; +import { handle, requireJson } from "@/lib/http"; +import { takeCreateToken } from "@/lib/ratelimit"; +import { ScenarioInvalid } from "@/lib/scenario"; +import { HttpError } from "@/lib/store"; +import { createWorkspace } from "@/lib/workspace"; +import { initialWorkspaceState, readWorkspaceInput } from "@/lib/workspace-setup"; + +function codeMatches(given: string | null, expected: string | null) { + if (!given || !expected) return false; + const a = Buffer.from(given); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} + +// Create a custom workspace. Nothing existing is replaced. Passcodes are returned once and stored only as hashes. +export async function POST(req: Request) { + return handle(async () => { + requireJson(req); + const cfg = config(); + let parsed: Awaited>; + try { + parsed = await readWorkspaceInput(req); + } catch (e) { + if (e instanceof ScenarioInvalid) return Response.json({ error: e.message, issues: e.issues }, { status: 400 }); + throw e; + } + // Access comes from config only: open create (serve.sh, loopback) or a matching signup code. + if (!cfg.allowOpenCreate && !codeMatches(parsed.signup_code, cfg.signupCode)) { + throw new HttpError(403, cfg.signupCode ? "That signup code isn’t right" : "Workspace creation is closed"); + } + takeCreateToken(); + const state = initialWorkspaceState(parsed.input, parsed.input_sha256); + const ws = await createWorkspace({ name: parsed.input.company, state, scenario: parsed.input }); + return Response.json( + { workspace: { id: ws.id, name: ws.name, path: `/w/${ws.id}` }, passcodes: ws.passcodes, shown_once: true }, + { status: 201, headers: { "cache-control": "no-store" } }, + ); + }); +} diff --git a/lib/auth.ts b/lib/auth.ts index 60c0f85a..38cb359c 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -157,6 +157,13 @@ export function requireWsSession(req: Request): WsContext { return bindWs(s); } +/** For GET /api/state: the caller's workspace, or Northstar when anonymous (as before). */ +export function stateContext(req: Request): { who: WsSession | null } & Pick { + const who = wsSessionFrom(req); + if (who) return { ...bindWs(who), who }; + return { who: null, mutate: (fn) => mutate(fn), read: () => readState() }; +} + export function bindWs(s: WsSession): WsContext { if (s.ws === "northstar") { // Flow functions read only actor and sid, so the extra fields never reach Northstar state. diff --git a/lib/http.ts b/lib/http.ts index d6345a22..02095fdb 100644 --- a/lib/http.ts +++ b/lib/http.ts @@ -44,3 +44,65 @@ export async function body>(req: Request): Promise rejectReserved(x, `${at}[${i}]`)); + if (v && typeof v === "object") + for (const k of Object.keys(v)) { + if (RESERVED_KEYS.has(k)) throw new HttpError(400, `“${k}” is not allowed as a field name (at ${at})`); + rejectReserved((v as Record)[k], `${at}.${k}`); + } +} + +/** + * Read at most `max` bytes (413 beyond that) and parse JSON (400 if invalid, or if any key is __proto__, + * constructor or prototype). An empty body is {}. Used by the workspace routes; body() is unchanged. + */ +export async function cappedJson>(req: Request, max = 64 * 1024, tooLarge = "This request is too large"): Promise { + if (Number(req.headers.get("content-length") || 0) > max) throw new HttpError(413, tooLarge); + const chunks: Uint8Array[] = []; + let size = 0; + if (req.body) { + const reader = req.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > max) { + await reader.cancel(); + throw new HttpError(413, tooLarge); + } + chunks.push(value); + } + } + const text = Buffer.concat(chunks).toString("utf8").trim(); + if (!text) return {} as T; + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new HttpError(400, "Send valid JSON"); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new HttpError(400, "Send a JSON object"); + rejectReserved(raw, "body"); + return raw as T; +} + +/** Per-field caps for custom workspace bodies: every string 300 characters, plan text 4000. */ +export function capFields(v: unknown, at = "body"): void { + if (typeof v === "string") { + const max = /(^|\.)(text|plan_text)$/.test(at) ? 4000 : 300; + if (v.length > max) throw new HttpError(400, `${at.replace(/^body\./, "")} is too long (max ${max} characters)`); + } else if (Array.isArray(v)) v.forEach((x, i) => capFields(x, `${at}[${i}]`)); + else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) capFields(x, `${at}.${k}`); +} + +/** Northstar bodies parse exactly as before (body()); custom workspace bodies are capped at 64 KB and per field. */ +export async function bodyFor>(req: Request, ws: string): Promise { + if (ws === "northstar") return body(req); + const b = await cappedJson(req); + capFields(b); + return b; +} diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts new file mode 100644 index 00000000..adf474f7 --- /dev/null +++ b/lib/ratelimit.ts @@ -0,0 +1,61 @@ +import { HttpError } from "./errors"; + +// In-memory limits for custom workspaces only. Northstar login is never limited (its tests send wrong passcodes). +// Maps live on globalThis, capped at 10,000 keys; the oldest entries are evicted first. + +const MAX_KEYS = 10_000; +const WINDOW_MS = 5 * 60 * 1000; +const FREE_FAILURES = 5; +const MAX_DELAY_MS = 10_000; + +interface Failures { + n: number; + first: number; + last: number; +} + +const g = globalThis as unknown as { __alignLoginFails?: Map; __alignCreates?: number[] }; +const fails = (g.__alignLoginFails ??= new Map()); +const creates = (g.__alignCreates ??= []); + +function evict(now: number) { + for (const [k, f] of fails) if (now - f.last > WINDOW_MS) fails.delete(k); + while (fails.size >= MAX_KEYS) fails.delete(fails.keys().next().value!); +} + +/** Soft backoff: after 5 failures in 5 minutes, each further try must wait a growing delay (1 s doubling, up to 10 s). */ +export function checkLoginBackoff(key: string, now = Date.now()) { + const f = fails.get(key); + if (!f || now - f.last > WINDOW_MS || f.n < FREE_FAILURES) return; + const delay = Math.min(MAX_DELAY_MS, 1000 * 2 ** (f.n - FREE_FAILURES)); + if (now - f.last < delay) throw new HttpError(429, "Too many attempts, try again shortly"); +} + +export function recordLoginFailure(key: string, now = Date.now()) { + const f = fails.get(key); + if (f && now - f.last <= WINDOW_MS) { + f.n++; + f.last = now; + fails.delete(key); // re-insert so eviction order follows recency + fails.set(key, f); + return; + } + evict(now); + fails.set(key, { n: 1, first: now, last: now }); +} + +export function clearLoginFailures(key: string) { + fails.delete(key); +} + +export function loginLimiterSize() { + return fails.size; +} + +/** Global creation bucket: ALIGN_CREATE_PER_HOUR (default 5) workspaces per rolling hour. */ +export function takeCreateToken(now = Date.now()) { + const perHour = Number(process.env.ALIGN_CREATE_PER_HOUR || 5); + while (creates.length && now - creates[0] > 60 * 60 * 1000) creates.shift(); + if (creates.length >= perHour) throw new HttpError(429, "Too many workspaces created recently, try again later"); + creates.push(now); +} diff --git a/lib/store.ts b/lib/store.ts index ff372e6d..58cc605e 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -134,9 +134,10 @@ export async function mutateWs(ws: CustomWsId, fn: (s: AppState) => T | Promi }); } -/** Replace a custom workspace's whole state (Restart). Built by lib/scenario.ts; the store never imports it. */ -export async function replaceWorkspaceState(ws: CustomWsId, next: AppState) { +/** Replace a custom workspace's whole state (Restart) under its lock. The caller builds it; the store never imports scenario.ts. */ +export async function replaceWorkspaceState(ws: CustomWsId, build: (current: AppState) => AppState) { return mutateWs(ws, (s) => { + const next = build(s); for (const k of Object.keys(s)) delete (s as unknown as Record)[k]; Object.assign(s, next); }); diff --git a/lib/workspace-setup.ts b/lib/workspace-setup.ts new file mode 100644 index 00000000..3b1eccef --- /dev/null +++ b/lib/workspace-setup.ts @@ -0,0 +1,49 @@ +import type { AppState } from "./types"; +import type { Session } from "./auth"; +import { cappedJson } from "./http"; +import { buildCustomState, MAX_BODY_BYTES, ScenarioInput, ScenarioInvalid } from "./scenario"; +import { id, now, replaceWorkspaceState, sha256 } from "./store"; +import { readScenarioFile, type CustomWsId } from "./workspace"; + +// Custom workspaces reuse the /setup schema and state builder from lib/scenario.ts unchanged. The body is the +// setup input without `confirm`, plus an optional signup_code. Nothing here touches Northstar state. + +export interface WorkspaceInput { + input: ScenarioInput; + input_sha256: string; + signup_code: string | null; +} + +function validate(raw: Record): { input: ScenarioInput; input_sha256: string } { + const parsed = ScenarioInput.safeParse({ ...raw, confirm: "replace" }); + if (!parsed.success) throw new ScenarioInvalid(parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))); + return { input: parsed.data, input_sha256: sha256(JSON.stringify(parsed.data)) }; +} + +export async function readWorkspaceInput(req: Request): Promise { + const raw = await cappedJson(req, MAX_BODY_BYTES, "This setup is too large. Keep it under 64 KB."); + const { signup_code, confirm, ...rest } = raw; + if (confirm !== undefined) throw new ScenarioInvalid([{ path: "confirm", message: "Not used when creating a workspace" }]); + if (signup_code !== undefined && typeof signup_code !== "string") throw new ScenarioInvalid([{ path: "signup_code", message: "Must be text" }]); + return { ...validate(rest), signup_code: (signup_code as string | undefined) ?? null }; +} + +/** A new workspace starts fresh: no sessions, mappings or simulations, and one audit row with the input hash. */ +export function initialWorkspaceState(input: ScenarioInput, input_sha256: string): AppState { + const at = now(); + const s = buildCustomState(input, at, input_sha256); + s.audit = [{ id: id("a"), actor: "leadership", session_id: "create", action: "workspace_created", target: `sha256:${input_sha256}`, at }]; + return s; +} + +/** Restart: rebuild from the saved setup input. Keeps the audit log; proposals, acceptances and timer start over. */ +export async function resetWorkspace(ws: CustomWsId, who: Session) { + const { confirm: _confirm, ...saved } = (await readScenarioFile(ws)) as Record; + void _confirm; + const { input, input_sha256 } = validate(saved); + await replaceWorkspaceState(ws, (current) => { + const next = buildCustomState(input, now(), input_sha256); + next.audit = [...current.audit, { id: id("a"), actor: who.actor, session_id: who.sid, action: "reset", target: "workspace", at: now() }]; + return next; + }); +} diff --git a/lib/workspace.ts b/lib/workspace.ts index b6b9aeeb..35b6a540 100644 --- a/lib/workspace.ts +++ b/lib/workspace.ts @@ -108,7 +108,7 @@ async function appendEvent(event: "created" | "deleted", id: CustomWsId) { } /** Live workspaces: directories named like an id. Skips _events.jsonl, .tmp-* and .deleting-*. */ -async function countWorkspaces(): Promise { +export async function countWorkspaces(): Promise { try { const names = await fs.readdir(config().workspacesRoot); return names.filter((n) => ID_RE.test(n)).length; From 0509f1669b7b3b01423f89fb96344221ee294a58 Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Date: Wed, 21 Oct 2026 15:07:00 -0700 Subject: [PATCH 03/63] LIM-2424: L0: workspace UI: /w/, sign-in, session ended, Restart Multi-workspace spec rev 2, section 8 (L4 WP8, WP1 Restart), in the cut. Additive in the cut: / and /setup are unchanged. - app/page.tsx moved to components/Home.tsx unchanged apart from a `workspace` prop; app/page.tsx renders , and the new app/w/[slug]/page.tsx renders in a Suspense boundary (cacheComponents). - useAlign(workspace): Me.workspace; a /w page doesn't poll /api/state before sign-in; a session for another workspace (or a custom session on /) counts as signed out; a 401 "Your session ended" from any call returns to sign-in with that message. - /w sign-in uses login-info (company and team names, role blurbs, no person names) and posts the workspace with the passcode. Sign-in screens link to the other workspace this browser is signed in to (server id, regex-checked before it goes into the href). - Leadership in a workspace gets "Restart this scenario" (confirm twice), which rebuilds from the saved setup input. The /setup scenario keeps "Back to the Northstar demo". test-all passes every step; Northstar UI 72/72. Browser check of /w: 7/7 (login names only, no pre-login poll, lead name and Restart after sign-in, no Northstar text, / shows the link, unknown id message). --- app/page.tsx | 467 +-------------------------------- app/w/[slug]/page.tsx | 16 ++ components/Home.tsx | 573 +++++++++++++++++++++++++++++++++++++++++ components/useAlign.ts | 57 +++- 4 files changed, 643 insertions(+), 470 deletions(-) create mode 100644 app/w/[slug]/page.tsx create mode 100644 components/Home.tsx diff --git a/app/page.tsx b/app/page.tsx index 6f2fb56e..99a70a5b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,467 +1,6 @@ -"use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { BriefDrawer } from "@/components/BriefDrawer"; -import { EvidenceView } from "@/components/EvidenceView"; -import { GoalsView } from "@/components/GoalsView"; -import { Inspector } from "@/components/Inspector"; -import type { Drawer, View, ViewProps } from "@/components/props"; -import { ProposalsView } from "@/components/ProposalsView"; -import { ScenarioDrawer } from "@/components/ScenarioDrawer"; -import { TeamLens } from "@/components/TeamLens"; -import { AgentPane } from "@/components/AgentPane"; -import { DecisionView } from "@/components/DecisionView"; -import { OrgView } from "@/components/OrgView"; -import { WorkMap } from "@/components/WorkMap"; -import { activeProposal, currentGoal } from "@/components/select"; -import { Badge, Button } from "@/components/ui"; -import { api, useAlign, type Selection } from "@/components/useAlign"; -import { companyMeasureId, conflictMeasureId, decisionMeasureId, dependencyId, labelsFor, type Labels } from "@/lib/labels"; - -type NavItem = { id: View; label: string; sub: string; icon: string }; -const NAV: NavItem[] = [ - { id: "decision", label: "Next decision", sub: "What needs you", icon: "◆" }, - { id: "goals", label: "Goals", sub: "How they connect", icon: "◎" }, - { id: "proposals", label: "Proposals", sub: "Changes in progress", icon: "✉" }, - { id: "evidence", label: "Evidence", sub: "Decisions and proof", icon: "▤" }, -]; -const NAV_LEADERSHIP: NavItem[] = [ - { id: "company", label: "Company goal", sub: "Set and send", icon: "◆" }, - { id: "goals", label: "Goals", sub: "How they connect", icon: "◎" }, - { id: "work", label: "Work", sub: "Where AI agent work goes", icon: "⌁" }, - { id: "evidence", label: "Evidence", sub: "Decisions and proof", icon: "▤" }, -]; -const TEAM_OF: Record = { product: "Product", security: "Security", leadership: "Leadership" }; -const initials = (name: string) => - name - .split(/[\s/]+/) - .filter(Boolean) - .slice(0, 2) - .map((w) => w[0]?.toUpperCase()) - .join(""); -const ROLES = [ - { id: "leadership", name: "CEO / Leadership", blurb: "Sets the company goal" }, - { id: "product", name: "Product Director", blurb: "Owns the beta launch" }, - { id: "security", name: "Alex / Security Lead", blurb: "Owns how customer issues get handled" }, -] as const; -type Role = (typeof ROLES)[number]["id"]; -// Custom scenarios show only the decision path in Build A; these views stay Northstar-only. -const CUSTOM_HIDDEN: View[] = ["goals", "evidence", "work"]; -const CUSTOM_BLURB: Record = { - leadership: "sets the company goal", - product: "asks for the change", - security: "owns the goal being changed", -}; -// Two-letter avatar from the team name: first letters of two words, else first letter plus next consonant -// ("Data Platform" → DP, "Product" → PR, "Leadership" → LD), so roles never share initials by job title. -const teamInitials = (team: string) => { - const w = team.split(/[\s/]+/).filter(Boolean); - if (w.length >= 2) return (w[0][0] + w[1][0]).toUpperCase(); - const c = w[0]?.slice(1).match(/[b-df-hj-np-tv-z]/i)?.[0] ?? w[0]?.[1] ?? ""; - return ((w[0]?.[0] ?? "") + c).toUpperCase(); -}; -// " lead", except when the team name already says it (a leadership team, or a name ending in "Lead"). -const youAre = (team: string, actor: string) => (actor === "leadership" || /\b(lead|leadership)$/i.test(team) ? team : `${team} lead`); -// Custom shell: below 1100px the page scrolls as a whole and the Inspector rail stacks under the main column, so -// independent testers on 900–1024px laptops never scroll sideways. The header wraps at every width. -const CUSTOM_SHELL = - "grid min-h-screen grid-cols-[236px_minmax(0,1fr)] grid-rows-[auto_auto_auto] min-[1100px]:h-screen min-[1100px]:grid-cols-[236px_minmax(0,1fr)_360px] min-[1100px]:grid-rows-[auto_minmax(0,1fr)] min-[1100px]:overflow-hidden"; -const CUSTOM_SIDEBAR = - "sticky top-0 row-span-3 flex h-screen flex-col self-start border-r border-zinc-200 bg-[var(--al-rail)] px-3 pb-3.5 min-[1100px]:static min-[1100px]:row-span-2 min-[1100px]:h-auto min-[1100px]:self-stretch"; -const CUSTOM_HEADER = "flex min-h-16 min-w-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-zinc-200 px-6 py-2.5 min-[1100px]:col-span-2"; -const CUSTOM_MAIN = "@container relative min-w-0 p-6 text-[16px] min-[1100px]:overflow-y-auto"; -const CUSTOM_RAIL = "min-w-0 border-t border-zinc-200 bg-zinc-50 p-[22px] min-[1100px]:overflow-y-auto min-[1100px]:border-t-0 min-[1100px]:border-l"; -// The company-goal strip stacks its three parts when the main column is narrow. -const CUSTOM_STRIP = - "mb-4 grid grid-cols-1 items-center gap-4 rounded-[14px] border border-zinc-200 bg-white px-5 py-3.5 @[60rem]:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)] @[60rem]:gap-6"; -const CUSTOM_STRIP_CELL = "flex min-w-0 flex-col gap-1 border-t border-zinc-200 pt-3 @[60rem]:border-t-0 @[60rem]:border-l @[60rem]:pl-6 @[60rem]:pt-0"; -// api() falls back to "HTTP " when a response carries no sentence; custom screens never show that. -const plainError = (e: string) => (/^HTTP \d+$/.test(e) ? "Something went wrong. Please try again." : e); - -function Login({ onDone, L }: { onDone: () => void; L?: Labels }) { - const [role, setRole] = useState("product"); - const [passcode, setPasscode] = useState(""); - const [err, setErr] = useState(null); - const [busy, setBusy] = useState(false); - async function submit(e: React.FormEvent) { - e.preventDefault(); - setBusy(true); - setErr(null); - try { - await api("/api/session", { body: { role, passcode } }); - onDone(); - } catch (x) { - setErr(L && !L.northstar ? plainError((x as Error).message) : (x as Error).message); - } finally { - setBusy(false); - } - } - return ( -
-
-
- Align - by Liminal -
- {L && !L.northstar ? ( -

{L.company}. Choose who you are; each person signs in separately.

- ) : ( -

Northstar Security, a made-up company for this demo. Choose who you are; each person signs in separately.

- )} -
- I am -
- {ROLES.map((r) => ( - - ))} -
-
- - {err &&

{err}

} - -
-
- ); -} +import Home from "@/components/Home"; +// The Northstar demo (and any /setup scenario). Custom workspaces render the same app at /w/. export default function Page() { - const { state, me, error, setError, loadError, loadMe, act, mark, refresh } = useAlign(); - const [view, setView] = useState("decision"); - const landed = useRef(null); // the role the landing view was set for; null when signed out - const [drawer, setDrawer] = useState(null); - const [selection, setSelection] = useState(null); - // The attached tested plan survives a reload, so a refreshed tab never sends an UNTESTED proposal by accident. - const [attached, setAttachedState] = useState(() => { - if (typeof window === "undefined") return null; - try { - return JSON.parse(window.sessionStorage.getItem("align.attached") ?? "null"); - } catch { - return null; - } - }); - const setAttached = useCallback((a: ViewProps["attached"]) => { - setAttachedState(a); - if (a) window.sessionStorage.setItem("align.attached", JSON.stringify(a)); - else window.sessionStorage.removeItem("align.attached"); - }, []); - const [confirmReset, setConfirmReset] = useState(false); - - const closeDrawer = useCallback(() => setDrawer(null), []); - useEffect(() => { - if (!error) return; - const t = setTimeout(() => setError(null), 6000); - return () => clearTimeout(t); - }, [error, setError]); - - useEffect(() => { - // Landing view once per sign-in, so a tester who signs out and back in as another role in the same window - // lands on that role's view. Guarded by the ref, so it can't cascade. - const actor = me?.actor ?? null; - if (actor === landed.current) return; - landed.current = actor; - // eslint-disable-next-line react-hooks/set-state-in-effect - if (actor) setView(actor === "leadership" ? "company" : "decision"); - }, [me]); - - if (!me) return
Loading Align…
; - if (!me.actor) return { loadMe(); refresh(); }} />; - if (!state) - return ( -
- {loadError ? Couldn’t load the workspace: {loadError} : Loading workspace…} -
- ); - - const L = labelsFor(state); - const custom = !L.northstar; - const props: ViewProps = { - s: state, - me, - selection, - select: setSelection, - go: (v) => { - setDrawer(null); - setView(v); - }, - openDrawer: setDrawer, - act, - mark, - attached, - setAttached, - }; - const pendingForMe = state.proposals.filter((p) => - me.actor === "security" ? ["PENDING_SECURITY", "ACKNOWLEDGED_BY_PRODUCT"].includes(p.state) : p.state === "COUNTERED", - ).length; - const rel = state.relations.find((r) => r.id === dependencyId(state)); - const unresolved = rel?.status === "UNKNOWN"; - const lastPush = state.pushes?.at(-1); - const ceo = currentGoal(state, "ceo"); - const nav = (me.actor === "leadership" ? NAV_LEADERSHIP : NAV) - .filter((n) => !custom || !CUSTOM_HIDDEN.includes(n.id)) - .map((n) => (custom && n.id === "company" ? { ...n, sub: "Shared with both teams" } : n)); - const openProposal = activeProposal(state); - const proposalOpen = openProposal && !["RATIFIED", "REJECTED"].includes(openProposal.state); - const decisionCount = pendingForMe + (me.actor === "product" && unresolved && !proposalOpen ? 1 : 0); - const counts: Partial> = { decision: decisionCount, proposals: pendingForMe }; - const myGoal = me.actor === "product" || me.actor === "security" ? currentGoal(state, me.actor) : undefined; - const kr2 = ceo?.measures.find((m) => m.id === companyMeasureId(state)); - // Custom: the goal's current target. An approval swaps the owner's measure, not its objective. - const lastAcc = state.acceptances.at(-1); - const myTarget = custom && myGoal - ? (lastAcc?.new_goal_id === myGoal.id ? myGoal.measures.find((m) => m.id === decisionMeasureId(state, lastAcc)) : undefined) ?? - myGoal.measures.find((m) => m.id === conflictMeasureId(state)) ?? - myGoal.measures[0] - : undefined; - // Custom scenarios have no live agent, so the rail always shows the Inspector. - const focus = !custom && (view === "decision" || view === "today"); - const alikeOk = state.brief?.alike_status === "retrieved"; - const ops = typeof window !== "undefined" && new URLSearchParams(window.location.search).has("ops"); - - async function signOut() { - await api("/api/session", { method: "DELETE" }); - loadMe(); - } - async function reset() { - if (!confirmReset) { - setConfirmReset(true); - setTimeout(() => setConfirmReset(false), 4000); - return; - } - setConfirmReset(false); - await act("/api/reset", { confirm: "reset" }); - setSelection(null); - setAttached(null); - setView(me?.actor === "leadership" ? "company" : "decision"); - } - - return ( -
- {/* Sidebar */} - - - {/* Top bar */} -
-
- {state.company} · {state.timeframe} -
- {custom && ( - - You are: {youAre(L.role[me.actor], me.actor)} - - )} - {custom ? ( - rel?.status === "ACCEPTED" ? ( - - {L.team.product} and {L.team.security} agreed - - ) : unresolved && me.actor !== "leadership" ? ( - - ▲ New company goal · {L.team.product} and {L.team.security} haven’t agreed yet - - ) : null - ) : unresolved && lastPush && me.actor !== "leadership" ? ( - - ) : lastPush && rel?.status === "ACCEPTED" ? ( - Company goal {lastPush.version} · Product and Security agreed - ) : lastPush ? ( - Company goal {lastPush.version} sent {new Date(lastPush.pushed_at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} - ) : null} -
- {ops && !custom && ( - - )} - {ops && custom && me.actor === "leadership" && ( - - )} - - {custom ? teamInitials(L.role[me.actor]) : initials(me.name ?? "")} - -
-
{custom ? L.person[me.actor] : me.name}
- {custom ? ( -
- {L.role[me.actor]} · {CUSTOM_BLURB[me.actor]} -
- ) : ( -
{TEAM_OF[me.actor] ?? ""} · Owner
- )} -
- -
-
- - {/* Canvas */} -
- {loadError && ( -
- ▲ Lost connection to the server ({loadError}). Showing the last update; reconnecting automatically. -
- )} - {L.sandbox && state.ingest_errors && state.ingest_errors.length > 0 && ( -
- ▲ Some practice-test results couldn’t be read and aren’t shown: {state.ingest_errors.join("; ")} -
- )} - {error && ( -
- {custom ? plainError(error) : error} -
- )} - {me.actor !== "leadership" && ceo && ( -
-
- ◎ -
-
- Company goal - {state.timeframe} - {ceo.changed && Changed today} -
-
{ceo.objective}
-
-
-
- {custom ? ( - The new company requirement - ) : ( - The CEO's new requirement - )} - {kr2 ? kr2.text : "No new requirement yet"} - {kr2 && rel && (custom ? ( - - {L.team.product} and {L.team.security}: {rel.status === "ACCEPTED" ? "agreed" : rel.status === "UNKNOWN" ? "not agreed yet" : "proposal in progress"} - - ) : Product and Security: {rel.status === "ACCEPTED" ? "agreed" : rel.status === "UNKNOWN" ? "not agreed yet" : "proposal in progress"})} -
-
- Your goal{myGoal ? ` · ${myGoal.version}` : ""} - {myGoal?.objective ?? "Unknown"} - {custom && myTarget && Target: {myTarget.text}} -
-
- )} - {view === "decision" && } - {view === "today" && } - {view === "company" && } - {view === "work" && !custom && } - {view === "goals" && !custom && } - {view === "team" && } - {view === "proposals" && } - {view === "evidence" && !custom && } - - {drawer && !custom && ( -
-
e.stopPropagation()} - role="dialog" - aria-modal="true" - > - {drawer === "brief" && } - {drawer === "scenario" && } -
-
- )} -
- - {/* Right rail: the live AI-agent pane on the decision screen, the inspector everywhere else */} - -
- ); + return ; } diff --git a/app/w/[slug]/page.tsx b/app/w/[slug]/page.tsx new file mode 100644 index 00000000..d671901b --- /dev/null +++ b/app/w/[slug]/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from "react"; +import Home from "@/components/Home"; + +// A custom workspace. The id in the URL is a convenience, not access control: the passcodes are. +export default function Page({ params }: PageProps<"/w/[slug]">) { + return ( + Loading Align…}> + + + ); +} + +async function Workspace({ params }: { params: PageProps<"/w/[slug]">["params"] }) { + const { slug } = await params; + return ; +} diff --git a/components/Home.tsx b/components/Home.tsx new file mode 100644 index 00000000..d1e14ad2 --- /dev/null +++ b/components/Home.tsx @@ -0,0 +1,573 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { BriefDrawer } from "@/components/BriefDrawer"; +import { EvidenceView } from "@/components/EvidenceView"; +import { GoalsView } from "@/components/GoalsView"; +import { Inspector } from "@/components/Inspector"; +import type { Drawer, View, ViewProps } from "@/components/props"; +import { ProposalsView } from "@/components/ProposalsView"; +import { ScenarioDrawer } from "@/components/ScenarioDrawer"; +import { TeamLens } from "@/components/TeamLens"; +import { AgentPane } from "@/components/AgentPane"; +import { DecisionView } from "@/components/DecisionView"; +import { OrgView } from "@/components/OrgView"; +import { WorkMap } from "@/components/WorkMap"; +import { activeProposal, currentGoal } from "@/components/select"; +import { Badge, Button } from "@/components/ui"; +import { api, SESSION_ENDED, useAlign, type Selection } from "@/components/useAlign"; +import { companyMeasureId, conflictMeasureId, decisionMeasureId, dependencyId, labelsFor, type Labels } from "@/lib/labels"; + +type NavItem = { id: View; label: string; sub: string; icon: string }; +const NAV: NavItem[] = [ + { id: "decision", label: "Next decision", sub: "What needs you", icon: "◆" }, + { id: "goals", label: "Goals", sub: "How they connect", icon: "◎" }, + { id: "proposals", label: "Proposals", sub: "Changes in progress", icon: "✉" }, + { id: "evidence", label: "Evidence", sub: "Decisions and proof", icon: "▤" }, +]; +const NAV_LEADERSHIP: NavItem[] = [ + { id: "company", label: "Company goal", sub: "Set and send", icon: "◆" }, + { id: "goals", label: "Goals", sub: "How they connect", icon: "◎" }, + { id: "work", label: "Work", sub: "Where AI agent work goes", icon: "⌁" }, + { id: "evidence", label: "Evidence", sub: "Decisions and proof", icon: "▤" }, +]; +const TEAM_OF: Record = { product: "Product", security: "Security", leadership: "Leadership" }; +const initials = (name: string) => + name + .split(/[\s/]+/) + .filter(Boolean) + .slice(0, 2) + .map((w) => w[0]?.toUpperCase()) + .join(""); +const ROLES = [ + { id: "leadership", name: "CEO / Leadership", blurb: "Sets the company goal" }, + { id: "product", name: "Product Director", blurb: "Owns the beta launch" }, + { id: "security", name: "Alex / Security Lead", blurb: "Owns how customer issues get handled" }, +] as const; +type Role = (typeof ROLES)[number]["id"]; +// Custom scenarios show only the decision path in Build A; these views stay Northstar-only. +const CUSTOM_HIDDEN: View[] = ["goals", "evidence", "work"]; +const CUSTOM_BLURB: Record = { + leadership: "sets the company goal", + product: "asks for the change", + security: "owns the goal being changed", +}; +// Two-letter avatar from the team name: first letters of two words, else first letter plus next consonant +// ("Data Platform" → DP, "Product" → PR, "Leadership" → LD), so roles never share initials by job title. +const teamInitials = (team: string) => { + const w = team.split(/[\s/]+/).filter(Boolean); + if (w.length >= 2) return (w[0][0] + w[1][0]).toUpperCase(); + const c = w[0]?.slice(1).match(/[b-df-hj-np-tv-z]/i)?.[0] ?? w[0]?.[1] ?? ""; + return ((w[0]?.[0] ?? "") + c).toUpperCase(); +}; +// " lead", except when the team name already says it (a leadership team, or a name ending in "Lead"). +const youAre = (team: string, actor: string) => (actor === "leadership" || /\b(lead|leadership)$/i.test(team) ? team : `${team} lead`); +// Custom shell: below 1100px the page scrolls as a whole and the Inspector rail stacks under the main column, so +// independent testers on 900–1024px laptops never scroll sideways. The header wraps at every width. +const CUSTOM_SHELL = + "grid min-h-screen grid-cols-[236px_minmax(0,1fr)] grid-rows-[auto_auto_auto] min-[1100px]:h-screen min-[1100px]:grid-cols-[236px_minmax(0,1fr)_360px] min-[1100px]:grid-rows-[auto_minmax(0,1fr)] min-[1100px]:overflow-hidden"; +const CUSTOM_SIDEBAR = + "sticky top-0 row-span-3 flex h-screen flex-col self-start border-r border-zinc-200 bg-[var(--al-rail)] px-3 pb-3.5 min-[1100px]:static min-[1100px]:row-span-2 min-[1100px]:h-auto min-[1100px]:self-stretch"; +const CUSTOM_HEADER = "flex min-h-16 min-w-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-zinc-200 px-6 py-2.5 min-[1100px]:col-span-2"; +const CUSTOM_MAIN = "@container relative min-w-0 p-6 text-[16px] min-[1100px]:overflow-y-auto"; +const CUSTOM_RAIL = "min-w-0 border-t border-zinc-200 bg-zinc-50 p-[22px] min-[1100px]:overflow-y-auto min-[1100px]:border-t-0 min-[1100px]:border-l"; +// The company-goal strip stacks its three parts when the main column is narrow. +const CUSTOM_STRIP = + "mb-4 grid grid-cols-1 items-center gap-4 rounded-[14px] border border-zinc-200 bg-white px-5 py-3.5 @[60rem]:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1fr)] @[60rem]:gap-6"; +const CUSTOM_STRIP_CELL = "flex min-w-0 flex-col gap-1 border-t border-zinc-200 pt-3 @[60rem]:border-t-0 @[60rem]:border-l @[60rem]:pl-6 @[60rem]:pt-0"; +// api() falls back to "HTTP " when a response carries no sentence; custom screens never show that. +const plainError = (e: string) => (/^HTTP \d+$/.test(e) ? "Something went wrong. Please try again." : e); + +/** What /api/workspaces//login-info returns: company and team names only, never person names. */ +interface LoginInfo { + name: string; + roles: { id: Role; team: string; blurb: string }[]; +} +const WS_ID = /^w_[a-z2-7]{12}$/; + +function Login({ onDone, L, workspace, ended, elsewhere }: { onDone: () => void; L?: Labels; workspace?: string; ended?: boolean; elsewhere?: string | null }) { + if (workspace) return ; + return ; +} + +/** Shown on a sign-in screen when this browser holds a session for a different custom workspace. */ +function Elsewhere({ id }: { id: string | null | undefined }) { + if (!id || !WS_ID.test(id)) return null; + return ( +

+ This browser is signed in to another workspace.{" "} + + Open that workspace + + . A browser holds one workspace session at a time. +

+ ); +} + +function WorkspaceLogin({ workspace, onDone, ended, elsewhere }: { workspace: string; onDone: () => void; ended?: boolean; elsewhere?: string | null }) { + const [info, setInfo] = useState(null); + const [missing, setMissing] = useState(false); + const [role, setRole] = useState("product"); + const [passcode, setPasscode] = useState(""); + const [err, setErr] = useState(ended ? SESSION_ENDED : null); + const [busy, setBusy] = useState(false); + useEffect(() => { + api(`/api/workspaces/${encodeURIComponent(workspace)}/login-info`) + .then(setInfo) + .catch(() => setMissing(true)); + }, [workspace]); + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setErr(null); + try { + await api("/api/session", { body: { role, passcode, workspace } }); + onDone(); + } catch (x) { + setErr(plainError((x as Error).message)); + } finally { + setBusy(false); + } + } + if (missing) + return ( +
+

This workspace no longer exists.

+
+ ); + if (!info) return
Loading Align…
; + return ( +
+
+
+ Align + by Liminal +
+

{info.name}. Choose your team; each person signs in separately with their own passcode.

+ +
+ I am on +
+ {info.roles.map((r) => ( + + ))} +
+
+ + {err &&

{err}

} + + +
+ ); +} + +function NorthstarLogin({ onDone, L, elsewhere }: { onDone: () => void; L?: Labels; elsewhere?: string | null }) { + const [role, setRole] = useState("product"); + const [passcode, setPasscode] = useState(""); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setErr(null); + try { + await api("/api/session", { body: { role, passcode } }); + onDone(); + } catch (x) { + setErr(L && !L.northstar ? plainError((x as Error).message) : (x as Error).message); + } finally { + setBusy(false); + } + } + return ( +
+
+
+ Align + by Liminal +
+ {L && !L.northstar ? ( +

{L.company}. Choose who you are; each person signs in separately.

+ ) : ( +

Northstar Security, a made-up company for this demo. Choose who you are; each person signs in separately.

+ )} + +
+ I am +
+ {ROLES.map((r) => ( + + ))} +
+
+ + {err &&

{err}

} + + +
+ ); +} + +/** The app. `workspace` is the /w/ slug for a custom workspace; omitted for Northstar at /. */ +export default function Home({ workspace }: { workspace?: string }) { + const { state, me, error, setError, loadError, loadMe, act, mark, refresh, ended, elsewhere } = useAlign(workspace); + const [view, setView] = useState("decision"); + const landed = useRef(null); // the role the landing view was set for; null when signed out + const [drawer, setDrawer] = useState(null); + const [selection, setSelection] = useState(null); + // The attached tested plan survives a reload, so a refreshed tab never sends an UNTESTED proposal by accident. + const [attached, setAttachedState] = useState(() => { + if (typeof window === "undefined") return null; + try { + return JSON.parse(window.sessionStorage.getItem("align.attached") ?? "null"); + } catch { + return null; + } + }); + const setAttached = useCallback((a: ViewProps["attached"]) => { + setAttachedState(a); + if (a) window.sessionStorage.setItem("align.attached", JSON.stringify(a)); + else window.sessionStorage.removeItem("align.attached"); + }, []); + const [confirmReset, setConfirmReset] = useState(false); + + const closeDrawer = useCallback(() => setDrawer(null), []); + useEffect(() => { + if (!error) return; + const t = setTimeout(() => setError(null), 6000); + return () => clearTimeout(t); + }, [error, setError]); + + useEffect(() => { + // Landing view once per sign-in, so a tester who signs out and back in as another role in the same window + // lands on that role's view. Guarded by the ref, so it can't cascade. + const actor = me?.actor ?? null; + if (actor === landed.current) return; + landed.current = actor; + // eslint-disable-next-line react-hooks/set-state-in-effect + if (actor) setView(actor === "leadership" ? "company" : "decision"); + }, [me]); + + if (!me) return
Loading Align…
; + if (!me.actor) + return { loadMe(); refresh(); }} />; + if (!state) + return ( +
+ {loadError ? Couldn’t load the workspace: {loadError} : Loading workspace…} +
+ ); + + const L = labelsFor(state); + const custom = !L.northstar; + const props: ViewProps = { + s: state, + me, + selection, + select: setSelection, + go: (v) => { + setDrawer(null); + setView(v); + }, + openDrawer: setDrawer, + act, + mark, + attached, + setAttached, + }; + const pendingForMe = state.proposals.filter((p) => + me.actor === "security" ? ["PENDING_SECURITY", "ACKNOWLEDGED_BY_PRODUCT"].includes(p.state) : p.state === "COUNTERED", + ).length; + const rel = state.relations.find((r) => r.id === dependencyId(state)); + const unresolved = rel?.status === "UNKNOWN"; + const lastPush = state.pushes?.at(-1); + const ceo = currentGoal(state, "ceo"); + const nav = (me.actor === "leadership" ? NAV_LEADERSHIP : NAV) + .filter((n) => !custom || !CUSTOM_HIDDEN.includes(n.id)) + .map((n) => (custom && n.id === "company" ? { ...n, sub: "Shared with both teams" } : n)); + const openProposal = activeProposal(state); + const proposalOpen = openProposal && !["RATIFIED", "REJECTED"].includes(openProposal.state); + const decisionCount = pendingForMe + (me.actor === "product" && unresolved && !proposalOpen ? 1 : 0); + const counts: Partial> = { decision: decisionCount, proposals: pendingForMe }; + const myGoal = me.actor === "product" || me.actor === "security" ? currentGoal(state, me.actor) : undefined; + const kr2 = ceo?.measures.find((m) => m.id === companyMeasureId(state)); + // Custom: the goal's current target. An approval swaps the owner's measure, not its objective. + const lastAcc = state.acceptances.at(-1); + const myTarget = custom && myGoal + ? (lastAcc?.new_goal_id === myGoal.id ? myGoal.measures.find((m) => m.id === decisionMeasureId(state, lastAcc)) : undefined) ?? + myGoal.measures.find((m) => m.id === conflictMeasureId(state)) ?? + myGoal.measures[0] + : undefined; + // Custom scenarios have no live agent, so the rail always shows the Inspector. + const focus = !custom && (view === "decision" || view === "today"); + const alikeOk = state.brief?.alike_status === "retrieved"; + const ops = typeof window !== "undefined" && new URLSearchParams(window.location.search).has("ops"); + + async function signOut() { + await api("/api/session", { method: "DELETE" }); + loadMe(); + } + async function reset() { + if (!confirmReset) { + setConfirmReset(true); + setTimeout(() => setConfirmReset(false), 4000); + return; + } + setConfirmReset(false); + await act("/api/reset", { confirm: "reset" }); + setSelection(null); + setAttached(null); + setView(me?.actor === "leadership" ? "company" : "decision"); + } + + return ( +
+ {/* Sidebar */} + + + {/* Top bar */} +
+
+ {state.company} · {state.timeframe} +
+ {custom && ( + + You are: {youAre(L.role[me.actor], me.actor)} + + )} + {custom ? ( + rel?.status === "ACCEPTED" ? ( + + {L.team.product} and {L.team.security} agreed + + ) : unresolved && me.actor !== "leadership" ? ( + + ▲ New company goal · {L.team.product} and {L.team.security} haven’t agreed yet + + ) : null + ) : unresolved && lastPush && me.actor !== "leadership" ? ( + + ) : lastPush && rel?.status === "ACCEPTED" ? ( + Company goal {lastPush.version} · Product and Security agreed + ) : lastPush ? ( + Company goal {lastPush.version} sent {new Date(lastPush.pushed_at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} + ) : null} +
+ {ops && !custom && ( + + )} + {workspace && me.actor === "leadership" && ( + + )} + {ops && custom && !workspace && me.actor === "leadership" && ( + + )} + + {custom ? teamInitials(L.role[me.actor]) : initials(me.name ?? "")} + +
+
{custom ? L.person[me.actor] : me.name}
+ {custom ? ( +
+ {L.role[me.actor]} · {CUSTOM_BLURB[me.actor]} +
+ ) : ( +
{TEAM_OF[me.actor] ?? ""} · Owner
+ )} +
+ +
+
+ + {/* Canvas */} +
+ {loadError && ( +
+ ▲ Lost connection to the server ({loadError}). Showing the last update; reconnecting automatically. +
+ )} + {L.sandbox && state.ingest_errors && state.ingest_errors.length > 0 && ( +
+ ▲ Some practice-test results couldn’t be read and aren’t shown: {state.ingest_errors.join("; ")} +
+ )} + {error && ( +
+ {custom ? plainError(error) : error} +
+ )} + {me.actor !== "leadership" && ceo && ( +
+
+ ◎ +
+
+ Company goal + {state.timeframe} + {ceo.changed && Changed today} +
+
{ceo.objective}
+
+
+
+ {custom ? ( + The new company requirement + ) : ( + The CEO's new requirement + )} + {kr2 ? kr2.text : "No new requirement yet"} + {kr2 && rel && (custom ? ( + + {L.team.product} and {L.team.security}: {rel.status === "ACCEPTED" ? "agreed" : rel.status === "UNKNOWN" ? "not agreed yet" : "proposal in progress"} + + ) : Product and Security: {rel.status === "ACCEPTED" ? "agreed" : rel.status === "UNKNOWN" ? "not agreed yet" : "proposal in progress"})} +
+
+ Your goal{myGoal ? ` · ${myGoal.version}` : ""} + {myGoal?.objective ?? "Unknown"} + {custom && myTarget && Target: {myTarget.text}} +
+
+ )} + {view === "decision" && } + {view === "today" && } + {view === "company" && } + {view === "work" && !custom && } + {view === "goals" && !custom && } + {view === "team" && } + {view === "proposals" && } + {view === "evidence" && !custom && } + + {drawer && !custom && ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + > + {drawer === "brief" && } + {drawer === "scenario" && } +
+
+ )} +
+ + {/* Right rail: the live AI-agent pane on the decision screen, the inspector everywhere else */} + +
+ ); +} diff --git a/components/useAlign.ts b/components/useAlign.ts index 0bfc2303..35b0a4d1 100644 --- a/components/useAlign.ts +++ b/components/useAlign.ts @@ -13,8 +13,13 @@ export interface Me { actor: Actor | null; name: string | null; sid: string | null; + workspace?: string; // custom workspace sessions only } +export const SESSION_ENDED = "Your session ended. Sign in again."; +const SIGNED_OUT: Me = { actor: null, name: null, sid: null }; +const isEnded = (e: unknown) => (e as { status?: number }).status === 401 && (e as Error).message === SESSION_ENDED; + export async function api(path: string, init?: { method?: string; body?: unknown }): Promise { const res = await fetch(path, { method: init?.method ?? (init?.body ? "POST" : "GET"), @@ -27,24 +32,63 @@ export async function api(path: string, init?: { method?: string; b return data as T; } -export function useAlign() { +/** + * `workspace` is the /w/ slug; omit it for the Northstar app at /. A session for a different workspace (or a + * custom session on /) counts as signed out here, and a /w page never polls /api/state before sign-in. + */ +export function useAlign(workspace?: string) { const [state, setState] = useState(null); const [me, setMe] = useState(null); const [error, setError] = useState(null); const [loadError, setLoadError] = useState(null); + const [ended, setEnded] = useState(false); // a custom session expired or was revoked + const [elsewhere, setElsewhere] = useState(null); // the workspace this browser is signed in to instead const timer = useRef | null>(null); + const live = useRef(!workspace); // may this page poll /api/state now? + + const endSession = useCallback(() => { + live.current = !workspace; + setState(null); + setMe(SIGNED_OUT); + setEnded(true); + }, [workspace]); const refresh = useCallback(async () => { + if (!live.current) return; try { const s = await api("/api/state"); + if (!live.current) return; setState(s); setLoadError(null); } catch (e) { + if (isEnded(e)) return endSession(); setLoadError((e as Error).message); } - }, []); + }, [endSession]); - const loadMe = useCallback(async () => setMe(await api("/api/session")), []); + const loadMe = useCallback(async () => { + let m: Me; + try { + m = await api("/api/session"); + } catch (e) { + if (isEnded(e)) return endSession(); + throw e; + } + if (m.actor && m.workspace !== workspace) { + live.current = false; + setState(null); + setElsewhere(m.workspace ?? "northstar"); + setMe(SIGNED_OUT); + return; + } + setElsewhere(null); + if (m.actor) setEnded(false); + const wasLive = live.current; + live.current = !workspace || !!m.actor; + if (!live.current) setState(null); + setMe(m); + if (live.current && !wasLive) refresh(); + }, [workspace, endSession, refresh]); useEffect(() => { // Subscribes to the server: state is set when the fetches resolve, not synchronously. @@ -65,16 +109,17 @@ export function useAlign() { await refresh(); return out; } catch (e) { - setError((e as Error).message); + if (isEnded(e)) endSession(); + else setError((e as Error).message); return null; } }, - [refresh], + [refresh, endSession], ); const mark = useCallback((action: string) => { api("/api/timer", { body: { action } }).catch(() => undefined); }, []); - return { state, me, error, setError, loadError, refresh, loadMe, act, mark }; + return { state, me, error, setError, loadError, refresh, loadMe, act, mark, ended, elsewhere }; } From 50c6537a7d4d58ea927a9bb48621471a11627635 Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Date: Wed, 21 Oct 2026 15:11:51 -0700 Subject: [PATCH 04/63] LIM-2424: L0: test-tenancy (groups A to C) in test-all Multi-workspace spec rev 2, section 11, in the cut (minus 15's limiter cap and 18). scripts/test-tenancy.mjs starts its own dev servers on the port, one per config phase (creation closed; signup code with cap 3; open create), with custom workspaces in a temp ALIGN_DATA_DIR. 56 checks: - A: marker isolation; workspace in body/query/header ignored; login-info names only; tampered, swapped-MAC, expired and epoch-bumped tokens; uniform 401 for bad workspace values with nothing written; Northstar-only routes 403 for custom tokens; custom flow leaves Northstar byte-identical and the reverse; 20+20 parallel writes; deleted workspace (rename aside then remove, since DELETE is outside the cut) fails its tokens and is not recreated; corrupt state 500 with the file unchanged; no plaintext passcodes, 0600 files, never served; a captured Northstar session does not reach a new workspace; static import check. - B: closed, wrong code, cap race (exactly one of two), 415, foreign Origin, 413, __proto__, forged Host 421; login 415; Northstar login unlimited; custom backoff and recovery; body and field caps; a custom accept emits no authority event. - C: the Northstar API + MCP snapshot is identical with a workspace on disk. Also Restart (leadership only, audit kept). test-all runs it last, after stopping its own server: every step passes. --- scripts/test-all.sh | 7 +- scripts/test-tenancy.mjs | 444 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 scripts/test-tenancy.mjs diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 531fa381..269361fb 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -2,7 +2,8 @@ # Build A gate runner. Usage: scripts/test-all.sh (run from a lane or integration worktree) # Checks frozen files, lint and types, starts a fresh dev server on , then runs in fixed order: # snapshot, test-flow, test-scenario, test-mcp, test-capture, test-mapper. Capture and mapper add sessions -# that survive reset, so they run last. One summary line per step; exits non-zero if any step fails. +# that survive reset, so they run last. Then test-tenancy, which starts its own servers on . +# One summary line per step; exits non-zero if any step fails. set -u PORT="${1:-}" @@ -63,6 +64,10 @@ step test-scenario node scripts/test-scenario.mjs "$BASE" step test-mcp env ALIGN_MCP_TOKEN="$TOKEN" node scripts/test-mcp.mjs "$BASE/api/mcp" step test-capture node scripts/test-capture.mjs "$BASE" step test-mapper env ALIGN_URL="$BASE" node scripts/test-mapper.mjs +# Multi-workspace tests need their own server configs (creation closed, signup code, open create). +stop_server +SERVER="" +step test-tenancy node scripts/test-tenancy.mjs "$PORT" echo if [ "$FAILS" -eq 0 ]; then echo "test-all: all steps passed (logs in $LOGDIR)"; else echo "test-all: $FAILS step(s) failed (logs in $LOGDIR)"; fi diff --git a/scripts/test-tenancy.mjs b/scripts/test-tenancy.mjs new file mode 100644 index 00000000..5a25cd13 --- /dev/null +++ b/scripts/test-tenancy.mjs @@ -0,0 +1,444 @@ +#!/usr/bin/env node +// Multi-workspace tenancy tests (spec rev 2 section 11, groups A to C, Mode L). Starts its own dev servers on , +// one per config phase, with custom workspaces in a temp ALIGN_DATA_DIR. Northstar stays in ./data, as in test-all. +// Usage: node scripts/test-tenancy.mjs (from a worktree root; refuses 3100 and a busy port) +// phase "closed": no create config -> creation closed +// phase "code": signup code, cap 3 -> wrong code, cap race +// phase "open": open create (serve.sh mode) -> everything else, plus the Northstar snapshot with workspaces on disk + +import { spawn, spawnSync } from "node:child_process"; +import { createHmac } from "node:crypto"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +const PORT = Number(process.argv[2]); +if (!Number.isInteger(PORT) || PORT === 3100) { + console.error("usage: node scripts/test-tenancy.mjs (not 3100)"); + process.exit(2); +} +const BASE = `http://127.0.0.1:${PORT}`; +const ROOT = process.cwd(); +const FIXTURE = JSON.parse(readFileSync(path.join(ROOT, "data/scenarios/example-custom.json"), "utf8")); +delete FIXTURE.confirm; +const NS_STATE = path.join(ROOT, "data/state.json"); +const ENDED = "Your session ended. Sign in again."; +const WRONG = "Wrong passcode for this role"; + +let pass = 0; +let fail = 0; +function check(name, ok, detail = "") { + if (ok) pass++; + else fail++; + console.log(`${ok ? "PASS" : "FAIL"} ${name}${ok ? "" : ` ${String(detail).slice(0, 300)}`}`); +} + +// ---- HTTP ---- +async function call(method, p, { body, cookie, headers = {}, raw } = {}) { + const h = { ...headers }; + if ((body !== undefined || raw !== undefined) && !h["content-type"]) h["content-type"] = "application/json"; + if (cookie) h.cookie = cookie; + const res = await fetch(BASE + p, { method, headers: h, body: raw ?? (body === undefined ? undefined : JSON.stringify(body)) }); + const text = await res.text(); + let json = null; + try { + json = JSON.parse(text); + } catch {} + return { status: res.status, json, text, cookie: (res.headers.get("set-cookie") || "").split(";")[0] }; +} + +/** fetch() won't send a forged Host; node:http will. */ +function withHost(method, p, host, body) { + return new Promise((resolve, reject) => { + const data = body === undefined ? undefined : JSON.stringify(body); + const req = http.request( + { host: "127.0.0.1", port: PORT, method, path: p, headers: { host, ...(data ? { "content-type": "application/json", "content-length": Buffer.byteLength(data) } : {}) } }, + (res) => { + let t = ""; + res.on("data", (c) => (t += c)); + res.on("end", () => resolve({ status: res.statusCode, text: t })); + }, + ); + req.on("error", reject); + if (data) req.write(data); + req.end(); + }); +} + +const create = (body, extra = {}) => call("POST", "/api/workspaces", { body, ...extra }); +async function signIn(ws, passcodes) { + const out = {}; + for (const role of ["leadership", "product", "security"]) { + const r = await call("POST", "/api/session", { body: { workspace: ws, role, passcode: passcodes[role] } }); + if (r.status !== 200) throw new Error(`sign in ${ws} ${role}: ${r.status} ${r.text}`); + out[role] = r.cookie; + out[`${role}_sid`] = r.json.sid; + } + return out; +} +async function northstarSignIn() { + const out = {}; + for (const role of ["leadership", "product", "security"]) { + const r = await call("POST", "/api/session", { body: { role, passcode: `${role}-demo` } }); + out[role] = r.cookie; + } + return out; +} + +/** The fixture with a marker appended to every text field, so leaks are detectable. */ +function marked(m) { + const t = (s) => `${s} ${m}`; + const f = structuredClone(FIXTURE); + f.company = t(f.company); + f.leadership = { team: t(f.leadership.team), lead: t(f.leadership.lead) }; + f.company_goal = { objective: t(f.company_goal.objective), measures: f.company_goal.measures.map(t) }; + for (const side of ["asking", "owning"]) f[side] = { ...f[side], team: t(f[side].team), lead: t(f[side].lead), objective: t(f[side].objective), measures: f[side].measures.map(t) }; + f.dependency = t(f.dependency); + f.work_items = f.work_items.map((w) => ({ ...w, task: t(w.task) })); + f.plan = { title: t(f.plan.title), text: t(f.plan.text) }; + return f; +} + +// ---- files ---- +const fileHash = (f) => (existsSync(f) ? createHmac("sha256", "k").update(readFileSync(f)).digest("hex") : "absent"); +function treeHash(dir) { + if (!existsSync(dir)) return "absent"; + const h = createHmac("sha256", "k"); + const walk = (d) => { + for (const n of readdirSync(d).sort()) { + const p = path.join(d, n); + if (statSync(p).isDirectory()) walk(p); + else h.update(p).update(readFileSync(p)); + } + }; + walk(dir); + return h.digest("hex"); +} +function allText(dir) { + let out = ""; + const walk = (d) => { + for (const n of readdirSync(d)) { + const p = path.join(d, n); + if (statSync(p).isDirectory()) walk(p); + else out += readFileSync(p, "utf8"); + } + }; + if (existsSync(dir)) walk(dir); + return out; +} + +/** Top-level keys whose JSON differs: failure detail only. */ +function changedKeys(a, b) { + const x = JSON.parse(a); + const y = JSON.parse(b); + return [...new Set([...Object.keys(x), ...Object.keys(y)])].filter((k) => JSON.stringify(x[k]) !== JSON.stringify(y[k])).join(",") || "(formatting only)"; +} + +// ---- servers ---- +let server = null; +async function startServer(env) { + rmSync(NS_STATE, { force: true }); + rmSync(path.join(ROOT, "data/.secret"), { force: true }); + const clean = { ...process.env }; + for (const k of ["AI_GATEWAY_API_KEY", "ALIGN_CAPTURE", "ALIGN_SECRET", "ALIGN_URL", "ALIGN_MCP_URL", "ALIGN_ALLOW_OPEN_CREATE", "ALIGN_SIGNUP_CODE", "ALIGN_MAX_WORKSPACES", "ALIGN_CREATE_PER_HOUR", "ALIGN_DATA_DIR", "ALIGN_PUBLIC"]) delete clean[k]; + server = spawn("npx", ["next", "dev", "-H", "127.0.0.1", "-p", String(PORT)], { env: { ...clean, ...env }, stdio: "ignore", detached: true }); + for (let i = 0; i < 90; i++) { + try { + const r = await fetch(`${BASE}/api/workspaces/config`); + if (r.status === 200) return; + } catch {} + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error("server did not start"); +} +async function stopServer() { + if (!server) return; + try { + process.kill(-server.pid, "SIGTERM"); + } catch {} + spawnSync("pkill", ["-f", `next dev -H 127.0.0.1 -p ${PORT}`]); + server = null; + await new Promise((r) => setTimeout(r, 2500)); + rmSync(NS_STATE, { force: true }); + rmSync(path.join(ROOT, "data/.secret"), { force: true }); +} +process.on("exit", () => { + if (server) + try { + process.kill(-server.pid, "SIGTERM"); + } catch {} +}); + +// ---- token helpers (the test has file access, like a host admin) ---- +function decode(cookie) { + const v = decodeURIComponent(cookie.split("=").slice(1).join("=")); + const [p, mac] = v.split("."); + return { payload: JSON.parse(Buffer.from(p, "base64url").toString("utf8")), p, mac }; +} +function forge(payload, secret) { + const p = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `align_session=${encodeURIComponent(`${p}.${createHmac("sha256", secret).update(p).digest("base64url")}`)}`; +} + +const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "align-tenancy-")); +try { + // ======== phase closed ======== + await startServer({ ALIGN_DATA_DIR: path.join(tmpRoot, "closed") }); + const closed = await create(FIXTURE); + check("B13 creation closed without signup code or ALIGN_ALLOW_OPEN_CREATE -> 403", closed.status === 403, `${closed.status} ${closed.text}`); + const cfgClosed = await call("GET", "/api/workspaces/config"); + check("B13 config says a signup code is required", cfgClosed.json?.signup_code_required === true, cfgClosed.text); + await stopServer(); + + // ======== phase code ======== + await startServer({ ALIGN_DATA_DIR: path.join(tmpRoot, "code"), ALIGN_SIGNUP_CODE: "tenancy-code-1", ALIGN_MAX_WORKSPACES: "3", ALIGN_CREATE_PER_HOUR: "100" }); + const wrong = await create({ ...FIXTURE, signup_code: "nope" }); + check("B13 wrong signup code -> 403", wrong.status === 403, `${wrong.status} ${wrong.text}`); + const c1 = await create({ ...FIXTURE, signup_code: "tenancy-code-1" }); + const c2 = await create({ ...FIXTURE, signup_code: "tenancy-code-1" }); + check("B13 right signup code -> 201", c1.status === 201 && c2.status === 201, `${c1.status} ${c2.status}`); + const race = await Promise.all([create({ ...FIXTURE, signup_code: "tenancy-code-1" }), create({ ...FIXTURE, signup_code: "tenancy-code-1" })]); + const statuses = race.map((r) => r.status).sort(); + check("B13 cap reached with two concurrent creates at N-1: exactly one succeeds", statuses[0] === 201 && statuses[1] === 409, statuses.join(",")); + const cfgFull = await call("GET", "/api/workspaces/config"); + check("B13 config reports max_reached", cfgFull.json?.max_reached === true, cfgFull.text); + await stopServer(); + + // ======== phase open ======== + const dataDir = path.join(tmpRoot, "open"); + const wsRoot = path.join(dataDir, "workspaces"); + const mcpToken = "tenancy-" + Date.now(); + await startServer({ ALIGN_DATA_DIR: dataDir, ALIGN_ALLOW_OPEN_CREATE: "1", ALIGN_CREATE_PER_HOUR: "1000", ALIGN_MCP_TOKEN: mcpToken }); + + // C19 Northstar API + MCP snapshot with another workspace on disk (before any Northstar capture: sessions survive reset) + const W0 = await create(marked("ZMARK")); + check("C19 setup: a workspace exists on disk", W0.status === 201, `${W0.status} ${W0.text.slice(0, 200)}`); + const snap = spawnSync("node", ["scripts/snapshot-northstar.mjs", BASE], { env: { ...process.env, ALIGN_MCP_TOKEN: mcpToken }, encoding: "utf8" }); + check("C19 Northstar snapshot identical to 850971e with workspaces on disk", snap.status === 0, (snap.stdout + snap.stderr).split("\n").slice(-4).join(" ")); + + // A11 create-time marker: a captured Northstar session must not reach a new workspace. + await call("POST", "/api/sessions/events", { body: { harness: "claude-code", session_id: "tenancy-capture-1", hook_event: "UserPromptSubmit", prompt: "NSMARKER captured prompt", cwd: "/tmp/x" } }); + const nsAfterCapture = await call("GET", "/api/state"); + check("A11 setup: the Northstar capture landed", nsAfterCapture.text.includes("NSMARKER"), "capture marker not in Northstar state"); + // The mapper maps the captured session in the background; wait for it so later Northstar hash checks are stable. + let mapped = false; + for (let i = 0; i < 30 && !mapped; i++) { + const st = await call("GET", "/api/state"); + mapped = (st.json?.mappings ?? []).some((m) => m.session_id === "claude-code:tenancy-capture-1"); + if (!mapped) await new Promise((r) => setTimeout(r, 500)); + } + await new Promise((r) => setTimeout(r, 1000)); + check("A11 setup: the background mapper settled", mapped, "no mapping for the captured session"); + + // B13 request-shape refusals + const plain = await create(undefined, { raw: JSON.stringify(FIXTURE), headers: { "content-type": "text/plain" } }); + check("B13 text/plain -> 415", plain.status === 415, `${plain.status}`); + const foreign = await create(FIXTURE, { headers: { origin: "http://evil.example" } }); + check("B13 foreign Origin -> 403", foreign.status === 403, `${foreign.status}`); + const over = await create(undefined, { raw: JSON.stringify({ ...FIXTURE, pad: "x".repeat(70 * 1024) }) }); + check("B13 oversize -> 413", over.status === 413, `${over.status}`); + const protoTop = await create(undefined, { raw: JSON.stringify(FIXTURE).replace(/^\{/, '{"__proto__":{"a":1},') }); + const protoNested = await create(undefined, { raw: JSON.stringify(FIXTURE).replace('"leadership":{', '"leadership":{"__proto__":{"a":1},') }); + check("B13 __proto__ top level and nested -> 400", protoTop.status === 400 && protoNested.status === 400, `${protoTop.status} ${protoNested.status}`); + const host = await withHost("POST", "/api/workspaces", "rebind.example:3000", FIXTURE); + check("B13 forged Host -> 421", host.status === 421, `${host.status}`); + const stateHost = await withHost("GET", "/api/state", "rebind.example"); + check("B13 forged Host on a Northstar route -> 421", stateHost.status === 421, `${stateHost.status}`); + + // A1 isolation + const A = await create(marked("AMARK")); + const B = await create(marked("BMARK")); + check("create A and B -> 201, distinct ids, path /w/, passcodes shown once", A.status === 201 && B.status === 201 && A.json.workspace.id !== B.json.workspace.id && A.json.workspace.path === `/w/${A.json.workspace.id}` && A.json.shown_once === true, `${A.status} ${B.status}`); + const a = A.json.workspace.id; + const b = B.json.workspace.id; + const sa = await signIn(a, A.json.passcodes); + const sb = await signIn(b, B.json.passcodes); + const stA = await call("GET", "/api/state", { cookie: sa.product }); + const stB = await call("GET", "/api/state", { cookie: sb.product }); + check("A1 A's state has A's markers and none of B's", stA.text.includes("AMARK") && !stA.text.includes("BMARK"), stA.text.slice(0, 200)); + check("A1 B's state has B's markers and none of A's", stB.text.includes("BMARK") && !stB.text.includes("AMARK"), stB.text.slice(0, 200)); + check("A11 new workspace has no Northstar capture marker (state and files)", !stA.text.includes("NSMARKER") && !allText(path.join(wsRoot, a)).includes("NSMARKER"), "NSMARKER leaked"); + check("A11 new workspace starts with empty sessions, mappings, simulations", stA.json.sessions.length === 0 && stA.json.mappings.length === 0 && stA.json.simulations.length === 0, "not empty"); + + + // A2 a session ignores any workspace in body, query or header + const nB0 = treeHash(path.join(wsRoot, b)); + await call("POST", `/api/timer?workspace=${b}`, { cookie: sa.product, body: { action: "brief_reviewed", workspace: b }, headers: { "x-align-workspace": b } }); + check("A2 workspace in body/query/header ignored: B untouched", treeHash(path.join(wsRoot, b)) === nB0, "B changed"); + const meA = await call("GET", `/api/session?workspace=${b}`, { cookie: sa.product }); + check("A2 GET /api/session reports A", meA.json?.workspace === a, meA.text); + const info = await call("GET", `/api/workspaces/${b}/login-info`); + const leads = [FIXTURE.leadership.lead, FIXTURE.asking.lead, FIXTURE.owning.lead]; + check("A2 login-info for B: names only, no person names, no plan text", info.status === 200 && info.text.includes("BMARK") && !leads.some((l) => info.text.includes(l)) && !info.text.includes(FIXTURE.plan.text.slice(0, 30)), info.text); + + // A3 tampered tokens + const secret = readFileSync(path.join(ROOT, "data/.secret"), "utf8").trim(); + const ta = decode(sa.product); + const tb = decode(sb.product); + const editWs = `align_session=${encodeURIComponent(`${Buffer.from(JSON.stringify({ ...ta.payload, ws: b })).toString("base64url")}.${ta.mac}`)}`; + const swapMac = `align_session=${encodeURIComponent(`${ta.p}.${tb.mac}`)}`; + const expired = forge({ ...ta.payload, exp: Date.now() - 1000 }, secret); + const r1 = await call("POST", "/api/timer", { cookie: editWs, body: { action: "brief_reviewed" } }); + const r2 = await call("POST", "/api/timer", { cookie: swapMac, body: { action: "brief_reviewed" } }); + const r3 = await call("POST", "/api/timer", { cookie: expired, body: { action: "brief_reviewed" } }); + check("A3 ws edited without re-signing -> 401", r1.status === 401, `${r1.status} ${r1.text}`); + check("A3 A's payload with B's MAC -> 401", r2.status === 401, `${r2.status}`); + check("A3 expired exp -> 401 Session ended", r3.status === 401 && r3.json?.error === ENDED, `${r3.status} ${r3.text}`); + const st3 = await call("GET", "/api/state", { cookie: expired }); + check("A3 expired token on GET /api/state -> 401, no Northstar fallback", st3.status === 401 && !st3.text.includes("Northstar"), `${st3.status}`); + + // A4 login workspace values + const before4 = treeHash(dataDir) + fileHash(NS_STATE); + const bad = ["northstar", "", null, 0, "../northstar", `w_${"a".repeat(11)}`, `w_${"a".repeat(13)}`, a.toUpperCase(), `w_aaaa%2Faaaaaaa`, `${a}\u0000`, `w_aaaaaaaaaaaa`]; + const outs = await Promise.all(bad.map((w) => call("POST", "/api/session", { body: { workspace: w, role: "product", passcode: A.json.passcodes.product } }))); + check("A4 every invalid or unknown workspace value -> uniform 401", outs.every((r) => r.status === 401 && r.json?.error === WRONG), outs.map((r) => r.status).join(",")); + const wrongRole = await call("POST", "/api/session", { body: { workspace: a, role: "admin", passcode: A.json.passcodes.product } }); + const wrongPass = await call("POST", "/api/session", { body: { workspace: a, role: "product", passcode: A.json.passcodes.security } }); + check("A4 unknown role and wrong passcode -> the same 401", wrongRole.json?.error === WRONG && wrongPass.json?.error === WRONG, `${wrongRole.text} ${wrongPass.text}`); + check("A4 nothing written by failed logins", treeHash(dataDir) + fileHash(NS_STATE) === before4, "tree changed"); + + // A5 Northstar-only routes with a custom token + const nsBefore5 = fileHash(NS_STATE); + const r5 = await Promise.all([ + call("POST", "/api/alike", { cookie: sa.leadership, body: {} }), + call("POST", "/api/mappings", { cookie: sa.leadership, body: { action: "remap" } }), + call("POST", "/api/mappings/claude-code:tenancy-capture-1/confirm", { cookie: sa.leadership, body: {} }), + call("POST", "/api/mappings/claude-code:tenancy-capture-1/reject", { cookie: sa.leadership, body: {} }), + call("POST", "/api/org/push", { cookie: sa.leadership, body: {} }), + call("POST", "/api/scenario", { cookie: sa.leadership, body: { ...FIXTURE, confirm: "replace" } }), + ]); + check("A5 alike, mappings POST/confirm/reject, org/push, scenario with a custom token -> 403", r5.every((r) => r.status === 403), r5.map((r) => r.status).join(",")); + check("A5 Northstar state unchanged", fileHash(NS_STATE) === nsBefore5, "changed"); + + // A6 custom flow leaves Northstar byte-identical + const ns6 = fileHash(NS_STATE); + const ns6Text = readFileSync(NS_STATE, "utf8"); + const p6 = await call("POST", "/api/proposals", { cookie: sa.product, body: { proposed_measure_text: "AMARK new target", rationale: "AMARK why", reciprocal_commitment: "AMARK commit" } }); + const pid = p6.json?.id; + const c6 = await call("POST", `/api/proposals/${pid}/counter`, { cookie: sa.security, body: { proposed_measure_text: "AMARK revised", rationale: "AMARK r", changes_plan: false } }); + const k6 = await call("POST", `/api/proposals/${pid}/acknowledge`, { cookie: sa.product, body: {} }); + const t6 = await call("POST", "/api/timer", { cookie: sa.product, body: { action: "brief_reviewed" } }); + check("A6 custom propose, counter, acknowledge, timer -> 200", [p6, c6, k6, t6].every((r) => r.status === 200), [p6, c6, k6, t6].map((r) => `${r.status}`).join(",")); + + // B17 a custom accept emits no authority event + const ctrl = new AbortController(); + let wire = ""; + const sse = fetch(`${BASE}/api/directives/stream?session=*`, { signal: ctrl.signal }) + .then(async (res) => { + const reader = res.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + wire += Buffer.from(value).toString("utf8"); + } + }) + .catch(() => {}); + await new Promise((r) => setTimeout(r, 800)); + const acc = await call("POST", `/api/proposals/${pid}/accept`, { cookie: sa.security, body: { rationale: "AMARK ok", accept_untested: true } }); + await new Promise((r) => setTimeout(r, 1500)); + ctrl.abort(); + await sse; + check("A6 custom accept -> 200 RATIFIED", acc.status === 200, `${acc.status} ${acc.text.slice(0, 200)}`); + check("B17 custom accept emits no authority event on ?session=*", wire.includes("event: hello") && !wire.includes("event: authority"), wire.slice(0, 300)); + const p6b = await call("POST", "/api/proposals", { cookie: sa.product, body: { proposed_measure_text: "AMARK second", rationale: "r", reciprocal_commitment: "c" } }); + const rj = await call("POST", `/api/proposals/${p6b.json?.id}/reject`, { cookie: sa.security, body: {} }); + check("A6 custom reject -> 200", rj.status === 200, `${rj.status} ${rj.text.slice(0, 200)}`); + check("A6 Northstar files byte-identical after the custom flow", fileHash(NS_STATE) === ns6, `changed keys: ${changedKeys(ns6Text, readFileSync(NS_STATE, "utf8"))}`); + const ns = await northstarSignIn(); + const bTree6 = treeHash(path.join(wsRoot, a)); + await call("POST", "/api/timer", { cookie: ns.product, body: { action: "brief_reviewed" } }); + await call("POST", "/api/proposals", { cookie: ns.product, body: { plan_id: "p-beta", plan_version: 1, simulation_refs: [], proposed_measure_text: "NS", rationale: "NS", reciprocal_commitment: "NS" } }); + check("A6 Northstar actions leave A's files byte-identical", treeHash(path.join(wsRoot, a)) === bTree6, "A changed"); + + // A7 parallel writes + const actions = ["ceo_change_viewed", "brief_reviewed", "scenario_reviewed", "evidence_opened"]; + await Promise.all([ + ...Array.from({ length: 20 }, (_, i) => call("POST", "/api/timer", { cookie: [sa.product, sa.security][i % 2], body: { action: actions[i % 4] } })), + ...Array.from({ length: 20 }, (_, i) => call("POST", "/api/timer", { cookie: [sb.product, sb.security][i % 2], body: { action: actions[i % 4] } })), + ]); + const sidsA = new Set([sa.leadership_sid, sa.product_sid, sa.security_sid, "create"]); + const sidsB = new Set([sb.leadership_sid, sb.product_sid, sb.security_sid, "create"]); + const auA = JSON.parse(readFileSync(path.join(wsRoot, a, "state.json"), "utf8")).audit; + const auB = JSON.parse(readFileSync(path.join(wsRoot, b, "state.json"), "utf8")).audit; + check("A7 20+20 parallel timer touches each land only in their own workspace", auA.every((r) => sidsA.has(r.session_id)) && auB.every((r) => sidsB.has(r.session_id)) && auB.some((r) => r.action === "brief_reviewed"), "cross-workspace audit row"); + + // B16 custom body caps + const o16 = await call("POST", "/api/proposals", { cookie: sb.product, raw: JSON.stringify({ proposed_measure_text: "x", pad: "y".repeat(70 * 1024) }) }); + const f300 = await call("POST", "/api/proposals", { cookie: sb.product, body: { proposed_measure_text: "y".repeat(301), rationale: "r", reciprocal_commitment: "c" } }); + const f4000 = await call("POST", "/api/proposals", { cookie: sb.product, body: { proposed_measure_text: "x", text: "y".repeat(4001), rationale: "r", reciprocal_commitment: "c" } }); + check("B16 oversize custom proposal body -> 413", o16.status === 413, `${o16.status}`); + check("B16 field over 300 / text over 4000 -> 400", f300.status === 400 && f4000.status === 400, `${f300.status} ${f4000.status}`); + + // B14 login CSRF; B15 rate limits + const form = await call("POST", "/api/session", { raw: JSON.stringify({ workspace: b, role: "product", passcode: "x" }), headers: { "content-type": "text/plain" } }); + check("B14 login-with-workspace as text/plain -> 415", form.status === 415, `${form.status}`); + const nsWrong = []; + for (let i = 0; i < 11; i++) nsWrong.push((await call("POST", "/api/session", { body: { role: "product", passcode: "wrong" } })).status); + check("B15 Northstar login never limited (11 wrong passcodes, all 401)", nsWrong.every((s) => s === 401), nsWrong.join(",")); + const fails = []; + for (let i = 0; i < 6; i++) fails.push((await call("POST", "/api/session", { body: { workspace: b, role: "product", passcode: "wrong" } })).status); + check("B15 custom login backs off after 5 failures (6th -> 429)", fails.slice(0, 5).every((s) => s === 401) && fails[5] === 429, fails.join(",")); + await new Promise((r) => setTimeout(r, 1200)); + const recover = await call("POST", "/api/session", { body: { workspace: b, role: "product", passcode: B.json.passcodes.product } }); + check("B15 custom login recovers after the delay", recover.status === 200, `${recover.status} ${recover.text}`); + + // Restart (leadership only) rebuilds from the saved setup + const rp = await call("POST", "/api/reset", { cookie: sb.product, body: { confirm: "reset" } }); + const rl = await call("POST", "/api/reset", { cookie: sb.leadership, body: { confirm: "reset" } }); + const afterReset = await call("GET", "/api/state", { cookie: sb.product }); + check("Restart: product -> 403; leadership -> clean custom state, audit kept", rp.status === 403 && rl.status === 200 && afterReset.json?.scenario === "custom" && afterReset.json.proposals.length === 0 && afterReset.json.audit.some((r) => r.action === "reset"), `${rp.status} ${rl.status}`); + + // A11 at rest + const everything = allText(dataDir) + allText(path.join(ROOT, "data")); + const codes = [...Object.values(A.json.passcodes), ...Object.values(B.json.passcodes)]; + check("A11 no plaintext passcode under any data folder", codes.every((c) => !everything.includes(c) && !everything.includes(c.replace(/-/g, ""))), "plaintext passcode found"); + const mode = (f) => (statSync(path.join(wsRoot, a, f)).mode & 0o777).toString(8); + check("A11 auth.json and scenario.json are 0600", mode("auth.json") === "600" && mode("scenario.json") === "600", `${mode("auth.json")} ${mode("scenario.json")}`); + const served = stA.text + afterReset.text + info.text; + check("A11 auth.json and scenario.json never appear in an API response", !served.includes('"salt"') && !served.includes('"epoch"') && !served.includes('"confirm":"replace"'), "leaked"); + + // A10 corrupt state + const fileA = path.join(wsRoot, a, "state.json"); + writeFileSync(fileA, readFileSync(fileA, "utf8").slice(0, 200)); + const corruptHash = fileHash(fileA); + const r10 = await call("GET", "/api/state", { cookie: sa.product }); + check("A10 corrupt state.json -> 500, file unchanged, no Northstar content", r10.status === 500 && fileHash(fileA) === corruptHash && !r10.text.includes("Northstar"), `${r10.status} ${r10.text}`); + + // A3 replay after an epoch bump + const authB = path.join(wsRoot, b, "auth.json"); + const au = JSON.parse(readFileSync(authB, "utf8")); + writeFileSync(authB, JSON.stringify({ ...au, epoch: au.epoch + 1 }), { mode: 0o600 }); + await new Promise((r) => setTimeout(r, 50)); + const r3b = await call("POST", "/api/timer", { cookie: sb.product, body: { action: "brief_reviewed" } }); + check("A3 replay after an epoch bump -> 401 Session ended", r3b.status === 401 && r3b.json?.error === ENDED, `${r3b.status} ${r3b.text}`); + writeFileSync(authB, JSON.stringify(au), { mode: 0o600 }); + + // A8 a deleted workspace: tokens fail, nothing recreated, B and Northstar untouched. + // (DELETE /api/workspace is outside the cut; this removes A the way deleteWorkspace does: rename aside, then remove.) + const bBefore8 = treeHash(path.join(wsRoot, b)); + const nsBefore8 = fileHash(NS_STATE); + renameSync(path.join(wsRoot, a), path.join(wsRoot, `.deleting-${a}`)); + rmSync(path.join(wsRoot, `.deleting-${a}`), { recursive: true, force: true }); + const [d1, d2, d3] = await Promise.all([ + call("POST", "/api/timer", { cookie: sa.product, body: { action: "brief_reviewed" } }), + call("GET", "/api/state", { cookie: sa.product }), + call("GET", `/api/workspaces/${a}/login-info`), + ]); + check("A8 deleted workspace: its tokens get 401 Session ended, login-info 404", d1.status === 401 && d2.status === 401 && d3.status === 404, `${d1.status} ${d2.status} ${d3.status}`); + check("A8 deleted workspace is not recreated", !existsSync(path.join(wsRoot, a)), "directory came back"); + check("A8 B and Northstar byte-identical", treeHash(path.join(wsRoot, b)) === bBefore8 && fileHash(NS_STATE) === nsBefore8, "changed"); + + // A12 static check + const awareFiles = ["session", "state", "reset", "timer", "proposals"].map((d) => `app/api/${d}/route.ts`).concat(["acknowledge", "counter", "reject", "accept"].map((a) => `app/api/proposals/[id]/${a}/route.ts`)); + const offenders = awareFiles.filter((f) => /import\s*\{[^}]*\b(mutate|readState)\b[^}]*\}\s*from\s*["']@\/lib\/store["']/.test(readFileSync(path.join(ROOT, f), "utf8"))); + check("A12 workspace-aware routes do not import mutate or readState from lib/store", offenders.length === 0, offenders.join(" ")); +} catch (e) { + check("tenancy run", false, e?.stack ?? e); +} finally { + await stopServer(); + rmSync(tmpRoot, { recursive: true, force: true }); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); From 4702e76ad818f126597d5125f594ee95a11e8ab4 Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Date: Wed, 21 Oct 2026 15:21:47 -0700 Subject: [PATCH 05/63] LIM-2424: L0: HOSTED=1 tester mode hardening (draft, not deployed) Founder ruling 10-21 "go C": one hosted server, a workspace per tester. Without HOSTED, behaviour is unchanged (test-all, Northstar UI 72/72). - lib/config.ts: HOSTED=1 refuses to start unless ALIGN_HOSTS (no loopback), ALIGN_SECRET (32+), ALIGN_DATA_DIR (absolute, outside the app), ALIGN_SIGNUP_CODE (12+) and three non-default Northstar passcodes (12+) are set, and AI_GATEWAY_API_KEY, ALIGN_CAPTURE and ALIGN_ALLOW_OPEN_CREATE are not. Open create is ignored when hosted. ALIGN_PUBLIC=1 is still refused. - proxy.ts + requireJson: the ALIGN_HOSTS allowlist replaces the loopback rule only when hosted (421 / 403). X-Forwarded-Proto is trusted only when hosted: anything but https gets 403. Capture (/api/sessions/events) is 404, and Northstar's no-session routes are 404 unless ALIGN_NORTHSTAR_PUBLIC=1. - state: anonymous GET /api/state is 404 when hosted. - auth + session: sessionCookie() builds Set-Cookie. Mode L is the same string as before; hosted uses __Host-align_session with Secure, and only that name is read. - test-tenancy: new hosted phase (H1-H7): refuses to start with default passcodes / no ALIGN_HOSTS / an AI key; loopback and foreign Host 421; plain http 403; create needs the code; foreign Origin 403; cookie is __Host- + Secure + HttpOnly + SameSite=Strict; the default Northstar passcode fails; an unprefixed cookie is not a session; capture 404; Northstar no-session routes 404; two workspaces can't see each other. 71/71. Also checked by hand under next start (production build). --- app/api/session/route.ts | 8 +-- app/api/state/route.ts | 4 ++ lib/auth.ts | 17 +++++- lib/config.ts | 71 +++++++++++++++++++++---- lib/http.ts | 6 ++- proxy.ts | 39 +++++++++++--- scripts/test-tenancy.mjs | 109 +++++++++++++++++++++++++++++++++++---- 7 files changed, 221 insertions(+), 33 deletions(-) diff --git a/app/api/session/route.ts b/app/api/session/route.ts index 35476d90..145b0d3c 100644 --- a/app/api/session/route.ts +++ b/app/api/session/route.ts @@ -1,4 +1,4 @@ -import { bindWs, COOKIE, DISPLAY, login, loginWorkspace, sessionFrom, wsSessionFrom, type WsSession } from "@/lib/auth"; +import { bindWs, DISPLAY, login, loginWorkspace, sessionCookie, sessionFrom, wsSessionFrom, type WsSession } from "@/lib/auth"; import { body, handle, requireJson } from "@/lib/http"; import { slotOf } from "@/lib/labels"; import { checkLoginBackoff, clearLoginFailures, recordLoginFailure } from "@/lib/ratelimit"; @@ -36,18 +36,18 @@ export async function POST(req: Request) { } clearLoginFailures(key); const res = Response.json({ actor: out.session.actor, name: await customName(out.session), sid: out.session.sid, workspace: out.session.ws }); - res.headers.append("set-cookie", `${COOKIE}=${encodeURIComponent(out.token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`); + res.headers.append("set-cookie", sessionCookie(out.token)); return res; } const { token, session } = login(String(b.role), String(b.passcode ?? "")); const res = Response.json({ actor: session.actor, name: DISPLAY[session.actor], sid: session.sid }); - res.headers.append("set-cookie", `${COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`); + res.headers.append("set-cookie", sessionCookie(token)); return res; }); } export async function DELETE() { const res = Response.json({ ok: true }); - res.headers.append("set-cookie", `${COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`); + res.headers.append("set-cookie", sessionCookie(null)); return res; } diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 4b58501e..10aa20c1 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -1,7 +1,9 @@ import { stateContext } from "@/lib/auth"; import { ingestEvidence } from "@/lib/evidence"; import { recordExcludedPrep, viewFor } from "@/lib/flow"; +import { config } from "@/lib/config"; import { handle } from "@/lib/http"; +import { HttpError } from "@/lib/store"; import { ensureRegistryFresh } from "@/lib/mapper"; @@ -10,6 +12,8 @@ export async function GET(req: Request) { // Anonymous is Northstar, as before. A custom session ended by expiry or revocation gets 401, never Northstar. const ctx = stateContext(req); const who = ctx.who; + // Hosted: Northstar is not public. Anonymous callers get 404 unless ALIGN_NORTHSTAR_PUBLIC=1. + if (!who && config().hosted && !config().northstarPublic) throw new HttpError(404, "Not found"); if (who && who.ws !== "northstar") { // Custom workspaces have no Sparta evidence, prep exclusions or agent registry. return viewFor(await ctx.read(), who.actor); diff --git a/lib/auth.ts b/lib/auth.ts index 38cb359c..51ac4439 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -2,11 +2,23 @@ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypt import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import type { Actor, AppState } from "./types"; +import { config } from "./config"; import { HttpError, mutate, mutateWs, readState } from "./store"; import { parseCustomWs, readAuth, verifyPasscode, type CustomWsId, type WsId } from "./workspace"; export const COOKIE = "align_session"; +/** HOSTED=1 uses the __Host- name (Secure, Path=/, no Domain), so a sibling subdomain can't plant a session cookie. */ +const cookieName = () => (config().hosted ? `__Host-${COOKIE}` : COOKIE); + +/** The Set-Cookie value. Mode L: exactly as before. Hosted: __Host- name and Secure. */ +export function sessionCookie(token: string | null): string { + const secure = config().hosted ? "; Secure" : ""; + return token === null + ? `${cookieName()}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0${secure}` + : `${cookieName()}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200${secure}`; +} + function secret(): string { if (process.env.ALIGN_SECRET) return process.env.ALIGN_SECRET; const file = path.join(process.cwd(), "data", ".secret"); @@ -121,8 +133,9 @@ export function readSession(token: string | undefined): Session | null { function tokenFrom(req: Request): TokenRead { const cookie = req.headers.get("cookie") || ""; - const match = cookie.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`)); - return readToken(match ? decodeURIComponent(match.slice(COOKIE.length + 1)) : undefined); + const name = cookieName(); + const match = cookie.split(/;\s*/).find((c) => c.startsWith(`${name}=`)); + return readToken(match ? decodeURIComponent(match.slice(name.length + 1)) : undefined); } /** Northstar session or null. A custom token is not a Northstar session. */ diff --git a/lib/config.ts b/lib/config.ts index 128f2bab..4a109319 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,39 +1,92 @@ import path from "node:path"; // Every multi-workspace setting, read from the environment only (never from request headers). -// This build ships Mode L (loopback) only: ALIGN_PUBLIC=1 is refused at startup. +// Mode L (loopback) is the default. HOSTED=1 is the hosted tester mode (founder ruling 10-21 "go C"): one server +// behind the platform's HTTPS proxy, an explicit hostname allowlist, strong secrets, no capture, Northstar closed. +// ALIGN_PUBLIC=1 (the spec's full Mode P) is still refused. export interface Config { + hosted: boolean; // HOSTED=1 + northstarPublic: boolean; // HOSTED=1 only: serve Northstar's no-session routes anyway (ALIGN_NORTHSTAR_PUBLIC=1) dataDir: string; // custom workspaces live under /workspaces; Northstar always stays in /data workspacesRoot: string; maxWorkspaces: number; signupCode: string | null; allowOpenCreate: boolean; - allowedHosts: Set; // hostnames without port + allowedHosts: Set; // hostnames without port: ALIGN_HOSTS when hosted, else the loopback names trustProxy: boolean; } const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"]; -export function config(): Config { - const dataDir = path.resolve(process.env.ALIGN_DATA_DIR || path.join(process.cwd(), "data")); - const max = Number(process.env.ALIGN_MAX_WORKSPACES || 25); - const hosts = (process.env.ALIGN_ALLOWED_HOSTS || "") +const list = (v: string | undefined) => + (v || "") .split(",") .map((h) => h.trim().toLowerCase()) .filter(Boolean); + +export function config(): Config { + const hosted = process.env.HOSTED === "1"; + const dataDir = path.resolve(process.env.ALIGN_DATA_DIR || path.join(process.cwd(), "data")); + const max = Number(process.env.ALIGN_MAX_WORKSPACES || 25); return { + hosted, + northstarPublic: hosted && process.env.ALIGN_NORTHSTAR_PUBLIC === "1", dataDir, workspacesRoot: path.join(dataDir, "workspaces"), maxWorkspaces: Number.isInteger(max) && max > 0 ? max : 25, signupCode: process.env.ALIGN_SIGNUP_CODE || null, - allowOpenCreate: process.env.ALIGN_ALLOW_OPEN_CREATE === "1", - allowedHosts: new Set(hosts.length ? hosts : LOOPBACK), + allowOpenCreate: !hosted && process.env.ALIGN_ALLOW_OPEN_CREATE === "1", + allowedHosts: new Set(hosted ? list(process.env.ALIGN_HOSTS) : LOOPBACK), trustProxy: process.env.ALIGN_TRUST_PROXY === "1", }; } -/** Startup check (instrumentation.ts). Mode P needs TLS, a signup code and more; none of that is in this build. */ +/** Hostname of a Host header, without port ("[::1]" keeps its brackets). */ +export function hostName(host: string | null): string { + const h = (host || "").toLowerCase(); + return h.startsWith("[") ? h.slice(0, h.indexOf("]") + 1) : h.split(":")[0]; +} + +/** The one host rule: the Host header names an allowed host (ALIGN_HOSTS when hosted, else loopback). */ +export function hostAllowed(host: string | null): boolean { + const name = hostName(host); + return !!name && config().allowedHosts.has(name); +} + +const DEFAULT_PASSCODES: Record = { + ALIGN_PRODUCT_PASSCODE: "product-demo", + ALIGN_SECURITY_PASSCODE: "security-demo", + ALIGN_LEADERSHIP_PASSCODE: "leadership-demo", +}; + +/** Everything wrong with a HOSTED=1 environment; empty means it may start. */ +export function hostedProblems(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string[] { + const p: string[] = []; + const hosts = list(env.ALIGN_HOSTS); + if (!hosts.length) p.push("ALIGN_HOSTS must list the public hostname(s)"); + if (hosts.some((h) => LOOPBACK.includes(h) || h === "::1")) p.push("ALIGN_HOSTS must not include loopback names"); + if (!env.ALIGN_SECRET || env.ALIGN_SECRET.length < 32) p.push("ALIGN_SECRET must be set (32+ characters)"); + const dir = env.ALIGN_DATA_DIR; + if (!dir || !path.isAbsolute(dir)) p.push("ALIGN_DATA_DIR must be an absolute path on the persistent volume"); + else if (!path.relative(path.resolve(cwd), path.resolve(dir)).startsWith("..")) p.push("ALIGN_DATA_DIR must be outside the app directory"); + if (!env.ALIGN_SIGNUP_CODE || env.ALIGN_SIGNUP_CODE.length < 12) p.push("ALIGN_SIGNUP_CODE must be set (12+ characters)"); + if (env.ALIGN_ALLOW_OPEN_CREATE === "1") p.push("ALIGN_ALLOW_OPEN_CREATE is not allowed when hosted"); + for (const [k, dflt] of Object.entries(DEFAULT_PASSCODES)) { + const v = env[k]; + if (!v || v === dflt) p.push(`${k} must be set to a non-default passcode`); + else if (v.length < 12) p.push(`${k} must be 12+ characters`); + } + if (env.AI_GATEWAY_API_KEY) p.push("AI_GATEWAY_API_KEY must not be set when hosted (no AI key)"); + if (env.ALIGN_CAPTURE && env.ALIGN_CAPTURE !== "0") p.push("ALIGN_CAPTURE must be off when hosted"); + return p; +} + +/** Startup check (instrumentation.ts). Throws, so the server does not start. */ export function assertStartupConfig() { if (process.env.ALIGN_PUBLIC === "1") throw new Error("Public mode is not supported in this build"); + if (process.env.HOSTED === "1") { + const p = hostedProblems(); + if (p.length) throw new Error(`HOSTED=1 refused to start:\n- ${p.join("\n- ")}`); + } } diff --git a/lib/http.ts b/lib/http.ts index 02095fdb..019d5342 100644 --- a/lib/http.ts +++ b/lib/http.ts @@ -1,3 +1,4 @@ +import { config, hostAllowed } from "./config"; import { HttpError } from "./store"; export async function handle(fn: () => Promise) { @@ -25,7 +26,10 @@ function loopbackHost(host: string | null): boolean { export function requireJson(req: Request) { const type = (req.headers.get("content-type") || "").split(";")[0].trim().toLowerCase(); if (type !== "application/json") throw new HttpError(415, "Send this as application/json"); - if (!loopbackHost(req.headers.get("host"))) throw new HttpError(403, "This request must come from this machine"); + if (config().hosted) { + // Hosted: the configured public hostnames replace the loopback rule (proxy.ts enforces the same list). + if (!hostAllowed(req.headers.get("host"))) throw new HttpError(403, "This request came from another site"); + } else if (!loopbackHost(req.headers.get("host"))) throw new HttpError(403, "This request must come from this machine"); // Compare with the Host the browser actually used; Next's req.url may normalize it (e.g. to localhost). const origin = req.headers.get("origin"); if (origin) { diff --git a/proxy.ts b/proxy.ts index ea2c2ca7..4af32916 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,16 +1,39 @@ import { NextResponse, type NextRequest } from "next/server"; +import { config as alignConfig, hostAllowed, hostName } from "@/lib/config"; import { LOCAL_HOSTS } from "@/lib/local-guard"; -// Every page and API route is for this machine only. A DNS-rebound page reaches the server under its own +// Runs before every page and API route. +// Mode L (default): every route is for this machine only. A DNS-rebound page reaches the server under its own // name, so refuse any Host header whose hostname is not loopback (any port). requireJson keeps its own check. +// HOSTED=1 (tester mode): the ALIGN_HOSTS allowlist replaces loopback; then HTTPS is required (X-Forwarded-Proto +// from the platform proxy is trusted only in this mode), agent capture is off, and Northstar's no-session routes +// are closed unless ALIGN_NORTHSTAR_PUBLIC=1. +const CAPTURE = ["/api/sessions/events"]; +const NORTHSTAR_OPEN = ["/api/sessions", "/api/directives", "/api/mappings", "/api/policy", "/api/simulations", "/api/evidence", "/api/mcp"]; +const under = (p: string, prefixes: string[]) => prefixes.some((x) => p === x || p.startsWith(`${x}/`)); + export function proxy(request: NextRequest) { - const host = (request.headers.get("host") || "").toLowerCase(); - const name = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0]; - if (name && LOCAL_HOSTS.has(name)) return NextResponse.next(); - return new NextResponse("Misdirected request: this server answers to localhost only\n", { - status: 421, - headers: { "content-type": "text/plain; charset=utf-8" }, - }); + const cfg = alignConfig(); + const name = hostName(request.headers.get("host")); + if (!cfg.hosted) { + if (name && LOCAL_HOSTS.has(name)) return NextResponse.next(); + return new NextResponse("Misdirected request: this server answers to localhost only\n", { + status: 421, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + } + if (!hostAllowed(request.headers.get("host"))) { + return new NextResponse("Misdirected request: this server answers to its own host name only\n", { + status: 421, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + } + if (request.headers.get("x-forwarded-proto") !== "https") return NextResponse.json({ error: "Use https" }, { status: 403 }); + const p = request.nextUrl.pathname; + if (under(p, CAPTURE) || (!cfg.northstarPublic && under(p, NORTHSTAR_OPEN))) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.next(); } export const config = { diff --git a/scripts/test-tenancy.mjs b/scripts/test-tenancy.mjs index 5a25cd13..f49ee368 100644 --- a/scripts/test-tenancy.mjs +++ b/scripts/test-tenancy.mjs @@ -5,6 +5,7 @@ // phase "closed": no create config -> creation closed // phase "code": signup code, cap 3 -> wrong code, cap race // phase "open": open create (serve.sh mode) -> everything else, plus the Northstar snapshot with workspaces on disk +// phase "hosted": HOSTED=1 refusals at startup, then a valid hosted config (Host align.test, X-Forwarded-Proto https) import { spawn, spawnSync } from "node:child_process"; import { createHmac } from "node:crypto"; @@ -66,6 +67,32 @@ function withHost(method, p, host, body) { }); } +/** A request as the platform proxy forwards it in hosted mode: allowlisted Host and X-Forwarded-Proto https. */ +function hosted(method, p, { body, cookie, headers = {} } = {}) { + return new Promise((resolve, reject) => { + const data = body === undefined ? undefined : JSON.stringify(body); + const h = { host: HOSTED_HOST, "x-forwarded-proto": "https", ...headers }; + if (data) Object.assign(h, { "content-type": h["content-type"] ?? "application/json", "content-length": Buffer.byteLength(data) }); + if (cookie) h.cookie = cookie; + const req = http.request({ host: "127.0.0.1", port: PORT, method, path: p, headers: h }, (res) => { + let t = ""; + res.on("data", (c) => (t += c)); + res.on("end", () => { + let json = null; + try { + json = JSON.parse(t); + } catch {} + const sc = res.headers["set-cookie"] ?? []; + resolve({ status: res.statusCode, text: t, json, setCookie: sc.join("\n"), cookie: (sc[0] || "").split(";")[0] }); + }); + }); + req.on("error", reject); + if (data) req.write(data); + req.end(); + }); +} +const HOSTED_HOST = "align.test"; + const create = (body, extra = {}) => call("POST", "/api/workspaces", { body, ...extra }); async function signIn(ws, passcodes) { const out = {}; @@ -141,16 +168,21 @@ async function startServer(env) { rmSync(NS_STATE, { force: true }); rmSync(path.join(ROOT, "data/.secret"), { force: true }); const clean = { ...process.env }; - for (const k of ["AI_GATEWAY_API_KEY", "ALIGN_CAPTURE", "ALIGN_SECRET", "ALIGN_URL", "ALIGN_MCP_URL", "ALIGN_ALLOW_OPEN_CREATE", "ALIGN_SIGNUP_CODE", "ALIGN_MAX_WORKSPACES", "ALIGN_CREATE_PER_HOUR", "ALIGN_DATA_DIR", "ALIGN_PUBLIC"]) delete clean[k]; - server = spawn("npx", ["next", "dev", "-H", "127.0.0.1", "-p", String(PORT)], { env: { ...clean, ...env }, stdio: "ignore", detached: true }); - for (let i = 0; i < 90; i++) { + for (const k of ["AI_GATEWAY_API_KEY", "ALIGN_CAPTURE", "ALIGN_SECRET", "ALIGN_URL", "ALIGN_MCP_URL", "ALIGN_ALLOW_OPEN_CREATE", "ALIGN_SIGNUP_CODE", "ALIGN_MAX_WORKSPACES", "ALIGN_CREATE_PER_HOUR", "ALIGN_DATA_DIR", "ALIGN_PUBLIC", "HOSTED", "ALIGN_HOSTS", "ALIGN_NORTHSTAR_PUBLIC", "ALIGN_PRODUCT_PASSCODE", "ALIGN_SECURITY_PASSCODE", "ALIGN_LEADERSHIP_PASSCODE"]) delete clean[k]; + server = spawn("npx", ["next", "dev", "-H", "127.0.0.1", "-p", String(PORT)], { env: { ...clean, ...env }, stdio: ["ignore", "ignore", "pipe"], detached: true }); + let exited = false; + let stderr = ""; + server.stderr.on("data", (c) => (stderr += c)); + server.on("exit", () => (exited = true)); + for (let i = 0; i < 90 && !exited; i++) { try { - const r = await fetch(`${BASE}/api/workspaces/config`); - if (r.status === 200) return; + // Hosted servers answer only for their allowlisted Host over (forwarded) https. + const r = env.HOSTED === "1" ? await hosted("GET", "/api/workspaces/config") : await fetch(`${BASE}/api/workspaces/config`); + if (r.status === 200) return { started: true, stderr }; } catch {} await new Promise((r) => setTimeout(r, 1000)); } - throw new Error("server did not start"); + return { started: false, stderr }; } async function stopServer() { if (!server) return; @@ -163,6 +195,10 @@ async function stopServer() { rmSync(NS_STATE, { force: true }); rmSync(path.join(ROOT, "data/.secret"), { force: true }); } +async function mustStart(env) { + const r = await startServer(env); + if (!r.started) throw new Error(`server did not start: ${r.stderr.slice(-400)}`); +} process.on("exit", () => { if (server) try { @@ -184,7 +220,7 @@ function forge(payload, secret) { const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "align-tenancy-")); try { // ======== phase closed ======== - await startServer({ ALIGN_DATA_DIR: path.join(tmpRoot, "closed") }); + await mustStart({ ALIGN_DATA_DIR: path.join(tmpRoot, "closed") }); const closed = await create(FIXTURE); check("B13 creation closed without signup code or ALIGN_ALLOW_OPEN_CREATE -> 403", closed.status === 403, `${closed.status} ${closed.text}`); const cfgClosed = await call("GET", "/api/workspaces/config"); @@ -192,7 +228,7 @@ try { await stopServer(); // ======== phase code ======== - await startServer({ ALIGN_DATA_DIR: path.join(tmpRoot, "code"), ALIGN_SIGNUP_CODE: "tenancy-code-1", ALIGN_MAX_WORKSPACES: "3", ALIGN_CREATE_PER_HOUR: "100" }); + await mustStart({ ALIGN_DATA_DIR: path.join(tmpRoot, "code"), ALIGN_SIGNUP_CODE: "tenancy-code-1", ALIGN_MAX_WORKSPACES: "3", ALIGN_CREATE_PER_HOUR: "100" }); const wrong = await create({ ...FIXTURE, signup_code: "nope" }); check("B13 wrong signup code -> 403", wrong.status === 403, `${wrong.status} ${wrong.text}`); const c1 = await create({ ...FIXTURE, signup_code: "tenancy-code-1" }); @@ -209,7 +245,7 @@ try { const dataDir = path.join(tmpRoot, "open"); const wsRoot = path.join(dataDir, "workspaces"); const mcpToken = "tenancy-" + Date.now(); - await startServer({ ALIGN_DATA_DIR: dataDir, ALIGN_ALLOW_OPEN_CREATE: "1", ALIGN_CREATE_PER_HOUR: "1000", ALIGN_MCP_TOKEN: mcpToken }); + await mustStart({ ALIGN_DATA_DIR: dataDir, ALIGN_ALLOW_OPEN_CREATE: "1", ALIGN_CREATE_PER_HOUR: "1000", ALIGN_MCP_TOKEN: mcpToken }); // C19 Northstar API + MCP snapshot with another workspace on disk (before any Northstar capture: sessions survive reset) const W0 = await create(marked("ZMARK")); @@ -433,6 +469,61 @@ try { const awareFiles = ["session", "state", "reset", "timer", "proposals"].map((d) => `app/api/${d}/route.ts`).concat(["acknowledge", "counter", "reject", "accept"].map((a) => `app/api/proposals/[id]/${a}/route.ts`)); const offenders = awareFiles.filter((f) => /import\s*\{[^}]*\b(mutate|readState)\b[^}]*\}\s*from\s*["']@\/lib\/store["']/.test(readFileSync(path.join(ROOT, f), "utf8"))); check("A12 workspace-aware routes do not import mutate or readState from lib/store", offenders.length === 0, offenders.join(" ")); + + // ======== phase hosted ======== + await stopServer(); + const hostedDir = path.join(tmpRoot, "hosted"); + const good = { + HOSTED: "1", + ALIGN_HOSTS: HOSTED_HOST, + ALIGN_SECRET: "t".repeat(48), + ALIGN_DATA_DIR: hostedDir, + ALIGN_SIGNUP_CODE: "hosted-signup-code-1", + ALIGN_PRODUCT_PASSCODE: "product-strong-pass-1", + ALIGN_SECURITY_PASSCODE: "security-strong-pass-1", + ALIGN_LEADERSHIP_PASSCODE: "leadership-strong-pass-1", + }; + const refusals = [ + ["default passcodes", { ALIGN_PRODUCT_PASSCODE: undefined, ALIGN_SECURITY_PASSCODE: undefined, ALIGN_LEADERSHIP_PASSCODE: undefined }, "non-default passcode"], + ["no ALIGN_HOSTS", { ALIGN_HOSTS: undefined }, "ALIGN_HOSTS"], + ["an AI key", { AI_GATEWAY_API_KEY: "sk-test" }, "AI_GATEWAY_API_KEY"], + ]; + for (const [what, delta, needle] of refusals) { + const env = { ...good, ...delta }; + for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k]; + const r = await startServer(env); + check(`H1 HOSTED=1 refuses to start with ${what}`, !r.started && r.stderr.includes(needle), r.started ? "it started" : r.stderr.slice(-300)); + await stopServer(); + } + await mustStart(good); + const hLoop = await call("GET", "/api/workspaces/config"); + const hOther = await withHost("GET", "/api/workspaces/config", "evil.example"); + check("H2 a non-allowlisted Host is refused (loopback and a foreign name -> 421)", hLoop.status === 421 && hOther.status === 421, `${hLoop.status} ${hOther.status}`); + const hHttp = await hosted("GET", "/api/workspaces/config", { headers: { "x-forwarded-proto": "http" } }); + check("H2 plain http behind the proxy -> 403", hHttp.status === 403, `${hHttp.status}`); + const hOpen = await hosted("POST", "/api/workspaces", { body: FIXTURE }); + check("H3 hosted create needs the signup code (open create is off)", hOpen.status === 403, `${hOpen.status} ${hOpen.text}`); + const hA = await hosted("POST", "/api/workspaces", { body: { ...marked("HAMARK"), signup_code: good.ALIGN_SIGNUP_CODE }, headers: { origin: `https://${HOSTED_HOST}` } }); + const hB = await hosted("POST", "/api/workspaces", { body: { ...marked("HBMARK"), signup_code: good.ALIGN_SIGNUP_CODE } }); + check("H3 hosted create with the signup code and same-site Origin -> 201", hA.status === 201 && hB.status === 201, `${hA.status} ${hB.status} ${hA.text.slice(0, 200)}`); + const hForeign = await hosted("POST", "/api/workspaces", { body: { ...FIXTURE, signup_code: good.ALIGN_SIGNUP_CODE }, headers: { origin: "https://evil.example" } }); + check("H3 foreign Origin -> 403", hForeign.status === 403, `${hForeign.status}`); + const hl = await hosted("POST", "/api/session", { body: { workspace: hA.json.workspace.id, role: "product", passcode: hA.json.passcodes.product } }); + check("H4 workspace session cookie: __Host- name, Secure, HttpOnly, SameSite=Strict", hl.status === 200 && /^__Host-align_session=/.test(hl.setCookie) && /; Secure/.test(hl.setCookie) && /HttpOnly/.test(hl.setCookie) && /SameSite=Strict/.test(hl.setCookie), hl.setCookie); + const nsDefault = await hosted("POST", "/api/session", { body: { role: "product", passcode: "product-demo" } }); + check("H4 the default Northstar passcode does not sign in", nsDefault.status === 401, `${nsDefault.status}`); + const plainCookie = hl.cookie.replace(/^__Host-/, ""); + const hPlain = await hosted("GET", "/api/session", { cookie: plainCookie }); + check("H4 a cookie without the __Host- prefix is not a session when hosted", hPlain.json?.actor === null, hPlain.text); + const hCap = await hosted("POST", "/api/sessions/events", { body: { harness: "claude-code", session_id: "h-1", hook_event: "UserPromptSubmit", prompt: "x" } }); + check("H5 capture refused (/api/sessions/events -> 404)", hCap.status === 404, `${hCap.status}`); + const hNs = await Promise.all(["/api/state", "/api/sessions", "/api/simulations", "/api/mappings", "/api/policy/security", "/api/directives?session=*"].map((p) => hosted("GET", p))); + check("H6 Northstar is not served anonymously (state, sessions, simulations, mappings, policy, directives -> 404)", hNs.every((r) => r.status === 404), hNs.map((r) => r.status).join(",")); + const hlB = await hosted("POST", "/api/session", { body: { workspace: hB.json.workspace.id, role: "product", passcode: hB.json.passcodes.product } }); + const hsA = await hosted("GET", "/api/state", { cookie: hl.cookie }); + const hsB = await hosted("GET", "/api/state", { cookie: hlB.cookie }); + check("H7 two hosted workspaces can't see each other", hsA.status === 200 && hsB.status === 200 && hsA.text.includes("HAMARK") && !hsA.text.includes("HBMARK") && hsB.text.includes("HBMARK") && !hsB.text.includes("HAMARK"), `${hsA.status} ${hsB.status}`); + check("H7 no Northstar content in a hosted workspace", !/Northstar|Sparta/.test(hsA.text), hsA.text.match(/Northstar|Sparta/)?.[0]); } catch (e) { check("tenancy run", false, e?.stack ?? e); } finally { From a71b145e02dab89a5647534cb1f80abbcfc6c893 Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Date: Wed, 21 Oct 2026 15:21:47 -0700 Subject: [PATCH 06/63] LIM-2424: L0: hosted deploy draft (Fly, not applied) Dockerfile, .dockerignore, fly.toml and docs/HOSTING.md for HOSTED=1 on one Fly machine with a persistent volume at /data. Nothing is created: no app, volume, DNS or account until Sean approves on LIM-2424. Fly because the spec needs exactly one Node process with a persistent disk (in-process locks, data/.pid): a Fly volume attaches to one machine, the edge terminates TLS and sets X-Forwarded-Proto, and secrets go through fly secrets. Serverless is ruled out as written. HOSTING.md covers the env vars, startup refusals, volume layout, creating tester workspaces, the host-admin delete path, deploy steps for later, and that ALIGN_CREATE_PER_HOUR must not be raised in hosted mode without Sean. The Dockerfile was not built locally (Docker daemon not running); the production build and HOSTED=1 behaviour were checked with next start. --- .dockerignore | 16 ++++++++ Dockerfile | 17 +++++++++ docs/HOSTING.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++ fly.toml | 28 ++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docs/HOSTING.md create mode 100644 fly.toml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..83aa7a4f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.next +node_modules +.env +.env.* +.claude +.worlds +.worlds-dashboard +data/state.json +data/.secret +data/.pid +data/*.tmp +data/workspaces +data/custom-scenario.json +*.pem +coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..9a8e41d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# DRAFT (LIM-2424 L0, not applied): Align hosted tester mode (HOSTED=1) on one machine with a persistent volume. +# See docs/HOSTING.md. Secrets come from the platform (fly secrets), never from this file or an .env in the image. +FROM node:24-bookworm-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npx next build + +FROM node:24-bookworm-slim +WORKDIR /app +ENV NODE_ENV=production HOSTED=1 ALIGN_DATA_DIR=/data PORT=8080 +COPY --from=build /app ./ +EXPOSE 8080 +# The platform proxy terminates TLS and connects over the machine's private interface, so bind all interfaces here; +# proxy.ts answers only for ALIGN_HOSTS and only for forwarded https. +CMD ["npx", "next", "start", "-H", "0.0.0.0", "-p", "8080"] diff --git a/docs/HOSTING.md b/docs/HOSTING.md new file mode 100644 index 00000000..10e52127 --- /dev/null +++ b/docs/HOSTING.md @@ -0,0 +1,97 @@ +# Hosting Align for unmoderated tests (HOSTED=1) + +**Status: draft.** Nothing has been deployed: there's no app, volume, DNS record or account yet. Sean approves the hosted setup on LIM-2424 before any of it is created. This page goes with `Dockerfile`, `.dockerignore` and `fly.toml`. + +Purpose (founder ruling 10-21 "go C"): every Proving Ground interviewee gets their own workspace and a link, and runs the test without a moderator. One server serves all the workspaces. + +## Why Fly + +- **One machine with a persistent volume.** The multi-workspace spec (rev 2, section 10) needs exactly one Node process on a host with a persistent disk, because state locks live in process memory and `data/.pid` refuses a second server. A Fly volume attaches to a single machine, and `min_machines_running = 1` with auto-stop off keeps that one process alive. +- **HTTPS at the edge.** The Fly proxy terminates TLS (`force_https`) and sets `X-Forwarded-Proto`, which the app trusts only when `HOSTED=1`. +- **Secrets stay off disk in the repo.** They're set with `fly secrets`. +- **Not serverless.** Serverless platforms (Vercel functions and the like) are ruled out as written: they run many processes and have no shared disk. Render or Railway with a single instance and a disk would also work; Fly was picked for the simplest volume plus single-machine setup. + +## What HOSTED=1 changes + +Without `HOSTED`, nothing changes: the loopback rules, cookie and Northstar behaviour stay byte-identical (gated by test-all). + +| Area | HOSTED=1 behaviour | +|---|---| +| Hosts | Only the hostnames in `ALIGN_HOSTS` are served. Every other Host gets 421, loopback included. `requireJson` checks the same list in place of its loopback rule. | +| HTTPS | Requests whose `X-Forwarded-Proto` isn't `https` get 403. | +| Cookies | `__Host-align_session`, with `Secure; HttpOnly; SameSite=Strict; Path=/`. A cookie without the prefix isn't accepted. | +| Creation | Needs `ALIGN_SIGNUP_CODE`. `ALIGN_ALLOW_OPEN_CREATE` is refused. A global bucket allows 5 creates per hour, and `ALIGN_MAX_WORKSPACES` caps the total (default 25). | +| Capture | `/api/sessions/events` returns 404, and `ALIGN_CAPTURE` must be off. No AI key is allowed (`AI_GATEWAY_API_KEY` must be unset). | +| Northstar | Not served anonymously. `/api/state` without a session returns 404, and so do the routes that take no session (`/api/sessions`, `/api/directives*`, `/api/mappings`, `/api/policy`, `/api/simulations`, `/api/evidence`, `/api/mcp`). Northstar sign-in needs non-default passcodes. Northstar data lives in the image's `data/` and resets on each deploy; tester data lives only on the volume. | +| Isolation | Each tester workspace is its own directory under `$ALIGN_DATA_DIR/workspaces//`. A workspace session can't read or write another workspace or Northstar (test-tenancy A1–A12, H7). | + +## Startup refusals + +The server won't start, and exits with the list of problems, unless all of these hold: + +- `ALIGN_HOSTS`: the public hostname(s), comma-separated, with no loopback names. +- `ALIGN_SECRET`: 32+ characters. Session tokens are signed with it, so rotating it signs everyone out. +- `ALIGN_DATA_DIR`: an absolute path outside the app directory (the volume, `/data`). +- `ALIGN_SIGNUP_CODE`: 12+ characters. +- `ALIGN_PRODUCT_PASSCODE`, `ALIGN_SECURITY_PASSCODE`, `ALIGN_LEADERSHIP_PASSCODE`: set, not the `*-demo` defaults, and 12+ characters. +- `AI_GATEWAY_API_KEY` unset, `ALIGN_CAPTURE` off, `ALIGN_ALLOW_OPEN_CREATE` unset. +- `ALIGN_PUBLIC` unset. The spec's full Mode P isn't in this build. + +## Environment + +| Variable | Where it's set | Value | +|---|---|---| +| `HOSTED` | `fly.toml` / Dockerfile | `1` | +| `ALIGN_DATA_DIR` | `fly.toml` | `/data` (the volume) | +| `ALIGN_HOSTS` | `fly.toml` | the app hostname, e.g. `align-pg-draft.fly.dev` | +| `ALIGN_MAX_WORKSPACES` | `fly.toml` | `25`. The cap exists for polling cost (spec section 10). | +| `ALIGN_SECRET` | `fly secrets` | `openssl rand -hex 32` | +| `ALIGN_SIGNUP_CODE` | `fly secrets` | a random code, given only to whoever creates tester workspaces | +| `ALIGN_*_PASSCODE` (3) | `fly secrets` | random, for the Northstar demo roles | +| `ALIGN_CREATE_PER_HOUR` | not set | Defaults to 5. **Don't raise it in hosted mode without Sean's approval.** It exists so the local tests can create more than five workspaces. | +| `ALIGN_NORTHSTAR_PUBLIC` | not set | Leave unset. Setting it to `1` reopens the Northstar no-session routes. | + +## The volume + +- Layout: `/data/workspaces//` holds `state.json`, `auth.json` (0600: passcode hashes and epoch), `scenario.json` (0600: the setup input, used by Restart and never served), `workspace.json` and `audit-archive.jsonl`. `/data/workspaces/_events.jsonl` logs creates and deletes, with no names or hashes. +- No plaintext passcode is stored anywhere. Passcodes are shown once at creation. +- Backup means copying `/data/workspaces`. What we promise testers about retention, deletion and backups is still open (spec Q4, Shruti). Answer it before the first real tester. + +## Creating tester workspaces + +One workspace per tester: + +1. Open `https:///new` (being built by S1 on `build-a/L0-new`), enter the signup code and the tester's two-team conflict, and create it. The success card shows the link `/w/` and the three passcodes, **once**. +2. Send the tester the link and the passcodes they need. The creator receives all three; in an unmoderated test, one tester plays all three roles in turn, and a browser profile holds one workspace session at a time. +3. Without the page, a JSON POST works too: + + ```sh + curl -sS https:///api/workspaces -H 'content-type: application/json' \ + -d @tester.json # the /setup body without "confirm", plus "signup_code" + ``` + + The response is 201 `{workspace:{id,name,path}, passcodes:{leadership,product,security}, shown_once:true}`. + +Leadership can restart a tester's scenario from the header ("Restart this scenario"). Passcode rotation, export and delete aren't in this build. Until they are, a host admin removes a workspace with `fly ssh console`: rename `/data/workspaces/` to `.deleting-`, then remove it. That's the same procedure the app uses, and its tokens fail from then on. + +## Deploy steps (for after Sean approves; not run) + +```sh +fly apps create align-pg-draft +fly volumes create align_data --size 1 --region sjc +fly secrets set ALIGN_SECRET=... ALIGN_SIGNUP_CODE=... \ + ALIGN_PRODUCT_PASSCODE=... ALIGN_SECURITY_PASSCODE=... ALIGN_LEADERSHIP_PASSCODE=... +fly deploy +fly scale count 1 +``` + +Check after the deploy, with a synthetic workspace only: + +- `curl -sI https:///api/state` returns 404 (Northstar closed). +- Creating without the code returns 403. +- Creating with the code returns 201. +- The `/w/` sign-in works, and the cookie is `__Host-align_session` with `Secure`. + +## Local test + +`node scripts/test-tenancy.mjs ` runs the hosted phase on a local dev server. It sends `Host: align.test` and `X-Forwarded-Proto: https`, the way the platform proxy forwards requests. `scripts/test-all.sh` runs it as its last step. diff --git a/fly.toml b/fly.toml new file mode 100644 index 00000000..d9a92446 --- /dev/null +++ b/fly.toml @@ -0,0 +1,28 @@ +# DRAFT (LIM-2424 L0, not applied): no app, volume, DNS or account exists yet. Sean approves on LIM-2424 first. +# One machine only: locks are in process memory and the volume attaches to a single machine (docs/HOSTING.md). +app = "align-pg-draft" +primary_region = "sjc" + +[build] + dockerfile = "Dockerfile" + +[env] + HOSTED = "1" + ALIGN_DATA_DIR = "/data" + ALIGN_HOSTS = "align-pg-draft.fly.dev" + ALIGN_MAX_WORKSPACES = "25" + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = "off" + auto_start_machines = false + min_machines_running = 1 + +[[mounts]] + source = "align_data" + destination = "/data" + +[[vm]] + memory = "1gb" + cpus = 1 From 9d2c1caefb3f76c28198533a6bd7533b9316824c Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Date: Wed, 21 Oct 2026 15:25:03 -0700 Subject: [PATCH 07/63] LIM-2424: L0: fictional clinical screening-window fixture for the first async tester scripts/fixtures/clinical-screening-window.json: the L2 setup input (with confirm, like data/scenarios/example-custom.json; drop confirm for POST /api/workspaces). All fictional: "Halverson Bio", a generic Phase 2 study, invented lead names, no real company, trial, drug or person and nothing patent-sensitive. Clinical Development asks Clinical Operations to move from a 4-week to a 5-week screening window; Operations is measured today on "Screening completed within 4 weeks at every active site". Minimal form plus optional fields: a second company measure, an asking-team measure, a second owning measure, plan title, 2 work items per team. Validated on the L0 server: POST /api/scenario as leadership 200, POST /api/workspaces 201, the full custom flow (23 views) with --forbid on the standard list plus Fernway|Acme|Brightline|Data Platform|SOC 2: clean. --- .../fixtures/clinical-screening-window.json | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 scripts/fixtures/clinical-screening-window.json diff --git a/scripts/fixtures/clinical-screening-window.json b/scripts/fixtures/clinical-screening-window.json new file mode 100644 index 00000000..051a8852 --- /dev/null +++ b/scripts/fixtures/clinical-screening-window.json @@ -0,0 +1,41 @@ +{ + "confirm": "replace", + "company": "Halverson Bio", + "timeframe": "This quarter", + "leadership": { "team": "Executive Team", "lead": "Chief Executive" }, + "company_goal": { + "objective": "Raise first-patient-in enrolment for the Phase 2 study this quarter.", + "measures": [ + "Extend the patient screening window from 4 to 5 weeks", + "First patient in at 10 active sites by quarter end" + ] + }, + "company_measure": 0, + "asking": { + "team": "Clinical Development", + "lead": "Maren Holt", + "objective": "Reach the Phase 2 enrolment target this quarter.", + "measures": ["40 patients randomised by quarter end"] + }, + "owning": { + "team": "Clinical Operations", + "lead": "Daniel Osei", + "objective": "Keep site activation on schedule.", + "measures": [ + "Screening completed within 4 weeks at every active site", + "New sites activated within 60 days of contract" + ] + }, + "owner_measure": 0, + "dependency": "Clinical Development can only reach the enrolment target if eligible patients get a fifth week to finish screening, and only Clinical Operations can change the screening procedure at the sites.", + "work_items": [ + { "team": "asking", "task": "Draft the protocol amendment for a 5-week screening window", "status": "in progress" }, + { "team": "asking", "task": "Update the pre-screening eligibility worksheet", "status": "planned" }, + { "team": "owning", "task": "Activate the next wave of sites on the 4-week screening checklist", "status": "in progress" }, + { "team": "owning", "task": "Retrain site coordinators on the current screening procedure", "status": "planned" } + ], + "plan": { + "title": "Five-week screening window", + "text": "Once the protocol amendment is approved by the ethics committees, Clinical Operations moves every active site to a 5-week screening window: it updates the site screening checklist and retrains coordinators within two weeks, and sites still in activation start on the new checklist. In return, Clinical Development submits the amendment and the revised eligibility worksheet, and takes over pre-screening chart review so coordinators' workload does not grow. Site activation dates stay as planned." + } +} From 1bfdee05f5d9bb7edf50a07111a10ba4c8b02732 Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Date: Wed, 21 Oct 2026 15:28:29 -0700 Subject: [PATCH 08/63] LIM-2424: /new creates a workspace from the shared setup form components/ScenarioForm.tsx holds the two-team form moved out of /setup unchanged (types, defaults, body, issue mapping, fields). /setup renders byte-identically in every state. app/new/page.tsx: no sign-in needed. Shows a signup code field when /api/workspaces/config requires one and a full message at the cap, then POSTs the form (no confirm) to /api/workspaces. The success card shows the /w/ link and the three passcodes once, each with a copy button, says to save them now, to hand each to a different person, and that a browser profile holds one workspace session at a time. --- app/new/page.tsx | 184 +++++++++++++++++++ app/setup/page.tsx | 332 +-------------------------------- components/ScenarioForm.tsx | 356 ++++++++++++++++++++++++++++++++++++ 3 files changed, 545 insertions(+), 327 deletions(-) create mode 100644 app/new/page.tsx create mode 100644 components/ScenarioForm.tsx diff --git a/app/new/page.tsx b/app/new/page.tsx new file mode 100644 index 00000000..4e7d7a24 --- /dev/null +++ b/app/new/page.tsx @@ -0,0 +1,184 @@ +"use client"; +import Link from "next/link"; +import { useEffect, useState, type ReactNode } from "react"; +import { Button } from "@/components/ui"; +import { EMPTY, Field, field, issuesFrom, ScenarioFields, toBody, type Form, type Issues } from "@/components/ScenarioForm"; + +// Create a new custom workspace from the same form as /setup. No sign-in is needed and creating does not sign anyone in. +// POSTs to /api/workspaces; the three passcodes come back once and are shown once on the success card. + +type Role = "leadership" | "product" | "security"; +interface Created { + workspace: { id: string; name: string; path: string }; + passcodes: Record; + people: { role: Role; team: string; lead: string }[]; +} + +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } + } + return ( + + ); +} + +export default function NewWorkspacePage() { + const [cfg, setCfg] = useState<{ signup_code_required: boolean; max_reached: boolean } | null | undefined>(undefined); + const [form, setForm] = useState
(EMPTY); + const [code, setCode] = useState(""); + const [issues, setIssues] = useState({}); + const [problem, setProblem] = useState(null); + const [busy, setBusy] = useState(false); + const [more, setMore] = useState(false); + const [created, setCreated] = useState(null); + + useEffect(() => { + fetch("/api/workspaces/config", { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .then(setCfg) + .catch(() => setCfg(null)); + }, []); + + const set = (fn: (f: Form) => Form) => setForm((f) => fn(structuredClone(f))); + + async function submit() { + setBusy(true); + setProblem(null); + setIssues({}); + try { + const res = await fetch("/api/workspaces", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...toBody(form), ...(code.trim() ? { signup_code: code.trim() } : {}) }), + cache: "no-store", + }); + const data = await res.json().catch(() => ({})); + if (res.status === 201) { + const t = (s: string) => s.trim(); + setCreated({ + workspace: data.workspace, + passcodes: data.passcodes, + people: [ + { role: "leadership", team: t(form.leadership.team) || "Leadership", lead: t(form.leadership.lead) || "Leadership" }, + { role: "product", team: t(form.asking.team), lead: t(form.asking.lead) }, + { role: "security", team: t(form.owning.team), lead: t(form.owning.lead) }, + ], + }); + setForm(EMPTY); + window.scrollTo(0, 0); + return; + } + const error = typeof data.error === "string" ? data.error : null; + if (res.status === 400 && Array.isArray(data.issues)) { + setIssues(issuesFrom(data.issues)); + setProblem("Some fields need fixing. They are marked below."); + } else if (res.status === 403 && cfg?.signup_code_required && error && /signup code/i.test(error)) { + setIssues({ signup_code: [error] }); + setProblem("Check the signup code."); + } else if (res.status === 413) setProblem("This setup is too long. Shorten the plan or the work items and try again."); + else if (res.status === 415) setProblem("The setup could not be sent. Reload the page and try again."); + else if (res.status === 421) setProblem("Open this page on the machine running Align, at its localhost address."); + else setProblem(error ?? "The workspace could not be created. Try again."); + } catch { + setProblem("The server did not answer. Check that Align is running and try again."); + } finally { + setBusy(false); + } + } + + const page = (children: ReactNode) =>
{children}
; + + if (created) { + const link = `${window.location.origin}${created.workspace.path}`; // only reached after a submit, so always in the browser + return page( +
+

Workspace ready: {created.workspace.name}

+
+

Save these passcodes now. They won’t be shown again.

+

Align keeps only a scrambled copy, so nobody can look them up later.

+
+ +

Workspace link

+
+ {link} + +
+ +

Passcodes

+
    + {created.people.map((p) => ( +
  • +

    {p.lead === p.team ? p.team : `${p.team} · ${p.lead}`}

    +
    + {created.passcodes[p.role]} + +
    +
  • + ))} +
+ +

+ You now hold all three passcodes, so you could act for every team. Give each one to a different person: {created.people.map((p) => (p.lead === p.team ? `someone in ${p.team}` : p.lead)).join(", ")}. +

+

+ A browser profile holds one workspace session at a time. Signing in here signs that browser out of any other workspace, including the demo. To try + several roles on one computer, use a separate browser profile or private window for each. +

+ + Open the workspace + +
, + ); + } + + if (cfg === undefined) return page(

Loading…

); + if (cfg?.max_reached) + return page( +

This server already has as many workspaces as it allows. Ask whoever runs Align to remove one, then try again.

, + ); + + const unplaced = Object.entries(issues).filter(([p]) => p === "form" || p === ""); + + return page( + <> +

Create a workspace

+

+ Describe one real disagreement between two teams. This makes a new, separate workspace with its own passcodes. Nothing in any other workspace changes. +

+ {cfg?.signup_code_required && ( +
+ + setCode(e.target.value)} /> + +
+ )} + + + + {problem && ( +
+ {problem} + {unplaced.flatMap(([, ms]) => ms).map((m) => ( + + {m} + + ))} +
+ )} + + + , + ); +} diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 692729d2..cd3bb97e 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -4,114 +4,10 @@ import { useEffect, useState, type ReactNode } from "react"; import { Button } from "@/components/ui"; import { labelsFor } from "@/lib/labels"; import type { Actor, StateView } from "@/lib/types"; +import { EMPTY, issuesFrom, ScenarioFields, toBody, type Form, type Issues } from "@/components/ScenarioForm"; // Leadership sets up its own two-team conflict. Reached by URL only (/setup); nothing links here. -// Every field maps 1:1 to the POST /api/scenario schema (lib/scenario.ts). Operator text is rendered as plain text only. - -type Side = "asking" | "owning"; -interface TeamForm { - team: string; - lead: string; - objective: string; - measures: string[]; -} -interface WorkRow { - team: Side; - task: string; - status: string; -} -interface Form { - company: string; - timeframe: string; - leadership: { team: string; lead: string }; - company_goal: { objective: string; measures: string[] }; - company_measure: number; - asking: TeamForm; - owning: TeamForm; - owner_measure: number; - dependency: string; - work_items: WorkRow[]; - plan: { title: string; text: string }; -} -type Issues = Record; - -const MAX_WORK_PER_TEAM = 3; // same cap as the API - -const emptyTeam = (measures: string[]): TeamForm => ({ team: "", lead: "", objective: "", measures }); -const EMPTY: Form = { - company: "", - timeframe: "", - leadership: { team: "", lead: "" }, - company_goal: { objective: "", measures: [""] }, - company_measure: 0, - asking: emptyTeam([]), // optional - owning: emptyTeam([""]), - owner_measure: 0, - dependency: "", - work_items: [ - { team: "asking", task: "", status: "" }, - { team: "owning", task: "", status: "" }, - ], - plan: { title: "", text: "" }, -}; - -/** The request body: trimmed text, optional fields dropped when empty (the server fills in its defaults). */ -function toBody(f: Form) { - const t = (s: string) => s.trim(); - const leadership = { ...(t(f.leadership.team) ? { team: t(f.leadership.team) } : {}), ...(t(f.leadership.lead) ? { lead: t(f.leadership.lead) } : {}) }; - return { - confirm: "replace", - company: t(f.company), - ...(t(f.timeframe) ? { timeframe: t(f.timeframe) } : {}), - ...(Object.keys(leadership).length ? { leadership } : {}), - company_goal: { objective: t(f.company_goal.objective), measures: f.company_goal.measures.map(t) }, - company_measure: f.company_measure, - asking: { ...f.asking, team: t(f.asking.team), lead: t(f.asking.lead), objective: t(f.asking.objective), measures: f.asking.measures.map(t).filter(Boolean) }, - owning: { ...f.owning, team: t(f.owning.team), lead: t(f.owning.lead), objective: t(f.owning.objective), measures: f.owning.measures.map(t) }, - owner_measure: f.owner_measure, - dependency: t(f.dependency), - work_items: f.work_items.map((w) => ({ team: w.team, task: t(w.task), ...(t(w.status) ? { status: t(w.status) } : {}) })), - plan: { ...(t(f.plan.title) ? { title: t(f.plan.title) } : {}), text: t(f.plan.text) }, - }; -} - -/** Plain-language versions of the schema's length messages; anything else (e.g. the cross-field rules) is already plain. */ -function plain(message: string): string { - const min = /expected (?:string|array) to have >=(\d+)/.exec(message); - if (min) return Number(min[1]) <= 1 ? "Fill this in." : `Write at least ${min[1]} characters.`; - const max = /expected (?:string|array) to have <=(\d+)/.exec(message); - if (max) return `Keep this under ${max[1]} characters.`; - if (/^Invalid input/.test(message)) return "Fill this in."; - return message; -} - -const field = "w-full rounded-[10px] border border-zinc-300 bg-white px-3 py-2.5 text-[16px] text-zinc-900 focus:border-violet-700 focus:outline-none"; - -function Field({ label, hint, error, children }: { label: string; hint?: string; error?: string[]; children: ReactNode }) { - return ( - - ); -} - -function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) { - return ( -
-

- {n}. {title} -

- {children} -
- ); -} +// The form itself is components/ScenarioForm.tsx, shared with /new; this page POSTs it to /api/scenario. export default function SetupPage() { const [actor, setActor] = useState(undefined); @@ -136,7 +32,6 @@ export default function SetupPage() { }, []); const set = (fn: (f: Form) => Form) => setForm((f) => fn(structuredClone(f))); - const err = (path: string) => issues[path]; async function submit() { setBusy(true); @@ -146,7 +41,7 @@ export default function SetupPage() { const res = await fetch("/api/scenario", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(toBody(form)), + body: JSON.stringify({ confirm: "replace", ...toBody(form) }), cache: "no-store", }); const data = await res.json().catch(() => ({})); @@ -155,9 +50,7 @@ export default function SetupPage() { return; } if (res.status === 400 && Array.isArray(data.issues)) { - const next: Issues = {}; - for (const i of data.issues as { path: string; message: string }[]) (next[i.path || "form"] ??= []).push(plain(i.message)); - setIssues(next); + setIssues(issuesFrom(data.issues)); setProblem("Some fields need fixing. They are marked below."); } else if (res.status === 413) setProblem("This setup is too long. Shorten the plan or the work items and try again."); else if (res.status === 415) setProblem("The setup could not be sent. Reload the page and try again."); @@ -193,69 +86,7 @@ export default function SetupPage() { , ); - const teamStep = (side: Side, n: number, title: string, extra?: ReactNode) => { - const t = form[side]; - return ( - - - set((f) => ((f[side].team = e.target.value), f))} /> - - - set((f) => ((f[side].lead = e.target.value), f))} /> - - - set((f) => ((f[side].objective = e.target.value), f))} /> - - {t.measures.map((m, i) => ( - 1 && i === form.owner_measure ? `Measure ${i + 1} (measured on today)` : `Measure ${i + 1}`} error={err(`${side}.measures.${i}`)}> -
- set((f) => ((f[side].measures[i] = e.target.value), f))} /> - {side === "owning" && t.measures.length > 1 && ( - - )} - {(side === "asking" || t.measures.length > 1) && ( - - )} -
-
- ))} - {err(`${side}.measures`)?.map((m) => ( -

- {m} -

- ))} - {more && t.measures.length < 5 && ( -
- -
- )} - {extra} -
- ); - }; - - const workCount = (side: Side) => form.work_items.filter((w) => w.team === side).length; - const full = (side: Side) => workCount(side) >= MAX_WORK_PER_TEAM; - const named = (side: Side) => form[side].team.trim() || (side === "asking" ? "the asking team" : "the owning team"); const unplaced = Object.entries(issues).filter(([p]) => p === "form" || p === ""); - const show = (value: string) => more || value !== ""; // an optional field: visible when expanded, or once it has a value - // The two starting rows (one per team) need no team picker; any other mix shows it. - const simpleWork = form.work_items.length === 2 && form.work_items[0].team === "asking" && form.work_items[1].team === "owning"; return page( <> @@ -264,160 +95,7 @@ export default function SetupPage() {

Describe one real disagreement between two teams. Saving replaces everything currently in Align with this scenario.

-
- -

- {more - ? "Optional fields are marked below. Anything left empty is filled in for you." - : "Only the fields needed for the conflict are shown. More detail adds a timeframe, leadership names, more requirements and measures, more work items, statuses and a plan title."} -

-
- - - - set((f) => ((f.company = e.target.value), f))} /> - - {show(form.timeframe) && ( - - set((f) => ((f.timeframe = e.target.value), f))} /> - - )} - {show(form.leadership.team) && ( - - set((f) => ((f.leadership.team = e.target.value), f))} /> - - )} - {show(form.leadership.lead) && ( - - set((f) => ((f.leadership.lead = e.target.value), f))} /> - - )} - - - - - set((f) => ((f.company_goal.objective = e.target.value), f))} /> - - {form.company_goal.measures.map((m, i) => ( - -
- set((f) => ((f.company_goal.measures[i] = e.target.value), f))} /> - {form.company_goal.measures.length > 1 && ( - - )} - {form.company_goal.measures.length > 1 && ( - - )} -
-
- ))} - {[...(err("company_goal.measures") ?? []), ...(err("company_measure") ?? [])].map((m) => ( -

- {m} -

- ))} - {more && form.company_goal.measures.length < 5 && ( - - )} -
- - {teamStep("asking", 3, "The team that asks for the change")} - {teamStep( - "owning", - 4, - "The team that owns the goal being changed", - <> - {err("owner_measure")?.map((m) => ( -

- {m} -

- ))} - -