Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e1a761aa | ||
|
|
54adbd2b3d | ||
|
|
311b4dda59 | ||
|
|
b5ee692843 | ||
|
|
bf8bc2128d | ||
|
|
01904b8f69 | ||
|
|
676900bd46 | ||
|
|
a3b0770205 | ||
|
|
2a30c68b84 | ||
|
|
f8f8f97be7 | ||
|
|
49b7943420 | ||
|
|
19e16bd44f | ||
|
|
3bd490c080 | ||
|
|
4b448109dd | ||
|
|
bc1149c15e | ||
|
|
089953a7cf | ||
|
|
7b25be22e9 | ||
|
|
4e3d179e61 | ||
|
|
ae58482b72 | ||
|
|
b2d40dada0 | ||
|
|
d30a4cce00 | ||
|
|
4cd280e48d | ||
|
|
8738a03893 | ||
|
|
04a01be992 | ||
|
|
812e2df1da | ||
|
|
8c292fb32f | ||
|
|
f45928c311 | ||
|
|
4d24ae8618 | ||
|
|
d790572e2e | ||
|
|
d7b1dd9601 | ||
|
|
0db2d19a22 | ||
|
|
8eb7e6354e | ||
|
|
9014a510a9 | ||
|
|
974e4740ab | ||
|
|
9cd6d39b71 | ||
|
|
143f925fd8 |
+6
-3
@@ -40,9 +40,12 @@ BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||
BETTER_AUTH_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
||||
# Public gateway URL — accessible from the browser, not just the server.
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
||||
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
|
||||
# Directory holding the built SPA bundle (vite build output). When set, the
|
||||
# gateway serves the SPA same-origin; when unset (dev), run the Vite dev
|
||||
# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway.
|
||||
# safe-default: unset in dev — SPA serving is an opt-in production concern
|
||||
#WEB_DIST_DIR=apps/web/dist
|
||||
|
||||
|
||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,3 +23,7 @@ infra/step-ca/dev-password
|
||||
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
||||
# file vanished mid-scan. Ignoring them removes the race.
|
||||
*.timestamp-*.mjs
|
||||
|
||||
# Playwright run artifacts (#1445, P6 E2E gate)
|
||||
apps/web/test-results/
|
||||
apps/web/playwright-report/
|
||||
|
||||
+8
-1
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"integration_trunk": "next",
|
||||
"release_branch": "main"
|
||||
"release_branch": "main",
|
||||
"flow": "trunk-release",
|
||||
"canonical_remote": "https://git.mosaicstack.dev/mosaicstack/stack",
|
||||
"canonical_clone": "host:/src/mosaic-stack",
|
||||
"worktree_root": "host:/src/mosaic-stack-worktrees",
|
||||
"worktree_policy": "orchestrator-precreated",
|
||||
"notes": "next=development/integration; main=production release. Never branch work off main. worktree_policy is TRANSITIONAL: the wrapper worktree consumer is BLOCKED on the J3/#1174 amendment (checked roots + capacity guard); pre-creation is the interim orchestration choice, not closed policy — it becomes a timing choice only after the wrapper can validate this root."
|
||||
}
|
||||
|
||||
+57
-9
@@ -38,10 +38,12 @@ when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
# This avoids from_secret which is blocked on pull_request events.
|
||||
# If the env vars aren't set, turbo falls back to local cache only.
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
|
||||
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
|
||||
# deployment — never pull_request). This PR pipeline deliberately gets no
|
||||
# remote-cache credentials: an untrusted PR must not be able to write to (or
|
||||
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
|
||||
# local cache only, which is the intended behavior here.
|
||||
|
||||
steps:
|
||||
install:
|
||||
@@ -100,11 +102,6 @@ steps:
|
||||
# repo. Pins that comment BODIES render on both paths and that a tea
|
||||
# failure is named as what it was (git-config vs credential).
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
|
||||
# Hermetic regression for mint-seat-credential.sh (fleet onboarding moved into
|
||||
# the framework): mock curl, sandboxed brain home, no tea, no network. Pins
|
||||
# that the admin seat is configured rather than hardcoded and that the seat
|
||||
# slot is written from the mint response at mode 600.
|
||||
- bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh
|
||||
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||
# endpoints and ordinary commands through. Both directions are asserted —
|
||||
@@ -118,6 +115,40 @@ steps:
|
||||
# stub supplies the scale instead of the host's own checkout.
|
||||
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
||||
|
||||
# Canonical repo-structure declaration gate (T51 WP5c, spec §5.4 point 2):
|
||||
# .mosaic/repo.json is the machine-readable structure SSOT consumed by git
|
||||
# wrappers and the T32 gate seat; this is its repo-side CI enforcement.
|
||||
# Path-conditional: runs when the declaration, the vendored validator, or this
|
||||
# pipeline config changes (manual runs always include it). Fails the pipeline
|
||||
# on any VALIDATION_ERROR and enforces the schema_version 2 authoring rule
|
||||
# (--require-v2: edited/new declarations may not stay v1). The validator is
|
||||
# vendored into the framework tree (spec §5.1 final home) — provenance in its
|
||||
# header; the hostile-input suite (101 arms, hermetic) runs alongside so the
|
||||
# gate's own instrument ships in the same commit as the gate.
|
||||
structure-declaration:
|
||||
image: *node_image
|
||||
commands:
|
||||
- apk add --no-cache bash git
|
||||
# MOSAIC_HOST_ROOT is a runtime anchor (spec §1.2a: unset fails closed
|
||||
# for managed validation). CI has no host, so the step provisions an
|
||||
# EXPLICIT fixture root — honest configuration for the resolution path,
|
||||
# never a guess about a real host; the per-host containment checks are
|
||||
# runtime concerns and do not run against a fixture. Grammar, schema,
|
||||
# refs, flow, remote normalization, and path grammar all prove here.
|
||||
- mkdir -p /tmp/t51-ci-hostroot
|
||||
- bash packages/mosaic/framework/tools/structure/validate-repo-json.sh .mosaic/repo.json --require-v2
|
||||
- bash packages/mosaic/framework/tools/structure/test-validate-repo-json.sh
|
||||
environment:
|
||||
MOSAIC_HOST_ROOT: /tmp/t51-ci-hostroot
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include:
|
||||
- '.mosaic/repo.json'
|
||||
- 'packages/mosaic/framework/tools/structure/**'
|
||||
- '.woodpecker/ci.yml'
|
||||
- event: manual
|
||||
|
||||
# Canonical verify:release stage `upgrade-guard`.
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
@@ -223,6 +254,23 @@ steps:
|
||||
depends_on:
|
||||
- typecheck
|
||||
|
||||
# Canonical verify:release stage `build` (#1445, P6): every PR proves the
|
||||
# full workspace build — including the SPA `vite build` — before merge,
|
||||
# instead of leaving build breakage to surface post-merge in publish.yml's
|
||||
# verify step. Same canonical command the publish pipeline's build step runs.
|
||||
build:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
depends_on:
|
||||
# after test, not typecheck: turbo gives `test` a ^build dependency, so
|
||||
# running this step concurrently with test would put two independent
|
||||
# turbo builds on the same shared-workspace dist/ and turbo cache with
|
||||
# no cross-process locking — the same serialization invariant
|
||||
# publish.yml documents for #1411.
|
||||
- test
|
||||
|
||||
services:
|
||||
ci-postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
|
||||
+291
-46
@@ -32,6 +32,11 @@ variables:
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
# #1407: ONE shared anchor for all three image steps. A second main-only
|
||||
# anchor previously gated build-web/build-appservice, so next-lane pushes
|
||||
# published gateway sha images with no web/appservice counterpart — no
|
||||
# sha-parity set existed for next-lane containerized deploys. Every image
|
||||
# step now builds on next too (sha-only destinations, enforced per step).
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
@@ -44,16 +49,6 @@ variables:
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main, next]
|
||||
@@ -73,6 +68,13 @@ steps:
|
||||
# being empty) and on any incomplete verification.
|
||||
verify:
|
||||
image: *node_image
|
||||
environment:
|
||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
||||
@@ -108,6 +110,13 @@ steps:
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
environment:
|
||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
@@ -202,6 +211,20 @@ steps:
|
||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||
# #1404: snapshot every publishable manifest BEFORE the transform so the
|
||||
# workspace can be restored byte-exact after publish. The transform
|
||||
# rewrites package.json in place (needed: pnpm publish reads the
|
||||
# workspace manifests); without restore, later steps in this pipeline
|
||||
# (build-gateway kaniko COPY + pnpm install --frozen-lockfile) see
|
||||
# manifests that no longer match pnpm-lock.yaml and fail
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE. Snapshot dir is step-local tmp.
|
||||
SNAPSHOT_DIR="$(mktemp -d /tmp/publish-next-manifests.XXXXXX)"
|
||||
export SNAPSHOT_DIR
|
||||
find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*" | while read -r mf; do
|
||||
mkdir -p "$SNAPSHOT_DIR/$(dirname "$mf")"
|
||||
cp -p "$mf" "$SNAPSHOT_DIR/$mf"
|
||||
done
|
||||
echo "[publish-next] snapshotted $(find "$SNAPSHOT_DIR" -name package.json | wc -l) manifests to $SNAPSHOT_DIR"
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
@@ -209,23 +232,38 @@ steps:
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
const exactVersions = new Map(); // name -> bumped next version
|
||||
|
||||
function walk(dir) {
|
||||
function walk(dir, visit) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
|
||||
visit(manifest, packagePath);
|
||||
}
|
||||
}
|
||||
walk(fullPath, visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
// #1389: two passes. Pass 1 bumps every publishable manifest to
|
||||
// <stable+1>-next.<pipeline> exactly as before, recording name ->
|
||||
// bumped version. Pass 2 rewrites every published manifest's
|
||||
// @mosaicstack/* dependency entries (dependencies, devDependencies,
|
||||
// peerDependencies, optionalDependencies) to the EXACT same-pipeline
|
||||
// build. A caret range like ^0.0.3-next.2636 leaves the resolver free
|
||||
// to pick any later build — and on a host with a stale cache, an
|
||||
// installer-side scaffold pinned at stable, or a registry hiccup, that
|
||||
// freedom is how a "next" install ends up executing stable-era code
|
||||
// (web1 evidence: old tier validator, missing migrations). Exact pins
|
||||
// make the defect class unrepresentable regardless of resolver path.
|
||||
function bump(manifest, packagePath) {
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
@@ -234,13 +272,40 @@ steps:
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
exactVersions.set(manifest.name, manifest.version);
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
let pinnedEntries = 0;
|
||||
function pin(manifest, packagePath) {
|
||||
let changed = false;
|
||||
for (const field of DEP_FIELDS) {
|
||||
const deps = manifest[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [name, range] of Object.entries(deps)) {
|
||||
if (!name.startsWith('@mosaicstack/')) continue;
|
||||
const exact = exactVersions.get(name);
|
||||
if (!exact) {
|
||||
throw new Error(
|
||||
manifest.name + ' depends on ' + name +
|
||||
' which has no bumped version in this publish set — cannot pin');
|
||||
}
|
||||
if (range === exact) continue;
|
||||
deps[name] = exact;
|
||||
pinnedEntries++;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root, bump);
|
||||
for (const root of roots) walk(root, pin);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
console.log('[publish-next] pinned ' + pinnedEntries + ' @mosaicstack/* dep entries to exact same-pipeline versions across ' + updated.length + ' manifests');
|
||||
NODE
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||
@@ -250,10 +315,188 @@ steps:
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
# #1389 post-publish guard: every freshly published manifest must carry
|
||||
# EXACT same-pipeline @mosaicstack/* dep pins (no ranges, no stable
|
||||
# fallback). A leak here fails the pipeline instead of shipping.
|
||||
node <<'GUARD'
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const registry = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const published = [];
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const m = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (m.name?.startsWith('@mosaicstack/') && !m.private) published.push(m.name);
|
||||
}
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const root of roots) walk(root);
|
||||
let failures = 0;
|
||||
for (const name of published) {
|
||||
let manifest;
|
||||
try {
|
||||
const out = execFileSync('npm', ['view', name + '@next', '--json', '--registry', registry],
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
||||
const arr = JSON.parse(out);
|
||||
manifest = Array.isArray(arr) ? arr[arr.length - 1] : arr;
|
||||
} catch (e) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ': npm view failed: ' + e.message);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
for (const field of fields) {
|
||||
const deps = manifest[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [dep, range] of Object.entries(deps)) {
|
||||
if (!dep.startsWith('@mosaicstack/')) continue;
|
||||
const expected = dep === name ? manifest.version : null;
|
||||
const isExactPin = /^\d+\.\d+\.\d+-next\./.test(range);
|
||||
const samePipeline = range.endsWith('-next.' + pipelineNumber);
|
||||
if (!isExactPin) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' range "' + range + '" is not an exact -next pin (stable-leak class, #1389)');
|
||||
failures++;
|
||||
} else if (!samePipeline) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' pinned "' + range + '" but this pipeline published -next.' + pipelineNumber + ' (cross-pipeline pin)');
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures > 0) {
|
||||
console.error('[publish-next-guard] FATAL: ' + failures + ' dep-pin violation(s) — stable-dep leak into next publish (#1389)');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[publish-next-guard] OK: all ' + published.length + ' published manifests carry exact same-pipeline @mosaicstack/* dep pins');
|
||||
GUARD
|
||||
# #1404 restore: put the workspace manifests back byte-exact so later
|
||||
# steps (build-gateway frozen-lockfile install) see the committed tree.
|
||||
RESTORE_FAIL=0
|
||||
while read -r mf; do
|
||||
if [ -f "$SNAPSHOT_DIR/$mf" ]; then
|
||||
cp -p "$SNAPSHOT_DIR/$mf" "$mf"
|
||||
else
|
||||
echo "[publish-next] FATAL: no snapshot for $mf — cannot restore (snapshot incomplete?)" >&2
|
||||
RESTORE_FAIL=1
|
||||
fi
|
||||
done < <(find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*")
|
||||
# Pristine guard (#1404 red-first control): the publish step must leave
|
||||
# the workspace byte-identical to the checkout for every manifest.
|
||||
# git diff is the arbiter — any residual mutation fails THIS step
|
||||
# instead of surfacing as ERR_PNPM_OUTDATED_LOCKFILE in build-gateway.
|
||||
if ! git diff --exit-code -- '**/package.json' >/dev/null 2>&1; then
|
||||
echo "[publish-next] FATAL: workspace package.json files still differ from HEAD after restore (#1404 class)" >&2
|
||||
git diff --stat -- '**/package.json' >&2 || true
|
||||
RESTORE_FAIL=1
|
||||
fi
|
||||
rm -rf "$SNAPSHOT_DIR"
|
||||
if [ "$RESTORE_FAIL" -ne 0 ]; then exit 1; fi
|
||||
echo "[publish-next] workspace manifests restored byte-exact (git diff clean); later steps see the committed tree"
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
|
||||
# #1445 (P6): headless Playwright E2E gate on every trunk merge. Boots the
|
||||
# real gateway on the embedded PGlite path (no DATABASE_URL, no services)
|
||||
# serving the built SPA bundle via WEB_DIST_DIR — the exact serving path the
|
||||
# gateway image ships (docker/gateway.Dockerfile sets WEB_DIST_DIR to the
|
||||
# baked bundle), which keeps #1407's parity guarantee: the image build steps
|
||||
# below depend on this gate, so a bundle that fails E2E never publishes.
|
||||
#
|
||||
# Image pinned to the @playwright/test version in pnpm-lock.yaml so the
|
||||
# image's bundled browsers match the workspace driver exactly (bump the two
|
||||
# together). The step installs no workspace packages (corepack does fetch
|
||||
# the pinned pnpm itself): it reuses the workspace node_modules
|
||||
# from `install` and the dist outputs from `build` — the gateway's runtime
|
||||
# dependency path is pure JS/WASM (PGlite is WASM, postgres-js is pure JS),
|
||||
# so the alpine-installed modules run unchanged under this glibc image.
|
||||
# depends_on publish-next-npm per the #1411 serialization invariant: this
|
||||
# step reads the workspace and must never run inside the manifest-transform
|
||||
# window.
|
||||
e2e:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
environment:
|
||||
GATEWAY_PORT: '14242'
|
||||
PLAYWRIGHT_BASE_URL: http://localhost:14242
|
||||
# The database is seeded by Playwright's globalSetup in this step, so
|
||||
# login failures are real failures: without this flag the suite's
|
||||
# skip-when-login-fails guards (a live-environment affordance) could
|
||||
# skip every authenticated spec and go green while proving nothing.
|
||||
E2E_REQUIRE_SEEDED_AUTH: '1'
|
||||
commands:
|
||||
- corepack enable
|
||||
- |
|
||||
# Throwaway signing secret for this step's ephemeral embedded database
|
||||
# (the gateway refuses to boot without one). Generated per run so no
|
||||
# usable literal lives in the tree.
|
||||
export BETTER_AUTH_SECRET="$(head -c 32 /dev/urandom | base64)"
|
||||
export WEB_DIST_DIR="$(pwd)/apps/web/dist"
|
||||
if [ ! -f "$WEB_DIST_DIR/index.html" ]; then
|
||||
echo "[e2e] FATAL: $WEB_DIST_DIR/index.html missing — did the build step run?" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Boot the gateway from the built dist, cwd- AND HOME-isolated: the
|
||||
# local-tier PGlite database lives under $HOME/.config/mosaic/gateway/
|
||||
# (database.module.ts), not under cwd, so HOME must point at the
|
||||
# throwaway dir too or the run would share a database with anything
|
||||
# else in the container's home.
|
||||
GATEWAY_RUN_DIR="$(mktemp -d /tmp/e2e-gateway.XXXXXX)"
|
||||
(cd "$GATEWAY_RUN_DIR" && export HOME="$GATEWAY_RUN_DIR" && exec node "$OLDPWD/apps/gateway/dist/main.js") > /tmp/gateway.log 2>&1 &
|
||||
GATEWAY_PID=$!
|
||||
ready=0
|
||||
for i in $(seq 1 90); do
|
||||
if node -e "fetch('http://localhost:' + process.env.GATEWAY_PORT + '/health', { signal: AbortSignal.timeout(2000) }).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
|
||||
echo "[e2e] FATAL: gateway process exited during startup" >&2
|
||||
cat /tmp/gateway.log >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[e2e] waiting for gateway ($i/90)..."
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "[e2e] FATAL: gateway did not become ready in 90s" >&2
|
||||
cat /tmp/gateway.log >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[e2e] gateway ready; running Playwright suite"
|
||||
set +e
|
||||
pnpm --filter @mosaicstack/web exec playwright test
|
||||
E2E_EXIT=$?
|
||||
set -e
|
||||
kill "$GATEWAY_PID" 2>/dev/null || true
|
||||
if [ "$E2E_EXIT" -ne 0 ]; then
|
||||
echo "[e2e] FATAL: Playwright suite failed (exit $E2E_EXIT); gateway log follows" >&2
|
||||
tail -100 /tmp/gateway.log >&2
|
||||
echo "[e2e] browser-side traces/screenshots are under apps/web/test-results/ in the step workspace (not persisted past the pod)" >&2
|
||||
fi
|
||||
exit "$E2E_EXIT"
|
||||
# Same filter as the image builds it gates: a merge that publishes no
|
||||
# image (docs-only on main) pays no browser suite, and a skipped e2e does
|
||||
# not block anything (skipped-dependency semantics, same as
|
||||
# publish-next-npm on tag events).
|
||||
when: *image_build_when
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: never read the workspace inside publish-next-npm's
|
||||
# manifest-transform window.
|
||||
- publish-next-npm
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
@@ -305,10 +548,20 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||
# its transform window and restores them at step end. Any step that
|
||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||
# pipeline 2648 raced a COPY inside the window and failed
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
|
||||
- e2e
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -322,8 +575,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next appservice publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||
@@ -332,30 +594,13 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
REGISTRY_PASS:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||
# its transform window and restores them at step end. Any step that
|
||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||
# pipeline 2648 raced a COPY inside the window and failed
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
|
||||
- e2e
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@mariozechner/pi-ai": "^0.65.0",
|
||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// The module under test imports @mosaicstack/db at module scope; we replace only the
|
||||
// pieces DatabaseModule uses (partial mock — the real module also exports the
|
||||
// schema the storage adapter's import chain needs) so the test pins the #1392
|
||||
// contract (refuse to start on an incomplete schema) without a live database.
|
||||
vi.mock('@mosaicstack/db', async (importOriginal) => {
|
||||
const actual: object = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
createDb: vi.fn(),
|
||||
createPgliteDb: vi.fn(),
|
||||
getMigrationStatus: vi.fn(),
|
||||
runPgliteMigrations: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { DatabaseModule } from './database.module.js';
|
||||
import { getMigrationStatus } from '@mosaicstack/db';
|
||||
import type { DbHandle } from '@mosaicstack/db';
|
||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
|
||||
function makeModule(storageType: 'postgres' | 'pglite', tier: string) {
|
||||
const storageAdapter = {
|
||||
name: storageType,
|
||||
migrate: vi.fn(),
|
||||
close: vi.fn(),
|
||||
} as unknown as StorageAdapter;
|
||||
const handle = { close: vi.fn() } as unknown as DbHandle;
|
||||
const config = {
|
||||
tier,
|
||||
storage: { type: storageType, url: 'postgresql://x' },
|
||||
} as unknown as MosaicConfig;
|
||||
return {
|
||||
mod: new DatabaseModule(handle, storageAdapter, config),
|
||||
storageAdapter,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DatabaseModule.onModuleInit — #1392 schema verification', () => {
|
||||
it('refuses to start when the postgres schema is incomplete', async () => {
|
||||
const { mod, storageAdapter } = makeModule('postgres', 'standalone');
|
||||
vi.mocked(getMigrationStatus).mockResolvedValue({
|
||||
appliedCount: 15,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_salty_morlocks',
|
||||
complete: false,
|
||||
});
|
||||
await expect(mod.onModuleInit()).rejects.toThrow('Database schema incomplete: 15/17');
|
||||
expect(storageAdapter.migrate).toHaveBeenCalled(); // migrations attempted first
|
||||
});
|
||||
|
||||
it('starts normally when the schema is complete', async () => {
|
||||
const { mod } = makeModule('postgres', 'standalone');
|
||||
vi.mocked(getMigrationStatus).mockResolvedValue({
|
||||
appliedCount: 17,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_salty_morlocks',
|
||||
complete: true,
|
||||
});
|
||||
await expect(mod.onModuleInit()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not verify postgres status for the local tier (PGlite migrates itself)', async () => {
|
||||
const { mod } = makeModule('pglite', 'local');
|
||||
vi.mocked(getMigrationStatus).mockClear();
|
||||
await expect(mod.onModuleInit()).resolves.toBeUndefined();
|
||||
expect(getMigrationStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
createDb,
|
||||
createPgliteDb,
|
||||
getMigrationStatus,
|
||||
runPgliteMigrations,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
@@ -74,6 +75,11 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||
// the same DATABASE_URL, so a single call covers both the gateway DB and
|
||||
// the storage tables. We deliberately do NOT call runMigrations() here to
|
||||
// avoid opening a second short-lived connection and doubling startup cost.
|
||||
//
|
||||
// #1392: we DO verify afterwards (getMigrationStatus opens one short-lived
|
||||
// connection) and refuse to start on an incomplete schema. A gateway that
|
||||
// boots "healthy" on an empty or partial database is precisely the failure
|
||||
// that shipped in the T63 batch: silent at startup, catastrophic later.
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (this.config.tier === 'local') {
|
||||
this.logger.log('Applying PGlite schema migrations...');
|
||||
@@ -81,6 +87,24 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||
}
|
||||
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
|
||||
await this.storageAdapter.migrate();
|
||||
|
||||
if (this.config.storage.type === 'postgres') {
|
||||
const status = await getMigrationStatus(this.config.storage.url);
|
||||
if (!status.complete) {
|
||||
this.logger.error(
|
||||
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied ` +
|
||||
`(last expected: ${status.expectedLastTag}). ` +
|
||||
'Refusing to start on a partial schema — see issues #1392/#1402. ' +
|
||||
"Remediation: re-run 'mosaic gateway install' (it now verifies), or apply migrations manually.",
|
||||
);
|
||||
throw new Error(
|
||||
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Database schema verified: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
|
||||
@@ -12,12 +12,19 @@ import { AppModule } from './app.module.js';
|
||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { mountSpaStatic } from './spa/serve-spa.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
|
||||
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
|
||||
// every request body into a 400 at first use; this surfaces it at boot.
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
|
||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required');
|
||||
}
|
||||
@@ -62,6 +69,7 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
mountAuthHandler(app);
|
||||
mountMcpHandler(app, app.get(McpService));
|
||||
await mountSpaStatic(app);
|
||||
|
||||
const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* E2E integration test — SPA static serving (Phase P5 cutover, #1444; tests
|
||||
* added in P6, #1445, review follow-up SF1 on PR #1453).
|
||||
*
|
||||
* Boots a real Nest+Fastify app the way main.ts does (mountSpaStatic after the
|
||||
* controllers) against a fixture dist directory, and pins the serving
|
||||
* contract:
|
||||
*
|
||||
* 1. `/` and client-side deep links fall back to index.html.
|
||||
* 2. Declared API routes win over the catch-all.
|
||||
* 3. Unknown backend paths (/api, /mcp, /socket.io) are JSON 404s, never the
|
||||
* SPA page — including with a query string (`/api?x=1`).
|
||||
* 4. Static files are served exactly; hashed /assets/ files get immutable
|
||||
* cache headers, everything else revalidates (max-age=0), and a missing
|
||||
* /assets/ file is a 404 — never the SPA fallback.
|
||||
* 5. WEB_DIST_DIR unset disables SPA serving entirely.
|
||||
* 6. WEB_DIST_DIR pointing at a directory without index.html fails at boot.
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Controller, Get, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import request from 'supertest';
|
||||
import { mountSpaStatic } from './serve-spa.js';
|
||||
|
||||
const INDEX_HTML = '<!doctype html><html><body>mosaic spa fixture</body></html>\n';
|
||||
const ASSET_JS = 'console.log("hashed asset");\n';
|
||||
|
||||
@Controller('api/spa-test')
|
||||
class SpaTestController {
|
||||
@Get('ping')
|
||||
ping(): { ok: boolean } {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function createApp(): Promise<INestApplication> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [SpaTestController],
|
||||
}).compile();
|
||||
|
||||
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
// Mirror main.ts ordering: SPA mounting happens after the app (and its
|
||||
// controllers) exist, before listen.
|
||||
await mountSpaStatic(app as NestFastifyApplication);
|
||||
await (app as NestFastifyApplication).getHttpAdapter().getInstance().ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('SPA static serving — fixture dist dir', () => {
|
||||
let app: INestApplication;
|
||||
let distDir: string;
|
||||
let previousWebDistDir: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
distDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-fixture-'));
|
||||
await writeFile(path.join(distDir, 'index.html'), INDEX_HTML);
|
||||
await writeFile(path.join(distDir, 'favicon.svg'), '<svg></svg>\n');
|
||||
await mkdir(path.join(distDir, 'assets'), { recursive: true });
|
||||
await writeFile(path.join(distDir, 'assets', 'app-abc123.js'), ASSET_JS);
|
||||
|
||||
previousWebDistDir = process.env['WEB_DIST_DIR'];
|
||||
process.env['WEB_DIST_DIR'] = distDir;
|
||||
app = await createApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousWebDistDir === undefined) {
|
||||
delete process.env['WEB_DIST_DIR'];
|
||||
} else {
|
||||
process.env['WEB_DIST_DIR'] = previousWebDistDir;
|
||||
}
|
||||
await app.close();
|
||||
await rm(distDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('serves index.html at /', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe(INDEX_HTML);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
});
|
||||
|
||||
it('falls back to index.html for client-side deep links', async () => {
|
||||
for (const deepLink of ['/chat', '/projects/42', '/settings']) {
|
||||
const res = await request(app.getHttpServer()).get(deepLink);
|
||||
expect(res.status, deepLink).toBe(200);
|
||||
expect(res.text, deepLink).toBe(INDEX_HTML);
|
||||
}
|
||||
});
|
||||
|
||||
it('declared API routes win over the SPA catch-all', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/spa-test/ping');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('unknown backend paths are JSON 404s, never the SPA page', async () => {
|
||||
for (const backendPath of ['/api/nope', '/api', '/mcp/nope', '/socket.io/nope']) {
|
||||
const res = await request(app.getHttpServer()).get(backendPath);
|
||||
expect(res.status, backendPath).toBe(404);
|
||||
expect(res.headers['content-type'], backendPath).toContain('application/json');
|
||||
expect(res.body, backendPath).toMatchObject({ error: 'Not Found', statusCode: 404 });
|
||||
}
|
||||
});
|
||||
|
||||
it('a backend path with a query string is still a backend 404 (/api?x=1)', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api?x=1');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
});
|
||||
|
||||
it('serves static files exactly', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/favicon.svg');
|
||||
expect(res.status).toBe(200);
|
||||
// supertest buffers image/svg+xml as a Buffer body, not res.text.
|
||||
const body = res.text || (res.body as Buffer).toString('utf8');
|
||||
expect(body).toBe('<svg></svg>\n');
|
||||
});
|
||||
|
||||
it('hashed /assets/ files get immutable cache headers', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/assets/app-abc123.js');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe(ASSET_JS);
|
||||
expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
});
|
||||
|
||||
it('missing /assets/ files are 404s, never the SPA page with an immutable header', async () => {
|
||||
// The exact request a browser with a stale index.html makes after a
|
||||
// deploy: the old hashed filename. Serving index.html here would poison
|
||||
// caches with a year-long immutable entry whose body is HTML.
|
||||
for (const missingAsset of ['/assets/app-old999.js', '/assets/app-old999.js?v=1']) {
|
||||
const res = await request(app.getHttpServer()).get(missingAsset);
|
||||
expect(res.status, missingAsset).toBe(404);
|
||||
expect(res.text, missingAsset).not.toContain('mosaic spa fixture');
|
||||
// The 404 carries no cache-control at all; ?? '' keeps the assertion valid.
|
||||
expect(res.headers['cache-control'] ?? '', missingAsset).not.toContain('immutable');
|
||||
}
|
||||
});
|
||||
|
||||
it('index.html and non-asset files revalidate (no immutable caching)', async () => {
|
||||
for (const revalidating of ['/', '/chat', '/favicon.svg']) {
|
||||
const res = await request(app.getHttpServer()).get(revalidating);
|
||||
expect(res.headers['cache-control'], revalidating).not.toContain('immutable');
|
||||
}
|
||||
});
|
||||
|
||||
it('non-GET unmatched requests keep the stock 404 (catch-all is GET/HEAD only)', async () => {
|
||||
const res = await request(app.getHttpServer()).post('/chat');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.text).not.toContain('mosaic spa fixture');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SPA static serving — configuration edges', () => {
|
||||
it('WEB_DIST_DIR unset disables SPA serving', async () => {
|
||||
const previous = process.env['WEB_DIST_DIR'];
|
||||
delete process.env['WEB_DIST_DIR'];
|
||||
try {
|
||||
const app = await createApp();
|
||||
const res = await request(app.getHttpServer()).get('/chat');
|
||||
expect(res.status).toBe(404);
|
||||
await app.close();
|
||||
} finally {
|
||||
if (previous !== undefined) {
|
||||
process.env['WEB_DIST_DIR'] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('WEB_DIST_DIR without index.html fails at boot', async () => {
|
||||
const emptyDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-empty-'));
|
||||
const previous = process.env['WEB_DIST_DIR'];
|
||||
process.env['WEB_DIST_DIR'] = emptyDir;
|
||||
try {
|
||||
await expect(createApp()).rejects.toThrow(/index\.html.*does not exist/);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env['WEB_DIST_DIR'];
|
||||
} else {
|
||||
process.env['WEB_DIST_DIR'] = previous;
|
||||
}
|
||||
await rm(emptyDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
|
||||
/** Request paths that belong to the backend, never to the SPA fallback. */
|
||||
const BACKEND_PREFIXES = ['/api', '/mcp', '/socket.io'] as const;
|
||||
|
||||
function isBackendPath(url: string): boolean {
|
||||
// Match on the path only: `/api?x=1` is a backend request, and the query
|
||||
// string must never turn it into an SPA fallback.
|
||||
const pathOnly = url.split('?', 1)[0] ?? url;
|
||||
return BACKEND_PREFIXES.some(
|
||||
(prefix) => pathOnly === prefix || pathOnly.startsWith(`${prefix}/`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the built web SPA bundle (Phase P5 cutover, #1444).
|
||||
*
|
||||
* WEB_DIST_DIR unset: SPA serving is disabled — dev runs the Vite dev server,
|
||||
* which proxies /api and /socket.io here. WEB_DIST_DIR set but not holding a
|
||||
* built bundle: fail at boot, because a gateway configured to serve the UI
|
||||
* silently serving 404s is an outage, not a degraded mode.
|
||||
*
|
||||
* Static files get exact routes (wildcard: false, so nothing shadows the API
|
||||
* routes); every other GET/HEAD outside the backend prefixes falls back to
|
||||
* index.html so client-side routes deep-link correctly.
|
||||
*/
|
||||
export async function mountSpaStatic(app: NestFastifyApplication): Promise<void> {
|
||||
const logger = new Logger('SpaStatic');
|
||||
const distDir = process.env['WEB_DIST_DIR'];
|
||||
if (!distDir) {
|
||||
logger.log('WEB_DIST_DIR not set; SPA serving disabled (dev mode uses the Vite dev server)');
|
||||
return;
|
||||
}
|
||||
|
||||
const root = path.resolve(distDir);
|
||||
const indexFile = path.join(root, 'index.html');
|
||||
if (!existsSync(indexFile)) {
|
||||
throw new Error(`WEB_DIST_DIR is '${distDir}' but '${indexFile}' does not exist`);
|
||||
}
|
||||
|
||||
// Default cache semantics: public, max-age=0 with ETag/Last-Modified, so
|
||||
// every response revalidates (304 when unchanged). Always correct, including
|
||||
// for index.html after a deploy.
|
||||
await app.register(
|
||||
fastifyStatic as never,
|
||||
{
|
||||
root,
|
||||
wildcard: false,
|
||||
index: false,
|
||||
} as never,
|
||||
);
|
||||
|
||||
const fastify = app.getHttpAdapter().getInstance();
|
||||
|
||||
// Files under /assets/ carry a content hash in their name (Vite emits them
|
||||
// that way), so they get long-lived immutable caching: a changed file is a
|
||||
// new URL, never a stale cache hit. An onSend hook rather than the plugin's
|
||||
// `setHeaders` option, because @fastify/static applies its own computed
|
||||
// cache-control (reply.headers) after calling setHeaders, overriding it.
|
||||
fastify.addHook('onSend', (req, reply, payload, done) => {
|
||||
const pathOnly = (req.raw.url ?? '').split('?', 1)[0] ?? '';
|
||||
if (reply.statusCode === 200 && pathOnly.startsWith('/assets/')) {
|
||||
void reply.header('cache-control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
done(null, payload);
|
||||
});
|
||||
|
||||
// A wildcard route, not setNotFoundHandler: Nest installs its own not-found
|
||||
// handler during init and Fastify allows only one. find-my-way matches
|
||||
// most-specific-first, so every declared route (API, static files) wins over
|
||||
// this catch-all; non-GET unmatched requests keep Fastify's stock 404.
|
||||
fastify.get('/*', (req, reply) => {
|
||||
const url = req.raw.url ?? '';
|
||||
const pathOnly = url.split('?', 1)[0] ?? url;
|
||||
if (isBackendPath(url)) {
|
||||
// An unknown backend path is an API 404, never the SPA page.
|
||||
void reply.code(404).send({
|
||||
message: `Route ${req.raw.method ?? 'GET'}:${url} not found`,
|
||||
error: 'Not Found',
|
||||
statusCode: 404,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathOnly === '/assets' || pathOnly.startsWith('/assets/')) {
|
||||
// A missing hashed asset — typically a browser holding a stale
|
||||
// index.html after a deploy — must 404. Falling through to the SPA
|
||||
// fallback would return index.html as the asset body, and the onSend
|
||||
// hook above would stamp it with a year-long immutable cache-control.
|
||||
void reply.code(404).send({
|
||||
message: `Asset ${pathOnly} not found`,
|
||||
error: 'Not Found',
|
||||
statusCode: 404,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// sendFile is decorated by @fastify/static; its type augmentation targets
|
||||
// a different fastify copy in the pnpm tree than the Nest adapter's.
|
||||
(reply as unknown as { sendFile: (file: string) => unknown }).sendFile('index.html');
|
||||
});
|
||||
|
||||
logger.log(`Serving SPA bundle from ${root}`);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Boot-time ValidationPipe metatype self-check (#1391).
|
||||
*
|
||||
* The check exists to fail loud at boot when the global pipe cannot see a
|
||||
* guarded DTO's decorated properties — the #436 class-erasure signature and
|
||||
* its dependency-graph cousins. Red/green arms:
|
||||
*
|
||||
* GREEN real module state: BootstrapSetupDto's three properties are
|
||||
* decorated and visible through the globalThis-shared storage.
|
||||
* RED a control class with NO decorators (the erasure shape): the
|
||||
* check throws PipeMetatypeCheckError naming every property.
|
||||
* RED-2 a control where one property is decorated and two are not: the
|
||||
* error names exactly the missing two — the miss list is precise,
|
||||
* not a blanket failure.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IsString } from 'class-validator';
|
||||
import {
|
||||
assertValidationPipeSeesDtoDecorators,
|
||||
PipeMetatypeCheckError,
|
||||
} from './validation-pipe-check.js';
|
||||
|
||||
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
|
||||
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
|
||||
// Simulate metatype erasure: an undecorated class standing where a
|
||||
// decorated DTO should be. Redefine the guard table for the test by
|
||||
// importing the module and pointing its table at the eroded class —
|
||||
// the check reads the table at call time, so a fresh module instance
|
||||
// with a swapped table reproduces the boot failure deterministically.
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class ErodedDto {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
expect(original).toBeDefined();
|
||||
// Swap in the eroded target (same declared properties, zero decorators).
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'ErodedDto',
|
||||
target: ErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('ErodedDto.name');
|
||||
expect(message).toContain('ErodedDto.email');
|
||||
expect(message).toContain('ErodedDto.password');
|
||||
}
|
||||
} finally {
|
||||
// Restore real module state for any later test in this file.
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
// And confirm the restore is real.
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class HalfErodedDto {
|
||||
@IsString()
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'HalfErodedDto',
|
||||
target: HalfErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
expect.unreachable('partially decorated DTO must fail the boot check');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('HalfErodedDto.email');
|
||||
expect(message).toContain('HalfErodedDto.password');
|
||||
expect(message).not.toContain('HalfErodedDto.name has no');
|
||||
}
|
||||
} finally {
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
* decorated properties of the DTOs it guards (#1391, #436 class).
|
||||
*
|
||||
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
|
||||
* `import type` class erasure (#436), or a dependency graph where the
|
||||
* controller's decorators and the application's route enhancers disagree
|
||||
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
|
||||
* ValidationPipe's whitelist treats every property as forbidden. The first
|
||||
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
|
||||
* worst place to discover wiring damage: the operator cannot tell a broken
|
||||
* payload from a broken daemon.
|
||||
*
|
||||
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
|
||||
* decorated properties, the gateway refuses to start with a named cause.
|
||||
* It catches the whole class — erasure, decorator metadata loss — on every
|
||||
* host, at the moment the damage exists rather than at first use.
|
||||
*
|
||||
* Storage sharing note: class-validator keys its metadata storage on
|
||||
* globalThis, so duplicate package copies do NOT hide metadata (measured,
|
||||
* #1391 diagnosis). What hides it is losing the metatype itself, which is
|
||||
* what this asserts against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DTOs the global pipe guards, mapped to the properties the whitelist must
|
||||
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
|
||||
* decorators register metadata keyed on the constructor, and its executor
|
||||
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
|
||||
* through `prototype` returns zero. Extend when adding DTOs to the app.
|
||||
*/
|
||||
export const PIPE_GUARDED_DTOS: Array<{
|
||||
name: string;
|
||||
target: abstract new (...args: never[]) => unknown;
|
||||
properties: string[];
|
||||
}> = [
|
||||
{
|
||||
name: 'BootstrapSetupDto',
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
constructor(missing: string[]) {
|
||||
super(
|
||||
'ValidationPipe metatype check failed: ' +
|
||||
missing.join('; ') +
|
||||
'. The global ValidationPipe cannot see decorated DTO properties — ' +
|
||||
'every request body would be rejected as non-whitelisted. ' +
|
||||
'Check for import-type erasure or decorator metadata loss in the ' +
|
||||
'dependency graph (see issues #436, #1391).',
|
||||
);
|
||||
this.name = 'PipeMetatypeCheckError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the pipe's whitelist can see every guarded DTO's decorated
|
||||
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
|
||||
* each miss. Pure function of module state: no I/O, safe to call twice.
|
||||
*/
|
||||
export function assertValidationPipeSeesDtoDecorators(): void {
|
||||
const storage = getMetadataStorage();
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const dto of PIPE_GUARDED_DTOS) {
|
||||
// class-validator records constraints keyed on the DTO's constructor
|
||||
// (decorators run on the class), and its executor resolves them via
|
||||
// object.constructor. A property with no recorded metadata is invisible
|
||||
// to the whitelist — whatever the cause — and fails here.
|
||||
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
|
||||
// strictGroups, groups?). No schema, always=true, no groups: every
|
||||
// constraint regardless of grouping, which is what the whitelist sees.
|
||||
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
|
||||
const decorated = new Set(metadatas.map((m) => m.propertyName));
|
||||
|
||||
for (const property of dto.properties) {
|
||||
if (!decorated.has(property)) {
|
||||
missing.push(
|
||||
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new PipeMetatypeCheckError(missing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'reflect-metadata';
|
||||
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { TeamsController } from './teams.controller.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
|
||||
const teamBeta = { id: 'team-beta', name: 'Beta' };
|
||||
|
||||
// user-1 is a member of team-alpha only; admin-1 has role admin.
|
||||
let currentUser: { id: string; role?: string } = { id: 'user-1' };
|
||||
|
||||
const teamsServiceMock = {
|
||||
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
|
||||
findAllForUser: vi.fn((userId: string) =>
|
||||
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
|
||||
),
|
||||
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
|
||||
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
|
||||
isMember: vi.fn((teamId: string, userId: string) =>
|
||||
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
|
||||
),
|
||||
};
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: { id: string; role?: string } }>();
|
||||
requestContext.user = currentUser;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
describe('teams endpoints are scoped to membership', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
currentUser = { id: 'user-1' };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns only the teams the user belongs to', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha]);
|
||||
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns every team for an admin', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha, teamBeta]);
|
||||
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns the team for a member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(teamAlpha);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
|
||||
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
|
||||
expect(denied.status).toBe(403);
|
||||
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
|
||||
|
||||
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: false });
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('an admin can look up any membership', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: true });
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,68 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
type RequestUser = { id: string; role?: string };
|
||||
|
||||
@Controller('api/teams')
|
||||
@UseGuards(AuthGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teams: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.teams.findAll();
|
||||
async list(@CurrentUser() user: RequestUser) {
|
||||
if (user.role === 'admin') {
|
||||
return this.teams.findAll();
|
||||
}
|
||||
return this.teams.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
@Get(':teamId')
|
||||
async findOne(@Param('teamId') teamId: string) {
|
||||
return this.teams.findById(teamId);
|
||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
return this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
|
||||
@Get(':teamId/members')
|
||||
async listMembers(@Param('teamId') teamId: string) {
|
||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
return this.teams.listMembers(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members/:userId')
|
||||
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||
async checkMembership(
|
||||
@Param('teamId') teamId: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
// A user may always ask about their own membership; anything else is
|
||||
// team-scoped like the other routes.
|
||||
if (userId !== user.id) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
const isMember = await this.teams.isMember(teamId, userId);
|
||||
return { isMember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-scoped access: admins see any team; everyone else only teams they
|
||||
* are a member of. NotFoundException when the team does not exist and
|
||||
* ForbiddenException when the user lacks access (same convention as the
|
||||
* projects controller).
|
||||
*/
|
||||
private async getAccessibleTeam(teamId: string, user: RequestUser) {
|
||||
const team = await this.teams.findById(teamId);
|
||||
if (!team) throw new NotFoundException('Team not found');
|
||||
if (user.role === 'admin') return team;
|
||||
const isMember = await this.teams.isMember(teamId, user.id);
|
||||
if (!isMember) throw new ForbiddenException('Not a member of this team');
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -56,6 +56,21 @@ export class TeamsService {
|
||||
return this.db.select().from(teams);
|
||||
}
|
||||
|
||||
/**
|
||||
* List only the teams the user is a member of.
|
||||
*/
|
||||
async findAllForUser(userId: string) {
|
||||
const memberRows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, userId));
|
||||
|
||||
const teamIds = memberRows.map((r) => r.teamId);
|
||||
if (teamIds.length === 0) return [];
|
||||
|
||||
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a team by ID.
|
||||
*/
|
||||
|
||||
+20
-28
@@ -1,11 +1,14 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, ADMIN_USER, TEST_USER } from './helpers/auth.js';
|
||||
import { loginAs, ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
test.describe('Admin page — admin user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(!url.includes('/chat'), 'No seeded admin user — skipping admin tests');
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded admin user — skipping admin tests',
|
||||
);
|
||||
});
|
||||
|
||||
test('admin page loads with the Admin Panel heading', async ({ page }) => {
|
||||
@@ -31,15 +34,11 @@ test.describe('Admin page — admin user', () => {
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name: /system health/i }).click();
|
||||
// Health cards or loading indicator should appear
|
||||
const hasLoading = await page
|
||||
const loadingOrCard = page
|
||||
.getByText(/loading health/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasCard = await page
|
||||
.getByText(/database/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasLoading || hasCard).toBe(true);
|
||||
.or(page.getByText(/database/i))
|
||||
.first();
|
||||
await expect(loadingOrCard).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,26 +46,19 @@ test.describe('Admin page — non-admin user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping non-admin tests');
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping non-admin tests',
|
||||
);
|
||||
});
|
||||
|
||||
test('non-admin visiting /admin sees access denied or is redirected', async ({ page }) => {
|
||||
test('non-admin visiting /admin never sees the admin panel', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
// Either redirected away or shown an access-denied message
|
||||
const onAdmin = page.url().includes('/admin');
|
||||
if (onAdmin) {
|
||||
// Should show some access-denied content rather than the full admin panel
|
||||
const hasPanel = await page
|
||||
.getByRole('heading', { name: /admin panel/i })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// If heading is visible, the guard allowed access (user may have admin role in this env)
|
||||
// — not a failure, just informational
|
||||
if (!hasPanel) {
|
||||
// access denied message, redirect, or guard placeholder
|
||||
const url = page.url();
|
||||
expect(url).toBeTruthy(); // environment-dependent — no hard assertion
|
||||
}
|
||||
}
|
||||
// Wait for the app shell to render (redirect and access-denied views both
|
||||
// keep the sidebar), then assert the panel itself is absent. globalSetup
|
||||
// seeds TEST_USER with role 'member', so this is a real authorization
|
||||
// assertion, not environment-dependent.
|
||||
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('heading', { name: /admin panel/i })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_USER } from './helpers/auth.js';
|
||||
import { REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
// ── Login page ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,18 +49,14 @@ test.describe('Login page', () => {
|
||||
});
|
||||
|
||||
test('redirects to /chat after successful login', async ({ page }) => {
|
||||
// Only meaningful with known-good credentials; against a live environment
|
||||
// this would just probe someone else's user table.
|
||||
test.skip(!REQUIRE_SEEDED_AUTH, 'needs seeded credentials (E2E_REQUIRE_SEEDED_AUTH=1)');
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password').fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
// Either reaches /chat or shows an error (if credentials are wrong in this env).
|
||||
// We assert a navigation away from /login, or the alert is shown.
|
||||
await Promise.race([
|
||||
expect(page).toHaveURL(/\/chat/, { timeout: 10_000 }),
|
||||
expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 }),
|
||||
]).catch(() => {
|
||||
// Acceptable — environment may not have seeded credentials
|
||||
});
|
||||
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+19
-26
@@ -1,45 +1,38 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
test.describe('Chat page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
// If login failed (no seeded user in env) we may be on /login — skip
|
||||
const url = page.url();
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping authenticated tests',
|
||||
);
|
||||
});
|
||||
|
||||
test('chat page loads and shows the welcome message or conversation list', async ({ page }) => {
|
||||
test('chat page loads and shows the conversation area', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
// Either there are conversations listed or the welcome empty-state is shown
|
||||
const hasWelcome = await page
|
||||
.getByRole('heading', { name: /welcome to mosaic chat/i })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasConversationPanel = await page
|
||||
.locator('[data-testid="conversation-list"], nav, aside')
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(hasWelcome || hasConversationPanel).toBe(true);
|
||||
await expect(page.getByRole('heading', { level: 1, name: /chat/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByRole('log', { name: /conversation/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('new conversation button is visible', async ({ page }) => {
|
||||
test('message composer input is visible', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
// "Start new conversation" button or a "+" button in the sidebar
|
||||
const newConvButton = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
||||
await expect(newConvButton).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByLabel('Message')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('clicking new conversation shows a chat input area', async ({ page }) => {
|
||||
test('command panel lists /new and exposes the run controls', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
// Find any button that creates a new conversation
|
||||
const newBtn = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
||||
await newBtn.click();
|
||||
// After creating, a text input for sending messages should appear
|
||||
const chatInput = page.getByRole('textbox').or(page.locator('textarea')).first();
|
||||
await expect(chatInput).toBeVisible({ timeout: 10_000 });
|
||||
// Conversations are command-driven: /new starts one via the commands panel.
|
||||
const commandList = page.getByRole('list', { name: /available commands/i });
|
||||
await expect(commandList).toBeVisible({ timeout: 10_000 });
|
||||
await expect(commandList.getByText('/new', { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('Command name')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /run command/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('sidebar navigation is present on chat page', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { FullConfig } from '@playwright/test';
|
||||
import { ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
/**
|
||||
* Seed the E2E users through the gateway's real APIs (#1445, P6).
|
||||
*
|
||||
* On a fresh database (CI boots the gateway on the embedded PGlite path):
|
||||
* 1. POST /api/bootstrap/setup creates ADMIN_USER as the first admin.
|
||||
* 2. The admin signs in and creates TEST_USER via the better-auth admin API.
|
||||
*
|
||||
* Against an environment that already has users (needsSetup=false), seeding is
|
||||
* skipped entirely: the specs keep their own skip-when-login-fails guards, so
|
||||
* a live environment stays usable as a test target without mutation. Under
|
||||
* E2E_REQUIRE_SEEDED_AUTH=1 (CI) that state is instead a hard failure and the
|
||||
* guards are disabled — see helpers/auth.ts.
|
||||
*
|
||||
* On a fresh database, any seeding failure throws and fails the whole run: an
|
||||
* E2E gate whose authenticated suites silently skip would pass while proving
|
||||
* nothing.
|
||||
*/
|
||||
export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
const baseURL = config.projects[0]?.use?.baseURL ?? 'http://localhost:14242';
|
||||
|
||||
const statusRes = await fetch(`${baseURL}/api/bootstrap/status`);
|
||||
if (!statusRes.ok) {
|
||||
throw new Error(`GET /api/bootstrap/status returned ${statusRes.status} — is the gateway up?`);
|
||||
}
|
||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||
if (!status.needsSetup) {
|
||||
if (REQUIRE_SEEDED_AUTH) {
|
||||
// CI boots the gateway on a fresh HOME-isolated database, so an
|
||||
// already-populated one means the isolation regressed — refuse to run
|
||||
// against unknown data rather than skip-and-pass.
|
||||
throw new Error(
|
||||
'E2E_REQUIRE_SEEDED_AUTH=1 but the database already has users — gateway HOME isolation regressed?',
|
||||
);
|
||||
}
|
||||
console.info('[e2e setup] users already exist; skipping seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const setupRes = await fetch(`${baseURL}/api/bootstrap/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: ADMIN_USER.name,
|
||||
email: ADMIN_USER.email,
|
||||
password: ADMIN_USER.password,
|
||||
}),
|
||||
});
|
||||
if (!setupRes.ok) {
|
||||
throw new Error(
|
||||
`POST /api/bootstrap/setup failed (${setupRes.status}): ${await setupRes.text()}`,
|
||||
);
|
||||
}
|
||||
console.info(`[e2e setup] bootstrap admin created: ${ADMIN_USER.email}`);
|
||||
|
||||
// better-auth's CSRF protection rejects requests without an Origin header
|
||||
// (403 MISSING_OR_NULL_ORIGIN), so the server-side fetches here send the
|
||||
// gateway's own origin — the same value a browser tab on the SPA would send.
|
||||
const authHeaders = { 'content-type': 'application/json', origin: baseURL };
|
||||
|
||||
const signInRes = await fetch(`${baseURL}/api/auth/sign-in/email`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({ email: ADMIN_USER.email, password: ADMIN_USER.password }),
|
||||
});
|
||||
if (!signInRes.ok) {
|
||||
throw new Error(`admin sign-in failed (${signInRes.status}): ${await signInRes.text()}`);
|
||||
}
|
||||
const cookies = signInRes.headers
|
||||
.getSetCookie()
|
||||
.map((cookie) => cookie.split(';', 1)[0])
|
||||
.join('; ');
|
||||
if (!cookies) {
|
||||
throw new Error('admin sign-in returned no session cookie');
|
||||
}
|
||||
|
||||
const createRes = await fetch(`${baseURL}/api/auth/admin/create-user`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders, cookie: cookies },
|
||||
body: JSON.stringify({
|
||||
name: TEST_USER.name,
|
||||
email: TEST_USER.email,
|
||||
password: TEST_USER.password,
|
||||
role: 'member',
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
throw new Error(
|
||||
`POST /api/auth/admin/create-user failed (${createRes.status}): ${await createRes.text()}`,
|
||||
);
|
||||
}
|
||||
console.info(`[e2e setup] test user created: ${TEST_USER.email}`);
|
||||
}
|
||||
@@ -13,11 +13,28 @@ export const ADMIN_USER = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Fill the login form and submit. Waits for navigation after success.
|
||||
* Set when the database was seeded by global-setup (CI sets it in the
|
||||
* publish.yml e2e step). Seeded credentials MUST work, so login failures are
|
||||
* hard failures and the skip-when-login-fails guards are disabled — otherwise
|
||||
* a login regression would skip every authenticated suite and the gate would
|
||||
* pass while proving nothing. Unset (a live environment used as a test
|
||||
* target), the guards stay on and unseeded credentials skip their suites.
|
||||
*/
|
||||
export const REQUIRE_SEEDED_AUTH = process.env['E2E_REQUIRE_SEEDED_AUTH'] === '1';
|
||||
|
||||
/**
|
||||
* Fill the login form and submit, then wait for the post-login redirect to
|
||||
* /chat. Under REQUIRE_SEEDED_AUTH a missed redirect throws (failing the
|
||||
* test). Otherwise the timeout is swallowed: the page stays on /login and the
|
||||
* callers' `test.skip(...)` guards see that. Without this wait, every guard
|
||||
* read page.url() before the redirect happened and skipped its suite even
|
||||
* when login succeeded (#1445).
|
||||
*/
|
||||
export async function loginAs(page: Page, email: string, password: string): Promise<void> {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
const redirect = page.waitForURL(/\/chat/, { timeout: 10_000 });
|
||||
await (REQUIRE_SEEDED_AUTH ? redirect : redirect.catch(() => {}));
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
test.describe('Sidebar navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping authenticated tests',
|
||||
);
|
||||
});
|
||||
|
||||
test('sidebar shows Mosaic brand link', async ({ page }) => {
|
||||
test('sidebar shows the Mosaic brand', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
await expect(page.getByRole('link', { name: /mosaic/i }).first()).toBeVisible();
|
||||
// The brand block is a logo image plus "Mosaic / Mission Control" text,
|
||||
// not a link.
|
||||
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible();
|
||||
await expect(page.getByText('Mission Control')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Chat nav link navigates to /chat', async ({ page }) => {
|
||||
@@ -48,11 +54,12 @@ test.describe('Sidebar navigation', () => {
|
||||
|
||||
test('active link is visually highlighted', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
// The active link should have a distinct class — check that the Chat link
|
||||
// has the active style class (bg-blue-600/20 text-blue-400)
|
||||
// The sidebar marks the active item with `font-medium` (plus an inline
|
||||
// primary-color style); inactive items get the hover class instead.
|
||||
const chatLink = page.getByRole('link', { name: /^chat$/i }).first();
|
||||
const cls = await chatLink.getAttribute('class');
|
||||
expect(cls).toContain('blue');
|
||||
const projectsLink = page.getByRole('link', { name: /^projects$/i }).first();
|
||||
await expect(chatLink).toHaveClass(/font-medium/);
|
||||
await expect(projectsLink).not.toHaveClass(/font-medium/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,18 +67,23 @@ test.describe('Route transitions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping authenticated tests',
|
||||
);
|
||||
});
|
||||
|
||||
test('navigating chat → projects → settings → chat works without errors', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
await expect(page).toHaveURL(/\/chat/);
|
||||
|
||||
// level: 1 — empty-state h2s ("No projects yet") also match the loose
|
||||
// patterns, and a two-element match is a strict-mode violation.
|
||||
await page.goto('/projects');
|
||||
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible();
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 1, name: /settings/i })).toBeVisible();
|
||||
|
||||
await page.goto('/chat');
|
||||
await expect(page).toHaveURL(/\/chat/);
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
test.describe('Projects page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping authenticated tests',
|
||||
);
|
||||
});
|
||||
|
||||
test('projects page loads with heading', async ({ page }) => {
|
||||
await page.goto('/projects');
|
||||
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible({ timeout: 10_000 });
|
||||
// level: 1 — the "No projects yet" empty-state h2 also matches /projects/i
|
||||
// and a two-element match is a strict-mode violation.
|
||||
await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('shows empty state or project cards when loaded', async ({ page }) => {
|
||||
@@ -18,23 +25,11 @@ test.describe('Projects page', () => {
|
||||
// Wait for loading state to clear
|
||||
await expect(page.getByText(/loading projects/i)).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const hasProjects = await page
|
||||
const cardsOrEmpty = page
|
||||
.locator('[class*="grid"]')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasEmpty = await page
|
||||
.getByText(/no projects yet/i)
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(hasProjects || hasEmpty).toBe(true);
|
||||
});
|
||||
|
||||
test('shows Active Mission section', async ({ page }) => {
|
||||
await page.goto('/projects');
|
||||
await expect(page.getByRole('heading', { name: /active mission/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
.or(page.getByText(/no projects yet/i))
|
||||
.first();
|
||||
await expect(cardsOrEmpty).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('sidebar navigation is present', async ({ page }) => {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
||||
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||
|
||||
test.describe('Settings page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||
const url = page.url();
|
||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
||||
test.skip(
|
||||
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
|
||||
'No seeded test user — skipping authenticated tests',
|
||||
);
|
||||
});
|
||||
|
||||
test('settings page loads with heading', async ({ page }) => {
|
||||
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
transpilePackages: ['@mosaicstack/design-tokens'],
|
||||
|
||||
// Enable gzip/brotli compression for all responses.
|
||||
compress: true,
|
||||
|
||||
// Reduce bundle size: disable source maps in production builds.
|
||||
productionBrowserSourceMaps: false,
|
||||
|
||||
// Image optimisation: allow the gateway origin as an external image source.
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Experimental: enable React compiler for automatic memoisation (Next 15+).
|
||||
// Falls back gracefully if the compiler plugin is not installed.
|
||||
experimental: {
|
||||
// Turbopack is the default in dev for Next 15; keep it opt-in for now.
|
||||
// turbo: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -3,22 +3,19 @@
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"build:vite": "vite build",
|
||||
"dev": "next dev -p 3101",
|
||||
"dev:vite": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:e2e": "playwright test",
|
||||
"start": "next start -p 3101"
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/design-tokens": "workspace:^",
|
||||
"@mosaicstack/types": "workspace:^",
|
||||
"better-auth": "^1.5.5",
|
||||
"clsx": "^2.1.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright E2E configuration for Mosaic web app.
|
||||
* Playwright E2E configuration for the Mosaic web SPA.
|
||||
*
|
||||
* Assumes:
|
||||
* - Next.js web app running on http://localhost:3000
|
||||
* - NestJS gateway running on http://localhost:14242
|
||||
* Assumes the NestJS gateway is already running on http://localhost:14242 and
|
||||
* serving the built SPA bundle (WEB_DIST_DIR pointing at apps/web/dist) — the
|
||||
* same serving path production uses (Phase P5, #1444). Override the target
|
||||
* with PLAYWRIGHT_BASE_URL.
|
||||
*
|
||||
* global-setup seeds the E2E users through the real bootstrap and admin APIs
|
||||
* when the database is empty; against an already-populated environment it
|
||||
* seeds nothing.
|
||||
*
|
||||
* Run with: pnpm --filter @mosaicstack/web test:e2e
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env['CI'],
|
||||
retries: process.env['CI'] ? 2 : 0,
|
||||
workers: process.env['CI'] ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
// CI needs the verdict in the step log; the html report is a local tool.
|
||||
reporter: process.env['CI'] ? 'list' : 'html',
|
||||
use: {
|
||||
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
|
||||
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:14242',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
@@ -27,6 +34,6 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
// Do NOT auto-start the dev server — tests assume it is already running.
|
||||
// Do NOT auto-start a server — tests assume the gateway is already running.
|
||||
// webServer is intentionally omitted so tests can run against a live env.
|
||||
});
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { GuestGuard } from '@/components/guest-guard';
|
||||
|
||||
export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<GuestGuard>
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg">
|
||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</GuestGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
||||
SsoProviderDiscovery['id'] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
||||
.catch(() => [] as SsoProviderDiscovery[])
|
||||
.then((providers) => setSsoProviders(providers.filter((provider) => provider.configured)));
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const email = form.get('email') as string;
|
||||
const password = form.get('password') as string;
|
||||
|
||||
const result = await signIn.email({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Sign in failed');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/chat');
|
||||
}
|
||||
|
||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
||||
setError(null);
|
||||
setSsoLoadingProviderId(providerId);
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.oauth2({
|
||||
providerId,
|
||||
callbackURL: '/chat',
|
||||
newUserCallbackURL: '/chat',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<SsoProviderButtons
|
||||
providers={ssoProviders}
|
||||
loadingProviderId={ssoLoadingProviderId}
|
||||
onOidcSignIn={(providerId) => {
|
||||
void handleSsoSignIn(providerId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="text-blue-400 hover:text-blue-300">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { signUp } from '@/lib/auth-client';
|
||||
|
||||
export default function RegisterPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const name = form.get('name') as string;
|
||||
const email = form.get('email') as string;
|
||||
const password = form.get('password') as string;
|
||||
|
||||
const result = await signUp.email({ name, email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Registration failed');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/chat');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-blue-400 hover:text-blue-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { destroySocket, getSocket } from '@/lib/socket';
|
||||
import type { Conversation, Message } from '@/lib/types';
|
||||
import {
|
||||
ConversationSidebar,
|
||||
type ConversationSidebarRef,
|
||||
} from '@/components/chat/conversation-sidebar';
|
||||
import { MessageBubble } from '@/components/chat/message-bubble';
|
||||
import { ChatInput } from '@/components/chat/chat-input';
|
||||
import { StreamingMessage } from '@/components/chat/streaming-message';
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
inputTypes: ('text' | 'image')[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
}
|
||||
|
||||
interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
export default function ChatPage(): React.ReactElement {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModelId, setSelectedModelId] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const sidebarRef = useRef<ConversationSidebarRef>(null);
|
||||
|
||||
// Track the active conversation ID in a ref so socket event handlers always
|
||||
// see the current value without needing to be re-registered.
|
||||
const activeIdRef = useRef<string | null>(null);
|
||||
activeIdRef.current = activeId;
|
||||
|
||||
// Accumulate streamed text in a ref so agent:end can read the full content
|
||||
// without stale-closure issues.
|
||||
const streamingTextRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
|
||||
if (savedState !== null) {
|
||||
setIsSidebarOpen(savedState === 'true');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
api<ProviderInfo[]>('/api/providers')
|
||||
.then((providers) => {
|
||||
const availableModels = providers
|
||||
.filter((provider) => provider.available)
|
||||
.flatMap((provider) => provider.models);
|
||||
setModels(availableModels);
|
||||
setSelectedModelId((current) => current || availableModels[0]?.id || '');
|
||||
})
|
||||
.catch(() => {
|
||||
setModels([]);
|
||||
setSelectedModelId('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load messages when active conversation changes
|
||||
useEffect(() => {
|
||||
if (!activeId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
// Clear streaming state when switching conversations
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
||||
.then(setMessages)
|
||||
.catch(() => {});
|
||||
}, [activeId]);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingText]);
|
||||
|
||||
// Socket.io setup — connect once for the page lifetime
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
|
||||
function onAgentStart(data: { conversationId: string }): void {
|
||||
// Only update state if the event belongs to the currently viewed conversation
|
||||
if (activeIdRef.current !== data.conversationId) return;
|
||||
setIsStreaming(true);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
}
|
||||
|
||||
function onAgentText(data: { conversationId: string; text: string }): void {
|
||||
if (activeIdRef.current !== data.conversationId) return;
|
||||
streamingTextRef.current += data.text;
|
||||
setStreamingText((prev) => prev + data.text);
|
||||
}
|
||||
|
||||
function onAgentEnd(data: { conversationId: string }): void {
|
||||
if (activeIdRef.current !== data.conversationId) return;
|
||||
const finalText = streamingTextRef.current;
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
// Append the completed assistant message to the local message list.
|
||||
// The Pi agent session is in-memory so the assistant response is not
|
||||
// persisted to the DB — we build the local UI state instead.
|
||||
if (finalText) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
conversationId: data.conversationId,
|
||||
role: 'assistant' as const,
|
||||
content: finalText,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
sidebarRef.current?.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function onError(data: { error: string; conversationId?: string }): void {
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `error-${Date.now()}`,
|
||||
conversationId: data.conversationId ?? '',
|
||||
role: 'system' as const,
|
||||
content: `Error: ${data.error}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
socket.on('agent:start', onAgentStart);
|
||||
socket.on('agent:text', onAgentText);
|
||||
socket.on('agent:end', onAgentEnd);
|
||||
socket.on('error', onError);
|
||||
|
||||
// Connect if not already connected
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket.off('agent:start', onAgentStart);
|
||||
socket.off('agent:text', onAgentText);
|
||||
socket.off('agent:end', onAgentEnd);
|
||||
socket.off('error', onError);
|
||||
// Fully tear down the socket when the chat page unmounts so we get a
|
||||
// fresh authenticated connection next time the page is visited.
|
||||
destroySocket();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNewConversation = useCallback(async (projectId?: string | null) => {
|
||||
const conv = await api<Conversation>('/api/conversations', {
|
||||
method: 'POST',
|
||||
body: { title: 'New conversation', projectId: projectId ?? null },
|
||||
});
|
||||
|
||||
sidebarRef.current?.addConversation({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
projectId: conv.projectId,
|
||||
updatedAt: conv.updatedAt,
|
||||
archived: conv.archived,
|
||||
});
|
||||
|
||||
setActiveId(conv.id);
|
||||
setMessages([]);
|
||||
setIsSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string, options?: { modelId?: string }) => {
|
||||
let convId = activeId;
|
||||
|
||||
// Auto-create conversation if none selected
|
||||
if (!convId) {
|
||||
const autoTitle = content.slice(0, 60);
|
||||
const conv = await api<Conversation>('/api/conversations', {
|
||||
method: 'POST',
|
||||
body: { title: autoTitle },
|
||||
});
|
||||
sidebarRef.current?.addConversation({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
projectId: conv.projectId,
|
||||
updatedAt: conv.updatedAt,
|
||||
archived: conv.archived,
|
||||
});
|
||||
setActiveId(conv.id);
|
||||
convId = conv.id;
|
||||
} else if (messages.length === 0) {
|
||||
// Auto-title the initial placeholder conversation from the first user message.
|
||||
const autoTitle = content.slice(0, 60);
|
||||
api<Conversation>(`/api/conversations/${convId}`, {
|
||||
method: 'PATCH',
|
||||
body: { title: autoTitle },
|
||||
})
|
||||
.then(() => sidebarRef.current?.refresh())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Optimistic user message in local UI state
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `user-${Date.now()}`,
|
||||
conversationId: convId,
|
||||
role: 'user' as const,
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
// Persist the user message to the DB so conversation history is
|
||||
// available when the page is reloaded or a new session starts.
|
||||
api<Message>(`/api/conversations/${convId}/messages`, {
|
||||
method: 'POST',
|
||||
body: { role: 'user', content },
|
||||
}).catch(() => {
|
||||
// Non-fatal: the agent can still process the message even if
|
||||
// REST persistence fails.
|
||||
});
|
||||
|
||||
// Send to WebSocket — gateway creates/resumes the agent session and
|
||||
// streams the response back via agent:start / agent:text / agent:end.
|
||||
const socket = getSocket();
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
socket.emit('message', {
|
||||
conversationId: convId,
|
||||
content,
|
||||
modelId: (options?.modelId ?? selectedModelId) || undefined,
|
||||
});
|
||||
},
|
||||
[activeId, messages, selectedModelId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
|
||||
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
|
||||
>
|
||||
<ConversationSidebar
|
||||
ref={sidebarRef}
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
currentConversationId={activeId}
|
||||
onSelectConversation={(conversationId) => {
|
||||
setActiveId(conversationId);
|
||||
setMessages([]);
|
||||
if (conversationId && window.innerWidth < 768) {
|
||||
setIsSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onNewConversation={(projectId) => {
|
||||
void handleNewConversation(projectId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className="flex items-center gap-3 border-b px-4 py-3"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSidebarOpen((open) => !open)}
|
||||
className="rounded-lg border p-2 transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
aria-label={isSidebarOpen ? 'Close conversation sidebar' : 'Open conversation sidebar'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||
Mosaic Chat
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeId ? (
|
||||
<>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{isStreaming && <StreamingMessage text={streamingText} />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
isStreaming={isStreaming}
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onModelChange={setSelectedModelId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-6">
|
||||
<div
|
||||
className="max-w-md rounded-2xl border px-8 py-10 text-center"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-medium" style={{ color: 'var(--text)' }}>
|
||||
Welcome to Mosaic Chat
|
||||
</h2>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
|
||||
Select a conversation or start a new one
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleNewConversation();
|
||||
}}
|
||||
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||
style={{ background: 'var(--primary)' }}
|
||||
>
|
||||
Start new conversation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AppShell } from '@/components/layout/app-shell';
|
||||
import { AuthGuard } from '@/components/auth-guard';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<AppShell>{children}</AppShell>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
||||
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
||||
import { PrdViewer } from '@/components/projects/prd-viewer';
|
||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
||||
|
||||
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
active: 'bg-success/20 text-success',
|
||||
paused: 'bg-warning/20 text-warning',
|
||||
completed: 'bg-blue-600/20 text-blue-400',
|
||||
archived: 'bg-gray-600/20 text-gray-400',
|
||||
};
|
||||
|
||||
interface TabButtonProps {
|
||||
id: Tab;
|
||||
label: string;
|
||||
activeTab: Tab;
|
||||
onClick: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(id)}
|
||||
className={cn(
|
||||
'border-b-2 px-4 py-2 text-sm transition-colors',
|
||||
activeTab === id
|
||||
? 'border-text-primary text-text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectDetailPage(): React.ReactElement {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = typeof params['id'] === 'string' ? params['id'] : '';
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
Promise.all([
|
||||
api<Project>(`/api/projects/${id}`),
|
||||
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
||||
api<Task[]>(`/api/tasks?projectId=${id}`).catch(() => [] as Task[]),
|
||||
])
|
||||
.then(([proj, allMissions, tks]) => {
|
||||
setProject(proj);
|
||||
setMissions(allMissions.filter((m) => m.projectId === id));
|
||||
setTasks(tks);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(err.message ?? 'Failed to load project');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleTaskClick = useCallback((task: Task) => {
|
||||
setSelectedTask(task);
|
||||
}, []);
|
||||
|
||||
const handleCloseTaskModal = useCallback(() => {
|
||||
setSelectedTask(null);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-text-muted">Loading project...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-error">{error ?? 'Project not found'}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/projects')}
|
||||
className="mt-4 text-sm text-text-muted underline hover:text-text-secondary"
|
||||
>
|
||||
Back to projects
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredTasks = taskFilter === 'all' ? tasks : tasks.filter((t) => t.status === taskFilter);
|
||||
|
||||
const prdContent = getPrdContent(project);
|
||||
const hasPrd = Boolean(prdContent);
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'tasks', label: `Tasks (${tasks.length})` },
|
||||
{ id: 'missions', label: `Missions (${missions.length})` },
|
||||
...(hasPrd ? [{ id: 'prd' as Tab, label: 'PRD' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/projects')}
|
||||
className="hover:text-text-secondary"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-text-primary">{project.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
statusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{project.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
||||
{new Date(project.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Tasks" value={String(tasks.length)} />
|
||||
<StatCard
|
||||
label="Done"
|
||||
value={String(tasks.filter((t) => t.status === 'done').length)}
|
||||
valueClass="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="In Progress"
|
||||
value={String(tasks.filter((t) => t.status === 'in-progress').length)}
|
||||
valueClass="text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={String(tasks.filter((t) => t.status === 'blocked').length)}
|
||||
valueClass={tasks.some((t) => t.status === 'blocked') ? 'text-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex gap-0 border-b border-surface-border">
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
label={tab.label}
|
||||
activeTab={activeTab}
|
||||
onClick={setActiveTab}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
||||
)}
|
||||
|
||||
{activeTab === 'tasks' && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<TaskStatusSummary
|
||||
tasks={tasks}
|
||||
activeFilter={taskFilter}
|
||||
onFilterChange={setTaskFilter}
|
||||
/>
|
||||
</div>
|
||||
<TaskListView tasks={filteredTasks} onTaskClick={handleTaskClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'missions' && <MissionTimeline missions={missions} />}
|
||||
|
||||
{activeTab === 'prd' && prdContent && (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
||||
<PrdViewer content={prdContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task detail modal */}
|
||||
{selectedTask && <TaskDetailModal task={selectedTask} onClose={handleCloseTaskModal} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewTabProps {
|
||||
project: Project;
|
||||
missions: Mission[];
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
function OverviewTab({ project, missions, tasks }: OverviewTabProps): React.ReactElement {
|
||||
const recentTasks = [...tasks]
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Recent tasks */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
||||
{recentTasks.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No tasks yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentTasks.map((task) => (
|
||||
<TaskSummaryRow key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Mission summary */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
||||
{missions.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No missions yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<MissionTimeline missions={missions.slice(0, 4)} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Metadata */}
|
||||
{project.metadata && Object.keys(project.metadata).length > 0 && (
|
||||
<section className="lg:col-span-2">
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<pre className="overflow-x-auto text-xs text-text-muted">
|
||||
{JSON.stringify(project.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
'not-started': 'bg-gray-600/20 text-gray-300',
|
||||
'in-progress': 'bg-blue-600/20 text-blue-400',
|
||||
blocked: 'bg-error/20 text-error',
|
||||
done: 'bg-success/20 text-success',
|
||||
cancelled: 'bg-gray-600/20 text-gray-500',
|
||||
};
|
||||
|
||||
function TaskSummaryRow({ task }: { task: Task }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2">
|
||||
<span className="truncate text-sm text-text-primary">{task.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-xs',
|
||||
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClass?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
|
||||
<p className="text-xs text-text-muted">{label}</p>
|
||||
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPrdContent(project: Project): string | null {
|
||||
if (!project.metadata) return null;
|
||||
|
||||
const prd = project.metadata['prd'];
|
||||
if (typeof prd === 'string' && prd.trim().length > 0) return prd;
|
||||
|
||||
const prdContent = project.metadata['prdContent'];
|
||||
if (typeof prdContent === 'string' && prdContent.trim().length > 0) return prdContent;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Project } from '@/lib/types';
|
||||
import { ProjectCard } from '@/components/projects/project-card';
|
||||
|
||||
export default function ProjectsPage(): React.ReactElement {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
api<Project[]>('/api/projects')
|
||||
.then(setProjects)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleProjectClick = useCallback(
|
||||
(project: Project) => {
|
||||
router.push(`/projects/${project.id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Projects</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Projects will appear here when created via the gateway API
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} onClick={handleProjectClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mission status section */}
|
||||
<MissionStatus />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionStatus(): React.ReactElement {
|
||||
const [mission, setMission] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api<Record<string, unknown>>('/api/coord/status')
|
||||
.then(setMission)
|
||||
.catch(() => setMission(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-4 text-lg font-semibold">Active Mission</h2>
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Loading mission status...</p>
|
||||
) : !mission ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
||||
<p className="text-sm text-text-muted">No active mission detected</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="Mission" value={String(mission['missionId'] ?? 'Unknown')} />
|
||||
<StatCard label="Phase" value={String(mission['currentPhase'] ?? '—')} />
|
||||
<StatCard
|
||||
label="Tasks"
|
||||
value={`${mission['completedTasks'] ?? 0} / ${mission['totalTasks'] ?? 0}`}
|
||||
/>
|
||||
<StatCard label="Status" value={String(mission['status'] ?? '—')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-elevated p-3">
|
||||
<p className="text-xs text-text-muted">{label}</p>
|
||||
<p className="mt-1 text-sm font-medium text-text-primary">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
export default function TasksPage(): React.ReactElement {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [view, setView] = useState<ViewMode>('kanban');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api<Task[]>('/api/tasks')
|
||||
.then(setTasks)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleTaskClick = useCallback((task: Task) => {
|
||||
// Task detail view will be added in future iteration
|
||||
console.log('Task clicked:', task.id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border border-surface-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('list')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'list'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('kanban')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'kanban'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
||||
) : view === 'kanban' ? (
|
||||
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
|
||||
) : (
|
||||
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
const params = useParams<{ provider: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
try {
|
||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||
if (cancelled) return;
|
||||
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderName(provider.name);
|
||||
if (!provider.configured) {
|
||||
setError(`${provider.name} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
if (provider.loginMode !== 'oidc') {
|
||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void redirectToProvider();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providerId, requestedCallbackURL]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{providerName
|
||||
? `Redirecting you to ${providerName}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Return to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||
If the redirect does not start automatically, return to the login page and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Mosaic',
|
||||
description: 'Mosaic Stack Dashboard',
|
||||
};
|
||||
|
||||
function themeScript(): string {
|
||||
return `
|
||||
(function () {
|
||||
try {
|
||||
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
||||
} catch (error) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
||||
/>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
|
||||
</head>
|
||||
<body>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function HomePage(): never {
|
||||
redirect('/chat');
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AdminRoleGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminRoleGuard({ children }: AdminRoleGuardProps): React.ReactElement | null {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const user = session?.user as
|
||||
| (NonNullable<typeof session>['user'] & { role?: string })
|
||||
| undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.replace('/login');
|
||||
} else if (!isPending && session && user?.role !== 'admin') {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [isPending, session, user?.role, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || user?.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps): React.ReactElement | null {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [isPending, session, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ModelInfo } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Conversation } from '@/lib/types';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface StreamingMessageProps {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import { formatAge, type FreshnessLabel } from '@/lib/freshness/model';
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface GuestGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Redirects authenticated users away from auth pages. */
|
||||
export function GuestGuard({ children }: GuestGuardProps): React.ReactElement | null {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session) {
|
||||
router.replace('/chat');
|
||||
}
|
||||
}, [isPending, session, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { signOut, useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AppHeaderProps {
|
||||
conversationTitle?: string | null;
|
||||
isSidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
const THEME_STORAGE_KEY = 'mosaic-chat-theme';
|
||||
|
||||
export function AppHeader({
|
||||
conversationTitle,
|
||||
isSidebarOpen,
|
||||
onToggleSidebar,
|
||||
}: AppHeaderProps): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [currentTime, setCurrentTime] = useState('');
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [theme, setTheme] = useState<ThemeMode>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
function updateTime(): void {
|
||||
setCurrentTime(
|
||||
new Date().toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
updateTime();
|
||||
const interval = window.setInterval(updateTime, 60_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/version.json')
|
||||
.then(async (res) => res.json() as Promise<{ version?: string; commit?: string }>)
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
setVersion(data.commit ? `${data.version}+${data.commit}` : data.version);
|
||||
}
|
||||
})
|
||||
.catch(() => setVersion(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const nextTheme = storedTheme === 'light' ? 'light' : 'dark';
|
||||
applyTheme(nextTheme);
|
||||
setTheme(nextTheme);
|
||||
}, []);
|
||||
|
||||
const handleThemeToggle = useCallback(() => {
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(nextTheme);
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
||||
setTheme(nextTheme);
|
||||
}, [theme]);
|
||||
|
||||
const handleSignOut = useCallback(async (): Promise<void> => {
|
||||
await signOut();
|
||||
window.location.href = '/login';
|
||||
}, []);
|
||||
|
||||
const userLabel = session?.user.name ?? session?.user.email ?? 'Mosaic User';
|
||||
const initials = useMemo(() => getInitials(userLabel), [userLabel]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-20 border-b backdrop-blur-xl"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
||||
aria-label="Toggle conversation sidebar"
|
||||
aria-expanded={isSidebarOpen}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<Link href="/chat" className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[var(--shadow-ms-md)]"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, var(--color-ms-blue-500), var(--color-ms-teal-500))',
|
||||
}}
|
||||
>
|
||||
M
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--color-text)]">Mosaic</div>
|
||||
<div className="hidden h-5 w-px bg-[var(--color-border)] md:block" />
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-ms-teal-500)] opacity-60" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-ms-teal-500)]" />
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-w-0 items-center gap-3 md:flex">
|
||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-2)]">
|
||||
{currentTime || '--:--'}
|
||||
</div>
|
||||
<div className="max-w-[24rem] truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{conversationTitle?.trim() || 'New Session'}
|
||||
</div>
|
||||
{version ? (
|
||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
||||
v{version}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<ShortcutHint label="⌘/" text="focus" />
|
||||
<ShortcutHint label="⌘K" text="focus" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleThemeToggle}
|
||||
className="inline-flex h-10 items-center justify-center rounded-2xl border px-3 text-sm transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? '☀︎' : '☾'}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label="Open user menu"
|
||||
>
|
||||
{session?.user.image ? (
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt={userLabel}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<div
|
||||
className="absolute right-0 top-12 min-w-56 rounded-3xl border p-2 shadow-[var(--shadow-ms-lg)]"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="border-b px-3 py-2" style={{ borderColor: 'var(--color-border)' }}>
|
||||
<div className="text-sm font-medium text-[var(--color-text)]">{userLabel}</div>
|
||||
{session?.user.email ? (
|
||||
<div className="text-xs text-[var(--color-muted)]">{session.user.email}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex rounded-2xl px-3 py-2 text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSignOut()}
|
||||
className="flex w-full rounded-2xl px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutHint({ label, text }: { label: string; text: string }): React.ReactElement {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
||||
<span className="font-medium text-[var(--color-text-2)]">{label}</span>
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitials(label: string): string {
|
||||
const words = label.split(/\s+/).filter(Boolean).slice(0, 2);
|
||||
if (words.length === 0) return 'M';
|
||||
return words.map((word) => word.charAt(0).toUpperCase()).join('');
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeMode): void {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'light') {
|
||||
root.setAttribute('data-theme', 'light');
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
root.removeAttribute('data-theme');
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { SidebarProvider, useSidebar } from './sidebar-context';
|
||||
import { Sidebar } from './sidebar';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
interface SidebarContextValue {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { MosaicLogo } from '@/components/ui/mosaic-logo';
|
||||
import { useSidebar } from './sidebar-context';
|
||||
@@ -99,7 +96,7 @@ const navItems: NavItem[] = [
|
||||
];
|
||||
|
||||
export function Sidebar(): React.ReactElement {
|
||||
const pathname = usePathname();
|
||||
const { pathname } = useLocation();
|
||||
const { mobileOpen, setMobileOpen } = useSidebar();
|
||||
|
||||
return (
|
||||
@@ -137,7 +134,7 @@ export function Sidebar(): React.ReactElement {
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
to={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all duration-150',
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from '@/providers/theme-provider';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { signOut, useSession } from '@/lib/auth-client';
|
||||
import { ThemeToggle } from './theme-toggle';
|
||||
import { useSidebar } from './sidebar-context';
|
||||
@@ -22,12 +20,12 @@ function MenuIcon(): React.JSX.Element {
|
||||
|
||||
export function Topbar(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
||||
|
||||
async function handleSignOut(): Promise<void> {
|
||||
await signOut();
|
||||
router.replace('/login');
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
function handleSidebarToggle(): void {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Mission, MissionStatus } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
interface PrdViewerProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Project } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type { Task, TaskStatus } from '@/lib/types';
|
||||
import { TaskCard } from './task-card';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task, TaskStatus } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export interface MosaicLogoProps {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { createAppRouter } from '@/routes';
|
||||
import '@/app/globals.css';
|
||||
import '@/globals.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
+33
-15
@@ -13,8 +13,18 @@ import {
|
||||
TasksRouteErrorBoundary,
|
||||
} from '@/spa/pages/resource-route-error-boundaries';
|
||||
import { TasksPage } from '@/spa/pages/tasks';
|
||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
import { SettingsPage } from '@/spa/pages/settings';
|
||||
import { AdminPage } from '@/spa/pages/admin';
|
||||
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { AppShell } from '@/components/layout/app-shell';
|
||||
|
||||
function DashboardLayout(): ReactElement {
|
||||
return (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function GuestLayout(): ReactElement {
|
||||
return (
|
||||
@@ -43,21 +53,29 @@ export const routes: RouteObject[] = [
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||
{
|
||||
path: '/projects',
|
||||
element: <ProjectsPage />,
|
||||
errorElement: <ProjectsRouteErrorBoundary />,
|
||||
element: <DashboardLayout />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||
{
|
||||
path: '/projects',
|
||||
element: <ProjectsPage />,
|
||||
errorElement: <ProjectsRouteErrorBoundary />,
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
element: <ProjectDetailPage />,
|
||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||
},
|
||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
element: <ProjectDetailPage />,
|
||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||
},
|
||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -21,3 +21,23 @@ export function AuthGuard(): ReactElement {
|
||||
|
||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export function AdminGuard(): ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const user = session.user as typeof session.user & { role?: string };
|
||||
|
||||
return user.role === 'admin' ? <Outlet /> : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, useSessionMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
authClient: {},
|
||||
}));
|
||||
|
||||
import { AdminPage } from './admin';
|
||||
import { AdminGuard } from '@/spa/guards';
|
||||
|
||||
const userFixtures = {
|
||||
users: [
|
||||
{
|
||||
id: 'u-admin',
|
||||
name: 'Ada Admin',
|
||||
email: '[email protected]',
|
||||
role: 'admin',
|
||||
banned: false,
|
||||
banReason: null,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'u-member',
|
||||
name: 'Mel Member',
|
||||
email: '[email protected]',
|
||||
role: 'member',
|
||||
banned: true,
|
||||
banReason: 'spam',
|
||||
createdAt: '2026-08-02T00:00:00.000Z',
|
||||
updatedAt: '2026-08-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderAdminRoute(): Promise<void> {
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
{ path: '/', element: <div>home page</div> },
|
||||
{ path: '/login', element: <div>login page</div> },
|
||||
];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
|
||||
return {
|
||||
data: { user: { id: 'u-1', name: 'Test', email: '[email protected]', role } },
|
||||
isPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
it('redirects unauthenticated visitors to /login', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('login page');
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects non-admin users to /', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('member'));
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('home page');
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the admin page for admin users', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('Admin Panel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPage users tab', () => {
|
||||
it('lists users with role and ban status after load', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users');
|
||||
expect(container.textContent).toContain('Ada Admin');
|
||||
expect(container.textContent).toContain('Mel Member');
|
||||
expect(container.textContent).toContain('Banned');
|
||||
expect(container.textContent).toContain('2 user(s)');
|
||||
});
|
||||
|
||||
it('shows the load error with a retry control', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockRejectedValueOnce(new Error('gateway unavailable'));
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('gateway unavailable');
|
||||
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
const retry = [...container.querySelectorAll('button')].find((b) =>
|
||||
b.textContent?.includes('Retry'),
|
||||
);
|
||||
expect(retry).toBeTruthy();
|
||||
await act(async () => {
|
||||
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Ada Admin');
|
||||
});
|
||||
|
||||
it('posts to the ban endpoint and reloads on Ban', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValue(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
const banButton = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.textContent === 'Ban',
|
||||
);
|
||||
expect(banButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
banButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users/u-admin/ban', { method: 'POST' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPage health tab', () => {
|
||||
it('loads health status when the tab is opened', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures).mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
database: { status: 'ok', latencyMs: 3 },
|
||||
cache: { status: 'ok', latencyMs: 1 },
|
||||
agentPool: { activeSessions: 2 },
|
||||
providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 4 }],
|
||||
checkedAt: '2026-08-26T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
const healthTab = [...container.querySelectorAll('button')].find((b) =>
|
||||
b.textContent?.includes('System Health'),
|
||||
);
|
||||
await act(async () => {
|
||||
healthTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/health');
|
||||
expect(container.textContent).toContain('Database (PostgreSQL)');
|
||||
expect(container.textContent).toContain('Active sessions: 2');
|
||||
expect(container.textContent).toContain('4 models');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { AdminRoleGuard } from '@/components/admin-role-guard';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
@@ -47,15 +44,9 @@ interface HealthStatusDto {
|
||||
|
||||
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
return (
|
||||
<AdminRoleGuard>
|
||||
<AdminContent />
|
||||
</AdminRoleGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminContent(): React.ReactElement {
|
||||
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
|
||||
// assumes an authenticated admin session.
|
||||
export function AdminPage(): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
||||
|
||||
return (
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -73,7 +74,11 @@ describe('ChatRouteErrorBoundary', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
root?.render(
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
function Boom(): never {
|
||||
@@ -71,7 +72,11 @@ describe('resource route error boundaries', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
root?.render(
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
useSessionMock: vi.fn(),
|
||||
updateUserMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
authClient: { updateUser: updateUserMock },
|
||||
}));
|
||||
|
||||
import { SettingsPage } from './settings';
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
useSessionMock.mockReset();
|
||||
updateUserMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderSettingsPage(): Promise<void> {
|
||||
const routes: RouteObject[] = [{ path: '/settings', element: <SettingsPage /> }];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): Promise<void> {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
return act(async () => {
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const session = {
|
||||
user: { id: 'u-1', name: 'Test User', email: '[email protected]', image: null },
|
||||
};
|
||||
|
||||
describe('SettingsPage profile tab', () => {
|
||||
it('renders the profile form from the session and saves via authClient', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
updateUserMock.mockResolvedValue({});
|
||||
|
||||
await renderSettingsPage();
|
||||
|
||||
const nameInput = container.querySelector<HTMLInputElement>('#profile-name');
|
||||
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
|
||||
expect(nameInput?.value).toBe('Test User');
|
||||
expect(emailInput?.value).toBe('[email protected]');
|
||||
expect(emailInput?.disabled).toBe(true);
|
||||
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(updateUserMock).toHaveBeenCalledWith({ name: 'Test User', image: null });
|
||||
expect(container.textContent).toContain('Saved!');
|
||||
});
|
||||
|
||||
it('surfaces an update failure without clearing the form', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
updateUserMock.mockResolvedValue({ error: { message: 'name rejected' } });
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(container.textContent).toContain('name rejected');
|
||||
expect(container.querySelector<HTMLInputElement>('#profile-name')?.value).toBe('Test User');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage appearance tab', () => {
|
||||
it('loads preferences and posts each changed preference on save', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
apiMock.mockImplementation((path: string) =>
|
||||
path.startsWith('/api/memory/preferences?')
|
||||
? Promise.resolve([{ key: 'ui.theme', value: 'dark', category: 'appearance' }])
|
||||
: Promise.resolve({}),
|
||||
);
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Appearance');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences?category=appearance');
|
||||
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: { key: 'ui.theme', value: 'dark', category: 'appearance', source: 'user' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage providers tab', () => {
|
||||
it('loads LLM and SSO providers and runs a connection test', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
apiMock.mockImplementation((path: string, opts?: { method?: string }) => {
|
||||
if (path === '/api/providers' && opts === undefined) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
available: true,
|
||||
models: [
|
||||
{
|
||||
id: 'llama3.2',
|
||||
provider: 'ollama',
|
||||
name: 'Llama 3.2',
|
||||
reasoning: false,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
inputTypes: ['text'],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (path === '/api/sso/providers') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (path === '/api/providers/test') {
|
||||
return Promise.resolve({ providerId: 'ollama', reachable: true, latencyMs: 12 });
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Providers');
|
||||
|
||||
expect(container.textContent).toContain('Ollama');
|
||||
expect(container.textContent).toContain('1 model');
|
||||
|
||||
await clickButtonByText('Test');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/providers/test', {
|
||||
method: 'POST',
|
||||
body: { providerId: 'ollama' },
|
||||
});
|
||||
expect(container.textContent).toContain('Reachable');
|
||||
});
|
||||
});
|
||||
+14
-6
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, useSession } from '@/lib/auth-client';
|
||||
@@ -59,9 +57,19 @@ function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
||||
return p.value as T;
|
||||
}
|
||||
|
||||
// The reset must not outlive the tab: an uncleared setTimeout fires into a
|
||||
// torn-down environment (unmount, or jsdom teardown under vitest).
|
||||
function useSavedBadgeReset(saveState: SaveState, setSaveState: (s: SaveState) => void): void {
|
||||
useEffect(() => {
|
||||
if (saveState !== 'saved') return undefined;
|
||||
const timer = setTimeout(() => setSaveState('idle'), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [saveState, setSaveState]);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
||||
|
||||
@@ -113,6 +121,7 @@ function ProfileTab({
|
||||
const [image, setImage] = useState(session?.user.image ?? '');
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
useSavedBadgeReset(saveState, setSaveState);
|
||||
|
||||
// Sync from session when it loads
|
||||
useEffect(() => {
|
||||
@@ -133,7 +142,6 @@ function ProfileTab({
|
||||
return;
|
||||
}
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
||||
setErrorMsg(message);
|
||||
@@ -196,6 +204,7 @@ function AppearanceTab(): React.ReactElement {
|
||||
const [defaultModel, setDefaultModel] = useState('');
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
useSavedBadgeReset(saveState, setSaveState);
|
||||
|
||||
useEffect(() => {
|
||||
api<Preference[]>('/api/memory/preferences?category=appearance')
|
||||
@@ -241,7 +250,6 @@ function AppearanceTab(): React.ReactElement {
|
||||
: []),
|
||||
]);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
setErrorMsg(message);
|
||||
@@ -325,6 +333,7 @@ function NotificationsTab(): React.ReactElement {
|
||||
const [emailDigest, setEmailDigest] = useState(false);
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
useSavedBadgeReset(saveState, setSaveState);
|
||||
|
||||
useEffect(() => {
|
||||
api<Preference[]>('/api/memory/preferences?category=communication')
|
||||
@@ -371,7 +380,6 @@ function NotificationsTab(): React.ReactElement {
|
||||
}),
|
||||
]);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||
setErrorMsg(message);
|
||||
@@ -21,3 +21,23 @@ for (const target of [globalThis, window]) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// jsdom (v29) does not implement window.matchMedia; the sidebar layout uses it
|
||||
// for its mobile breakpoint. Minimal always-desktop stub.
|
||||
if (typeof window.matchMedia !== 'function') {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["src", "vite.config.ts", "vitest.config.ts"],
|
||||
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
|
||||
@@ -10,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/appservice/package.json ./apps/appservice/
|
||||
COPY packages/ ./packages/
|
||||
COPY plugins/ ./plugins/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
|
||||
|
||||
@@ -8,14 +8,16 @@ WORKDIR /app
|
||||
# Copy workspace manifests first for layer-cached install
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/gateway/package.json ./apps/gateway/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/ ./packages/
|
||||
COPY plugins/ ./plugins/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||
RUN pnpm turbo run build --filter @mosaicstack/gateway...
|
||||
# Build gateway, the web SPA bundle it serves (#1444), and all of their
|
||||
# workspace dependencies via the turbo dependency graph
|
||||
RUN pnpm turbo run build --filter @mosaicstack/gateway... --filter @mosaicstack/web...
|
||||
# Produce a self-contained deploy artifact: flat node_modules, no pnpm symlinks
|
||||
# --legacy is required for pnpm v10 when inject-workspace-packages is not set
|
||||
RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
||||
@@ -38,6 +40,9 @@ COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||
# copy from builder explicitly as belt-and-suspenders
|
||||
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
||||
# The built web SPA bundle; served by the gateway (apps/gateway/src/spa/serve-spa.ts)
|
||||
COPY --chown=node:node --from=builder /app/apps/web/dist ./web-dist
|
||||
ENV WEB_DIST_DIR=/app/web-dist
|
||||
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
||||
EXPOSE 14242
|
||||
USER node
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/ ./packages/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm --filter @mosaicstack/web build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
+242
-1014
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mosaic Stack Roadmap
|
||||
|
||||
Companion to [docs/PRD.md](./PRD.md). Governed by the D11 rule: **every planned
|
||||
phase appears here from day one, even as a placeholder** — nothing exists only
|
||||
in heads. A phase marked _placeholder_ is a commitment to design it, not a
|
||||
design; scoping one requires its own PRD section or requirements doc plus
|
||||
review.
|
||||
|
||||
Phases are product phases. The in-flight platform workstreams (KBN-100/101
|
||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
|
||||
Part II contracts in the PRD) run as parallel tracks under their own issues
|
||||
and are prerequisites where noted.
|
||||
|
||||
| Phase | Scope | Status |
|
||||
| ----- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
||||
| P0 | Current state on `next`: read-only dashboard, chat, auth/SSO login, admin tabs | shipped, evolving |
|
||||
| P1 | **v1 slice** (PRD Part I §9) | next up |
|
||||
| P2 | Connectors + comms + wizard expansion | placeholder |
|
||||
| P3 | Full onboarding profile + M365 | placeholder |
|
||||
| P4 | Enterprise mode + one-way conversion | placeholder |
|
||||
| P5 | Federation | placeholder (deliberately undesigned, D3) |
|
||||
|
||||
## P0 — current state
|
||||
|
||||
What exists on `next` today: web dashboard (login/register/SSO, chat,
|
||||
read-only projects/tasks, settings, admin user/system-health tabs), the
|
||||
Gateway, the CLI-first framework tooling, and the fleet control plane. The
|
||||
webUI audit (USC estate, webui-audit lane) measures the gap between this and
|
||||
P1.
|
||||
|
||||
## P1 — v1 slice (D11)
|
||||
|
||||
1. Standalone onboarding wizard: system/company name, component choices,
|
||||
initial user, initial estate + project, seeded examples, re-runnable.
|
||||
2. Hierarchy core: company → estate → project → workspace → kanban, read-only
|
||||
task bubble-up (kanban SOT Amendment A1 is the schema contract).
|
||||
3. Basic RBAC on the hierarchy.
|
||||
4. Minimal agent enrollment: one harness, API key, name/persona.
|
||||
|
||||
Prerequisites: KBN-100/101 schema foundation; the D8 tool inventory and
|
||||
webUI→tool mapping (any missing tool is built first, D12).
|
||||
|
||||
## P2 — connectors + comms + wizard expansion (placeholder)
|
||||
|
||||
Email and drive connectors (Gmail/IMAP, Google Drive/OneDrive/Dropbox) with
|
||||
granular agentic-access consent; comms integrations (Matrix/Discord/Slack)
|
||||
including agent auto-enroll. Wizard gains the corresponding tabs (D4), plus
|
||||
the D4 capabilities deferred out of P1's minimal slice: expanded agent
|
||||
enrollment (OAuth login, multi-account, model choice with recommendation,
|
||||
account assignment, comms auto-enroll) and the Standalone SSO/OIDC
|
||||
configuration tab.
|
||||
|
||||
## P3 — full onboarding profile + M365 (placeholder)
|
||||
|
||||
Complete user onboarding profile (communication-style capture, optional
|
||||
voice-matching interview) under the D14 custody rule; M365 connectors,
|
||||
available to both deployment modes as ordinary connectors (same consent model
|
||||
as the P2 connector class). The Enterprise install flow's M365 prominence
|
||||
(D4) arrives with the Enterprise phase, P4.
|
||||
|
||||
## P4 — Enterprise mode + conversion (placeholder)
|
||||
|
||||
Enterprise install flow (org chart, RBAC focus, immediate OIDC, SSO
|
||||
prominent); per-user brains with architectural isolation (D14); Vault
|
||||
required; the one-way Standalone → Enterprise conversion (D3).
|
||||
|
||||
## P5 — federation (placeholder)
|
||||
|
||||
Connecting deployments: system-level config, assigned users, rights and
|
||||
data-access control, trusts with boundaries, strict data access, exfiltration
|
||||
monitoring. Explicitly not designed yet (D3); nothing in earlier phases may
|
||||
foreclose it. Requires its own PRD + threat model before any scoping.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -212,6 +212,20 @@ Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts
|
||||
|
||||
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
|
||||
|
||||
### E2E Gate (#1445, P6)
|
||||
|
||||
Trunk publish pipelines run a headless Playwright suite (`e2e` step) before any image publishes: the built gateway `dist` boots on a throwaway embedded PGlite database (isolated via a fresh `HOME`), serves the built SPA bundle through `WEB_DIST_DIR` — the same serving path the gateway image ships — and the suite runs against it inside the pinned `mcr.microsoft.com/playwright` image. `E2E_REQUIRE_SEEDED_AUTH=1` makes login failures hard failures (the skip-when-login-fails guards are a live-environment affordance only). Both image build steps depend on this gate.
|
||||
|
||||
Reproduce locally (Ubuntu-based environments; Fedora's headless-shell rendering is broken):
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
BETTER_AUTH_SECRET="$(head -c 32 /dev/urandom | base64)" GATEWAY_PORT=14242 \
|
||||
WEB_DIST_DIR="$PWD/apps/web/dist" HOME="$(mktemp -d)" node apps/gateway/dist/main.js &
|
||||
E2E_REQUIRE_SEEDED_AUTH=1 PLAYWRIGHT_BASE_URL=http://localhost:14242 \
|
||||
pnpm --filter @mosaicstack/web exec playwright test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding New Agent Tools
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
# Identity Account-Lifecycle Contract
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 4 of 9).
|
||||
Authority: PRD D10 (better-auth is the account system of record), Q1 ruled O1
|
||||
by Jason 2026-08-26 (webui-audit T10). This document turns that ruling into
|
||||
enforceable policy. It also carries the bootstrap/first-admin invariant from
|
||||
issue #1430, folded in here after PR #1431's independent review showed the
|
||||
quick-fix approach was insufficient.
|
||||
|
||||
Revision 2: addresses the 9 findings of the independent review
|
||||
(`fleet/lanes/webui-audit/findings/pr1433-review.md`) — epoch enforcement
|
||||
tightened (§3), canonical email split from provider claims (§5), external
|
||||
principal keyed by issuer+subject with DB uniqueness and link step-up (§6),
|
||||
JIT default precedence and first-admin SSO path defined (§2, §4),
|
||||
deactivation made measurable (§7.1), deletion kept in scope and the existing
|
||||
hard-delete endpoint required to fail closed (§7.3), workspace identity
|
||||
reconciled with the native-kanban SOT (§1.4), verification matrix expanded
|
||||
(§8), factual labels corrected (§7.3, §8.1).
|
||||
|
||||
Revision 3: addresses the residuals and new findings of the revision-2
|
||||
re-review (`fleet/lanes/webui-audit/findings/pr1433-review-r2.md`) —
|
||||
`users.emailVerified` added to the canonical set with a defined reset rule on
|
||||
email change (§5.1–5.2), external-principal uniqueness moved to
|
||||
(issuer, subject) (§6.1), "can actually use" defined (§6.5), the shipped
|
||||
delete affordances (web admin page, `mosaic auth users delete`) required to
|
||||
be removed or disabled with a defined user-visible state (§7.3), the
|
||||
admin-creation switch removed in favor of plain admin authorization (§2.3),
|
||||
and §8 extended with observables for IdP removal, forward-auth non-use,
|
||||
first-admin SSO, wizard-recorded JIT choice, the admin-guide statement, and
|
||||
positive/expiry-bound step-up cases.
|
||||
|
||||
Scope: account creation, bootstrap, federated login, account linking, claim
|
||||
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
|
||||
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema
|
||||
(contract 1), sensitive-data custody (contract 7 / D14).
|
||||
|
||||
## 1. System of record
|
||||
|
||||
1. better-auth's tables (`users`, `accounts`, `sessions`, `verifications`) are
|
||||
the only account system of record. All foreign keys reference `users.id`.
|
||||
2. External IdPs (Authentik or any OIDC provider) are login methods, attached
|
||||
through better-auth's generic-OAuth plugin (`packages/auth/src/sso.ts`).
|
||||
They never own accounts. Removing an IdP removes a login method, not users.
|
||||
3. The forward-auth perimeter shim is a deployment measure. Once in-app OIDC
|
||||
is configured for a deployment, the shim is demoted: it may stay as network
|
||||
perimeter, but no application code may read identity from its headers.
|
||||
4. **Account ≠ workspace membership.** Creating an account (by any path:
|
||||
bootstrap, sign-up, invite, JIT, admin creation) creates no workspace, no
|
||||
hierarchy grant, and no workspace-scoped authority (native-kanban SOT
|
||||
REQ-TEN-001 / REQ-ID-001). The better-auth `role` field is a platform/auth
|
||||
role (`member` | `admin`), not workspace membership. Workspace grants are
|
||||
defined by contract 2; until then a fresh account can authenticate and
|
||||
holds no workspace authority.
|
||||
|
||||
## 2. Registration gating
|
||||
|
||||
Measured current state on `next`: `emailAndPassword.enabled: true` with no
|
||||
gating — anyone who can reach the Gateway can create an account via
|
||||
`POST /api/auth/sign-up/email` and receives role `member`.
|
||||
|
||||
Contract:
|
||||
|
||||
1. A single server-side setting `registration_mode` with values
|
||||
`open | invite | closed`. It lives in the database (admin-mutable at
|
||||
runtime), not in env config.
|
||||
2. Default after bootstrap: `closed`. The wizard (contract 3) may set a
|
||||
different mode during setup, recorded as an explicit operator choice.
|
||||
While the bootstrap epoch is open (§3), the effective mode is `closed`
|
||||
regardless of any stored value: the setting takes effect only after the
|
||||
epoch completes.
|
||||
3. `closed` blocks self-service email/password sign-up. It does not block
|
||||
admin-created users or OIDC JIT (§4). Post-bootstrap admin creation is
|
||||
gated by admin authorization alone — there is no separate switch for it.
|
||||
JIT is gated by its per-provider flag (§4.1). All user-creating paths are
|
||||
closed while the bootstrap epoch is open (§3).
|
||||
4. `invite` requires a single-use, expiring invite token bound to an email
|
||||
address. Invite issuance is an admin operation and is audit-logged.
|
||||
5. Enforcement point: a better-auth hook (or equivalent middleware executed
|
||||
inside the auth handler path), not a Gateway route guard in front of it —
|
||||
the raw `/api/auth/*` handler must be incapable of bypassing the gate.
|
||||
|
||||
## 3. Bootstrap / first-admin invariant (from #1430)
|
||||
|
||||
Invariant: **the system transitions from zero users to one admin user exactly
|
||||
once per bootstrap epoch, atomically, regardless of concurrency or which code
|
||||
path writes users.**
|
||||
|
||||
Constraints any implementation MUST satisfy (each traces to a verified defect
|
||||
in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`):
|
||||
|
||||
1. **Durable fail-closed epoch state, obeyed by every writer.** The epoch
|
||||
lives in a constraint-backed one-row `bootstrap_state` table. While the
|
||||
epoch is open, every non-bootstrap user-creating writer — better-auth
|
||||
sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside
|
||||
the writer's own path (better-auth hook for the raw handler; guard for
|
||||
admin routes). A partial unique index or a winning epoch-transition row is
|
||||
necessary but not sufficient on its own: neither stops an untagged insert
|
||||
from a writer that never consulted the epoch. Both layers are required:
|
||||
database-level transition safety (the epoch-completing write races safely
|
||||
and at most one wins) and writer-level refusal (no path can create a user
|
||||
without reading epoch state).
|
||||
2. **Atomic first-admin transition.** The admin user, its credential account,
|
||||
the initial admin token, and the epoch-completed transition commit in one
|
||||
database transaction or not at all. A better-auth call through
|
||||
`drizzleAdapter(db)` runs on the root pool and is NOT part of any caller
|
||||
transaction; it may be used inside the bootstrap transition only if the
|
||||
adapter is explicitly bound to the transaction handle. Otherwise the
|
||||
bootstrap writer must create the user rows itself within the transaction.
|
||||
3. **Pool safety.** No design may hold a pooled connection inside a
|
||||
transaction while awaiting a write that acquires a second connection from
|
||||
the same pool (`DB_POOL_MAX=1` is a supported configuration).
|
||||
4. **Re-runnability (D4).** Bootstrap is not a one-shot: after the first-admin
|
||||
epoch completes, re-running the wizard reconfigures the system but never
|
||||
re-opens the zero-user transition. "Setup already completed" is a stable,
|
||||
testable state, and factory-reset (a future, explicitly destructive
|
||||
operation) is the only way to open a new epoch.
|
||||
5. **No stranded partial outcome.** A failure at any point in the transition
|
||||
leaves nothing observable (no admin user without its token, no completed
|
||||
epoch without an admin) and setup remains retryable — this follows from
|
||||
§3.2 and is stated separately because it is the pre-existing failure mode
|
||||
the #1431 review verified.
|
||||
6. **First-admin via SSO (D4).** When the operator chooses SSO for the
|
||||
initial user, the wizard executes the OIDC login as part of the bootstrap
|
||||
transition itself: the bootstrap writer creates the account from the
|
||||
asserted identity inside the §3.2 transaction. This path is the bootstrap
|
||||
writer, not JIT — §4's JIT gate stays closed during the epoch and is not
|
||||
an obstacle to D4.
|
||||
|
||||
## 4. JIT provisioning (OIDC first login)
|
||||
|
||||
1. A successful OIDC login with no matching account creates a user
|
||||
just-in-time only when `jit_provisioning` is enabled for that provider.
|
||||
The flag is per-provider and defaults off, always. There is no
|
||||
mode-implied default: Enterprise setup enables JIT only when the wizard
|
||||
records it as an explicit operator choice for a named provider (this
|
||||
replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT
|
||||
enabled", which contradicted the per-provider default).
|
||||
2. JIT users receive platform role `member`, never an elevated role,
|
||||
regardless of IdP claims (§5), and no workspace authority (§1.4).
|
||||
3. An optional per-provider email-domain allowlist constrains JIT. The
|
||||
allowlist matches only when the IdP asserts the email with
|
||||
`email_verified: true`; an unverified address never satisfies the
|
||||
allowlist. Empty allowlist with JIT on means any authenticated subject at
|
||||
that IdP gets an account — permitted, but the wizard must present it as an
|
||||
explicit choice.
|
||||
4. JIT is disabled while the bootstrap epoch is open (§3.1). The first-admin
|
||||
SSO path is §3.6, not JIT.
|
||||
|
||||
## 5. Claim mapping
|
||||
|
||||
1. **Two stores, not one.** Provider-observed claims (`email`,
|
||||
`email_verified`, display name, avatar) are recorded per external
|
||||
principal — keyed by issuer + subject (§6.1) — at first login and
|
||||
refreshed at each login. The canonical account fields (`users.email`,
|
||||
`users.emailVerified`, `users.name`, `users.image`) are set exactly once
|
||||
at account creation and are never silently overwritten by a later login.
|
||||
For SSO-created accounts (JIT or first-admin SSO), `users.emailVerified`
|
||||
is set from the provider's `email_verified` claim at creation; for
|
||||
password-created accounts it is false until the address completes
|
||||
verification.
|
||||
2. **Canonical email changes only through an explicit workflow.** Either the
|
||||
user-initiated email change (with verification of the new address) or an
|
||||
admin edit. Any canonical email change — user- or admin-initiated — sets
|
||||
`users.emailVerified` to false until the new address completes
|
||||
verification; an admin may instead explicitly attest the address as
|
||||
verified in the same operation, and that attestation is audit-logged. A
|
||||
provider-claim refresh never rebinds `users.email` or
|
||||
`users.emailVerified`; a divergence between canonical email and the latest
|
||||
provider-observed email is surfaced per §6.4.
|
||||
3. Never mapped from IdP claims: `role` and any future authorization
|
||||
attribute. Authorization lives in the system of record and in the RBAC
|
||||
layer (contract 2). An IdP group/role claim may at most be recorded for
|
||||
audit; it grants nothing.
|
||||
|
||||
## 6. Account linking trust
|
||||
|
||||
1. **External principal identity is issuer + subject.** A linked identity is
|
||||
keyed by the OIDC issuer and subject claims, not by an unqualified
|
||||
provider subject id and not by email. The linked-identity row stores the
|
||||
issuer, and the database enforces at most one local account per
|
||||
**(issuer, subject)** with a unique constraint on those stored columns —
|
||||
uniqueness on (provider, subject) is insufficient because provider →
|
||||
issuer is not one-to-one: two provider configurations can point at the
|
||||
same issuer, and the identity must not alias across them. The current
|
||||
non-unique `(provider_id, account_id)` index satisfies neither;
|
||||
application-level checks without a uniqueness witness lose
|
||||
concurrent-callback races. Each configured provider additionally binds to
|
||||
exactly one issuer, immutable after creation (changing the issuer means
|
||||
creating a new provider).
|
||||
2. Linking an OIDC identity to an existing account happens only in one of two
|
||||
ways: (a) explicit link initiated by the logged-in user from settings,
|
||||
which requires step-up: a fresh reauthentication (password or existing
|
||||
linked method) no older than a short bound the implementation defines
|
||||
(≤ 10 minutes) — a session cookie alone is insufficient, so a stolen
|
||||
session cannot quietly attach a durable login method; or (b) automatic
|
||||
link when the IdP asserts a verified email exactly matching an existing
|
||||
account **and** the provider is marked `trusted_for_linking`
|
||||
(per-provider flag, default off).
|
||||
3. Untrusted-provider email collision produces a login error naming the
|
||||
conflict, not an auto-link and not a duplicate account.
|
||||
4. A linked identity whose IdP-observed email later diverges from the
|
||||
canonical account email keeps working (the link is by issuer + subject,
|
||||
§6.1) but the divergence is surfaced in the user's settings and audit log
|
||||
(the per-principal claim store in §5.1 is what makes the divergence
|
||||
representable).
|
||||
5. Unlinking a login method is refused when it would leave the account with
|
||||
no **usable** login method. Usable means: a set password, or a linked
|
||||
identity whose provider is currently configured and enabled on this
|
||||
deployment. A linked identity whose provider has been removed or disabled
|
||||
(§1.2) is not usable and does not count; setting a password first lifts
|
||||
the refusal.
|
||||
|
||||
## 7. Deactivation propagation
|
||||
|
||||
1. **Deactivation (better-auth admin ban) is authoritative and bounded.**
|
||||
Concretely:
|
||||
- Ban and session revocation are one operation: the ban commit revokes all
|
||||
better-auth sessions for the user. If revocation partially fails, the
|
||||
ban itself must already be committed and every guard denies from that
|
||||
point (fail closed); the operation is retryable.
|
||||
- Every authenticated entry path checks banned state: HTTP session guards,
|
||||
the admin bearer-token path (which today does not test `banned` — an
|
||||
implementation defect this contract makes non-conformant), MCP, and
|
||||
Socket.IO.
|
||||
- Active socket connections are terminated or denied within 30 seconds of
|
||||
the ban commit, or at the next inbound message on that socket, whichever
|
||||
comes first (socket auth at connect-time only, as today, does not
|
||||
satisfy this).
|
||||
- The current admin ban route updates only the user row; it does not
|
||||
conform to this section until revocation and guard coverage land.
|
||||
- Admin tokens owned by the banned user are revoked in the same operation.
|
||||
2. Deactivation at an external IdP does not propagate automatically in this
|
||||
contract's scope (no SCIM). Operational rule: removing a user from the IdP
|
||||
without banning them in Mosaic leaves any password or other linked login
|
||||
method usable — the admin guide must state this. SCIM/webhook-driven
|
||||
propagation is future work and out of scope here.
|
||||
3. **Deletion is not deactivation, and deletion is gated here.** Account
|
||||
deletion semantics (FK fan-out across the 21 foreign-key constraints to
|
||||
`users.id`, spread over 19 referencing tables) require their own
|
||||
deletion-and-retention contract, chartered as an addition to the S2 list —
|
||||
contract 7 is the D14 sensitive-data custody contract and does not cover
|
||||
account deletion. Until that deletion contract is ratified: the existing
|
||||
hard-delete endpoint (`DELETE /api/admin/users/:id`) is disabled and fails
|
||||
closed, and deactivation is the only supported removal operation. A
|
||||
contract that merely declared deactivation "the only supported removal"
|
||||
while the endpoint stayed live would be false on its face.
|
||||
Disabling the endpoint alone is insufficient — its shipped callers must
|
||||
not be left as advertised operations that now fail generically:
|
||||
- The admin web UI delete action (`apps/web/src/app/(dashboard)/admin/page.tsx`
|
||||
and any SPA port of it) is removed, or replaced by a disabled control
|
||||
whose visible text states that deletion is unavailable pending the
|
||||
deletion-and-retention contract and points at deactivation.
|
||||
- The CLI command `mosaic auth users delete`
|
||||
(`packages/mosaic/src/commands/auth.ts`) is removed, or exits non-zero
|
||||
with a message stating the same and naming the deactivation command.
|
||||
- Both surfaces expose deactivation as the supported operation.
|
||||
|
||||
## 8. Verification requirements
|
||||
|
||||
Every MUST above needs a bounded observable. The matrix:
|
||||
|
||||
1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two
|
||||
distinct physical connections (pattern:
|
||||
`apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`,
|
||||
which runs in the `test` CI step against the `ci-postgres` PostgreSQL
|
||||
service — note that pattern multiplexes one pooled handle, so the tests
|
||||
here must explicitly open separate connections). Races to cover:
|
||||
setup-vs-setup, setup-vs-raw-sign-up, setup-vs-JIT, setup-vs-admin-create.
|
||||
Plus: liveness under `DB_POOL_MAX=1`; fault injection after each write in
|
||||
the transition (user, credential, token, epoch) proving nothing observable
|
||||
leaks and setup retries; wizard re-run after completion proving the
|
||||
zero-user transition never re-opens. Mocked-transaction specs are
|
||||
supplementary; they cannot prove serialization.
|
||||
2. **Registration gating (§2).** Spec coverage of all three modes against the
|
||||
raw `/api/auth/` handler path, not only Gateway controllers; invite
|
||||
lifecycle (single-use, expiry, email binding); effective-`closed` while
|
||||
the epoch is open regardless of stored mode.
|
||||
3. **JIT (§4).** Provider flag off → no account on first OIDC login; on →
|
||||
account with platform role `member` and no workspace grant; domain
|
||||
allowlist rejects an unverified email claim even when the domain matches;
|
||||
JIT refused while the epoch is open.
|
||||
4. **Claim mapping (§5).** Login refresh updates the per-principal claim
|
||||
store and touches none of the canonical fields (`users.email`,
|
||||
`users.emailVerified`, name, image); explicit email-change workflow is the
|
||||
only path that rebinds canonical email; every canonical email change
|
||||
resets `users.emailVerified` to false unless the admin attestation path
|
||||
is taken, and that attestation appears in the audit log.
|
||||
5. **Linking (§6).** Unique-constraint witness: concurrent first-login
|
||||
callbacks for the same (issuer, subject) yield exactly one account, and
|
||||
two provider configurations sharing one issuer cannot create two accounts
|
||||
for the same subject; trusted auto-link; untrusted collision error;
|
||||
step-up both ways: an explicit link succeeds immediately after a fresh
|
||||
reauthentication and is refused once the implementation's chosen bound
|
||||
(≤ 10 minutes) has elapsed, and refused with no reauthentication at all;
|
||||
unlink refusal when no remaining method is usable per §6.5, including the
|
||||
removed-provider case, and acceptance after a password is set; divergence
|
||||
surfaced after IdP email change.
|
||||
6. **Deactivation (§7).** Ban revokes sessions atomically or fails closed
|
||||
(partial-failure injection); guard denial post-ban on each transport:
|
||||
HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated
|
||||
within the 30-second/next-message bound; banned user's admin tokens
|
||||
unusable; hard-delete endpoint returns a fail-closed error while the
|
||||
deletion contract is unratified; the admin web UI renders no live delete
|
||||
action (absent, or disabled with the §7.3 text) and `mosaic auth users
|
||||
delete` exits non-zero with the §7.3 message — both asserted by spec.
|
||||
7. **System of record and bootstrap edges (§1, §3.6, §4.3).** IdP removal:
|
||||
deleting a provider configuration leaves every user row intact and every
|
||||
other login method working (spec over the provider-config removal path).
|
||||
Forward-auth non-use: with in-app OIDC configured, a request carrying
|
||||
forward-auth identity headers and no session is treated as anonymous —
|
||||
no code path derives identity from those headers (negative spec at the
|
||||
Gateway entry). First-admin SSO: the §3.6 transition commits account,
|
||||
token, and epoch atomically from the asserted identity, and fault
|
||||
injection mid-transition leaves nothing observable (same harness as §8.1).
|
||||
Wizard-recorded JIT choice: enabling JIT for a provider writes an
|
||||
explicit per-provider operator-choice record, and no mode selection
|
||||
enables it implicitly (assert the stored record, not UI behavior).
|
||||
8. **Documentation observable (§7.2).** The admin guide contains the
|
||||
IdP-removal-does-not-deactivate statement; verified by a docs assertion
|
||||
(content check in CI or an enumerated review-checklist item on the
|
||||
implementing PR) — a MUST about documentation needs a checkable artifact,
|
||||
not intent.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–8 as written, with one decision embedded: registration
|
||||
defaults to `closed` after bootstrap (§2.2) — say "agreed" or name the mode
|
||||
you want as the default.
|
||||
@@ -372,3 +372,87 @@ The P0–P3 canon does not authorize:
|
||||
## 7. Global release evidence
|
||||
|
||||
P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
|
||||
|
||||
## 8. Amendment A1 — hierarchy parentage and RBAC chain above workspaces
|
||||
|
||||
**Status:** amendment to the ratified canon, added by reviewed PR under
|
||||
decision D13 (operator ruling, 2026-08-25; decision owner Jason). It adds
|
||||
parent structure ABOVE workspaces. Sections 1–7, every invariant in §3, and
|
||||
every REQ above remain binding verbatim, with exactly one express modification:
|
||||
the narrow portfolio-analytics carve-out stated in §8.2.4. Nothing else below
|
||||
this line is weakened.
|
||||
|
||||
### 8.1 What is added
|
||||
|
||||
1. A platform hierarchy exists above workspaces:
|
||||
**company/organization → estate → platform-project → workspace**. Each
|
||||
workspace belongs to exactly one platform-project, each platform-project to
|
||||
exactly one estate, each estate to exactly one company.
|
||||
2. **Record class.** Hierarchy records (company, estate, platform-project,
|
||||
their parentage edges, and hierarchy-level access grants) are a new,
|
||||
explicitly named record class: **tenancy/authorization structure records**.
|
||||
They are not business or orchestration records, so §3 invariant 10 and
|
||||
REQ-TEN-001 do not apply to them and are not weakened by them — those two
|
||||
requirements bind business/orchestration rows exactly as before.
|
||||
Constraints on the new class:
|
||||
- Hierarchy tables MUST NOT carry task, plan, or any other
|
||||
business/orchestration payload — parentage, naming, and grant data only.
|
||||
- A hierarchy record can never be the subject of work: it cannot be
|
||||
claimed, ordered, gated, or referenced as a dependency by any
|
||||
business/orchestration row.
|
||||
- Hierarchy mutations flow through the same sole-writable-SOT, fail-closed,
|
||||
audited mutation path as everything else (§8.2.3).
|
||||
3. The hierarchy serves exactly two runtime functions, plus audited
|
||||
maintenance of its own structure:
|
||||
- **RBAC evaluation:** access grants are declared per company, estate, or
|
||||
platform-project and evaluate down the chain to workspace-scoped
|
||||
authorization. Tenant context continues to be derived from authenticated
|
||||
authority (REQ-TEN-001); the chain adds where grants can be declared,
|
||||
not a bypass of workspace authorization.
|
||||
- **Read-only roll-ups:** task and status visualization bubbles up the
|
||||
hierarchy as aggregation over workspaces the reader is authorized on.
|
||||
- **Chain maintenance (not a third runtime function):** re-parenting an
|
||||
asset — moving a workspace to another platform-project, a
|
||||
platform-project to another estate, and so on ("assets are transferable
|
||||
subject to the structure", PRD Part I §4) — is an audited edit of the
|
||||
hierarchy records themselves under §8.3. It never modifies
|
||||
business/orchestration rows and never crosses a workspace boundary for
|
||||
them; the workspace's contents move with the workspace untouched.
|
||||
4. Naming: this amendment says **platform-project** for the hierarchy level
|
||||
above workspaces, because §5 REQ-PLAN-001 already defines `projects` as
|
||||
planning entities INSIDE a workspace. The two are different objects. Final
|
||||
terminology (rename of one or the other) is an implementation-PR decision
|
||||
under this amendment's review; the schema MUST NOT merge them.
|
||||
|
||||
### 8.2 What is explicitly unchanged
|
||||
|
||||
1. `workspace_id` remains the hard mechanical isolation unit (§2 D2,
|
||||
REQ-TEN-001). Hierarchy tables carry parentage; they do not create
|
||||
cross-workspace relationships between business/orchestration rows, which
|
||||
remain rejected (§3 invariant 10).
|
||||
2. Roll-up is **never a write**: no aggregation path may mutate, claim, order,
|
||||
or gate work in any workspace. Bubble-up views are generated projections in
|
||||
the sense of §3 invariant 5 — non-authoritative and never import sources.
|
||||
3. Fail-closed mutation health (§3 invariants 3–4), sole writable PostgreSQL
|
||||
SOT, fencing, audit, and the Coordinator/Certifier authority rules are
|
||||
untouched.
|
||||
4. No §6 non-goal is authorized, with one express, narrow carve-out that this
|
||||
amendment makes to the "portfolio analytics" non-goal: the read-only
|
||||
roll-up of §8.1 — per-workspace task counts and statuses aggregated up the
|
||||
parent chain, over workspaces the reader is authorized on — is in scope.
|
||||
Everything beyond that boundary (metrics, trends, forecasting, scoring,
|
||||
dashboards computed across workspaces, any derived analytic that is not a
|
||||
direct count/status aggregation) remains a non-goal. This is an explicit
|
||||
narrowing by amendment, not a claim that §6 is unchanged; every other §6
|
||||
non-goal is untouched.
|
||||
|
||||
### 8.3 Acceptance (binding on the implementing PRs)
|
||||
|
||||
- Schema tests prove each workspace resolves to exactly one
|
||||
platform-project/estate/company chain and that chain edits are audited.
|
||||
- Authorization tests prove a grant at each hierarchy level yields exactly the
|
||||
workspace permissions the chain implies, and that revocation up the chain
|
||||
propagates.
|
||||
- Negative tests prove roll-up endpoints cannot mutate state and that a
|
||||
reader sees aggregates only over workspaces they are authorized on
|
||||
(no cross-tenant existence oracles).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,351 @@
|
||||
# Roll-up Projection Contract (S2 contract 8)
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 8 of 9).
|
||||
Authority: `native-kanban-sot.md` §8 (A1 amendment) — "task and status
|
||||
visualization bubbles up the hierarchy as aggregation over workspaces
|
||||
the reader is authorized on" (§8.1.3); roll-up is never a write and
|
||||
bubble-up views are generated projections, non-authoritative and never
|
||||
import sources (§8.2.2); the express, narrow carve-out from the
|
||||
portfolio-analytics non-goal covers per-workspace task counts and
|
||||
statuses aggregated up the parent chain over readable workspaces, and
|
||||
nothing beyond that boundary (§8.2.4); acceptance requires that roll-up
|
||||
endpoints cannot mutate state and that a reader sees aggregates only
|
||||
over workspaces they are authorized on, with no cross-tenant existence
|
||||
oracles (§8.3). A5 rank 5 names the deliverable: an authorized
|
||||
read-only roll-up query over only readable workspaces, at every
|
||||
hierarchy level, as its own non-mutating query tool, dependent on ranks
|
||||
1–3.
|
||||
|
||||
Revision 2 (terra review F1–F7): membership-only readability is now
|
||||
workspace-local — it contributes at the workspace node only and never
|
||||
promotes ancestor visibility; upward aggregation requires an effective
|
||||
chain role, and §1 defines direct vs effective grants in contract 2's
|
||||
terms (F1). The no-oracle rule gains a defined equivalence predicate
|
||||
(normalized byte equality with an enumerated volatile-field set) and a
|
||||
partial-scope hidden-sibling witness (F2). §2.5 enumerates the closed
|
||||
semantic result and denial schemas field-by-field, including the
|
||||
explicit-zero representation (F3). Cache invalidation, when a cache
|
||||
exists, is witnessed per invalidator class (F4). Non-authoritative and
|
||||
never-gate rules gain an import-graph/data-flow witness, and the
|
||||
mutation check is aligned to contract 1 §6.7's both-table zero-write
|
||||
assertion (F5). The fixture gains a second estate with distinct counts
|
||||
and explicit company-, estate-, project-grant, and membership cases
|
||||
(F6). The §5.3 legacy-row exclusion and pre-rank no-obligation rules
|
||||
are disclosed as drafting additions (F7).
|
||||
|
||||
Revision 3 (terra re-review residuals): the partial-scope witnesses are
|
||||
reconstructed at levels where chain grants can actually differ —
|
||||
platform-project siblings under one estate and estate siblings under
|
||||
one company — because contract 1 §3.1/§3.4 defines no workspace-level
|
||||
grant target, so no reader can hold a chain grant on two of three
|
||||
sibling workspaces (F2). §2.5 now defines one field-exact recursive
|
||||
record — every node, including the queried node and every leaf, is the
|
||||
same five-field shape with a required, deterministically ordered
|
||||
`children` array that is empty at workspaces — and the whole-result
|
||||
rules (no optional fields, denial envelope, wire faithfulness) are
|
||||
their own §2.6 at section scope (F3). The fixture assigns workspaces
|
||||
to named platform-projects, and §6.1's grant-level cases are the three
|
||||
levels contract 1 defines, with workspace-level access covered by the
|
||||
membership case and stated as having no direct chain grant (F6).
|
||||
|
||||
This contract binds the projection semantics (§2), reader authorization
|
||||
semantics (§3), read-only enforcement (§4), dependencies and phase
|
||||
timing (§5), witnesses (§6), and disclosed drafting additions (§7). It
|
||||
defines the roll-up only: hierarchy shape stays with contract 1
|
||||
(`hierarchy-schema.md`), grant vocabulary and evaluation with contract 2
|
||||
(`rbac-grant-model.md`), the task lifecycle and status taxonomy with
|
||||
`native-kanban-sot.md`'s typed surface, and the tool↔Gateway mapping
|
||||
row with contract 5 (`tool-gateway-mapping.md`).
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
1. **Roll-up**: the read-only projection of per-workspace task counts
|
||||
by status, aggregated up the contract 1 parent chain (workspace →
|
||||
platform-project → estate → company).
|
||||
2. **Effective chain role** (at a node, for a reader): the role
|
||||
contract 2 §3 evaluation yields at that node — from a grant on the
|
||||
node itself (a **direct grant**) or from a grant on an ancestor
|
||||
whose domain covers it (an **inherited grant**, contract 2 §3.2).
|
||||
The role vocabulary is contract 2 §2's; this contract adds no role
|
||||
and no new authority source.
|
||||
3. **Chain-readable workspace** (for a reader): a workspace where the
|
||||
reader's effective chain role permits reading task state.
|
||||
4. **Member-readable workspace** (for a reader): a workspace readable
|
||||
only through workspace membership under the SOT's own membership
|
||||
rules (REQ-ID-001), with no effective chain role. Membership
|
||||
confers workspace-local semantics only (contract 2 §3.1, §7.4): it
|
||||
never contributes authority, visibility, or aggregation upward.
|
||||
5. **Aggregation scope** (of a hierarchy node, for a reader): the set
|
||||
of chain-readable workspaces in that node's descendant subtree;
|
||||
plus, when the node is itself a workspace, that workspace if it is
|
||||
chain-readable or member-readable. A member-readable workspace
|
||||
therefore contributes to exactly one node's aggregation scope: its
|
||||
own.
|
||||
6. **Projection**: a generated, non-authoritative view in the sense of
|
||||
`native-kanban-sot.md` §3 invariant 5 — derived from SOT rows,
|
||||
never an import source, never authoritative.
|
||||
|
||||
## 2. Projection semantics
|
||||
|
||||
1. **Aggregate content.** The roll-up for a node reports, per
|
||||
workspace in the reader's aggregation scope and as subtree totals:
|
||||
task counts keyed by the typed lifecycle's status values (owned by
|
||||
`native-kanban-sot.md`; this contract introduces no status), and
|
||||
nothing else. Direct count/status aggregation is the entire
|
||||
surface.
|
||||
2. **Every level.** The roll-up is queryable at workspace,
|
||||
platform-project, estate, and company level. A node's totals equal
|
||||
the sum over its aggregation scope; chain resolution is contract 1
|
||||
§2.5's (every workspace resolves to exactly one chain), so no
|
||||
workspace is counted twice and none is orphaned.
|
||||
3. **Carve-out boundary.** Everything beyond direct count/status
|
||||
aggregation — metrics, trends, forecasting, scoring, velocity,
|
||||
cross-workspace derived analytics, dashboards computed across
|
||||
workspaces — remains a `native-kanban-sot.md` §6 non-goal
|
||||
(§8.2.4). The response schema is closed (§2.5; §6.7 witness):
|
||||
adding any field is an amendment to this contract.
|
||||
4. **Non-authoritative.** No consumer may treat roll-up output as a
|
||||
source of record; it is recomputable at any time from SOT rows and
|
||||
is never imported, persisted as authoritative state, or used to
|
||||
gate or deny work (witness §6.8 — both the write-path and the
|
||||
decision-path prohibitions are witnessed).
|
||||
5. **Closed semantic schema.** The successful result is exactly one
|
||||
**roll-up node record**, a single recursive shape used at every
|
||||
depth. A roll-up node record consists of exactly these five
|
||||
fields, and no others:
|
||||
- `id`: the node's identifier.
|
||||
- `type`: one of the four contract 1 levels.
|
||||
- `name`: the node's name.
|
||||
- `totals`: one entry per status value of the typed lifecycle —
|
||||
every status key present, a count of zero represented explicitly
|
||||
as `0`, never by key absence. At a workspace node, `totals` is
|
||||
that workspace's own counts; at any other node, `totals` is the
|
||||
sum over the node's aggregation scope (§2.2). This is how §2.1's
|
||||
"per workspace and as subtree totals" content is carried:
|
||||
per-workspace counts are the leaf records' `totals`, subtree
|
||||
totals are the interior records' `totals`.
|
||||
- `children`: a required array, present on EVERY node record. Its
|
||||
elements are the reader-visible (§3.2) child nodes of this node,
|
||||
each itself a complete roll-up node record, recursing down to
|
||||
the workspaces in the reader's aggregation scope. At a workspace
|
||||
node the array is exactly `[]` — a workspace record never has
|
||||
children. The array is ordered deterministically, ascending by
|
||||
`id`; the implementing PR asserts that ordering. A node outside
|
||||
§3.2 visibility never appears at any depth.
|
||||
|
||||
The queried node's record IS the whole result — there is no
|
||||
wrapper field around it.
|
||||
|
||||
6. **Whole-result rules.** There are no optional result fields at any
|
||||
depth. The denial/nonexistent response is the contract 5 §4.2
|
||||
not-found-class error envelope with no fields beyond that
|
||||
envelope. The wire DTO is expressed under contract 5 §4.1, and
|
||||
MUST be a faithful serialization of exactly the §2.5 recursive
|
||||
record: a wire field with no corresponding semantic field is a
|
||||
conformance defect.
|
||||
|
||||
## 3. Reader authorization semantics
|
||||
|
||||
1. **Scope rule.** A reader's roll-up over any node aggregates ONLY
|
||||
the reader's aggregation scope (§1.5). An unreadable workspace
|
||||
contributes nothing to any total — not a count, not a row, not a
|
||||
presence marker. A member-readable workspace contributes only at
|
||||
the workspace node itself (§1.4–§1.5): querying it directly
|
||||
succeeds; it never appears in, and never adds to, any ancestor's
|
||||
response for that reader.
|
||||
2. **Node visibility.** A node appears in a roll-up response iff the
|
||||
reader's aggregation scope at that node is non-empty, or the
|
||||
reader holds an effective chain role at the node (§1.2 — direct or
|
||||
inherited; contract 2 §3.2 makes a grant's domain the node and its
|
||||
subtree, so an ancestor grant makes empty descendants visible per
|
||||
the ruling). Per the ruling below, a node with an effective chain
|
||||
role but an empty aggregation scope appears with zero counts.
|
||||
Workspace membership alone never makes any non-workspace node
|
||||
visible. A node where the reader has neither an effective chain
|
||||
role nor a non-empty aggregation scope does not appear at all.
|
||||
3. **No existence oracle.** The response MUST NOT disclose the
|
||||
existence, count, name, or any property of unreadable workspaces
|
||||
or of nodes outside §3.2 visibility — no "N workspaces hidden"
|
||||
fields, no total-vs-visible discrepancy fields. A query naming a
|
||||
node outside §3.2 visibility MUST satisfy the §3.4 response
|
||||
equivalence with a query naming a nonexistent node (fail closed,
|
||||
`rbac-grant-model.md` §3.5 pattern: a decision path that cannot
|
||||
read grant state denies).
|
||||
4. **Response equivalence predicate.** Two responses are equivalent
|
||||
when they carry the identical HTTP status, the identical contract
|
||||
5 §4.2 error code, and byte-identical bodies after normalizing
|
||||
exactly the declared volatile envelope fields — correlation id and
|
||||
response timestamp, and nothing else. The implementing PR declares
|
||||
that volatile-field list in the witness; any additional
|
||||
normalization is a conformance defect. This is contract 5 §4.2's
|
||||
same code/status/shape rule made executable.
|
||||
5. **Live evaluation.** Readability is evaluated per contract 2 §3.5
|
||||
(live rows or transactionally-invalidated cache). Revocation
|
||||
propagates per contract 2 §6: the next roll-up query decided after
|
||||
the revoking transaction commits excludes the revoked scope.
|
||||
|
||||
## 4. Read-only enforcement
|
||||
|
||||
1. **Never a write.** No roll-up path may mutate, claim, order, or
|
||||
gate work in any workspace (§8.2.2). The roll-up ships as a
|
||||
non-mutating query tool (A5 rank 5) — a query surface with no
|
||||
command counterpart.
|
||||
2. **Mechanical enforcement.** The implementing PR executes roll-up
|
||||
database work inside read-only transactions (or an equivalently
|
||||
privilege-restricted path), so a mutation attempt fails at the
|
||||
database boundary, not only by convention.
|
||||
3. **Freshness.** v1 computes the roll-up live from SOT rows at query
|
||||
time. A cache is an implementation option only if it is
|
||||
invalidated in the same transaction as any task, hierarchy, grant,
|
||||
or membership mutation that affects it (each invalidator class
|
||||
witnessed, §6.5), and it is never authoritative (§1.6).
|
||||
|
||||
## 5. Dependencies and phase timing
|
||||
|
||||
1. The roll-up depends on A5 ranks 1–3: contract 1's hierarchy tables
|
||||
(the parent chain), contract 2's evaluator (readability), and the
|
||||
typed Kanban lifecycle (the task state being counted). It ships
|
||||
after them and reads their surfaces; it defines none of them.
|
||||
2. The roll-up query is one tool with one Gateway mapping row under
|
||||
contract 5's regime (request/result/error/audit contracts there);
|
||||
this contract binds its semantics (§2.5 defines the semantic
|
||||
fields the contract 5 §4.1 DTO serializes), not its wire encoding.
|
||||
3. Legacy task rows outside the typed lifecycle are not aggregated;
|
||||
the roll-up begins counting a workspace's tasks when they exist in
|
||||
the typed surface. No roll-up obligation attaches to v1 before
|
||||
ranks 1–3 exist. Both rules are drafting additions disclosed in §7
|
||||
(they trace to no §8 sentence).
|
||||
|
||||
## 6. Verification requirements
|
||||
|
||||
Binding on the implementing PRs. Every witness names, in its
|
||||
implementation, the exact endpoints/tools, tables, and fixtures it
|
||||
exercises. The base fixture seeds two companies; under company A **two
|
||||
estates with distinct, non-identical count profiles**: estate A1 with
|
||||
two platform-projects — P1 holding workspaces W1 and W2, P2 holding
|
||||
workspace W3 — and estate A2 with one platform-project P3 holding one
|
||||
workspace W4, all with known task counts across at least three
|
||||
statuses; under company B one workspace.
|
||||
|
||||
1. **Correctness witnesses:** for a reader holding a direct company-A
|
||||
grant, roll-up totals at every level equal the seeded sums — each
|
||||
workspace, each platform-project, estate A1 and estate A2
|
||||
separately (their distinct profiles asserted distinct), and the
|
||||
company total equal to A1+A2 — keyed by the typed status values,
|
||||
with no double count across the chain. For a reader holding a
|
||||
direct estate-A1 grant, the estate-A1 result equals the A1 sum and
|
||||
a company-A query returns company A with exactly A1's contribution
|
||||
(estate A2 invisible). Each of the three chain grant levels
|
||||
contract 1 §3.1 defines — company, estate, platform-project
|
||||
(below, §6.2) — has an explicit direct-grant case, none simulated
|
||||
by unioning lower access. Workspace-level access has NO direct
|
||||
chain grant (contract 1 §3.1/§3.4 define no workspace grant
|
||||
target) and is covered by the §6.2 membership case.
|
||||
2. **Scope witnesses:** a reader with a direct grant on
|
||||
platform-project P1 only sees exactly P1's subtree counts
|
||||
(W1+W2): a P1 query returns W1+W2; an estate-A1 query returns the
|
||||
estate node with exactly P1's contribution, sibling project P2 and
|
||||
its workspace W3 absent at every depth; a company-A query likewise
|
||||
carries only P1's contribution. An estate-sibling case: a reader
|
||||
with a direct grant on estate A1 only queries company A and
|
||||
receives exactly A1's contribution, estate A2 absent. (Chain
|
||||
grants exist only at company, estate, and platform-project —
|
||||
contract 1 §3.1 — so partial scope among SIBLING WORKSPACES of
|
||||
one project is not constructible by grants and is not witnessed;
|
||||
the constructible partial-scope cases are the project- and
|
||||
estate-sibling ones above.) **Membership locality (§1.4):** a member-only reader queries
|
||||
the workspace directly and receives its counts; the same reader
|
||||
querying the workspace's parent (or any ancestor) receives the
|
||||
§3.4-equivalent nonexistent-node response, and no ancestor
|
||||
response for any other reader changes because of that membership.
|
||||
3. **No-oracle witnesses:** the P1-only reader's estate-A1 response
|
||||
above contains no field disclosing P2's or W3's existence
|
||||
(closed-schema comparison against an estate-A1-granted reader's
|
||||
response: identical field set, differing only in counts and
|
||||
visible nodes). **Partial-scope hidden node:** the P1-only reader
|
||||
— who sees estate A1 and the P1 subtree — queries hidden sibling
|
||||
project P2 by its real id, and separately hidden workspace W3 by
|
||||
its real id; each response satisfies the §3.4 equivalence
|
||||
predicate against the same query naming a nonexistent id, under
|
||||
one fixed request context with the declared volatile-field
|
||||
normalization. **Cross-tenant:** an unauthorized reader naming company B receives
|
||||
a response §3.4-equivalent to naming a nonexistent id. Each
|
||||
equivalence check is executable byte comparison after the declared
|
||||
normalization, not a shape judgment.
|
||||
4. **Empty-vs-hidden witness (ruling):** a reader granted (direct
|
||||
chain grant) on an empty platform-project receives it with zero
|
||||
counts — every status key present at `0` (§2.5); with the grant
|
||||
deleted, the same query returns the §3.4-equivalent
|
||||
nonexistent-node response. An inherited-grant case: a company
|
||||
grant makes an empty descendant platform-project visible with zero
|
||||
counts.
|
||||
5. **Cache-invalidation witnesses (conditional):** bound only if the
|
||||
implementation caches — for EACH invalidator class, prime the
|
||||
cache, commit one mutation of that class, and assert the next
|
||||
query reflects it: a task status change, a task creation, a
|
||||
membership removal (the member-readable workspace disappears from
|
||||
its own node's next query), a workspace reparenting (both old and
|
||||
new parent totals correct), and a grant revocation. A live
|
||||
(cacheless) v1 implementation records that fact and the witnesses
|
||||
bind at the PR that introduces a cache.
|
||||
6. **Mutation witnesses:** the roll-up surface rejects every mutating
|
||||
verb/command; a crafted attempt to issue a write through the
|
||||
roll-up's database path fails at the read-only boundary (§4.2);
|
||||
after any roll-up query, the row diff is empty across BOTH the
|
||||
workspace tables and the hierarchy tables (contract 1 §6.7's
|
||||
both-table zero-write assertion).
|
||||
7. **Closed-schema witness:** the response is asserted field-exact
|
||||
against the §2.5 recursive record at every depth — exactly
|
||||
`id`/`type`/`name`/`totals`/`children` on every node, every typed
|
||||
status present with explicit zeros, `children: []` at every
|
||||
workspace record, the declared ascending-`id` ordering, no
|
||||
wrapper field — and a response carrying any field outside the
|
||||
record at any depth fails the assertion (carve-out boundary,
|
||||
§2.3). The denial envelope is asserted field-exact against
|
||||
contract 5 §4.2's envelope (§2.6).
|
||||
8. **Non-authoritative and never-gate witnesses:** (a) a static
|
||||
production import-graph inventory (hierarchy contract §6.3 style,
|
||||
production code over `apps/` and `packages/`, tests excluded)
|
||||
shows no production module imports the roll-up query module or its
|
||||
result DTO into any SOT write path, any authorization/gating
|
||||
decision path, or any persistence beyond the response lifetime —
|
||||
asserted in both directions (the roll-up module's consumers are
|
||||
enumerated and each is a presentation surface); (b) a behavioral
|
||||
probe: with roll-up output artificially perturbed (test double),
|
||||
no authorization outcome and no work-gating decision anywhere in
|
||||
the fixture suite changes — proving no gate consumes it.
|
||||
9. **Revocation witness:** after revoking the grant that made a
|
||||
subtree readable, the next roll-up query excludes it (contract 2
|
||||
§6.2 bound).
|
||||
|
||||
## 7. Drafting additions (PRD §12.1 disclosure)
|
||||
|
||||
Proposed drafting additions, visible here for ratification, each
|
||||
severable; the aggregation itself, its authorization scope, its
|
||||
read-only nature, and the no-oracle acceptance are traced to
|
||||
`native-kanban-sot.md` §8 and are not additions:
|
||||
|
||||
1. The §3.2 node-visibility rule and the granted-but-empty behavior
|
||||
(the ruling below).
|
||||
2. The §3.3–§3.4 nonexistent-node response equivalence, with its
|
||||
normalized-byte-equality predicate, as the concrete no-oracle
|
||||
mechanism.
|
||||
3. The §4.2 read-only-transaction mechanical enforcement.
|
||||
4. The §4.3 cache option with transactional invalidation and the
|
||||
§6.5 per-invalidator witnesses.
|
||||
5. The §2.5 closed response schema as an amendment boundary.
|
||||
6. The §1.4 membership-locality rule — membership-only readability
|
||||
contributes at the workspace node only (this contract's
|
||||
reconciliation of `native-kanban-sot.md` §8.1.3 "authorized on"
|
||||
with contract 2 §3.1/§7.4's workspace-local membership).
|
||||
7. The §5.3 legacy-row exclusion and the §5.3 pre-rank no-obligation
|
||||
rule.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ruling requested (one decision): shall a node the reader holds an
|
||||
effective chain role on (direct or inherited, §1.2) but whose
|
||||
aggregation scope is empty appear in the roll-up with zero counts
|
||||
(recommended — it lets the UI show a granted-but-empty subtree
|
||||
honestly) — or, as the alternative, be indistinguishable from a
|
||||
nonexistent node until it contains a readable workspace?
|
||||
@@ -7,7 +7,6 @@ export default tseslint.config(
|
||||
ignores: [
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'**/.next/**',
|
||||
'**/coverage/**',
|
||||
'**/drizzle.config.ts',
|
||||
'**/framework/**',
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"preflight": "node scripts/preflight.mjs",
|
||||
"clean:generated": "node scripts/clean-generated.mjs",
|
||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||
"verify:release": "node scripts/verify-release.mjs",
|
||||
"test:checkout": "node --test scripts/*.test.mjs",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "accounts" ADD COLUMN "issuer" text;
|
||||
--> statement-breakpoint
|
||||
-- Backfill (#1395): better-auth >=1.7 sign-in filters accounts on
|
||||
-- (provider_id = 'credential' AND issuer = 'local:credential'). Existing
|
||||
-- credential rows predate the column and would fail that filter on upgraded
|
||||
-- installs. Credential rows ONLY: better-auth owns issuer semantics for
|
||||
-- oauth/sso rows going forward (each provider's real issuer value), so those
|
||||
-- stay NULL until the provider's next flow writes them.
|
||||
UPDATE "accounts" SET "issuer" = 'local:credential' WHERE "provider_id" = 'credential' AND "issuer" IS NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
||||
"when": 1784050648841,
|
||||
"tag": "0016_salty_morlocks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1787609223282,
|
||||
"tag": "0017_accounts_issuer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
export { createDb, type Db, type DbHandle } from './client.js';
|
||||
export { createPgliteDb } from './client-pglite.js';
|
||||
export { runMigrations, runPgliteMigrations } from './migrate.js';
|
||||
export {
|
||||
runMigrations,
|
||||
runPgliteMigrations,
|
||||
getMigrationStatus,
|
||||
readJournalTags,
|
||||
applyMigrationsByHash,
|
||||
type HashLedgerDeps,
|
||||
type MigrationPlanEntry,
|
||||
type MigrationStatus,
|
||||
} from './migrate.js';
|
||||
export * from './schema.js';
|
||||
export * from './federation.js';
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
applyMigrationsByHash,
|
||||
readJournalTags,
|
||||
type HashLedgerDeps,
|
||||
type MigrationPlanEntry,
|
||||
} from './migrate.js';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* In-memory hash-ledger harness */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface LedgerHarness extends HashLedgerDeps {
|
||||
ledger: Map<string, number>;
|
||||
/** Recorded statement executions in order: `${hashPrefix}:${stmtIndex}`. */
|
||||
executed: string[];
|
||||
/** Optional: statements that should throw when executed. */
|
||||
failOn?: (migrationHash: string, stmtIdx: number) => boolean;
|
||||
}
|
||||
|
||||
function makeHarness(plan: MigrationPlanEntry[]): LedgerHarness {
|
||||
const hashToEntry = new Map(plan.map((p) => [p.hash, p]));
|
||||
const h: LedgerHarness = {
|
||||
ledger: new Map(),
|
||||
executed: [],
|
||||
ensureLedger: async () => {},
|
||||
appliedHashes: async () => [...h.ledger.keys()],
|
||||
recordApplied: async (hash, folderMillis) => {
|
||||
h.ledger.set(hash, folderMillis);
|
||||
},
|
||||
runStatement: async (statement) => {
|
||||
void statement;
|
||||
// runStatement does not know which migration it belongs to; the
|
||||
// executed log is filled by the wrapper below.
|
||||
},
|
||||
};
|
||||
// Wrap runStatement so the executed log records migration context. We
|
||||
// reconstruct context by tracking a cursor the core advances per migration.
|
||||
let cursor = 0;
|
||||
const flat: Array<{ hash: string; idx: number }> = [];
|
||||
for (const m of plan)
|
||||
for (const [i] of m.statements.entries()) flat.push({ hash: m.hash, idx: i });
|
||||
h.runStatement = async () => {
|
||||
const at = flat[cursor] ?? { hash: '??', idx: -1 };
|
||||
cursor += 1;
|
||||
if (h.failOn && at.hash !== '??' && h.failOn(at.hash, at.idx)) {
|
||||
throw new Error(`simulated failure in ${at.hash} #${at.idx.toString()}`);
|
||||
}
|
||||
h.executed.push(`${at.hash.slice(0, 6)}:${at.idx.toString()}`);
|
||||
};
|
||||
void hashToEntry;
|
||||
return h;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Fixtures */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// Reproduces the REAL journal defect shape (#1402 D1): 0009/0010 carry
|
||||
// `when` timestamps BELOW 0008's. Under the old drizzle postgres-js
|
||||
// migrator these were silently skipped on any upgrade whose ledger was
|
||||
// last stamped in the 0008 era.
|
||||
const JOURNAL_FIXTURE: MigrationPlanEntry[] = [
|
||||
{ hash: 'aaaa0000', folderMillis: 1773368153122, statements: ['CREATE TABLE a (id int)'] },
|
||||
{ hash: 'bbbb0008', folderMillis: 1776822435828, statements: ['CREATE TABLE b (id int)'] },
|
||||
// Backdated entries, exactly as shipped:
|
||||
{
|
||||
hash: 'cccc0009',
|
||||
folderMillis: 1745280000000,
|
||||
statements: ['ALTER TYPE t ADD VALUE', "CREATE TABLE c (s t DEFAULT 'pending')"],
|
||||
},
|
||||
{ hash: 'dddd0010', folderMillis: 1745366400000, statements: ['CREATE TABLE d (id int)'] },
|
||||
];
|
||||
|
||||
/** A ledger last stamped at the 0008 era: only pre-0009 hashes recorded. */
|
||||
const LEDGER_AT_0008_ERA = new Map<string, number>([
|
||||
['aaaa0000', 1773368153122],
|
||||
['bbbb0008', 1776822435828],
|
||||
]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The core: apply-by-hash in journal order */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe('applyMigrationsByHash', () => {
|
||||
it('applies backdated journal entries that a timestamp-based migrator would skip (#1402 D1)', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
h.ledger = new Map(LEDGER_AT_0008_ERA);
|
||||
|
||||
const result = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
|
||||
// D1 in one sentence: 0009 and 0010 applied despite folderMillis < 0008.
|
||||
expect(result).toEqual({ applied: 2, skipped: 2 });
|
||||
expect(h.ledger.has('cccc0009')).toBe(true);
|
||||
expect(h.ledger.has('dddd0010')).toBe(true);
|
||||
});
|
||||
|
||||
it('executes statements individually (ALTER TYPE visibility, #1402 D2 shape)', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
// 0009's two statements recorded as separate executions, in order.
|
||||
expect(h.executed).toContain('cccc00:0');
|
||||
expect(h.executed).toContain('cccc00:1');
|
||||
expect(h.executed.indexOf('cccc00:0')).toBeLessThan(h.executed.indexOf('cccc00:1'));
|
||||
});
|
||||
|
||||
it('is idempotent: a fully-applied ledger applies nothing', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
const first = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
const second = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
expect(first.applied).toBe(4);
|
||||
expect(second).toEqual({ applied: 0, skipped: 4 });
|
||||
expect(h.executed).toHaveLength(5); // 5 statements; second run executed NONE (not 10)
|
||||
});
|
||||
|
||||
it('records no ledger row when a statement fails (crash prefix replays loudly)', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
h.failOn = (hash, idx) => hash === 'cccc0009' && idx === 1;
|
||||
|
||||
await expect(applyMigrationsByHash(h, JOURNAL_FIXTURE)).rejects.toThrow(
|
||||
/cccc0009 statement #1 failed: simulated failure/,
|
||||
);
|
||||
// Statement 0 of 0009 executed, but NO ledger row for 0009: the next run
|
||||
// replays it and fails loudly on "already exists" instead of silently
|
||||
// believing 0009 applied.
|
||||
expect(h.ledger.has('cccc0009')).toBe(false);
|
||||
expect(h.executed).toContain('cccc00:0');
|
||||
});
|
||||
|
||||
it('applies in JOURNAL order, not timestamp order', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
// 5 statements total (0009 has two); prefix per migration: journal order,
|
||||
// so 0009's pair sits between 0008 and 0010.
|
||||
const order = h.executed.map((e) => e.slice(0, 4));
|
||||
expect(order).toEqual(['aaaa', 'bbbb', 'cccc', 'cccc', 'dddd']);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Journal integrity against the shipped folder */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe('readJournalTags', () => {
|
||||
it('reads the shipped journal in order and sees the known backdated pair', () => {
|
||||
const folder = resolve(__dirname, '../drizzle');
|
||||
const tags = readJournalTags(folder);
|
||||
expect(tags.length).toBeGreaterThan(0);
|
||||
// The shipped defect (#1402 D1): these two entries carry April-2025
|
||||
// timestamps below 0008's June-2026 one. If this assertion ever fails
|
||||
// because the journal was FIXED (timestamps corrected or drizzle-kit
|
||||
// regenerated), update #1402 — the hash-ledger core stays correct either
|
||||
// way; this test pins the shipped reality the core was built for.
|
||||
const t9 = tags.find((t) => t.startsWith('0009_'));
|
||||
const t10 = tags.find((t) => t.startsWith('0010_'));
|
||||
const t8 = tags.find((t) => t.startsWith('0008_'));
|
||||
expect([t8, t9, t10]).toBeDefined();
|
||||
const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as {
|
||||
entries: Array<{ tag: string; when: number }>;
|
||||
};
|
||||
const when = new Map(journal.entries.map((e) => [e.tag, e.when]));
|
||||
if (t8 && t9 && t10) {
|
||||
expect(when.get(t9)!).toBeLessThan(when.get(t8)!); // backdated below 0008
|
||||
expect(when.get(t10)!).toBeLessThan(when.get(t8)!); // backdated below 0008
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,75 @@ describe('runPgliteMigrations', () => {
|
||||
await expect(runPgliteMigrations(handle)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives accounts an issuer column (#1395) — better-auth >=1.7 requires it', async () => {
|
||||
await runPgliteMigrations(handle);
|
||||
|
||||
const result = (await handle.db.execute(sql`
|
||||
SELECT column_name, is_nullable, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'accounts' AND column_name = 'issuer'
|
||||
`)) as unknown as {
|
||||
rows: Array<{ column_name: string; is_nullable: string; data_type: string }>;
|
||||
};
|
||||
|
||||
// Nullable by design: the 1.5.x line this repo's lockfile resolves to does
|
||||
// not write the field; 1.7+ populates it. One schema serves both.
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0]?.is_nullable).toBe('YES');
|
||||
expect(result.rows[0]?.data_type).toBe('text');
|
||||
});
|
||||
|
||||
it('backfills ONLY credential rows with the synthetic issuer (#1395 upgrade path)', async () => {
|
||||
// Simulate an upgraded install: migrate through 0016 only, seed pre-issuer
|
||||
// rows (one credential, one oauth), then apply 0017 and discriminate.
|
||||
const client = (handle.db as unknown as { $client: PgliteExec }).$client;
|
||||
|
||||
// Migrate to 0016 by replaying every ledger file except 0017 — the ledger
|
||||
// table gates re-application, so a plain replay of 0000..0016 is enough.
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.join(import.meta.dirname, '..', 'drizzle');
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => /^\d{4}_.*\.sql$/.test(f) && f < '0017')
|
||||
.sort();
|
||||
for (const f of files) {
|
||||
const raw = fs.readFileSync(path.join(dir, f), 'utf-8');
|
||||
for (const stmt of raw.split('--> statement-breakpoint')) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) await client.exec(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
await client.exec(`
|
||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES ('u1', 'Legacy User', '[email protected]', true, now(), now());
|
||||
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
|
||||
VALUES
|
||||
('a1', '[email protected]', 'credential', 'u1', now(), now()),
|
||||
('a2', 'oauth-provider-1', 'google', 'u1', now(), now());
|
||||
`);
|
||||
|
||||
// Apply 0017 (column + backfill).
|
||||
const sql0017 = fs.readFileSync(path.join(dir, '0017_accounts_issuer.sql'), 'utf-8');
|
||||
for (const stmt of sql0017.split('--> statement-breakpoint')) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) await client.exec(trimmed);
|
||||
}
|
||||
|
||||
const rows = (await handle.db.execute(sql`
|
||||
SELECT provider_id, issuer FROM accounts ORDER BY id
|
||||
`)) as unknown as { rows: Array<{ provider_id: string; issuer: string | null }> };
|
||||
|
||||
const byProvider = new Map(rows.rows.map((r) => [r.provider_id, r.issuer]));
|
||||
// Credential rows get better-auth's synthetic local issuer — the value
|
||||
// sign-in filters on (better-auth dist createLocalAccountIssuer).
|
||||
expect(byProvider.get('credential')).toBe('local:credential');
|
||||
// OAuth rows are LEFT NULL: better-auth owns their issuer semantics going
|
||||
// forward (each provider's real issuer on its next flow).
|
||||
expect(byProvider.get('google')).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces statement-level error context on failure and leaves no ledger row', async () => {
|
||||
// Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE,
|
||||
// forcing it to fail without IF NOT EXISTS.
|
||||
|
||||
+222
-69
@@ -1,8 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js';
|
||||
import { migrate as migratePostgres } from 'drizzle-orm/postgres-js/migrator';
|
||||
import { readMigrationFiles } from 'drizzle-orm/migrator';
|
||||
import postgres from 'postgres';
|
||||
import { DEFAULT_DATABASE_URL } from './defaults.js';
|
||||
@@ -21,89 +20,243 @@ function migrationsFolder(): string {
|
||||
return resolve(here, '../drizzle');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared hash-ledger migration core (#1392 / #1402) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
//
|
||||
// Both tiers migrate through this single core, which applies migrations in
|
||||
// JOURNAL ORDER, one statement at a time, and skips by HASH — never by
|
||||
// folderMillis timestamp. The previous postgres path delegated to drizzle's
|
||||
// postgres-js migrator, which:
|
||||
//
|
||||
// * applies only migrations with folderMillis > last-applied, silently
|
||||
// skipping journal entries whose `when` is older than the ledger's newest
|
||||
// stamp — 0009/0010 carry April-2025 timestamps below 0008's June-2026
|
||||
// one, so any database last migrated in the 0008 era silently loses
|
||||
// 0009/0010 forever (#1402 D1); and
|
||||
// * wraps each migration in ONE transaction, which breaks migrations that
|
||||
// do `ALTER TYPE ADD VALUE` and then reference the new value in the same
|
||||
// migration (0009) — Postgres' check_safe_enum_use rejects it (#1402 D2).
|
||||
//
|
||||
// Per-statement execution (each statement autocommits) and skip-by-hash fix
|
||||
// both. The PGlite path has run this way since it was written; this is the
|
||||
// TODO it left behind, now shared instead of duplicated.
|
||||
|
||||
/** One migration as loaded from the shipped drizzle/ folder. */
|
||||
export interface MigrationPlanEntry {
|
||||
hash: string;
|
||||
folderMillis: number;
|
||||
statements: string[];
|
||||
}
|
||||
|
||||
/** The persistence operations the hash-ledger core needs, per tier. */
|
||||
export interface HashLedgerDeps {
|
||||
/** Create the drizzle schema + ledger table if absent (idempotent). */
|
||||
ensureLedger(): Promise<void>;
|
||||
/** Hashes already recorded in the ledger. */
|
||||
appliedHashes(): Promise<string[]>;
|
||||
/** Record one fully-applied migration in the ledger. */
|
||||
recordApplied(hash: string, folderMillis: number): Promise<void>;
|
||||
/** Execute one SQL statement, autocommitting (never inside a wider tx). */
|
||||
runStatement(statement: string): Promise<void>;
|
||||
}
|
||||
|
||||
function loadPlan(): MigrationPlanEntry[] {
|
||||
return readMigrationFiles({ migrationsFolder: migrationsFolder() }).map((m) => ({
|
||||
hash: m.hash,
|
||||
folderMillis: m.folderMillis,
|
||||
statements: m.sql.map((s) => s.trim()).filter((s) => s.length > 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply every unapplied migration in journal order, skipping by hash.
|
||||
*
|
||||
* Failure model: each statement autocommits, and the ledger row is written
|
||||
* only after all statements of a migration succeed. A crash mid-migration
|
||||
* leaves the prefix applied with no ledger entry, so the next boot replays
|
||||
* those statements and fails loudly on "already exists". Recovery: drop the
|
||||
* partially-applied objects, or insert the migration's hash into
|
||||
* `drizzle.__drizzle_migrations` manually. The thrown error identifies the
|
||||
* statement and migration that failed.
|
||||
*/
|
||||
export async function applyMigrationsByHash(
|
||||
deps: HashLedgerDeps,
|
||||
plan: MigrationPlanEntry[] = loadPlan(),
|
||||
): Promise<{ applied: number; skipped: number }> {
|
||||
await deps.ensureLedger();
|
||||
const alreadyApplied = new Set(await deps.appliedHashes());
|
||||
|
||||
let applied = 0;
|
||||
let skipped = 0;
|
||||
for (const migration of plan) {
|
||||
if (alreadyApplied.has(migration.hash)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
for (const [stmtIdx, stmt] of migration.statements.entries()) {
|
||||
try {
|
||||
await deps.runStatement(stmt);
|
||||
} catch (err) {
|
||||
const cause = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` +
|
||||
`Statement: ${stmt.slice(0, 200)}${stmt.length > 200 ? '…' : ''}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
await deps.recordApplied(migration.hash, migration.folderMillis);
|
||||
applied += 1;
|
||||
}
|
||||
return { applied, skipped };
|
||||
}
|
||||
|
||||
const LEDGER_DDL = [
|
||||
'CREATE SCHEMA IF NOT EXISTS drizzle',
|
||||
`CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at bigint
|
||||
)`,
|
||||
];
|
||||
|
||||
function connectionString(url?: string): string {
|
||||
return url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Drizzle migrations against a postgres database, hash-ledger style.
|
||||
* Idempotent: re-running against a fully-migrated database applies nothing.
|
||||
*/
|
||||
export async function runMigrations(url?: string): Promise<void> {
|
||||
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
||||
const sqlClient = postgres(connectionString, { max: 1 });
|
||||
const db = drizzlePostgres(sqlClient);
|
||||
const sqlClient = postgres(connectionString(url), { max: 1 });
|
||||
try {
|
||||
// TODO: postgres-tier first-install also fails because (a) Drizzle wraps every
|
||||
// migration in one transaction (breaks 0009's ALTER TYPE ADD VALUE → SET DEFAULT
|
||||
// sequence) and (b) drizzle/meta/_journal.json has 0009 ordered before 0008,
|
||||
// which the postgres-js migrator skips by `created_at < folderMillis`. The
|
||||
// PGlite path below sidesteps both. A follow-up should either share the
|
||||
// per-statement loop (see runPgliteMigrations) or fix the journal ordering.
|
||||
await migratePostgres(db, { migrationsFolder: migrationsFolder() });
|
||||
await applyMigrationsByHash({
|
||||
ensureLedger: async () => {
|
||||
for (const ddl of LEDGER_DDL) await sqlClient.unsafe(ddl);
|
||||
},
|
||||
appliedHashes: async () => {
|
||||
const rows = (await sqlClient.unsafe(
|
||||
'SELECT hash FROM drizzle.__drizzle_migrations',
|
||||
)) as Array<{ hash: string }>;
|
||||
return rows.map((r) => String(r.hash));
|
||||
},
|
||||
recordApplied: async (hash, folderMillis) => {
|
||||
await sqlClient.unsafe(
|
||||
'INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)',
|
||||
[hash, folderMillis],
|
||||
);
|
||||
},
|
||||
runStatement: async (stmt) => {
|
||||
await sqlClient.unsafe(stmt);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await sqlClient.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Drizzle migrations against an embedded PGlite database.
|
||||
//
|
||||
// We don't reuse drizzle's pglite migrator because it wraps ALL migrations in
|
||||
// one outer transaction, which breaks Postgres' `check_safe_enum_use` rule —
|
||||
// e.g. migration 0009 does `ALTER TYPE ADD VALUE 'pending'` then references
|
||||
// `'pending'` as a default in the same tx. PGlite's `exec()` runs each
|
||||
// statement under the Simple Query protocol, autocommitting between them.
|
||||
//
|
||||
// We still write to the standard `drizzle.__drizzle_migrations` ledger so the
|
||||
// result is interoperable with `runMigrations()` on a postgres-backed deploy
|
||||
// (modulo the journal-ordering bug noted above).
|
||||
//
|
||||
// We skip-by-hash rather than skip-by-folderMillis (which is what Drizzle's
|
||||
// postgres-js migrator does). That's deliberate — out-of-order timestamps in
|
||||
// `_journal.json` won't silently drop migrations.
|
||||
//
|
||||
// Failure model: each statement autocommits, and the ledger row is written
|
||||
// only after all statements in a migration succeed. A crash mid-migration
|
||||
// leaves the prefix applied with no ledger entry, so the next boot will
|
||||
// replay those statements and fail loudly on "already exists". Recovery:
|
||||
// drop the partially-applied objects, or insert the migration's hash into
|
||||
// `drizzle.__drizzle_migrations` manually. The error log identifies which
|
||||
// statement of which migration was the culprit.
|
||||
/**
|
||||
* Apply Drizzle migrations against an embedded PGlite database.
|
||||
*
|
||||
* We don't reuse drizzle's pglite migrator for the same reasons as the
|
||||
* postgres path (single-transaction wrap; folderMillis skip). PGlite's
|
||||
* `exec()` runs each statement under the Simple Query protocol,
|
||||
* autocommitting between them — exactly the semantics the shared core needs.
|
||||
*
|
||||
* The ledger rows this writes are interoperable with the postgres path (same
|
||||
* schema, same hashes), because both consume the same shipped migrations.
|
||||
*/
|
||||
export async function runPgliteMigrations(handle: DbHandle): Promise<void> {
|
||||
const client = (handle.db as unknown as { $client?: PgliteExecutor }).$client;
|
||||
if (!client || typeof client.exec !== 'function') {
|
||||
throw new Error('runPgliteMigrations: handle.db is not backed by a PGlite client');
|
||||
}
|
||||
|
||||
await client.exec('CREATE SCHEMA IF NOT EXISTS drizzle');
|
||||
await client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at bigint
|
||||
)
|
||||
`);
|
||||
await applyMigrationsByHash({
|
||||
ensureLedger: async () => {
|
||||
for (const ddl of LEDGER_DDL) await client.exec(ddl);
|
||||
},
|
||||
appliedHashes: async () => {
|
||||
const rows = (await handle.db.execute(
|
||||
sql`SELECT hash FROM drizzle.__drizzle_migrations`,
|
||||
)) as unknown as ExecuteRows<{ hash: string }>;
|
||||
return rows.rows.map((r) => String(r.hash));
|
||||
},
|
||||
recordApplied: async (hash, folderMillis) => {
|
||||
await handle.db.execute(
|
||||
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${hash}, ${folderMillis})`,
|
||||
);
|
||||
},
|
||||
runStatement: async (stmt) => {
|
||||
await client.exec(stmt);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const appliedRows = (await handle.db.execute(
|
||||
sql`SELECT hash FROM drizzle.__drizzle_migrations`,
|
||||
)) as unknown as ExecuteRows<{ hash: string }>;
|
||||
const applied = new Set(appliedRows.rows.map((r) => r.hash));
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Migration status (#1392: the installer must VERIFY, not assume) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const migrations = readMigrationFiles({ migrationsFolder: migrationsFolder() });
|
||||
for (const migration of migrations) {
|
||||
if (applied.has(migration.hash)) continue;
|
||||
/** Read the journal tags (migration folder names) in journal order. */
|
||||
export function readJournalTags(folder: string = migrationsFolder()): string[] {
|
||||
const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as {
|
||||
entries?: Array<{ tag?: string }>;
|
||||
};
|
||||
return (journal.entries ?? []).map((e) => e.tag ?? '').filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
// Run each statement-breakpoint chunk in its own exec() call so PGlite
|
||||
// commits between statements — this is what lets `ALTER TYPE ADD VALUE`
|
||||
// become visible before a subsequent statement references the new value.
|
||||
for (const [stmtIdx, stmt] of migration.sql.entries()) {
|
||||
const trimmed = stmt.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
await client.exec(trimmed);
|
||||
} catch (err) {
|
||||
const cause = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`runPgliteMigrations: migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` +
|
||||
`Statement: ${trimmed.slice(0, 200)}${trimmed.length > 200 ? '…' : ''}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
export interface MigrationStatus {
|
||||
/** Hashes recorded in the database's ledger (0 if no ledger exists). */
|
||||
appliedCount: number;
|
||||
/** Migrations shipped in this package's drizzle/ folder. */
|
||||
expectedCount: number;
|
||||
/** The tag (folder name) of the last journal entry, for error messages. */
|
||||
expectedLastTag: string;
|
||||
/** True iff every shipped migration's hash is in the ledger. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report whether a postgres database carries the full shipped schema.
|
||||
*
|
||||
* Read-only apart from `ensureLedger` semantics: it never creates the ledger
|
||||
* (unlike the migrators), so a database with NO ledger reports
|
||||
* appliedCount=0 / complete=false — the exact #1392/#1389 signature (an
|
||||
* install whose dependency set shipped no migrations at all).
|
||||
*/
|
||||
export async function getMigrationStatus(url?: string): Promise<MigrationStatus> {
|
||||
const sqlClient = postgres(connectionString(url), { max: 1 });
|
||||
try {
|
||||
const plan = loadPlan();
|
||||
const tags = readJournalTags();
|
||||
const expectedHashes = new Set(plan.map((m) => m.hash));
|
||||
|
||||
let appliedHashes: string[] = [];
|
||||
const regRows = (await sqlClient.unsafe(
|
||||
"SELECT to_regclass('drizzle.__drizzle_migrations') AS reg",
|
||||
)) as Array<{ reg: string | null }>;
|
||||
if (regRows[0]?.reg) {
|
||||
const rows = (await sqlClient.unsafe(
|
||||
'SELECT hash FROM drizzle.__drizzle_migrations',
|
||||
)) as Array<{ hash: string }>;
|
||||
appliedHashes = rows.map((r) => String(r.hash));
|
||||
}
|
||||
await handle.db.execute(
|
||||
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${migration.hash}, ${migration.folderMillis})`,
|
||||
);
|
||||
|
||||
const appliedSet = new Set(appliedHashes);
|
||||
return {
|
||||
appliedCount: appliedHashes.length,
|
||||
expectedCount: plan.length,
|
||||
expectedLastTag: tags[tags.length - 1] ?? '',
|
||||
complete:
|
||||
plan.length > 0 &&
|
||||
plan.every((m) => appliedSet.has(m.hash)) &&
|
||||
// A ledger with entries OUTSIDE the shipped plan means the database
|
||||
// came from a different (e.g. newer) build — not "complete" either.
|
||||
appliedHashes.every((h) => expectedHashes.has(h)),
|
||||
};
|
||||
} finally {
|
||||
await sqlClient.end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ export const accounts = pgTable(
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
// better-auth >=1.7 requires an issuer on every account row: credential
|
||||
// sign-up writes the synthetic 'local:credential', OAuth rows carry the
|
||||
// provider's real issuer, and sign-in filters on (providerId, issuer).
|
||||
// Nullable because the 1.5.x line this repo's lockfile resolves to does
|
||||
// not know the field — 1.5 ignores it, 1.7 populates it (#1395).
|
||||
issuer: text('issuer'),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -23,8 +23,14 @@ absent. Do not use raw `tmux send-keys` for fleet messaging.
|
||||
```bash
|
||||
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
|
||||
tools/git/ci-queue-wait.sh --purpose push|merge # REQUIRED before any push/merge
|
||||
tools/git/repo-decl.sh # shared .mosaic/repo.json consumption lib (sourced)
|
||||
```
|
||||
|
||||
**Reviewer grants** — `tools/git/grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]` adds a
|
||||
review seat to an org repo through an org team (Gitea only; code read + issues/pulls write, verified
|
||||
by read-back). Team approvals do not count as official under branch protection unless the team is
|
||||
whitelisted — see the tool header.
|
||||
|
||||
**GITEA_LOGIN gotcha** — the wrappers default to login `mosaicstack`; on a USC repo that fails with
|
||||
`gitea / Error: GetUserByName ... not found`. Pick the login from the repo's `origin` host first:
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user