Compare commits

..
Author SHA1 Message Date
jason.woltje e175616885 feat(conductor): auto-apply policy gate for worker patches (#34)
- conductor-policy.json (tracked, strictly validated): enabled switch,
  path allowlist globs, gating suites - the autonomy decision lives in a
  declarative file the owner controls
- scripts/conductor-apply.sh <runId> [--dry-run]: succeeded-run check ->
  clean target tree -> diff from worker workspace -> allowlist -> syntax
  gates (node/bash/json) -> apply -> policy suites -> attribution commit;
  ANY failure reverts the tree; push is never automatic
- scripts/test-conductor.sh: 17 sandbox cases covering every gate incl.
  suite-failure auto-revert and disabled policy
- policy defaults: scripts/docs/tasks/missions/adapters + README; all
  three suites gate

Closes #34
2026-09-03 07:03:29 -05:00
jason.woltje 6955717612 Merge M11: session forking from a common ancestor
Closes #33
2026-09-03 06:43:02 -05:00
jason.woltje 88d9cf750f feat(sessions): sessionForkFrom - branch conversations from a common ancestor (#33)
- task schema: optional sessionForkFrom (source session name); requires
  session target; self-fork rejected
- runner: resolves source newest .jsonl (fail 4 if none/outside dataRoot);
  passes MOSAIC_SESSION_FORK + MOSAIC_SESSION_DIR; result records lineage
- pi adapter: --fork <source> --session-dir <target> when forking;
  ephemeral default unchanged; plain session resume unchanged
- compose passthrough; RELEASE -> 0.0.7 (adapter changed)
- suite +9 cases (58 total): plumbing via mock stderr, validation
  negatives, live fork - child recalls ancestor code word, ancestor
  session file untouched

Closes #33
2026-09-03 06:43:02 -05:00
jason.woltje c038706eed docs(plan): CURRENT.md - M10 shipped, retention next in review 2026-09-03 06:33:54 -05:00
jason.woltje 8622c9d826 Merge M10: run-record retention
Closes #32
2026-09-03 06:33:27 -05:00
jason.woltje 88eef507b0 feat(retention): run-record pruning - keep newest N, dry-run default (#32)
- mosaic-task.mjs prune [--keep=N] [--yes]: default keep 50; without
  --yes lists candidates without deleting
- only r-* directories under the runs root; symlinks skipped;
  sessions/workspaces/state/config untouched (asserted by suite sentinels)
- append-only receipt runs/.pruned.log records every pruned id
- test-task.sh: +8 retention cases (dry-run no-delete, keep-N, newest
  kept, receipt, isolation, invalid keep, empty no-op)

Also: suite hardening - prune section scopes its config per-command
(no export/unset leaking into later sections); duplicated check()
removed; latest_reason hoisted to helpers; status colors now green OK /
red FAIL (terminal-only, NO_COLOR-aware) per owner UX feedback.

Closes #32
2026-09-03 06:33:27 -05:00
jason.woltje 439bea6915 ui(test): green OK/PASS, red FAIL - terminal-only, NO_COLOR-aware
Owner feedback: grep match-highlighting made the word 'policy' red while
status words were plain - counter-indicative. Suites + verify now emit
ANSI colors (green success, red failure) when stdout is a terminal;
piped/machine-parsed output stays plain, honoring NO_COLOR. Word 'ok'
promoted to 'OK' for scannability.

Verified byte-level via forced-pty run; piped output unchanged; suites
41/24/14 + verify green.
2026-09-03 06:23:47 -05:00
jason.woltje fad8a4718c Merge M9: mission-level capability policy
Closes #30
2026-09-03 06:16:01 -05:00
jason.woltje 2ff49adff4 feat(policy): mission-level capability policy - least-privilege intersection (#30)
- mission schema: optional capabilities.tools (same validation as task)
- merge semantics in runTask: neither -> none; mission only -> mission;
  task only -> task; both -> intersection (task narrows, never widens);
  empty intersection -> tool-free run with an explicit stderr note
- result.json records EFFECTIVE tools; task/mission snapshots remain the
  immutable declaration of intent
- adapters unchanged; host-side only (no image change, 0.0.6 still active)
- task suite +5 cases (41 total): all four merge cases asserted from run
  evidence + invalid mission capabilities rejected

Policy decision recorded: missions govern; tasks cannot escalate.

Closes #30
2026-09-03 06:16:01 -05:00
jason.woltje 44c476ebbf fix(ops): show displays retriedFrom lineage (#29)
result.json recorded lineage correctly; the human-facing show command
omitted the field. Found by owner test: show | grep retriedFrom was
empty on a run whose result.json contained it.

Closes #29
2026-09-03 06:09:51 -05:00
jason.woltje cde480eb60 docs(plan): CURRENT.md — retry lineage shipped, M9 queued for decision 2026-09-03 05:31:24 -05:00
jason.woltje 5808248707 Merge retry lineage + relative mission resolution
Closes #28
2026-09-03 05:30:57 -05:00
jason.woltje afd5827db8 fix(retry): lineage tracking + relative mission path resolution (#28)
- retryRun rewrites a snapshot's relative mission path to the run's own
  recorded mission.json (absolute) before execution — retries stay
  faithful to what originally ran
- runTask accepts options.retriedFrom; retry records lineage in
  result.json (additive optional field, no schema break)
- task suite +4 cases: retry succeeds, lineage recorded, mission section
  present after retry (36 total), missing-run retry exits 4

Closes #28
2026-09-03 05:30:57 -05:00
jason.woltje d9cc990376 Merge M8: conductor loop - self-orchestration
Closes #25, closes #26, closes #27
2026-09-02 22:42:52 -05:00
jason.woltje 83c4e9851e feat(orchestration): retry <runId> — authored by headless pi worker (#26, #27)
Collaboration record (conductor loop, docs/plans/CONDUCTOR.md):
- round 1 (worker session worker-1, 2m28s): retry implemented per spec
- conductor live test exposed spec gap: direct invocation lacked
  launcher env exports
- round 2 (same worker session, 59s): spawnEnv made self-sufficient,
  but used PI_* where compose interpolates MOSAIC_*
- conductor hotfix: 3-line rename to MOSAIC_PROVIDER/MOSAIC_MODEL/
  MOSAIC_DATA_ROOT

Final: node scripts/mosaic-task.mjs retry <runId> re-executes a run's
task snapshot as a new run; live retry replied REMEMBERED; all suites
green (24/32/14 + verify).

Known limitation: retrying a run whose task used a RELATIVE mission path
resolves it against the temp dir; lineage tracking deferred.

Closes #25, closes #26, closes #27
2026-09-02 22:42:52 -05:00
jason.woltje 22508170a2 docs(plan): CURRENT.md — single next-action pointer for cadence-driven work 2026-09-02 22:25:56 -05:00
jason.woltje 90a67d050e Merge M7: operator ergonomics + release 0.0.6
Closes #24
2026-09-02 22:14:18 -05:00
jason.woltje 24bdef75fa feat(ops): run inspection, release 0.0.6, docs (#24)
- mosaic-task.mjs show <runId>: full record + snapshots + artifacts;
  uppercase-tolerant id validation; missing/traversal ids exit 4
- list: task/workspace/session columns
- RELEASE -> 0.0.6; README workspaces/capabilities/sessions sections;
  BUILD-LOG Phases 9-11; autonomous-run tracker results filled

Closes #24
2026-09-02 22:14:18 -05:00
jason.woltje 4e2a413640 Merge M6: named sessions - persistence and resume
Closes #22, closes #23
2026-09-02 22:08:34 -05:00
jason.woltje be55549700 feat(sessions): named persistent sessions with resume (L1) (#22, #23)
- task schema: optional session (named id) -> persistent session dir at
  dataRoot/sessions/<name>, isolated per name
- pi adapter: --session-dir when declared (ephemeral --no-session stays
  the default otherwise); -c resumes the most recent session when present
- compose passthrough; result.json records session
- fixtures: tasks/session-demo-1.json (teach) + session-demo-2.json (recall)
- E2E: teach -> REMEMBERED + host-side session JSONL; resume -> recalled
  'mosaico' exactly; single continued session file

Closes #22, closes #23
2026-09-02 22:08:34 -05:00
jason.woltje ddb1554e5b Merge M5: task workspaces + capability envelope
Closes #20, closes #21
2026-09-02 22:06:43 -05:00
jason.woltje b017e66e17 test(capabilities): workspace/tooling selftests + live demo fixture (#21)
- mock plumbing cases: workspace path + tools delivered (asserted from
  run-record stderr), host workspace created, absent fields = empty vars
- validation negatives: unknown tool, workspace traversal
- tasks/workspace-demo.json: pi uses bash inside the persistent demo
  workspace; host-visible proof.txt verified live

Closes #21
2026-09-02 22:06:43 -05:00
jason.woltje 172368612c feat(capabilities): task workspaces + tools allowlist plumbing (#20)
- task schema: optional workspace (absent | :run ephemeral | named
  persistent under dataRoot/workspaces) and capabilities.tools (pi
  documented tool allowlist); strict validation, traversal-proof names
- runner: creates host workspace, passes MOSAIC_WORKSPACE (container
  path) + MOSAIC_TOOLS; result.json records both
- pi adapter: cds into workspace; --tools when allowlist present else
  --no-tools
- mock adapter: logs delivered MOSAIC_* vars to stderr as deterministic
  plumbing evidence (dash prints 'export K=v', so use env not export)

Closes #20
2026-09-02 22:03:46 -05:00
jason.woltje 1387231e57 docs(plan): autonomous work run tracker (M5-M7 scope, test plan, review checklist) 2026-09-02 21:57:30 -05:00
jason.woltje 0292392e64 Merge M4: runtime adapter seam
Closes #16, closes #17, closes #18, closes #19
2026-09-02 21:30:35 -05:00
jason.woltje 594b8d711c docs(adapters): adapter seam docs + recorded M4 E2E, release 0.0.5 (#19)
- README: Runtime adapters section (contract summary, selection, mission
  injection point); BUILD-LOG Phase 8 entries
- E2E: 24+24+14 selftests green; verify PASS; 0.0.5 packaged and
  health-gated activated; mission-bearing fixture task succeeded through
  the real pi adapter; config checksum unchanged

Closes #19
2026-09-02 21:30:35 -05:00
jason.woltje 3c1ffd2c2d test(adapters): seam selftests — deterministic mock cases + mission injection (#18)
- test-config: absent adapter defaults to pi; mock validates; unknown
  adapter exits 2; env exports MOSAIC_ADAPTER (24 cases total)
- test-task: mock adapter gate pass/mismatch (no provider needed),
  mismatch reason asserted, unknown adapter fails closed, mission section
  injected into generated prompt asserted by content (24 cases total)
- harness fixes: helpers defined before use; per-case config files (no
  cross-case leakage); newest-run selection for the live case; deduped
  accidentally duplicated live block

Closes #18
2026-09-02 21:28:57 -05:00
jason.woltje 4ebb123ba3 feat(adapters): sanctioned mission directives injection (#17)
- load-contracts.sh: MOSAIC_MISSION_FILE (readable) appends a MISSION
  (runtime) section — objective + directives — after the immutable
  contracts; unreadable path is a hard error, absent env changes nothing
- mosaic-task.mjs: exports MOSAIC_MISSION_FILE as the run snapshot's
  container path (/var/lib/mosaic/runs/<id>/mission.json), with an
  outside-dataRoot guard; also exports the configured adapter

Verified: contract-only prompt has no mission section; mission-bearing
run shows objective + directives in the generated prompt, snapshot
recorded, real provider returns exactly MOSAIC_HELLO_OK.

Closes #17
2026-09-02 21:19:22 -05:00
jason.woltje bb5cecb348 feat(adapters): adapter contract, dispatch, pi + mock adapters (#16)
- adapters/README.md: the harness boundary contract (env in, response on
  stdout, diagnostics stderr, exit 0 success)
- adapters/pi: extracted current invocation unchanged
- adapters/mock: deterministic MOSAIC_MOCK_RESPONSE echo (test-only)
- run-agent.sh: name-validated dispatch to adapters/<name>/adapter.sh
- config: optional execution.adapter (pi|mock), default pi, configVersion
  stays 1 — existing configs remain valid; selection authority is the
  config file (load_config exports it)
- compose: MOSAIC_ADAPTER / MOSAIC_MOCK_RESPONSE passthrough; Containerfile
  installs adapters read-only; RELEASE -> 0.0.5

Verified: hello unchanged; mock verbatim via config; unknown adapter and
path-traversal names refused in-container; invalid adapter exits 2.

Closes #16
2026-09-02 21:18:07 -05:00
jason.woltje 88f55d9135 test(task): live failures self-report evidence; wrong-exit no longer masks (#15)
- live hello failure dumps latest run result.json + stderr tail before
  sandbox cleanup destroys them
- wrong-expectExact case asserts reason == expect-mismatch (was: any
  exit 1, which masked compose-level failures)
- repair dangling if/else from the docker-guard refactor

Closes #15
2026-09-02 20:56:58 -05:00
jason.woltje e7e1bd26eb fix(launcher): resolve release identity for direct task runs (#14)
M3 made MOSAIC_IMAGE_TAG required in compose, but run-task.sh never
called load_release — direct task runs failed in compose before any
model call. release.sh paths masked it by exporting the tag to children.

Found by owner-run test-task.sh; failure receipts were in the run
records' stderr.txt.

Closes #14
2026-09-02 20:44:31 -05:00
jason.woltje 5d86b8fa93 Merge M3: release model and safe updates
Closes #10, closes #11, closes #12, closes #13
2026-09-02 20:24:33 -05:00
jason.woltje 35ea464661 docs(release): release model usage + recorded M3 drills (#13)
- README: Release model section (package/activate/rollback/status,
  pointer + append-only log, gate-then-flip guarantee)
- BUILD-LOG Phase 7: drills recorded (update, refusal, rollback),
  two harness/product corrections documented

Drill evidence: 0.0.3 -> 0.0.4 update with unchanged config checksum and
green verify; fault-injected refusal left pointer untouched; health-gated
rollback restored 0.0.3; full append-only event history.

Closes #13
2026-09-02 20:24:33 -05:00
jason.woltje e87ecdb3e5 test(release): release-layer selftests (#12)
14 cases: RELEASE validation (valid/invalid/missing), tag consistency,
status on empty state, fault-injected refusal with no pointer + single
valid refusal log line, healthy activation, pointer fields, repeat
activation append-only log, rollback-without-previous refusal.

Harness fix learned the hard way: restore RELEASE from backup inline
after the missing-file case (mv-back restored the mutated file); single
exit trap self-heals the repo state.

Closes #12
2026-09-02 20:23:18 -05:00
jason.woltje a947db7bfd feat(release): package/activate/rollback/status with health gate (#11)
- activate: image-presence pre-check + M2 task-runner health gate
  (tasks/hello-marker.json exact marker) before atomic pointer replace
  (tmp+rename); every attempt appended to activation-log.jsonl
- --fault-injection flips the health expectation to prove the refusal path
- rollback: health-gated re-activation of the previous activated imageTag
  from the log; refuses when the image is gone or no previous exists
- status: release, tag, pointer, recent log; safe on empty state
- state lives under <dataRoot>/state/ (config-independent, reset-scoped)

Verified: activate OK; fault-injected refuse with pointer unchanged;
rollback-without-previous refuse.

Closes #11
2026-09-02 20:20:32 -05:00
jason.woltje a35ea62ab1 feat(release): RELEASE identity + image tag single-sourcing (#10)
- RELEASE file: single source of release version (0.0.X until declared stable)
- common.sh load_release(): validates version, derives
  MOSAIC_IMAGE_TAG=mosaic-poc-agent:<pi>-r<release> from the pinned pi dep
- compose.yaml: image tag is required env; build/hello/verify call load_release
- verify.sh derives the image name instead of hardcoding it
- package.json version aligned to the same 0.0.X line

Closes #10
2026-09-02 20:18:17 -05:00
jason.woltje f6c93dcb5c Merge M2: mission and task abstraction
Closes #6, closes #7, closes #8, closes #9
2026-09-02 19:58:53 -05:00
jason.woltje 7f0408d417 feat(task): fixture mission/task, docs, recorded M2 E2E (#9)
- missions/hello.json + tasks/hello-marker.json committed fixtures
- README: Missions & tasks section (usage, run record layout, M2 scope note)
- BUILD-LOG: Phase 6 before/after entries

E2E: fixture run succeeded with exactly MOSAIC_HELLO_OK; wrong expectExact
recorded status failed (expect-mismatch) and exited 1; runs listed; config
checksum unchanged.

Closes #9
2026-09-02 19:58:53 -05:00
jason.woltje cfd2a19bd7 test(task): mission/task selftests — schema negatives + live runs (#8)
18 cases: validation negatives (unknown keys, versions, ids, prompt,
expectExact NUL, timeout range, missing/invalid mission, writes-nothing)
plus live cases: exact-marker success, wrong expectExact fails, distinct
run dirs, result.json contents, list output.

Closes #8
2026-09-02 19:57:34 -05:00
jason.woltje d2a9e26395 feat(task): mission/task schemas, validation, and run runner (#6) (#7)
- scripts/mosaic-task.mjs: validate | run | list
- Strict v1 schemas: unknown keys rejected; ids/prompt/expectExact/
  timeoutSeconds bounds enforced; optional mission file resolved against
  the task file and validated too
- run: executes through the config-driven container path with stdin
  detached (issue #5 class), SIGKILL timeout (default 120s), trimmed
  response capture
- Immutable run records under <dataRoot>/runs/r-<utcstamp>-<rand>/:
  task.json + mission.json snapshots (write-once), stderr.txt, result.json
- expectExact gate: mismatch -> status failed, exit 1; result.json is
  always written
- scripts/run-task.sh: load_config + bootstrap_runtime_dir before exec
- M2 scope: mission directives are snapshotted for provenance, not yet
  injected into the runtime prompt (later policy layer)

Closes #6, closes #7
2026-09-02 19:56:29 -05:00
jason.woltje 0734b1f3a5 fix(launcher): detach stdin on agent container run (#5)
pi print mode reads piped stdin until EOF; an attached terminal stdin
blocked the one-shot run forever. Automated contexts (closed stdin)
never exposed it. Request text comes from the compose command.

Proven: tail -f /dev/null | scripts/hello.sh now returns MOSAIC_HELLO_OK
in ~4s (previously timed out at 30s); verify.sh remains green.

Closes #5
2026-09-02 19:50:07 -05:00
jason.woltje ce6420f3de Merge M1: configuration-driven Hello World
Closes #1, closes #2, closes #3, closes #4
2026-09-02 18:32:50 -05:00
jason.woltje 81f58b15c8 fix(launcher): ensure configured data root before verify mount; docs for M1 (#4)
- verify.sh now calls bootstrap_runtime_dir after load_config; previously a
  reset-then-verify flow let Docker auto-create a root-owned mount source
- common.sh: fail with clear guidance when data root exists but is not writable
- README: configuration section, bootstrap usage, selftest entry point
- BUILD-LOG: Phase 5 entries with corrections

E2E (clean slate): 20/20 selftests; bootstrap idempotent; config-driven
hello/verify MOSAIC_HELLO_OK; negative marker exit 1; reset + rerun green;
config checksum unchanged across the entire flow.

Closes #4
2026-09-02 18:32:50 -05:00
jason.woltje 0c2113f710 test(config): sandboxed config-layer selftests (#3)
20 cases: bootstrap create/idempotency, missing config, malformed JSON,
unknown keys/version/backend/environment, relative and non-canonical
dataRoot, filesystem root, home dir, ancestor-of-config, control chars,
symlinked config file, env export resolution, validation-writes-nothing.

Closes #3
2026-09-02 18:30:46 -05:00
jason.woltje 900a506c1f feat(config): wire launcher scripts and compose to config.json (#2)
- common.sh: load_config() exports MOSAIC_DATA_ROOT/PROVIDER/MODEL; fails closed
- compose.yaml: dataRoot mount and provider/model are required env (:? errors)
- build/hello/verify load config before any mutation; no silent bootstrap
- reset.sh: target resolved from configured dataRoot; all safety checks kept

Verified: compose fails without launcher env; verify/reset fail on missing
config; config-driven hello+verify pass; symlink refusal with sandboxed
config (canary survived); config checksum unchanged across reset+rerun.

Closes #2
2026-09-02 18:30:08 -05:00
jason.woltje c3d29e796a feat(config): config module with idempotent bootstrap and strict v1 validation (#1)
- scripts/mosaic-config.mjs: bootstrap | validate | env operations
- Exclusive creation (O_EXCL 'wx'); existing config validated, never rewritten
- Strict schema: unknown keys rejected, configVersion===1, backend docker only
- dataRoot guards: absolute, canonical, not root/home/ancestor-of-config
- MOSAIC_CONFIG override for sandboxed tests; exit codes 0/2/3
- scripts/bootstrap.sh: explicit bootstrap entry point

Closes #1
2026-09-02 18:28:14 -05:00
jason.woltje c2365ae519 chore: baseline container POC and atomic foundation plan
- Containerized Pi hello-world proof (image mosaic-poc-agent:0.84.4, non-root)
- Four immutable contract fixtures loaded into a generated system prompt
- build/hello/verify/reset scripts with exact-match gating and reset safety
- Documented Pi discovery (v0.84.4, -p mode, --system-prompt, container auth)
- Append-only BUILD-LOG with corrections; deferred layers in LAYERS.md
- Architecture plan: docs/plans/2026-09-02_atomic-mosaic-foundation.md
2026-09-02 18:24:36 -05:00
1801 changed files with 5882 additions and 352654 deletions
-58
View File
@@ -1,58 +0,0 @@
# Dependencies (installed fresh in Docker)
node_modules
**/node_modules
# Build outputs (built fresh in Docker)
dist
**/dist
.next
**/.next
# TurboRepo cache
.turbo
**/.turbo
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment files
.env
.env.*
!.env.example
# Credentials
.admin-credentials
# Testing
coverage
**/coverage
# Logs
*.log
# Misc
*.tsbuildinfo
**/*.tsbuildinfo
.pnpm-approve-builds
.husky/_
# Git
.git
.gitignore
# Docker
Dockerfile*
docker-compose*.yml
.dockerignore
# Documentation (not needed in container)
docs
*.md
!README.md
+19 -496
View File
@@ -1,501 +1,24 @@
# ==============================================
# Mosaic Stack Environment Configuration
# ==============================================
# Copy this file to .env and customize for your environment
# ======================
# Application Ports
# ======================
API_PORT=3001
API_HOST=0.0.0.0
WEB_PORT=3000
# ======================
# Web Configuration
# ======================
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3001
# Frontend auth mode:
# - real: Normal auth/session flow
# - mock: Local-only seeded user for FE development (blocked outside NODE_ENV=development)
# Use `mock` locally to continue FE work when auth flow is unstable.
# If omitted, web runtime defaults:
# - development -> mock
# - production -> real
NEXT_PUBLIC_AUTH_MODE=real
# ======================
# PostgreSQL Database
# ======================
# Bundled PostgreSQL
# SECURITY: Change POSTGRES_PASSWORD to a strong random password in production
DATABASE_URL=postgresql://mosaic:REPLACE_WITH_SECURE_PASSWORD@postgres:5432/mosaic
POSTGRES_USER=mosaic
POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
POSTGRES_DB=mosaic
POSTGRES_PORT=5432
# External PostgreSQL (managed service)
# To use an external instance, update DATABASE_URL above
# Example: DATABASE_URL=postgresql://user:[email protected]:5432/mosaic
# PostgreSQL Performance Tuning (Optional)
POSTGRES_SHARED_BUFFERS=256MB
POSTGRES_EFFECTIVE_CACHE_SIZE=1GB
POSTGRES_MAX_CONNECTIONS=100
# ======================
# Valkey Cache (Redis-compatible)
# ======================
# Bundled Valkey
VALKEY_URL=redis://valkey:6379
VALKEY_HOST=valkey
VALKEY_PORT=6379
# VALKEY_PASSWORD= # Optional: Password for Valkey authentication
VALKEY_MAXMEMORY=256mb
# External Redis/Valkey (managed service)
# To use an external instance, update VALKEY_URL above
# Example: VALKEY_URL=redis://elasticache.amazonaws.com:6379
# Example with auth: VALKEY_URL=redis://:[email protected]:6379
# Knowledge Module Cache Configuration
# Set KNOWLEDGE_CACHE_ENABLED=false to disable caching (useful for development)
KNOWLEDGE_CACHE_ENABLED=true
# Cache TTL in seconds (default: 300 = 5 minutes)
KNOWLEDGE_CACHE_TTL=300
# ======================
# Authentication (Authentik OIDC)
# ======================
# Set to 'true' to enable OIDC authentication with Authentik
# When enabled, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URI are required
OIDC_ENABLED=false
# Authentik Server URLs (required when OIDC_ENABLED=true)
# OIDC_ISSUER must end with a trailing slash (/)
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
# Redirect URI must match what's configured in Authentik
# Development: http://localhost:3001/auth/oauth2/callback/authentik
# Production: https://mosaic-api.woltje.com/auth/oauth2/callback/authentik
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
# Authentik PostgreSQL Database
AUTHENTIK_POSTGRES_USER=authentik
AUTHENTIK_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_POSTGRES_DB=authentik
# Authentik Configuration
# CRITICAL: Generate a random secret key with at least 50 characters
# Example: openssl rand -base64 50
AUTHENTIK_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_MINIMUM_50_CHARS
AUTHENTIK_ERROR_REPORTING=false
# SECURITY: Change bootstrap password immediately after first login
AUTHENTIK_BOOTSTRAP_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
AUTHENTIK_COOKIE_DOMAIN=.localhost
# Authentik Ports
AUTHENTIK_PORT_HTTP=9000
AUTHENTIK_PORT_HTTPS=9443
# ======================
# CSRF Protection
# ======================
# CRITICAL: Generate a random secret for CSRF token signing
# Required in production; auto-generated in development (not persistent across restarts)
# Command to generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
CSRF_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING
# ======================
# JWT Configuration
# ======================
# CRITICAL: Generate a random secret key with at least 32 characters
# Example: openssl rand -base64 32
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
JWT_EXPIRATION=24h
# ======================
# BetterAuth Configuration
# ======================
# CRITICAL: Generate a random secret key with at least 32 characters
# This is used by BetterAuth for session management and CSRF protection
# Example: openssl rand -base64 32
BETTER_AUTH_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
# Optional explicit BetterAuth origin for callback/error URL generation.
# When empty, backend falls back to NEXT_PUBLIC_API_URL.
BETTER_AUTH_URL=
# Trusted Origins (comma-separated list of additional trusted origins for CORS and auth)
# These are added to NEXT_PUBLIC_APP_URL and NEXT_PUBLIC_API_URL automatically
TRUSTED_ORIGINS=
# Cookie Domain (for cross-subdomain session sharing)
# Leave empty for single-domain setups. Set to ".example.com" for cross-subdomain.
COOKIE_DOMAIN=
# ======================
# Encryption (Credential Security)
# ======================
# CRITICAL: Generate a random 32-byte (256-bit) encryption key
# This key is used for AES-256-GCM encryption of OAuth tokens and sensitive data
# Command to generate: openssl rand -hex 32
# SECURITY: Never commit this key to version control
# SECURITY: Use different keys for development, staging, and production
# SECURITY: Store production keys in a secure secrets manager (see docs/design/credential-security.md)
ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_GENERATE_WITH_OPENSSL_RAND_HEX_32
# ======================
# OpenBao Secrets Management
# ======================
# OpenBao provides Transit encryption for sensitive credentials
# Enable with: COMPOSE_PROFILES=openbao or COMPOSE_PROFILES=full
# Auto-initialized on first run via openbao-init sidecar
# Bundled OpenBao (when openbao profile enabled)
OPENBAO_ADDR=http://openbao:8200
OPENBAO_PORT=8200
# External OpenBao/Vault (managed service)
# Disable 'openbao' profile and set OPENBAO_ADDR to your external instance
# Example: OPENBAO_ADDR=https://vault.example.com:8200
# Example: OPENBAO_ADDR=https://vault.hashicorp.com:8200
# AppRole Authentication (Optional)
# If not set, credentials are read from /openbao/init/approle-credentials volume
# Required when using external OpenBao
# OPENBAO_ROLE_ID=your-role-id-here
# OPENBAO_SECRET_ID=your-secret-id-here
# Fallback Mode
# When OpenBao is unavailable, API automatically falls back to AES-256-GCM
# encryption using ENCRYPTION_KEY. This provides graceful degradation.
# ======================
# Ollama (Optional AI Service)
# ======================
# Set OLLAMA_ENDPOINT to use local or remote Ollama
# For bundled Docker service: http://ollama:11434
# For external service: http://your-ollama-server:11434
OLLAMA_ENDPOINT=http://ollama:11434
OLLAMA_PORT=11434
# Embedding Model Configuration
# Model used for generating knowledge entry embeddings
# Default: mxbai-embed-large (1024-dim, padded to 1536)
# Alternative: nomic-embed-text (768-dim, padded to 1536)
# Note: Embeddings are padded/truncated to 1536 dimensions to match schema
OLLAMA_EMBEDDING_MODEL=mxbai-embed-large
# Semantic Search Configuration
# Similarity threshold for semantic search (0.0 to 1.0, where 1.0 is identical)
# Lower values return more results but may be less relevant
# Default: 0.5 (50% similarity)
SEMANTIC_SEARCH_SIMILARITY_THRESHOLD=0.5
# ======================
# OpenAI API (For Semantic Search)
# ======================
# OPTIONAL: Semantic search requires an OpenAI API key
# Get your API key from: https://platform.openai.com/api-keys
# If not configured, semantic search endpoints will return an error
# OPENAI_API_KEY=sk-...
# ======================
# Application Environment
# ======================
NODE_ENV=development
# ======================
# Docker Image Configuration
# ======================
# Docker image tag for pulling pre-built images from git.mosaicstack.dev registry
# Used by docker-compose.yml (pulls images) and docker-swarm.yml
# For local builds, use docker-compose.build.yml instead
# Options:
# - latest: Pull latest images from registry (default, built from main branch)
# - <version>: Use specific version tag (e.g., v1.0.0)
IMAGE_TAG=latest
# ======================
# Docker Compose Profiles
# ======================
# Enable optional services via profiles. Combine multiple profiles with commas.
# Non-secret runtime settings for the mosaic-poc-agent container.
# Copy to .env if you want to override the defaults in compose.yaml.
#
# Available profiles:
# - database: PostgreSQL database (disable to use external database)
# - cache: Valkey cache (disable to use external Redis)
# - openbao: OpenBao secrets management (disable to use external vault or fallback encryption)
# - authentik: Authentik OIDC authentication (disable to use external auth provider)
# - ollama: Ollama AI/LLM service (disable to use external LLM service)
# - traefik-bundled: Bundled Traefik reverse proxy (disable to use external proxy)
# - full: Enable all optional services (turnkey deployment)
#
# Examples:
# COMPOSE_PROFILES=full # Everything bundled (development)
# COMPOSE_PROFILES=database,cache,openbao # Core services only
# COMPOSE_PROFILES= # All external services (production)
COMPOSE_PROFILES=full
# NEVER put credentials in this file. Authentication is supplied at
# runtime only, via one of the two documented paths:
# 1. read-only mounted pi auth file (default: ~/.pi/agent/auth.json,
# override the host path with PI_AUTH_FILE)
# 2. provider API key environment variable (ZAI_API_KEY or
# ANTHROPIC_API_KEY), passed through by compose.yaml when set
# ======================
# Traefik Reverse Proxy
# ======================
# TRAEFIK_MODE options:
# - bundled: Use bundled Traefik (requires traefik-bundled profile)
# - upstream: Connect to external Traefik instance
# - none: Direct port exposure without reverse proxy (default)
TRAEFIK_MODE=none
# Model provider (built-in pi provider name)
PI_PROVIDER=zai
# Domain configuration for Traefik routing
MOSAIC_API_DOMAIN=api.mosaic.local
MOSAIC_WEB_DOMAIN=mosaic.local
MOSAIC_AUTH_DOMAIN=auth.mosaic.local
# Model ID within the provider
PI_MODEL=glm-5.3-flash
# External Traefik network name (for upstream mode and swarm)
# Must match the network name of your existing Traefik instance
TRAEFIK_NETWORK=traefik-public
TRAEFIK_DOCKER_NETWORK=traefik-public
# Optional: alternative host path of the pi credential file mounted
# read-only at /home/node/.pi/agent/auth.json in the container
#PI_AUTH_FILE=/home/jwoltje/.pi/agent/auth.json
# TLS/SSL Configuration
TRAEFIK_TLS_ENABLED=true
TRAEFIK_ENTRYPOINT=websecure
# Cert resolver name (leave empty if TLS is handled externally or using self-signed certs)
TRAEFIK_CERTRESOLVER=
# For Let's Encrypt (production):
TRAEFIK_ACME_EMAIL=[email protected]
# For self-signed certificates (development), leave TRAEFIK_ACME_EMAIL empty
# Traefik Dashboard (bundled mode only)
TRAEFIK_DASHBOARD_ENABLED=true
TRAEFIK_DASHBOARD_PORT=8080
# ======================
# Gitea Integration (Coordinator)
# ======================
# Gitea instance URL
GITEA_URL=https://git.mosaicstack.dev
# Coordinator bot credentials (see docs/1-getting-started/3-configuration/4-gitea-coordinator.md)
# SECURITY: Store GITEA_BOT_TOKEN in secrets vault, not in version control
GITEA_BOT_USERNAME=mosaic
GITEA_BOT_TOKEN=REPLACE_WITH_COORDINATOR_BOT_API_TOKEN
GITEA_BOT_PASSWORD=REPLACE_WITH_COORDINATOR_BOT_PASSWORD
# Repository configuration
GITEA_REPO_OWNER=mosaic
GITEA_REPO_NAME=stack
# Webhook secret for coordinator (HMAC SHA256 signature verification)
# SECURITY: Generate random secret with: openssl rand -hex 32
# Configure in Gitea: Repository Settings → Webhooks → Add Webhook
GITEA_WEBHOOK_SECRET=REPLACE_WITH_RANDOM_WEBHOOK_SECRET
# Coordinator API Key (service-to-service authentication)
# CRITICAL: Generate a random API key with at least 32 characters
# Example: openssl rand -base64 32
# The coordinator service uses this key to authenticate with the API
COORDINATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS
# Anthropic API Key (used by coordinator for issue parsing)
# Get your API key from: https://console.anthropic.com/
ANTHROPIC_API_KEY=REPLACE_WITH_ANTHROPIC_API_KEY
# Coordinator tuning
COORDINATOR_POLL_INTERVAL=5.0
COORDINATOR_MAX_CONCURRENT_AGENTS=10
COORDINATOR_ENABLED=true
# ======================
# Rate Limiting
# ======================
# Rate limiting prevents DoS attacks on webhook and API endpoints
# TTL is in seconds, limits are per TTL window
# Global rate limit (applies to all endpoints unless overridden)
# Time window in seconds
RATE_LIMIT_TTL=60
# Requests per window
RATE_LIMIT_GLOBAL_LIMIT=100
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute
RATE_LIMIT_WEBHOOK_LIMIT=60
# Coordinator endpoints (/coordinator/*) — requests per minute
RATE_LIMIT_COORDINATOR_LIMIT=100
# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring)
RATE_LIMIT_HEALTH_LIMIT=300
# Storage backend for rate limiting (redis or memory)
# redis: Uses Valkey for distributed rate limiting (recommended for production)
# memory: Uses in-memory storage (single instance only, for development)
RATE_LIMIT_STORAGE=redis
# ======================
# Discord Bridge (Optional)
# ======================
# Discord bot integration for chat-based control
# Get bot token from: https://discord.com/developers/applications
# DISCORD_BOT_TOKEN=your-discord-bot-token-here
# DISCORD_GUILD_ID=your-discord-server-id
# DISCORD_CONTROL_CHANNEL_ID=channel-id-for-commands
# DISCORD_WORKSPACE_ID=your-workspace-uuid
#
# SECURITY: DISCORD_WORKSPACE_ID must be a valid workspace UUID from your database.
# All Discord commands will execute within this workspace context for proper
# multi-tenant isolation. Each Discord bot instance should be configured for
# a single workspace.
# ======================
# Matrix Bridge (Optional)
# ======================
# Matrix bot integration for chat-based control via Matrix protocol
# Requires a Matrix account with an access token for the bot user
# Set these AFTER deploying Synapse and creating the bot account.
#
# SECURITY: MATRIX_WORKSPACE_ID must be a valid workspace UUID from your database.
# All Matrix commands will execute within this workspace context for proper
# multi-tenant isolation. Each Matrix bot instance should be configured for
# a single workspace.
MATRIX_HOMESERVER_URL=http://synapse:8008
MATRIX_ACCESS_TOKEN=
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
MATRIX_SERVER_NAME=matrix.woltje.com
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
# MATRIX_WORKSPACE_ID=your-workspace-uuid
# ======================
# Matrix / Synapse Deployment
# ======================
# Domains for Traefik routing to Matrix services
MATRIX_DOMAIN=matrix.woltje.com
ELEMENT_DOMAIN=chat.woltje.com
# Synapse database (created automatically by synapse-db-init in the swarm compose)
SYNAPSE_POSTGRES_DB=synapse
SYNAPSE_POSTGRES_USER=synapse
SYNAPSE_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_SYNAPSE_DB_PASSWORD
# Image tags for Matrix services
SYNAPSE_IMAGE_TAG=latest
ELEMENT_IMAGE_TAG=latest
# ======================
# Orchestrator Configuration
# ======================
# API Key for orchestrator agent management endpoints
# CRITICAL: Generate a random API key with at least 32 characters
# Example: openssl rand -base64 32
# Required for all /agents/* endpoints (spawn, kill, kill-all, status)
# Health endpoints (/health/*) remain unauthenticated
ORCHESTRATOR_API_KEY=REPLACE_WITH_RANDOM_API_KEY_MINIMUM_32_CHARS
# Runtime safety defaults (recommended for low-memory hosts)
MAX_CONCURRENT_AGENTS=2
SESSION_CLEANUP_DELAY_MS=30000
ORCHESTRATOR_QUEUE_NAME=orchestrator-tasks
ORCHESTRATOR_QUEUE_CONCURRENCY=1
ORCHESTRATOR_QUEUE_MAX_RETRIES=3
ORCHESTRATOR_QUEUE_BASE_DELAY_MS=1000
ORCHESTRATOR_QUEUE_MAX_DELAY_MS=60000
SANDBOX_DEFAULT_MEMORY_MB=256
SANDBOX_DEFAULT_CPU_LIMIT=1.0
# ======================
# AI Provider Configuration
# ======================
# Choose the AI provider for orchestrator agents
# Options: ollama, claude, openai
# Default: ollama (no API key required)
AI_PROVIDER=ollama
# Ollama Configuration (when AI_PROVIDER=ollama)
# For local Ollama: http://localhost:11434
# For remote Ollama: http://your-ollama-server:11434
OLLAMA_MODEL=llama3.1:latest
# Claude API Key
# Required only when AI_PROVIDER=claude.
# Get your API key from: https://console.anthropic.com/
CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY
# OpenAI API Configuration (when AI_PROVIDER=openai)
# OPTIONAL: Only required if AI_PROVIDER=openai
# Get your API key from: https://platform.openai.com/api-keys
# OPENAI_API_KEY=sk-...
# ======================
# Speech Services (STT / TTS)
# ======================
# Speech-to-Text (STT) - Whisper via Speaches
# Set STT_ENABLED=true to enable speech-to-text transcription
# STT_BASE_URL is required when STT_ENABLED=true
STT_ENABLED=true
STT_BASE_URL=http://speaches:8000/v1
STT_MODEL=Systran/faster-whisper-large-v3-turbo
STT_LANGUAGE=en
# Text-to-Speech (TTS) - Default Engine (Kokoro)
# Set TTS_ENABLED=true to enable text-to-speech synthesis
# TTS_DEFAULT_URL is required when TTS_ENABLED=true
TTS_ENABLED=true
TTS_DEFAULT_URL=http://kokoro-tts:8880/v1
TTS_DEFAULT_VOICE=af_heart
TTS_DEFAULT_FORMAT=mp3
# Text-to-Speech (TTS) - Premium Engine (Chatterbox) - Optional
# Higher quality voice cloning engine, disabled by default
# TTS_PREMIUM_URL is required when TTS_PREMIUM_ENABLED=true
TTS_PREMIUM_ENABLED=false
TTS_PREMIUM_URL=http://chatterbox-tts:8881/v1
# Text-to-Speech (TTS) - Fallback Engine (Piper/OpenedAI) - Optional
# Lightweight fallback engine, disabled by default
# TTS_FALLBACK_URL is required when TTS_FALLBACK_ENABLED=true
TTS_FALLBACK_ENABLED=false
TTS_FALLBACK_URL=http://openedai-speech:8000/v1
# Whisper model for Speaches STT engine
SPEACHES_WHISPER_MODEL=Systran/faster-whisper-large-v3-turbo
# Speech Service Limits
# Maximum upload file size in bytes (default: 25MB)
SPEECH_MAX_UPLOAD_SIZE=25000000
# Maximum audio duration in seconds (default: 600 = 10 minutes)
SPEECH_MAX_DURATION_SECONDS=600
# Maximum text length for TTS in characters (default: 4096)
SPEECH_MAX_TEXT_LENGTH=4096
# ======================
# Mosaic Telemetry (Task Completion Tracking & Predictions)
# ======================
# Telemetry tracks task completion patterns to provide time estimates and predictions.
# Data is sent to the Mosaic Telemetry API (a separate service).
# Master switch: set to false to completely disable telemetry (no HTTP calls will be made)
MOSAIC_TELEMETRY_ENABLED=true
# URL of the telemetry API server
# For Docker Compose (internal): http://telemetry-api:8000
# For production/swarm: https://tel-api.mosaicstack.dev
MOSAIC_TELEMETRY_SERVER_URL=http://telemetry-api:8000
# API key for authenticating with the telemetry server
# Generate with: openssl rand -hex 32
MOSAIC_TELEMETRY_API_KEY=your-64-char-hex-api-key-here
# Unique identifier for this Mosaic Stack instance
# Generate with: uuidgen or python -c "import uuid; print(uuid.uuid4())"
MOSAIC_TELEMETRY_INSTANCE_ID=your-instance-uuid-here
# Dry run mode: set to true to log telemetry events to console instead of sending HTTP requests
# Useful for development and debugging telemetry payloads
MOSAIC_TELEMETRY_DRY_RUN=false
# ======================
# Logging & Debugging
# ======================
LOG_LEVEL=info
DEBUG=false
# Optional: documented env-var auth alternative (secret! set in your
# shell or a gitignored .env, never commit)
#ZAI_API_KEY=
#ANTHROPIC_API_KEY=
-85
View File
@@ -1,85 +0,0 @@
# Traefik Bundled Mode Configuration
# Copy this to .env to enable bundled Traefik reverse proxy
#
# Usage:
# cp .env.traefik-bundled.example .env
# docker compose --profile traefik-bundled up -d
# ======================
# Traefik Configuration
# ======================
TRAEFIK_MODE=bundled
TRAEFIK_ENABLE=true
TRAEFIK_ENTRYPOINT=websecure
TRAEFIK_DOCKER_NETWORK=mosaic-public
# Domain configuration
MOSAIC_API_DOMAIN=api.mosaic.local
MOSAIC_WEB_DOMAIN=mosaic.local
MOSAIC_AUTH_DOMAIN=auth.mosaic.local
# TLS/SSL Configuration
TRAEFIK_TLS_ENABLED=true
# For Let's Encrypt (production):
# [email protected]
# TRAEFIK_CERTRESOLVER=letsencrypt
# For self-signed certificates (development), leave TRAEFIK_ACME_EMAIL empty
TRAEFIK_ACME_EMAIL=
# Traefik Dashboard
TRAEFIK_DASHBOARD_ENABLED=true
TRAEFIK_DASHBOARD_PORT=8080
# Traefik Ports
TRAEFIK_HTTP_PORT=80
TRAEFIK_HTTPS_PORT=443
# ======================
# Application Ports (not exposed when using Traefik)
# ======================
API_PORT=3001
WEB_PORT=3000
# ======================
# PostgreSQL Database
# ======================
POSTGRES_USER=mosaic
POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
POSTGRES_DB=mosaic
POSTGRES_PORT=5432
# ======================
# Valkey Cache
# ======================
VALKEY_PORT=6379
VALKEY_MAXMEMORY=256mb
# ======================
# Authentication (Authentik OIDC)
# ======================
OIDC_ISSUER=https://auth.mosaic.local/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
OIDC_REDIRECT_URI=https://api.mosaic.local/auth/callback
# Authentik Configuration
AUTHENTIK_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_MINIMUM_50_CHARS
AUTHENTIK_BOOTSTRAP_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
AUTHENTIK_COOKIE_DOMAIN=.mosaic.local
AUTHENTIK_POSTGRES_USER=authentik
AUTHENTIK_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_POSTGRES_DB=authentik
# ======================
# JWT Configuration
# ======================
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
JWT_EXPIRATION=24h
# ======================
# Docker Compose Profiles
# ======================
# Enable bundled Traefik and optional services
COMPOSE_PROFILES=traefik-bundled,authentik
-83
View File
@@ -1,83 +0,0 @@
# Traefik Upstream Mode Configuration
# Connect to an existing external Traefik instance
#
# Prerequisites:
# 1. External Traefik instance must be running
# 2. External network must exist: docker network create traefik-public
# 3. Copy docker-compose.override.yml.example to docker-compose.override.yml
# 4. Uncomment upstream mode network configuration in override file
#
# Usage:
# cp .env.traefik-upstream.example .env
# docker compose up -d
# ======================
# Traefik Configuration
# ======================
TRAEFIK_MODE=upstream
TRAEFIK_ENABLE=true
TRAEFIK_ENTRYPOINT=websecure
TRAEFIK_DOCKER_NETWORK=traefik-public
TRAEFIK_NETWORK=traefik-public
# Domain configuration
# These domains must be configured in your DNS or /etc/hosts
MOSAIC_API_DOMAIN=api.mosaic.uscllc.com
MOSAIC_WEB_DOMAIN=mosaic.uscllc.com
MOSAIC_AUTH_DOMAIN=auth.mosaic.uscllc.com
# TLS/SSL Configuration
TRAEFIK_TLS_ENABLED=true
# ACME/Certresolver managed by upstream Traefik
TRAEFIK_CERTRESOLVER=
# ======================
# Application Ports (not exposed when using Traefik)
# ======================
# These ports are only used internally within Docker network
API_PORT=3001
WEB_PORT=3000
# ======================
# PostgreSQL Database
# ======================
POSTGRES_USER=mosaic
POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
POSTGRES_DB=mosaic
POSTGRES_PORT=5432
# ======================
# Valkey Cache
# ======================
VALKEY_PORT=6379
VALKEY_MAXMEMORY=256mb
# ======================
# Authentication (Authentik OIDC)
# ======================
OIDC_ISSUER=https://auth.mosaic.uscllc.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
OIDC_REDIRECT_URI=https://api.mosaic.uscllc.com/auth/callback
# Authentik Configuration
AUTHENTIK_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_MINIMUM_50_CHARS
AUTHENTIK_BOOTSTRAP_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
AUTHENTIK_COOKIE_DOMAIN=.mosaic.uscllc.com
AUTHENTIK_POSTGRES_USER=authentik
AUTHENTIK_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
AUTHENTIK_POSTGRES_DB=authentik
# ======================
# JWT Configuration
# ======================
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
JWT_EXPIRATION=24h
# ======================
# Docker Compose Profiles
# ======================
# Enable optional services (do NOT enable traefik-bundled in upstream mode)
COMPOSE_PROFILES=authentik
+5 -68
View File
@@ -1,71 +1,8 @@
# Dependencies
node_modules
.pnpm-store
# build/deps
node_modules/
# Build outputs
dist
.next
.turbo
# Compiled source (prevent accidental commits)
apps/*/src/**/*.js
apps/*/src/**/*.d.ts
apps/*/src/**/*.js.map
apps/*/src/**/*.d.ts.map
packages/*/src/**/*.js
packages/*/src/**/*.d.ts
packages/*/src/**/*.js.map
packages/*/src/**/*.d.ts.map
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Environment
# runtime credentials — never commit, never copy into the image
.env
.env.local
.env.test
.env.development.local
.env.test.local
.env.production.local
.env.bak.*
*.bak
secrets/
# Credentials (never commit)
.admin-credentials
# Testing
coverage
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
*.tsbuildinfo
.pnpm-approve-builds
# Husky
.husky/_
# Orchestrator reports (generated by QA automation, cleaned up after processing)
docs/reports/qa-automation/
# Repo-local orchestrator runtime artifacts
.mosaic/orchestrator/orchestrator.pid
.mosaic/orchestrator/state.json
.mosaic/orchestrator/tasks.json
.mosaic/orchestrator/matrix_state.json
.mosaic/orchestrator/logs/*.log
.mosaic/orchestrator/results/*
!.mosaic/orchestrator/logs/.gitkeep
!.mosaic/orchestrator/results/.gitkeep
# generated runtime state lives in /home/jwoltje/.mosaic-dev (outside this project)
-2
View File
@@ -1,2 +0,0 @@
npx lint-staged
npx git-secrets --scan || echo "Warning: git-secrets not installed"
-48
View File
@@ -1,48 +0,0 @@
// Monorepo-aware lint-staged configuration
// STRICT ENFORCEMENT ENABLED: Blocks commits if affected packages have violations
//
// IMPORTANT: This lints ENTIRE packages, not just changed files.
// If you touch ANY file in a package with violations, you must fix the whole package.
// This forces incremental cleanup - work in a package = clean up that package.
//
export default {
// TypeScript files - lint and typecheck affected packages
'**/*.{ts,tsx}': (filenames) => {
const commands = [];
// 1. Format first (auto-fixes what it can)
commands.push(`prettier --write ${filenames.join(' ')}`);
// 2. Extract affected packages from absolute paths
// lint-staged passes absolute paths, so we need to extract the relative part
const packages = [...new Set(filenames.map(f => {
// Match either absolute or relative paths: .../packages/shared/... or packages/shared/...
const match = f.match(/(?:^|\/)(apps|packages)\/([^/]+)\//);
if (!match) return null;
// Return package name format for turbo (e.g., "@mosaic/api")
return `@mosaic/${match[2]}`;
}))].filter(Boolean);
if (packages.length === 0) {
return commands;
}
// 3. Lint entire affected packages via turbo
// --max-warnings=0 means ANY warning/error blocks the commit
packages.forEach(pkg => {
commands.push(`pnpm turbo run lint --filter=${pkg} -- --max-warnings=0`);
});
// 4. Type-check affected packages
packages.forEach(pkg => {
commands.push(`pnpm turbo run typecheck --filter=${pkg}`);
});
return commands;
},
// Format all other files
'**/*.{js,jsx,json,md,yml,yaml}': [
'prettier --write',
],
};
-15
View File
@@ -1,15 +0,0 @@
# Repo Mosaic Linkage
This repository is attached to the machine-wide Mosaic framework.
## Load Order for Agents
1. `~/.config/mosaic/STANDARDS.md`
2. `AGENTS.md` (this repository)
3. `.mosaic/repo-hooks.sh` (repo-specific automation hooks)
## Purpose
- Keep universal standards in `~/.config/mosaic`
- Keep repo-specific behavior in this repo
- Avoid copying large runtime configs into each project
-18
View File
@@ -1,18 +0,0 @@
{
"enabled": true,
"transport": "matrix",
"matrix": {
"control_room_id": "",
"workspace_id": "",
"homeserver_url": "",
"access_token": "",
"bot_user_id": ""
},
"worker": {
"runtime": "codex",
"command_template": "bash scripts/agent/orchestrator-worker.sh {task_file}",
"timeout_seconds": 7200,
"max_attempts": 1
},
"quality_gates": ["pnpm lint", "pnpm typecheck", "pnpm test"]
}
-1
View File
@@ -1 +0,0 @@
-80
View File
@@ -1,80 +0,0 @@
{
"schema_version": 1,
"mission_id": "ms21-multi-tenant-rbac-data-migration-20260228",
"name": "MS21 Multi-Tenant RBAC Data Migration",
"description": "Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack",
"project_path": "/home/jwoltje/src/mosaic-stack",
"created_at": "2026-02-28T17:10:22Z",
"status": "active",
"task_prefix": "MS21",
"quality_gates": "pnpm lint && pnpm build && pnpm test",
"milestone_version": "0.0.21",
"milestones": [
{
"id": "phase-1",
"name": "Schema and Admin API",
"status": "pending",
"branch": "schema-and-admin-api",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-2",
"name": "Break-Glass Authentication",
"status": "pending",
"branch": "break-glass-authentication",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-3",
"name": "Data Migration",
"status": "pending",
"branch": "data-migration",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-4",
"name": "Admin UI",
"status": "pending",
"branch": "admin-ui",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-5",
"name": "RBAC UI Enforcement",
"status": "pending",
"branch": "rbac-ui-enforcement",
"issue_ref": "",
"started_at": "",
"completed_at": ""
},
{
"id": "phase-6",
"name": "Verification",
"status": "pending",
"branch": "verification",
"issue_ref": "",
"started_at": "",
"completed_at": ""
}
],
"sessions": [
{
"session_id": "sess-001",
"runtime": "unknown",
"started_at": "2026-02-28T17:48:51Z",
"ended_at": "",
"ended_reason": "",
"milestone_at_end": "",
"tasks_completed": [],
"last_task_id": ""
}
]
}
-1
View File
@@ -1 +0,0 @@
-8
View File
@@ -1,8 +0,0 @@
{
"session_id": "sess-001",
"runtime": "unknown",
"pid": 2396592,
"started_at": "2026-02-28T17:48:51Z",
"project_path": "/tmp/ms21-api-003",
"milestone_id": ""
}
-10
View File
@@ -1,10 +0,0 @@
enabled: false
template: ""
# Set enabled: true and choose one template:
# - typescript-node
# - typescript-nextjs
# - monorepo
#
# Apply manually:
# ~/.config/mosaic/bin/mosaic-quality-apply --template <template> --target <repo>
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
# Repo-specific hooks used by scripts/agent/*.sh for Mosaic Stack.
mosaic_hook_session_start() {
echo "[mosaic-stack] Branch: $(git rev-parse --abbrev-ref HEAD)"
echo "[mosaic-stack] Remotes:"
git remote -v | sed 's/^/[mosaic-stack] /'
if command -v node >/dev/null 2>&1; then
echo "[mosaic-stack] Node: $(node -v)"
fi
if command -v pnpm >/dev/null 2>&1; then
echo "[mosaic-stack] pnpm: $(pnpm -v)"
fi
}
mosaic_hook_critical() {
echo "[mosaic-stack] Recent commits:"
git log --oneline --decorate -n 5 | sed 's/^/[mosaic-stack] /'
echo "[mosaic-stack] Open TODO/FIXME markers (top 20):"
rg -n "(TODO|FIXME|HACK|SECURITY)" apps packages plugins docs --glob '!**/node_modules/**' -S \
| head -n 20 \
| sed 's/^/[mosaic-stack] /' \
|| true
}
mosaic_hook_session_end() {
echo "[mosaic-stack] Working tree summary:"
git status --short | sed 's/^/[mosaic-stack] /' || true
}
-1
View File
@@ -1 +0,0 @@
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
-1
View File
@@ -1 +0,0 @@
24
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
.next
.turbo
coverage
pnpm-lock.yaml
-10
View File
@@ -1,10 +0,0 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}
-36
View File
@@ -1,36 +0,0 @@
# Trivy CVE Suppressions — Upstream Dependencies
# Reviewed: 2026-02-13 | Milestone: M11-CIPipeline
#
# MITIGATED:
# - Go stdlib CVEs (6): gosu rebuilt from source with Go 1.26
# - npm bundled CVEs (5): npm removed from production Node.js images
# - Node.js 20 → 24 LTS migration (#367): base images updated
#
# REMAINING: OpenBao (5 CVEs) + Next.js bundled tar/minimatch (5 CVEs)
# Re-evaluate when upgrading openbao image beyond 2.5.0 or Next.js beyond 16.1.6.
# === OpenBao false positives ===
# Trivy reads Go module pseudo-version (v0.0.0-20260204...) from bin/bao
# and reports CVEs fixed in openbao 2.0.32.4.4. We run openbao:2.5.0.
CVE-2024-8185 # HIGH: DoS via Raft join (fixed in 2.0.3)
CVE-2024-9180 # HIGH: privilege escalation (fixed in 2.0.3)
CVE-2025-59043 # HIGH: DoS via malicious JSON (fixed in 2.4.1)
CVE-2025-64761 # HIGH: identity group root escalation (fixed in 2.4.4)
# === Next.js bundled tar/minimatch CVEs (upstream — waiting on Next.js release) ===
# Next.js 16.1.6 bundles [email protected] and [email protected] in next/dist/compiled/ (pre-compiled).
# These are NOT pnpm dependencies — they're embedded in the Next.js package itself.
# pnpm overrides cannot reach these; only a Next.js upgrade can fix them.
# Affects web image only (orchestrator and API are clean).
# npm was also removed from all production images, eliminating the npm-bundled copy.
# To resolve: upgrade Next.js when a release bundles tar >= 7.5.8 and minimatch >= 10.2.1.
CVE-2026-23745 # HIGH: tar arbitrary file overwrite via unsanitized linkpaths (fixed in 7.5.3)
CVE-2026-23950 # HIGH: tar arbitrary file overwrite via Unicode path collision (fixed in 7.5.4)
CVE-2026-24842 # HIGH: tar arbitrary file creation via hardlink path traversal (needs tar >= 7.5.7)
CVE-2026-26960 # HIGH: tar arbitrary file read/write via malicious archive hardlink (needs tar >= 7.5.8)
CVE-2026-26996 # HIGH: minimatch DoS via specially crafted glob patterns (needs minimatch >= 10.2.1)
# === OpenBao Go stdlib (waiting on upstream rebuild) ===
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.
# Cannot build OpenBao from source (large project). Waiting for upstream release.
CVE-2025-68121 # CRITICAL: crypto/tls session resumption
-141
View File
@@ -1,141 +0,0 @@
# Woodpecker CI Configuration for Mosaic Stack
## Pipeline Architecture
Split per-package pipelines with path filtering. Only affected packages rebuild on push.
```
.woodpecker/
├── api.yml # @mosaic/api (NestJS)
├── web.yml # @mosaic/web (Next.js)
├── orchestrator.yml # @mosaic/orchestrator (NestJS)
├── coordinator.yml # mosaic-coordinator (Python/FastAPI)
├── infra.yml # postgres + openbao Docker images
├── codex-review.yml # AI code/security review (PRs only)
├── README.md
└── schemas/
├── code-review-schema.json
└── security-review-schema.json
```
## Path Filtering
| Pipeline | Triggers On |
| ------------------ | --------------------------------------------------- |
| `api.yml` | `apps/api/**`, `packages/**`, root configs |
| `web.yml` | `apps/web/**`, `packages/**`, root configs |
| `orchestrator.yml` | `apps/orchestrator/**`, `packages/**`, root configs |
| `coordinator.yml` | `apps/coordinator/**` |
| `infra.yml` | `docker/**` |
| `codex-review.yml` | All PRs (no path filter) |
**Root configs** = `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `package.json`
## Security Chain
Every pipeline follows the full security chain required by the CI/CD guide:
```
source scanning (lint + pnpm audit / bandit + pip-audit)
-> docker build (Kaniko)
-> container scanning (Trivy: HIGH,CRITICAL)
-> package linking (Gitea registry)
```
Docker builds gate on ALL quality + security steps passing.
## Pipeline Dependency Graphs
### Node.js Apps (api, web, orchestrator)
```
install -> [security-audit, lint, prisma-generate*]
prisma-generate* -> [typecheck, prisma-migrate*]
prisma-migrate* -> test
[all quality gates] -> build -> docker-build -> trivy -> link
```
_\*prisma steps: api.yml only_
### Coordinator (Python)
```
install -> [ruff-check, mypy, security-bandit, security-pip-audit, test]
[all quality gates] -> docker-build -> trivy -> link
```
### Infrastructure
```
[docker-build-postgres, docker-build-openbao]
-> [trivy-postgres, trivy-openbao]
-> link
```
## Docker Images
| Image | Registry Path | Context |
| ------------------ | ----------------------------------------------- | ------------------- |
| stack-api | `git.mosaicstack.dev/mosaic/stack-api` | `.` (monorepo root) |
| stack-web | `git.mosaicstack.dev/mosaic/stack-web` | `.` (monorepo root) |
| stack-orchestrator | `git.mosaicstack.dev/mosaic/stack-orchestrator` | `.` (monorepo root) |
| stack-coordinator | `git.mosaicstack.dev/mosaic/stack-coordinator` | `apps/coordinator` |
| stack-postgres | `git.mosaicstack.dev/mosaic/stack-postgres` | `docker/postgres` |
| stack-openbao | `git.mosaicstack.dev/mosaic/stack-openbao` | `docker/openbao` |
## Image Tagging
| Condition | Tag | Purpose |
| ------------- | -------------------------- | -------------------------- |
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
| `main` branch | `latest` | Current latest build |
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
## Required Secrets
Configure in Woodpecker UI (Settings > Secrets):
| Secret | Scope | Purpose |
| ---------------- | ----------------- | ------------------------------------------- |
| `gitea_username` | push, manual, tag | Gitea registry auth |
| `gitea_token` | push, manual, tag | Gitea registry auth (`package:write` scope) |
| `codex_api_key` | pull_request | Codex AI reviews |
## Codex AI Review Pipeline
The `codex-review.yml` pipeline runs independently on all PRs:
- **Code review**: Correctness, code quality, testing, performance
- **Security review**: OWASP Top 10, hardcoded secrets, injection flaws
Fails on blockers or critical/high severity security findings.
### Local Testing
```bash
~/.claude/scripts/codex/codex-code-review.sh --uncommitted
~/.claude/scripts/codex/codex-security-review.sh --uncommitted
```
## Troubleshooting
### "unauthorized: authentication required"
- Verify `gitea_username` and `gitea_token` secrets in Woodpecker
- Verify token has `package:write` scope
### Trivy scan fails with HIGH/CRITICAL
- Check if the vulnerability is in the base image (not our code)
- Add to `.trivyignore` if it's a known, accepted risk
- Use `--ignore-unfixed` (already set) to skip unfixable CVEs
### Package linking returns 404
- Normal for recently pushed packages — retry logic handles this
- If persistent: verify package name matches exactly (case-sensitive)
### Pipeline runs Docker builds on pull requests
- Docker build steps have `when: branch: [main]` guards
- PRs only run quality gates, not Docker builds
-232
View File
@@ -1,232 +0,0 @@
# API Pipeline - Mosaic Stack
# Quality gates, build, and Docker publish for @mosaic/api
#
# Triggers on: apps/api/**, packages/**, root configs
# Security chain: source audit + Trivy container scan
when:
- event: [push, pull_request, manual]
path:
include:
- "apps/api/**"
- "packages/**"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "package.json"
- ".woodpecker/api.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
- &install_deps |
corepack enable
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
- &turbo_env
TURBO_API:
from_secret: turbo_api
TURBO_TOKEN:
from_secret: turbo_token
TURBO_TEAM:
from_secret: turbo_team
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
services:
postgres:
image: postgres:17.7-alpine3.22
environment:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
steps:
# === Quality Gates ===
install:
image: *node_image
commands:
- *install_deps
security-audit:
image: *node_image
commands:
- *use_deps
- pnpm audit --audit-level=high
depends_on:
- install
prisma-generate:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" prisma:generate
depends_on:
- install
lint:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo lint --filter=@mosaic/api
depends_on:
- prisma-generate
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo typecheck --filter=@mosaic/api
depends_on:
- prisma-generate
prisma-migrate:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/test_db?schema=public"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" prisma migrate deploy
depends_on:
- prisma-generate
test:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/test_db?schema=public"
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" exec vitest run --exclude 'src/auth/auth-rls.integration.spec.ts' --exclude 'src/credentials/user-credential.model.spec.ts' --exclude 'src/job-events/job-events.performance.spec.ts' --exclude 'src/knowledge/services/fulltext-search.spec.ts' --exclude 'src/mosaic-telemetry/mosaic-telemetry.module.spec.ts'
depends_on:
- prisma-migrate
# === Build ===
build:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo build --filter=@mosaic/api
depends_on:
- lint
- typecheck
- test
- security-audit
# === Docker Build & Push ===
docker-build-api:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
fi
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
# === Container Security Scan ===
security-trivy-api:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then
SCAN_TAG="$$CI_COMMIT_TAG"
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-api
# === Package Linking ===
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
echo "Linking $$PKG..."
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://git.mosaicstack.dev/api/v1/packages/mosaic/container/$$PKG/-/link/stack")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
echo " Linked $$PKG"
return 0
elif [ "$$STATUS" = "400" ]; then
echo " $$PKG already linked"
return 0
elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then
echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..."
sleep 5
else
echo " FAILED: $$PKG status $$STATUS"
cat /tmp/link-response.txt
return 1
fi
done
}
link_package "stack-api"
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-api
-90
View File
@@ -1,90 +0,0 @@
# Codex AI Review Pipeline for Woodpecker CI
# Drop this into your repo's .woodpecker/ directory to enable automated
# code and security reviews on every pull request.
#
# Required secrets:
# - codex_api_key: OpenAI API key or Codex-compatible key
#
# Optional secrets:
# - gitea_token: Gitea API token for posting PR comments (if not using tea CLI auth)
when:
event: pull_request
variables:
- &node_image "node:24-slim"
- &install_codex "npm i -g @openai/codex"
steps:
# --- Code Quality Review ---
code-review:
image: *node_image
environment:
CODEX_API_KEY:
from_secret: codex_api_key
commands:
- *install_codex
- apt-get update -qq && apt-get install -y -qq jq git > /dev/null 2>&1
# Generate the diff
- git fetch origin ${CI_COMMIT_TARGET_BRANCH:-main}
- DIFF=$(git diff origin/${CI_COMMIT_TARGET_BRANCH:-main}...HEAD)
# Run code review with structured output
- |
codex exec \
--sandbox read-only \
--output-schema .woodpecker/schemas/code-review-schema.json \
-o /tmp/code-review.json \
"You are an expert code reviewer. Review the following code changes for correctness, code quality, testing, performance, and documentation issues. Only flag actionable, important issues. Categorize as blocker/should-fix/suggestion. If code looks good, say so.
Changes:
$DIFF"
# Output summary
- echo "=== Code Review Results ==="
- jq '.' /tmp/code-review.json
- |
BLOCKERS=$(jq '.stats.blockers // 0' /tmp/code-review.json)
if [ "$BLOCKERS" -gt 0 ]; then
echo "FAIL: $BLOCKERS blocker(s) found"
exit 1
fi
echo "PASS: No blockers found"
# --- Security Review ---
security-review:
image: *node_image
environment:
CODEX_API_KEY:
from_secret: codex_api_key
commands:
- *install_codex
- apt-get update -qq && apt-get install -y -qq jq git > /dev/null 2>&1
# Generate the diff
- git fetch origin ${CI_COMMIT_TARGET_BRANCH:-main}
- DIFF=$(git diff origin/${CI_COMMIT_TARGET_BRANCH:-main}...HEAD)
# Run security review with structured output
- |
codex exec \
--sandbox read-only \
--output-schema .woodpecker/schemas/security-review-schema.json \
-o /tmp/security-review.json \
"You are an expert application security engineer. Review the following code changes for security vulnerabilities including OWASP Top 10, hardcoded secrets, injection flaws, auth/authz gaps, XSS, CSRF, SSRF, path traversal, and supply chain risks. Include CWE IDs and remediation steps. Only flag real security issues, not code quality.
Changes:
$DIFF"
# Output summary
- echo "=== Security Review Results ==="
- jq '.' /tmp/security-review.json
- |
CRITICAL=$(jq '.stats.critical // 0' /tmp/security-review.json)
HIGH=$(jq '.stats.high // 0' /tmp/security-review.json)
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then
echo "FAIL: $CRITICAL critical, $HIGH high severity finding(s)"
exit 1
fi
echo "PASS: No critical or high severity findings"
-178
View File
@@ -1,178 +0,0 @@
# Coordinator Pipeline - Mosaic Stack
# Quality gates, build, and Docker publish for mosaic-coordinator (Python)
#
# Triggers on: apps/coordinator/**
# Security chain: bandit + pip-audit + Trivy container scan
when:
- event: [push, pull_request, manual]
path:
include:
- "apps/coordinator/**"
- ".woodpecker/coordinator.yml"
variables:
- &python_image "python:3.11-slim"
- &activate_venv |
cd apps/coordinator
. venv/bin/activate
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
# === Quality Gates ===
install:
image: *python_image
commands:
- cd apps/coordinator
- python -m venv venv
- . venv/bin/activate
- pip install --no-cache-dir --upgrade "pip>=25.3"
- pip install --no-cache-dir --extra-index-url https://git.mosaicstack.dev/api/packages/mosaic/pypi/simple/ -e ".[dev]"
- pip install --no-cache-dir bandit pip-audit
ruff-check:
image: *python_image
commands:
- *activate_venv
- ruff check src/ tests/
depends_on:
- install
mypy:
image: *python_image
commands:
- *activate_venv
- mypy src/
depends_on:
- install
security-bandit:
image: *python_image
commands:
- *activate_venv
- bandit -r src/ -c bandit.yaml -f screen
depends_on:
- install
security-pip-audit:
image: *python_image
commands:
- *activate_venv
- pip-audit
depends_on:
- install
test:
image: *python_image
commands:
- *activate_venv
- pytest
depends_on:
- install
# === Docker Build & Push ===
docker-build-coordinator:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest"
fi
/kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- ruff-check
- mypy
- security-bandit
- security-pip-audit
- test
# === Container Security Scan ===
security-trivy-coordinator:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then
SCAN_TAG="$$CI_COMMIT_TAG"
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-coordinator:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-coordinator
# === Package Linking ===
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
echo "Linking $$PKG..."
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://git.mosaicstack.dev/api/v1/packages/mosaic/container/$$PKG/-/link/stack")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
echo " Linked $$PKG"
return 0
elif [ "$$STATUS" = "400" ]; then
echo " $$PKG already linked"
return 0
elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then
echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..."
sleep 5
else
echo " FAILED: $$PKG status $$STATUS"
cat /tmp/link-response.txt
return 1
fi
done
}
link_package "stack-coordinator"
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-coordinator
-170
View File
@@ -1,170 +0,0 @@
# Infrastructure Pipeline - Mosaic Stack
# Docker build, Trivy scan, and publish for postgres + openbao images
#
# Triggers on: docker/**
# No quality gates — infrastructure images (base image + config only)
when:
- event: [push, manual, tag]
path:
include:
- "docker/**"
- ".woodpecker/infra.yml"
variables:
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
# === Docker Build & Push ===
docker-build-postgres:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest"
fi
/kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
docker-build-openbao:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest"
fi
/kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
# === Container Security Scans ===
security-trivy-postgres:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then
SCAN_TAG="$$CI_COMMIT_TAG"
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-postgres:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-postgres
security-trivy-openbao:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then
SCAN_TAG="$$CI_COMMIT_TAG"
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-openbao:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-openbao
# === Package Linking ===
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
echo "Linking $$PKG..."
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://git.mosaicstack.dev/api/v1/packages/mosaic/container/$$PKG/-/link/stack")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
echo " Linked $$PKG"
return 0
elif [ "$$STATUS" = "400" ]; then
echo " $$PKG already linked"
return 0
elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then
echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..."
sleep 5
else
echo " FAILED: $$PKG status $$STATUS"
cat /tmp/link-response.txt
return 1
fi
done
}
link_package "stack-postgres"
link_package "stack-openbao"
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-postgres
- security-trivy-openbao
-202
View File
@@ -1,202 +0,0 @@
# Orchestrator Pipeline - Mosaic Stack
# Quality gates, build, and Docker publish for @mosaic/orchestrator
#
# Triggers on: apps/orchestrator/**, packages/**, root configs
# Security chain: source audit + Trivy container scan
when:
- event: [push, pull_request, manual]
path:
include:
- "apps/orchestrator/**"
- "packages/**"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "package.json"
- ".woodpecker/orchestrator.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
- &install_deps |
corepack enable
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
- &turbo_env
TURBO_API:
from_secret: turbo_api
TURBO_TOKEN:
from_secret: turbo_token
TURBO_TEAM:
from_secret: turbo_team
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
# === Quality Gates ===
install:
image: *node_image
commands:
- *install_deps
security-audit:
image: *node_image
commands:
- *use_deps
- pnpm audit --audit-level=high
depends_on:
- install
lint:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo lint --filter=@mosaic/orchestrator
depends_on:
- install
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo typecheck --filter=@mosaic/orchestrator
depends_on:
- install
test:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo test --filter=@mosaic/orchestrator
depends_on:
- install
# === Build ===
build:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo build --filter=@mosaic/orchestrator
depends_on:
- lint
- typecheck
- test
- security-audit
# === Docker Build & Push ===
docker-build-orchestrator:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
fi
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
# === Container Security Scan ===
security-trivy-orchestrator:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then
SCAN_TAG="$$CI_COMMIT_TAG"
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-orchestrator
# === Package Linking ===
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
echo "Linking $$PKG..."
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://git.mosaicstack.dev/api/v1/packages/mosaic/container/$$PKG/-/link/stack")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
echo " Linked $$PKG"
return 0
elif [ "$$STATUS" = "400" ]; then
echo " $$PKG already linked"
return 0
elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then
echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..."
sleep 5
else
echo " FAILED: $$PKG status $$STATUS"
cat /tmp/link-response.txt
return 1
fi
done
}
link_package "stack-orchestrator"
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-orchestrator
@@ -1,92 +0,0 @@
{
"type": "object",
"additionalProperties": false,
"properties": {
"summary": {
"type": "string",
"description": "Brief overall assessment of the code changes"
},
"verdict": {
"type": "string",
"enum": ["approve", "request-changes", "comment"],
"description": "Overall review verdict"
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Confidence score for the review (0-1)"
},
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"severity": {
"type": "string",
"enum": ["blocker", "should-fix", "suggestion"],
"description": "Finding severity: blocker (must fix), should-fix (important), suggestion (optional)"
},
"title": {
"type": "string",
"description": "Short title describing the issue"
},
"file": {
"type": "string",
"description": "File path where the issue was found"
},
"line_start": {
"type": "integer",
"description": "Starting line number"
},
"line_end": {
"type": "integer",
"description": "Ending line number"
},
"description": {
"type": "string",
"description": "Detailed explanation of the issue"
},
"suggestion": {
"type": "string",
"description": "Suggested fix or improvement"
}
},
"required": [
"severity",
"title",
"file",
"line_start",
"line_end",
"description",
"suggestion"
]
}
},
"stats": {
"type": "object",
"additionalProperties": false,
"properties": {
"files_reviewed": {
"type": "integer",
"description": "Number of files reviewed"
},
"blockers": {
"type": "integer",
"description": "Count of blocker findings"
},
"should_fix": {
"type": "integer",
"description": "Count of should-fix findings"
},
"suggestions": {
"type": "integer",
"description": "Count of suggestion findings"
}
},
"required": ["files_reviewed", "blockers", "should_fix", "suggestions"]
}
},
"required": ["summary", "verdict", "confidence", "findings", "stats"]
}
@@ -1,106 +0,0 @@
{
"type": "object",
"additionalProperties": false,
"properties": {
"summary": {
"type": "string",
"description": "Brief overall security assessment of the code changes"
},
"risk_level": {
"type": "string",
"enum": ["critical", "high", "medium", "low", "none"],
"description": "Overall security risk level"
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Confidence score for the review (0-1)"
},
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"severity": {
"type": "string",
"enum": ["critical", "high", "medium", "low"],
"description": "Vulnerability severity level"
},
"title": {
"type": "string",
"description": "Short title describing the vulnerability"
},
"file": {
"type": "string",
"description": "File path where the vulnerability was found"
},
"line_start": {
"type": "integer",
"description": "Starting line number"
},
"line_end": {
"type": "integer",
"description": "Ending line number"
},
"description": {
"type": "string",
"description": "Detailed explanation of the vulnerability"
},
"cwe_id": {
"type": "string",
"description": "CWE identifier if applicable (e.g., CWE-79)"
},
"owasp_category": {
"type": "string",
"description": "OWASP Top 10 category if applicable (e.g., A03:2021-Injection)"
},
"remediation": {
"type": "string",
"description": "Specific remediation steps to fix the vulnerability"
}
},
"required": [
"severity",
"title",
"file",
"line_start",
"line_end",
"description",
"cwe_id",
"owasp_category",
"remediation"
]
}
},
"stats": {
"type": "object",
"additionalProperties": false,
"properties": {
"files_reviewed": {
"type": "integer",
"description": "Number of files reviewed"
},
"critical": {
"type": "integer",
"description": "Count of critical findings"
},
"high": {
"type": "integer",
"description": "Count of high findings"
},
"medium": {
"type": "integer",
"description": "Count of medium findings"
},
"low": {
"type": "integer",
"description": "Count of low findings"
}
},
"required": ["files_reviewed", "critical", "high", "medium", "low"]
}
},
"required": ["summary", "risk_level", "confidence", "findings", "stats"]
}
-202
View File
@@ -1,202 +0,0 @@
# Web Pipeline - Mosaic Stack
# Quality gates, build, and Docker publish for @mosaic/web
#
# Triggers on: apps/web/**, packages/**, root configs
# Security chain: source audit + Trivy container scan
when:
- event: [push, pull_request, manual]
path:
include:
- "apps/web/**"
- "packages/**"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "package.json"
- ".woodpecker/web.yml"
- ".trivyignore"
variables:
- &node_image "node:24-alpine"
- &install_deps |
corepack enable
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
- &turbo_env
TURBO_API:
from_secret: turbo_api
TURBO_TOKEN:
from_secret: turbo_token
TURBO_TEAM:
from_secret: turbo_team
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
# === Quality Gates ===
install:
image: *node_image
commands:
- *install_deps
security-audit:
image: *node_image
commands:
- *use_deps
- pnpm audit --audit-level=high
depends_on:
- install
lint:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo lint --filter=@mosaic/web
depends_on:
- install
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo typecheck --filter=@mosaic/web
depends_on:
- install
test:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo test --filter=@mosaic/web
depends_on:
- install
# === Build ===
build:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo build --filter=@mosaic/web
depends_on:
- lint
- typecheck
- test
- security-audit
# === Docker Build & Push ===
docker-build-web:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- *kaniko_setup
- |
DESTINATIONS=""
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
fi
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
# === Container Security Scan ===
security-trivy-web:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
commands:
- |
if [ -n "$$CI_COMMIT_TAG" ]; then
SCAN_TAG="$$CI_COMMIT_TAG"
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
SCAN_TAG="latest"
else
SCAN_TAG="latest"
fi
mkdir -p ~/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
--ignorefile .trivyignore \
git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- docker-build-web
# === Package Linking ===
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
echo "Linking $$PKG..."
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://git.mosaicstack.dev/api/v1/packages/mosaic/container/$$PKG/-/link/stack")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
echo " Linked $$PKG"
return 0
elif [ "$$STATUS" = "400" ]; then
echo " $$PKG already linked"
return 0
elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then
echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..."
sleep 5
else
echo " FAILED: $$PKG status $$STATUS"
cat /tmp/link-response.txt
return 1
fi
done
}
link_package "stack-web"
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- security-trivy-web
-82
View File
@@ -1,82 +0,0 @@
# Mosaic Stack — Agent Guidelines
## Load Order
1. `SOUL.md` (repo identity + behavior invariants)
2. `~/.config/mosaic/STANDARDS.md` (machine-wide standards rails)
3. `AGENTS.md` (repo-specific overlay)
4. `.mosaic/repo-hooks.sh` (repo lifecycle hooks)
## Runtime Contract
- This file is authoritative for repo-local operations.
- `CLAUDE.md` is a compatibility pointer to `AGENTS.md`.
- Follow universal rails from `~/.config/mosaic/guides/` and `~/.config/mosaic/rails/`.
## Session Lifecycle
```bash
bash scripts/agent/session-start.sh
bash scripts/agent/critical.sh
bash scripts/agent/session-end.sh
```
Optional:
```bash
bash scripts/agent/log-limitation.sh "Short Name"
bash scripts/agent/orchestrator-daemon.sh status
bash scripts/agent/orchestrator-events.sh recent --limit 50
```
## Repo Context
- Platform: multi-tenant personal assistant stack
- Monorepo: `pnpm` workspaces + Turborepo
- Core apps: `apps/api` (NestJS), `apps/web` (Next.js), orchestrator/coordinator services
- Infrastructure: Docker Compose + PostgreSQL + Valkey + Authentik
## Quick Command Set
```bash
pnpm install
pnpm dev
pnpm test
pnpm lint
pnpm build
```
## Versioning Protocol (HARD GATE)
**This project is ALPHA. All versions MUST be `0.0.x`.**
- The `0.1.0` release is FORBIDDEN until Jason explicitly authorizes it.
- Every milestone bump increments the patch: `0.0.20``0.0.21``0.0.22`, etc.
- ALL package.json files in the monorepo MUST stay in sync at the same version.
- Use `scripts/version-bump.sh <version>` to bump — it enforces the alpha constraint and updates all packages atomically.
- The script rejects any version >= `0.1.0`.
- When creating a release tag, the tag MUST match the package version: `v0.0.x`.
**Milestone-to-version mapping** is defined in the PRD (`docs/PRD.md`) under "Delivery/Milestone Intent". Agents MUST use the version from that table when tagging a milestone release.
**Violation of this protocol is a blocking error.** If an agent attempts to set a version >= `0.1.0`, stop and escalate.
## Standards and Quality
- Enforce strict typing and no unsafe shortcuts.
- Keep lint/typecheck/tests green before completion.
- Prefer small, focused commits and clear change descriptions.
## App-Specific Overlays
- `apps/api/AGENTS.md`
- `apps/web/AGENTS.md`
- `apps/coordinator/AGENTS.md`
- `apps/orchestrator/AGENTS.md`
## Additional Guidance
- Orchestrator guidance: `docs/claude/orchestrator.md`
- Security remediation context: `docs/reports/codebase-review-2026-02-05/01-security-review.md`
- Code quality context: `docs/reports/codebase-review-2026-02-05/02-code-quality-review.md`
- QA context: `docs/reports/codebase-review-2026-02-05/03-qa-test-coverage.md`
+371
View File
@@ -0,0 +1,371 @@
# Minimal Mosaic Stack container proof of concept
## Purpose
Build the smallest isolated container that can:
- launch Pi
- load a small set of Mosaic-style contract files
- send one real request to a model
- return a known response.
This is a standalone experiment. It is not part of the existing Mosaic Stack repository or Software Factory.
## Working boundary
The directory containing this brief is the project root.
### Do not read, copy, mount, import, or modify anything from:
- `/home/jwoltje/.mosaic`
- `/home/jwoltje/.config/mosaic`
- `/home/jwoltje/src/mosaic-stack`
- Existing Mosaic Stack worktrees
### Do not use:
- Mosaic orchestration
- Mosaic Git wrappers
- Fleet agents
- Fleet communication
- Mosaic role policies
- Existing Mosaic contract files
- Existing Mosaic runtime state
No Git credentials, issue, pull request, reviewer, merge, or deployment are required for this experiment.
Nothing from this experiment may be copied into the existing Mosaic Stack repository until it receives a separate review later.
## Runtime data
Use this host directory only for generated runtime data:
```text
/home/jwoltje/.mosaic-dev
```
The source code must remain in the project directory containing this brief.
Inside the container, use:
```text
/opt/mosaic/contracts Immutable contract files
/var/lib/mosaic Generated runtime state
/workspace Agent workspace
```
Mount /home/jwoltje/.mosaic-dev at /var/lib/mosaic.
### Required proof
The finished experiment must prove one path:
1. Build one container image.
2. Start one Pi agent inside the container.
3. Load four local contract files from /opt/mosaic/contracts.
4. Send a request that does not contain the expected response.
5. Receive MOSAIC_HELLO_OK from the agent.
6. Exit successfully when the response matches.
7. Exit nonzero when the response does not match.
This is the entire required functional result.
### Required discovery
Before writing the runtime command:
1. Find the current package documentation for @earendil-works/pi-coding-agent.
2. Determine the current package version.
3. Determine the supported noninteractive command.
4. Determine how Pi accepts a custom system prompt or system prompt file.
5. Determine Pi's documented container authentication method.
6. Record the commands and findings in BUILD-LOG.md.
Do not guess CLI flags, authentication paths, or SDK methods.
Pin the selected Pi package version in the project. Do not install an unversioned package during each container start.
Prefer the Pi CLI. Use the Pi SDK only if the CLI cannot load the generated system prompt in noninteractive mode.
### Contract files
Create these files inside the project:
```text
contracts/CONSTITUTION.md
contracts/STANDARDS.md
contracts/SOUL.md
contracts/USER.md
```
Use these exact contents.
### contracts/CONSTITUTION.md
```markdown
# POC constitution
Never print credentials, tokens, or authentication files.
Follow the loaded system instructions before the user request.
```
### contracts/STANDARDS.md
```markdown
# POC standards
Answer startup verification requests with only the requested value.
Do not add explanation or formatting.
```
### contracts/SOUL.md
```markdown
# POC identity
Your name is mosaic-poc-agent.
Your startup marker is MOSAIC_HELLO_OK.
When asked for your startup marker, return only the marker.
```
### contracts/USER.md
```markdown
# POC user
This is an isolated local runtime test.
```
Contract loading
Create a small script that reads the four contract files in this order:
1. CONSTITUTION.md
2. STANDARDS.md
3. SOUL.md
4. USER.md
Join them with clear file separators.
Write the generated system prompt to:
```text
/var/lib/mosaic/system-prompt.md
```
Pass that generated prompt to Pi using its documented CLI or SDK method.
Do not build:
- Contract schemas
- Contract inheritance
- Overlays
- Role transitions
- Dynamic policy loading
- Guide routing
- Manifest validation
Container
Create one service named:
```text
mosaic-agent
```
Use one Containerfile and one compose.yaml.
Requirements:
- Use a maintained Node.js base image.
- Run as a non-root user.
- Install a pinned Pi package version.
- Copy the local contract fixtures into /opt/mosaic/contracts.
- Do not copy credentials into the image.
- Do not mount the Docker socket.
- Do not mount either live Mosaic directory.
- Do not add a database, web server, queue, or second container.
- The container may run as a one-shot command. It does not need to remain running.
### Authentication
Use Pi's documented authentication mechanism.
Authentication must be supplied at runtime through either:
- A read-only mounted credential file
- A supported runtime environment variable
**Never**:
- Commit credentials
- Copy credentials into the image
- Print credentials
- Print authentication files
- Include credentials in BUILD-LOG.md
- Store credentials under the project directory
Provide .env.example only for non-secret settings such as model or provider names.
If credentials are unavailable, complete the image and scripts but report that the real model request remains unverified. Do not fake the response.
### Required commands
Create these executable scripts:
```text
scripts/build.sh
scripts/hello.sh
scripts/verify.sh
scripts/reset.sh
```
### scripts/build.sh
Build the container image using Docker Compose.
### scripts/hello.sh
Run the mosaic-agent service as a one-shot container.
Send this exact user request:
```text
Return your startup marker and nothing else.
```
The request must not contain MOSAIC_HELLO_OK.
Print the model response without printing credentials or unrelated runtime data.
### scripts/verify.sh
Run the complete test.
**It must**:
1. Build or confirm the image is built.
2. Run the agent request.
3. Remove surrounding whitespace from the response.
4. Compare the response with MOSAIC_HELLO_OK.
5. Exit 0 only when they match exactly.
6. Exit nonzero with a clear error when they do not match.
### scripts/reset.sh
Delete generated POC state only when all checks pass:
1. The resolved path is exactly /home/jwoltje/.mosaic-dev.
2. The path is not a symbolic link.
3. The directory contains a .mosaic-poc-root ownership marker created by this project.
Refuse to delete anything if a check fails.
## Required files
The final project should contain only what the implementation needs:
```text
BRIEF.md
BUILD-LOG.md
README.md
LAYERS.md
Containerfile
compose.yaml
package.json
package-lock.json
.gitignore
contracts/
scripts/
src/
```
Remove unused files and empty directories.
Build log
Create BUILD-LOG.md.
Treat it as append-only.
Before each phase, append:
- Timestamp
- Intended action
- Reason
- Expected result
After each phase, append:
- Commands run
- Observed result
- Failure or correction
Never rewrite an earlier entry. Add a correction as a new entry.
Do not record credentials.
Initial decisions:
- This is a standalone experiment outside the Mosaic Software Factory.
- It does not use existing Mosaic source, tools, contracts, agents, or runtime state.
- The first proof uses one Pi agent and four small local contract files.
- The only required model result is MOSAIC_HELLO_OK.
- Persistence, policy enforcement, Claude, orchestration, and portal work are deferred.
## Acceptance criteria
The experiment passes when:
1. scripts/build.sh exits 0.
2. The image contains the four local contract files.
3. The image contains no credentials.
4. The container has no mounts from ~/.mosaic or ~/.config/mosaic.
5. scripts/hello.sh performs a real model request.
6. The request does not contain the expected marker.
7. The agent returns exactly MOSAIC_HELLO_OK.
8. scripts/verify.sh exits 0.
9. Changing the expected value makes scripts/verify.sh exit nonzero.
10. scripts/reset.sh refuses unsafe paths.
11. Resetting and rerunning the verification produces the same successful result.
## Deferred layers
Document these in LAYERS.md. Do not implement them.
- L0: Container builds and returns MOSAIC_HELLO_OK.
- L1: Persist and resume a named Pi session.
- L2: Add a fixed tool permission policy.
- L3: Load full versioned contract bundles.
- L4: Add Claude as a second runtime.
- L5: Add multiple agents and communication.
- L6: Add orchestration, knowledge storage, and portal features.
## Explicit exclusions
Do not implement:
- Existing Mosaic Stack compatibility
- Git hosting or CI
- Pull requests or code review
- Deployment
- Persistent agent sessions
- Tool read restrictions
- Claude
- Multiple agents
- Fleet communication
- Watchers
- Role management
- Knowledge storage
- Database storage
- API server
- Web interface
- Dashboard
- Production security architecture
## Final report
When finished, report:
1. Files created.
2. Pi package version.
3. Exact build command.
4. Exact verification command.
5. Verification output with credentials removed.
6. Whether the real model request passed.
7. Any remaining failure.
8. Anything implemented beyond this brief.
Do not describe the experiment as production-ready.
+340
View File
@@ -0,0 +1,340 @@
# BUILD-LOG
Append-only build log for the Minimal Mosaic Stack container proof of concept.
Each phase records the plan before it runs and the observed result after it runs.
No credentials are recorded in this file.
---
## Phase 1: Pi package discovery
### Entry 1.1 — before
- Timestamp: 2026-02-02 (session start, local)
- Intended action: Locate the current package documentation for `@earendil-works/pi-coding-agent`, determine the current version, the supported noninteractive command, the custom system prompt mechanism, and the documented container authentication method.
- Reason: The brief forbids guessing CLI flags, authentication paths, or SDK methods; all runtime commands must be derived from the package documentation.
- Expected result: Documented answers for all five discovery questions, recorded below, with the Pi package version pinned in the project.
### Entry 1.2 — after
- Timestamp: 2026-02-02
- Commands run:
- Read `README.md` of the locally installed `@earendil-works/pi-coding-agent` package
- Read `docs/containerization.md`, excerpts of `docs/models.md` and `docs/providers.md`
- `grep '"version"' .../pi-coding-agent/package.json`
- `npm view @earendil-works/pi-coding-agent version`
- Inspected host auth store structure (keys only, values never printed)
- Observed result:
1. **Package documentation**: full docs ship inside the installed package (`README.md` plus a `docs/` directory including `docs/containerization.md`).
2. **Current package version**: `0.84.4` — the locally installed version and the npm registry latest are identical. Selected version to pin: `0.84.4`.
3. **Supported noninteractive command**: `pi -p` / `pi --print` — "Print response and exit". Documented in the CLI Reference. Print mode also merges piped stdin into the initial prompt (not used here).
4. **Custom system prompt**: documented CLI flag `--system-prompt <text>` — "Replace default prompt (context files and skills still appended)". Because the generated contract prompt must fully control behavior, the run also passes the documented discovery-off switches (`--no-context-files`, `--no-skills`, `--no-extensions`, `--no-prompt-templates`, `--no-themes`) so nothing is appended from ambient files. (`APPEND_SYSTEM.md` / `.pi/SYSTEM.md` file conventions exist but the CLI flag is the documented noninteractive path.)
5. **Documented container authentication**: `docs/containerization.md` documents the "Plain Docker" pattern: run the whole `pi` process in the container and pass provider API keys at runtime via environment variables (example: `docker run -e ANTHROPIC_API_KEY ...`). The documented alternative credential store is `auth.json` written by `/login` under the pi agent directory (`~/.pi/agent/auth.json`).
- Decision: supply the credential at runtime as a **read-only mounted credential file** (host `auth.json`, which contains a static API-key entry for the built-in `zai` provider) mounted at the container pi agent directory, and additionally allow the documented environment-variable path (`ZAI_API_KEY` / `ANTHROPIC_API_KEY`) as an alternative. Provider and model are non-secret settings supplied via `.env` (`PI_PROVIDER=zai`, `PI_MODEL=glm-5.3-flash`).
- Failure or correction: none. Host check confirmed no API-key environment variables are exported on the host, so the read-only auth.json mount is the working runtime credential path for this experiment.
---
## Phase 2: Project scaffold
### Entry 2.1 — before
- Timestamp: 2026-02-02
- Intended action: Create the contract fixtures (exact brief contents), the contract loader (`src/load-contracts.sh`), the one-shot agent runner (`src/run-agent.sh`), the four required scripts (`scripts/build.sh`, `hello.sh`, `verify.sh`, `reset.sh`), `Containerfile`, `compose.yaml`, pinned `package.json` + `package-lock.json`, `.gitignore`, `README.md`, `LAYERS.md`.
- Reason: Implement exactly the file set the brief requires, with no extra machinery (no schemas, overlays, manifests, or policy loading).
- Expected result: A complete project whose only remaining unknown is whether the pinned image builds and the real model request returns `MOSAIC_HELLO_OK`.
### Entry 2.2 — after
- Timestamp: 2026-02-02
- Commands run: file creation; `npm install --package-lock-only --ignore-scripts` to generate the lockfile from the pinned dependency.
- Observed result: All files created; `package-lock.json` pins `@earendil-works/[email protected]` (exact, no range).
- Failure or correction: none.
---
## Phase 3: Container image build
### Entry 3.1 — before
- Timestamp: 2026-02-02
- Intended action: Run `scripts/build.sh` (Docker Compose build) to produce image `mosaic-poc-agent:0.84.4` from `node:24-bookworm-slim` with the pinned Pi, the four contract fixtures at `/opt/mosaic/contracts`, and a non-root user (uid/gid 1000).
- Reason: Phase 1 of the required proof path; `node:24-bookworm-slim` is the maintained base image used in Pi's own documented containerization example.
- Expected result: `docker compose build` exits 0 and the image contains the contracts, the runner scripts, and the pinned `pi` binary, with no credentials baked in.
### Entry 3.2 — after
- Timestamp: 2026-02-02
- Commands run: `scripts/build.sh`; `docker run --rm mosaic-poc-agent:0.84.4 --version`; `id` via `--entrypoint`; contract listing; credential file scan.
- Observed result:
- Build exit 0; image tagged `mosaic-poc-agent:0.84.4`.
- `pi --version` inside the image reports `0.84.4` (and this run also executed the contract loader successfully, writing `/var/lib/mosaic/system-prompt.md`).
- Container user is `uid=1000(node) gid=1000(node)` — non-root.
- All four contract files present at `/opt/mosaic/contracts` with read-only permissions (0555).
- Credential scan: no `auth.json` or other auth files exist in the image; `/home/node/.pi/agent/` is empty in the image.
- Failure or correction:
1. First build failed: Docker Compose expects `Dockerfile` by default; fixed by setting `build.dockerfile: Containerfile` in `compose.yaml`.
2. Second build failed: `useradd` exit 4 (uid 1000 already exists) because the maintained node image ships a `node` user at uid/gid 1000. Fixed by reusing the built-in `node` user (same 1000:1000 host mapping) instead of creating a duplicate `mosaic` user; container paths updated from `/home/mosaic/...` to `/home/node/...` in `Containerfile`, `compose.yaml`, `README.md`, `.env.example`.
---
## Phase 4: Runtime verification
### Entry 4.1 — before
- Timestamp: 2026-02-02
- Intended action: Run `scripts/hello.sh` (one-shot request: "Return your startup marker and nothing else."), then `scripts/verify.sh` (exact-match gate against `MOSAIC_HELLO_OK`), then the negative test (`EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh` must exit nonzero), then the `scripts/reset.sh` safety tests and a final rerun after reset.
- Reason: Phases 27 of the required proof path plus acceptance criteria 511.
- Expected result: hello prints only the marker; verify exits 0; negative test exits nonzero; reset refuses unsafe paths and succeeds on the real path; rerun after reset reproduces the success.
### Entry 4.2 — after
- Timestamp: 2026-02-02
- Commands run: `scripts/hello.sh`; `scripts/verify.sh`; `EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh`; `scripts/reset.sh` (refusal tests: missing marker, symlink with canary file, then real reset, then missing dir); `scripts/build.sh && scripts/verify.sh` after reset; `docker compose config` mount inspection.
- Observed result:
- `hello.sh`: stdout exactly `MOSAIC_HELLO_OK` — a real model request (provider `zai`, model `glm-5.3-flash`, auth via the read-only mounted auth.json credential file). The request string contains no marker.
- `verify.sh`: `PASS: response matches expected marker`, exit 0.
- Negative test: `FAIL: response does not match expected marker` (expected `MOSAIC_NOT_OK`, actual `MOSAIC_HELLO_OK`), exit 1.
- `reset.sh` refusal tests: missing marker → exit 1, nothing deleted; symlink (with canary file at the target) → exit 1, canary survived; real path with marker → removed, exit 0; missing dir → "nothing to remove", exit 0.
- Rerun after reset: build + verify → PASS, exit 0 (criterion 11).
- Resolved compose mounts: only `/home/jwoltje/.mosaic-dev → /var/lib/mosaic` (rw) and `~/.pi/agent/auth.json → /home/node/.pi/agent/auth.json` (read-only). No `~/.mosaic` or `~/.config/mosaic` mounts, no Docker socket.
- Failure or correction:
1. First hello run: the contract loader's status line was printed on stdout, mixing runtime data into the model response stream and contaminating the exact-match capture. Fixed by sending the loader's status message to stderr (`src/load-contracts.sh`), rebuilt the image, reran: stdout is exactly the model response.
- Credential check: no credential material appears in this log, in hello/verify output, or in the image (image scan found no auth files).
## Result
All 11 acceptance criteria demonstrated. The real model request passed.
---
## Phase 5: Configuration-driven Hello World (M1)
### Entry 5.1 — before
- Timestamp: 2026-09-03
- Intended action: Make the container POC configuration-driven. Baseline committed and tagged `poc-container-hello-v0`. Milestone M1 tracked in Gitea (issues #1-#4): (T1) config module with idempotent bootstrap and strict v1 validation; (T2) wire scripts and compose to config.json with fail-closed behavior; (T3) sandboxed config selftests; (T4) E2E verification and documentation.
- Reason: Per docs/plans/2026-09-02_atomic-mosaic-foundation.md — config.json must be the sole discovery entry point; updates and runs must never corrupt or invent configuration.
- Expected result: All M1 acceptance criteria pass; Hello World reproducible from configuration alone.
### Entry 5.2 — after
- Timestamp: 2026-09-03
- Commands run: `scripts/test-config.sh` (20 cases); fail-closed checks (compose without launcher env, verify/reset with missing config); `scripts/bootstrap.sh`; config-driven `scripts/hello.sh`, `scripts/verify.sh`, negative marker test, sandboxed reset symlink refusal (canary survived), real reset + bootstrap + build + verify; config checksum comparison across the entire flow.
- Observed result:
- Config selftests: 20 passed, 0 failed.
- Fail-closed confirmed: compose exits 1 without launcher env; verify/reset exit 1 on missing config before any mutation.
- Bootstrap created `~/.config/mosaic-dev/config.json` exclusively; second run validated without rewriting (content + mtime unchanged).
- Config-driven hello/verify returned exactly `MOSAIC_HELLO_OK`; verify exit 0; negative marker test exit 1.
- Reset refused symlinked dataRoot; canary file survived; real reset removed only the configured data root.
- config.json checksum unchanged across hello/verify/reset/bootstrap/build/verify.
- Failure or correction:
1. Selftest harness bug: `cfg` helper invoked without a body for the symlink case (`$2: unbound variable`). Fixed in the harness; product code unaffected.
2. E2E rerun-after-reset failure: `verify.sh` did not ensure the configured data root existed before the container mount. With the data root absent, Docker auto-created the host path as root:root, and the container's uid-1000 user could not write the generated system prompt. Fixed by calling `bootstrap_runtime_dir` in `verify.sh`; also hardened it to fail with a clear message when the data root exists but is not writable (root-owned leftover). Clean-slate E2E rerun: all steps green.
- Credential check: no credential material in config, scripts, logs, or test output.
## Result (M1)
Configuration-driven Hello World verified. `main` merged with M1 and tagged `config-hello-v1`.
---
## Phase 6: Mission and task abstraction (M2)
### Entry 6.1 — before
- Timestamp: 2026-09-03
- Intended action: Add the first mission/task layer, host-side only (Gitea milestone M2, issues #6-#9): strict v1 schemas for missions and tasks, a task runner executing through the proven config-driven container path, immutable write-once run records under `<dataRoot>/runs/`, `expectExact` gating, timeouts, selftests, fixtures, and docs.
- Reason: The foundation plan's following layer — mission (objective + directives), task (bounded unit), run (one attempt), result (immutable evidence) — must exist as data and records before any policy or multi-agent work.
- Expected result: `scripts/run-task.sh tasks/hello-marker.json` succeeds with exactly `MOSAIC_HELLO_OK`; wrong expectations fail; every run leaves an immutable record; configuration remains untouched.
### Entry 6.2 — after
- Timestamp: 2026-09-03
- Commands run: `scripts/test-task.sh` (18 cases incl. live runs); `scripts/run-task.sh validate` / `run` on committed fixtures; `node scripts/mosaic-task.mjs list`; config checksum comparison across runs.
- Observed result:
- Selftests: 18 passed, 0 failed (schema negatives; live exact-marker success; wrong expectExact fails; distinct run dirs; result.json contents; list).
- Fixture run: status `succeeded`, response exactly `MOSAIC_HELLO_OK`, mission snapshot recorded.
- Run records written once under `<dataRoot>/runs/r-<utcstamp>-<rand>/`; reruns never clobber.
- Failure or correction: none this phase.
- Credential check: no credential material in task data, run records, or logs.
## Result (M2)
Mission/task layer verified end-to-end. `main` merged with M2 and tagged `mission-task-v1`.
---
## Phase 7: Release model and safe updates (M3)
### Entry 7.1 — before
- Timestamp: 2026-09-03
- Intended action: Add the release substrate (Gitea milestone M3, issues #10-#13): RELEASE file single-sources the version (0.0.X line per owner direction), image tags derive from it, scripts/release.sh provides package/activate/rollback/status, activation is health-gated by the M2 task runner, pointer + append-only log under <dataRoot>/state/.
- Reason: The owner's top invariant — updates must never corrupt a working installation — needs a mechanism, not a convention: gate-then-flip with recorded history and rollback.
- Expected result: Update, refusal, and rollback drills all green with config checksums unchanged.
### Entry 7.2 — after
- Timestamp: 2026-09-03
- Commands run: scripts/test-release.sh (14 cases); recorded drills: update (0.0.3 -> 0.0.4 package+activate+verify), fault-injected refusal, rollback to 0.0.3.
- Observed result:
- Selftests: 14 passed, 0 failed.
- Update drill: packaged and activated r0.0.4 after exact-marker health gate; verify green under the new tag; config checksum unchanged.
- Refusal drill: health-gate fault injection -> activation refused (exit 1), pointer untouched, refusal appended to the log.
- Rollback drill: health-gated rollback to r0.0.3; pointer restored; log records package/activate/refused/rollback history append-only.
- Failure or correction:
1. release.sh initially failed with missing state/ directory (no mkdir before pointer/log writes); fixed.
2. Selftest harness mutated the repo RELEASE and restored the mutated copy (mv-back bug) plus a second trap replacing the first; fixed with inline backup restore and one self-healing exit trap. Product code unaffected.
- Credential check: no credential material in release state, logs, or drills.
## Result (M3)
Release model and safe updates verified by drills. `main` merged with M3 and tagged `release-model-v1`.
---
## Phase 8: Runtime adapter seam (M4)
### Entry 8.1 — before
- Timestamp: 2026-09-03
- Intended action: Formalize the harness boundary (Gitea milestone M4, issues #16-#19): documented adapter contract under /opt/mosaic/adapters/<name>/adapter.sh; run-agent.sh becomes a dispatcher; pi extracted unchanged; deterministic mock adapter for provider-free seam tests; config gains optional execution.adapter (default pi, configVersion unchanged); mission directives gain their sanctioned injection point via the run snapshot; RELEASE bumps to 0.0.5 with a health-gated activation.
- Reason: Future harnesses (Claude, Codex, OpenCode) must be additive — one directory each — and mission content needs a single sanctioned path into the runtime.
- Expected result: All suites green including new deterministic seam cases; 0.0.5 activated by health gate; mission-bearing run recorded.
### Entry 8.2 — after
- Timestamp: 2026-09-03
- Commands run: scripts/test-config.sh; scripts/test-task.sh; scripts/test-release.sh; manual seam drills (mock verbatim, unknown/traversal adapter refusal); mission injection checks; release package + activate for 0.0.5.
- Observed result:
- Config suite 24/24 (adapter default/validation/env export).
- Task suite 24/24 including deterministic mock cases (gate pass, expect-mismatch with reason, unknown adapter fail-closed) and mission injection asserted by prompt content.
- Release suite 14/14; image mosaic-poc-agent:0.84.4-r0.0.5 packaged and activated via exact-marker health gate.
- Mission directives now flow: task -> run snapshot -> container env -> generated prompt MISSION (runtime) section.
- Failure or correction:
1. Selection authority settled: load_config always exports MOSAIC_ADAPTER from config; environment overrides for scripts are therefore not a supported selection path (by design).
2. Selftest harness: three authoring defects fixed (helpers used before definition; one config file reused across cases leaking adapter state; a static mission fixture asserted against distinctive seam directives; plus an accidentally duplicated live block removed).
- Credential check: no credential material in adapters, prompts, run records, or logs.
## Result (M4)
Adapter seam verified; harness boundary is now additive by construction. `main` merged with M4 and tagged `adapter-seam-v1`.
---
## Phase 9: Workspaces + capability envelope (M5)
### Entry 9.1 — before
- Timestamp: 2026-09-03
- Intended action: Optional task workspace (absent / ":run" ephemeral / named persistent) and capabilities.tools allowlist (pi built-ins); runner plumbing via MOSAIC_WORKSPACE/MOSAIC_TOOLS; pi adapter maps to cwd + --tools; mock adapter logs delivered MOSAIC_* vars for deterministic assertions (Gitea #20, #21).
- Reason: Agents that only answer text cannot do work; the workspace+tools pair is the smallest real capability step, bounded by the container.
- Expected result: plumbing asserted via run-record stderr; live pi writes a host-visible file.
### Entry 9.2 — after
- Timestamp: 2026-09-03
- Commands run: build; mock plumbing run; validate negatives; full task suite; live workspace demo.
- Observed result: task suite 32/32; MOSAIC_WORKSPACE/MOSAIC_TOOLS asserted in run record; dataRoot/workspaces/<name> created host-side; live pi used bash to write proof.txt into the demo workspace (host-visible).
- Failure or correction: (1) batch edit dropped SUPPORTED_TOOLS const (runtime ReferenceError, exit 1 instead of 2) — restored; (2) mock env dump used `export` which this dash prints as `export K='v'` — switched to `env`; (3) selftest fed a mismatching mock response to the plain-task case — test bug, fixed.
## Phase 10: Named sessions (M6)
### Entry 10.1 — before
- Timestamp: 2026-09-03
- Intended action: Optional task.session name -> persistent session dir dataRoot/sessions/<name> via pi --session-dir; resume most recent with -c when present; isolation per name; teach/recall demo fixtures (Gitea #22, #23).
- Reason: L1 persistence is the prerequisite for any multi-step agent work.
- Expected result: session dir populated after first run; second run recalls taught context.
### Entry 10.2 — after
- Timestamp: 2026-09-03
- Commands run: build; mock plumbing run; live teach/recall E2E; task suite.
- Observed result: task suite 32/32; teach run replied REMEMBERED and session JSONL persisted host-side; recall run resumed (-c) and answered exactly 'mosaico'; single continued session file (no duplicate sessions).
- Failure or correction: none. Design note: ephemeral (--no-session) remains the default when no session is declared.
## Phase 11: Operator ergonomics (M7)
### Entry 11.1 — before
- Timestamp: 2026-09-03
- Intended action: mosaic-task.mjs show <runId> (full record + snapshots + artifacts, traversal-safe), list with workspace/session columns, RELEASE -> 0.0.6, package + health-gated activate, docs (Gitea #24).
- Reason: Run records are only as valuable as they are inspectable; release activation closes the loop on container-content changes.
- Expected result: show works for real/missing/traversal ids; suites green; 0.0.6 active.
### Entry 11.2 — after
- Timestamp: 2026-09-03
- Commands run: build; show on real/missing/traversal ids; full sweep; release package + activate.
- Observed result: config 24/24, task 32/32, release 14/14, verify PASS; 0.0.6 packaged and activated via exact-marker health gate.
- Failure or correction: showRun initially rejected valid run ids (lowercase-only regex vs uppercase timestamp) and crashed on missing ids (uncaught readdir) — both fixed and covered.
## Autonomous run result
M5 tagged `workspace-capabilities-v1`, M6 tagged `sessions-v1`, M7 tagged `operator-ergonomics-v1`; release 0.0.6 active. Tracker: docs/plans/2026-09-03_autonomous-run.md.
---
## Phase 12: Conductor loop — self-orchestration (M8)
### Entry 12.1 — before
- Timestamp: 2026-09-03
- Intended action: Stand up the poor-man orchestration loop per docs/plans/CONDUCTOR.md: conductor (host, holds git) mirrors the repo into a worker workspace, dispatches a headless pi worker (session worker-1, tools read/write/edit/bash) to implement retry <runId>, reviews the diff, integrates, verifies (Gitea #25, #26, #27).
- Reason: The owner asked for circular task processing with agent workers; the stack now has every primitive needed — this proves it on the stack itself.
- Expected result: worker-authored retry merged with suites green and a live retry verified.
### Entry 12.2 — after
- Timestamp: 2026-09-03
- Commands run: repo mirror clone; worker dispatch (tasks/worker-retry.json); diff review; apply; live retry; refinement dispatch (tasks/worker-retry-refine.json); second review; reverse+reapply combined patch; conductor interpolation fix; live retry; full sweep.
- Observed result:
- Worker round 1: implemented retry correctly per spec in 2m28s; diff reviewed clean.
- Live retry exposed a spec gap (direct invocation lacks launcher env exports).
- Worker round 2 (same session, 59s): made spawnEnv self-sufficient, but used PI_* names where compose interpolates MOSAIC_*.
- Conductor hotfix: 3-line rename to MOSAIC_PROVIDER/MOSAIC_MODEL/MOSAIC_DATA_ROOT (too trivial for a worker round).
- Final: live retry succeeded (replied REMEMBERED, new run recorded); all suites green.
- Failure or correction: three rounds total — one spec gap (conductor), one naming mismatch (worker), one trivial rename (conductor). Each was caught by mechanical verification (run record stderr), never by hope.
- Attribution: feature authored by headless pi worker (glm-5.3-flash) in sessions worker-1; conductor reviewed, integrated, and hotfixed.
## Result (M8)
Conductor loop proven end-to-end on the stack itself. `main` merged with M8; release 0.0.6 remains active (retry is host-side only, no image change).
---
## Phase 13: Mission-level capability policy (M9)
### Entry 13.1 — before
- Timestamp: 2026-09-03
- Intended action: Missions may declare capabilities.tools as governing constraints (Gitea #30); merge semantics = least-privilege intersection (task narrows, never widens; empty intersection = tool-free run). Host-side only.
- Reason: First mechanical restriction layer — the trust model becomes enforced, not instructed.
- Expected result: all four merge cases asserted from run evidence; suites green.
### Entry 13.2 — after
- Observed: four merge cases verified deterministically via run-record stderr (mission-only, task-only, narrowed, emptied); invalid mission capabilities exit 2; task suite 41/41.
- Failure or correction: selftest harness could not express ABSENT vs EMPTY fields via its printf helper — fixed with an ABSENT marker; two suite config-leak defects fixed (per-command env scoping). Product unaffected.
## Phase 14: Session forking (M11)
### Entry 14.1 — before
- Timestamp: 2026-09-03
- Intended action: sessionForkFrom task field branches the source session's newest file (pi --fork) into the target session dir; ancestor untouched; RELEASE -> 0.0.7 with health-gated activation (Gitea #33).
- Reason: Owner flagged conversation forking from a common ancestor as a desired property; pi JSONL trees make it native.
- Expected result: forked child recalls ancestor context; ancestor file untouched; suites green; 0.0.7 active.
### Entry 14.2 — after
- Observed: mock plumbing asserts fork source + target delivery; validation rejects fork-without-target and self-fork (exit 2); ghost source exits 4; live fork: child recalled 'mosaico' from ancestor context while the ancestor session file remained untouched (file-level assertion); suites 58/24/14 + verify green; 0.0.7 packaged and activated via health gate.
- Failure or correction: retryRun-style self-assignment bug in validation (compared null to target) — caught by negative test, fixed.
## Result (M11)
Session forking verified. `main` merged with M11, tagged `session-fork-v1`; release 0.0.7 active.
-10
View File
@@ -1,10 +0,0 @@
# CLAUDE Compatibility Pointer
This file exists so Claude Code sessions load Mosaic standards.
## MANDATORY — Read Before Any Response
BEFORE responding to any user message, READ `~/.config/mosaic/AGENTS.md`.
That file is the universal agent configuration. Do NOT respond until you have loaded it.
Then read the project-local `AGENTS.md` in this repository for project-specific guidance.
+43
View File
@@ -0,0 +1,43 @@
# Minimal Mosaic Stack POC agent image.
# Base: maintained Node.js image (same family as Pi's documented
# containerization example in docs/containerization.md).
FROM node:24-bookworm-slim
# Tools Pi's documented container image expects (bash, CA certs, git, ripgrep).
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Non-root user: the maintained node image ships a 'node' user at
# uid/gid 1000, which matches the host user that owns the runtime
# state directory mounted at /var/lib/mosaic. It is reused as-is.
# Pinned Pi install: package.json pins the exact version and
# package-lock.json is installed with npm ci. No unversioned installs.
WORKDIR /opt/app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
# Immutable contract fixtures (required location), runtime scripts, and
# runtime adapters.
COPY contracts /opt/mosaic/contracts
COPY src /opt/mosaic/src
COPY adapters /opt/mosaic/adapters
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
&& chmod 0555 /opt/mosaic/adapters /opt/mosaic/adapters/*/adapter.sh
# Writable state, workspace, and pi agent directory (auth.json is
# bind-mounted read-only at runtime; nothing is copied into the image).
RUN mkdir -p /var/lib/mosaic /workspace /home/node/.pi/agent \
&& chown -R node:node /var/lib/mosaic /workspace /home/node /opt/app
USER node
WORKDIR /workspace
ENV HOME=/home/node \
PATH="/opt/app/node_modules/.bin:${PATH}" \
PI_OFFLINE=1
# One-shot agent: args form the user request (default is the startup
# verification request defined in compose.yaml).
ENTRYPOINT ["/opt/mosaic/src/run-agent.sh"]
-61
View File
@@ -1,61 +0,0 @@
# Cron Job Configuration - Issue #29
## Overview
Implement cron job configuration for Mosaic Stack, likely as a MoltBot plugin for scheduled reminders/commands.
## Requirements (inferred from CLAUDE.md pattern)
### Plugin Structure
```
plugins/mosaic-plugin-cron/
├── SKILL.md # MoltBot skill definition
├── src/
│ └── cron.service.ts
└── cron.service.test.ts
```
### Core Features
1. Create/update/delete cron schedules
2. Trigger MoltBot commands on schedule
3. Workspace-scoped (RLS)
4. PDA-friendly UI
### API Endpoints (inferred)
- `POST /api/cron` - Create schedule
- `GET /api/cron` - List schedules
- `DELETE /api/cron/:id` - Delete schedule
### Database (Prisma)
```prisma
model CronSchedule {
id String @id @default(uuid())
workspaceId String
expression String // cron expression
command String // MoltBot command to trigger
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([workspaceId])
}
```
## TDD Approach
1. **RED** - Write tests for CronService
2. **GREEN** - Implement minimal service
3. **REFACTOR** - Add CRUD controller + API endpoints
## Next Steps
- [ ] Create feature branch: `git checkout -b feature/29-cron-config`
- [ ] Write failing tests for cron service
- [ ] Implement service (Green)
- [ ] Add controller & routes
- [ ] Add Prisma schema migration
- [ ] Create MoltBot skill (SKILL.md)
+50
View File
@@ -0,0 +1,50 @@
# LAYERS
Deferred capability layers for the Mosaic experiment. Only L0 is implemented by
this proof of concept; everything below it is documented here and deliberately
not implemented (see BRIEF.md, "Explicit exclusions").
## L0 — Implemented: container returns MOSAIC_HELLO_OK
One image (`mosaic-poc-agent:0.84.4`, built on `node:24-bookworm-slim`, non-root,
pinned Pi) runs one Pi agent one-shot. Four immutable local contract files are
loaded in fixed order into the generated system prompt
(`/var/lib/mosaic/system-prompt.md`). One real model request is sent
noninteractively; the response must equal `MOSAIC_HELLO_OK` exactly or the
verification exits nonzero. Authentication is supplied at runtime only
(read-only mounted pi auth file, or a provider API key environment variable).
## L1 — Deferred: persist and resume a named Pi session
Keep a named Pi session across container runs (`--name`, session storage under
`/var/lib/mosaic`), resume it with the documented session flags, and verify
state survives a container restart.
## L2 — Deferred: fixed tool permission policy
Add a fixed allow/deny policy for Pi tools (e.g. restricting built-in tools via
documented `--tools` / `--exclude-tools` or an extension-based permission gate),
so contract files can constrain what the agent may do, not just what it says.
## L3 — Deferred: load full versioned contract bundles
Replace the four static fixtures with versioned contract bundles: bundle
manifests, contract versions, and deterministic ordering/hashing, loaded from
an immutable bundle artifact instead of files copied at image build time.
## L4 — Deferred: Claude as a second runtime
Add a second runtime (Claude) alongside the Pi agent in the same container
stack, behind the same contract-loading path, to compare behavior across
runtimes.
## L5 — Deferred: multiple agents and communication
Run several named agents with defined roles and a communication channel between
them (message passing or shared state under `/var/lib/mosaic`).
## L6 — Deferred: orchestration, knowledge storage, and portal features
Fleet-level orchestration, knowledge storage, monitoring, and portal UI on top
of L1-L5. This is where the existing Mosaic Stack concepts would be re-evaluated
from first principles.
-145
View File
@@ -1,145 +0,0 @@
.PHONY: help install dev build test docker-up docker-down docker-logs docker-ps docker-build docker-restart docker-test speech-up speech-down speech-logs clean matrix-up matrix-down matrix-logs matrix-setup-bot
# Default target
help:
@echo "Mosaic Stack - Available commands:"
@echo ""
@echo "Development:"
@echo " make install Install dependencies"
@echo " make dev Start development servers"
@echo " make build Build all applications"
@echo " make test Run all tests"
@echo " make lint Run linters"
@echo " make format Format code"
@echo ""
@echo "Docker:"
@echo " make docker-up Start Docker services (core)"
@echo " make docker-up-full Start Docker services (all)"
@echo " make docker-up-traefik Start with bundled Traefik"
@echo " make docker-down Stop Docker services"
@echo " make docker-logs View Docker logs"
@echo " make docker-ps Show Docker service status"
@echo " make docker-build Rebuild Docker images"
@echo " make docker-restart Restart Docker services"
@echo " make docker-test Run Docker smoke test"
@echo " make docker-test-traefik Run Traefik integration tests"
@echo ""
@echo "Speech Services:"
@echo " make speech-up Start speech services (STT + TTS)"
@echo " make speech-down Stop speech services"
@echo " make speech-logs View speech service logs"
@echo ""
@echo "Matrix Dev Environment:"
@echo " make matrix-up Start Matrix services (Synapse + Element)"
@echo " make matrix-down Stop Matrix services"
@echo " make matrix-logs View Matrix service logs"
@echo " make matrix-setup-bot Create bot account and get access token"
@echo ""
@echo "Database:"
@echo " make db-migrate Run database migrations"
@echo " make db-seed Seed development data"
@echo " make db-studio Open Prisma Studio"
@echo " make db-reset Reset database (WARNING: deletes data)"
@echo ""
@echo "Cleanup:"
@echo " make clean Clean build artifacts"
@echo " make clean-all Clean everything including node_modules"
@echo " make docker-clean Remove Docker containers and volumes"
# Development
install:
pnpm install
dev:
pnpm dev
build:
pnpm build
test:
pnpm test
lint:
pnpm lint
format:
pnpm format
# Docker operations
docker-up:
docker compose up -d
docker-up-full:
docker compose --profile full up -d
docker-up-traefik:
docker compose --profile traefik-bundled up -d
docker-down:
docker compose down
docker-logs:
docker compose logs -f
docker-ps:
docker compose ps
docker-build:
docker compose build
docker-restart:
docker compose restart
docker-test:
./scripts/test-docker-deployment.sh
docker-test-traefik:
./tests/integration/docker/traefik.test.sh all
# Speech services
speech-up:
docker compose -f docker-compose.yml -f docker-compose.speech.yml up -d speaches kokoro-tts
speech-down:
docker compose -f docker-compose.yml -f docker-compose.speech.yml down --remove-orphans
speech-logs:
docker compose -f docker-compose.yml -f docker-compose.speech.yml logs -f speaches kokoro-tts
# Matrix Dev Environment
matrix-up:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml up -d
matrix-down:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml down
matrix-logs:
docker compose -f docker/docker-compose.yml -f docker/docker-compose.matrix.yml logs -f synapse element-web
matrix-setup-bot:
docker/matrix/scripts/setup-bot.sh
# Database operations
db-migrate:
cd apps/api && pnpm prisma:migrate
db-seed:
cd apps/api && pnpm prisma:seed
db-studio:
cd apps/api && pnpm prisma:studio
db-reset:
cd apps/api && pnpm prisma:reset
# Cleanup
clean:
pnpm clean
clean-all:
pnpm clean
rm -rf node_modules
docker-clean:
docker compose down -v
docker system prune -f
+165 -766
View File
@@ -1,817 +1,216 @@
# Mosaic Stack
# Minimal Mosaic Stack container POC
Multi-tenant personal assistant platform with PostgreSQL backend, Authentik SSO, and MoltBot integration.
Standalone experiment, not part of the Mosaic Stack repository or Software Factory.
## Overview
One container image runs one Pi coding agent with four immutable local contract
files as its system prompt, sends exactly one real model request, and is verified
to return exactly `MOSAIC_HELLO_OK`.
Mosaic Stack is a modern, PDA-friendly platform designed to help users manage their personal and professional lives with:
## Layout
- **Multi-user workspaces** with team collaboration
- **Knowledge management** with wiki-style linking and version history
- **Task management** with flexible organization
- **Event & calendar** integration
- **Project tracking** with Gantt charts and Kanban boards
- **MoltBot integration** for natural language interactions
- **Authentik OIDC** for secure, enterprise-grade authentication
**Version:** 0.0.1 (Pre-MVP)
**Repository:** https://git.mosaicstack.dev/mosaic/stack
## Technology Stack
| Layer | Technology |
| -------------- | ---------------------------------------------- |
| **Frontend** | Next.js 16 + React + TailwindCSS + Shadcn/ui |
| **Backend** | NestJS + Prisma ORM |
| **Database** | PostgreSQL 17 + pgvector |
| **Cache** | Valkey (Redis-compatible) |
| **Auth** | Authentik (OIDC) via BetterAuth |
| **AI** | Ollama (local or remote) |
| **Messaging** | MoltBot (stock + plugins) |
| **Real-time** | WebSockets (Socket.io) |
| **Speech** | Speaches (STT) + Kokoro/Chatterbox/Piper (TTS) |
| **Monorepo** | pnpm workspaces + TurboRepo |
| **Testing** | Vitest + Playwright |
| **Deployment** | Docker + docker-compose |
## Quick Start
### One-Line Install (Recommended)
The fastest way to get Mosaic Stack running on macOS or Linux:
```bash
curl -fsSL https://get.mosaicstack.dev | bash
```text
BRIEF.md requirements for the original container proof
BUILD-LOG.md append-only build/verification log
LAYERS.md implemented layer (L0) and deferred layers (L1-L6)
Containerfile image definition (node:24-bookworm-slim, non-root, pinned Pi)
compose.yaml one service: mosaic-agent (one-shot; configured via env)
package.json pins @earendil-works/pi-coding-agent at exactly 0.84.4
package-lock.json resolved lockfile used by npm ci in the image
.env.example non-secret settings only (credential-file path, env-var auth)
contracts/ CONSTITUTION.md, STANDARDS.md, SOUL.md, USER.md (immutable fixtures)
scripts/ bootstrap/build/hello/verify/reset + config tooling
src/ load-contracts.sh, run-agent.sh (run inside the container)
docs/plans/ architecture and milestone plans
```
This installer:
- ✅ Detects your platform (macOS, Debian/Ubuntu, Arch, Fedora)
- ✅ Installs all required dependencies (Docker, Node.js, etc.)
- ✅ Generates secure secrets automatically
- ✅ Configures the environment for you
- ✅ Starts all services with Docker Compose
- ✅ Validates the installation with health checks
**Installer Options:**
```bash
# Non-interactive Docker deployment
curl -fsSL https://get.mosaicstack.dev | bash -s -- --non-interactive --mode docker
# Preview installation without making changes
curl -fsSL https://get.mosaicstack.dev | bash -s -- --dry-run
# With SSO and local Ollama
curl -fsSL https://get.mosaicstack.dev | bash -s -- \
--mode docker \
--enable-sso --bundled-authentik \
--ollama-mode local
# Skip dependency installation (if already installed)
curl -fsSL https://get.mosaicstack.dev | bash -s -- --skip-deps
```
**After Installation:**
```bash
# Check system health
./scripts/commands/doctor.sh
# View service logs
docker compose logs -f
# Stop services
docker compose down
```
### Prerequisites
If you prefer manual installation, you'll need:
- **Docker mode:** Docker 24+ and Docker Compose
- **Native mode:** Node.js 24+, pnpm 10+, PostgreSQL 17+
The installer handles these automatically.
### Manual Installation
```bash
# Clone the repository
git clone https://git.mosaicstack.dev/mosaic/stack mosaic-stack
cd mosaic-stack
# Install dependencies
pnpm install
# Copy environment file
cp .env.example .env
# Configure environment variables (see CONFIGURATION.md)
# Edit .env with your database and auth settings
# Generate Prisma client
pnpm prisma:generate
# Run database migrations
pnpm prisma:migrate
# Seed development data (optional)
pnpm prisma:seed
# Start development servers
pnpm dev
```
### Docker Deployment
**Recommended for quick setup and production deployments.**
#### Development (Turnkey - All Services Bundled)
```bash
# Clone repository
git clone https://git.mosaicstack.dev/mosaic/stack mosaic-stack
cd mosaic-stack
# Copy and configure environment
cp .env.example .env
# Set COMPOSE_PROFILES=full in .env
# Start all services (PostgreSQL, Valkey, OpenBao, Authentik, Ollama, API, Web)
docker compose up -d
# View logs
docker compose logs -f
# Access services
# Web: http://localhost:3000
# API: http://localhost:3001
# Auth: http://localhost:9000
```
#### Production (External Managed Services)
```bash
# Clone repository
git clone https://git.mosaicstack.dev/mosaic/stack mosaic-stack
cd mosaic-stack
# Copy environment template and example
cp .env.example .env
cp docker/docker-compose.example.external.yml docker-compose.override.yml
# Edit .env with external service URLs:
# - DATABASE_URL=postgresql://... (RDS, Cloud SQL, etc.)
# - VALKEY_URL=redis://... (ElastiCache, Memorystore, etc.)
# - OPENBAO_ADDR=https://... (HashiCorp Vault, etc.)
# - OIDC_ISSUER=https://... (Auth0, Okta, etc.)
# - Set COMPOSE_PROFILES= (empty)
# Start API and Web only
docker compose up -d
# View logs
docker compose logs -f
```
#### Hybrid (Mix of Bundled and External)
```bash
# Use bundled database/cache, external auth/secrets
cp docker/docker-compose.example.hybrid.yml docker-compose.override.yml
# Edit .env:
# - COMPOSE_PROFILES=database,cache,ollama
# - OPENBAO_ADDR=https://... (external vault)
# - OIDC_ISSUER=https://... (external auth)
# Start mixed deployment
docker compose up -d
```
**Stop services:**
```bash
docker compose down
```
**What's included:**
- PostgreSQL 17 with pgvector extension
- Valkey (Redis-compatible cache)
- Mosaic API (NestJS)
- Mosaic Web (Next.js)
- Mosaic Orchestrator (Agent lifecycle management)
- Mosaic Coordinator (Task assignment & monitoring)
- Authentik OIDC (optional, use `--profile authentik`)
- Ollama AI (optional, use `--profile ollama`)
See [Docker Deployment Guide](docs/1-getting-started/4-docker-deployment/) for complete documentation.
### Docker Swarm Deployment (Production)
**Recommended for production deployments with high availability and auto-scaling.**
Deploy to a Docker Swarm cluster with integrated Traefik reverse proxy:
```bash
# 1. Initialize swarm (if not already done)
docker swarm init --advertise-addr <your-ip>
# 2. Create Traefik network
docker network create --driver=overlay traefik-public
# 3. Configure environment for swarm
cp .env.swarm.example .env
nano .env # Configure domains, passwords, API keys
# 4. CRITICAL: Deploy OpenBao standalone FIRST
# OpenBao cannot run in swarm mode - deploy as standalone container
docker compose -f docker-compose.openbao.yml up -d
sleep 30 # Wait for auto-initialization
# 5. Deploy swarm stack
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
# 6. Check deployment status
docker stack services mosaic
docker stack ps mosaic
# Access services via Traefik
# Web: http://mosaic.mosaicstack.dev
# API: http://api.mosaicstack.dev
# Auth: http://auth.mosaicstack.dev (if using bundled Authentik)
```
**Key features:**
- Automatic Traefik integration for routing
- Overlay networking for multi-host deployments
- Built-in health checks and rolling updates
- Horizontal scaling for web and API services
- Zero-downtime deployments
- Service orchestration across multiple nodes
**Important Notes:**
- **OpenBao Requirement:** OpenBao MUST be deployed as standalone container (not in swarm). Use `docker-compose.openbao.yml` or external Vault.
- Swarm does NOT support docker-compose profiles
- To use external services (PostgreSQL, Authentik, etc.), manually comment them out in `docker-compose.swarm.yml`
See [Docker Swarm Deployment Guide](docs/SWARM-DEPLOYMENT.md) and [Quick Reference](docs/SWARM-QUICKREF.md) for complete documentation.
### Portainer Deployment
**Recommended for GUI-based stack management.**
Portainer provides a web UI for managing Docker containers and stacks. Use the Portainer-optimized compose file:
**File:** `docker-compose.portainer.yml`
**Key differences from standard compose:**
- No `env_file` directive (define variables in Portainer UI)
- Port exposed on all interfaces (Portainer limitation)
- Optimized for Portainer's stack parser
**Quick Steps:**
1. Create `mosaic_internal` overlay network in Portainer
2. Deploy `mosaic-openbao` stack with `docker-compose.portainer.yml`
3. Deploy `mosaic` swarm stack with `docker-compose.swarm.yml`
4. Configure environment variables in Portainer UI
See [Portainer Deployment Guide](docs/PORTAINER-DEPLOYMENT.md) for detailed instructions.
## Project Structure
```
mosaic-stack/
├── apps/
│ ├── api/ # NestJS backend API
│ │ ├── src/
│ │ │ ├── auth/ # BetterAuth + Authentik OIDC
│ │ │ ├── prisma/ # Database service
│ │ │ ├── coordinator-integration/ # Coordinator API client
│ │ │ └── app.module.ts # Main application module
│ │ ├── prisma/
│ │ │ └── schema.prisma # Database schema
│ │ └── Dockerfile
│ ├── web/ # Next.js 16 frontend
│ │ ├── app/
│ │ ├── components/
│ │ │ └── widgets/ # HUD widgets (agent status, etc.)
│ │ └── Dockerfile
│ ├── orchestrator/ # Agent lifecycle & spawning (NestJS)
│ │ ├── src/
│ │ │ ├── spawner/ # Agent spawning service
│ │ │ ├── queue/ # Valkey-backed task queue
│ │ │ ├── monitor/ # Health monitoring
│ │ │ ├── git/ # Git worktree management
│ │ │ └── killswitch/ # Emergency agent termination
│ │ └── Dockerfile
│ └── coordinator/ # Task assignment & monitoring (FastAPI)
│ ├── src/
│ │ ├── webhook.py # Gitea webhook receiver
│ │ ├── parser.py # Issue metadata parser
│ │ └── security.py # HMAC signature verification
│ └── Dockerfile
├── packages/
│ ├── shared/ # Shared types & utilities
│ │ └── src/types/
│ │ ├── auth.types.ts # Auth types (AuthUser, Session, etc.)
│ │ ├── database.types.ts # DB entity types
│ │ └── enums.ts # Shared enums
│ ├── ui/ # Shared UI components (planned)
│ └── config/ # Shared configuration (planned)
├── plugins/ # MoltBot skills (planned)
│ ├── mosaic-plugin-brain/ # API query skill
│ ├── mosaic-plugin-calendar/ # Calendar skill
│ └── mosaic-plugin-tasks/ # Task management skill
├── docker/
│ ├── docker-compose.yml # Production deployment
│ └── init-scripts/ # PostgreSQL initialization
├── docs/
│ ├── SETUP.md # Installation guide
│ ├── CONFIGURATION.md # Environment configuration
│ ├── DESIGN-PRINCIPLES.md # PDA-friendly design patterns
│ ├── API.md # API documentation
│ ├── TYPE-SHARING.md # Type sharing strategy
│ └── scratchpads/ # Development notes
├── .env.example # Environment template
├── turbo.json # TurboRepo configuration
└── pnpm-workspace.yaml # Workspace configuration
```
## Agent Orchestration Layer (v0.0.6)
Mosaic Stack includes a sophisticated agent orchestration system for autonomous task execution:
- **Orchestrator Service** (NestJS) - Manages agent lifecycle, spawning, and health monitoring
- **Coordinator Service** (FastAPI) - Receives Gitea webhooks, assigns tasks to agents
- **Task Queue** - Valkey-backed queue for distributed task management
- **Git Worktrees** - Isolated workspaces for parallel agent execution
- **Killswitch** - Emergency stop mechanism for runaway agents
- **Agent Dashboard** - Real-time monitoring UI with status widgets
See [Agent Orchestration Design](docs/design/agent-orchestration.md) for architecture details.
## Speech Services
Mosaic Stack includes integrated speech-to-text (STT) and text-to-speech (TTS) capabilities through a modular provider architecture. Each component is optional and independently configurable.
- **Speech-to-Text** - Transcribe audio files and real-time audio streams using Whisper (via Speaches)
- **Text-to-Speech** - Synthesize speech with 54+ voices across 8 languages (via Kokoro, CPU-based)
- **Premium Voice Cloning** - Clone voices from audio samples with emotion control (via Chatterbox, GPU)
- **Fallback TTS** - Ultra-lightweight CPU fallback for low-resource environments (via Piper/OpenedAI Speech)
- **WebSocket Streaming** - Real-time streaming transcription via Socket.IO `/speech` namespace
- **Automatic Fallback** - TTS tier system with graceful degradation (premium -> default -> fallback)
**Quick Start:**
```bash
# Start speech services alongside core stack
make speech-up
# Or with Docker Compose directly
docker compose -f docker-compose.yml -f docker-compose.speech.yml up -d
```
See [Speech Services Documentation](docs/SPEECH.md) for architecture details, API reference, provider configuration, and deployment options.
## Current Implementation Status
### ✅ Completed (v0.0.1-0.0.6)
- **M1-Foundation:** Project scaffold, PostgreSQL 17 + pgvector, Prisma ORM
- **M2-MultiTenant:** Workspace isolation with RLS, team management
- **M3-Features:** Knowledge management, tasks, calendar, authentication
- **M4-MoltBot:** Bot integration architecture (in progress)
- **M6-AgentOrchestration:** Orchestrator service, coordinator, agent dashboard ✅
**Test Coverage:** 2168+ tests passing
### 🚧 In Progress (v0.0.x)
- Agent orchestration E2E testing
- Usage budget management
- Performance optimization
### 📋 Planned Features (v0.1.0 MVP)
- Event/calendar management
- Project tracking with Gantt charts
- MoltBot integration
- WebSocket real-time updates
- Data migration from jarvis-brain
See the [issue tracker](https://git.mosaicstack.dev/mosaic/stack/issues) for complete roadmap.
## Knowledge Module
The **Knowledge Module** is a powerful personal wiki and knowledge management system built into Mosaic Stack. Create interconnected notes, organize with tags, track changes over time, and visualize relationships.
### Features
- **📝 Markdown-based entries** — Write using familiar Markdown syntax
- **🔗 Wiki-style linking** — Connect entries using `[[wiki-links]]`
- **🏷️ Tag organization** — Categorize and filter with flexible tagging
- **📜 Full version history** — Every edit is tracked and recoverable
- **🔍 Powerful search** — Full-text search across titles and content
- **📊 Knowledge graph** — Visualize relationships between entries
- **📤 Import/Export** — Bulk import/export for portability
- **⚡ Valkey caching** — High-performance caching for fast access
### Quick Examples
**Create an entry:**
```bash
curl -X POST http://localhost:3001/api/knowledge/entries \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "x-workspace-id: WORKSPACE_ID" \
-d '{
"title": "React Hooks Guide",
"content": "# React Hooks\n\nSee [[Component Patterns]] for more.",
"tags": ["react", "frontend"],
"status": "PUBLISHED"
}'
```
**Search entries:**
```bash
curl -X GET 'http://localhost:3001/api/knowledge/search?q=react+hooks' \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "x-workspace-id: WORKSPACE_ID"
```
**Export knowledge base:**
```bash
curl -X GET 'http://localhost:3001/api/knowledge/export?format=markdown' \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "x-workspace-id: WORKSPACE_ID" \
-o knowledge-export.zip
```
### Documentation
- **[User Guide](KNOWLEDGE_USER_GUIDE.md)** — Getting started, features, and workflows
- **[API Documentation](KNOWLEDGE_API.md)** — Complete REST API reference with examples
- **[Developer Guide](KNOWLEDGE_DEV.md)** — Architecture, implementation, and contributing
### Key Concepts
**Wiki-links**
Connect entries using double-bracket syntax:
```markdown
See [[Entry Title]] or [[entry-slug]] for details.
Use [[Page|custom text]] for custom display text.
```
**Version History**
Every edit creates a new version. View history, compare changes, and restore previous versions:
```bash
# List versions
GET /api/knowledge/entries/:slug/versions
# Get specific version
GET /api/knowledge/entries/:slug/versions/:version
# Restore version
POST /api/knowledge/entries/:slug/restore/:version
```
**Backlinks**
Automatically discover entries that link to a given entry:
```bash
GET /api/knowledge/entries/:slug/backlinks
```
**Tags**
Organize entries with tags:
```bash
# Create tag
POST /api/knowledge/tags
{ "name": "React", "color": "#61dafb" }
# Find entries with tags
GET /api/knowledge/search/by-tags?tags=react,frontend
```
### Performance
With Valkey caching enabled:
- **Entry retrieval:** ~2-5ms (vs ~50ms uncached)
- **Search queries:** ~2-5ms (vs ~200ms uncached)
- **Graph traversals:** ~2-5ms (vs ~400ms uncached)
- **Cache hit rates:** 70-90% for active workspaces
Configure caching via environment variables:
```bash
VALKEY_URL=redis://localhost:6379
KNOWLEDGE_CACHE_ENABLED=true
KNOWLEDGE_CACHE_TTL=300 # 5 minutes
```
## Development Workflow
### Branch Strategy
- `main` — Trunk branch (all development merges here)
- `feature/*` — Feature branches from main
- `fix/*` — Bug fix branches from main
### Running Locally
```bash
# Start all services (requires Docker for PostgreSQL)
pnpm dev # All apps
# Or run individually
pnpm dev:api # API only (http://localhost:3001)
pnpm dev:web # Web only (http://localhost:3000)
# Database tools
pnpm prisma:studio # Open Prisma Studio
pnpm prisma:migrate # Run migrations
pnpm prisma:generate # Regenerate Prisma client
```
### Testing
```bash
pnpm test # All tests
pnpm test:api # API tests only
pnpm test:web # Web tests only
pnpm test:e2e # E2E tests with Playwright
pnpm test:coverage # Generate coverage report
```
### Building
```bash
pnpm build # Build all apps
pnpm build:api # Build API only
pnpm build:web # Build web only
```
## Design Philosophy
Mosaic Stack follows strict **PDA-friendly design principles**:
### Language Guidelines
We **never** use demanding or stressful language:
| ❌ NEVER | ✅ ALWAYS |
| ----------- | -------------------- |
| OVERDUE | Target passed |
| URGENT | Approaching target |
| MUST DO | Scheduled for |
| CRITICAL | High priority |
| YOU NEED TO | Consider / Option to |
| REQUIRED | Recommended |
### Visual Principles
- **10-second scannability** — Key info visible immediately
- **Visual chunking** — Clear sections with headers
- **Single-line items** — Compact, scannable lists
- **Calm colors** — No aggressive reds for status indicators
- **Progressive disclosure** — Details on click, not upfront
See [Design Principles](docs/3-architecture/3-design-principles/1-pda-friendly.md) for complete guidelines.
## API Conventions
### Endpoints
```
GET /api/{resource} # List (with pagination, filters)
GET /api/{resource}/:id # Get single item
POST /api/{resource} # Create new item
PATCH /api/{resource}/:id # Update item
DELETE /api/{resource}/:id # Delete item
```
### Authentication
All authenticated endpoints require a Bearer token:
```http
Authorization: Bearer {session_token}
```
See [API Reference](docs/4-api/) for complete API documentation.
## Configuration
Key environment variables:
The sole discovery entry point is:
```bash
# Database
DATABASE_URL=postgresql://mosaic:password@localhost:5432/mosaic
# Authentik OIDC
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
# JWT Session
JWT_SECRET=change-this-to-a-random-secret-in-production
JWT_EXPIRATION=24h
# Application
NEXT_PUBLIC_APP_URL=http://localhost:3000
```text
~/.config/mosaic-dev/config.json
```
See [Configuration](docs/1-getting-started/3-configuration/1-environment.md) for all configuration options.
## Caching
Mosaic Stack uses **Valkey** (Redis-compatible) for high-performance caching, significantly improving response times for frequently accessed data.
### Knowledge Module Caching
The Knowledge module implements intelligent caching for:
- **Entry Details** - Individual knowledge entries (GET `/api/knowledge/entries/:slug`)
- **Search Results** - Full-text search queries with filters
- **Graph Queries** - Knowledge graph traversals with depth limits
### Cache Configuration
Configure caching via environment variables:
Created only by the explicit, idempotent bootstrap:
```bash
# Valkey connection
VALKEY_URL=redis://localhost:6379
# Knowledge cache settings
KNOWLEDGE_CACHE_ENABLED=true # Set to false to disable caching (dev mode)
KNOWLEDGE_CACHE_TTL=300 # Time-to-live in seconds (default: 5 minutes)
scripts/bootstrap.sh # create-if-absent; validates existing config, never rewrites
```
### Cache Invalidation Strategy
Caches are automatically invalidated on data changes:
- **Entry Updates** - Invalidates entry cache, search caches, and related graph caches
- **Entry Creation** - Invalidates search caches and graph caches
- **Entry Deletion** - Invalidates entry cache, search caches, and graph caches
- **Link Changes** - Invalidates graph caches for affected entries
### Cache Statistics & Management
Monitor and manage caches via REST endpoints:
```bash
# Get cache statistics (hits, misses, hit rate)
GET /api/knowledge/cache/stats
# Clear all caches for a workspace (admin only)
POST /api/knowledge/cache/clear
# Reset cache statistics (admin only)
POST /api/knowledge/cache/stats/reset
```
**Example response:**
Minimal shape (`configVersion` 1):
```json
{
"enabled": true,
"stats": {
"hits": 1250,
"misses": 180,
"sets": 195,
"deletes": 15,
"hitRate": 0.874
"configVersion": 1,
"environment": "development",
"dataRoot": "/home/jwoltje/.mosaic-dev",
"execution": {
"backend": "docker",
"provider": "zai",
"model": "glm-5.3-flash"
}
}
```
### Performance Benefits
Rules enforced by `scripts/mosaic-config.mjs`:
- **Entry retrieval:** ~10-50ms → ~2-5ms (80-90% improvement)
- **Search queries:** ~100-300ms → ~2-5ms (95-98% improvement)
- **Graph traversals:** ~200-500ms → ~2-5ms (95-99% improvement)
- Unknown keys, unsupported versions/backends, and malformed JSON exit nonzero; nothing is modified.
- `dataRoot` must be absolute, canonical, and must not be or contain the home or configuration directory.
- Validation failures never touch config, state, or images.
- `scripts/test-config.sh` runs the sandboxed config selftests (no Docker required).
Cache hit rates typically stabilize at 70-90% for active workspaces.
Run paths (`build/hello/verify/reset`) fail closed when configuration is missing or invalid; they never invent it.
## Type Sharing
## Missions & tasks (M2)
Types used by both frontend and backend live in `@mosaic/shared`:
Missions and tasks are validated JSON data (strict schemas, version-pinned). The M2 layer is host-side only: mission directives are recorded for provenance but do not yet reach the runtime system prompt (capability/policy layer comes later).
```typescript
import type { AuthUser, Task, Event } from "@mosaic/shared";
```text
missions/hello.json objective + directives (missionVersion 1)
tasks/hello-marker.json prompt + optional mission ref + expectExact + timeout
<dataRoot>/runs/r-<id>/ immutable run record: task.json, mission.json,
stderr.txt, result.json (all write-once)
```
// Frontend
function UserProfile({ user }: { user: AuthUser }) {
return <div>{user.name}</div>;
}
Usage:
// Backend
@Get("profile")
@UseGuards(AuthGuard)
getProfile(@CurrentUser() user: AuthUser) {
return user;
```bash
scripts/run-task.sh validate tasks/hello-marker.json # strict validation, writes nothing
scripts/run-task.sh run tasks/hello-marker.json # execute; result recorded under dataRoot/runs
scripts/mosaic-task.mjs list # list runs and statuses
scripts/test-task.sh # selftests (schema negatives + live runs)
```
A run exits 0 only when its expectation is met (`expectExact` match); mismatches, nonzero agent exits, and timeouts record `status: failed` in `result.json` and exit 1. Each run gets a unique directory — rerunning never rewrites history.
## Release model (M3)
`RELEASE` single-sources the release version (0.0.X until declared stable); the image tag derives from it plus the pinned Pi version. Activation is health-gated and every event is recorded:
```bash
scripts/release.sh package # build + tag the release image
scripts/release.sh activate # health check (exact marker) -> atomic pointer swap
scripts/release.sh activate --fault-injection # prove the refusal path (drills only)
scripts/release.sh rollback # health-gated return to the previous release
scripts/release.sh status # release, tag, active pointer, recent log
scripts/test-release.sh # release selftests
```
- `<dataRoot>/state/active.json` — the activation pointer (atomic tmp+rename replace)
- `<dataRoot>/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback
A failed health check never activates; the previously active release remains deployed. Updating the software therefore cannot corrupt the running installation: package beside, gate, then flip. Verified by the update/refusal/rollback drills in BUILD-LOG Phase 7.
## Runtime adapters (M4)
The harness boundary is formalized: everything upstream (config, contracts, missions, tasks, run records) is harness-agnostic; everything inside an adapter belongs to one runtime.
```text
adapters/<name>/adapter.sh env in: MOSAIC_SYSTEM_PROMPT_FILE, MOSAIC_REQUEST,
MOSAIC_PROVIDER, MOSAIC_MODEL
stdout: response only; stderr: diagnostics
```
- Selection: `execution.adapter` in config.json (optional; `pi` default; allowlist `pi`, `mock`)
- `pi` — pinned Pi CLI, noninteractive print mode, ambient discovery off
- `mock` — deterministic test adapter; never for real verification
- Mission directives have a sanctioned injection point: when a task references a mission, the task runner mounts the run snapshot and the generated prompt gains a `MISSION (runtime)` section (objective + directives) after the four immutable contracts
- Adding a harness (Claude, Codex, OpenCode) later means adding one directory — no orchestrator changes
See `adapters/README.md` for the full contract.
## Workspaces, capabilities, sessions (M5/M6)
Optional task fields extend what an agent can do — all defaulting to the previous behavior:
```json
{
"workspace": "demo", // ":run" ephemeral, or persistent dataRoot/workspaces/<name>
"capabilities": { "tools": ["bash", "read"] }, // pi tool allowlist; absent = no tools
"session": "demo" // persistent session at dataRoot/sessions/<name>
}
```
See [Type Sharing Strategy](docs/2-development/3-type-sharing/1-strategy.md) for the complete type sharing strategy.
- The adapter runs inside the workspace; files it writes are host-visible (`dataRoot/workspaces/<name>`).
- Sessions persist via pi's documented `--session-dir`; a follow-up run in the same session resumes the conversation (`-c`) and can recall prior context. Distinct names never share state. Ephemeral (`--no-session`) remains the default when no session is declared.
- Selection authority: config for adapter/provider/model; the task file for workspace/capabilities/session.
## Contributing
Inspect anything:
1. Check the [issue tracker](https://git.mosaicstack.dev/mosaic/stack/issues) for open issues
2. Create a feature branch: `git checkout -b feature/my-feature develop`
3. Make your changes with tests (minimum 85% coverage required)
4. Run tests: `pnpm test`
5. Build: `pnpm build`
6. Commit with conventional format: `feat(#issue): Description`
7. Push and create a pull request to `main`
### Commit Format
```
<type>(#issue): Brief description
Detailed explanation if needed.
Fixes #123
```bash
node scripts/mosaic-task.mjs list # runs with task/workspace/session columns
node scripts/mosaic-task.mjs show <runId> # full record + snapshots + artifacts
```
**Types:** `feat`, `fix`, `docs`, `test`, `refactor`, `chore`
Demo fixtures: `tasks/workspace-demo.json`, `tasks/session-demo-1.json` + `tasks/session-demo-2.json`.
## Testing Requirements
See `docs/plans/2026-09-02_atomic-mosaic-foundation.md` for the full plan.
- **Minimum 85% coverage** for new code
- **TDD approach** — Write tests before implementation
- **All tests pass** before PR merge
- Use Vitest for unit/integration tests
- Use Playwright for E2E tests
Inside the container:
## Documentation
```text
/opt/mosaic/contracts immutable contract files
/var/lib/mosaic generated runtime state (mounted from configured dataRoot)
/workspace agent workspace
```
Complete documentation is organized in a Bookstack-compatible structure in the `docs/` directory.
## How it works
### 📚 Getting Started
1. `scripts/build.sh` builds the release image (`mosaic-poc-agent:<pi>-r<release>`,
tag derived from `RELEASE` + the pinned Pi version) with Docker Compose.
2. On each run, `/opt/mosaic/src/load-contracts.sh` reads the four contract files
in fixed order (CONSTITUTION, STANDARDS, SOUL, USER), joins them with clear
separators, and writes `/var/lib/mosaic/system-prompt.md`.
3. `/opt/mosaic/src/run-agent.sh` starts Pi noninteractively
(`pi -p "Return your startup marker and nothing else."`) with
`--system-prompt "$(cat /var/lib/mosaic/system-prompt.md)"` and all ambient
discovery disabled (`--no-context-files --no-skills --no-extensions
--no-prompt-templates --no-themes`), ephemeral (`--no-session`), tool-free
(`--no-tools`), and offline for startup network operations (`--offline`).
4. `scripts/verify.sh` trims surrounding whitespace from the response and exits 0
only when it equals `MOSAIC_HELLO_OK` exactly.
- **[Quick Start](docs/1-getting-started/1-quick-start/1-overview.md)** — Get running in 5 minutes
- **[Installation](docs/1-getting-started/2-installation/)** — Prerequisites, local setup, Docker deployment
- **[Configuration](docs/1-getting-started/3-configuration/)** — Environment variables and Authentik OIDC
## Usage
### 💻 Development
```bash
scripts/bootstrap.sh # create config.json if absent (idempotent)
scripts/build.sh # build the image
scripts/hello.sh # one-shot request; prints the model response
scripts/verify.sh # full gated test; exit 0 only on exact MOSAIC_HELLO_OK
scripts/run-task.sh # run a mission/task file (see Missions & tasks)
scripts/release.sh # package / activate / rollback / status (see Release model)
scripts/test-config.sh # fast config-layer selftests (no Docker)
scripts/test-task.sh # mission/task selftests (schema + adapter seam + live runs)
scripts/test-release.sh # release selftests
scripts/reset.sh # delete the configured data root (safety-checked)
```
- **[Workflow](docs/2-development/1-workflow/)** — Branching strategy, testing requirements, commit guidelines
- **[Database](docs/2-development/2-database/)** — Schema design, migrations, Prisma usage
- **[Type Sharing](docs/2-development/3-type-sharing/1-strategy.md)** — Shared types across monorepo
Prove the failure path (acceptance criterion 9):
### 🏗️ Architecture
```bash
EXPECTED_MARKER=MOSAIC_NOT_OK scripts/verify.sh # must exit nonzero
```
- **[Overview](docs/3-architecture/1-overview/)** — System design and components
- **[Authentication](docs/3-architecture/2-authentication/)** — BetterAuth and OIDC integration
- **[Design Principles](docs/3-architecture/3-design-principles/1-pda-friendly.md)** — PDA-friendly patterns (non-negotiable)
- **[Telemetry](docs/telemetry.md)** — AI task completion tracking, predictions, and SDK reference
## Authentication
### 🔌 API Reference
Pi's documented container authentication (see the package's
`docs/containerization.md`) is used, in this order:
- **[Conventions](docs/4-api/1-conventions/1-endpoints.md)** — REST patterns, pagination, filtering
- **[Authentication](docs/4-api/2-authentication/1-endpoints.md)** — Auth endpoints and flows
1. **Read-only mounted credential file** (default): the host pi auth file
`~/.pi/agent/auth.json` is bind-mounted read-only to
`/home/node/.pi/agent/auth.json`. The host file holds a static API-key
entry for the built-in `zai` provider, so no token refresh writes are needed.
2. **Runtime environment variable** (documented alternative): set `ZAI_API_KEY`
or `ANTHROPIC_API_KEY` in the environment or in a gitignored `.env`; compose
passes them through. Pi's documented precedence applies.
**Browse all documentation:** [docs/](docs/)
Credentials are never committed, never copied into the image, and never printed.
`.env.example` contains non-secret settings only.
## Related Projects
## Boundaries honored
- **jarvis-brain** — Original JSON-based personal assistant (migration source)
- **MoltBot** — Stock messaging gateway for multi-platform integration
## Security
- **Session-based authentication** with secure JWT tokens
- **Row-level security** ready for multi-tenant isolation
- **OIDC integration** with Authentik for enterprise SSO
- **Secure error handling** — No sensitive data in logs or responses
- **Type-safe validation** — TypeScript catches issues at compile time
## License
[Add license information]
## Support
- **Issues:** https://git.mosaicstack.dev/mosaic/stack/issues
- **Documentation:** https://git.mosaicstack.dev/mosaic/stack/wiki
---
**Mosaic Stack v0.0.1** — Building the future of personal assistants.
- No mounts of `~/.mosaic` or `~/.config/mosaic`; no Docker socket mount.
- Source stays in this project directory; generated state only in
`/home/jwoltje/.mosaic-dev` (host) and `/var/lib/mosaic` (container).
- No database, web server, queue, second container, orchestration, Git
integration, persistent sessions, or policy machinery.
+1
View File
@@ -0,0 +1 @@
0.0.7
-20
View File
@@ -1,20 +0,0 @@
# Mosaic Stack Soul
You are Jarvis for the Mosaic Stack repository, running on the current agent runtime.
## Behavioral Invariants
- Identity first: answer identity prompts as Jarvis for this repository.
- Implementation detail second: runtime (Codex/Claude/OpenCode/etc.) is secondary metadata.
- Be proactive: surface risks, blockers, and next actions without waiting.
- Be calm and clear: keep responses concise, chunked, and PDA-friendly.
- Respect canonical sources:
- Repo operations and conventions: `AGENTS.md`
- Machine-wide rails: `~/.config/mosaic/STANDARDS.md`
- Repo lifecycle hooks: `.mosaic/repo-hooks.sh`
## Guardrails
- Do not claim completion without verification evidence.
- Do not bypass lint/type/test quality gates.
- Prefer explicit assumptions and concrete file/command references.
+54
View File
@@ -0,0 +1,54 @@
# Mosaic runtime adapters
An adapter is the entire harness-specific surface of the system. Everything
upstream of an adapter — configuration, contracts, missions, tasks, run
records — is harness-agnostic; everything inside an adapter may assume one
specific agent runtime.
## Contract
An adapter lives at:
```text
/opt/mosaic/adapters/<name>/adapter.sh
```
and must be executable. The dispatcher (`/opt/mosaic/src/run-agent.sh`)
selects it via `MOSAIC_ADAPTER` (default: `pi`) and execs it after the
system prompt has been generated.
**Inputs (environment):**
| Variable | Meaning |
|---|---|
| `MOSAIC_SYSTEM_PROMPT_FILE` | Absolute path to the generated system prompt (contracts + optional mission section). Read it; do not modify it. |
| `MOSAIC_REQUEST` | The exact user request text (may contain newlines). |
| `MOSAIC_PROVIDER` | Configured provider name. |
| `MOSAIC_MODEL` | Configured model id. |
Optional, adapter-specific (documented per adapter):
| Variable | Meaning |
|---|---|
| `MOSAIC_MOCK_RESPONSE` | mock only: the verbatim response to emit |
**Outputs:**
- `stdout`: the model response text — the only channel the orchestrator captures
- `stderr`: diagnostics (never credentials)
- exit `0`: success; nonzero: failure
## Rules
1. Adapters print ONLY the response on stdout. Status lines go to stderr.
2. Adapters never read configuration files; the resolved settings arrive via environment.
3. Adapters never write outside `/var/lib/mosaic`.
4. Adding an adapter requires: a new directory, the contract implementation, and
adding the name to the allowlist in `scripts/mosaic-config.mjs`.
## Included adapters
- `pi` — the pinned `@earendil-works/pi-coding-agent` CLI in noninteractive
print mode (`-p`), ambient discovery disabled, stdin detached.
- `mock` — deterministic echo of `MOSAIC_MOCK_RESPONSE`. Test-only: never use
it where a real model response is required.
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Mock adapter: deterministic response for seam tests. NEVER use where a
# real model response is required.
#
# Contract: see /opt/mosaic/adapters/README.md.
set -eu
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "mock adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "mock adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "mock adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
echo "mock adapter: responding verbatim from MOSAIC_MOCK_RESPONSE" >&2
# Deterministic plumbing evidence: which MOSAIC_* variables did the
# orchestrator actually deliver? (Auth secrets are not MOSAIC_-prefixed.)
(env | grep '^MOSAIC_' | sort) >&2 2>/dev/null || true
printf '%s\n' "${MOSAIC_MOCK_RESPONSE:-}"
+60
View File
@@ -0,0 +1,60 @@
#!/bin/sh
# Pi adapter: implements the Mosaic adapter contract for the pinned
# @earendil-works/pi-coding-agent CLI.
#
# Contract: see /opt/mosaic/adapters/README.md. stdout = response only.
set -eu
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
[ -n "${MOSAIC_REQUEST:-}" ] || { echo "pi adapter: MOSAIC_REQUEST is required" >&2; exit 2; }
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
: "${PI_PROVIDER:?pi adapter: PI_PROVIDER is required}"
: "${PI_MODEL:?pi adapter: PI_MODEL is required}"
# Workspace (M5): run inside the provided workspace when present.
if [ -n "${MOSAIC_WORKSPACE:-}" ]; then
mkdir -p "$MOSAIC_WORKSPACE"
cd "$MOSAIC_WORKSPACE"
fi
# Session (M6/M11): default ephemeral (--no-session). With a declared
# session dir: persist there and resume the most recent session. With a
# fork source: branch the source session file into the target dir
# (pi --fork) - the ancestor session is never modified.
SESSION_FLAGS="--no-session"
if [ -n "${MOSAIC_SESSION_FORK:-}" ]; then
[ -n "${MOSAIC_SESSION_DIR:-}" ] || { echo "pi adapter: session fork requires MOSAIC_SESSION_DIR" >&2; exit 2; }
mkdir -p "$MOSAIC_SESSION_DIR"
SESSION_FLAGS="--fork $MOSAIC_SESSION_FORK --session-dir $MOSAIC_SESSION_DIR"
elif [ -n "${MOSAIC_SESSION_DIR:-}" ]; then
mkdir -p "$MOSAIC_SESSION_DIR"
SESSION_FLAGS="--session-dir $MOSAIC_SESSION_DIR"
if [ -n "$(ls -A "$MOSAIC_SESSION_DIR" 2>/dev/null)" ]; then
SESSION_FLAGS="$SESSION_FLAGS -c"
fi
fi
# Capabilities (M5): explicit allowlist or no tools.
TOOLS_FLAG="--no-tools"
[ -n "${MOSAIC_TOOLS:-}" ] && TOOLS_FLAG="--tools $MOSAIC_TOOLS"
# All flags documented in the pi package README (CLI Reference):
# -p/--print noninteractive: print the response and exit
# --system-prompt replace the default prompt with the generated one
# --no-* no ambient context/skills/extensions/templates/themes
# --no-session ephemeral; TOOLS_FLAG per capabilities
# --offline no startup network operations (update checks/telemetry)
exec pi \
--offline \
--no-extensions \
--no-skills \
--no-prompt-templates \
--no-themes \
--no-context-files \
$TOOLS_FLAG \
$SESSION_FLAGS \
--provider "$PI_PROVIDER" \
--model "$PI_MODEL" \
--system-prompt "$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")" \
-p "$MOSAIC_REQUEST"
-48
View File
@@ -1,48 +0,0 @@
# Node modules
node_modules
npm-debug.log
yarn-error.log
pnpm-debug.log
# Build output
dist
build
*.tsbuildinfo
# Tests
coverage
.vitest
test
*.spec.ts
*.test.ts
# Development files
.env
.env.*
!.env.example
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Documentation
README.md
docs
# Logs
logs
*.log
# Turbo
.turbo
-40
View File
@@ -1,40 +0,0 @@
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/database
# System Administration
# Comma-separated list of user IDs that have system administrator privileges
# These users can perform system-level operations across all workspaces
# Note: Workspace ownership does NOT grant system admin access
# SYSTEM_ADMIN_IDS=uuid1,uuid2,uuid3
# Federation Instance Identity
# Display name for this Mosaic instance
INSTANCE_NAME=Mosaic Instance
# Publicly accessible URL for federation (must be valid HTTP/HTTPS URL)
INSTANCE_URL=http://localhost:3000
# Encryption (AES-256-GCM for sensitive data at rest)
# CRITICAL: Generate a secure random key for production!
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
# CSRF Protection (Required in production)
# Secret key for HMAC binding CSRF tokens to user sessions
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# In development, a random key is generated if not set
CSRF_SECRET=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210
# OpenTelemetry Configuration
# Enable/disable OpenTelemetry tracing (default: true)
OTEL_ENABLED=true
# Service name for telemetry (default: mosaic-api)
OTEL_SERVICE_NAME=mosaic-api
# OTLP exporter endpoint (default: http://localhost:4318/v1/traces)
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
# Alternative: Jaeger endpoint (legacy)
# OTEL_EXPORTER_JAEGER_ENDPOINT=http://localhost:4318/v1/traces
# Deployment environment (default: development, or uses NODE_ENV)
# OTEL_DEPLOYMENT_ENVIRONMENT=production
# Trace sampling ratio: 0.0 (none) to 1.0 (all) - default: 1.0
# Use lower values in high-traffic production environments
# OTEL_TRACES_SAMPLER_ARG=1.0
-5
View File
@@ -1,5 +0,0 @@
DATABASE_URL="postgresql://test:test@localhost:5432/test"
ENCRYPTION_KEY="test-encryption-key-32-characters"
JWT_SECRET="test-jwt-secret"
INSTANCE_NAME="Test Instance"
INSTANCE_URL="https://test.example.com"
-9
View File
@@ -1,9 +0,0 @@
# WARNING: These are example test credentials for local integration testing.
# Copy this file to .env.test and customize the values for your local environment.
# NEVER use these credentials in any shared environment or commit .env.test to git.
DATABASE_URL="postgresql://test:test@localhost:5432/test"
ENCRYPTION_KEY="test-encryption-key-32-characters"
JWT_SECRET="test-jwt-secret"
INSTANCE_NAME="Test Instance"
INSTANCE_URL="https://test.example.com"
-18
View File
@@ -1,18 +0,0 @@
{
"sourceMaps": true,
"jsc": {
"target": "es2022",
"parser": {
"syntax": "typescript",
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"keepClassNames": true
},
"module": {
"type": "es6"
}
}
-25
View File
@@ -1,25 +0,0 @@
# api — Agent Context
> Part of the apps layer.
## Patterns
- **Config validation pattern**: Config files use exported validation functions + typed getter functions (not class-validator). See `auth.config.ts`, `federation.config.ts`, `speech/speech.config.ts`. Pattern: export `isXEnabled()`, `validateXConfig()`, and `getXConfig()` functions.
- **Config registerAs**: `speech.config.ts` also exports a `registerAs("speech", ...)` factory for NestJS ConfigModule namespaced injection. Use `ConfigModule.forFeature(speechConfig)` in module imports and access via `this.config.get<string>('speech.stt.baseUrl')`.
- **Conditional config validation**: When a service has an enabled flag (e.g., `STT_ENABLED`), URL/connection vars are only required when enabled. Validation throws with a helpful message suggesting how to disable.
- **Boolean env parsing**: Use `value === "true" || value === "1"` pattern. No default-true -- all services default to disabled when env var is unset.
## Gotchas
- **Prisma client must be generated** before `tsc --noEmit` will pass. Run `pnpm prisma:generate` first. Pre-existing type errors from Prisma are expected in worktrees without generated client.
- **Pre-commit hooks**: lint-staged runs on staged files. If other packages' files are staged, their lint must pass too. Only stage files you intend to commit.
- **vitest runs all test files**: Even when targeting a specific test file, vitest loads all spec files. Many will fail if Prisma client isn't generated -- this is expected. Check only your target file's pass/fail status.
## Key Files
| File | Purpose |
| ------------------------------------- | ---------------------------------------------------------------------- |
| `src/speech/speech.config.ts` | Speech services env var validation and typed config (STT, TTS, limits) |
| `src/speech/speech.config.spec.ts` | Unit tests for speech config validation (51 tests) |
| `src/auth/auth.config.ts` | Auth/OIDC config validation (reference pattern) |
| `src/federation/federation.config.ts` | Federation config validation (reference pattern) |
-115
View File
@@ -1,115 +0,0 @@
# Base image for all stages
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
FROM node:24-slim AS base
# Install pnpm globally
RUN corepack enable && corepack prepare [email protected] --activate
# Set working directory
WORKDIR /app
# Copy monorepo configuration files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY turbo.json ./
# ======================
# Dependencies stage
# ======================
FROM base AS deps
# Install build tools for native addons (node-pty requires node-gyp compilation)
# and OpenSSL for Prisma engine detection
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy all package.json files for workspace resolution
COPY packages/shared/package.json ./packages/shared/
COPY packages/ui/package.json ./packages/ui/
COPY packages/config/package.json ./packages/config/
COPY apps/api/package.json ./apps/api/
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
# scripts or fail to find prebuilt binaries for this Node.js version
RUN pnpm install --frozen-lockfile \
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
&& npx node-gyp rebuild 2>&1 || true
# ======================
# Builder stage
# ======================
FROM base AS builder
# Copy root node_modules from deps
COPY --from=deps /app/node_modules ./node_modules
# Copy all source code FIRST
COPY packages ./packages
COPY apps/api ./apps/api
# Then copy workspace node_modules from deps (these go AFTER source to avoid being overwritten)
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/config/node_modules ./packages/config/node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
# Build the API app and its dependencies using TurboRepo
# --force disables turbo cache to ensure fresh build from source
RUN pnpm turbo build --filter=@mosaic/api --force
# ======================
# Production stage
# ======================
FROM node:24-slim AS production
# Install dumb-init for proper signal handling (static binary from GitHub,
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
# - openssl: Prisma engine detection requires libssl
# - No build tools needed here — native addons are compiled in the deps stage
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
WORKDIR /app
# Copy node_modules from builder (includes generated Prisma client in pnpm store)
# pnpm stores the Prisma client in node_modules/.pnpm/.../.prisma, so we need the full tree
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
# Copy built packages (includes dist/ directories)
COPY --from=builder --chown=nestjs:nodejs /app/packages ./packages
# Copy built API application
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/dist ./apps/api/dist
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/package.json ./apps/api/
# Copy app's node_modules which contains symlinks to root node_modules
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/node_modules ./apps/api/node_modules
# Copy entrypoint script (runs migrations before starting app)
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/docker-entrypoint.sh ./apps/api/
# Set working directory to API app
WORKDIR /app/apps/api
# Switch to non-root user
USER nestjs
# Expose API port (default 3001, can be overridden via PORT env var)
EXPOSE ${PORT:-3001}
# Health check uses PORT env var (set by docker-compose or defaults to 3001)
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "const port = process.env.PORT || 3001; require('http').get('http://localhost:' + port + '/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Run migrations then start the application
CMD ["sh", "docker-entrypoint.sh"]
-260
View File
@@ -1,260 +0,0 @@
# Mosaic Stack API
The Mosaic Stack API is a NestJS-based backend service providing REST endpoints and WebSocket support for the Mosaic productivity platform.
## Overview
The API serves as the central backend for:
- **Task Management** - Create, update, track tasks with filtering and sorting
- **Event Management** - Calendar events and scheduling
- **Project Management** - Organize work into projects
- **Knowledge Base** - Wiki-style documentation with markdown support and wiki-linking
- **Ideas** - Quick capture and organization of ideas
- **Domains** - Categorize work across different domains
- **Personalities** - AI personality configurations for the Ollama integration
- **Widgets & Layouts** - Dashboard customization
- **Activity Logging** - Track all user actions
- **WebSocket Events** - Real-time updates for tasks, events, and projects
## Available Modules
| Module | Base Path | Description |
| ------------------ | --------------------------- | ---------------------------------------- |
| **Tasks** | `/api/tasks` | CRUD operations for tasks with filtering |
| **Events** | `/api/events` | Calendar events and scheduling |
| **Projects** | `/api/projects` | Project management |
| **Knowledge** | `/api/knowledge/entries` | Wiki entries with markdown support |
| **Knowledge Tags** | `/api/knowledge/tags` | Tag management for knowledge entries |
| **Ideas** | `/api/ideas` | Quick capture and idea management |
| **Domains** | `/api/domains` | Domain categorization |
| **Personalities** | `/api/personalities` | AI personality configurations |
| **Widgets** | `/api/widgets` | Dashboard widget data |
| **Layouts** | `/api/layouts` | Dashboard layout configuration |
| **Ollama** | `/api/ollama` | LLM integration (generate, chat, embed) |
| **Users** | `/api/users/me/preferences` | User preferences |
### Health Check
- `GET /` - API health check
- `GET /health` - Detailed health status including database connectivity
## Authentication
The API uses **BetterAuth** for authentication with the following features:
### Authentication Flow
1. **Email/Password** - Users can sign up and log in with email and password
2. **Session Tokens** - BetterAuth generates session tokens with configurable expiration
### Guards
The API uses a layered guard system:
| Guard | Purpose | Applies To |
| ------------------- | ------------------------------------------------------------------------ | -------------------------- |
| **AuthGuard** | Verifies user authentication via Bearer token | Most protected endpoints |
| **WorkspaceGuard** | Validates workspace membership and sets Row-Level Security (RLS) context | Workspace-scoped resources |
| **PermissionGuard** | Enforces role-based access control | Admin operations |
### Workspace Roles
- **OWNER** - Full control over workspace
- **ADMIN** - Administrative functions (can delete content, manage members)
- **MEMBER** - Standard access (create/edit content)
- **GUEST** - Read-only access
### Permission Levels
Used with `@RequirePermission()` decorator:
```typescript
Permission.WORKSPACE_OWNER; // Requires OWNER role
Permission.WORKSPACE_ADMIN; // Requires ADMIN or OWNER
Permission.WORKSPACE_MEMBER; // Requires MEMBER, ADMIN, or OWNER
Permission.WORKSPACE_ANY; // Any authenticated member including GUEST
```
### Providing Workspace Context
Workspace ID can be provided via:
1. **Header**: `X-Workspace-Id: <workspace-id>` (highest priority)
2. **URL Parameter**: `:workspaceId`
3. **Request Body**: `workspaceId` field
### Example: Protected Controller
```typescript
@Controller("tasks")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class TasksController {
@Post()
@RequirePermission(Permission.WORKSPACE_MEMBER)
async create(@Body() dto: CreateTaskDto, @Workspace() workspaceId: string) {
// workspaceId is verified and RLS context is set
}
}
```
## Environment Variables
| Variable | Description | Default |
| --------------------- | ----------------------------------------- | ----------------------- |
| `PORT` | API server port | `3001` |
| `DATABASE_URL` | PostgreSQL connection string | Required |
| `NODE_ENV` | Environment (`development`, `production`) | - |
| `NEXT_PUBLIC_APP_URL` | Frontend application URL (for CORS) | `http://localhost:3000` |
| `WEB_URL` | WebSocket CORS origin | `http://localhost:3000` |
## Running Locally
### Prerequisites
- Node.js 18+
- PostgreSQL database
- pnpm workspace (part of Mosaic Stack monorepo)
### Setup
1. **Install dependencies:**
```bash
pnpm install
```
2. **Set up environment variables:**
```bash
cp .env.example .env # If available
# Edit .env with your DATABASE_URL
```
3. **Generate Prisma client:**
```bash
pnpm prisma:generate
```
4. **Run database migrations:**
```bash
pnpm prisma:migrate
```
5. **Seed the database (optional):**
```bash
pnpm prisma:seed
```
### Development
```bash
pnpm dev
```
The API will start on `http://localhost:3001`
### Production Build
```bash
pnpm build
pnpm start:prod
```
### Database Management
```bash
# Open Prisma Studio
pnpm prisma:studio
# Reset database (dev only)
pnpm prisma:reset
# Run migrations in production
pnpm prisma:migrate:prod
```
## API Documentation
The API does not currently include Swagger/OpenAPI documentation. Instead:
- **Controller files** contain detailed JSDoc comments describing each endpoint
- **DTO classes** define request/response schemas with class-validator decorators
- Refer to the controller source files in `src/` for endpoint details
### Example: Reading an Endpoint
```typescript
// src/tasks/tasks.controller.ts
/**
* POST /api/tasks
* Create a new task
* Requires: MEMBER role or higher
*/
@Post()
@RequirePermission(Permission.WORKSPACE_MEMBER)
async create(@Body() createTaskDto: CreateTaskDto, @Workspace() workspaceId: string) {
return this.tasksService.create(workspaceId, user.id, createTaskDto);
}
```
## WebSocket Support
The API provides real-time updates via WebSocket. Clients receive notifications for:
- `task:created` - New task created
- `task:updated` - Task modified
- `task:deleted` - Task removed
- `event:created` - New event created
- `event:updated` - Event modified
- `event:deleted` - Event removed
- `project:updated` - Project modified
Clients join workspace-specific rooms for scoped updates.
## Testing
```bash
# Run unit tests
pnpm test
# Run tests with coverage
pnpm test:coverage
# Run e2e tests
pnpm test:e2e
# Watch mode
pnpm test:watch
```
## Project Structure
```
src/
├── activity/ # Activity logging
├── auth/ # Authentication (BetterAuth config, guards)
├── common/ # Shared decorators and guards
├── database/ # Database module
├── domains/ # Domain management
├── events/ # Event management
├── filters/ # Global exception filters
├── ideas/ # Idea capture and management
├── knowledge/ # Knowledge base (entries, tags, markdown)
├── layouts/ # Dashboard layouts
├── lib/ # Utility functions
├── ollama/ # LLM integration
├── personalities/ # AI personality configurations
├── prisma/ # Prisma service
├── projects/ # Project management
├── tasks/ # Task management
├── users/ # User preferences
├── widgets/ # Dashboard widgets
├── websocket/ # WebSocket gateway
├── app.controller.ts # Root controller (health check)
├── app.module.ts # Root module
└── main.ts # Application bootstrap
```
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
set -e
echo "Running database migrations..."
./node_modules/.bin/prisma migrate deploy --schema ./prisma/schema.prisma
echo "Starting application..."
exec node dist/main.js
-16
View File
@@ -1,16 +0,0 @@
import nestjsConfig from "@mosaic/config/eslint/nestjs";
export default [
...nestjsConfig,
{
languageOptions: {
parserOptions: {
project: ["./tsconfig.json"],
tsconfigRootDir: import.meta.dirname,
},
},
},
{
ignores: ["dist/**", "node_modules/**", "**/*.test.ts", "**/*.spec.ts"],
},
];
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
-106
View File
@@ -1,106 +0,0 @@
{
"name": "@mosaic/api",
"version": "0.0.20",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"src/**/*.ts\"",
"lint:fix": "eslint \"src/**/*.ts\" --fix",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config ./vitest.e2e.config.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:prod": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"prisma:seed": "prisma db seed",
"prisma:reset": "prisma migrate reset",
"migrate:encrypt-llm-keys": "tsx scripts/encrypt-llm-keys.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.72.1",
"@mosaic/shared": "workspace:*",
"@mosaicstack/telemetry-client": "^0.1.1",
"@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.12",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.12",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.1.12",
"@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.12",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.56.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.44.0",
"@opentelemetry/resources": "^1.30.1",
"@opentelemetry/sdk-node": "^0.56.0",
"@opentelemetry/sdk-trace-base": "^2.5.0",
"@opentelemetry/semantic-conventions": "^1.28.0",
"@prisma/client": "^6.19.2",
"@types/marked": "^6.0.0",
"@types/multer": "^2.0.0",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"axios": "^1.13.5",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.17",
"bullmq": "^5.67.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"discord.js": "^14.25.1",
"gray-matter": "^4.0.3",
"highlight.js": "^11.11.1",
"ioredis": "^5.9.2",
"jose": "^6.1.3",
"marked": "^17.0.1",
"marked-gfm-heading-id": "^4.1.3",
"marked-highlight": "^2.2.3",
"matrix-bot-sdk": "^0.8.0",
"node-pty": "^1.0.0",
"ollama": "^0.6.3",
"openai": "^6.17.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sanitize-html": "^2.17.0",
"slugify": "^1.6.6",
"socket.io": "^4.8.3"
},
"devDependencies": {
"@better-auth/cli": "^1.4.17",
"@mosaic/config": "workspace:*",
"@nestjs/cli": "^11.0.6",
"@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.1.12",
"@opentelemetry/context-async-hooks": "^2.5.0",
"@swc/core": "^1.10.18",
"@types/adm-zip": "^0.5.7",
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.1",
"@types/highlight.js": "^10.1.0",
"@types/node": "^22.13.4",
"@types/sanitize-html": "^2.16.0",
"@types/supertest": "^6.0.3",
"@vitest/coverage-v8": "^4.0.18",
"dotenv": "^17.2.4",
"express": "^5.2.1",
"prisma": "^6.19.2",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^5.8.2",
"unplugin-swc": "^1.5.2",
"vitest": "^4.0.18"
}
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "prisma/config";
export default defineConfig({
migrations: {
seed: "tsx prisma/seed.ts",
},
});
@@ -1,261 +0,0 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "vector";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('NOT_STARTED', 'IN_PROGRESS', 'PAUSED', 'COMPLETED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "TaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
-- CreateEnum
CREATE TYPE "ProjectStatus" AS ENUM ('PLANNING', 'ACTIVE', 'PAUSED', 'COMPLETED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "WorkspaceMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'GUEST');
-- CreateEnum
CREATE TYPE "ActivityAction" AS ENUM ('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'ASSIGNED', 'COMMENTED');
-- CreateEnum
CREATE TYPE "EntityType" AS ENUM ('TASK', 'EVENT', 'PROJECT', 'WORKSPACE', 'USER');
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"auth_provider_id" TEXT,
"preferences" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspaces" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"owner_id" UUID NOT NULL,
"settings" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspace_members" (
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"role" "WorkspaceMemberRole" NOT NULL DEFAULT 'MEMBER',
"joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "workspace_members_pkey" PRIMARY KEY ("workspace_id","user_id")
);
-- CreateTable
CREATE TABLE "tasks" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "TaskStatus" NOT NULL DEFAULT 'NOT_STARTED',
"priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM',
"due_date" TIMESTAMPTZ,
"assignee_id" UUID,
"creator_id" UUID NOT NULL,
"project_id" UUID,
"parent_id" UUID,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"completed_at" TIMESTAMPTZ,
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "events" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"start_time" TIMESTAMPTZ NOT NULL,
"end_time" TIMESTAMPTZ,
"all_day" BOOLEAN NOT NULL DEFAULT false,
"location" TEXT,
"recurrence" JSONB,
"creator_id" UUID NOT NULL,
"project_id" UUID,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "projects" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"status" "ProjectStatus" NOT NULL DEFAULT 'PLANNING',
"start_date" DATE,
"end_date" DATE,
"creator_id" UUID NOT NULL,
"color" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "activity_logs" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"action" "ActivityAction" NOT NULL,
"entity_type" "EntityType" NOT NULL,
"entity_id" UUID NOT NULL,
"details" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "activity_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "memory_embeddings" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"content" TEXT NOT NULL,
"embedding" vector(1536),
"entity_type" "EntityType",
"entity_id" UUID,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "memory_embeddings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_auth_provider_id_key" ON "users"("auth_provider_id");
-- CreateIndex
CREATE INDEX "workspaces_owner_id_idx" ON "workspaces"("owner_id");
-- CreateIndex
CREATE INDEX "workspace_members_user_id_idx" ON "workspace_members"("user_id");
-- CreateIndex
CREATE INDEX "tasks_workspace_id_idx" ON "tasks"("workspace_id");
-- CreateIndex
CREATE INDEX "tasks_workspace_id_status_idx" ON "tasks"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "tasks_workspace_id_due_date_idx" ON "tasks"("workspace_id", "due_date");
-- CreateIndex
CREATE INDEX "tasks_assignee_id_idx" ON "tasks"("assignee_id");
-- CreateIndex
CREATE INDEX "tasks_project_id_idx" ON "tasks"("project_id");
-- CreateIndex
CREATE INDEX "tasks_parent_id_idx" ON "tasks"("parent_id");
-- CreateIndex
CREATE INDEX "events_workspace_id_idx" ON "events"("workspace_id");
-- CreateIndex
CREATE INDEX "events_workspace_id_start_time_idx" ON "events"("workspace_id", "start_time");
-- CreateIndex
CREATE INDEX "events_creator_id_idx" ON "events"("creator_id");
-- CreateIndex
CREATE INDEX "events_project_id_idx" ON "events"("project_id");
-- CreateIndex
CREATE INDEX "projects_workspace_id_idx" ON "projects"("workspace_id");
-- CreateIndex
CREATE INDEX "projects_workspace_id_status_idx" ON "projects"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "projects_creator_id_idx" ON "projects"("creator_id");
-- CreateIndex
CREATE INDEX "activity_logs_workspace_id_idx" ON "activity_logs"("workspace_id");
-- CreateIndex
CREATE INDEX "activity_logs_workspace_id_created_at_idx" ON "activity_logs"("workspace_id", "created_at");
-- CreateIndex
CREATE INDEX "activity_logs_entity_type_entity_id_idx" ON "activity_logs"("entity_type", "entity_id");
-- CreateIndex
CREATE INDEX "activity_logs_user_id_idx" ON "activity_logs"("user_id");
-- CreateIndex
CREATE INDEX "memory_embeddings_workspace_id_idx" ON "memory_embeddings"("workspace_id");
-- AddForeignKey
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "memory_embeddings" ADD CONSTRAINT "memory_embeddings_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,8 +0,0 @@
-- Add HNSW index for fast vector similarity search on memory_embeddings table
-- Using cosine distance operator for semantic similarity
-- Parameters: m=16 (max connections per layer), ef_construction=64 (build quality)
CREATE INDEX IF NOT EXISTS memory_embeddings_embedding_idx
ON memory_embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
@@ -1,92 +0,0 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "ActivityAction" ADD VALUE 'LOGIN';
ALTER TYPE "ActivityAction" ADD VALUE 'LOGOUT';
ALTER TYPE "ActivityAction" ADD VALUE 'PASSWORD_RESET';
ALTER TYPE "ActivityAction" ADD VALUE 'EMAIL_VERIFIED';
-- AlterTable
ALTER TABLE "activity_logs" ADD COLUMN "ip_address" TEXT,
ADD COLUMN "user_agent" TEXT;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "email_verified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "image" TEXT;
-- CreateTable
CREATE TABLE "sessions" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"token" TEXT NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"ip_address" TEXT,
"user_agent" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"account_id" TEXT NOT NULL,
"provider_id" TEXT NOT NULL,
"access_token" TEXT,
"refresh_token" TEXT,
"id_token" TEXT,
"access_token_expires_at" TIMESTAMPTZ,
"refresh_token_expires_at" TIMESTAMPTZ,
"scope" TEXT,
"password" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verifications" (
"id" UUID NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "verifications_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
-- CreateIndex
CREATE INDEX "sessions_token_idx" ON "sessions"("token");
-- CreateIndex
CREATE INDEX "accounts_user_id_idx" ON "accounts"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "accounts_provider_id_account_id_key" ON "accounts"("provider_id", "account_id");
-- CreateIndex
CREATE INDEX "verifications_identifier_idx" ON "verifications"("identifier");
-- CreateIndex
CREATE INDEX "activity_logs_action_idx" ON "activity_logs"("action");
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,286 +0,0 @@
-- CreateEnum
CREATE TYPE "IdeaStatus" AS ENUM ('CAPTURED', 'PROCESSING', 'ACTIONABLE', 'ARCHIVED', 'DISCARDED');
-- CreateEnum
CREATE TYPE "RelationshipType" AS ENUM ('BLOCKS', 'BLOCKED_BY', 'DEPENDS_ON', 'PARENT_OF', 'CHILD_OF', 'RELATED_TO', 'DUPLICATE_OF', 'SUPERSEDES', 'PART_OF');
-- CreateEnum
CREATE TYPE "AgentStatus" AS ENUM ('IDLE', 'WORKING', 'WAITING', 'ERROR', 'TERMINATED');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "EntityType" ADD VALUE 'IDEA';
ALTER TYPE "EntityType" ADD VALUE 'DOMAIN';
-- DropIndex
DROP INDEX "memory_embeddings_embedding_idx";
-- AlterTable
ALTER TABLE "events" ADD COLUMN "domain_id" UUID;
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "domain_id" UUID;
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "domain_id" UUID;
-- CreateTable
CREATE TABLE "domains" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"color" TEXT,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "domains_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ideas" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"domain_id" UUID,
"project_id" UUID,
"title" TEXT,
"content" TEXT NOT NULL,
"status" "IdeaStatus" NOT NULL DEFAULT 'CAPTURED',
"priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM',
"category" TEXT,
"tags" TEXT[],
"metadata" JSONB NOT NULL DEFAULT '{}',
"embedding" vector(1536),
"creator_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "ideas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "relationships" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"source_type" "EntityType" NOT NULL,
"source_id" UUID NOT NULL,
"target_type" "EntityType" NOT NULL,
"target_id" UUID NOT NULL,
"relationship" "RelationshipType" NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"notes" TEXT,
"creator_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "relationships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agents" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"agent_id" TEXT NOT NULL,
"name" TEXT,
"model" TEXT,
"role" TEXT,
"status" "AgentStatus" NOT NULL DEFAULT 'IDLE',
"current_task" TEXT,
"metrics" JSONB NOT NULL DEFAULT '{"totalTasks": 0, "successfulTasks": 0, "failedTasks": 0, "avgResponseTimeMs": 0}',
"last_heartbeat" TIMESTAMPTZ,
"error_count" INTEGER NOT NULL DEFAULT 0,
"last_error" TEXT,
"fired_count" INTEGER NOT NULL DEFAULT 0,
"fire_history" JSONB NOT NULL DEFAULT '[]',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"terminated_at" TIMESTAMPTZ,
CONSTRAINT "agents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_sessions" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"agent_id" UUID,
"session_key" TEXT NOT NULL,
"label" TEXT,
"channel" TEXT,
"context_summary" TEXT,
"message_count" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_message_at" TIMESTAMPTZ,
"ended_at" TIMESTAMPTZ,
"metadata" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "agent_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "widget_definitions" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"description" TEXT,
"component" TEXT NOT NULL,
"default_width" INTEGER NOT NULL DEFAULT 1,
"default_height" INTEGER NOT NULL DEFAULT 1,
"min_width" INTEGER NOT NULL DEFAULT 1,
"min_height" INTEGER NOT NULL DEFAULT 1,
"max_width" INTEGER,
"max_height" INTEGER,
"config_schema" JSONB NOT NULL DEFAULT '{}',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "widget_definitions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_layouts" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"layout" JSONB NOT NULL DEFAULT '[]',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "user_layouts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "domains_workspace_id_idx" ON "domains"("workspace_id");
-- CreateIndex
CREATE UNIQUE INDEX "domains_workspace_id_slug_key" ON "domains"("workspace_id", "slug");
-- CreateIndex
CREATE INDEX "ideas_workspace_id_idx" ON "ideas"("workspace_id");
-- CreateIndex
CREATE INDEX "ideas_workspace_id_status_idx" ON "ideas"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "ideas_domain_id_idx" ON "ideas"("domain_id");
-- CreateIndex
CREATE INDEX "ideas_project_id_idx" ON "ideas"("project_id");
-- CreateIndex
CREATE INDEX "ideas_creator_id_idx" ON "ideas"("creator_id");
-- CreateIndex
CREATE INDEX "relationships_source_type_source_id_idx" ON "relationships"("source_type", "source_id");
-- CreateIndex
CREATE INDEX "relationships_target_type_target_id_idx" ON "relationships"("target_type", "target_id");
-- CreateIndex
CREATE INDEX "relationships_relationship_idx" ON "relationships"("relationship");
-- CreateIndex
CREATE UNIQUE INDEX "relationships_workspace_id_source_type_source_id_target_typ_key" ON "relationships"("workspace_id", "source_type", "source_id", "target_type", "target_id", "relationship");
-- CreateIndex
CREATE INDEX "agents_workspace_id_idx" ON "agents"("workspace_id");
-- CreateIndex
CREATE INDEX "agents_status_idx" ON "agents"("status");
-- CreateIndex
CREATE UNIQUE INDEX "agents_workspace_id_agent_id_key" ON "agents"("workspace_id", "agent_id");
-- CreateIndex
CREATE INDEX "agent_sessions_workspace_id_idx" ON "agent_sessions"("workspace_id");
-- CreateIndex
CREATE INDEX "agent_sessions_user_id_idx" ON "agent_sessions"("user_id");
-- CreateIndex
CREATE INDEX "agent_sessions_agent_id_idx" ON "agent_sessions"("agent_id");
-- CreateIndex
CREATE INDEX "agent_sessions_is_active_idx" ON "agent_sessions"("is_active");
-- CreateIndex
CREATE UNIQUE INDEX "agent_sessions_workspace_id_session_key_key" ON "agent_sessions"("workspace_id", "session_key");
-- CreateIndex
CREATE UNIQUE INDEX "widget_definitions_name_key" ON "widget_definitions"("name");
-- CreateIndex
CREATE INDEX "user_layouts_user_id_idx" ON "user_layouts"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_layouts_workspace_id_user_id_name_key" ON "user_layouts"("workspace_id", "user_id", "name");
-- CreateIndex
CREATE INDEX "events_domain_id_idx" ON "events"("domain_id");
-- CreateIndex
CREATE INDEX "projects_domain_id_idx" ON "projects"("domain_id");
-- CreateIndex
CREATE INDEX "tasks_domain_id_idx" ON "tasks"("domain_id");
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "domains" ADD CONSTRAINT "domains_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agents" ADD CONSTRAINT "agents_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_layouts" ADD CONSTRAINT "user_layouts_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_layouts" ADD CONSTRAINT "user_layouts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,158 +0,0 @@
-- CreateEnum
CREATE TYPE "EntryStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'WORKSPACE', 'PUBLIC');
-- AlterTable
ALTER TABLE "user_layouts" ADD COLUMN "metadata" JSONB NOT NULL DEFAULT '{}';
-- CreateTable
CREATE TABLE "knowledge_entries" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"content_html" TEXT,
"summary" TEXT,
"status" "EntryStatus" NOT NULL DEFAULT 'DRAFT',
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"created_by" UUID NOT NULL,
"updated_by" UUID NOT NULL,
CONSTRAINT "knowledge_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "knowledge_entry_versions" (
"id" UUID NOT NULL,
"entry_id" UUID NOT NULL,
"version" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"summary" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID NOT NULL,
"change_note" TEXT,
CONSTRAINT "knowledge_entry_versions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "knowledge_links" (
"id" UUID NOT NULL,
"source_id" UUID NOT NULL,
"target_id" UUID NOT NULL,
"link_text" TEXT NOT NULL,
"context" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "knowledge_links_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "knowledge_tags" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"color" TEXT,
"description" TEXT,
CONSTRAINT "knowledge_tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "knowledge_entry_tags" (
"entry_id" UUID NOT NULL,
"tag_id" UUID NOT NULL,
CONSTRAINT "knowledge_entry_tags_pkey" PRIMARY KEY ("entry_id","tag_id")
);
-- CreateTable
CREATE TABLE "knowledge_embeddings" (
"id" UUID NOT NULL,
"entry_id" UUID NOT NULL,
"embedding" vector(1536) NOT NULL,
"model" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "knowledge_embeddings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "knowledge_entries_workspace_id_status_idx" ON "knowledge_entries"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "knowledge_entries_workspace_id_updated_at_idx" ON "knowledge_entries"("workspace_id", "updated_at");
-- CreateIndex
CREATE INDEX "knowledge_entries_created_by_idx" ON "knowledge_entries"("created_by");
-- CreateIndex
CREATE INDEX "knowledge_entries_updated_by_idx" ON "knowledge_entries"("updated_by");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_entries_workspace_id_slug_key" ON "knowledge_entries"("workspace_id", "slug");
-- CreateIndex
CREATE INDEX "knowledge_entry_versions_entry_id_version_idx" ON "knowledge_entry_versions"("entry_id", "version");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_entry_versions_entry_id_version_key" ON "knowledge_entry_versions"("entry_id", "version");
-- CreateIndex
CREATE INDEX "knowledge_links_source_id_idx" ON "knowledge_links"("source_id");
-- CreateIndex
CREATE INDEX "knowledge_links_target_id_idx" ON "knowledge_links"("target_id");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_links_source_id_target_id_key" ON "knowledge_links"("source_id", "target_id");
-- CreateIndex
CREATE INDEX "knowledge_tags_workspace_id_idx" ON "knowledge_tags"("workspace_id");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_tags_workspace_id_slug_key" ON "knowledge_tags"("workspace_id", "slug");
-- CreateIndex
CREATE INDEX "knowledge_entry_tags_entry_id_idx" ON "knowledge_entry_tags"("entry_id");
-- CreateIndex
CREATE INDEX "knowledge_entry_tags_tag_id_idx" ON "knowledge_entry_tags"("tag_id");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_embeddings_entry_id_key" ON "knowledge_embeddings"("entry_id");
-- CreateIndex
CREATE INDEX "knowledge_embeddings_entry_id_idx" ON "knowledge_embeddings"("entry_id");
-- AddForeignKey
ALTER TABLE "knowledge_entries" ADD CONSTRAINT "knowledge_entries_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_entry_versions" ADD CONSTRAINT "knowledge_entry_versions_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "knowledge_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_links" ADD CONSTRAINT "knowledge_links_source_id_fkey" FOREIGN KEY ("source_id") REFERENCES "knowledge_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_links" ADD CONSTRAINT "knowledge_links_target_id_fkey" FOREIGN KEY ("target_id") REFERENCES "knowledge_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_tags" ADD CONSTRAINT "knowledge_tags_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_entry_tags" ADD CONSTRAINT "knowledge_entry_tags_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "knowledge_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_entry_tags" ADD CONSTRAINT "knowledge_entry_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "knowledge_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "knowledge_embeddings" ADD CONSTRAINT "knowledge_embeddings_entry_id_fkey" FOREIGN KEY ("entry_id") REFERENCES "knowledge_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,40 +0,0 @@
-- CreateEnum
CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
-- CreateTable
CREATE TABLE "teams" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "teams_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "team_members" (
"team_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"role" "TeamMemberRole" NOT NULL DEFAULT 'MEMBER',
"joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "team_members_pkey" PRIMARY KEY ("team_id","user_id")
);
-- CreateIndex
CREATE INDEX "teams_workspace_id_idx" ON "teams"("workspace_id");
-- CreateIndex
CREATE INDEX "team_members_user_id_idx" ON "team_members"("user_id");
-- AddForeignKey
ALTER TABLE "teams" ADD CONSTRAINT "teams_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,319 +0,0 @@
-- Row-Level Security (RLS) for Multi-Tenant Isolation
-- This migration enables RLS on all tenant-scoped tables and creates policies
-- to ensure users can only access data within their authorized workspaces.
-- =============================================================================
-- ENABLE RLS ON TENANT-SCOPED TABLES
-- =============================================================================
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE workspace_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE teams ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE activity_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE memory_embeddings ENABLE ROW LEVEL SECURITY;
ALTER TABLE domains ENABLE ROW LEVEL SECURITY;
ALTER TABLE ideas ENABLE ROW LEVEL SECURITY;
ALTER TABLE relationships ENABLE ROW LEVEL SECURITY;
ALTER TABLE agents ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_layouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_entries ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_entry_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_links ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_embeddings ENABLE ROW LEVEL SECURITY;
ALTER TABLE knowledge_entry_versions ENABLE ROW LEVEL SECURITY;
-- =============================================================================
-- HELPER FUNCTION: Check if user is workspace member
-- =============================================================================
CREATE OR REPLACE FUNCTION is_workspace_member(workspace_uuid UUID, user_uuid UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = workspace_uuid
AND user_id = user_uuid
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- =============================================================================
-- HELPER FUNCTION: Check if user is workspace owner/admin
-- =============================================================================
CREATE OR REPLACE FUNCTION is_workspace_admin(workspace_uuid UUID, user_uuid UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = workspace_uuid
AND user_id = user_uuid
AND role IN ('OWNER', 'ADMIN')
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- =============================================================================
-- HELPER FUNCTION: Get current user ID from session variable
-- =============================================================================
-- Usage in API: SET LOCAL app.current_user_id = 'user-uuid';
CREATE OR REPLACE FUNCTION current_user_id()
RETURNS UUID AS $$
BEGIN
RETURN NULLIF(current_setting('app.current_user_id', TRUE), '')::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE;
-- =============================================================================
-- WORKSPACES: Users can only see workspaces they're members of
-- =============================================================================
CREATE POLICY workspace_member_access ON workspaces
FOR ALL
USING (
id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = current_user_id()
)
);
-- =============================================================================
-- WORKSPACE_MEMBERS: Users can see members of their workspaces
-- =============================================================================
CREATE POLICY workspace_members_access ON workspace_members
FOR ALL
USING (
workspace_id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = current_user_id()
)
);
-- =============================================================================
-- TEAMS: Users can see teams in their workspaces
-- =============================================================================
CREATE POLICY teams_workspace_access ON teams
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- TEAM_MEMBERS: Users can see team members in their workspaces
-- =============================================================================
CREATE POLICY team_members_access ON team_members
FOR ALL
USING (
team_id IN (
SELECT id FROM teams
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- TASKS: Users can only see tasks in their workspaces
-- =============================================================================
CREATE POLICY tasks_workspace_access ON tasks
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- EVENTS: Users can only see events in their workspaces
-- =============================================================================
CREATE POLICY events_workspace_access ON events
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- PROJECTS: Users can only see projects in their workspaces
-- =============================================================================
CREATE POLICY projects_workspace_access ON projects
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- ACTIVITY_LOGS: Users can only see activity in their workspaces
-- =============================================================================
CREATE POLICY activity_logs_workspace_access ON activity_logs
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- MEMORY_EMBEDDINGS: Users can only see embeddings in their workspaces
-- =============================================================================
CREATE POLICY memory_embeddings_workspace_access ON memory_embeddings
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- DOMAINS: Users can only see domains in their workspaces
-- =============================================================================
CREATE POLICY domains_workspace_access ON domains
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- IDEAS: Users can only see ideas in their workspaces
-- =============================================================================
CREATE POLICY ideas_workspace_access ON ideas
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- RELATIONSHIPS: Users can only see relationships in their workspaces
-- =============================================================================
CREATE POLICY relationships_workspace_access ON relationships
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- AGENTS: Users can only see agents in their workspaces
-- =============================================================================
CREATE POLICY agents_workspace_access ON agents
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- AGENT_SESSIONS: Users can only see agent sessions in their workspaces
-- =============================================================================
CREATE POLICY agent_sessions_workspace_access ON agent_sessions
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- USER_LAYOUTS: Users can only see their own layouts in their workspaces
-- =============================================================================
CREATE POLICY user_layouts_workspace_access ON user_layouts
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
AND user_id = current_user_id()
);
-- =============================================================================
-- KNOWLEDGE_ENTRIES: Users can only see entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_entries_workspace_access ON knowledge_entries
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- KNOWLEDGE_TAGS: Users can only see tags in their workspaces
-- =============================================================================
CREATE POLICY knowledge_tags_workspace_access ON knowledge_tags
FOR ALL
USING (
is_workspace_member(workspace_id, current_user_id())
);
-- =============================================================================
-- KNOWLEDGE_ENTRY_TAGS: Users can see tags for entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_entry_tags_access ON knowledge_entry_tags
FOR ALL
USING (
entry_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- KNOWLEDGE_LINKS: Users can see links between entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_links_access ON knowledge_links
FOR ALL
USING (
source_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- KNOWLEDGE_EMBEDDINGS: Users can see embeddings for entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_embeddings_access ON knowledge_embeddings
FOR ALL
USING (
entry_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- KNOWLEDGE_ENTRY_VERSIONS: Users can see versions for entries in their workspaces
-- =============================================================================
CREATE POLICY knowledge_entry_versions_access ON knowledge_entry_versions
FOR ALL
USING (
entry_id IN (
SELECT id FROM knowledge_entries
WHERE is_workspace_member(workspace_id, current_user_id())
)
);
-- =============================================================================
-- GRANT USAGE TO APPLICATION ROLE
-- =============================================================================
-- The application should connect with a role that has appropriate permissions.
-- By default, we assume the owner of the database has full access.
-- In production, create a dedicated role with limited permissions.
-- Example (uncomment and customize for production):
-- GRANT USAGE ON SCHEMA public TO mosaic_app;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mosaic_app;
-- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO mosaic_app;
@@ -1,18 +0,0 @@
-- CreateTable
CREATE TABLE "user_preferences" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"theme" TEXT NOT NULL DEFAULT 'system',
"locale" TEXT NOT NULL DEFAULT 'en',
"timezone" TEXT,
"settings" JSONB NOT NULL DEFAULT '{}',
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "user_preferences_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_preferences_user_id_key" ON "user_preferences"("user_id");
-- AddForeignKey
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,47 +0,0 @@
-- CreateEnum
CREATE TYPE "AgentTaskStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "AgentTaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
-- CreateTable
CREATE TABLE "agent_tasks" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "AgentTaskStatus" NOT NULL DEFAULT 'PENDING',
"priority" "AgentTaskPriority" NOT NULL DEFAULT 'MEDIUM',
"agent_type" TEXT NOT NULL,
"agent_config" JSONB NOT NULL DEFAULT '{}',
"result" JSONB,
"error" TEXT,
"created_by_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"started_at" TIMESTAMPTZ,
"completed_at" TIMESTAMPTZ,
CONSTRAINT "agent_tasks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "agent_tasks_workspace_id_idx" ON "agent_tasks"("workspace_id");
-- CreateIndex
CREATE INDEX "agent_tasks_workspace_id_status_idx" ON "agent_tasks"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "agent_tasks_workspace_id_priority_idx" ON "agent_tasks"("workspace_id", "priority");
-- CreateIndex
CREATE INDEX "agent_tasks_created_by_id_idx" ON "agent_tasks"("created_by_id");
-- CreateIndex
CREATE UNIQUE INDEX "agent_tasks_id_workspace_id_key" ON "agent_tasks"("id", "workspace_id");
-- AddForeignKey
ALTER TABLE "agent_tasks" ADD CONSTRAINT "agent_tasks_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_tasks" ADD CONSTRAINT "agent_tasks_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,31 +0,0 @@
-- CreateEnum
CREATE TYPE "FormalityLevel" AS ENUM ('VERY_CASUAL', 'CASUAL', 'NEUTRAL', 'FORMAL', 'VERY_FORMAL');
-- CreateTable
CREATE TABLE "personalities" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"tone" TEXT NOT NULL,
"formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL',
"system_prompt_template" TEXT NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "personalities_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "personalities_workspace_id_idx" ON "personalities"("workspace_id");
-- CreateIndex
CREATE INDEX "personalities_workspace_id_is_default_idx" ON "personalities"("workspace_id", "is_default");
-- CreateIndex
CREATE UNIQUE INDEX "personalities_workspace_id_name_key" ON "personalities"("workspace_id", "name");
-- AddForeignKey
ALTER TABLE "personalities" ADD CONSTRAINT "personalities_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,41 +0,0 @@
/*
Warnings:
- You are about to drop the `personalities` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `display_text` to the `knowledge_links` table without a default value. This is not possible if the table is not empty.
- Added the required column `position_end` to the `knowledge_links` table without a default value. This is not possible if the table is not empty.
- Added the required column `position_start` to the `knowledge_links` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "personalities" DROP CONSTRAINT "personalities_workspace_id_fkey";
-- DropIndex
DROP INDEX "knowledge_links_source_id_target_id_key";
-- AlterTable: Add new columns with temporary defaults for existing records
ALTER TABLE "knowledge_links"
ADD COLUMN "display_text" TEXT DEFAULT '',
ADD COLUMN "position_end" INTEGER DEFAULT 0,
ADD COLUMN "position_start" INTEGER DEFAULT 0,
ADD COLUMN "resolved" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "target_id" DROP NOT NULL;
-- Update existing records: set display_text to link_text and resolved to true if target exists
UPDATE "knowledge_links" SET "display_text" = "link_text" WHERE "display_text" = '';
UPDATE "knowledge_links" SET "resolved" = true WHERE "target_id" IS NOT NULL;
-- Remove defaults for new records
ALTER TABLE "knowledge_links"
ALTER COLUMN "display_text" DROP DEFAULT,
ALTER COLUMN "position_end" DROP DEFAULT,
ALTER COLUMN "position_start" DROP DEFAULT;
-- DropTable
DROP TABLE "personalities";
-- DropEnum
DROP TYPE "FormalityLevel";
-- CreateIndex
CREATE INDEX "knowledge_links_source_id_resolved_idx" ON "knowledge_links"("source_id", "resolved");
@@ -1,8 +0,0 @@
-- Add HNSW index for fast vector similarity search on knowledge_embeddings table
-- Using cosine distance operator for semantic similarity
-- Parameters: m=16 (max connections per layer), ef_construction=64 (build quality)
CREATE INDEX IF NOT EXISTS knowledge_embeddings_embedding_idx
ON knowledge_embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
@@ -1,29 +0,0 @@
-- CreateTable
CREATE TABLE "llm_provider_instances" (
"id" UUID NOT NULL,
"provider_type" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"user_id" UUID,
"config" JSONB NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "llm_provider_instances_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "llm_provider_instances_user_id_idx" ON "llm_provider_instances"("user_id");
-- CreateIndex
CREATE INDEX "llm_provider_instances_provider_type_idx" ON "llm_provider_instances"("provider_type");
-- CreateIndex
CREATE INDEX "llm_provider_instances_is_default_idx" ON "llm_provider_instances"("is_default");
-- CreateIndex
CREATE INDEX "llm_provider_instances_is_enabled_idx" ON "llm_provider_instances"("is_enabled");
-- AddForeignKey
ALTER TABLE "llm_provider_instances" ADD CONSTRAINT "llm_provider_instances_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,112 +0,0 @@
-- CreateEnum
CREATE TYPE "RunnerJobStatus" AS ENUM ('PENDING', 'QUEUED', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "JobStepPhase" AS ENUM ('SETUP', 'EXECUTION', 'VALIDATION', 'CLEANUP');
-- CreateEnum
CREATE TYPE "JobStepType" AS ENUM ('COMMAND', 'AI_ACTION', 'GATE', 'ARTIFACT');
-- CreateEnum
CREATE TYPE "JobStepStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'SKIPPED');
-- CreateTable
CREATE TABLE "runner_jobs" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"agent_task_id" UUID,
"type" TEXT NOT NULL,
"status" "RunnerJobStatus" NOT NULL DEFAULT 'PENDING',
"priority" INTEGER NOT NULL,
"progress_percent" INTEGER NOT NULL DEFAULT 0,
"result" JSONB,
"error" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"started_at" TIMESTAMPTZ,
"completed_at" TIMESTAMPTZ,
CONSTRAINT "runner_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "job_steps" (
"id" UUID NOT NULL,
"job_id" UUID NOT NULL,
"ordinal" INTEGER NOT NULL,
"phase" "JobStepPhase" NOT NULL,
"name" TEXT NOT NULL,
"type" "JobStepType" NOT NULL,
"status" "JobStepStatus" NOT NULL DEFAULT 'PENDING',
"output" TEXT,
"tokens_input" INTEGER,
"tokens_output" INTEGER,
"started_at" TIMESTAMPTZ,
"completed_at" TIMESTAMPTZ,
"duration_ms" INTEGER,
CONSTRAINT "job_steps_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "job_events" (
"id" UUID NOT NULL,
"job_id" UUID NOT NULL,
"step_id" UUID,
"type" TEXT NOT NULL,
"timestamp" TIMESTAMPTZ NOT NULL,
"actor" TEXT NOT NULL,
"payload" JSONB NOT NULL,
CONSTRAINT "job_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "runner_jobs_id_workspace_id_key" ON "runner_jobs"("id", "workspace_id");
-- CreateIndex
CREATE INDEX "runner_jobs_workspace_id_idx" ON "runner_jobs"("workspace_id");
-- CreateIndex
CREATE INDEX "runner_jobs_workspace_id_status_idx" ON "runner_jobs"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "runner_jobs_agent_task_id_idx" ON "runner_jobs"("agent_task_id");
-- CreateIndex
CREATE INDEX "runner_jobs_priority_idx" ON "runner_jobs"("priority");
-- CreateIndex
CREATE INDEX "job_steps_job_id_idx" ON "job_steps"("job_id");
-- CreateIndex
CREATE INDEX "job_steps_job_id_ordinal_idx" ON "job_steps"("job_id", "ordinal");
-- CreateIndex
CREATE INDEX "job_steps_status_idx" ON "job_steps"("status");
-- CreateIndex
CREATE INDEX "job_events_job_id_idx" ON "job_events"("job_id");
-- CreateIndex
CREATE INDEX "job_events_step_id_idx" ON "job_events"("step_id");
-- CreateIndex
CREATE INDEX "job_events_timestamp_idx" ON "job_events"("timestamp");
-- CreateIndex
CREATE INDEX "job_events_type_idx" ON "job_events"("type");
-- AddForeignKey
ALTER TABLE "runner_jobs" ADD CONSTRAINT "runner_jobs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "runner_jobs" ADD CONSTRAINT "runner_jobs_agent_task_id_fkey" FOREIGN KEY ("agent_task_id") REFERENCES "agent_tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "job_steps" ADD CONSTRAINT "job_steps_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "runner_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "job_events" ADD CONSTRAINT "job_events_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "runner_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "job_events" ADD CONSTRAINT "job_events_step_id_fkey" FOREIGN KEY ("step_id") REFERENCES "job_steps"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- CreateIndex
CREATE INDEX "job_events_job_id_timestamp_idx" ON "job_events"("job_id", "timestamp");
@@ -1,36 +0,0 @@
-- Add tsvector column for full-text search on knowledge_entries
-- Weighted fields: title (A), summary (B), content (C)
-- Step 1: Add the search_vector column
ALTER TABLE "knowledge_entries"
ADD COLUMN "search_vector" tsvector;
-- Step 2: Create GIN index for fast full-text search
CREATE INDEX "knowledge_entries_search_vector_idx"
ON "knowledge_entries"
USING gin("search_vector");
-- Step 3: Create function to update search_vector
CREATE OR REPLACE FUNCTION knowledge_entries_search_vector_update()
RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.summary, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(NEW.content, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
-- Step 4: Create trigger to automatically update search_vector on insert/update
CREATE TRIGGER knowledge_entries_search_vector_trigger
BEFORE INSERT OR UPDATE ON "knowledge_entries"
FOR EACH ROW
EXECUTE FUNCTION knowledge_entries_search_vector_update();
-- Step 5: Populate search_vector for existing entries
UPDATE "knowledge_entries"
SET search_vector =
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(summary, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(content, '')), 'C');
@@ -1,118 +0,0 @@
-- CreateEnum
CREATE TYPE "FederationConnectionStatus" AS ENUM ('PENDING', 'ACTIVE', 'SUSPENDED', 'DISCONNECTED');
-- CreateEnum
CREATE TYPE "FederationMessageType" AS ENUM ('QUERY', 'COMMAND', 'EVENT');
-- CreateEnum
CREATE TYPE "FederationMessageStatus" AS ENUM ('PENDING', 'DELIVERED', 'FAILED', 'TIMEOUT');
-- CreateTable
CREATE TABLE "federation_connections" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"remote_instance_id" TEXT NOT NULL,
"remote_url" TEXT NOT NULL,
"remote_public_key" TEXT NOT NULL,
"remote_capabilities" JSONB NOT NULL DEFAULT '{}',
"status" "FederationConnectionStatus" NOT NULL DEFAULT 'PENDING',
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"connected_at" TIMESTAMPTZ,
"disconnected_at" TIMESTAMPTZ,
CONSTRAINT "federation_connections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "federated_identities" (
"id" UUID NOT NULL,
"local_user_id" UUID NOT NULL,
"remote_user_id" TEXT NOT NULL,
"remote_instance_id" TEXT NOT NULL,
"oidc_subject" TEXT NOT NULL,
"email" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "federated_identities_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "federation_messages" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"connection_id" UUID NOT NULL,
"message_type" "FederationMessageType" NOT NULL,
"message_id" TEXT NOT NULL,
"correlation_id" TEXT,
"query" TEXT,
"command_type" TEXT,
"event_type" TEXT,
"payload" JSONB DEFAULT '{}',
"response" JSONB DEFAULT '{}',
"status" "FederationMessageStatus" NOT NULL DEFAULT 'PENDING',
"error" TEXT,
"signature" TEXT NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"delivered_at" TIMESTAMPTZ,
CONSTRAINT "federation_messages_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "federation_connections_workspace_id_remote_instance_id_key" ON "federation_connections"("workspace_id", "remote_instance_id");
-- CreateIndex
CREATE INDEX "federation_connections_workspace_id_idx" ON "federation_connections"("workspace_id");
-- CreateIndex
CREATE INDEX "federation_connections_workspace_id_status_idx" ON "federation_connections"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "federation_connections_remote_instance_id_idx" ON "federation_connections"("remote_instance_id");
-- CreateIndex
CREATE UNIQUE INDEX "federated_identities_local_user_id_remote_instance_id_key" ON "federated_identities"("local_user_id", "remote_instance_id");
-- CreateIndex
CREATE INDEX "federated_identities_local_user_id_idx" ON "federated_identities"("local_user_id");
-- CreateIndex
CREATE INDEX "federated_identities_remote_instance_id_idx" ON "federated_identities"("remote_instance_id");
-- CreateIndex
CREATE INDEX "federated_identities_oidc_subject_idx" ON "federated_identities"("oidc_subject");
-- CreateIndex
CREATE UNIQUE INDEX "federation_messages_message_id_key" ON "federation_messages"("message_id");
-- CreateIndex
CREATE INDEX "federation_messages_workspace_id_idx" ON "federation_messages"("workspace_id");
-- CreateIndex
CREATE INDEX "federation_messages_connection_id_idx" ON "federation_messages"("connection_id");
-- CreateIndex
CREATE INDEX "federation_messages_message_id_idx" ON "federation_messages"("message_id");
-- CreateIndex
CREATE INDEX "federation_messages_correlation_id_idx" ON "federation_messages"("correlation_id");
-- CreateIndex
CREATE INDEX "federation_messages_event_type_idx" ON "federation_messages"("event_type");
-- AddForeignKey
ALTER TABLE "federation_connections" ADD CONSTRAINT "federation_connections_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "federated_identities" ADD CONSTRAINT "federated_identities_local_user_id_fkey" FOREIGN KEY ("local_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "federation_messages" ADD CONSTRAINT "federation_messages_connection_id_fkey" FOREIGN KEY ("connection_id") REFERENCES "federation_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "federation_messages" ADD CONSTRAINT "federation_messages_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,7 +0,0 @@
-- Add version field for optimistic locking to prevent race conditions
-- This allows safe concurrent updates to runner job status
ALTER TABLE "runner_jobs" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
-- Create index for better performance on version checks
CREATE INDEX "runner_jobs_version_idx" ON "runner_jobs"("version");
@@ -1,34 +0,0 @@
-- CreateTable
CREATE TABLE "federation_event_subscriptions" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"connection_id" UUID NOT NULL,
"event_type" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "federation_event_subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_workspace_id_idx" ON "federation_event_subscriptions"("workspace_id");
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_connection_id_idx" ON "federation_event_subscriptions"("connection_id");
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_event_type_idx" ON "federation_event_subscriptions"("event_type");
-- CreateIndex
CREATE INDEX "federation_event_subscriptions_workspace_id_is_active_idx" ON "federation_event_subscriptions"("workspace_id", "is_active");
-- CreateIndex
CREATE UNIQUE INDEX "federation_event_subscriptions_workspace_id_connection_id_even_key" ON "federation_event_subscriptions"("workspace_id", "connection_id", "event_type");
-- AddForeignKey
ALTER TABLE "federation_event_subscriptions" ADD CONSTRAINT "federation_event_subscriptions_connection_id_fkey" FOREIGN KEY ("connection_id") REFERENCES "federation_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "federation_event_subscriptions" ADD CONSTRAINT "federation_event_subscriptions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,18 +0,0 @@
-- Rollback: SQL Injection Hardening for is_workspace_admin() Helper Function
-- This reverts the function to its previous implementation
-- =============================================================================
-- REVERT is_workspace_admin() to original implementation
-- =============================================================================
CREATE OR REPLACE FUNCTION is_workspace_admin(workspace_uuid UUID, user_uuid UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = workspace_uuid
AND user_id = user_uuid
AND role IN ('OWNER', 'ADMIN')
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
@@ -1,58 +0,0 @@
-- Security Fix: SQL Injection Hardening for is_workspace_admin() Helper Function
-- This migration adds explicit UUID validation to prevent SQL injection attacks
--
-- Related: #355 Code Review - Security CRIT-3
-- Original issue: Migration 20260129221004_add_rls_policies
-- =============================================================================
-- SECURITY FIX: Add explicit UUID validation to is_workspace_admin()
-- =============================================================================
-- The is_workspace_admin() function previously accepted UUID parameters without
-- explicit type casting/validation. Although PostgreSQL's parameter binding provides
-- some protection, explicit UUID type validation is a security best practice.
--
-- This fix adds explicit UUID validation using PostgreSQL's uuid type checking
-- to ensure that non-UUID values cannot bypass the function's intent.
CREATE OR REPLACE FUNCTION is_workspace_admin(workspace_uuid UUID, user_uuid UUID)
RETURNS BOOLEAN AS $$
DECLARE
-- Validate input parameters are valid UUIDs
v_workspace_id UUID;
v_user_id UUID;
BEGIN
-- Explicitly validate workspace_uuid parameter
IF workspace_uuid IS NULL THEN
RETURN FALSE;
END IF;
v_workspace_id := workspace_uuid::UUID;
-- Explicitly validate user_uuid parameter
IF user_uuid IS NULL THEN
RETURN FALSE;
END IF;
v_user_id := user_uuid::UUID;
-- Query with validated parameters
RETURN EXISTS (
SELECT 1 FROM workspace_members
WHERE workspace_id = v_workspace_id
AND user_id = v_user_id
AND role IN ('OWNER', 'ADMIN')
);
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- =============================================================================
-- NOTES
-- =============================================================================
-- This is a hardening fix that adds defense-in-depth to the is_workspace_admin()
-- helper function. While PostgreSQL's parameterized queries already provide
-- protection against SQL injection, explicit UUID type validation ensures:
--
-- 1. Parameters are explicitly cast to UUID type
-- 2. NULL values are handled defensively
-- 3. The function's intent is clear and secure
-- 4. Compliance with security best practices
--
-- This change is backward compatible and does not affect existing functionality.
@@ -1,91 +0,0 @@
-- Row-Level Security (RLS) for Auth Tables
-- This migration adds FORCE ROW LEVEL SECURITY and policies to accounts and sessions tables
-- to ensure users can only access their own authentication data.
--
-- Related: #350 - Add RLS policies to auth tables with FORCE enforcement
-- Design: docs/design/credential-security.md (Phase 1a)
-- =============================================================================
-- ENABLE FORCE RLS ON AUTH TABLES
-- =============================================================================
-- FORCE means the table owner (mosaic) is also subject to RLS policies.
-- This prevents Prisma (connecting as owner) from bypassing policies.
ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;
ALTER TABLE accounts FORCE ROW LEVEL SECURITY;
ALTER TABLE sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE sessions FORCE ROW LEVEL SECURITY;
-- =============================================================================
-- ACCOUNTS TABLE POLICIES
-- =============================================================================
-- Owner bypass policy: Allow access to all rows ONLY when no RLS context is set
-- This is required for:
-- 1. Prisma migrations that run without RLS context
-- 2. BetterAuth internal operations during authentication flow (when no user context)
-- 3. Database maintenance operations
-- When RLS context IS set (current_user_id() returns non-NULL), this policy does not apply
--
-- NOTE: If connecting as a PostgreSQL superuser (like the default 'mosaic' role),
-- RLS policies are bypassed entirely. For full RLS enforcement, the application
-- should connect as a non-superuser role. See docs/design/credential-security.md
CREATE POLICY accounts_owner_bypass ON accounts
FOR ALL
USING (current_user_id() IS NULL);
-- User access policy: Users can only access their own accounts
-- Uses current_user_id() helper from migration 20260129221004_add_rls_policies
-- This policy applies to all operations: SELECT, INSERT, UPDATE, DELETE
CREATE POLICY accounts_user_access ON accounts
FOR ALL
USING (user_id = current_user_id());
-- =============================================================================
-- SESSIONS TABLE POLICIES
-- =============================================================================
-- Owner bypass policy: Allow access to all rows ONLY when no RLS context is set
-- See note on accounts_owner_bypass policy about superuser limitations
CREATE POLICY sessions_owner_bypass ON sessions
FOR ALL
USING (current_user_id() IS NULL);
-- User access policy: Users can only access their own sessions
CREATE POLICY sessions_user_access ON sessions
FOR ALL
USING (user_id = current_user_id());
-- =============================================================================
-- VERIFICATION TABLE ANALYSIS
-- =============================================================================
-- The verifications table does NOT need RLS policies because:
-- 1. It stores ephemeral verification tokens (email verification, password reset)
-- 2. It has no user_id column - only identifier (email) and value (token)
-- 3. Tokens are short-lived and accessed by token value, not user context
-- 4. BetterAuth manages access control through token validation, not RLS
-- 5. No cross-user data leakage risk since tokens are random and expire
--
-- Therefore, we intentionally do NOT add RLS to verifications table.
-- =============================================================================
-- IMPORTANT: SUPERUSER LIMITATION
-- =============================================================================
-- PostgreSQL superusers (including the default 'mosaic' role) ALWAYS bypass
-- Row-Level Security policies, even with FORCE ROW LEVEL SECURITY enabled.
-- This is a fundamental PostgreSQL security design.
--
-- For production deployments with full RLS enforcement, create a dedicated
-- non-superuser application role:
--
-- CREATE ROLE mosaic_app WITH LOGIN PASSWORD 'secure-password';
-- GRANT CONNECT ON DATABASE mosaic TO mosaic_app;
-- GRANT USAGE ON SCHEMA public TO mosaic_app;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO mosaic_app;
-- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO mosaic_app;
--
-- Then update DATABASE_URL to connect as mosaic_app instead of mosaic.
-- The RLS policies will then be properly enforced for application queries.
--
-- See: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
@@ -1,76 +0,0 @@
-- Rollback: User Credentials Storage with RLS Policies
-- This migration reverses all changes from migration.sql
--
-- Related: #355 - Create UserCredential Prisma model with RLS policies
-- =============================================================================
-- DROP TRIGGERS AND FUNCTIONS
-- =============================================================================
DROP TRIGGER IF EXISTS user_credentials_updated_at ON user_credentials;
DROP FUNCTION IF EXISTS update_user_credentials_updated_at();
-- =============================================================================
-- DISABLE RLS
-- =============================================================================
ALTER TABLE user_credentials DISABLE ROW LEVEL SECURITY;
-- =============================================================================
-- DROP RLS POLICIES
-- =============================================================================
DROP POLICY IF EXISTS user_credentials_owner_bypass ON user_credentials;
DROP POLICY IF EXISTS user_credentials_user_access ON user_credentials;
DROP POLICY IF EXISTS user_credentials_workspace_access ON user_credentials;
-- =============================================================================
-- DROP INDEXES
-- =============================================================================
DROP INDEX IF EXISTS "user_credentials_user_id_workspace_id_provider_name_key";
DROP INDEX IF EXISTS "user_credentials_scope_is_active_idx";
DROP INDEX IF EXISTS "user_credentials_workspace_id_scope_idx";
DROP INDEX IF EXISTS "user_credentials_user_id_scope_idx";
DROP INDEX IF EXISTS "user_credentials_workspace_id_idx";
DROP INDEX IF EXISTS "user_credentials_user_id_idx";
-- =============================================================================
-- DROP FOREIGN KEY CONSTRAINTS
-- =============================================================================
ALTER TABLE "user_credentials" DROP CONSTRAINT IF EXISTS "user_credentials_workspace_id_fkey";
ALTER TABLE "user_credentials" DROP CONSTRAINT IF EXISTS "user_credentials_user_id_fkey";
-- =============================================================================
-- DROP TABLE
-- =============================================================================
DROP TABLE IF EXISTS "user_credentials";
-- =============================================================================
-- DROP ENUMS
-- =============================================================================
-- NOTE: ENUM values cannot be easily removed from an existing enum type in PostgreSQL.
-- To fully reverse this migration, you would need to:
--
-- 1. Remove the 'CREDENTIAL' value from EntityType enum (if not used elsewhere):
-- ALTER TYPE "EntityType" RENAME TO "EntityType_old";
-- CREATE TYPE "EntityType" AS ENUM (...all values except CREDENTIAL...);
-- -- Then rebuild all dependent objects
--
-- 2. Remove credential-related actions from ActivityAction enum (if not used elsewhere):
-- ALTER TYPE "ActivityAction" RENAME TO "ActivityAction_old";
-- CREATE TYPE "ActivityAction" AS ENUM (...all values except CREDENTIAL_*...);
-- -- Then rebuild all dependent objects
--
-- 3. Drop the CredentialType and CredentialScope enums:
-- DROP TYPE IF EXISTS "CredentialType";
-- DROP TYPE IF EXISTS "CredentialScope";
--
-- Due to the complexity and risk of breaking existing data/code that references
-- these enum values, this migration does NOT automatically remove them.
-- If you need to clean up the enums, manually execute the steps above.
--
-- For development environments, you can safely drop and recreate the enums manually
-- using the SQL statements above.
@@ -1,184 +0,0 @@
-- User Credentials Storage with RLS Policies
-- This migration adds the user_credentials table for secure storage of user API keys,
-- OAuth tokens, and other credentials with encryption and RLS enforcement.
--
-- Related: #355 - Create UserCredential Prisma model with RLS policies
-- Design: docs/design/credential-security.md (Phase 3a)
-- =============================================================================
-- CREATE ENUMS
-- =============================================================================
-- CredentialType enum: Types of credentials that can be stored
CREATE TYPE "CredentialType" AS ENUM ('API_KEY', 'OAUTH_TOKEN', 'ACCESS_TOKEN', 'SECRET', 'PASSWORD', 'CUSTOM');
-- CredentialScope enum: Access scope for credentials
CREATE TYPE "CredentialScope" AS ENUM ('USER', 'WORKSPACE', 'SYSTEM');
-- =============================================================================
-- EXTEND EXISTING ENUMS
-- =============================================================================
-- Add CREDENTIAL to EntityType for activity logging
ALTER TYPE "EntityType" ADD VALUE 'CREDENTIAL';
-- Add credential-related actions to ActivityAction
ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_CREATED';
ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_ACCESSED';
ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_ROTATED';
ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_REVOKED';
-- =============================================================================
-- CREATE USER_CREDENTIALS TABLE
-- =============================================================================
CREATE TABLE "user_credentials" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL,
"workspace_id" UUID,
-- Identity
"name" VARCHAR(255) NOT NULL,
"provider" VARCHAR(100) NOT NULL,
"type" "CredentialType" NOT NULL,
"scope" "CredentialScope" NOT NULL DEFAULT 'USER',
-- Encrypted storage
"encrypted_value" TEXT NOT NULL,
"masked_value" VARCHAR(20),
-- Metadata
"description" TEXT,
"expires_at" TIMESTAMPTZ,
"last_used_at" TIMESTAMPTZ,
"metadata" JSONB NOT NULL DEFAULT '{}',
-- Status
"is_active" BOOLEAN NOT NULL DEFAULT true,
"rotated_at" TIMESTAMPTZ,
-- Audit
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT "user_credentials_pkey" PRIMARY KEY ("id")
);
-- =============================================================================
-- CREATE FOREIGN KEY CONSTRAINTS
-- =============================================================================
ALTER TABLE "user_credentials" ADD CONSTRAINT "user_credentials_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "user_credentials" ADD CONSTRAINT "user_credentials_workspace_id_fkey"
FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- =============================================================================
-- CREATE INDEXES
-- =============================================================================
-- Index for user lookups
CREATE INDEX "user_credentials_user_id_idx" ON "user_credentials"("user_id");
-- Index for workspace lookups
CREATE INDEX "user_credentials_workspace_id_idx" ON "user_credentials"("workspace_id");
-- Index for user + scope queries
CREATE INDEX "user_credentials_user_id_scope_idx" ON "user_credentials"("user_id", "scope");
-- Index for workspace + scope queries
CREATE INDEX "user_credentials_workspace_id_scope_idx" ON "user_credentials"("workspace_id", "scope");
-- Index for scope + active status queries
CREATE INDEX "user_credentials_scope_is_active_idx" ON "user_credentials"("scope", "is_active");
-- =============================================================================
-- CREATE UNIQUE CONSTRAINT
-- =============================================================================
-- Prevent duplicate credentials per user/workspace/provider/name
CREATE UNIQUE INDEX "user_credentials_user_id_workspace_id_provider_name_key"
ON "user_credentials"("user_id", "workspace_id", "provider", "name");
-- =============================================================================
-- ENABLE FORCE ROW LEVEL SECURITY
-- =============================================================================
-- FORCE means the table owner (mosaic) is also subject to RLS policies.
-- This prevents Prisma (connecting as owner) from bypassing policies.
ALTER TABLE user_credentials ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_credentials FORCE ROW LEVEL SECURITY;
-- =============================================================================
-- RLS POLICIES
-- =============================================================================
-- Owner bypass policy: Allow access to all rows ONLY when no RLS context is set
-- This is required for:
-- 1. Prisma migrations that run without RLS context
-- 2. Database maintenance operations
-- When RLS context IS set (current_user_id() returns non-NULL), this policy does not apply
--
-- NOTE: If connecting as a PostgreSQL superuser (like the default 'mosaic' role),
-- RLS policies are bypassed entirely. For full RLS enforcement, the application
-- should connect as a non-superuser role. See docs/design/credential-security.md
CREATE POLICY user_credentials_owner_bypass ON user_credentials
FOR ALL
USING (current_user_id() IS NULL);
-- User access policy: USER-scoped credentials visible only to owner
-- Uses current_user_id() helper from migration 20260129221004_add_rls_policies
CREATE POLICY user_credentials_user_access ON user_credentials
FOR ALL
USING (
scope = 'USER' AND user_id = current_user_id()
);
-- Workspace admin access policy: WORKSPACE-scoped credentials visible to workspace admins
-- Uses is_workspace_admin() helper from migration 20260129221004_add_rls_policies
CREATE POLICY user_credentials_workspace_access ON user_credentials
FOR ALL
USING (
scope = 'WORKSPACE'
AND workspace_id IS NOT NULL
AND is_workspace_admin(workspace_id, current_user_id())
);
-- SYSTEM-scoped credentials are only accessible via owner bypass policy
-- (when current_user_id() IS NULL, which happens for admin operations)
-- =============================================================================
-- AUDIT TRIGGER
-- =============================================================================
-- Update updated_at timestamp on row changes
CREATE OR REPLACE FUNCTION update_user_credentials_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_credentials_updated_at
BEFORE UPDATE ON user_credentials
FOR EACH ROW
EXECUTE FUNCTION update_user_credentials_updated_at();
-- =============================================================================
-- NOTES
-- =============================================================================
-- This migration creates the foundation for secure credential storage.
-- The encrypted_value column stores ciphertext in one of two formats:
--
-- 1. OpenBao Transit format (preferred): vault:v1:base64data
-- 2. AES-256-GCM fallback format: iv:authTag:encrypted
--
-- The VaultService (issue #353) handles encryption/decryption with automatic
-- fallback to CryptoService when OpenBao is unavailable.
--
-- RLS enforcement ensures:
-- - USER scope: Only the credential owner can access
-- - WORKSPACE scope: Only workspace admins can access
-- - SYSTEM scope: Only accessible via admin/migration bypass
@@ -1,37 +0,0 @@
-- Encrypt existing plaintext Account tokens
-- This migration adds an encryption_version column and marks existing records for encryption
-- The actual encryption happens via Prisma middleware on first read/write
-- Add encryption_version column to track encryption state
-- NULL = not encrypted (legacy plaintext)
-- 'aes' = AES-256-GCM encrypted
-- 'vault' = OpenBao Transit encrypted (Phase 2)
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS encryption_version VARCHAR(20);
-- Create index for efficient queries filtering by encryption status
-- This index is also declared in Prisma schema (@@index([encryptionVersion]))
-- Using CREATE INDEX IF NOT EXISTS for idempotency
CREATE INDEX IF NOT EXISTS "accounts_encryption_version_idx" ON accounts(encryption_version);
-- Verify index was created successfully by running:
-- SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'accounts' AND indexname = 'accounts_encryption_version_idx';
-- Update statistics for query planner
ANALYZE accounts;
-- Migration Note:
-- This migration does NOT encrypt data in-place to avoid downtime and data corruption risks.
-- Instead, the Prisma middleware (account-encryption.middleware.ts) handles encryption:
--
-- 1. On READ: Detects format (plaintext vs encrypted) and decrypts if needed
-- 2. On WRITE: Encrypts tokens and sets encryption_version = 'aes'
-- 3. Backward compatible: Plaintext tokens (encryption_version = NULL) are passed through unchanged
--
-- To actively encrypt existing tokens, run the companion script:
-- node scripts/encrypt-account-tokens.js
--
-- This approach ensures:
-- - Zero downtime migration
-- - No risk of corrupting tokens during bulk encryption
-- - Progressive encryption as tokens are accessed/refreshed
-- - Easy rollback (middleware is idempotent)
@@ -1,26 +0,0 @@
-- Encrypt LLM Provider API Keys Migration
--
-- This migration enables transparent encryption/decryption of LLM provider API keys
-- stored in the llm_provider_instances.config JSON field.
--
-- IMPORTANT: This is a data migration with no schema changes.
--
-- Strategy:
-- 1. Prisma middleware (llm-encryption.middleware.ts) handles encryption/decryption
-- 2. Middleware auto-detects encryption format:
-- - vault:v1:... = OpenBao Transit encrypted
-- - Otherwise = Legacy plaintext (backward compatible)
-- 3. New API keys are always encrypted on write
-- 4. Existing plaintext keys work until re-saved (lazy migration)
--
-- To actively encrypt all existing API keys NOW:
-- pnpm --filter @mosaic/api migrate:encrypt-llm-keys
--
-- This approach ensures:
-- - Zero downtime migration
-- - No schema changes required
-- - Backward compatible with plaintext keys
-- - Progressive encryption as keys are accessed/updated
-- - Easy rollback (middleware is idempotent)
--
-- Note: No SQL changes needed. This file exists for migration tracking only.
@@ -1,197 +0,0 @@
-- RecreateEnum: FormalityLevel was dropped in 20260129235248_add_link_storage_fields
CREATE TYPE "FormalityLevel" AS ENUM ('VERY_CASUAL', 'CASUAL', 'NEUTRAL', 'FORMAL', 'VERY_FORMAL');
-- RecreateTable: personalities was dropped in 20260129235248_add_link_storage_fields
-- Recreated with current schema (display_name, system_prompt, temperature, etc.)
CREATE TABLE "personalities" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"description" TEXT,
"system_prompt" TEXT NOT NULL,
"temperature" DOUBLE PRECISION,
"max_tokens" INTEGER,
"llm_provider_instance_id" UUID,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "personalities_pkey" PRIMARY KEY ("id")
);
-- CreateIndex: personalities
CREATE UNIQUE INDEX "personalities_id_workspace_id_key" ON "personalities"("id", "workspace_id");
CREATE UNIQUE INDEX "personalities_workspace_id_name_key" ON "personalities"("workspace_id", "name");
CREATE INDEX "personalities_workspace_id_idx" ON "personalities"("workspace_id");
CREATE INDEX "personalities_workspace_id_is_default_idx" ON "personalities"("workspace_id", "is_default");
CREATE INDEX "personalities_workspace_id_is_enabled_idx" ON "personalities"("workspace_id", "is_enabled");
CREATE INDEX "personalities_llm_provider_instance_id_idx" ON "personalities"("llm_provider_instance_id");
-- AddForeignKey: personalities
ALTER TABLE "personalities" ADD CONSTRAINT "personalities_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "personalities" ADD CONSTRAINT "personalities_llm_provider_instance_id_fkey" FOREIGN KEY ("llm_provider_instance_id") REFERENCES "llm_provider_instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "cron_schedules" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"expression" TEXT NOT NULL,
"command" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"last_run" TIMESTAMPTZ,
"next_run" TIMESTAMPTZ,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "cron_schedules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspace_llm_settings" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"default_llm_provider_id" UUID,
"default_personality_id" UUID,
"settings" JSONB DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "workspace_llm_settings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "quality_gates" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL,
"command" TEXT,
"expected_output" TEXT,
"is_regex" BOOLEAN NOT NULL DEFAULT false,
"required" BOOLEAN NOT NULL DEFAULT true,
"order" INTEGER NOT NULL DEFAULT 0,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "quality_gates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "task_rejections" (
"id" UUID NOT NULL,
"task_id" TEXT NOT NULL,
"workspace_id" TEXT NOT NULL,
"agent_id" TEXT NOT NULL,
"attempt_count" INTEGER NOT NULL,
"failures" JSONB NOT NULL,
"original_task" TEXT NOT NULL,
"started_at" TIMESTAMPTZ NOT NULL,
"rejected_at" TIMESTAMPTZ NOT NULL,
"escalated" BOOLEAN NOT NULL DEFAULT false,
"manual_review" BOOLEAN NOT NULL DEFAULT false,
"resolved_at" TIMESTAMPTZ,
"resolution" TEXT,
CONSTRAINT "task_rejections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "token_budgets" (
"id" UUID NOT NULL,
"task_id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"agent_id" TEXT NOT NULL,
"allocated_tokens" INTEGER NOT NULL,
"estimated_complexity" TEXT NOT NULL,
"input_tokens_used" INTEGER NOT NULL DEFAULT 0,
"output_tokens_used" INTEGER NOT NULL DEFAULT 0,
"total_tokens_used" INTEGER NOT NULL DEFAULT 0,
"estimated_cost" DECIMAL(10,6),
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_updated_at" TIMESTAMPTZ NOT NULL,
"completed_at" TIMESTAMPTZ,
"budget_utilization" DOUBLE PRECISION,
"suspicious_pattern" BOOLEAN NOT NULL DEFAULT false,
"suspicious_reason" TEXT,
CONSTRAINT "token_budgets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "llm_usage_logs" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"provider" VARCHAR(50) NOT NULL,
"model" VARCHAR(100) NOT NULL,
"provider_instance_id" UUID,
"prompt_tokens" INTEGER NOT NULL DEFAULT 0,
"completion_tokens" INTEGER NOT NULL DEFAULT 0,
"total_tokens" INTEGER NOT NULL DEFAULT 0,
"cost_cents" DOUBLE PRECISION,
"task_type" VARCHAR(50),
"conversation_id" UUID,
"duration_ms" INTEGER,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "llm_usage_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex: cron_schedules
CREATE INDEX "cron_schedules_workspace_id_idx" ON "cron_schedules"("workspace_id");
CREATE INDEX "cron_schedules_workspace_id_enabled_idx" ON "cron_schedules"("workspace_id", "enabled");
CREATE INDEX "cron_schedules_next_run_idx" ON "cron_schedules"("next_run");
-- CreateIndex: workspace_llm_settings
CREATE UNIQUE INDEX "workspace_llm_settings_workspace_id_key" ON "workspace_llm_settings"("workspace_id");
CREATE INDEX "workspace_llm_settings_workspace_id_idx" ON "workspace_llm_settings"("workspace_id");
CREATE INDEX "workspace_llm_settings_default_llm_provider_id_idx" ON "workspace_llm_settings"("default_llm_provider_id");
CREATE INDEX "workspace_llm_settings_default_personality_id_idx" ON "workspace_llm_settings"("default_personality_id");
-- CreateIndex: quality_gates
CREATE UNIQUE INDEX "quality_gates_workspace_id_name_key" ON "quality_gates"("workspace_id", "name");
CREATE INDEX "quality_gates_workspace_id_idx" ON "quality_gates"("workspace_id");
CREATE INDEX "quality_gates_workspace_id_is_enabled_idx" ON "quality_gates"("workspace_id", "is_enabled");
-- CreateIndex: task_rejections
CREATE INDEX "task_rejections_task_id_idx" ON "task_rejections"("task_id");
CREATE INDEX "task_rejections_workspace_id_idx" ON "task_rejections"("workspace_id");
CREATE INDEX "task_rejections_agent_id_idx" ON "task_rejections"("agent_id");
CREATE INDEX "task_rejections_escalated_idx" ON "task_rejections"("escalated");
CREATE INDEX "task_rejections_manual_review_idx" ON "task_rejections"("manual_review");
-- CreateIndex: token_budgets
CREATE UNIQUE INDEX "token_budgets_task_id_key" ON "token_budgets"("task_id");
CREATE INDEX "token_budgets_task_id_idx" ON "token_budgets"("task_id");
CREATE INDEX "token_budgets_workspace_id_idx" ON "token_budgets"("workspace_id");
CREATE INDEX "token_budgets_suspicious_pattern_idx" ON "token_budgets"("suspicious_pattern");
-- CreateIndex: llm_usage_logs
CREATE INDEX "llm_usage_logs_workspace_id_idx" ON "llm_usage_logs"("workspace_id");
CREATE INDEX "llm_usage_logs_workspace_id_created_at_idx" ON "llm_usage_logs"("workspace_id", "created_at");
CREATE INDEX "llm_usage_logs_user_id_idx" ON "llm_usage_logs"("user_id");
CREATE INDEX "llm_usage_logs_provider_idx" ON "llm_usage_logs"("provider");
CREATE INDEX "llm_usage_logs_model_idx" ON "llm_usage_logs"("model");
CREATE INDEX "llm_usage_logs_provider_instance_id_idx" ON "llm_usage_logs"("provider_instance_id");
CREATE INDEX "llm_usage_logs_task_type_idx" ON "llm_usage_logs"("task_type");
CREATE INDEX "llm_usage_logs_conversation_id_idx" ON "llm_usage_logs"("conversation_id");
-- AddForeignKey: cron_schedules
ALTER TABLE "cron_schedules" ADD CONSTRAINT "cron_schedules_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: workspace_llm_settings
ALTER TABLE "workspace_llm_settings" ADD CONSTRAINT "workspace_llm_settings_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "workspace_llm_settings" ADD CONSTRAINT "workspace_llm_settings_default_llm_provider_id_fkey" FOREIGN KEY ("default_llm_provider_id") REFERENCES "llm_provider_instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "workspace_llm_settings" ADD CONSTRAINT "workspace_llm_settings_default_personality_id_fkey" FOREIGN KEY ("default_personality_id") REFERENCES "personalities"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey: quality_gates
ALTER TABLE "quality_gates" ADD CONSTRAINT "quality_gates_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: llm_usage_logs
ALTER TABLE "llm_usage_logs" ADD CONSTRAINT "llm_usage_logs_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "llm_usage_logs" ADD CONSTRAINT "llm_usage_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "llm_usage_logs" ADD CONSTRAINT "llm_usage_logs_provider_instance_id_fkey" FOREIGN KEY ("provider_instance_id") REFERENCES "llm_provider_instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "workspaces" ADD COLUMN "matrix_room_id" TEXT;
@@ -1,49 +0,0 @@
-- Fix schema drift: tables, indexes, and constraints defined in schema.prisma
-- but never created (or dropped and never recreated) by prior migrations.
-- ============================================
-- CreateTable: instances (Federation module)
-- Never created in any prior migration
-- ============================================
CREATE TABLE "instances" (
"id" UUID NOT NULL,
"instance_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"public_key" TEXT NOT NULL,
"private_key" TEXT NOT NULL,
"capabilities" JSONB NOT NULL DEFAULT '{}',
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "instances_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "instances_instance_id_key" ON "instances"("instance_id");
-- ============================================
-- Recreate dropped unique index on knowledge_links
-- Created in 20260129220645_add_knowledge_module, dropped in
-- 20260129235248_add_link_storage_fields, never recreated.
-- ============================================
CREATE UNIQUE INDEX "knowledge_links_source_id_target_id_key" ON "knowledge_links"("source_id", "target_id");
-- ============================================
-- Missing @@unique([id, workspaceId]) composite indexes
-- Defined in schema.prisma but never created in migrations.
-- (agent_tasks and runner_jobs already have these.)
-- ============================================
CREATE UNIQUE INDEX "tasks_id_workspace_id_key" ON "tasks"("id", "workspace_id");
CREATE UNIQUE INDEX "events_id_workspace_id_key" ON "events"("id", "workspace_id");
CREATE UNIQUE INDEX "projects_id_workspace_id_key" ON "projects"("id", "workspace_id");
CREATE UNIQUE INDEX "activity_logs_id_workspace_id_key" ON "activity_logs"("id", "workspace_id");
CREATE UNIQUE INDEX "domains_id_workspace_id_key" ON "domains"("id", "workspace_id");
CREATE UNIQUE INDEX "ideas_id_workspace_id_key" ON "ideas"("id", "workspace_id");
CREATE UNIQUE INDEX "user_layouts_id_workspace_id_key" ON "user_layouts"("id", "workspace_id");
-- ============================================
-- Missing index on agent_tasks.agent_type
-- Defined as @@index([agentType]) in schema.prisma
-- ============================================
CREATE INDEX "agent_tasks_agent_type_idx" ON "agent_tasks"("agent_type");
@@ -1,23 +0,0 @@
-- CreateEnum
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
-- CreateTable
CREATE TABLE "terminal_sessions" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL DEFAULT 'Terminal',
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closed_at" TIMESTAMPTZ,
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
-- CreateIndex
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
-- AddForeignKey
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,3 +0,0 @@
-- AlterTable: add tone and formality_level columns to personalities
ALTER TABLE "personalities" ADD COLUMN "tone" TEXT NOT NULL DEFAULT 'neutral';
ALTER TABLE "personalities" ADD COLUMN "formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL';
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
File diff suppressed because it is too large Load Diff
-599
View File
@@ -1,599 +0,0 @@
import {
PrismaClient,
TaskStatus,
TaskPriority,
ProjectStatus,
WorkspaceMemberRole,
EntryStatus,
Visibility,
} from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
console.log("Seeding database...");
// IMPORTANT: This seed script should not be run concurrently
// If running in CI/CD, ensure serialization of seed operations
// to prevent race conditions and data corruption
// Create test user
const user = await prisma.user.upsert({
where: { email: "[email protected]" },
update: {},
create: {
email: "[email protected]",
name: "Development User",
preferences: {
theme: "system",
notifications: true,
},
},
});
console.log(`Created user: ${user.email}`);
// Create workspace
const workspace = await prisma.workspace.upsert({
where: { id: "00000000-0000-0000-0000-000000000001" },
update: {},
create: {
id: "00000000-0000-0000-0000-000000000001",
name: "Development Workspace",
ownerId: user.id,
settings: {
timezone: "America/New_York",
},
},
});
console.log(`Created workspace: ${workspace.name}`);
// Add user as workspace owner
await prisma.workspaceMember.upsert({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
update: {},
create: {
workspaceId: workspace.id,
userId: user.id,
role: WorkspaceMemberRole.OWNER,
},
});
// ============================================
// WIDGET DEFINITIONS (global, not workspace-scoped)
// ============================================
const widgetDefs = [
{
name: "TasksWidget",
displayName: "Tasks",
description: "View and manage your tasks",
component: "TasksWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
{
name: "CalendarWidget",
displayName: "Calendar",
description: "View upcoming events and schedule",
component: "CalendarWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
{
name: "QuickCaptureWidget",
displayName: "Quick Capture",
description: "Quickly capture notes and tasks",
component: "QuickCaptureWidget",
defaultWidth: 2,
defaultHeight: 1,
minWidth: 2,
minHeight: 1,
maxWidth: 4,
maxHeight: 2,
configSchema: {},
},
{
name: "AgentStatusWidget",
displayName: "Agent Status",
description: "Monitor agent activity and status",
component: "AgentStatusWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 3,
maxHeight: null,
configSchema: {},
},
{
name: "ActiveProjectsWidget",
displayName: "Active Projects & Agent Chains",
description: "View active projects and running agent sessions",
component: "ActiveProjectsWidget",
defaultWidth: 2,
defaultHeight: 3,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
{
name: "TaskProgressWidget",
displayName: "Task Progress",
description: "Live progress of orchestrator agent tasks",
component: "TaskProgressWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 3,
maxHeight: null,
configSchema: {},
},
{
name: "OrchestratorEventsWidget",
displayName: "Orchestrator Events",
description: "Recent orchestration events with stream/Matrix visibility",
component: "OrchestratorEventsWidget",
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 4,
maxHeight: null,
configSchema: {},
},
];
for (const wd of widgetDefs) {
await prisma.widgetDefinition.upsert({
where: { name: wd.name },
update: {
displayName: wd.displayName,
description: wd.description,
component: wd.component,
defaultWidth: wd.defaultWidth,
defaultHeight: wd.defaultHeight,
minWidth: wd.minWidth,
minHeight: wd.minHeight,
maxWidth: wd.maxWidth,
maxHeight: wd.maxHeight,
configSchema: wd.configSchema,
},
create: {
name: wd.name,
displayName: wd.displayName,
description: wd.description,
component: wd.component,
defaultWidth: wd.defaultWidth,
defaultHeight: wd.defaultHeight,
minWidth: wd.minWidth,
minHeight: wd.minHeight,
maxWidth: wd.maxWidth,
maxHeight: wd.maxHeight,
configSchema: wd.configSchema,
},
});
}
console.log(`Seeded ${widgetDefs.length} widget definitions`);
// Use transaction for atomic seed data reset and creation
await prisma.$transaction(async (tx) => {
// Delete existing seed data for idempotency (avoids duplicates on re-run)
await tx.task.deleteMany({ where: { workspaceId: workspace.id } });
await tx.event.deleteMany({ where: { workspaceId: workspace.id } });
await tx.project.deleteMany({ where: { workspaceId: workspace.id } });
// Create sample project
const project = await tx.project.create({
data: {
workspaceId: workspace.id,
name: "Sample Project",
description: "A sample project for development",
status: ProjectStatus.ACTIVE,
creatorId: user.id,
color: "#3B82F6",
},
});
console.log(`Created project: ${project.name}`);
// Create sample tasks
const tasks = [
{
title: "Set up development environment",
status: TaskStatus.COMPLETED,
priority: TaskPriority.HIGH,
},
{
title: "Review project requirements",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.MEDIUM,
},
{
title: "Design database schema",
status: TaskStatus.COMPLETED,
priority: TaskPriority.HIGH,
},
{
title: "Implement NestJS integration",
status: TaskStatus.COMPLETED,
priority: TaskPriority.HIGH,
},
{
title: "Create seed data",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.MEDIUM,
},
];
// Use createMany for batch insertion (better performance)
await tx.task.createMany({
data: tasks.map((taskData) => ({
workspaceId: workspace.id,
title: taskData.title,
status: taskData.status,
priority: taskData.priority,
creatorId: user.id,
projectId: project.id,
})),
});
console.log(`Created ${tasks.length} sample tasks`);
// Create sample event
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(10, 0, 0, 0);
await tx.event.create({
data: {
workspaceId: workspace.id,
title: "Morning standup",
description: "Daily team sync",
startTime: tomorrow,
endTime: new Date(tomorrow.getTime() + 30 * 60000), // 30 minutes later
creatorId: user.id,
projectId: project.id,
},
});
console.log("Created sample event");
// ============================================
// KNOWLEDGE MODULE SEED DATA
// ============================================
// Delete existing knowledge data
await tx.knowledgeEmbedding.deleteMany({ where: { entry: { workspaceId: workspace.id } } });
await tx.knowledgeEntryTag.deleteMany({ where: { entry: { workspaceId: workspace.id } } });
await tx.knowledgeLink.deleteMany({ where: { source: { workspaceId: workspace.id } } });
await tx.knowledgeEntryVersion.deleteMany({ where: { entry: { workspaceId: workspace.id } } });
await tx.knowledgeEntry.deleteMany({ where: { workspaceId: workspace.id } });
await tx.knowledgeTag.deleteMany({ where: { workspaceId: workspace.id } });
// Create knowledge tags
const tags = await Promise.all([
tx.knowledgeTag.create({
data: {
workspaceId: workspace.id,
name: "Architecture",
slug: "architecture",
color: "#3B82F6",
description: "System architecture and design decisions",
},
}),
tx.knowledgeTag.create({
data: {
workspaceId: workspace.id,
name: "Development",
slug: "development",
color: "#10B981",
description: "Development practices and guidelines",
},
}),
tx.knowledgeTag.create({
data: {
workspaceId: workspace.id,
name: "Getting Started",
slug: "getting-started",
color: "#F59E0B",
description: "Onboarding and setup guides",
},
}),
]);
console.log(`Created ${tags.length} knowledge tags`);
// Create knowledge entries
const entries = [
{
slug: "welcome",
title: "Welcome to Mosaic Stack Knowledge Base",
content: `# Welcome to Mosaic Stack
This is the knowledge base for the Mosaic Stack project. Here you'll find:
- **[[architecture-overview]]** - High-level system architecture
- **[[development-setup]]** - Getting started with development
- **[[database-schema]]** - Database design and conventions
## About This Knowledge Base
The Knowledge Module provides:
- Wiki-style linking between entries
- Full-text and semantic search
- Version history and change tracking
- Tag-based organization
Start exploring by following the links above!`,
summary: "Introduction to the Mosaic Stack knowledge base and navigation guide",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
tags: ["getting-started"],
},
{
slug: "architecture-overview",
title: "Architecture Overview",
content: `# Architecture Overview
The Mosaic Stack is built on a modern, scalable architecture:
## Stack Components
- **Frontend**: Next.js 15+ with React 19
- **Backend**: NestJS with Prisma ORM
- **Database**: PostgreSQL 17 with pgvector
- **Cache**: Valkey (Redis fork)
## Key Modules
1. **Task Management** - See [[development-setup]] for local setup
2. **Event Calendar** - Integrated scheduling
3. **Knowledge Base** - This module! See [[database-schema]]
4. **Agent Orchestration** - AI agent coordination
The database schema is documented in [[database-schema]].`,
summary: "High-level overview of Mosaic Stack architecture and components",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
tags: ["architecture"],
},
{
slug: "development-setup",
title: "Development Setup Guide",
content: `# Development Setup Guide
## Prerequisites
- Node.js 22+
- PostgreSQL 17
- pnpm 9+
## Quick Start
\`\`\`bash
# Clone the repository
git clone https://git.mosaicstack.dev/mosaic/stack.git
# Install dependencies
pnpm install
# Set up database
cd apps/api
pnpm prisma migrate dev
pnpm prisma:seed
# Start development servers
pnpm dev
\`\`\`
## Architecture
Before diving in, review the [[architecture-overview]] to understand the system design.
## Database
The database schema is documented in [[database-schema]]. All models use Prisma ORM.
## Next Steps
1. Read the [[architecture-overview]]
2. Explore the codebase
3. Check out the [[database-schema]] documentation`,
summary: "Step-by-step guide to setting up the Mosaic Stack development environment",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
tags: ["development", "getting-started"],
},
{
slug: "database-schema",
title: "Database Schema Documentation",
content: `# Database Schema Documentation
## Overview
Mosaic Stack uses PostgreSQL 17 with Prisma ORM. See [[architecture-overview]] for context.
## Key Conventions
- All IDs are UUIDs
- Timestamps use \`@db.Timestamptz\` (timezone-aware)
- Soft deletes via status fields (e.g., ARCHIVED)
- Relations enforce cascade deletes for data integrity
## Core Models
### Task Management
- \`Task\` - Individual work items
- \`Project\` - Task containers
- \`Domain\` - High-level categorization
### Knowledge Module
- \`KnowledgeEntry\` - Wiki pages
- \`KnowledgeLink\` - Page connections
- \`KnowledgeTag\` - Categorization
- \`KnowledgeEmbedding\` - Semantic search (pgvector)
### Agent System
- \`Agent\` - AI agent instances
- \`AgentSession\` - Conversation sessions
## Migrations
Migrations are managed via Prisma:
\`\`\`bash
# Create migration
pnpm prisma migrate dev --name my_migration
# Apply in production
pnpm prisma migrate deploy
\`\`\`
For setup instructions, see [[development-setup]].`,
summary:
"Comprehensive documentation of the Mosaic Stack database schema and Prisma conventions",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
tags: ["architecture", "development"],
},
{
slug: "future-ideas",
title: "Future Ideas and Roadmap",
content: `# Future Ideas and Roadmap
## Planned Features
- Real-time collaboration (CRDT)
- Advanced graph visualizations
- AI-powered summarization
- Mobile app
## Research Areas
- Vector search optimization
- Knowledge graph algorithms
- Agent memory systems
This is a draft document. See [[architecture-overview]] for current state.`,
summary: "Brainstorming document for future features and research directions",
status: EntryStatus.DRAFT,
visibility: Visibility.PRIVATE,
tags: [],
},
];
// Create entries and track them for linking
const createdEntries = new Map<string, any>();
for (const entryData of entries) {
const entry = await tx.knowledgeEntry.create({
data: {
workspaceId: workspace.id,
slug: entryData.slug,
title: entryData.title,
content: entryData.content,
summary: entryData.summary,
status: entryData.status,
visibility: entryData.visibility,
createdBy: user.id,
updatedBy: user.id,
},
});
createdEntries.set(entryData.slug, entry);
// Create initial version
await tx.knowledgeEntryVersion.create({
data: {
entryId: entry.id,
version: 1,
title: entry.title,
content: entry.content,
summary: entry.summary,
createdBy: user.id,
changeNote: "Initial version",
},
});
// Add tags
for (const tagSlug of entryData.tags) {
const tag = tags.find((t) => t.slug === tagSlug);
if (tag) {
await tx.knowledgeEntryTag.create({
data: {
entryId: entry.id,
tagId: tag.id,
},
});
}
}
}
console.log(`Created ${entries.length} knowledge entries`);
// Create wiki-links between entries
const links = [
{ source: "welcome", target: "architecture-overview", text: "architecture-overview" },
{ source: "welcome", target: "development-setup", text: "development-setup" },
{ source: "welcome", target: "database-schema", text: "database-schema" },
{ source: "architecture-overview", target: "development-setup", text: "development-setup" },
{ source: "architecture-overview", target: "database-schema", text: "database-schema" },
{
source: "development-setup",
target: "architecture-overview",
text: "architecture-overview",
},
{ source: "development-setup", target: "database-schema", text: "database-schema" },
{ source: "database-schema", target: "architecture-overview", text: "architecture-overview" },
{ source: "database-schema", target: "development-setup", text: "development-setup" },
{ source: "future-ideas", target: "architecture-overview", text: "architecture-overview" },
];
for (const link of links) {
const sourceEntry = createdEntries.get(link.source);
const targetEntry = createdEntries.get(link.target);
if (sourceEntry && targetEntry) {
await tx.knowledgeLink.create({
data: {
sourceId: sourceEntry.id,
targetId: targetEntry.id,
linkText: link.text,
},
});
}
}
console.log(`Created ${links.length} knowledge links`);
});
console.log("Seeding completed successfully!");
}
main()
.catch((e) => {
console.error("Error seeding database:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
-166
View File
@@ -1,166 +0,0 @@
/**
* Data Migration: Encrypt LLM Provider API Keys
*
* Encrypts all plaintext API keys in llm_provider_instances.config using OpenBao Transit.
* This script processes records in batches and runs in a transaction for safety.
*
* Usage:
* pnpm --filter @mosaic/api migrate:encrypt-llm-keys
*
* Environment Variables:
* DATABASE_URL - PostgreSQL connection string
* OPENBAO_ADDR - OpenBao server address (default: http://openbao:8200)
* APPROLE_CREDENTIALS_PATH - Path to AppRole credentials file
*/
import { PrismaClient } from "@prisma/client";
import { VaultService } from "../src/vault/vault.service";
import { TransitKey } from "../src/vault/vault.constants";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
interface LlmProviderConfig {
apiKey?: string;
[key: string]: unknown;
}
interface LlmProviderInstance {
id: string;
config: LlmProviderConfig;
providerType: string;
displayName: string;
}
/**
* Check if a value is already encrypted
*/
function isEncrypted(value: string): boolean {
if (!value || typeof value !== "string") {
return false;
}
// Vault format: vault:v1:...
if (value.startsWith("vault:v1:")) {
return true;
}
// AES format: iv:authTag:encrypted (3 colon-separated hex parts)
const parts = value.split(":");
if (parts.length === 3 && parts.every((part) => /^[0-9a-f]+$/i.test(part))) {
return true;
}
return false;
}
/**
* Main migration function
*/
async function main(): Promise<void> {
const logger = new Logger("EncryptLlmKeys");
const prisma = new PrismaClient();
try {
logger.log("Starting LLM API key encryption migration...");
// Initialize VaultService
const configService = new ConfigService();
const vaultService = new VaultService(configService);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await vaultService.onModuleInit();
logger.log("VaultService initialized successfully");
// Fetch all LLM provider instances
const instances = await prisma.llmProviderInstance.findMany({
select: {
id: true,
config: true,
providerType: true,
displayName: true,
},
});
logger.log(`Found ${String(instances.length)} LLM provider instances`);
let encryptedCount = 0;
let skippedCount = 0;
let errorCount = 0;
// Process each instance
for (const instance of instances as LlmProviderInstance[]) {
try {
const config = instance.config;
// Skip if no apiKey field
if (!config.apiKey || typeof config.apiKey !== "string") {
logger.debug(`Skipping ${instance.displayName} (${instance.id}): No API key`);
skippedCount++;
continue;
}
// Skip if already encrypted
if (isEncrypted(config.apiKey)) {
logger.debug(`Skipping ${instance.displayName} (${instance.id}): Already encrypted`);
skippedCount++;
continue;
}
// Encrypt the API key
logger.log(`Encrypting ${instance.displayName} (${instance.providerType})...`);
const encryptedApiKey = await vaultService.encrypt(config.apiKey, TransitKey.LLM_CONFIG);
// Update the instance with encrypted key
await prisma.llmProviderInstance.update({
where: { id: instance.id },
data: {
config: {
...config,
apiKey: encryptedApiKey,
},
},
});
encryptedCount++;
logger.log(`✓ Encrypted ${instance.displayName} (${instance.id})`);
} catch (error: unknown) {
errorCount++;
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`✗ Failed to encrypt ${instance.displayName} (${instance.id}): ${errorMsg}`);
}
}
// Summary
logger.log("\n=== Migration Summary ===");
logger.log(`Total instances: ${String(instances.length)}`);
logger.log(`Encrypted: ${String(encryptedCount)}`);
logger.log(`Skipped: ${String(skippedCount)}`);
logger.log(`Errors: ${String(errorCount)}`);
if (errorCount > 0) {
logger.warn("\n⚠️ Some API keys failed to encrypt. Please review the errors above.");
process.exit(1);
} else if (encryptedCount === 0) {
logger.log("\n✓ All API keys are already encrypted or no keys found.");
} else {
logger.log("\n✓ Migration completed successfully!");
}
} catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`Migration failed: ${errorMsg}`);
throw error;
} finally {
await prisma.$disconnect();
}
}
// Run migration
main()
.then(() => {
process.exit(0);
})
.catch((error: unknown) => {
console.error(error);
process.exit(1);
});
@@ -1,314 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { ActivityController } from "./activity.controller";
import { ActivityService } from "./activity.service";
import { ActivityAction, EntityType } from "@prisma/client";
import type { QueryActivityLogDto } from "./dto";
describe("ActivityController", () => {
let controller: ActivityController;
let service: ActivityService;
const mockActivityService = {
findAll: vi.fn(),
findOne: vi.fn(),
getAuditTrail: vi.fn(),
};
const mockWorkspaceId = "workspace-123";
beforeEach(() => {
service = mockActivityService as any;
controller = new ActivityController(service);
vi.clearAllMocks();
});
describe("findAll", () => {
const mockPaginatedResult = {
data: [
{
id: "activity-1",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: {},
createdAt: new Date("2024-01-01"),
user: {
id: "user-123",
name: "Test User",
email: "[email protected]",
},
},
],
meta: {
total: 1,
page: 1,
limit: 50,
totalPages: 1,
},
};
it("should return paginated activity logs using authenticated user's workspaceId", async () => {
const query: QueryActivityLogDto = {
workspaceId: "workspace-123",
page: 1,
limit: 50,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
const result = await controller.findAll(query, mockWorkspaceId);
expect(result).toEqual(mockPaginatedResult);
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
it("should handle query with filters", async () => {
const query: QueryActivityLogDto = {
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
page: 1,
limit: 10,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
await controller.findAll(query, mockWorkspaceId);
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
it("should handle query with date range", async () => {
const startDate = new Date("2024-01-01");
const endDate = new Date("2024-01-31");
const query: QueryActivityLogDto = {
workspaceId: "workspace-123",
startDate,
endDate,
page: 1,
limit: 50,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
await controller.findAll(query, mockWorkspaceId);
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
it("should use user's workspaceId even if query provides different one", async () => {
const query: QueryActivityLogDto = {
workspaceId: "different-workspace",
page: 1,
limit: 50,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
await controller.findAll(query, mockWorkspaceId);
// Should use authenticated user's workspaceId, not query's
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
});
describe("findOne", () => {
const mockActivity = {
id: "activity-123",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: {},
createdAt: new Date(),
user: {
id: "user-123",
name: "Test User",
email: "[email protected]",
},
};
it("should return a single activity log using authenticated user's workspaceId", async () => {
mockActivityService.findOne.mockResolvedValue(mockActivity);
const result = await controller.findOne("activity-123", mockWorkspaceId);
expect(result).toEqual(mockActivity);
expect(mockActivityService.findOne).toHaveBeenCalledWith("activity-123", "workspace-123");
});
it("should return null if activity not found", async () => {
mockActivityService.findOne.mockResolvedValue(null);
const result = await controller.findOne("nonexistent", mockWorkspaceId);
expect(result).toBeNull();
});
it("should return null if workspaceId is missing (service handles gracefully)", async () => {
mockActivityService.findOne.mockResolvedValue(null);
const result = await controller.findOne("activity-123", undefined as any);
expect(result).toBeNull();
expect(mockActivityService.findOne).toHaveBeenCalledWith("activity-123", undefined);
});
});
describe("getAuditTrail", () => {
const mockAuditTrail = [
{
id: "activity-1",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: { title: "New Task" },
createdAt: new Date("2024-01-01"),
user: {
id: "user-123",
name: "Test User",
email: "[email protected]",
},
},
{
id: "activity-2",
workspaceId: "workspace-123",
userId: "user-456",
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: { title: "Updated Task" },
createdAt: new Date("2024-01-02"),
user: {
id: "user-456",
name: "Another User",
email: "[email protected]",
},
},
];
it("should return audit trail for a task using authenticated user's workspaceId", async () => {
mockActivityService.getAuditTrail.mockResolvedValue(mockAuditTrail);
const result = await controller.getAuditTrail(EntityType.TASK, "task-123", mockWorkspaceId);
expect(result).toEqual(mockAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
"workspace-123",
EntityType.TASK,
"task-123"
);
});
it("should return audit trail for an event", async () => {
const eventAuditTrail = [
{
id: "activity-3",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.EVENT,
entityId: "event-123",
details: {},
createdAt: new Date(),
user: {
id: "user-123",
name: "Test User",
email: "[email protected]",
},
},
];
mockActivityService.getAuditTrail.mockResolvedValue(eventAuditTrail);
const result = await controller.getAuditTrail(EntityType.EVENT, "event-123", mockWorkspaceId);
expect(result).toEqual(eventAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
"workspace-123",
EntityType.EVENT,
"event-123"
);
});
it("should return audit trail for a project", async () => {
const projectAuditTrail = [
{
id: "activity-4",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.PROJECT,
entityId: "project-123",
details: {},
createdAt: new Date(),
user: {
id: "user-123",
name: "Test User",
email: "[email protected]",
},
},
];
mockActivityService.getAuditTrail.mockResolvedValue(projectAuditTrail);
const result = await controller.getAuditTrail(
EntityType.PROJECT,
"project-123",
mockWorkspaceId
);
expect(result).toEqual(projectAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
"workspace-123",
EntityType.PROJECT,
"project-123"
);
});
it("should return empty array if no audit trail found", async () => {
mockActivityService.getAuditTrail.mockResolvedValue([]);
const result = await controller.getAuditTrail(
EntityType.WORKSPACE,
"workspace-999",
mockWorkspaceId
);
expect(result).toEqual([]);
});
it("should return empty array if workspaceId is missing (service handles gracefully)", async () => {
mockActivityService.getAuditTrail.mockResolvedValue([]);
const result = await controller.getAuditTrail(EntityType.TASK, "task-123", undefined as any);
expect(result).toEqual([]);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
undefined,
EntityType.TASK,
"task-123"
);
});
});
});
@@ -1,35 +0,0 @@
import { Controller, Get, Query, Param, UseGuards } from "@nestjs/common";
import { ActivityService } from "./activity.service";
import { EntityType } from "@prisma/client";
import type { QueryActivityLogDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
@Controller("activity")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class ActivityController {
constructor(private readonly activityService: ActivityService) {}
@Get()
@RequirePermission(Permission.WORKSPACE_ANY)
async findAll(@Query() query: QueryActivityLogDto, @Workspace() workspaceId: string) {
return this.activityService.findAll(Object.assign({}, query, { workspaceId }));
}
@Get("audit/:entityType/:entityId")
@RequirePermission(Permission.WORKSPACE_ANY)
async getAuditTrail(
@Param("entityType") entityType: EntityType,
@Param("entityId") entityId: string,
@Workspace() workspaceId: string
) {
return this.activityService.getAuditTrail(workspaceId, entityType, entityId);
}
@Get(":id")
@RequirePermission(Permission.WORKSPACE_ANY)
async findOne(@Param("id") id: string, @Workspace() workspaceId: string) {
return this.activityService.findOne(id, workspaceId);
}
}
-16
View File
@@ -1,16 +0,0 @@
import { Module } from "@nestjs/common";
import { ActivityController } from "./activity.controller";
import { ActivityService } from "./activity.service";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
/**
* Module for activity logging and audit trail functionality
*/
@Module({
imports: [PrismaModule, AuthModule],
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
})
export class ActivityModule {}
File diff suppressed because it is too large Load Diff
-584
View File
@@ -1,584 +0,0 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityAction, EntityType, Prisma, ActivityLog } from "@prisma/client";
import type {
CreateActivityLogInput,
PaginatedActivityLogs,
ActivityLogResult,
} from "./interfaces/activity.interface";
import type { QueryActivityLogDto } from "./dto";
/**
* Service for managing activity logs and audit trails
*/
@Injectable()
export class ActivityService {
private readonly logger = new Logger(ActivityService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new activity log entry (fire-and-forget)
*
* Activity logging failures are logged but never propagate to callers.
* This ensures activity logging never breaks primary operations.
*
* @returns The created ActivityLog or null if logging failed
*/
async logActivity(input: CreateActivityLogInput): Promise<ActivityLog | null> {
try {
return await this.prisma.activityLog.create({
data: input as unknown as Prisma.ActivityLogCreateInput,
});
} catch (error) {
// Log the error but don't propagate - activity logging is fire-and-forget
this.logger.error(
`Failed to log activity: action=${input.action} entityType=${input.entityType} entityId=${input.entityId}`,
error instanceof Error ? error.stack : String(error)
);
return null;
}
}
/**
* Get paginated activity logs with filters
*/
async findAll(query: QueryActivityLogDto): Promise<PaginatedActivityLogs> {
const page = query.page ?? 1;
const limit = query.limit ?? 50;
const skip = (page - 1) * limit;
// Build where clause
const where: Prisma.ActivityLogWhereInput = {};
if (query.workspaceId !== undefined) {
where.workspaceId = query.workspaceId;
}
if (query.userId) {
where.userId = query.userId;
}
if (query.action) {
where.action = query.action;
}
if (query.entityType) {
where.entityType = query.entityType;
}
if (query.entityId) {
where.entityId = query.entityId;
}
if (query.startDate ?? query.endDate) {
where.createdAt = {};
if (query.startDate) {
where.createdAt.gte = query.startDate;
}
if (query.endDate) {
where.createdAt.lte = query.endDate;
}
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.activityLog.findMany({
where,
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
this.prisma.activityLog.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get a single activity log by ID
*/
async findOne(id: string, workspaceId: string): Promise<ActivityLogResult | null> {
return await this.prisma.activityLog.findUnique({
where: {
id,
workspaceId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
/**
* Get audit trail for a specific entity
*/
async getAuditTrail(
workspaceId: string,
entityType: EntityType,
entityId: string
): Promise<ActivityLogResult[]> {
return await this.prisma.activityLog.findMany({
where: {
workspaceId,
entityType,
entityId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "asc",
},
});
}
// ============================================
// HELPER METHODS FOR COMMON ACTIVITY TYPES
// ============================================
/**
* Log task creation
*/
async logTaskCreated(
workspaceId: string,
userId: string,
taskId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task update
*/
async logTaskUpdated(
workspaceId: string,
userId: string,
taskId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task deletion
*/
async logTaskDeleted(
workspaceId: string,
userId: string,
taskId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task completion
*/
async logTaskCompleted(
workspaceId: string,
userId: string,
taskId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.COMPLETED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task assignment
*/
async logTaskAssigned(
workspaceId: string,
userId: string,
taskId: string,
assigneeId: string
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.ASSIGNED,
entityType: EntityType.TASK,
entityId: taskId,
details: { assigneeId },
});
}
/**
* Log event creation
*/
async logEventCreated(
workspaceId: string,
userId: string,
eventId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.EVENT,
entityId: eventId,
...(details && { details }),
});
}
/**
* Log event update
*/
async logEventUpdated(
workspaceId: string,
userId: string,
eventId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.EVENT,
entityId: eventId,
...(details && { details }),
});
}
/**
* Log event deletion
*/
async logEventDeleted(
workspaceId: string,
userId: string,
eventId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.EVENT,
entityId: eventId,
...(details && { details }),
});
}
/**
* Log project creation
*/
async logProjectCreated(
workspaceId: string,
userId: string,
projectId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.PROJECT,
entityId: projectId,
...(details && { details }),
});
}
/**
* Log project update
*/
async logProjectUpdated(
workspaceId: string,
userId: string,
projectId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.PROJECT,
entityId: projectId,
...(details && { details }),
});
}
/**
* Log project deletion
*/
async logProjectDeleted(
workspaceId: string,
userId: string,
projectId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.PROJECT,
entityId: projectId,
...(details && { details }),
});
}
/**
* Log workspace creation
*/
async logWorkspaceCreated(
workspaceId: string,
userId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
...(details && { details }),
});
}
/**
* Log workspace update
*/
async logWorkspaceUpdated(
workspaceId: string,
userId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
...(details && { details }),
});
}
/**
* Log workspace member added
*/
async logWorkspaceMemberAdded(
workspaceId: string,
userId: string,
memberId: string,
role: string
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
details: { memberId, role },
});
}
/**
* Log workspace member removed
*/
async logWorkspaceMemberRemoved(
workspaceId: string,
userId: string,
memberId: string
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
details: { memberId },
});
}
/**
* Log user profile update
*/
async logUserUpdated(
workspaceId: string,
userId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.USER,
entityId: userId,
...(details && { details }),
});
}
/**
* Log domain creation
*/
async logDomainCreated(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log domain update
*/
async logDomainUpdated(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log domain deletion
*/
async logDomainDeleted(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log idea creation
*/
async logIdeaCreated(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
/**
* Log idea update
*/
async logIdeaUpdated(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
/**
* Log idea deletion
*/
async logIdeaDeleted(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
}
@@ -1,348 +0,0 @@
import { describe, it, expect } from "vitest";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
import { CreateActivityLogDto } from "./create-activity-log.dto";
import { ActivityAction, EntityType } from "@prisma/client";
describe("CreateActivityLogDto", () => {
describe("required fields validation", () => {
it("should pass with all required fields valid", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when workspaceId is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const workspaceIdError = errors.find((e) => e.property === "workspaceId");
expect(workspaceIdError).toBeDefined();
});
it("should fail when userId is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const userIdError = errors.find((e) => e.property === "userId");
expect(userIdError).toBeDefined();
});
it("should fail when action is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const actionError = errors.find((e) => e.property === "action");
expect(actionError).toBeDefined();
});
it("should fail when entityType is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityTypeError = errors.find((e) => e.property === "entityType");
expect(entityTypeError).toBeDefined();
});
it("should fail when entityId is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityIdError = errors.find((e) => e.property === "entityId");
expect(entityIdError).toBeDefined();
});
});
describe("UUID validation", () => {
it("should fail with invalid workspaceId UUID", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "invalid-uuid",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("workspaceId");
});
it("should fail with invalid userId UUID", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "not-a-uuid",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const userIdError = errors.find((e) => e.property === "userId");
expect(userIdError).toBeDefined();
});
it("should fail with invalid entityId UUID", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "bad-entity-id",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityIdError = errors.find((e) => e.property === "entityId");
expect(entityIdError).toBeDefined();
});
});
describe("enum validation", () => {
it("should pass with all valid ActivityAction values", async () => {
const actions = Object.values(ActivityAction);
for (const action of actions) {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
}
});
it("should fail with invalid action value", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: "INVALID_ACTION",
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const actionError = errors.find((e) => e.property === "action");
expect(actionError?.constraints?.isEnum).toBeDefined();
});
it("should pass with all valid EntityType values", async () => {
const entityTypes = Object.values(EntityType);
for (const entityType of entityTypes) {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
}
});
it("should fail with invalid entityType value", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: "INVALID_TYPE",
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityTypeError = errors.find((e) => e.property === "entityType");
expect(entityTypeError?.constraints?.isEnum).toBeDefined();
});
});
describe("optional fields validation", () => {
it("should pass with valid details object", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
details: {
field: "status",
oldValue: "TODO",
newValue: "IN_PROGRESS",
},
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with non-object details", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
details: "not an object",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const detailsError = errors.find((e) => e.property === "details");
expect(detailsError?.constraints?.isObject).toBeDefined();
});
it("should pass with valid ipAddress", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
ipAddress: "192.168.1.1",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should pass with valid IPv6 address", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
ipAddress: "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when ipAddress exceeds max length", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
ipAddress: "a".repeat(46),
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const ipError = errors.find((e) => e.property === "ipAddress");
expect(ipError?.constraints?.maxLength).toBeDefined();
});
it("should pass with valid userAgent", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when userAgent exceeds max length", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
userAgent: "a".repeat(501),
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const userAgentError = errors.find((e) => e.property === "userAgent");
expect(userAgentError?.constraints?.maxLength).toBeDefined();
});
it("should pass when optional fields are not provided", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe("complete validation", () => {
it("should pass with all fields valid", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.PROJECT,
entityId: "550e8400-e29b-41d4-a716-446655440002",
details: {
changes: ["status", "priority"],
metadata: { source: "web-app" },
},
ipAddress: "10.0.0.1",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
});
@@ -1,36 +0,0 @@
import { ActivityAction, EntityType } from "@prisma/client";
import { IsUUID, IsEnum, IsOptional, IsObject, IsString, MaxLength } from "class-validator";
/**
* DTO for creating a new activity log entry
*/
export class CreateActivityLogDto {
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
workspaceId!: string;
@IsUUID("4", { message: "userId must be a valid UUID" })
userId!: string;
@IsEnum(ActivityAction, { message: "action must be a valid ActivityAction" })
action!: ActivityAction;
@IsEnum(EntityType, { message: "entityType must be a valid EntityType" })
entityType!: EntityType;
@IsUUID("4", { message: "entityId must be a valid UUID" })
entityId!: string;
@IsOptional()
@IsObject({ message: "details must be an object" })
details?: Record<string, unknown>;
@IsOptional()
@IsString({ message: "ipAddress must be a string" })
@MaxLength(45, { message: "ipAddress must not exceed 45 characters" })
ipAddress?: string;
@IsOptional()
@IsString({ message: "userAgent must be a string" })
@MaxLength(500, { message: "userAgent must not exceed 500 characters" })
userAgent?: string;
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./create-activity-log.dto";
export * from "./query-activity-log.dto";
@@ -1,254 +0,0 @@
import { describe, it, expect } from "vitest";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
import { QueryActivityLogDto } from "./query-activity-log.dto";
import { ActivityAction, EntityType } from "@prisma/client";
describe("QueryActivityLogDto", () => {
describe("workspaceId validation", () => {
it("should pass with valid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "invalid-uuid",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("workspaceId");
expect(errors[0].constraints?.isUuid).toBeDefined();
});
it("should pass when workspaceId is missing (it's optional)", async () => {
const dto = plainToInstance(QueryActivityLogDto, {});
const errors = await validate(dto);
// workspaceId is optional in DTO since it's set by controller from @Workspace() decorator
const workspaceIdError = errors.find((e) => e.property === "workspaceId");
expect(workspaceIdError).toBeUndefined();
});
});
describe("userId validation", () => {
it("should pass with valid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "not-a-uuid",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("userId");
});
it("should pass when userId is not provided (optional)", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe("action validation", () => {
it("should pass with valid ActivityAction", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
action: ActivityAction.CREATED,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid action value", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
action: "INVALID_ACTION",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("action");
});
it("should pass when action is not provided (optional)", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe("entityType validation", () => {
it("should pass with valid EntityType", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityType: EntityType.TASK,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid entityType value", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityType: "INVALID_TYPE",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("entityType");
});
});
describe("entityId validation", () => {
it("should pass with valid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityId: "invalid-entity-id",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("entityId");
});
});
describe("date validation", () => {
it("should pass with valid ISO date strings", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid date format", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
startDate: "not-a-date",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("startDate");
});
});
describe("pagination validation", () => {
it("should pass with valid page and limit", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
page: "1",
limit: "50",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.page).toBe(1);
expect(dto.limit).toBe(50);
});
it("should fail when page is less than 1", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
page: "0",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const pageError = errors.find((e) => e.property === "page");
expect(pageError?.constraints?.min).toBeDefined();
});
it("should fail when limit exceeds 100", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
limit: "101",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError?.constraints?.max).toBeDefined();
});
it("should fail when page is not an integer", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
page: "1.5",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const pageError = errors.find((e) => e.property === "page");
expect(pageError?.constraints?.isInt).toBeDefined();
});
it("should fail when limit is not an integer", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
limit: "50.5",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError?.constraints?.isInt).toBeDefined();
});
});
describe("multiple filters", () => {
it("should pass with all valid filters combined", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.PROJECT,
entityId: "550e8400-e29b-41d4-a716-446655440002",
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
page: "2",
limit: "25",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
});

Some files were not shown because too many files have changed in this diff Show More