Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
752ee40edd | ||
|
|
c5a45784ac | ||
|
|
385b2b6886 | ||
|
|
70c7311a46 | ||
|
|
bf8bc2128d | ||
|
|
01904b8f69 | ||
|
|
676900bd46 | ||
|
|
a3b0770205 | ||
|
|
2a30c68b84 | ||
|
|
f8f8f97be7 | ||
|
|
49b7943420 | ||
|
|
19e16bd44f | ||
|
|
3bd490c080 | ||
|
|
4b448109dd | ||
|
|
bc1149c15e | ||
|
|
089953a7cf | ||
|
|
7b25be22e9 | ||
|
|
4e3d179e61 | ||
|
|
ae58482b72 | ||
|
|
b2d40dada0 | ||
|
|
d30a4cce00 | ||
|
|
4cd280e48d | ||
|
|
8738a03893 | ||
|
|
04a01be992 | ||
|
|
812e2df1da | ||
|
|
8c292fb32f | ||
|
|
f45928c311 | ||
|
|
4d24ae8618 | ||
|
|
d790572e2e | ||
|
|
d7b1dd9601 | ||
|
|
0db2d19a22 | ||
|
|
8eb7e6354e | ||
|
|
9014a510a9 | ||
|
|
974e4740ab | ||
|
|
9cd6d39b71 | ||
|
|
143f925fd8 | ||
|
|
24294d3b77 | ||
|
|
24caeab057 | ||
|
|
888a6ad29b | ||
|
|
24462f460e | ||
|
|
a480ee83dc | ||
|
|
fd43ed5420 | ||
|
|
1d84bc3f3d | ||
|
|
6306914965 | ||
|
|
af43a7a63e | ||
|
|
ca97b885b0 | ||
|
|
6db0bead44 | ||
|
|
c671290d77 | ||
|
|
6a9b2cf6c1 | ||
|
|
6bd93a621d | ||
|
|
e9485c3d96 | ||
|
|
d2f0846dcc | ||
|
|
4f22a58041 | ||
|
|
9b6869fab7 |
+6
-3
@@ -40,9 +40,12 @@ BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||
BETTER_AUTH_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
||||
# Public gateway URL — accessible from the browser, not just the server.
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
||||
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
|
||||
# Directory holding the built SPA bundle (vite build output). When set, the
|
||||
# gateway serves the SPA same-origin; when unset (dev), run the Vite dev
|
||||
# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway.
|
||||
# safe-default: unset in dev — SPA serving is an opt-in production concern
|
||||
#WEB_DIST_DIR=apps/web/dist
|
||||
|
||||
|
||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"integration_trunk": "next",
|
||||
"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."
|
||||
}
|
||||
+49
-4
@@ -38,10 +38,12 @@ when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
# This avoids from_secret which is blocked on pull_request events.
|
||||
# If the env vars aren't set, turbo falls back to local cache only.
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
|
||||
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
|
||||
# deployment — never pull_request). This PR pipeline deliberately gets no
|
||||
# remote-cache credentials: an untrusted PR must not be able to write to (or
|
||||
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
|
||||
# local cache only, which is the intended behavior here.
|
||||
|
||||
steps:
|
||||
install:
|
||||
@@ -91,6 +93,15 @@ steps:
|
||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||
# joins CI directly rather than the exclusions file.
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||
# Hermetic regression for the git identity ladder (#1356): mock tea on PATH,
|
||||
# sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins
|
||||
# fail-closed: a seat whose login is missing gets a named error, never a
|
||||
# borrowed identity. Joins CI directly; its #1007 exclusion is burned down.
|
||||
- bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
|
||||
# Hermetic regression for issue-view.sh (#1357): mock tea/curl, sandboxed
|
||||
# repo. Pins that comment BODIES render on both paths and that a tea
|
||||
# failure is named as what it was (git-config vs credential).
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
|
||||
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||
# endpoints and ordinary commands through. Both directions are asserted —
|
||||
@@ -104,6 +115,40 @@ steps:
|
||||
# stub supplies the scale instead of the host's own checkout.
|
||||
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
||||
|
||||
# Canonical repo-structure declaration gate (T51 WP5c, spec §5.4 point 2):
|
||||
# .mosaic/repo.json is the machine-readable structure SSOT consumed by git
|
||||
# wrappers and the T32 gate seat; this is its repo-side CI enforcement.
|
||||
# Path-conditional: runs when the declaration, the vendored validator, or this
|
||||
# pipeline config changes (manual runs always include it). Fails the pipeline
|
||||
# on any VALIDATION_ERROR and enforces the schema_version 2 authoring rule
|
||||
# (--require-v2: edited/new declarations may not stay v1). The validator is
|
||||
# vendored into the framework tree (spec §5.1 final home) — provenance in its
|
||||
# header; the hostile-input suite (101 arms, hermetic) runs alongside so the
|
||||
# gate's own instrument ships in the same commit as the gate.
|
||||
structure-declaration:
|
||||
image: *node_image
|
||||
commands:
|
||||
- apk add --no-cache bash git
|
||||
# MOSAIC_HOST_ROOT is a runtime anchor (spec §1.2a: unset fails closed
|
||||
# for managed validation). CI has no host, so the step provisions an
|
||||
# EXPLICIT fixture root — honest configuration for the resolution path,
|
||||
# never a guess about a real host; the per-host containment checks are
|
||||
# runtime concerns and do not run against a fixture. Grammar, schema,
|
||||
# refs, flow, remote normalization, and path grammar all prove here.
|
||||
- mkdir -p /tmp/t51-ci-hostroot
|
||||
- bash packages/mosaic/framework/tools/structure/validate-repo-json.sh .mosaic/repo.json --require-v2
|
||||
- bash packages/mosaic/framework/tools/structure/test-validate-repo-json.sh
|
||||
environment:
|
||||
MOSAIC_HOST_ROOT: /tmp/t51-ci-hostroot
|
||||
when:
|
||||
- event: pull_request
|
||||
path:
|
||||
include:
|
||||
- '.mosaic/repo.json'
|
||||
- 'packages/mosaic/framework/tools/structure/**'
|
||||
- '.woodpecker/ci.yml'
|
||||
- event: manual
|
||||
|
||||
# Canonical verify:release stage `upgrade-guard`.
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
|
||||
+197
-46
@@ -32,6 +32,11 @@ variables:
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
# #1407: ONE shared anchor for all three image steps. A second main-only
|
||||
# anchor previously gated build-web/build-appservice, so next-lane pushes
|
||||
# published gateway sha images with no web/appservice counterpart — no
|
||||
# sha-parity set existed for next-lane containerized deploys. Every image
|
||||
# step now builds on next too (sha-only destinations, enforced per step).
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
@@ -44,16 +49,6 @@ variables:
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main, next]
|
||||
@@ -73,6 +68,13 @@ steps:
|
||||
# being empty) and on any incomplete verification.
|
||||
verify:
|
||||
image: *node_image
|
||||
environment:
|
||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
||||
@@ -108,6 +110,13 @@ steps:
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
environment:
|
||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
@@ -202,6 +211,20 @@ steps:
|
||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||
# #1404: snapshot every publishable manifest BEFORE the transform so the
|
||||
# workspace can be restored byte-exact after publish. The transform
|
||||
# rewrites package.json in place (needed: pnpm publish reads the
|
||||
# workspace manifests); without restore, later steps in this pipeline
|
||||
# (build-gateway kaniko COPY + pnpm install --frozen-lockfile) see
|
||||
# manifests that no longer match pnpm-lock.yaml and fail
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE. Snapshot dir is step-local tmp.
|
||||
SNAPSHOT_DIR="$(mktemp -d /tmp/publish-next-manifests.XXXXXX)"
|
||||
export SNAPSHOT_DIR
|
||||
find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*" | while read -r mf; do
|
||||
mkdir -p "$SNAPSHOT_DIR/$(dirname "$mf")"
|
||||
cp -p "$mf" "$SNAPSHOT_DIR/$mf"
|
||||
done
|
||||
echo "[publish-next] snapshotted $(find "$SNAPSHOT_DIR" -name package.json | wc -l) manifests to $SNAPSHOT_DIR"
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
@@ -209,23 +232,38 @@ steps:
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
const exactVersions = new Map(); // name -> bumped next version
|
||||
|
||||
function walk(dir) {
|
||||
function walk(dir, visit) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
|
||||
visit(manifest, packagePath);
|
||||
}
|
||||
}
|
||||
walk(fullPath, visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
// #1389: two passes. Pass 1 bumps every publishable manifest to
|
||||
// <stable+1>-next.<pipeline> exactly as before, recording name ->
|
||||
// bumped version. Pass 2 rewrites every published manifest's
|
||||
// @mosaicstack/* dependency entries (dependencies, devDependencies,
|
||||
// peerDependencies, optionalDependencies) to the EXACT same-pipeline
|
||||
// build. A caret range like ^0.0.3-next.2636 leaves the resolver free
|
||||
// to pick any later build — and on a host with a stale cache, an
|
||||
// installer-side scaffold pinned at stable, or a registry hiccup, that
|
||||
// freedom is how a "next" install ends up executing stable-era code
|
||||
// (web1 evidence: old tier validator, missing migrations). Exact pins
|
||||
// make the defect class unrepresentable regardless of resolver path.
|
||||
function bump(manifest, packagePath) {
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
@@ -234,13 +272,40 @@ steps:
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
exactVersions.set(manifest.name, manifest.version);
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
let pinnedEntries = 0;
|
||||
function pin(manifest, packagePath) {
|
||||
let changed = false;
|
||||
for (const field of DEP_FIELDS) {
|
||||
const deps = manifest[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [name, range] of Object.entries(deps)) {
|
||||
if (!name.startsWith('@mosaicstack/')) continue;
|
||||
const exact = exactVersions.get(name);
|
||||
if (!exact) {
|
||||
throw new Error(
|
||||
manifest.name + ' depends on ' + name +
|
||||
' which has no bumped version in this publish set — cannot pin');
|
||||
}
|
||||
if (range === exact) continue;
|
||||
deps[name] = exact;
|
||||
pinnedEntries++;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root, bump);
|
||||
for (const root of roots) walk(root, pin);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
console.log('[publish-next] pinned ' + pinnedEntries + ' @mosaicstack/* dep entries to exact same-pipeline versions across ' + updated.length + ' manifests');
|
||||
NODE
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||
@@ -250,6 +315,94 @@ steps:
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
# #1389 post-publish guard: every freshly published manifest must carry
|
||||
# EXACT same-pipeline @mosaicstack/* dep pins (no ranges, no stable
|
||||
# fallback). A leak here fails the pipeline instead of shipping.
|
||||
node <<'GUARD'
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const registry = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const published = [];
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const m = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (m.name?.startsWith('@mosaicstack/') && !m.private) published.push(m.name);
|
||||
}
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const root of roots) walk(root);
|
||||
let failures = 0;
|
||||
for (const name of published) {
|
||||
let manifest;
|
||||
try {
|
||||
const out = execFileSync('npm', ['view', name + '@next', '--json', '--registry', registry],
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
||||
const arr = JSON.parse(out);
|
||||
manifest = Array.isArray(arr) ? arr[arr.length - 1] : arr;
|
||||
} catch (e) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ': npm view failed: ' + e.message);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
for (const field of fields) {
|
||||
const deps = manifest[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [dep, range] of Object.entries(deps)) {
|
||||
if (!dep.startsWith('@mosaicstack/')) continue;
|
||||
const expected = dep === name ? manifest.version : null;
|
||||
const isExactPin = /^\d+\.\d+\.\d+-next\./.test(range);
|
||||
const samePipeline = range.endsWith('-next.' + pipelineNumber);
|
||||
if (!isExactPin) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' range "' + range + '" is not an exact -next pin (stable-leak class, #1389)');
|
||||
failures++;
|
||||
} else if (!samePipeline) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' pinned "' + range + '" but this pipeline published -next.' + pipelineNumber + ' (cross-pipeline pin)');
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures > 0) {
|
||||
console.error('[publish-next-guard] FATAL: ' + failures + ' dep-pin violation(s) — stable-dep leak into next publish (#1389)');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[publish-next-guard] OK: all ' + published.length + ' published manifests carry exact same-pipeline @mosaicstack/* dep pins');
|
||||
GUARD
|
||||
# #1404 restore: put the workspace manifests back byte-exact so later
|
||||
# steps (build-gateway frozen-lockfile install) see the committed tree.
|
||||
RESTORE_FAIL=0
|
||||
while read -r mf; do
|
||||
if [ -f "$SNAPSHOT_DIR/$mf" ]; then
|
||||
cp -p "$SNAPSHOT_DIR/$mf" "$mf"
|
||||
else
|
||||
echo "[publish-next] FATAL: no snapshot for $mf — cannot restore (snapshot incomplete?)" >&2
|
||||
RESTORE_FAIL=1
|
||||
fi
|
||||
done < <(find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*")
|
||||
# Pristine guard (#1404 red-first control): the publish step must leave
|
||||
# the workspace byte-identical to the checkout for every manifest.
|
||||
# git diff is the arbiter — any residual mutation fails THIS step
|
||||
# instead of surfacing as ERR_PNPM_OUTDATED_LOCKFILE in build-gateway.
|
||||
if ! git diff --exit-code -- '**/package.json' >/dev/null 2>&1; then
|
||||
echo "[publish-next] FATAL: workspace package.json files still differ from HEAD after restore (#1404 class)" >&2
|
||||
git diff --stat -- '**/package.json' >&2 || true
|
||||
RESTORE_FAIL=1
|
||||
fi
|
||||
rm -rf "$SNAPSHOT_DIR"
|
||||
if [ "$RESTORE_FAIL" -ne 0 ]; then exit 1; fi
|
||||
echo "[publish-next] workspace manifests restored byte-exact (git diff clean); later steps see the committed tree"
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
@@ -305,10 +458,18 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||
# its transform window and restores them at step end. Any step that
|
||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||
# pipeline 2648 raced a COPY inside the window and failed
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -322,8 +483,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next appservice publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||
@@ -332,30 +502,11 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
REGISTRY_PASS:
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||
# its transform window and restores them at step end. Any step that
|
||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||
# pipeline 2648 raced a COPY inside the window and failed
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
|
||||
@@ -81,6 +81,73 @@ pnpm format:check # Prettier check
|
||||
pnpm build # Build all packages and applications
|
||||
```
|
||||
|
||||
## Branch Model and Merge Process — `main` and `next` (CANONICAL)
|
||||
|
||||
**Every contribution targets `next` first. No exceptions.** Features, fixes, tests,
|
||||
docs, and policy changes all take the same route; urgency changes queue priority,
|
||||
never the route. Agents never commit to or merge into `main`.
|
||||
|
||||
| Branch | Role | Who merges into it |
|
||||
| ------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `next` | Integration trunk — the only PR target for contributions | The designated merge-gate agent, after all gates pass. Never the PR author. |
|
||||
| `main` | Stable/release line — receives promotion merges from `next` only | Jason only (or an agent he explicitly delegates for a named promotion). |
|
||||
|
||||
### Contribution sequencing (in order, no skipping)
|
||||
|
||||
1. **Issue first.** Work is tracked in a Gitea issue before a branch exists. The
|
||||
issue number appears in the branch name and the PR body.
|
||||
2. **Branch from the current `origin/next` head.** Name it
|
||||
`feat/…`, `fix/…`, `docs/…`, or `test/…` with the issue number
|
||||
(e.g. `docs/1214-branch-process`). Record the base SHA in the PR body.
|
||||
3. **Develop with evidence.** Applicable tests accompany the change. Hooks are
|
||||
never bypassed (`--no-verify` is prohibited). Stage explicit paths — never
|
||||
`git add -A`.
|
||||
4. **Open the PR against `next`.** The body states: scope, base SHA,
|
||||
verification commands with results, and any known pre-existing failures on
|
||||
the base — documented, not retried to green and not absorbed silently.
|
||||
5. **CI must be terminal-green on the exact head.** All bounded Woodpecker
|
||||
steps succeed (`verify-terminal-green` contract). Pipelines for fork PRs
|
||||
start `blocked`; a maintainer approves the run — approving CI is not
|
||||
approving the PR.
|
||||
6. **Independent review. Self-merge is prohibited** — for every agent, on every
|
||||
PR, including trivial ones. Where the change touches protected or
|
||||
contract-bearing content, the reviewer verifies the exact head
|
||||
(exact-byte/exact-blob comparison), not a description of it. An `AMEND`
|
||||
verdict returns the PR to its author; the reviewer's gate stays held until
|
||||
a fresh exact head passes.
|
||||
7. **Merge into `next`** happens only after CI green + review pass, pinned to
|
||||
the reviewed head SHA (a post-review push voids the review).
|
||||
8. **Promotion `next` → `main`** is a deliberate, Jason-owned reconciliation
|
||||
merge — not part of any contribution's lifecycle. Contributors are done at
|
||||
step 7.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- **Contributor** — base pinning, green CI, evidence in the PR body,
|
||||
responding to AMEND verdicts, never merging own work.
|
||||
- **Reviewer / merge gate** — independent verification on the exact head;
|
||||
holds and lifts gates; executes the merge into `next`.
|
||||
- **Orchestrator / adjudicator** — cross-PR sequencing, disposition when PRs
|
||||
collide, conflict adjudication.
|
||||
- **Jason** — `next` → `main` promotions, merge-authority grants, collaborator
|
||||
and token provisioning. Agents cannot grant themselves or each other any of
|
||||
these.
|
||||
|
||||
### Hotfixes and divergence
|
||||
|
||||
- A hotfix follows the same path: branch from `next`, PR to `next`, gates,
|
||||
merge, then an expedited Jason-owned promotion if `main` needs it urgently.
|
||||
Committing the fix to `main` directly is prohibited even under pressure.
|
||||
- **Never land work on `main` that is not on `next`.** This has happened
|
||||
(issue #1152's goal controller reached `main` without reaching `next`) and
|
||||
every later PR paid for it. If it happens anyway: transplant the work onto
|
||||
a `next`-based branch with provenance-preserving commits
|
||||
(`git cherry-pick -x` or explicit SHA references in the messages), PR it
|
||||
through the normal gates, and let promotion re-align `main`. Do not
|
||||
hand-patch `main` to compensate.
|
||||
- Force-pushing a branch you do not own is prohibited; rebasing your own PR
|
||||
branch is fine before review, and voids any review already given.
|
||||
|
||||
## Database and Local Runtime Safety
|
||||
|
||||
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@mariozechner/pi-ai": "^0.65.0",
|
||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { type Type } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AgentService, type AgentSession } from '../agent/agent.service.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||
import { PreferencesModule } from '../preferences/preferences.module.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
|
||||
const fakeDb = {
|
||||
$client: { exec: async (): Promise<void> => {} },
|
||||
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: async (): Promise<void> => {} }),
|
||||
};
|
||||
|
||||
const fakeProviderService = {
|
||||
onModuleInit: async (): Promise<void> => {},
|
||||
onModuleDestroy: (): void => {},
|
||||
getRegistry: () => ({ getAvailable: () => [], getAll: () => [], find: () => undefined }),
|
||||
getDefaultModel: () => undefined,
|
||||
listAvailableModels: () => [],
|
||||
listProviders: () => [],
|
||||
getAdapter: () => undefined,
|
||||
getProvidersHealth: () => [],
|
||||
};
|
||||
|
||||
function compileRealAppGraph(): Promise<TestingModule> {
|
||||
return Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider('DB_HANDLE')
|
||||
.useValue({ db: fakeDb, close: async (): Promise<void> => {} })
|
||||
.overrideProvider('DB')
|
||||
.useValue(fakeDb)
|
||||
.overrideProvider('STORAGE_ADAPTER')
|
||||
.useValue({
|
||||
name: 'required-security-wiring-test',
|
||||
migrate: async (): Promise<void> => {},
|
||||
close: async (): Promise<void> => {},
|
||||
})
|
||||
.overrideProvider('AUTH')
|
||||
.useValue({})
|
||||
.overrideProvider('BRAIN')
|
||||
.useValue({ conversations: {}, agents: {} })
|
||||
.overrideProvider('LOG_SERVICE')
|
||||
.useValue({})
|
||||
.overrideProvider('MEMORY')
|
||||
.useValue({})
|
||||
.overrideProvider('MEMORY_ADAPTER')
|
||||
.useValue({})
|
||||
.overrideProvider(ProviderService)
|
||||
.useValue(fakeProviderService)
|
||||
.compile();
|
||||
}
|
||||
|
||||
function providerToken(provider: unknown): unknown {
|
||||
return typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
|
||||
}
|
||||
|
||||
interface MaskingConsumer {
|
||||
moduleType: Type<unknown>;
|
||||
token: Type<unknown>;
|
||||
useValue: object;
|
||||
}
|
||||
|
||||
async function compileWithoutProvider(
|
||||
moduleType: Type<unknown>,
|
||||
missingToken: Type<unknown>,
|
||||
maskingConsumer: MaskingConsumer,
|
||||
): Promise<{ error: unknown; moduleRef: TestingModule | undefined }> {
|
||||
const touchedModules = new Set([moduleType, maskingConsumer.moduleType]);
|
||||
const originals = Array.from(touchedModules, (touchedModule: Type<unknown>) => ({
|
||||
moduleType: touchedModule,
|
||||
providers: (Reflect.getMetadata('providers', touchedModule) ?? []) as unknown[],
|
||||
exports: (Reflect.getMetadata('exports', touchedModule) ?? []) as unknown[],
|
||||
}));
|
||||
|
||||
for (const original of originals) {
|
||||
const providers = original.providers.flatMap((provider: unknown): unknown[] => {
|
||||
const token = providerToken(provider);
|
||||
if (original.moduleType === moduleType && token === missingToken) return [];
|
||||
if (original.moduleType === maskingConsumer.moduleType && token === maskingConsumer.token) {
|
||||
return [{ provide: maskingConsumer.token, useValue: maskingConsumer.useValue }];
|
||||
}
|
||||
return [provider];
|
||||
});
|
||||
const exports = original.exports.filter(
|
||||
(exported: unknown): boolean =>
|
||||
original.moduleType !== moduleType || providerToken(exported) !== missingToken,
|
||||
);
|
||||
Reflect.defineMetadata('providers', providers, original.moduleType);
|
||||
Reflect.defineMetadata('exports', exports, original.moduleType);
|
||||
}
|
||||
|
||||
let moduleRef: TestingModule | undefined;
|
||||
let error: unknown;
|
||||
try {
|
||||
moduleRef = await compileRealAppGraph();
|
||||
} catch (caught: unknown) {
|
||||
error = caught;
|
||||
} finally {
|
||||
for (const original of originals) {
|
||||
Reflect.defineMetadata('providers', original.providers, original.moduleType);
|
||||
Reflect.defineMetadata('exports', original.exports, original.moduleType);
|
||||
}
|
||||
}
|
||||
return { error, moduleRef };
|
||||
}
|
||||
|
||||
async function closeIfCompiled(moduleRef: TestingModule | undefined): Promise<void> {
|
||||
if (moduleRef) await moduleRef.close();
|
||||
}
|
||||
|
||||
describe('required security wiring — real AppModule startup refusal', () => {
|
||||
it('FL-01 positive control: the real graph compiles when CommandAuthorizationService is bound', async () => {
|
||||
const moduleRef = await compileRealAppGraph();
|
||||
try {
|
||||
expect(moduleRef.get(CommandAuthorizationService, { strict: false })).toBeInstanceOf(
|
||||
CommandAuthorizationService,
|
||||
);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('FL-01 negative control: absence read as permission is refused at module compilation', async () => {
|
||||
const { error, moduleRef } = await compileWithoutProvider(
|
||||
CommandsModule,
|
||||
CommandAuthorizationService,
|
||||
{
|
||||
moduleType: CommandsModule,
|
||||
token: CommandRuntimeApprovalVerifier,
|
||||
useValue: {},
|
||||
},
|
||||
);
|
||||
await closeIfCompiled(moduleRef);
|
||||
|
||||
expect(
|
||||
error,
|
||||
'absence read as permission: AppModule compilation accepted a missing CommandAuthorizationService binding',
|
||||
).toBeInstanceOf(Error);
|
||||
if (!(error instanceof Error)) return;
|
||||
expect(error.message).toContain('CommandExecutorService');
|
||||
expect(error.message).toContain('CommandAuthorizationService');
|
||||
});
|
||||
|
||||
it('FL-11 positive control: the real graph compiles when SystemOverrideService is bound', async () => {
|
||||
const moduleRef = await compileRealAppGraph();
|
||||
try {
|
||||
expect(moduleRef.get(SystemOverrideService, { strict: false })).toBeInstanceOf(
|
||||
SystemOverrideService,
|
||||
);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('FL-11 negative control: absence read as permission is refused at module compilation', async () => {
|
||||
const { error, moduleRef } = await compileWithoutProvider(
|
||||
PreferencesModule,
|
||||
SystemOverrideService,
|
||||
{
|
||||
moduleType: CommandsModule,
|
||||
token: CommandExecutorService,
|
||||
useValue: {},
|
||||
},
|
||||
);
|
||||
await closeIfCompiled(moduleRef);
|
||||
|
||||
expect(
|
||||
error,
|
||||
'absence read as permission: AppModule compilation accepted a missing SystemOverrideService binding',
|
||||
).toBeInstanceOf(Error);
|
||||
if (!(error instanceof Error)) return;
|
||||
expect(error.message).toContain('AgentService');
|
||||
expect(error.message).toContain('SystemOverrideService');
|
||||
});
|
||||
});
|
||||
|
||||
const actorScope = { userId: 'security-user', tenantId: 'security-tenant' };
|
||||
const conversationId = 'security-conversation';
|
||||
|
||||
function directExecutorWithoutAuthorization(systemOverrideSet: ReturnType<typeof vi.fn>) {
|
||||
const registry = {
|
||||
getManifest: vi.fn(() => ({
|
||||
version: 1,
|
||||
commands: [
|
||||
{
|
||||
name: 'system',
|
||||
aliases: [],
|
||||
description: 'Set instruction authority',
|
||||
scope: 'agent' as const,
|
||||
execution: 'socket' as const,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
})),
|
||||
};
|
||||
return new CommandExecutorService(
|
||||
registry as never,
|
||||
{ getSession: vi.fn() } as never,
|
||||
{ set: systemOverrideSet, clear: vi.fn() } as never,
|
||||
{ collect: vi.fn() } as never,
|
||||
null,
|
||||
{ agents: {} } as never,
|
||||
null,
|
||||
null,
|
||||
{ getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []) } as never,
|
||||
undefined as never,
|
||||
);
|
||||
}
|
||||
|
||||
function directAgentWithoutSystemOverride(piPrompt: ReturnType<typeof vi.fn>): {
|
||||
service: AgentService;
|
||||
session: AgentSession;
|
||||
} {
|
||||
const service = new AgentService(
|
||||
{
|
||||
getDefaultModel: vi.fn(() => null),
|
||||
getRegistry: vi.fn(() => ({})),
|
||||
findModel: vi.fn(),
|
||||
listAvailableModels: vi.fn(() => []),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ available: false } as never,
|
||||
{} as never,
|
||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||
undefined as never,
|
||||
null,
|
||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
null,
|
||||
);
|
||||
const session = {
|
||||
id: conversationId,
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
piSession: { prompt: piPrompt },
|
||||
listeners: new Set(),
|
||||
unsubscribe: vi.fn(),
|
||||
createdAt: Date.now(),
|
||||
promptCount: 0,
|
||||
channels: new Set(),
|
||||
skillPromptAdditions: [],
|
||||
sandboxDir: process.cwd(),
|
||||
allowedTools: null,
|
||||
userId: actorScope.userId,
|
||||
tenantId: actorScope.tenantId,
|
||||
metrics: {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date(0).toISOString(),
|
||||
},
|
||||
} as unknown as AgentSession;
|
||||
const internals = service as unknown as { sessions: Map<string, AgentSession> };
|
||||
internals.sessions.set(conversationId, session);
|
||||
return { service, session };
|
||||
}
|
||||
|
||||
describe('required security wiring — malformed direct absence has zero effects', () => {
|
||||
it('FL-01 refuses command execution before any command effect when authorization is absent', async () => {
|
||||
const systemOverrideSet = vi.fn().mockResolvedValue(undefined);
|
||||
const executor = directExecutorWithoutAuthorization(systemOverrideSet);
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'system',
|
||||
args: 'authority that must not be stored',
|
||||
conversationId,
|
||||
};
|
||||
let error: unknown;
|
||||
|
||||
try {
|
||||
await executor.execute(payload, actorScope);
|
||||
} catch (caught: unknown) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect
|
||||
.soft(
|
||||
error,
|
||||
'absence read as permission: direct executor accepted missing command authorization',
|
||||
)
|
||||
.toBeInstanceOf(Error);
|
||||
expect
|
||||
.soft(
|
||||
systemOverrideSet,
|
||||
'absence read as permission: command effect occurred without command authorization',
|
||||
)
|
||||
.not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FL-11 refuses prompt execution before any provider or session effect when system override authority is absent', async () => {
|
||||
const piPrompt = vi.fn().mockResolvedValue(undefined);
|
||||
const { service, session } = directAgentWithoutSystemOverride(piPrompt);
|
||||
let error: unknown;
|
||||
|
||||
try {
|
||||
await service.prompt(conversationId, 'must not reach provider', actorScope);
|
||||
} catch (caught: unknown) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect
|
||||
.soft(
|
||||
error,
|
||||
'absence read as permission: direct session accepted missing system override authority',
|
||||
)
|
||||
.toBeInstanceOf(Error);
|
||||
expect
|
||||
.soft(
|
||||
piPrompt,
|
||||
'absence read as permission: provider prompt occurred without system override authority',
|
||||
)
|
||||
.not.toHaveBeenCalled();
|
||||
expect
|
||||
.soft(
|
||||
session.promptCount,
|
||||
'absence read as permission: session state changed without system override authority',
|
||||
)
|
||||
.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ function makeService(operatorMemory: unknown = null): AgentService {
|
||||
{} as never,
|
||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||
null,
|
||||
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
null,
|
||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
operatorMemory as never,
|
||||
|
||||
@@ -132,9 +132,8 @@ export class AgentService implements OnModuleDestroy {
|
||||
@Inject(CoordService) private readonly coordService: CoordService,
|
||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||
@Optional()
|
||||
@Inject(SystemOverrideService)
|
||||
private readonly systemOverride: SystemOverrideService | null,
|
||||
private readonly systemOverride: SystemOverrideService,
|
||||
@Optional()
|
||||
@Inject(PreferencesService)
|
||||
private readonly preferencesService: PreferencesService | null,
|
||||
@@ -709,23 +708,22 @@ export class AgentService implements OnModuleDestroy {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
this.assertSessionScope(session, scope);
|
||||
session.promptCount += 1;
|
||||
|
||||
// Channel attachments are untrusted URI references. Preserve exact,
|
||||
// authenticated metadata for the agent without treating it as authority.
|
||||
const attachmentContext = this.attachmentContext(attachments);
|
||||
|
||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||
// Prepend session-scoped system override if present (renew TTL on each turn).
|
||||
// Required instruction-authority wiring is consulted before session/provider effects.
|
||||
let effectiveMessage = `${message}${attachmentContext}`;
|
||||
if (this.systemOverride) {
|
||||
const override = await this.systemOverride.get(sessionId, scope);
|
||||
if (override) {
|
||||
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
||||
await this.systemOverride.renew(sessionId, scope);
|
||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||
}
|
||||
const override = await this.systemOverride.get(sessionId, scope);
|
||||
if (override) {
|
||||
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
||||
await this.systemOverride.renew(sessionId, scope);
|
||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||
}
|
||||
|
||||
session.promptCount += 1;
|
||||
try {
|
||||
await session.piSession.prompt(effectiveMessage);
|
||||
} catch (err) {
|
||||
|
||||
@@ -80,6 +80,10 @@ const mockMcpClient = {
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
};
|
||||
|
||||
const allowAuthorization = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
|
||||
function buildService(
|
||||
redis: typeof mockRedis | null = mockRedis,
|
||||
mcpClient: {
|
||||
@@ -98,6 +102,7 @@ function buildService(
|
||||
null,
|
||||
mockChatGateway as never,
|
||||
mcpClient as never,
|
||||
allowAuthorization as never,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,8 @@ export class CommandExecutorService {
|
||||
@Inject(forwardRef(() => ChatGateway))
|
||||
private readonly chatGateway: ChatGateway | null,
|
||||
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly authorization: CommandAuthorizationService | null = null,
|
||||
private readonly authorization: CommandAuthorizationService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
@@ -57,13 +56,13 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
|
||||
const authorization = await this.authorization?.authorize(
|
||||
const authorization = await this.authorization.authorize(
|
||||
def,
|
||||
payload,
|
||||
userId,
|
||||
payload.approvalId,
|
||||
);
|
||||
if (authorization && !authorization.allowed) {
|
||||
if (!authorization.allowed) {
|
||||
return { command, conversationId, success: false, message: authorization.reason };
|
||||
}
|
||||
|
||||
@@ -171,7 +170,7 @@ export class CommandExecutorService {
|
||||
const def = this.registry
|
||||
.getManifest()
|
||||
.commands.find((command) => command.name === payload.command);
|
||||
if (!def || !this.authorization) return null;
|
||||
if (!def) return null;
|
||||
return this.authorization.createApproval(def, payload, scope.userId);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ const mockMcpClient = {
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const allowAuthorization = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRegistry(): CommandRegistryService {
|
||||
@@ -74,6 +78,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
||||
null, // reloadService (optional)
|
||||
null, // chatGateway (optional)
|
||||
mockMcpClient as never,
|
||||
allowAuthorization as never,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// The module under test imports @mosaicstack/db at module scope; we replace only the
|
||||
// pieces DatabaseModule uses (partial mock — the real module also exports the
|
||||
// schema the storage adapter's import chain needs) so the test pins the #1392
|
||||
// contract (refuse to start on an incomplete schema) without a live database.
|
||||
vi.mock('@mosaicstack/db', async (importOriginal) => {
|
||||
const actual: object = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
createDb: vi.fn(),
|
||||
createPgliteDb: vi.fn(),
|
||||
getMigrationStatus: vi.fn(),
|
||||
runPgliteMigrations: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { DatabaseModule } from './database.module.js';
|
||||
import { getMigrationStatus } from '@mosaicstack/db';
|
||||
import type { DbHandle } from '@mosaicstack/db';
|
||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
|
||||
function makeModule(storageType: 'postgres' | 'pglite', tier: string) {
|
||||
const storageAdapter = {
|
||||
name: storageType,
|
||||
migrate: vi.fn(),
|
||||
close: vi.fn(),
|
||||
} as unknown as StorageAdapter;
|
||||
const handle = { close: vi.fn() } as unknown as DbHandle;
|
||||
const config = {
|
||||
tier,
|
||||
storage: { type: storageType, url: 'postgresql://x' },
|
||||
} as unknown as MosaicConfig;
|
||||
return {
|
||||
mod: new DatabaseModule(handle, storageAdapter, config),
|
||||
storageAdapter,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DatabaseModule.onModuleInit — #1392 schema verification', () => {
|
||||
it('refuses to start when the postgres schema is incomplete', async () => {
|
||||
const { mod, storageAdapter } = makeModule('postgres', 'standalone');
|
||||
vi.mocked(getMigrationStatus).mockResolvedValue({
|
||||
appliedCount: 15,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_salty_morlocks',
|
||||
complete: false,
|
||||
});
|
||||
await expect(mod.onModuleInit()).rejects.toThrow('Database schema incomplete: 15/17');
|
||||
expect(storageAdapter.migrate).toHaveBeenCalled(); // migrations attempted first
|
||||
});
|
||||
|
||||
it('starts normally when the schema is complete', async () => {
|
||||
const { mod } = makeModule('postgres', 'standalone');
|
||||
vi.mocked(getMigrationStatus).mockResolvedValue({
|
||||
appliedCount: 17,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_salty_morlocks',
|
||||
complete: true,
|
||||
});
|
||||
await expect(mod.onModuleInit()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not verify postgres status for the local tier (PGlite migrates itself)', async () => {
|
||||
const { mod } = makeModule('pglite', 'local');
|
||||
vi.mocked(getMigrationStatus).mockClear();
|
||||
await expect(mod.onModuleInit()).resolves.toBeUndefined();
|
||||
expect(getMigrationStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
createDb,
|
||||
createPgliteDb,
|
||||
getMigrationStatus,
|
||||
runPgliteMigrations,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
@@ -74,6 +75,11 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||
// the same DATABASE_URL, so a single call covers both the gateway DB and
|
||||
// the storage tables. We deliberately do NOT call runMigrations() here to
|
||||
// avoid opening a second short-lived connection and doubling startup cost.
|
||||
//
|
||||
// #1392: we DO verify afterwards (getMigrationStatus opens one short-lived
|
||||
// connection) and refuse to start on an incomplete schema. A gateway that
|
||||
// boots "healthy" on an empty or partial database is precisely the failure
|
||||
// that shipped in the T63 batch: silent at startup, catastrophic later.
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (this.config.tier === 'local') {
|
||||
this.logger.log('Applying PGlite schema migrations...');
|
||||
@@ -81,6 +87,24 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||
}
|
||||
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
|
||||
await this.storageAdapter.migrate();
|
||||
|
||||
if (this.config.storage.type === 'postgres') {
|
||||
const status = await getMigrationStatus(this.config.storage.url);
|
||||
if (!status.complete) {
|
||||
this.logger.error(
|
||||
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied ` +
|
||||
`(last expected: ${status.expectedLastTag}). ` +
|
||||
'Refusing to start on a partial schema — see issues #1392/#1402. ' +
|
||||
"Remediation: re-run 'mosaic gateway install' (it now verifies), or apply migrations manually.",
|
||||
);
|
||||
throw new Error(
|
||||
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Database schema verified: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
|
||||
@@ -12,12 +12,19 @@ import { AppModule } from './app.module.js';
|
||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { mountSpaStatic } from './spa/serve-spa.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
|
||||
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
|
||||
// every request body into a 400 at first use; this surfaces it at boot.
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
|
||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required');
|
||||
}
|
||||
@@ -62,6 +69,7 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
mountAuthHandler(app);
|
||||
mountMcpHandler(app, app.get(McpService));
|
||||
await mountSpaStatic(app);
|
||||
|
||||
const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
|
||||
@@ -159,6 +159,7 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||
reloadService,
|
||||
mockChatGateway as never,
|
||||
mockMcpClient as never,
|
||||
{ authorize: vi.fn().mockResolvedValue({ allowed: true }) } as never,
|
||||
);
|
||||
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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 {
|
||||
return BACKEND_PREFIXES.some((prefix) => url === prefix || url.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; immutable caching for hashed /assets/ files
|
||||
// is a P6 optimization.
|
||||
await app.register(
|
||||
fastifyStatic as never,
|
||||
{
|
||||
root,
|
||||
wildcard: false,
|
||||
index: false,
|
||||
} as never,
|
||||
);
|
||||
|
||||
// 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.
|
||||
const fastify = app.getHttpAdapter().getInstance();
|
||||
fastify.get('/*', (req, reply) => {
|
||||
const url = req.raw.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;
|
||||
}
|
||||
// sendFile is decorated by @fastify/static; its type augmentation targets
|
||||
// a different fastify copy in the pnpm tree than the Nest adapter's.
|
||||
(reply as unknown as { sendFile: (file: string) => unknown }).sendFile('index.html');
|
||||
});
|
||||
|
||||
logger.log(`Serving SPA bundle from ${root}`);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Boot-time ValidationPipe metatype self-check (#1391).
|
||||
*
|
||||
* The check exists to fail loud at boot when the global pipe cannot see a
|
||||
* guarded DTO's decorated properties — the #436 class-erasure signature and
|
||||
* its dependency-graph cousins. Red/green arms:
|
||||
*
|
||||
* GREEN real module state: BootstrapSetupDto's three properties are
|
||||
* decorated and visible through the globalThis-shared storage.
|
||||
* RED a control class with NO decorators (the erasure shape): the
|
||||
* check throws PipeMetatypeCheckError naming every property.
|
||||
* RED-2 a control where one property is decorated and two are not: the
|
||||
* error names exactly the missing two — the miss list is precise,
|
||||
* not a blanket failure.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IsString } from 'class-validator';
|
||||
import {
|
||||
assertValidationPipeSeesDtoDecorators,
|
||||
PipeMetatypeCheckError,
|
||||
} from './validation-pipe-check.js';
|
||||
|
||||
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
|
||||
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
|
||||
// Simulate metatype erasure: an undecorated class standing where a
|
||||
// decorated DTO should be. Redefine the guard table for the test by
|
||||
// importing the module and pointing its table at the eroded class —
|
||||
// the check reads the table at call time, so a fresh module instance
|
||||
// with a swapped table reproduces the boot failure deterministically.
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class ErodedDto {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
expect(original).toBeDefined();
|
||||
// Swap in the eroded target (same declared properties, zero decorators).
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'ErodedDto',
|
||||
target: ErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('ErodedDto.name');
|
||||
expect(message).toContain('ErodedDto.email');
|
||||
expect(message).toContain('ErodedDto.password');
|
||||
}
|
||||
} finally {
|
||||
// Restore real module state for any later test in this file.
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
// And confirm the restore is real.
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class HalfErodedDto {
|
||||
@IsString()
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'HalfErodedDto',
|
||||
target: HalfErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
expect.unreachable('partially decorated DTO must fail the boot check');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('HalfErodedDto.email');
|
||||
expect(message).toContain('HalfErodedDto.password');
|
||||
expect(message).not.toContain('HalfErodedDto.name has no');
|
||||
}
|
||||
} finally {
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
* decorated properties of the DTOs it guards (#1391, #436 class).
|
||||
*
|
||||
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
|
||||
* `import type` class erasure (#436), or a dependency graph where the
|
||||
* controller's decorators and the application's route enhancers disagree
|
||||
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
|
||||
* ValidationPipe's whitelist treats every property as forbidden. The first
|
||||
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
|
||||
* worst place to discover wiring damage: the operator cannot tell a broken
|
||||
* payload from a broken daemon.
|
||||
*
|
||||
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
|
||||
* decorated properties, the gateway refuses to start with a named cause.
|
||||
* It catches the whole class — erasure, decorator metadata loss — on every
|
||||
* host, at the moment the damage exists rather than at first use.
|
||||
*
|
||||
* Storage sharing note: class-validator keys its metadata storage on
|
||||
* globalThis, so duplicate package copies do NOT hide metadata (measured,
|
||||
* #1391 diagnosis). What hides it is losing the metatype itself, which is
|
||||
* what this asserts against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DTOs the global pipe guards, mapped to the properties the whitelist must
|
||||
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
|
||||
* decorators register metadata keyed on the constructor, and its executor
|
||||
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
|
||||
* through `prototype` returns zero. Extend when adding DTOs to the app.
|
||||
*/
|
||||
export const PIPE_GUARDED_DTOS: Array<{
|
||||
name: string;
|
||||
target: abstract new (...args: never[]) => unknown;
|
||||
properties: string[];
|
||||
}> = [
|
||||
{
|
||||
name: 'BootstrapSetupDto',
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
constructor(missing: string[]) {
|
||||
super(
|
||||
'ValidationPipe metatype check failed: ' +
|
||||
missing.join('; ') +
|
||||
'. The global ValidationPipe cannot see decorated DTO properties — ' +
|
||||
'every request body would be rejected as non-whitelisted. ' +
|
||||
'Check for import-type erasure or decorator metadata loss in the ' +
|
||||
'dependency graph (see issues #436, #1391).',
|
||||
);
|
||||
this.name = 'PipeMetatypeCheckError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the pipe's whitelist can see every guarded DTO's decorated
|
||||
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
|
||||
* each miss. Pure function of module state: no I/O, safe to call twice.
|
||||
*/
|
||||
export function assertValidationPipeSeesDtoDecorators(): void {
|
||||
const storage = getMetadataStorage();
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const dto of PIPE_GUARDED_DTOS) {
|
||||
// class-validator records constraints keyed on the DTO's constructor
|
||||
// (decorators run on the class), and its executor resolves them via
|
||||
// object.constructor. A property with no recorded metadata is invisible
|
||||
// to the whitelist — whatever the cause — and fails here.
|
||||
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
|
||||
// strictGroups, groups?). No schema, always=true, no groups: every
|
||||
// constraint regardless of grouping, which is what the whitelist sees.
|
||||
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
|
||||
const decorated = new Set(metadatas.map((m) => m.propertyName));
|
||||
|
||||
for (const property of dto.properties) {
|
||||
if (!decorated.has(property)) {
|
||||
missing.push(
|
||||
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new PipeMetatypeCheckError(missing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'reflect-metadata';
|
||||
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { TeamsController } from './teams.controller.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
|
||||
const teamBeta = { id: 'team-beta', name: 'Beta' };
|
||||
|
||||
// user-1 is a member of team-alpha only; admin-1 has role admin.
|
||||
let currentUser: { id: string; role?: string } = { id: 'user-1' };
|
||||
|
||||
const teamsServiceMock = {
|
||||
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
|
||||
findAllForUser: vi.fn((userId: string) =>
|
||||
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
|
||||
),
|
||||
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
|
||||
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
|
||||
isMember: vi.fn((teamId: string, userId: string) =>
|
||||
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
|
||||
),
|
||||
};
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: { id: string; role?: string } }>();
|
||||
requestContext.user = currentUser;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
describe('teams endpoints are scoped to membership', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
currentUser = { id: 'user-1' };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns only the teams the user belongs to', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha]);
|
||||
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns every team for an admin', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha, teamBeta]);
|
||||
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns the team for a member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(teamAlpha);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
|
||||
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
|
||||
expect(denied.status).toBe(403);
|
||||
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
|
||||
|
||||
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: false });
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('an admin can look up any membership', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: true });
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,68 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
type RequestUser = { id: string; role?: string };
|
||||
|
||||
@Controller('api/teams')
|
||||
@UseGuards(AuthGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teams: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.teams.findAll();
|
||||
async list(@CurrentUser() user: RequestUser) {
|
||||
if (user.role === 'admin') {
|
||||
return this.teams.findAll();
|
||||
}
|
||||
return this.teams.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
@Get(':teamId')
|
||||
async findOne(@Param('teamId') teamId: string) {
|
||||
return this.teams.findById(teamId);
|
||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
return this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
|
||||
@Get(':teamId/members')
|
||||
async listMembers(@Param('teamId') teamId: string) {
|
||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
return this.teams.listMembers(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members/:userId')
|
||||
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||
async checkMembership(
|
||||
@Param('teamId') teamId: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
// A user may always ask about their own membership; anything else is
|
||||
// team-scoped like the other routes.
|
||||
if (userId !== user.id) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
const isMember = await this.teams.isMember(teamId, userId);
|
||||
return { isMember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-scoped access: admins see any team; everyone else only teams they
|
||||
* are a member of. NotFoundException when the team does not exist and
|
||||
* ForbiddenException when the user lacks access (same convention as the
|
||||
* projects controller).
|
||||
*/
|
||||
private async getAccessibleTeam(teamId: string, user: RequestUser) {
|
||||
const team = await this.teams.findById(teamId);
|
||||
if (!team) throw new NotFoundException('Team not found');
|
||||
if (user.role === 'admin') return team;
|
||||
const isMember = await this.teams.isMember(teamId, user.id);
|
||||
if (!isMember) throw new ForbiddenException('Not a member of this team');
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -56,6 +56,21 @@ export class TeamsService {
|
||||
return this.db.select().from(teams);
|
||||
}
|
||||
|
||||
/**
|
||||
* List only the teams the user is a member of.
|
||||
*/
|
||||
async findAllForUser(userId: string) {
|
||||
const memberRows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, userId));
|
||||
|
||||
const teamIds = memberRows.map((r) => r.teamId);
|
||||
if (teamIds.length === 0) return [];
|
||||
|
||||
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a team by ID.
|
||||
*/
|
||||
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
transpilePackages: ['@mosaicstack/design-tokens'],
|
||||
|
||||
// Enable gzip/brotli compression for all responses.
|
||||
compress: true,
|
||||
|
||||
// Reduce bundle size: disable source maps in production builds.
|
||||
productionBrowserSourceMaps: false,
|
||||
|
||||
// Image optimisation: allow the gateway origin as an external image source.
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Experimental: enable React compiler for automatic memoisation (Next 15+).
|
||||
// Falls back gracefully if the compiler plugin is not installed.
|
||||
experimental: {
|
||||
// Turbopack is the default in dev for Next 15; keep it opt-in for now.
|
||||
// turbo: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -3,22 +3,19 @@
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"build:vite": "vite build",
|
||||
"dev": "next dev -p 3101",
|
||||
"dev:vite": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:e2e": "playwright test",
|
||||
"start": "next start -p 3101"
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/design-tokens": "workspace:^",
|
||||
"@mosaicstack/types": "workspace:^",
|
||||
"better-auth": "^1.5.5",
|
||||
"clsx": "^2.1.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { GuestGuard } from '@/components/guest-guard';
|
||||
|
||||
export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<GuestGuard>
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg">
|
||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</GuestGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
||||
SsoProviderDiscovery['id'] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
||||
.catch(() => [] as SsoProviderDiscovery[])
|
||||
.then((providers) => setSsoProviders(providers.filter((provider) => provider.configured)));
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const email = form.get('email') as string;
|
||||
const password = form.get('password') as string;
|
||||
|
||||
const result = await signIn.email({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Sign in failed');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/chat');
|
||||
}
|
||||
|
||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
||||
setError(null);
|
||||
setSsoLoadingProviderId(providerId);
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.oauth2({
|
||||
providerId,
|
||||
callbackURL: '/chat',
|
||||
newUserCallbackURL: '/chat',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<SsoProviderButtons
|
||||
providers={ssoProviders}
|
||||
loadingProviderId={ssoLoadingProviderId}
|
||||
onOidcSignIn={(providerId) => {
|
||||
void handleSsoSignIn(providerId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="text-blue-400 hover:text-blue-300">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { signUp } from '@/lib/auth-client';
|
||||
|
||||
export default function RegisterPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const name = form.get('name') as string;
|
||||
const email = form.get('email') as string;
|
||||
const password = form.get('password') as string;
|
||||
|
||||
const result = await signUp.email({ name, email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Registration failed');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/chat');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-blue-400 hover:text-blue-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { destroySocket, getSocket } from '@/lib/socket';
|
||||
import type { Conversation, Message } from '@/lib/types';
|
||||
import {
|
||||
ConversationSidebar,
|
||||
type ConversationSidebarRef,
|
||||
} from '@/components/chat/conversation-sidebar';
|
||||
import { MessageBubble } from '@/components/chat/message-bubble';
|
||||
import { ChatInput } from '@/components/chat/chat-input';
|
||||
import { StreamingMessage } from '@/components/chat/streaming-message';
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
inputTypes: ('text' | 'image')[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
}
|
||||
|
||||
interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
export default function ChatPage(): React.ReactElement {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModelId, setSelectedModelId] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const sidebarRef = useRef<ConversationSidebarRef>(null);
|
||||
|
||||
// Track the active conversation ID in a ref so socket event handlers always
|
||||
// see the current value without needing to be re-registered.
|
||||
const activeIdRef = useRef<string | null>(null);
|
||||
activeIdRef.current = activeId;
|
||||
|
||||
// Accumulate streamed text in a ref so agent:end can read the full content
|
||||
// without stale-closure issues.
|
||||
const streamingTextRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
|
||||
if (savedState !== null) {
|
||||
setIsSidebarOpen(savedState === 'true');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
api<ProviderInfo[]>('/api/providers')
|
||||
.then((providers) => {
|
||||
const availableModels = providers
|
||||
.filter((provider) => provider.available)
|
||||
.flatMap((provider) => provider.models);
|
||||
setModels(availableModels);
|
||||
setSelectedModelId((current) => current || availableModels[0]?.id || '');
|
||||
})
|
||||
.catch(() => {
|
||||
setModels([]);
|
||||
setSelectedModelId('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load messages when active conversation changes
|
||||
useEffect(() => {
|
||||
if (!activeId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
// Clear streaming state when switching conversations
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
||||
.then(setMessages)
|
||||
.catch(() => {});
|
||||
}, [activeId]);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingText]);
|
||||
|
||||
// Socket.io setup — connect once for the page lifetime
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
|
||||
function onAgentStart(data: { conversationId: string }): void {
|
||||
// Only update state if the event belongs to the currently viewed conversation
|
||||
if (activeIdRef.current !== data.conversationId) return;
|
||||
setIsStreaming(true);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
}
|
||||
|
||||
function onAgentText(data: { conversationId: string; text: string }): void {
|
||||
if (activeIdRef.current !== data.conversationId) return;
|
||||
streamingTextRef.current += data.text;
|
||||
setStreamingText((prev) => prev + data.text);
|
||||
}
|
||||
|
||||
function onAgentEnd(data: { conversationId: string }): void {
|
||||
if (activeIdRef.current !== data.conversationId) return;
|
||||
const finalText = streamingTextRef.current;
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
// Append the completed assistant message to the local message list.
|
||||
// The Pi agent session is in-memory so the assistant response is not
|
||||
// persisted to the DB — we build the local UI state instead.
|
||||
if (finalText) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
conversationId: data.conversationId,
|
||||
role: 'assistant' as const,
|
||||
content: finalText,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
sidebarRef.current?.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function onError(data: { error: string; conversationId?: string }): void {
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
streamingTextRef.current = '';
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `error-${Date.now()}`,
|
||||
conversationId: data.conversationId ?? '',
|
||||
role: 'system' as const,
|
||||
content: `Error: ${data.error}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
socket.on('agent:start', onAgentStart);
|
||||
socket.on('agent:text', onAgentText);
|
||||
socket.on('agent:end', onAgentEnd);
|
||||
socket.on('error', onError);
|
||||
|
||||
// Connect if not already connected
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket.off('agent:start', onAgentStart);
|
||||
socket.off('agent:text', onAgentText);
|
||||
socket.off('agent:end', onAgentEnd);
|
||||
socket.off('error', onError);
|
||||
// Fully tear down the socket when the chat page unmounts so we get a
|
||||
// fresh authenticated connection next time the page is visited.
|
||||
destroySocket();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNewConversation = useCallback(async (projectId?: string | null) => {
|
||||
const conv = await api<Conversation>('/api/conversations', {
|
||||
method: 'POST',
|
||||
body: { title: 'New conversation', projectId: projectId ?? null },
|
||||
});
|
||||
|
||||
sidebarRef.current?.addConversation({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
projectId: conv.projectId,
|
||||
updatedAt: conv.updatedAt,
|
||||
archived: conv.archived,
|
||||
});
|
||||
|
||||
setActiveId(conv.id);
|
||||
setMessages([]);
|
||||
setIsSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string, options?: { modelId?: string }) => {
|
||||
let convId = activeId;
|
||||
|
||||
// Auto-create conversation if none selected
|
||||
if (!convId) {
|
||||
const autoTitle = content.slice(0, 60);
|
||||
const conv = await api<Conversation>('/api/conversations', {
|
||||
method: 'POST',
|
||||
body: { title: autoTitle },
|
||||
});
|
||||
sidebarRef.current?.addConversation({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
projectId: conv.projectId,
|
||||
updatedAt: conv.updatedAt,
|
||||
archived: conv.archived,
|
||||
});
|
||||
setActiveId(conv.id);
|
||||
convId = conv.id;
|
||||
} else if (messages.length === 0) {
|
||||
// Auto-title the initial placeholder conversation from the first user message.
|
||||
const autoTitle = content.slice(0, 60);
|
||||
api<Conversation>(`/api/conversations/${convId}`, {
|
||||
method: 'PATCH',
|
||||
body: { title: autoTitle },
|
||||
})
|
||||
.then(() => sidebarRef.current?.refresh())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Optimistic user message in local UI state
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `user-${Date.now()}`,
|
||||
conversationId: convId,
|
||||
role: 'user' as const,
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
// Persist the user message to the DB so conversation history is
|
||||
// available when the page is reloaded or a new session starts.
|
||||
api<Message>(`/api/conversations/${convId}/messages`, {
|
||||
method: 'POST',
|
||||
body: { role: 'user', content },
|
||||
}).catch(() => {
|
||||
// Non-fatal: the agent can still process the message even if
|
||||
// REST persistence fails.
|
||||
});
|
||||
|
||||
// Send to WebSocket — gateway creates/resumes the agent session and
|
||||
// streams the response back via agent:start / agent:text / agent:end.
|
||||
const socket = getSocket();
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
socket.emit('message', {
|
||||
conversationId: convId,
|
||||
content,
|
||||
modelId: (options?.modelId ?? selectedModelId) || undefined,
|
||||
});
|
||||
},
|
||||
[activeId, messages, selectedModelId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
|
||||
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
|
||||
>
|
||||
<ConversationSidebar
|
||||
ref={sidebarRef}
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
currentConversationId={activeId}
|
||||
onSelectConversation={(conversationId) => {
|
||||
setActiveId(conversationId);
|
||||
setMessages([]);
|
||||
if (conversationId && window.innerWidth < 768) {
|
||||
setIsSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onNewConversation={(projectId) => {
|
||||
void handleNewConversation(projectId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className="flex items-center gap-3 border-b px-4 py-3"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSidebarOpen((open) => !open)}
|
||||
className="rounded-lg border p-2 transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
aria-label={isSidebarOpen ? 'Close conversation sidebar' : 'Open conversation sidebar'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||
Mosaic Chat
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeId ? (
|
||||
<>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{isStreaming && <StreamingMessage text={streamingText} />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
isStreaming={isStreaming}
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onModelChange={setSelectedModelId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-6">
|
||||
<div
|
||||
className="max-w-md rounded-2xl border px-8 py-10 text-center"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-medium" style={{ color: 'var(--text)' }}>
|
||||
Welcome to Mosaic Chat
|
||||
</h2>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
|
||||
Select a conversation or start a new one
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleNewConversation();
|
||||
}}
|
||||
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||
style={{ background: 'var(--primary)' }}
|
||||
>
|
||||
Start new conversation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AppShell } from '@/components/layout/app-shell';
|
||||
import { AuthGuard } from '@/components/auth-guard';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<AppShell>{children}</AppShell>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
||||
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
||||
import { PrdViewer } from '@/components/projects/prd-viewer';
|
||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
||||
|
||||
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
active: 'bg-success/20 text-success',
|
||||
paused: 'bg-warning/20 text-warning',
|
||||
completed: 'bg-blue-600/20 text-blue-400',
|
||||
archived: 'bg-gray-600/20 text-gray-400',
|
||||
};
|
||||
|
||||
interface TabButtonProps {
|
||||
id: Tab;
|
||||
label: string;
|
||||
activeTab: Tab;
|
||||
onClick: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(id)}
|
||||
className={cn(
|
||||
'border-b-2 px-4 py-2 text-sm transition-colors',
|
||||
activeTab === id
|
||||
? 'border-text-primary text-text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectDetailPage(): React.ReactElement {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = typeof params['id'] === 'string' ? params['id'] : '';
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
Promise.all([
|
||||
api<Project>(`/api/projects/${id}`),
|
||||
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
||||
api<Task[]>(`/api/tasks?projectId=${id}`).catch(() => [] as Task[]),
|
||||
])
|
||||
.then(([proj, allMissions, tks]) => {
|
||||
setProject(proj);
|
||||
setMissions(allMissions.filter((m) => m.projectId === id));
|
||||
setTasks(tks);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(err.message ?? 'Failed to load project');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleTaskClick = useCallback((task: Task) => {
|
||||
setSelectedTask(task);
|
||||
}, []);
|
||||
|
||||
const handleCloseTaskModal = useCallback(() => {
|
||||
setSelectedTask(null);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-text-muted">Loading project...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-error">{error ?? 'Project not found'}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/projects')}
|
||||
className="mt-4 text-sm text-text-muted underline hover:text-text-secondary"
|
||||
>
|
||||
Back to projects
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredTasks = taskFilter === 'all' ? tasks : tasks.filter((t) => t.status === taskFilter);
|
||||
|
||||
const prdContent = getPrdContent(project);
|
||||
const hasPrd = Boolean(prdContent);
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'tasks', label: `Tasks (${tasks.length})` },
|
||||
{ id: 'missions', label: `Missions (${missions.length})` },
|
||||
...(hasPrd ? [{ id: 'prd' as Tab, label: 'PRD' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/projects')}
|
||||
className="hover:text-text-secondary"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-text-primary">{project.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
statusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{project.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
||||
{new Date(project.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Tasks" value={String(tasks.length)} />
|
||||
<StatCard
|
||||
label="Done"
|
||||
value={String(tasks.filter((t) => t.status === 'done').length)}
|
||||
valueClass="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="In Progress"
|
||||
value={String(tasks.filter((t) => t.status === 'in-progress').length)}
|
||||
valueClass="text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={String(tasks.filter((t) => t.status === 'blocked').length)}
|
||||
valueClass={tasks.some((t) => t.status === 'blocked') ? 'text-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex gap-0 border-b border-surface-border">
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
label={tab.label}
|
||||
activeTab={activeTab}
|
||||
onClick={setActiveTab}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
||||
)}
|
||||
|
||||
{activeTab === 'tasks' && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<TaskStatusSummary
|
||||
tasks={tasks}
|
||||
activeFilter={taskFilter}
|
||||
onFilterChange={setTaskFilter}
|
||||
/>
|
||||
</div>
|
||||
<TaskListView tasks={filteredTasks} onTaskClick={handleTaskClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'missions' && <MissionTimeline missions={missions} />}
|
||||
|
||||
{activeTab === 'prd' && prdContent && (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
||||
<PrdViewer content={prdContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task detail modal */}
|
||||
{selectedTask && <TaskDetailModal task={selectedTask} onClose={handleCloseTaskModal} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewTabProps {
|
||||
project: Project;
|
||||
missions: Mission[];
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
function OverviewTab({ project, missions, tasks }: OverviewTabProps): React.ReactElement {
|
||||
const recentTasks = [...tasks]
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Recent tasks */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
||||
{recentTasks.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No tasks yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentTasks.map((task) => (
|
||||
<TaskSummaryRow key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Mission summary */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
||||
{missions.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No missions yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<MissionTimeline missions={missions.slice(0, 4)} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Metadata */}
|
||||
{project.metadata && Object.keys(project.metadata).length > 0 && (
|
||||
<section className="lg:col-span-2">
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<pre className="overflow-x-auto text-xs text-text-muted">
|
||||
{JSON.stringify(project.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
'not-started': 'bg-gray-600/20 text-gray-300',
|
||||
'in-progress': 'bg-blue-600/20 text-blue-400',
|
||||
blocked: 'bg-error/20 text-error',
|
||||
done: 'bg-success/20 text-success',
|
||||
cancelled: 'bg-gray-600/20 text-gray-500',
|
||||
};
|
||||
|
||||
function TaskSummaryRow({ task }: { task: Task }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2">
|
||||
<span className="truncate text-sm text-text-primary">{task.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-xs',
|
||||
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClass?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
|
||||
<p className="text-xs text-text-muted">{label}</p>
|
||||
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPrdContent(project: Project): string | null {
|
||||
if (!project.metadata) return null;
|
||||
|
||||
const prd = project.metadata['prd'];
|
||||
if (typeof prd === 'string' && prd.trim().length > 0) return prd;
|
||||
|
||||
const prdContent = project.metadata['prdContent'];
|
||||
if (typeof prdContent === 'string' && prdContent.trim().length > 0) return prdContent;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Project } from '@/lib/types';
|
||||
import { ProjectCard } from '@/components/projects/project-card';
|
||||
|
||||
export default function ProjectsPage(): React.ReactElement {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
api<Project[]>('/api/projects')
|
||||
.then(setProjects)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleProjectClick = useCallback(
|
||||
(project: Project) => {
|
||||
router.push(`/projects/${project.id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Projects</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Projects will appear here when created via the gateway API
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} onClick={handleProjectClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mission status section */}
|
||||
<MissionStatus />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionStatus(): React.ReactElement {
|
||||
const [mission, setMission] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api<Record<string, unknown>>('/api/coord/status')
|
||||
.then(setMission)
|
||||
.catch(() => setMission(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-4 text-lg font-semibold">Active Mission</h2>
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Loading mission status...</p>
|
||||
) : !mission ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
||||
<p className="text-sm text-text-muted">No active mission detected</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="Mission" value={String(mission['missionId'] ?? 'Unknown')} />
|
||||
<StatCard label="Phase" value={String(mission['currentPhase'] ?? '—')} />
|
||||
<StatCard
|
||||
label="Tasks"
|
||||
value={`${mission['completedTasks'] ?? 0} / ${mission['totalTasks'] ?? 0}`}
|
||||
/>
|
||||
<StatCard label="Status" value={String(mission['status'] ?? '—')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-elevated p-3">
|
||||
<p className="text-xs text-text-muted">{label}</p>
|
||||
<p className="mt-1 text-sm font-medium text-text-primary">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
export default function TasksPage(): React.ReactElement {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [view, setView] = useState<ViewMode>('kanban');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api<Task[]>('/api/tasks')
|
||||
.then(setTasks)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleTaskClick = useCallback((task: Task) => {
|
||||
// Task detail view will be added in future iteration
|
||||
console.log('Task clicked:', task.id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border border-surface-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('list')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'list'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('kanban')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'kanban'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
||||
) : view === 'kanban' ? (
|
||||
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
|
||||
) : (
|
||||
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
const params = useParams<{ provider: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
try {
|
||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||
if (cancelled) return;
|
||||
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderName(provider.name);
|
||||
if (!provider.configured) {
|
||||
setError(`${provider.name} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
if (provider.loginMode !== 'oidc') {
|
||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void redirectToProvider();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providerId, requestedCallbackURL]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{providerName
|
||||
? `Redirecting you to ${providerName}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Return to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||
If the redirect does not start automatically, return to the login page and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Mosaic',
|
||||
description: 'Mosaic Stack Dashboard',
|
||||
};
|
||||
|
||||
function themeScript(): string {
|
||||
return `
|
||||
(function () {
|
||||
try {
|
||||
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
||||
} catch (error) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
||||
/>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
|
||||
</head>
|
||||
<body>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function HomePage(): never {
|
||||
redirect('/chat');
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AdminRoleGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminRoleGuard({ children }: AdminRoleGuardProps): React.ReactElement | null {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const user = session?.user as
|
||||
| (NonNullable<typeof session>['user'] & { role?: string })
|
||||
| undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.replace('/login');
|
||||
} else if (!isPending && session && user?.role !== 'admin') {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [isPending, session, user?.role, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || user?.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps): React.ReactElement | null {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [isPending, session, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ModelInfo } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Conversation } from '@/lib/types';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface StreamingMessageProps {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import { formatAge, type FreshnessLabel } from '@/lib/freshness/model';
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
interface GuestGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Redirects authenticated users away from auth pages. */
|
||||
export function GuestGuard({ children }: GuestGuardProps): React.ReactElement | null {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session) {
|
||||
router.replace('/chat');
|
||||
}
|
||||
}, [isPending, session, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { signOut, useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AppHeaderProps {
|
||||
conversationTitle?: string | null;
|
||||
isSidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
const THEME_STORAGE_KEY = 'mosaic-chat-theme';
|
||||
|
||||
export function AppHeader({
|
||||
conversationTitle,
|
||||
isSidebarOpen,
|
||||
onToggleSidebar,
|
||||
}: AppHeaderProps): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [currentTime, setCurrentTime] = useState('');
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [theme, setTheme] = useState<ThemeMode>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
function updateTime(): void {
|
||||
setCurrentTime(
|
||||
new Date().toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
updateTime();
|
||||
const interval = window.setInterval(updateTime, 60_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/version.json')
|
||||
.then(async (res) => res.json() as Promise<{ version?: string; commit?: string }>)
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
setVersion(data.commit ? `${data.version}+${data.commit}` : data.version);
|
||||
}
|
||||
})
|
||||
.catch(() => setVersion(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const nextTheme = storedTheme === 'light' ? 'light' : 'dark';
|
||||
applyTheme(nextTheme);
|
||||
setTheme(nextTheme);
|
||||
}, []);
|
||||
|
||||
const handleThemeToggle = useCallback(() => {
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(nextTheme);
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
||||
setTheme(nextTheme);
|
||||
}, [theme]);
|
||||
|
||||
const handleSignOut = useCallback(async (): Promise<void> => {
|
||||
await signOut();
|
||||
window.location.href = '/login';
|
||||
}, []);
|
||||
|
||||
const userLabel = session?.user.name ?? session?.user.email ?? 'Mosaic User';
|
||||
const initials = useMemo(() => getInitials(userLabel), [userLabel]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-20 border-b backdrop-blur-xl"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
||||
aria-label="Toggle conversation sidebar"
|
||||
aria-expanded={isSidebarOpen}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<Link href="/chat" className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[var(--shadow-ms-md)]"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, var(--color-ms-blue-500), var(--color-ms-teal-500))',
|
||||
}}
|
||||
>
|
||||
M
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--color-text)]">Mosaic</div>
|
||||
<div className="hidden h-5 w-px bg-[var(--color-border)] md:block" />
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-ms-teal-500)] opacity-60" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-ms-teal-500)]" />
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-w-0 items-center gap-3 md:flex">
|
||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-2)]">
|
||||
{currentTime || '--:--'}
|
||||
</div>
|
||||
<div className="max-w-[24rem] truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{conversationTitle?.trim() || 'New Session'}
|
||||
</div>
|
||||
{version ? (
|
||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
||||
v{version}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<ShortcutHint label="⌘/" text="focus" />
|
||||
<ShortcutHint label="⌘K" text="focus" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleThemeToggle}
|
||||
className="inline-flex h-10 items-center justify-center rounded-2xl border px-3 text-sm transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? '☀︎' : '☾'}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label="Open user menu"
|
||||
>
|
||||
{session?.user.image ? (
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt={userLabel}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<div
|
||||
className="absolute right-0 top-12 min-w-56 rounded-3xl border p-2 shadow-[var(--shadow-ms-lg)]"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="border-b px-3 py-2" style={{ borderColor: 'var(--color-border)' }}>
|
||||
<div className="text-sm font-medium text-[var(--color-text)]">{userLabel}</div>
|
||||
{session?.user.email ? (
|
||||
<div className="text-xs text-[var(--color-muted)]">{session.user.email}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex rounded-2xl px-3 py-2 text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSignOut()}
|
||||
className="flex w-full rounded-2xl px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutHint({ label, text }: { label: string; text: string }): React.ReactElement {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
||||
<span className="font-medium text-[var(--color-text-2)]">{label}</span>
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitials(label: string): string {
|
||||
const words = label.split(/\s+/).filter(Boolean).slice(0, 2);
|
||||
if (words.length === 0) return 'M';
|
||||
return words.map((word) => word.charAt(0).toUpperCase()).join('');
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeMode): void {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'light') {
|
||||
root.setAttribute('data-theme', 'light');
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
root.removeAttribute('data-theme');
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { SidebarProvider, useSidebar } from './sidebar-context';
|
||||
import { Sidebar } from './sidebar';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
interface SidebarContextValue {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { MosaicLogo } from '@/components/ui/mosaic-logo';
|
||||
import { useSidebar } from './sidebar-context';
|
||||
@@ -99,7 +96,7 @@ const navItems: NavItem[] = [
|
||||
];
|
||||
|
||||
export function Sidebar(): React.ReactElement {
|
||||
const pathname = usePathname();
|
||||
const { pathname } = useLocation();
|
||||
const { mobileOpen, setMobileOpen } = useSidebar();
|
||||
|
||||
return (
|
||||
@@ -137,7 +134,7 @@ export function Sidebar(): React.ReactElement {
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
to={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all duration-150',
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from '@/providers/theme-provider';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { signOut, useSession } from '@/lib/auth-client';
|
||||
import { ThemeToggle } from './theme-toggle';
|
||||
import { useSidebar } from './sidebar-context';
|
||||
@@ -22,12 +20,12 @@ function MenuIcon(): React.JSX.Element {
|
||||
|
||||
export function Topbar(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
||||
|
||||
async function handleSignOut(): Promise<void> {
|
||||
await signOut();
|
||||
router.replace('/login');
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
function handleSidebarToggle(): void {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Mission, MissionStatus } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
interface PrdViewerProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Project } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type { Task, TaskStatus } from '@/lib/types';
|
||||
import { TaskCard } from './task-card';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task, TaskStatus } from '@/lib/types';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export interface MosaicLogoProps {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { createAppRouter } from '@/routes';
|
||||
import '@/app/globals.css';
|
||||
import '@/globals.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
+33
-15
@@ -13,8 +13,18 @@ import {
|
||||
TasksRouteErrorBoundary,
|
||||
} from '@/spa/pages/resource-route-error-boundaries';
|
||||
import { TasksPage } from '@/spa/pages/tasks';
|
||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
import { SettingsPage } from '@/spa/pages/settings';
|
||||
import { AdminPage } from '@/spa/pages/admin';
|
||||
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { AppShell } from '@/components/layout/app-shell';
|
||||
|
||||
function DashboardLayout(): ReactElement {
|
||||
return (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function GuestLayout(): ReactElement {
|
||||
return (
|
||||
@@ -43,21 +53,29 @@ export const routes: RouteObject[] = [
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||
{
|
||||
path: '/projects',
|
||||
element: <ProjectsPage />,
|
||||
errorElement: <ProjectsRouteErrorBoundary />,
|
||||
element: <DashboardLayout />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||
{
|
||||
path: '/projects',
|
||||
element: <ProjectsPage />,
|
||||
errorElement: <ProjectsRouteErrorBoundary />,
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
element: <ProjectDetailPage />,
|
||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||
},
|
||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
element: <ProjectDetailPage />,
|
||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||
},
|
||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -21,3 +21,23 @@ export function AuthGuard(): ReactElement {
|
||||
|
||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export function AdminGuard(): ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const user = session.user as typeof session.user & { role?: string };
|
||||
|
||||
return user.role === 'admin' ? <Outlet /> : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, useSessionMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
authClient: {},
|
||||
}));
|
||||
|
||||
import { AdminPage } from './admin';
|
||||
import { AdminGuard } from '@/spa/guards';
|
||||
|
||||
const userFixtures = {
|
||||
users: [
|
||||
{
|
||||
id: 'u-admin',
|
||||
name: 'Ada Admin',
|
||||
email: '[email protected]',
|
||||
role: 'admin',
|
||||
banned: false,
|
||||
banReason: null,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'u-member',
|
||||
name: 'Mel Member',
|
||||
email: '[email protected]',
|
||||
role: 'member',
|
||||
banned: true,
|
||||
banReason: 'spam',
|
||||
createdAt: '2026-08-02T00:00:00.000Z',
|
||||
updatedAt: '2026-08-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderAdminRoute(): Promise<void> {
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
{ path: '/', element: <div>home page</div> },
|
||||
{ path: '/login', element: <div>login page</div> },
|
||||
];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
|
||||
return {
|
||||
data: { user: { id: 'u-1', name: 'Test', email: '[email protected]', role } },
|
||||
isPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
it('redirects unauthenticated visitors to /login', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('login page');
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects non-admin users to /', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('member'));
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('home page');
|
||||
expect(apiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the admin page for admin users', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('Admin Panel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPage users tab', () => {
|
||||
it('lists users with role and ban status after load', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users');
|
||||
expect(container.textContent).toContain('Ada Admin');
|
||||
expect(container.textContent).toContain('Mel Member');
|
||||
expect(container.textContent).toContain('Banned');
|
||||
expect(container.textContent).toContain('2 user(s)');
|
||||
});
|
||||
|
||||
it('shows the load error with a retry control', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockRejectedValueOnce(new Error('gateway unavailable'));
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
expect(container.textContent).toContain('gateway unavailable');
|
||||
|
||||
apiMock.mockResolvedValueOnce(userFixtures);
|
||||
const retry = [...container.querySelectorAll('button')].find((b) =>
|
||||
b.textContent?.includes('Retry'),
|
||||
);
|
||||
expect(retry).toBeTruthy();
|
||||
await act(async () => {
|
||||
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Ada Admin');
|
||||
});
|
||||
|
||||
it('posts to the ban endpoint and reloads on Ban', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValue(userFixtures);
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
const banButton = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.textContent === 'Ban',
|
||||
);
|
||||
expect(banButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
banButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users/u-admin/ban', { method: 'POST' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPage health tab', () => {
|
||||
it('loads health status when the tab is opened', async () => {
|
||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
||||
apiMock.mockResolvedValueOnce(userFixtures).mockResolvedValueOnce({
|
||||
status: 'ok',
|
||||
database: { status: 'ok', latencyMs: 3 },
|
||||
cache: { status: 'ok', latencyMs: 1 },
|
||||
agentPool: { activeSessions: 2 },
|
||||
providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 4 }],
|
||||
checkedAt: '2026-08-26T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await renderAdminRoute();
|
||||
|
||||
const healthTab = [...container.querySelectorAll('button')].find((b) =>
|
||||
b.textContent?.includes('System Health'),
|
||||
);
|
||||
await act(async () => {
|
||||
healthTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/health');
|
||||
expect(container.textContent).toContain('Database (PostgreSQL)');
|
||||
expect(container.textContent).toContain('Active sessions: 2');
|
||||
expect(container.textContent).toContain('4 models');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { AdminRoleGuard } from '@/components/admin-role-guard';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
@@ -47,15 +44,9 @@ interface HealthStatusDto {
|
||||
|
||||
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
return (
|
||||
<AdminRoleGuard>
|
||||
<AdminContent />
|
||||
</AdminRoleGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminContent(): React.ReactElement {
|
||||
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
|
||||
// assumes an authenticated admin session.
|
||||
export function AdminPage(): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
||||
|
||||
return (
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -73,7 +74,11 @@ describe('ChatRouteErrorBoundary', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
root?.render(
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
function Boom(): never {
|
||||
@@ -71,7 +72,11 @@ describe('resource route error boundaries', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
root?.render(
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
useSessionMock: vi.fn(),
|
||||
updateUserMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
authClient: { updateUser: updateUserMock },
|
||||
}));
|
||||
|
||||
import { SettingsPage } from './settings';
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
useSessionMock.mockReset();
|
||||
updateUserMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderSettingsPage(): Promise<void> {
|
||||
const routes: RouteObject[] = [{ path: '/settings', element: <SettingsPage /> }];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): Promise<void> {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
return act(async () => {
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const session = {
|
||||
user: { id: 'u-1', name: 'Test User', email: '[email protected]', image: null },
|
||||
};
|
||||
|
||||
describe('SettingsPage profile tab', () => {
|
||||
it('renders the profile form from the session and saves via authClient', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
updateUserMock.mockResolvedValue({});
|
||||
|
||||
await renderSettingsPage();
|
||||
|
||||
const nameInput = container.querySelector<HTMLInputElement>('#profile-name');
|
||||
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
|
||||
expect(nameInput?.value).toBe('Test User');
|
||||
expect(emailInput?.value).toBe('[email protected]');
|
||||
expect(emailInput?.disabled).toBe(true);
|
||||
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(updateUserMock).toHaveBeenCalledWith({ name: 'Test User', image: null });
|
||||
expect(container.textContent).toContain('Saved!');
|
||||
});
|
||||
|
||||
it('surfaces an update failure without clearing the form', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
updateUserMock.mockResolvedValue({ error: { message: 'name rejected' } });
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(container.textContent).toContain('name rejected');
|
||||
expect(container.querySelector<HTMLInputElement>('#profile-name')?.value).toBe('Test User');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage appearance tab', () => {
|
||||
it('loads preferences and posts each changed preference on save', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
apiMock.mockImplementation((path: string) =>
|
||||
path.startsWith('/api/memory/preferences?')
|
||||
? Promise.resolve([{ key: 'ui.theme', value: 'dark', category: 'appearance' }])
|
||||
: Promise.resolve({}),
|
||||
);
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Appearance');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences?category=appearance');
|
||||
|
||||
await clickButtonByText('Save changes');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences', {
|
||||
method: 'POST',
|
||||
body: { key: 'ui.theme', value: 'dark', category: 'appearance', source: 'user' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage providers tab', () => {
|
||||
it('loads LLM and SSO providers and runs a connection test', async () => {
|
||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
||||
apiMock.mockImplementation((path: string, opts?: { method?: string }) => {
|
||||
if (path === '/api/providers' && opts === undefined) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
available: true,
|
||||
models: [
|
||||
{
|
||||
id: 'llama3.2',
|
||||
provider: 'ollama',
|
||||
name: 'Llama 3.2',
|
||||
reasoning: false,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 4096,
|
||||
inputTypes: ['text'],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (path === '/api/sso/providers') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (path === '/api/providers/test') {
|
||||
return Promise.resolve({ providerId: 'ollama', reachable: true, latencyMs: 12 });
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
await renderSettingsPage();
|
||||
await clickButtonByText('Providers');
|
||||
|
||||
expect(container.textContent).toContain('Ollama');
|
||||
expect(container.textContent).toContain('1 model');
|
||||
|
||||
await clickButtonByText('Test');
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/providers/test', {
|
||||
method: 'POST',
|
||||
body: { providerId: 'ollama' },
|
||||
});
|
||||
expect(container.textContent).toContain('Reachable');
|
||||
});
|
||||
});
|
||||
+1
-3
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, useSession } from '@/lib/auth-client';
|
||||
@@ -61,7 +59,7 @@ function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
||||
|
||||
@@ -21,3 +21,23 @@ for (const target of [globalThis, window]) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// jsdom (v29) does not implement window.matchMedia; the sidebar layout uses it
|
||||
// for its mobile breakpoint. Minimal always-desktop stub.
|
||||
if (typeof window.matchMedia !== 'function') {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["src", "vite.config.ts", "vitest.config.ts"],
|
||||
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
|
||||
@@ -10,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/appservice/package.json ./apps/appservice/
|
||||
COPY packages/ ./packages/
|
||||
COPY plugins/ ./plugins/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
|
||||
|
||||
@@ -8,14 +8,16 @@ WORKDIR /app
|
||||
# Copy workspace manifests first for layer-cached install
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/gateway/package.json ./apps/gateway/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/ ./packages/
|
||||
COPY plugins/ ./plugins/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
# Build gateway and all of its workspace dependencies via turbo dependency graph
|
||||
RUN pnpm turbo run build --filter @mosaicstack/gateway...
|
||||
# Build gateway, the web SPA bundle it serves (#1444), and all of their
|
||||
# workspace dependencies via the turbo dependency graph
|
||||
RUN pnpm turbo run build --filter @mosaicstack/gateway... --filter @mosaicstack/web...
|
||||
# Produce a self-contained deploy artifact: flat node_modules, no pnpm symlinks
|
||||
# --legacy is required for pnpm v10 when inject-workspace-packages is not set
|
||||
RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
|
||||
@@ -38,6 +40,9 @@ COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
||||
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
|
||||
# copy from builder explicitly as belt-and-suspenders
|
||||
COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
||||
# The built web SPA bundle; served by the gateway (apps/gateway/src/spa/serve-spa.ts)
|
||||
COPY --chown=node:node --from=builder /app/apps/web/dist ./web-dist
|
||||
ENV WEB_DIST_DIR=/app/web-dist
|
||||
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
||||
EXPOSE 14242
|
||||
USER node
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/ ./packages/
|
||||
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||
COPY scripts/ ./scripts/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm --filter @mosaicstack/web build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Administrator Guide
|
||||
|
||||
> **Status:** Partially migrated. Current SSO and local upgrade/recovery procedures are available; held procedures are labeled non-operative.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Administrator Operations
|
||||
|
||||
> **Status:** Partially migrated. Procedures explicitly identify whether they are current or held.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mos Connector Lease Operations — M1
|
||||
|
||||
> **Status:** Held / non-operative.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Upgrade safety and recovery
|
||||
|
||||
> **Supported route:** an already installed `mosaic` CLI using the local PGlite
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Security
|
||||
|
||||
> **Status:** Partially migrated. The SSO provider and Discord ingress security pages are current.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Discord ingress security
|
||||
|
||||
> **Status:** Current Discord behavior only. Telegram shared-contract parity, Matrix channel ingress, and a gateway-wide shared adapter registry are not implemented or are not proven by the current source/tests.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
title: SSO Providers
|
||||
type: runbook
|
||||
audience: admin
|
||||
status: current
|
||||
source_of_truth: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# API Documentation
|
||||
|
||||
> **Status:** Scaffold only. The canonical gateway contract has not yet been migrated into this directory.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Developer Guide
|
||||
|
||||
> **Status:** Partially migrated. Architecture, lease-broker verification, and channel-adapter authoring pages are current; other contributor chapters remain unmigrated.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
> **Status:** Partially migrated. The lease-broker security-contract pages below are current references; the remaining architecture pages are still being classified.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Channel protocol architecture
|
||||
|
||||
> **Status:** Current shared type contract and Discord compatibility baseline. The shared gateway registry, Telegram parity, Matrix integration, identity-linking, and multi-surface multiplexing described below are draft or unimplemented.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Compaction observer revocation and runtime generations
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Architecture Decisions
|
||||
|
||||
> **Status:** Current decision index. A decision describes an implemented and accepted boundary; draft proposals belong under `rfcs/` or `docs/plans/`.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: record
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
||||
|
||||
> **Decision status:** Current implemented decision (M1).
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Authenticated external lease broker protocol
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# WI-1 lease broker security notes
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Whole mutator-class lease gate
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Architecture RFCs
|
||||
|
||||
> **Status:** Current proposal index. RFCs are draft design material and have no operational or implementation authority until an approved decision and implementation evidence supersede them.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# RFC: Optional AI Egress Gateways
|
||||
|
||||
> **Status:** Draft / proposed — not approved, not current, and not integrated.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
---
|
||||
|
||||
# Channel adapters
|
||||
|
||||
> **Status:** Current shared channel types plus the Discord reference/compatibility implementation. A shared gateway adapter registry, Telegram parity, and Matrix channel integration remain unimplemented or unproven.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
kind: guide
|
||||
status: active
|
||||
title: Lease-broker operations
|
||||
type: runbook
|
||||
audience: developer
|
||||
status: current
|
||||
source_of_truth: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
kind: tracking
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mission Manifest — MVP
|
||||
|
||||
> Top-level rollup tracking Mosaic Stack MVP execution.
|
||||
|
||||
+247
-1014
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user