Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2148c20d26 | ||
|
|
5964dab891 | ||
|
|
bdb903cf69 | ||
|
|
bec2eb118b | ||
|
|
07624140e4 | ||
|
|
1c79af25d4 | ||
|
|
e605c83b27 | ||
|
|
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 | ||
|
|
24294d3b77 | ||
|
|
24caeab057 | ||
|
|
888a6ad29b | ||
|
|
24462f460e | ||
|
|
a480ee83dc | ||
|
|
fd43ed5420 | ||
|
|
1d84bc3f3d | ||
|
|
6306914965 | ||
|
|
af43a7a63e | ||
|
|
ca97b885b0 |
+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."
|
||||
}
|
||||
|
||||
+66
-4
@@ -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:
|
||||
@@ -91,6 +93,15 @@ steps:
|
||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||
# joins CI directly rather than the exclusions file.
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||
# Hermetic regression for the git identity ladder (#1356): mock tea on PATH,
|
||||
# sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins
|
||||
# fail-closed: a seat whose login is missing gets a named error, never a
|
||||
# borrowed identity. Joins CI directly; its #1007 exclusion is burned down.
|
||||
- bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
|
||||
# Hermetic regression for issue-view.sh (#1357): mock tea/curl, sandboxed
|
||||
# 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 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 —
|
||||
@@ -104,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
|
||||
@@ -209,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,106 @@
|
||||
import { RequestMethod, type Type } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy route-inventory baseline (contract 1 §6.3(a)).
|
||||
*
|
||||
* M4-1b-i ships the audit event + outbox machinery with NO mutation routes:
|
||||
* the hierarchy command family (controllers + DTOs) lands in M4-1b-ii once
|
||||
* contract 2 merges. This witness enumerates every route the AppModule graph
|
||||
* declares and pins that baseline, so a hierarchy route appearing before its
|
||||
* command-family witnesses exist fails here first. When M4-1b-ii lands, this
|
||||
* baseline is replaced by an exact inventory of the command family.
|
||||
*/
|
||||
|
||||
interface RouteEntry {
|
||||
method: string;
|
||||
path: string;
|
||||
controller: string;
|
||||
}
|
||||
|
||||
/** Module-metadata entry: a module class or a DynamicModule-shaped object. */
|
||||
type ModuleEntry =
|
||||
| Type<unknown>
|
||||
| { module: Type<unknown>; imports?: unknown[]; controllers?: Type<unknown>[] };
|
||||
|
||||
function collectControllers(root: ModuleEntry): Type<unknown>[] {
|
||||
const visited = new Set<unknown>();
|
||||
const controllers: Type<unknown>[] = [];
|
||||
const walk = (entry: ModuleEntry | undefined | null): void => {
|
||||
if (!entry || visited.has(entry)) return;
|
||||
visited.add(entry);
|
||||
const moduleClass = typeof entry === 'function' ? entry : entry.module;
|
||||
// Entries with no resolvable class (forwardRef wrappers, async dynamic
|
||||
// modules) carry no decorator metadata to read here.
|
||||
if (typeof moduleClass !== 'function') return;
|
||||
if (visited.has(moduleClass) && typeof entry !== 'function') return;
|
||||
visited.add(moduleClass);
|
||||
// 'controllers' / 'imports' are the metadata keys the @Module decorator writes.
|
||||
const declared = (Reflect.getMetadata('controllers', moduleClass) ?? []) as Type<unknown>[];
|
||||
controllers.push(...declared);
|
||||
if (typeof entry !== 'function' && entry.controllers) controllers.push(...entry.controllers);
|
||||
const imports = [
|
||||
...((Reflect.getMetadata('imports', moduleClass) ?? []) as ModuleEntry[]),
|
||||
...(typeof entry !== 'function' ? ((entry.imports ?? []) as ModuleEntry[]) : []),
|
||||
];
|
||||
for (const imported of imports) walk(imported);
|
||||
};
|
||||
walk(root);
|
||||
return controllers;
|
||||
}
|
||||
|
||||
function routesOf(controller: Type<unknown>): RouteEntry[] {
|
||||
// 'path' on the class is the @Controller prefix; 'path'/'method' on a
|
||||
// handler are written by the @Get/@Post/... route decorators.
|
||||
const base = (Reflect.getMetadata('path', controller) ?? '') as string | string[];
|
||||
const bases = Array.isArray(base) ? base : [base];
|
||||
const routes: RouteEntry[] = [];
|
||||
const prototype = controller.prototype as Record<string, unknown>;
|
||||
for (const name of Object.getOwnPropertyNames(prototype)) {
|
||||
if (name === 'constructor') continue;
|
||||
const handler = Object.getOwnPropertyDescriptor(prototype, name)?.value;
|
||||
if (typeof handler !== 'function') continue;
|
||||
const method = Reflect.getMetadata('method', handler) as number | undefined;
|
||||
if (method === undefined) continue;
|
||||
const sub = (Reflect.getMetadata('path', handler) ?? '/') as string;
|
||||
for (const prefix of bases) {
|
||||
const path = `/${prefix}/${sub}`.replace(/\/+/g, '/').replace(/(.)\/$/, '$1');
|
||||
routes.push({
|
||||
method: RequestMethod[method] ?? String(method),
|
||||
path,
|
||||
controller: controller.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
describe('hierarchy route-inventory baseline (§6.3(a))', () => {
|
||||
const inventory = collectControllers(AppModule).flatMap(routesOf);
|
||||
|
||||
it('control: the enumeration sees the known route surface', () => {
|
||||
const paths = inventory.map((r) => `${r.method} ${r.path}`);
|
||||
expect(paths).toContain('GET /health');
|
||||
expect(paths).toContain('POST /api/workspaces');
|
||||
expect(paths).toContain('GET /api/teams');
|
||||
expect(inventory.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('declares zero hierarchy mutation routes before M4-1b-ii', () => {
|
||||
const hierarchyRoutes = inventory.filter((r) =>
|
||||
/hierarch|compan|estate|platform[-_]?project/i.test(r.path),
|
||||
);
|
||||
expect(
|
||||
hierarchyRoutes,
|
||||
'a hierarchy route landed without replacing the §6.3(a) baseline with a command-family inventory',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('HierarchyModule itself declares no controllers', () => {
|
||||
expect((Reflect.getMetadata('controllers', HierarchyModule) ?? []) as unknown[]).toEqual([]);
|
||||
const hierarchyControllers = collectControllers(HierarchyModule);
|
||||
expect(hierarchyControllers).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { GCModule } from './gc/gc.module.js';
|
||||
import { HarnessModule } from './harness/harness.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
||||
import { QueueModule } from './queue/queue.module.js';
|
||||
import { FederationModule } from './federation/federation.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
@@ -65,6 +66,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
||||
QueueModule,
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
HierarchyModule,
|
||||
...(federationEnabled ? [FederationModule] : []),
|
||||
],
|
||||
controllers: [HealthController],
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
companies,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyOutbox,
|
||||
platformProjects,
|
||||
runPgliteMigrations,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import {
|
||||
HierarchyAuditIdempotencyConflictError,
|
||||
HierarchyAuditRepository,
|
||||
HierarchyNodeNotFoundError,
|
||||
type AppendHierarchyEventInput,
|
||||
} from './hierarchy-audit.repository.js';
|
||||
|
||||
/**
|
||||
* Repository-level §6.4 witnesses for the hierarchy audit machinery
|
||||
* (contract 1 §5.2, REQ-AUD-001): same-transaction atomicity of state +
|
||||
* event + outbox, rollback leaving no residue, idempotent replay, snapshot
|
||||
* parent chains, events surviving target deletion, per-target ordering, and
|
||||
* the outbox claim/complete/release CAS. The schema-level constraints are
|
||||
* witnessed in packages/db/src/hierarchy-audit.witness.test.ts.
|
||||
*/
|
||||
describe('hierarchy audit repository integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let repo: HierarchyAuditRepository;
|
||||
|
||||
const input = (
|
||||
overrides: Partial<AppendHierarchyEventInput> = {},
|
||||
): AppendHierarchyEventInput => ({
|
||||
actorId: 'user-actor',
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: randomUUID(),
|
||||
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
|
||||
correlationId: 'corr-1',
|
||||
idempotencyKey: `key-${randomUUID()}`,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-audit-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [HierarchyAuditRepository, { provide: DB, useValue: handle.db }],
|
||||
}).compile();
|
||||
repo = moduleRef.get(HierarchyAuditRepository);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await moduleRef.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('commits state, event, and outbox record atomically in one transaction', async () => {
|
||||
const companyId = randomUUID();
|
||||
const key = `key-${randomUUID()}`;
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Atomic Co', slug: 'atomic-co' });
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
const result = await repo.append(tx, {
|
||||
...input({ targetId: companyId, idempotencyKey: key }),
|
||||
targetSnapshot: { ...snapshot },
|
||||
});
|
||||
expect(result.replayed).toBe(false);
|
||||
expect(result.event.idempotencyKey).toBe(key);
|
||||
});
|
||||
const events = await handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
|
||||
expect(events).toHaveLength(1);
|
||||
const outbox = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.eventId, events[0]!.id));
|
||||
expect(outbox).toHaveLength(1);
|
||||
expect(outbox[0]).toMatchObject({
|
||||
status: 'pending',
|
||||
idempotencyKey: key,
|
||||
correlationId: 'corr-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('a rolled-back transaction leaves no state, no event, and no outbox record', async () => {
|
||||
const companyId = randomUUID();
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Doomed Co', slug: 'doomed-co' });
|
||||
await repo.append(tx, input({ targetId: companyId, idempotencyKey: key }));
|
||||
throw new Error('deliberate rollback');
|
||||
}),
|
||||
).rejects.toThrow('deliberate rollback');
|
||||
const [companyRows, eventRows, outboxRows] = await Promise.all([
|
||||
handle.db.select().from(companies).where(eq(companies.id, companyId)),
|
||||
handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key)),
|
||||
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key)),
|
||||
]);
|
||||
expect(companyRows).toHaveLength(0);
|
||||
expect(eventRows).toHaveLength(0);
|
||||
expect(outboxRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('replays a duplicate idempotency key without inserting a second event or outbox record', async () => {
|
||||
const first = input();
|
||||
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(original.replayed).toBe(false);
|
||||
expect(replay.replayed).toBe(true);
|
||||
expect(replay.event.id).toBe(original.event.id);
|
||||
const outbox = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.eventId, original.event.id));
|
||||
expect(outbox).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws on a duplicate idempotency key carrying different event content', async () => {
|
||||
const first = input();
|
||||
await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) =>
|
||||
repo.append(tx, { ...first, verb: 'rename', targetId: randomUUID() }),
|
||||
),
|
||||
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
|
||||
});
|
||||
|
||||
it('throws on a duplicate idempotency key whose transfer destination differs', async () => {
|
||||
const from = { kind: 'company' as const, id: randomUUID(), slug: 'src-co' };
|
||||
const to = { kind: 'company' as const, id: randomUUID(), slug: 'dst-co' };
|
||||
const first = input({
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
transferFrom: from,
|
||||
transferTo: to,
|
||||
});
|
||||
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(original.replayed).toBe(false);
|
||||
// Identical retry replays; a retry re-routed to a different destination must conflict.
|
||||
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(replay.replayed).toBe(true);
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) =>
|
||||
repo.append(tx, { ...first, transferTo: { ...to, id: randomUUID() } }),
|
||||
),
|
||||
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
|
||||
});
|
||||
|
||||
it('builds root-first parent chains and rejects unknown nodes', async () => {
|
||||
const companyId = randomUUID();
|
||||
const estateId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Chain Co', slug: 'chain-co' });
|
||||
await tx
|
||||
.insert(estates)
|
||||
.values({ id: estateId, name: 'Chain Estate', slug: 'chain-estate', companyId });
|
||||
await tx
|
||||
.insert(platformProjects)
|
||||
.values({ id: projectId, name: 'Chain Project', slug: 'chain-project', estateId });
|
||||
});
|
||||
const snapshot = await repo.snapshot(handle.db, 'platform_project', projectId);
|
||||
expect(snapshot).toMatchObject({ id: projectId, slug: 'chain-project', name: 'Chain Project' });
|
||||
expect(snapshot.parentChain).toEqual([
|
||||
{ kind: 'company', id: companyId, slug: 'chain-co' },
|
||||
{ kind: 'estate', id: estateId, slug: 'chain-estate' },
|
||||
]);
|
||||
await expect(repo.snapshot(handle.db, 'estate', randomUUID())).rejects.toThrow(
|
||||
HierarchyNodeNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps events readable, in per-target seq order, after the target row is deleted', async () => {
|
||||
const companyId = randomUUID();
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Mortal Co', slug: 'mortal-co' });
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
await repo.append(tx, input({ targetId: companyId, targetSnapshot: { ...snapshot } }));
|
||||
});
|
||||
await handle.db.transaction(async (tx) => {
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
await repo.append(tx, {
|
||||
...input({ verb: 'delete', targetId: companyId }),
|
||||
targetSnapshot: { ...snapshot },
|
||||
});
|
||||
await tx.delete(companies).where(eq(companies.id, companyId));
|
||||
});
|
||||
const events = await repo.eventsForTarget(companyId);
|
||||
expect(events.map((e) => e.verb)).toEqual(['create', 'delete']);
|
||||
expect(events[1]!.seq).toBeGreaterThan(events[0]!.seq);
|
||||
expect((events[1]!.targetSnapshot as { id: string }).id).toBe(companyId);
|
||||
});
|
||||
|
||||
it('claims the oldest pending outbox record exactly once, completes and releases by CAS', async () => {
|
||||
// Drain records left pending by earlier cases so ordering is deterministic.
|
||||
for (;;) {
|
||||
const drained = await repo.claimPendingOutbox();
|
||||
if (!drained) break;
|
||||
await repo.completeOutbox(drained.id);
|
||||
}
|
||||
const older = await handle.db.transaction(async (tx) => repo.append(tx, input()));
|
||||
const newer = await handle.db.transaction(async (tx) => repo.append(tx, input()));
|
||||
|
||||
const claimed = await repo.claimPendingOutbox();
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.eventId).toBe(older.event.id);
|
||||
expect(claimed!.status).toBe('processing');
|
||||
|
||||
// Delivery fails: release returns it to pending and it is claimable again.
|
||||
await repo.releaseOutbox(claimed!.id);
|
||||
const reclaimed = await repo.claimPendingOutbox();
|
||||
expect(reclaimed!.id).toBe(claimed!.id);
|
||||
|
||||
await repo.completeOutbox(reclaimed!.id);
|
||||
const done = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.id, reclaimed!.id));
|
||||
expect(done[0]!.status).toBe('delivered');
|
||||
expect(done[0]!.deliveredAt).not.toBeNull();
|
||||
// completeOutbox is CAS-guarded on 'processing': completing again is a no-op.
|
||||
await repo.completeOutbox(reclaimed!.id);
|
||||
|
||||
const second = await repo.claimPendingOutbox();
|
||||
expect(second!.eventId).toBe(newer.event.id);
|
||||
await repo.completeOutbox(second!.id);
|
||||
expect(await repo.claimPendingOutbox()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyOutbox,
|
||||
platformProjects,
|
||||
type Db,
|
||||
type HIERARCHY_AUDIT_TARGET_KINDS,
|
||||
type HIERARCHY_AUDIT_VERBS,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy audit event + outbox machinery (contract 1 §5.2).
|
||||
*
|
||||
* Every hierarchy mutation writes its semantic audit event AND the event's
|
||||
* outbox record on the caller's transaction, so state, event, and outbox
|
||||
* commit or roll back together. Events reference their target by an
|
||||
* immutable snapshot (id, slug, parent chain at event time), never by a
|
||||
* foreign key into the class tables — append-only events survive the
|
||||
* deletion of their target. This module exposes no update or delete path
|
||||
* for events: append-only is a property of the code surface, witnessed by
|
||||
* the integration tests.
|
||||
*
|
||||
* This is NOT a class-table writer: it touches only the audit/outbox
|
||||
* tables, so it does not appear on the writer-coverage allowlist. The
|
||||
* hierarchy command repositories (M4-1b-ii) are the allowlisted writers and
|
||||
* call into this on their own transactions.
|
||||
*/
|
||||
|
||||
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
|
||||
export type HierarchyTargetKind = (typeof HIERARCHY_AUDIT_TARGET_KINDS)[number];
|
||||
export type HierarchyNodeKind = Exclude<HierarchyTargetKind, 'grant'>;
|
||||
|
||||
export interface ParentChainEntry {
|
||||
readonly kind: HierarchyNodeKind;
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
/** Immutable node snapshot at event time; parentChain is root-first. */
|
||||
export interface HierarchyNodeSnapshot {
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
readonly parentChain: readonly ParentChainEntry[];
|
||||
}
|
||||
|
||||
export interface AppendHierarchyEventInput {
|
||||
readonly actorId: string;
|
||||
readonly verb: HierarchyAuditVerb;
|
||||
readonly targetKind: HierarchyTargetKind;
|
||||
readonly targetId: string;
|
||||
/** Node events: HierarchyNodeSnapshot. Grant events: subject/target/role snapshot (contract 2 §4.4). */
|
||||
readonly targetSnapshot: Record<string, unknown>;
|
||||
/** Present exactly on transfers (CHECK-enforced): source/destination parent { kind, id, slug }. */
|
||||
readonly transferFrom?: ParentChainEntry;
|
||||
readonly transferTo?: ParentChainEntry;
|
||||
readonly correlationId: string;
|
||||
/** Prior event in the causal chain (e.g. the delete event causing cascaded grant_revoke events). */
|
||||
readonly causationId?: string;
|
||||
readonly idempotencyKey: string;
|
||||
}
|
||||
|
||||
export type HierarchyAuditEventRow = typeof hierarchyAuditEvents.$inferSelect;
|
||||
export type HierarchyOutboxRow = typeof hierarchyOutbox.$inferSelect;
|
||||
|
||||
export interface AppendHierarchyEventResult {
|
||||
readonly event: HierarchyAuditEventRow;
|
||||
/** True when the idempotency key had already committed an identical event (REQ-AUD-001 duplicate suppression). */
|
||||
readonly replayed: boolean;
|
||||
}
|
||||
|
||||
type Tx = Pick<Db, 'insert' | 'select'>;
|
||||
|
||||
export class HierarchyAuditIdempotencyConflictError extends Error {
|
||||
constructor(idempotencyKey: string) {
|
||||
super(
|
||||
`hierarchy audit idempotency key ${idempotencyKey} already exists with different event content`,
|
||||
);
|
||||
this.name = 'HierarchyAuditIdempotencyConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class HierarchyNodeNotFoundError extends Error {
|
||||
constructor(kind: HierarchyNodeKind, id: string) {
|
||||
super(`hierarchy node not found: ${kind} ${id}`);
|
||||
this.name = 'HierarchyNodeNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one audit event and its outbox record on the caller's transaction.
|
||||
* A duplicate idempotency key with identical semantic content returns the
|
||||
* prior event (replayed: true) without inserting anything; a duplicate key
|
||||
* with different content throws.
|
||||
*/
|
||||
export async function appendHierarchyEvent(
|
||||
tx: Tx,
|
||||
input: AppendHierarchyEventInput,
|
||||
): Promise<AppendHierarchyEventResult> {
|
||||
const inserted = await tx
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values({
|
||||
actorId: input.actorId,
|
||||
verb: input.verb,
|
||||
targetKind: input.targetKind,
|
||||
targetId: input.targetId,
|
||||
targetSnapshot: input.targetSnapshot,
|
||||
transferFrom: input.transferFrom ?? null,
|
||||
transferTo: input.transferTo ?? null,
|
||||
correlationId: input.correlationId,
|
||||
causationId: input.causationId ?? null,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const event = inserted[0];
|
||||
if (event) {
|
||||
await tx.insert(hierarchyOutbox).values({
|
||||
eventId: event.id,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
correlationId: input.correlationId,
|
||||
});
|
||||
return { event, replayed: false };
|
||||
}
|
||||
|
||||
const prior = await tx
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, input.idempotencyKey))
|
||||
.limit(1);
|
||||
const existing = prior[0];
|
||||
if (!existing || !sameEvent(existing, input)) {
|
||||
throw new HierarchyAuditIdempotencyConflictError(input.idempotencyKey);
|
||||
}
|
||||
// Event and outbox committed atomically the first time, so the outbox
|
||||
// record already exists; a replay inserts nothing.
|
||||
return { event: existing, replayed: true };
|
||||
}
|
||||
|
||||
/** Key-order-independent serialization: jsonb does not preserve key order. */
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
const body = Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(',');
|
||||
return `{${body}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function sameEvent(row: HierarchyAuditEventRow, input: AppendHierarchyEventInput): boolean {
|
||||
return (
|
||||
row.actorId === input.actorId &&
|
||||
row.verb === input.verb &&
|
||||
row.targetKind === input.targetKind &&
|
||||
row.targetId === input.targetId &&
|
||||
row.correlationId === input.correlationId &&
|
||||
(row.causationId ?? null) === (input.causationId ?? null) &&
|
||||
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot) &&
|
||||
// Transfer source/destination are semantic content (§5.2): a retry with a
|
||||
// different destination must conflict, never silently replay.
|
||||
canonicalJson(row.transferFrom ?? null) === canonicalJson(input.transferFrom ?? null) &&
|
||||
canonicalJson(row.transferTo ?? null) === canonicalJson(input.transferTo ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable snapshot for a node: its row plus the parent chain up
|
||||
* to the company root, root-first, read on the caller's transaction so the
|
||||
* snapshot is consistent with the mutation it audits.
|
||||
*/
|
||||
export async function buildNodeSnapshot(
|
||||
tx: Tx,
|
||||
kind: HierarchyNodeKind,
|
||||
id: string,
|
||||
): Promise<HierarchyNodeSnapshot> {
|
||||
if (kind === 'company') {
|
||||
const rows = await tx.select().from(companies).where(eq(companies.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
return { id: row.id, slug: row.slug, name: row.name, parentChain: [] };
|
||||
}
|
||||
if (kind === 'estate') {
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
const parent = await buildNodeSnapshot(tx, 'company', row.companyId);
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
parentChain: [...parent.parentChain, { kind: 'company', id: parent.id, slug: parent.slug }],
|
||||
};
|
||||
}
|
||||
const rows = await tx.select().from(platformProjects).where(eq(platformProjects.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
const parent = await buildNodeSnapshot(tx, 'estate', row.estateId);
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
parentChain: [...parent.parentChain, { kind: 'estate', id: parent.id, slug: parent.slug }],
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyAuditRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/** Compose an event+outbox append into a caller-owned transaction. */
|
||||
append(tx: Tx, input: AppendHierarchyEventInput): Promise<AppendHierarchyEventResult> {
|
||||
return appendHierarchyEvent(tx, input);
|
||||
}
|
||||
|
||||
snapshot(tx: Tx, kind: HierarchyNodeKind, id: string): Promise<HierarchyNodeSnapshot> {
|
||||
return buildNodeSnapshot(tx, kind, id);
|
||||
}
|
||||
|
||||
/** Per-target ordered event history (REQ-AUD-001 per-target ordering; read-only). */
|
||||
async eventsForTarget(targetId: string): Promise<HierarchyAuditEventRow[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.targetId, targetId))
|
||||
.orderBy(asc(hierarchyAuditEvents.seq));
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the oldest pending outbox record (claim-by-CAS: the UPDATE is
|
||||
* guarded on status so a lost race returns null and the caller retries).
|
||||
*/
|
||||
async claimPendingOutbox(): Promise<HierarchyOutboxRow | null> {
|
||||
const candidates = await this.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.status, 'pending'))
|
||||
.orderBy(asc(hierarchyOutbox.createdAt))
|
||||
.limit(1);
|
||||
const candidate = candidates[0];
|
||||
if (!candidate) return null;
|
||||
const claimed = await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'processing', updatedAt: new Date() })
|
||||
.where(and(eq(hierarchyOutbox.id, candidate.id), eq(hierarchyOutbox.status, 'pending')))
|
||||
.returning();
|
||||
return claimed[0] ?? null;
|
||||
}
|
||||
|
||||
async completeOutbox(id: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'delivered', deliveredAt: now, updatedAt: now })
|
||||
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
|
||||
}
|
||||
|
||||
/** Return a claimed record to pending (delivery failed; it stays replayable). */
|
||||
async releaseOutbox(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
|
||||
|
||||
/**
|
||||
* Hierarchy (tenancy/authorization structure) feature module.
|
||||
*
|
||||
* M4-1b-i ships the audit event + outbox machinery only (contract 1 §5.2).
|
||||
* The hierarchy command family — controllers, DTOs, and the allowlisted
|
||||
* class-table repositories — lands in M4-1b-ii once contract 2 (RBAC grant
|
||||
* model) merges; until then this module exposes no routes, which the
|
||||
* route-inventory witness asserts.
|
||||
*/
|
||||
@Module({
|
||||
providers: [HierarchyAuditRepository],
|
||||
exports: [HierarchyAuditRepository],
|
||||
})
|
||||
export class HierarchyModule {}
|
||||
@@ -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() {
|
||||
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';
|
||||
|
||||
+22
-4
@@ -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 (
|
||||
@@ -42,6 +52,9 @@ export const routes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{
|
||||
element: <DashboardLayout />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||
@@ -56,8 +69,13 @@ export const routes: RouteObject[] = [
|
||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||
},
|
||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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"]
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Administrator Guide
|
||||
|
||||
> **Status:** Partially migrated. Current SSO and local upgrade/recovery procedures are available; held procedures are labeled non-operative.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Administrator Operations
|
||||
|
||||
> **Status:** Partially migrated. Procedures explicitly identify whether they are current or held.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mos Connector Lease Operations — M1
|
||||
|
||||
> **Status:** Held / non-operative.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Upgrade safety and recovery
|
||||
|
||||
> **Supported route:** an already installed `mosaic` CLI using the local PGlite
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Security
|
||||
|
||||
> **Status:** Partially migrated. The SSO provider and Discord ingress security pages are current.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Discord ingress security
|
||||
|
||||
> **Status:** Current Discord behavior only. Telegram shared-contract parity, Matrix channel ingress, and a gateway-wide shared adapter registry are not implemented or are not proven by the current source/tests.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
title: SSO Providers
|
||||
type: runbook
|
||||
audience: admin
|
||||
status: current
|
||||
source_of_truth: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# API Documentation
|
||||
|
||||
> **Status:** Scaffold only. The canonical gateway contract has not yet been migrated into this directory.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Developer Guide
|
||||
|
||||
> **Status:** Partially migrated. Architecture, lease-broker verification, and channel-adapter authoring pages are current; other contributor chapters remain unmigrated.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
> **Status:** Partially migrated. The lease-broker security-contract pages below are current references; the remaining architecture pages are still being classified.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Channel protocol architecture
|
||||
|
||||
> **Status:** Current shared type contract and Discord compatibility baseline. The shared gateway registry, Telegram parity, Matrix integration, identity-linking, and multi-surface multiplexing described below are draft or unimplemented.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Compaction observer revocation and runtime generations
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Architecture Decisions
|
||||
|
||||
> **Status:** Current decision index. A decision describes an implemented and accepted boundary; draft proposals belong under `rfcs/` or `docs/plans/`.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
||||
|
||||
> **Decision status:** Current implemented decision (M1).
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Authenticated external lease broker protocol
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user