Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a6969688 | ||
|
|
41e8046371 | ||
|
|
6e16675ea2 | ||
|
|
19ebc422aa | ||
|
|
bd749831b1 | ||
|
|
f8e1b43b5b | ||
|
|
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 |
+6
-3
@@ -40,9 +40,12 @@ BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
|||||||
BETTER_AUTH_URL=http://localhost:14242
|
BETTER_AUTH_URL=http://localhost:14242
|
||||||
|
|
||||||
|
|
||||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
|
||||||
# Public gateway URL — accessible from the browser, not just the server.
|
# Directory holding the built SPA bundle (vite build output). When set, the
|
||||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
# 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 ───────────────────────────────────────────────────────────
|
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -23,3 +23,7 @@ infra/step-ca/dev-password
|
|||||||
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
||||||
# file vanished mid-scan. Ignoring them removes the race.
|
# file vanished mid-scan. Ignoring them removes the race.
|
||||||
*.timestamp-*.mjs
|
*.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",
|
"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
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|
||||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
|
||||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
|
||||||
# This avoids from_secret which is blocked on pull_request events.
|
# deployment — never pull_request). This PR pipeline deliberately gets no
|
||||||
# If the env vars aren't set, turbo falls back to local cache only.
|
# 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:
|
steps:
|
||||||
install:
|
install:
|
||||||
@@ -91,6 +93,15 @@ steps:
|
|||||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||||
# joins CI directly rather than the exclusions file.
|
# joins CI directly rather than the exclusions file.
|
||||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
- 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
|
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||||
# endpoints and ordinary commands through. Both directions are asserted —
|
# 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.
|
# stub supplies the scale instead of the host's own checkout.
|
||||||
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
- 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`.
|
# Canonical verify:release stage `upgrade-guard`.
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||||
@@ -209,6 +254,23 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- 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:
|
services:
|
||||||
ci-postgres:
|
ci-postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
|
|||||||
+291
-46
@@ -32,6 +32,11 @@ variables:
|
|||||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
# 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
|
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||||
# the separate `event: tag` entry.)
|
# 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
|
- &image_build_when
|
||||||
- event: tag
|
- event: tag
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
@@ -44,16 +49,6 @@ variables:
|
|||||||
- '.woodpecker/**'
|
- '.woodpecker/**'
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
branch: next
|
branch: next
|
||||||
- &main_image_build_when
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
exclude:
|
|
||||||
- 'packages/mosaic/**'
|
|
||||||
- 'docs/**'
|
|
||||||
- '**/*.md'
|
|
||||||
- '.woodpecker/**'
|
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main, next]
|
- branch: [main, next]
|
||||||
@@ -73,6 +68,13 @@ steps:
|
|||||||
# being empty) and on any incomplete verification.
|
# being empty) and on any incomplete verification.
|
||||||
verify:
|
verify:
|
||||||
image: *node_image
|
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:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
||||||
@@ -108,6 +110,13 @@ steps:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
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:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm build
|
- pnpm build
|
||||||
@@ -202,6 +211,20 @@ steps:
|
|||||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
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="$(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);'
|
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'
|
node <<'NODE'
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
@@ -209,23 +232,38 @@ steps:
|
|||||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||||
const roots = ['apps', 'packages', 'plugins'];
|
const roots = ['apps', 'packages', 'plugins'];
|
||||||
const updated = [];
|
const updated = [];
|
||||||
|
const exactVersions = new Map(); // name -> bumped next version
|
||||||
|
|
||||||
function walk(dir) {
|
function walk(dir, visit) {
|
||||||
if (!fs.existsSync(dir)) return;
|
if (!fs.existsSync(dir)) return;
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||||
const fullPath = path.join(dir, entry.name);
|
const fullPath = path.join(dir, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const packagePath = path.join(fullPath, 'package.json');
|
const packagePath = path.join(fullPath, 'package.json');
|
||||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
if (fs.existsSync(packagePath)) {
|
||||||
walk(fullPath);
|
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||||
|
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
|
||||||
|
visit(manifest, packagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(fullPath, visit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePackage(packagePath) {
|
// #1389: two passes. Pass 1 bumps every publishable manifest to
|
||||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
// <stable+1>-next.<pipeline> exactly as before, recording name ->
|
||||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
// 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);
|
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||||
if (!stableMatch) {
|
if (!stableMatch) {
|
||||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||||
@@ -234,13 +272,40 @@ steps:
|
|||||||
const oldVersion = manifest.version;
|
const oldVersion = manifest.version;
|
||||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
exactVersions.set(manifest.name, manifest.version);
|
||||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + 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');
|
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||||
for (const line of updated) console.log('[publish-next] ' + line);
|
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
|
NODE
|
||||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
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")"
|
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||||
@@ -250,10 +315,188 @@ steps:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
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:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
- 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
|
# TODO: Uncomment when ready to publish to npmjs.org
|
||||||
# publish-npmjs:
|
# publish-npmjs:
|
||||||
# image: *node_image
|
# image: *node_image
|
||||||
@@ -305,10 +548,20 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
- 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:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: REGISTRY_USERNAME
|
||||||
@@ -322,8 +575,17 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- 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}"
|
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"
|
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
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||||
@@ -332,30 +594,13 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
- verify
|
||||||
|
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||||
build-web:
|
# its transform window and restores them at step end. Any step that
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||||
when: *main_image_build_when
|
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||||
environment:
|
# pipeline 2648 raced a COPY inside the window and failed
|
||||||
REGISTRY_USER:
|
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||||
from_secret: REGISTRY_USERNAME
|
# serialization invariant; add it to every new workspace consumer.
|
||||||
REGISTRY_PASS:
|
- publish-next-npm
|
||||||
from_secret: REGISTRY_PASSWORD
|
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
- e2e
|
||||||
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
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.80.0",
|
"@anthropic-ai/sdk": "^0.80.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
|
"@fastify/static": "^8.3.0",
|
||||||
"@mariozechner/pi-ai": "^0.65.0",
|
"@mariozechner/pi-ai": "^0.65.0",
|
||||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@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 { HarnessModule } from './harness/harness.module.js';
|
||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
|
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
import { FederationModule } from './federation/federation.module.js';
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
@@ -65,6 +66,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
HierarchyModule,
|
||||||
...(federationEnabled ? [FederationModule] : []),
|
...(federationEnabled ? [FederationModule] : []),
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
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 {
|
import {
|
||||||
createDb,
|
createDb,
|
||||||
createPgliteDb,
|
createPgliteDb,
|
||||||
|
getMigrationStatus,
|
||||||
runPgliteMigrations,
|
runPgliteMigrations,
|
||||||
type Db,
|
type Db,
|
||||||
type DbHandle,
|
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 same DATABASE_URL, so a single call covers both the gateway DB and
|
||||||
// the storage tables. We deliberately do NOT call runMigrations() here to
|
// the storage tables. We deliberately do NOT call runMigrations() here to
|
||||||
// avoid opening a second short-lived connection and doubling startup cost.
|
// 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> {
|
async onModuleInit(): Promise<void> {
|
||||||
if (this.config.tier === 'local') {
|
if (this.config.tier === 'local') {
|
||||||
this.logger.log('Applying PGlite schema migrations...');
|
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})...`);
|
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
|
||||||
await this.storageAdapter.migrate();
|
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> {
|
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 { mountAuthHandler } from './auth/auth.controller.js';
|
||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
|
import { mountSpaStatic } from './spa/serve-spa.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
import { resolveGatewayConfigPath } from './env.js';
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
|
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
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']) {
|
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||||
throw new Error('BETTER_AUTH_SECRET is required');
|
throw new Error('BETTER_AUTH_SECRET is required');
|
||||||
}
|
}
|
||||||
@@ -62,6 +69,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
mountAuthHandler(app);
|
mountAuthHandler(app);
|
||||||
mountMcpHandler(app, app.get(McpService));
|
mountMcpHandler(app, app.get(McpService));
|
||||||
|
await mountSpaStatic(app);
|
||||||
|
|
||||||
const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
|
const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
|
||||||
await app.listen(port, '0.0.0.0');
|
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 { AuthGuard } from '../auth/auth.guard.js';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { TeamsService } from './teams.service.js';
|
import { TeamsService } from './teams.service.js';
|
||||||
|
|
||||||
|
type RequestUser = { id: string; role?: string };
|
||||||
|
|
||||||
@Controller('api/teams')
|
@Controller('api/teams')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class TeamsController {
|
export class TeamsController {
|
||||||
constructor(private readonly teams: TeamsService) {}
|
constructor(private readonly teams: TeamsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list() {
|
async list(@CurrentUser() user: RequestUser) {
|
||||||
return this.teams.findAll();
|
if (user.role === 'admin') {
|
||||||
|
return this.teams.findAll();
|
||||||
|
}
|
||||||
|
return this.teams.findAllForUser(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId')
|
@Get(':teamId')
|
||||||
async findOne(@Param('teamId') teamId: string) {
|
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||||
return this.teams.findById(teamId);
|
return this.getAccessibleTeam(teamId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members')
|
@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);
|
return this.teams.listMembers(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members/:userId')
|
@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);
|
const isMember = await this.teams.isMember(teamId, userId);
|
||||||
return { isMember };
|
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 { 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';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -56,6 +56,21 @@ export class TeamsService {
|
|||||||
return this.db.select().from(teams);
|
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.
|
* Find a team by ID.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+20
-28
@@ -1,11 +1,14 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
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.describe('Admin page — admin user', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAs(page, ADMIN_USER.email, ADMIN_USER.password);
|
await loginAs(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
const url = page.url();
|
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 }) => {
|
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.goto('/admin');
|
||||||
await page.getByRole('button', { name: /system health/i }).click();
|
await page.getByRole('button', { name: /system health/i }).click();
|
||||||
// Health cards or loading indicator should appear
|
// Health cards or loading indicator should appear
|
||||||
const hasLoading = await page
|
const loadingOrCard = page
|
||||||
.getByText(/loading health/i)
|
.getByText(/loading health/i)
|
||||||
.isVisible()
|
.or(page.getByText(/database/i))
|
||||||
.catch(() => false);
|
.first();
|
||||||
const hasCard = await page
|
await expect(loadingOrCard).toBeVisible({ timeout: 10_000 });
|
||||||
.getByText(/database/i)
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
expect(hasLoading || hasCard).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,26 +46,19 @@ test.describe('Admin page — non-admin user', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
const url = page.url();
|
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');
|
await page.goto('/admin');
|
||||||
// Either redirected away or shown an access-denied message
|
// Wait for the app shell to render (redirect and access-denied views both
|
||||||
const onAdmin = page.url().includes('/admin');
|
// keep the sidebar), then assert the panel itself is absent. globalSetup
|
||||||
if (onAdmin) {
|
// seeds TEST_USER with role 'member', so this is a real authorization
|
||||||
// Should show some access-denied content rather than the full admin panel
|
// assertion, not environment-dependent.
|
||||||
const hasPanel = await page
|
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible({ timeout: 10_000 });
|
||||||
.getByRole('heading', { name: /admin panel/i })
|
await expect(page.getByRole('heading', { name: /admin panel/i })).not.toBeVisible();
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
// If heading is visible, the guard allowed access (user may have admin role in this env)
|
|
||||||
// — not a failure, just informational
|
|
||||||
if (!hasPanel) {
|
|
||||||
// access denied message, redirect, or guard placeholder
|
|
||||||
const url = page.url();
|
|
||||||
expect(url).toBeTruthy(); // environment-dependent — no hard assertion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { TEST_USER } from './helpers/auth.js';
|
import { REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
|
||||||
|
|
||||||
// ── Login page ────────────────────────────────────────────────────────────────
|
// ── Login page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,18 +49,14 @@ test.describe('Login page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('redirects to /chat after successful login', async ({ 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.goto('/login');
|
||||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||||
await page.getByLabel('Password').fill(TEST_USER.password);
|
await page.getByLabel('Password').fill(TEST_USER.password);
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
// Either reaches /chat or shows an error (if credentials are wrong in this env).
|
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
|
||||||
// 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
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+19
-26
@@ -1,45 +1,38 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
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.describe('Chat page', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
// If login failed (no seeded user in env) we may be on /login — skip
|
// If login failed (no seeded user in env) we may be on /login — skip
|
||||||
const url = page.url();
|
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');
|
await page.goto('/chat');
|
||||||
// Either there are conversations listed or the welcome empty-state is shown
|
await expect(page.getByRole('heading', { level: 1, name: /chat/i })).toBeVisible({
|
||||||
const hasWelcome = await page
|
timeout: 10_000,
|
||||||
.getByRole('heading', { name: /welcome to mosaic chat/i })
|
});
|
||||||
.isVisible()
|
await expect(page.getByRole('log', { name: /conversation/i })).toBeVisible();
|
||||||
.catch(() => false);
|
|
||||||
const hasConversationPanel = await page
|
|
||||||
.locator('[data-testid="conversation-list"], nav, aside')
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
expect(hasWelcome || hasConversationPanel).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('new conversation button is visible', async ({ page }) => {
|
test('message composer input is visible', async ({ page }) => {
|
||||||
await page.goto('/chat');
|
await page.goto('/chat');
|
||||||
// "Start new conversation" button or a "+" button in the sidebar
|
await expect(page.getByLabel('Message')).toBeVisible({ timeout: 10_000 });
|
||||||
const newConvButton = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
|
||||||
await expect(newConvButton).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');
|
await page.goto('/chat');
|
||||||
// Find any button that creates a new conversation
|
// Conversations are command-driven: /new starts one via the commands panel.
|
||||||
const newBtn = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
const commandList = page.getByRole('list', { name: /available commands/i });
|
||||||
await newBtn.click();
|
await expect(commandList).toBeVisible({ timeout: 10_000 });
|
||||||
// After creating, a text input for sending messages should appear
|
await expect(commandList.getByText('/new', { exact: true })).toBeVisible();
|
||||||
const chatInput = page.getByRole('textbox').or(page.locator('textarea')).first();
|
await expect(page.getByLabel('Command name')).toBeVisible();
|
||||||
await expect(chatInput).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByRole('button', { name: /run command/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sidebar navigation is present on chat page', async ({ page }) => {
|
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> {
|
export async function loginAs(page: Page, email: string, password: string): Promise<void> {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('Email').fill(email);
|
await page.getByLabel('Email').fill(email);
|
||||||
await page.getByLabel('Password').fill(password);
|
await page.getByLabel('Password').fill(password);
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
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 { 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.describe('Sidebar navigation', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
const url = page.url();
|
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 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 }) => {
|
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 }) => {
|
test('active link is visually highlighted', async ({ page }) => {
|
||||||
await page.goto('/chat');
|
await page.goto('/chat');
|
||||||
// The active link should have a distinct class — check that the Chat link
|
// The sidebar marks the active item with `font-medium` (plus an inline
|
||||||
// has the active style class (bg-blue-600/20 text-blue-400)
|
// primary-color style); inactive items get the hover class instead.
|
||||||
const chatLink = page.getByRole('link', { name: /^chat$/i }).first();
|
const chatLink = page.getByRole('link', { name: /^chat$/i }).first();
|
||||||
const cls = await chatLink.getAttribute('class');
|
const projectsLink = page.getByRole('link', { name: /^projects$/i }).first();
|
||||||
expect(cls).toContain('blue');
|
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 }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
const url = page.url();
|
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 }) => {
|
test('navigating chat → projects → settings → chat works without errors', async ({ page }) => {
|
||||||
await page.goto('/chat');
|
await page.goto('/chat');
|
||||||
await expect(page).toHaveURL(/\/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 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 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 page.goto('/chat');
|
||||||
await expect(page).toHaveURL(/\/chat/);
|
await expect(page).toHaveURL(/\/chat/);
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
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.describe('Projects page', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
const url = page.url();
|
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 }) => {
|
test('projects page loads with heading', async ({ page }) => {
|
||||||
await page.goto('/projects');
|
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 }) => {
|
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
|
// Wait for loading state to clear
|
||||||
await expect(page.getByText(/loading projects/i)).not.toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText(/loading projects/i)).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
const hasProjects = await page
|
const cardsOrEmpty = page
|
||||||
.locator('[class*="grid"]')
|
.locator('[class*="grid"]')
|
||||||
.isVisible()
|
.or(page.getByText(/no projects yet/i))
|
||||||
.catch(() => false);
|
.first();
|
||||||
const hasEmpty = await page
|
await expect(cardsOrEmpty).toBeVisible({ timeout: 10_000 });
|
||||||
.getByText(/no projects yet/i)
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
expect(hasProjects || hasEmpty).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows Active Mission section', async ({ page }) => {
|
|
||||||
await page.goto('/projects');
|
|
||||||
await expect(page.getByRole('heading', { name: /active mission/i })).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sidebar navigation is present', async ({ page }) => {
|
test('sidebar navigation is present', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
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.describe('Settings page', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
||||||
const url = page.url();
|
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 }) => {
|
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",
|
"version": "0.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build-web.mjs",
|
"build": "vite build",
|
||||||
"build:vite": "vite build",
|
"dev": "vite",
|
||||||
"dev": "next dev -p 3101",
|
"preview": "vite preview",
|
||||||
"dev:vite": "vite",
|
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test"
|
||||||
"start": "next start -p 3101"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/design-tokens": "workspace:^",
|
"@mosaicstack/design-tokens": "workspace:^",
|
||||||
"@mosaicstack/types": "workspace:^",
|
"@mosaicstack/types": "workspace:^",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"next": "^16.0.0",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playwright E2E configuration for Mosaic web app.
|
* Playwright E2E configuration for the Mosaic web SPA.
|
||||||
*
|
*
|
||||||
* Assumes:
|
* Assumes the NestJS gateway is already running on http://localhost:14242 and
|
||||||
* - Next.js web app running on http://localhost:3000
|
* serving the built SPA bundle (WEB_DIST_DIR pointing at apps/web/dist) — the
|
||||||
* - NestJS gateway running on http://localhost:14242
|
* 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
|
* Run with: pnpm --filter @mosaicstack/web test:e2e
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
|
globalSetup: './e2e/global-setup.ts',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env['CI'],
|
forbidOnly: !!process.env['CI'],
|
||||||
retries: process.env['CI'] ? 2 : 0,
|
retries: process.env['CI'] ? 2 : 0,
|
||||||
workers: process.env['CI'] ? 1 : undefined,
|
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: {
|
use: {
|
||||||
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
|
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:14242',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
},
|
},
|
||||||
@@ -27,6 +34,6 @@ export default defineConfig({
|
|||||||
use: { ...devices['Desktop Chrome'] },
|
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.
|
// 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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { ModelInfo } from '@/lib/types';
|
import type { ModelInfo } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Conversation } from '@/lib/types';
|
import type { Conversation } from '@/lib/types';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
interface StreamingMessageProps {
|
interface StreamingMessageProps {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { formatAge, type FreshnessLabel } from '@/lib/freshness/model';
|
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 type { ReactNode } from 'react';
|
||||||
import { SidebarProvider, useSidebar } from './sidebar-context';
|
import { SidebarProvider, useSidebar } from './sidebar-context';
|
||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
interface SidebarContextValue {
|
interface SidebarContextValue {
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
'use client';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { MosaicLogo } from '@/components/ui/mosaic-logo';
|
import { MosaicLogo } from '@/components/ui/mosaic-logo';
|
||||||
import { useSidebar } from './sidebar-context';
|
import { useSidebar } from './sidebar-context';
|
||||||
@@ -99,7 +96,7 @@ const navItems: NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar(): React.ReactElement {
|
export function Sidebar(): React.ReactElement {
|
||||||
const pathname = usePathname();
|
const { pathname } = useLocation();
|
||||||
const { mobileOpen, setMobileOpen } = useSidebar();
|
const { mobileOpen, setMobileOpen } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -137,7 +134,7 @@ export function Sidebar(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
to={item.href}
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all duration-150',
|
'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';
|
import { useTheme } from '@/providers/theme-provider';
|
||||||
|
|
||||||
interface ThemeToggleProps {
|
interface ThemeToggleProps {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
'use client';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { signOut, useSession } from '@/lib/auth-client';
|
import { signOut, useSession } from '@/lib/auth-client';
|
||||||
import { ThemeToggle } from './theme-toggle';
|
import { ThemeToggle } from './theme-toggle';
|
||||||
import { useSidebar } from './sidebar-context';
|
import { useSidebar } from './sidebar-context';
|
||||||
@@ -22,12 +20,12 @@ function MenuIcon(): React.JSX.Element {
|
|||||||
|
|
||||||
export function Topbar(): React.ReactElement {
|
export function Topbar(): React.ReactElement {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
||||||
|
|
||||||
async function handleSignOut(): Promise<void> {
|
async function handleSignOut(): Promise<void> {
|
||||||
await signOut();
|
await signOut();
|
||||||
router.replace('/login');
|
navigate('/login', { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSidebarToggle(): void {
|
function handleSidebarToggle(): void {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Mission, MissionStatus } from '@/lib/types';
|
import type { Mission, MissionStatus } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
interface PrdViewerProps {
|
interface PrdViewerProps {
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Project } from '@/lib/types';
|
import type { Project } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { Task, TaskStatus } from '@/lib/types';
|
import type { Task, TaskStatus } from '@/lib/types';
|
||||||
import { TaskCard } from './task-card';
|
import { TaskCard } from './task-card';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Task } from '@/lib/types';
|
import type { Task } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Task } from '@/lib/types';
|
import type { Task } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Task, TaskStatus } from '@/lib/types';
|
import type { Task, TaskStatus } from '@/lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
export interface MosaicLogoProps {
|
export interface MosaicLogoProps {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { ThemeProvider } from '@/providers/theme-provider';
|
import { ThemeProvider } from '@/providers/theme-provider';
|
||||||
import { createAppRouter } from '@/routes';
|
import { createAppRouter } from '@/routes';
|
||||||
import '@/app/globals.css';
|
import '@/globals.css';
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
export type Theme = 'dark' | 'light';
|
export type Theme = 'dark' | 'light';
|
||||||
|
|||||||
+33
-15
@@ -13,8 +13,18 @@ import {
|
|||||||
TasksRouteErrorBoundary,
|
TasksRouteErrorBoundary,
|
||||||
} from '@/spa/pages/resource-route-error-boundaries';
|
} from '@/spa/pages/resource-route-error-boundaries';
|
||||||
import { TasksPage } from '@/spa/pages/tasks';
|
import { TasksPage } from '@/spa/pages/tasks';
|
||||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
import { SettingsPage } from '@/spa/pages/settings';
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
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 {
|
function GuestLayout(): ReactElement {
|
||||||
return (
|
return (
|
||||||
@@ -43,21 +53,29 @@ export const routes: RouteObject[] = [
|
|||||||
{
|
{
|
||||||
element: <AuthGuard />,
|
element: <AuthGuard />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
|
||||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
|
||||||
{
|
{
|
||||||
path: '/projects',
|
element: <DashboardLayout />,
|
||||||
element: <ProjectsPage />,
|
children: [
|
||||||
errorElement: <ProjectsRouteErrorBoundary />,
|
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||||
|
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||||
|
{
|
||||||
|
path: '/projects',
|
||||||
|
element: <ProjectsPage />,
|
||||||
|
errorElement: <ProjectsRouteErrorBoundary />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/projects/:id',
|
||||||
|
element: <ProjectDetailPage />,
|
||||||
|
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||||
|
},
|
||||||
|
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||||
|
{ path: '/settings', element: <SettingsPage /> },
|
||||||
|
{
|
||||||
|
element: <AdminGuard />,
|
||||||
|
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/projects/:id',
|
|
||||||
element: <ProjectDetailPage />,
|
|
||||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
|
||||||
},
|
|
||||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
|
||||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
|
||||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,3 +21,23 @@ export function AuthGuard(): ReactElement {
|
|||||||
|
|
||||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
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 { useEffect, useState, useCallback } from 'react';
|
||||||
import { AdminRoleGuard } from '@/components/admin-role-guard';
|
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
@@ -47,15 +44,9 @@ interface HealthStatusDto {
|
|||||||
|
|
||||||
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AdminPage(): React.ReactElement {
|
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
|
||||||
return (
|
// assumes an authenticated admin session.
|
||||||
<AdminRoleGuard>
|
export function AdminPage(): React.ReactElement {
|
||||||
<AdminContent />
|
|
||||||
</AdminRoleGuard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminContent(): React.ReactElement {
|
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/auth-client', () => ({
|
|||||||
useSession: useSessionMock,
|
useSession: useSessionMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { ThemeProvider } from '@/providers/theme-provider';
|
||||||
import { routes } from '@/routes';
|
import { routes } from '@/routes';
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -73,7 +74,11 @@ describe('ChatRouteErrorBoundary', () => {
|
|||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
try {
|
try {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<RouterProvider router={router} />);
|
root?.render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/auth-client', () => ({
|
|||||||
useSession: useSessionMock,
|
useSession: useSessionMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { ThemeProvider } from '@/providers/theme-provider';
|
||||||
import { routes } from '@/routes';
|
import { routes } from '@/routes';
|
||||||
|
|
||||||
function Boom(): never {
|
function Boom(): never {
|
||||||
@@ -71,7 +72,11 @@ describe('resource route error boundaries', () => {
|
|||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
try {
|
try {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<RouterProvider router={router} />);
|
root?.render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
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 { useCallback, useEffect, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { authClient, useSession } from '@/lib/auth-client';
|
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;
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SettingsPage(): React.ReactElement {
|
export function SettingsPage(): React.ReactElement {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
||||||
|
|
||||||
@@ -113,6 +121,7 @@ function ProfileTab({
|
|||||||
const [image, setImage] = useState(session?.user.image ?? '');
|
const [image, setImage] = useState(session?.user.image ?? '');
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
useSavedBadgeReset(saveState, setSaveState);
|
||||||
|
|
||||||
// Sync from session when it loads
|
// Sync from session when it loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -133,7 +142,6 @@ function ProfileTab({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaveState('saved');
|
setSaveState('saved');
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
||||||
setErrorMsg(message);
|
setErrorMsg(message);
|
||||||
@@ -196,6 +204,7 @@ function AppearanceTab(): React.ReactElement {
|
|||||||
const [defaultModel, setDefaultModel] = useState('');
|
const [defaultModel, setDefaultModel] = useState('');
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
useSavedBadgeReset(saveState, setSaveState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<Preference[]>('/api/memory/preferences?category=appearance')
|
api<Preference[]>('/api/memory/preferences?category=appearance')
|
||||||
@@ -241,7 +250,6 @@ function AppearanceTab(): React.ReactElement {
|
|||||||
: []),
|
: []),
|
||||||
]);
|
]);
|
||||||
setSaveState('saved');
|
setSaveState('saved');
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||||
setErrorMsg(message);
|
setErrorMsg(message);
|
||||||
@@ -325,6 +333,7 @@ function NotificationsTab(): React.ReactElement {
|
|||||||
const [emailDigest, setEmailDigest] = useState(false);
|
const [emailDigest, setEmailDigest] = useState(false);
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
useSavedBadgeReset(saveState, setSaveState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<Preference[]>('/api/memory/preferences?category=communication')
|
api<Preference[]>('/api/memory/preferences?category=communication')
|
||||||
@@ -371,7 +380,6 @@ function NotificationsTab(): React.ReactElement {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
setSaveState('saved');
|
setSaveState('saved');
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
||||||
setErrorMsg(message);
|
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",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2022",
|
||||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"plugins": [{ "name": "next" }],
|
"types": ["vite/client"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./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"]
|
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ export default defineConfig({
|
|||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
|
||||||
esbuild: {
|
|
||||||
jsx: 'automatic',
|
|
||||||
},
|
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|||||||
COPY apps/appservice/package.json ./apps/appservice/
|
COPY apps/appservice/package.json ./apps/appservice/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY plugins/ ./plugins/
|
COPY plugins/ ./plugins/
|
||||||
|
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
|
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ WORKDIR /app
|
|||||||
# Copy workspace manifests first for layer-cached install
|
# Copy workspace manifests first for layer-cached install
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
COPY apps/gateway/package.json ./apps/gateway/
|
COPY apps/gateway/package.json ./apps/gateway/
|
||||||
|
COPY apps/web/package.json ./apps/web/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY plugins/ ./plugins/
|
COPY plugins/ ./plugins/
|
||||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
# Build gateway, the web SPA bundle it serves (#1444), and all of their
|
||||||
RUN pnpm turbo run build --filter @mosaicstack/gateway...
|
# 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
|
# 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
|
# --legacy is required for pnpm v10 when inject-workspace-packages is not set
|
||||||
RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
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;
|
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||||
# copy from builder explicitly as belt-and-suspenders
|
# copy from builder explicitly as belt-and-suspenders
|
||||||
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
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)
|
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
||||||
EXPOSE 14242
|
EXPOSE 14242
|
||||||
USER node
|
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
|
# Administrator Guide
|
||||||
|
|
||||||
> **Status:** Partially migrated. Current SSO and local upgrade/recovery procedures are available; held procedures are labeled non-operative.
|
> **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
|
# Administrator Operations
|
||||||
|
|
||||||
> **Status:** Partially migrated. Procedures explicitly identify whether they are current or held.
|
> **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
|
# Mos Connector Lease Operations — M1
|
||||||
|
|
||||||
> **Status:** Held / non-operative.
|
> **Status:** Held / non-operative.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Upgrade safety and recovery
|
# Upgrade safety and recovery
|
||||||
|
|
||||||
> **Supported route:** an already installed `mosaic` CLI using the local PGlite
|
> **Supported route:** an already installed `mosaic` CLI using the local PGlite
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
|
|
||||||
> **Status:** Partially migrated. The SSO provider and Discord ingress security pages are current.
|
> **Status:** Partially migrated. The SSO provider and Discord ingress security pages are current.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Discord ingress security
|
# 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.
|
> **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
|
title: SSO Providers
|
||||||
type: runbook
|
|
||||||
audience: admin
|
audience: admin
|
||||||
status: current
|
|
||||||
source_of_truth: false
|
source_of_truth: false
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# API Documentation
|
# API Documentation
|
||||||
|
|
||||||
> **Status:** Scaffold only. The canonical gateway contract has not yet been migrated into this directory.
|
> **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
|
# Developer Guide
|
||||||
|
|
||||||
> **Status:** Partially migrated. Architecture, lease-broker verification, and channel-adapter authoring pages are current; other contributor chapters remain unmigrated.
|
> **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
|
# Architecture
|
||||||
|
|
||||||
> **Status:** Partially migrated. The lease-broker security-contract pages below are current references; the remaining architecture pages are still being classified.
|
> **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
|
# 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.
|
> **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
|
# Compaction observer revocation and runtime generations
|
||||||
|
|
||||||
> **Status:** Current contract reference.
|
> **Status:** Current contract reference.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Architecture Decisions
|
# Architecture Decisions
|
||||||
|
|
||||||
> **Status:** Current decision index. A decision describes an implemented and accepted boundary; draft proposals belong under `rfcs/` or `docs/plans/`.
|
> **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
|
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
||||||
|
|
||||||
> **Decision status:** Current implemented decision (M1).
|
> **Decision status:** Current implemented decision (M1).
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Authenticated external lease broker protocol
|
# Authenticated external lease broker protocol
|
||||||
|
|
||||||
> **Status:** Current contract reference.
|
> **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