Imported from 20057AMO/madar (
AGENTS.md). Install upstream withnpx skills add 20057AMO/madar. Copyright stays with the author.
AGENTS.md — Madar
Naming (2026-08): the product was renamed WSD-Pro → Madar (مدار). All user-facing strings, docs and baked opencode content say Madar; the GitHub repo is now
20057AMO/madarand backup downloads are namedmadar-backup-*.json(legacywsd-pro-backupexports still importable). Deliberately KEPT for data/infra compatibility:wsd.*localStorage keys,WSD_*env vars, docker resource names (wsd-procontainer,wsd-pro-appimage,wsd-<slug>project containers,wsd/workspaceimage), the per-project goals filenameWSD_PROJECT.md, and the legacywsd-pro-backupimport marker alongside the newmadar-backup. The retired JWT default-secret literals now survive ONLY in thejwt-secret-core.tsdeny-list, thejwt-secret.test.tsregression cases and this paragraph (they must be recognizable to be refused, and no code path may ever use them as key material) — nothing signs with them, see "JWT signing secret" below.
Vertical Goal (الهدف الرأسي)
Madar is an excellent, professional environment for a development team — بيئة متكاملة واحترافية لفريق تطوير.
The product unifies the whole development lifecycle in one place:
- بيئة تطوير مستقلة لكل مشروع — حاوية خاصة + محرر VS Code مضمّن + طرفية + ملفات + سجلات
- تخطيط وتوثيق — لوحة التخطيط البصري (Canvas) + ملاحظات (أفكار/أخطاء/أهداف) + نظرة سياق ذكية
- ذكاء اصطناعي مدمج — opencode + وكلاء/مهارات/أوامر + محادثة بسياق المشروع الكامل
- تعاون وفريق — أعضاء وأدوار وصلاحيات + لقطات/نسخ احتياطية + مراقبة وتنبيهات + مقاييس تخزين
كل ميزة أو تعديل يجب أن يخدم هذا الهدف: بيئة واحدة احترافية يعمل فيها الفريق من التخطيط إلى التطوير والاختبار والإنتاج.
Docker Rebuild Rule
After every code change to frontend/ or backend/, always rebuild and restart the container:
docker compose build app && docker compose up -d app
No exceptions. Every feature, fix, or refactor must be tested inside the running container.
Build & Verify
Local (development)
cd frontend && node node_modules\typescript\bin\tsc --noEmit
cd frontend && node node_modules\vite\bin\vite.js build
cd backend && node node_modules\typescript\bin\tsc --noEmit
Docker (integration)
docker compose build app && docker compose up -d app
Testing
Full backend suite against the running container (server must be up on port 3000):
cd backend && node --test --test-concurrency=1 "tests/**/*.test.ts"
| Suite | File | Coverage |
|---|---|---|
| Auth & access control | tests/auth.test.ts |
401s, forged/tampered/expired tokens, setup guard, per-user login identity matrix (editor/viewer logins sign their OWN id/role/JWT — the fix that ended first-admin session impersonation — 403s through requireAdmin/requireRole, revocation-aware tv, sessions never carry the 2fa-pending scope), deep per-user 2FA journey (enrol TOTP on a NON-first editor → challenge → verify signs the editor; pending token minted for the challenged user) gated behind WSD_TEST_ACCOUNT_PASSWORD (isolated server) because it costs 4 slots of the shared 10/min auth limiter; all login/setup calls use Retry-After backoff so window bleed from other suites never 429s the assertions |
| Session revocation | tests/auth-revoke.test.ts |
logout-everywhere + token rotation (needs WSD_TEST_ACCOUNT_PASSWORD; self-skips) |
| Session revocation (account-scoped, CWE-613) | tests/session-revocation.test.ts |
A session must not outlive its subject. Offline units drive the REAL verifiers in user-store.ts against a temp data dir (the pattern of jwt-secret.test.ts / embed-proxy.test.ts): a deleted user's session and their embed credential both 401 (the unknown-id fallback that kept a deleted admin's 24h token working is gone from every verifier, sibling providers/2fa-pending ones included), the live role beats the token's role claim (a viewer token claiming admin still gets 403, a demotion lands on the next verification), a bumped tv is refused, and every scope (embed/providers/2fa-pending) is rejected as a session. Live rows then assert the same over HTTP against the running container: the deleted-admin 401 matrix on /users+/webhooks+/auth/audit, the deleted editor's cookie refused by the proxy at :4097, and a live editor still working end to end |
| Project lifecycle | tests/projects.lifecycle.test.ts |
Real Docker: create → env → files → logs/stats/ports → stop/start → delete → 404; duplicate feature (copy source project: workspaces files + developer notes + planning canvas carried over, fresh ports applied, owner = duplicator) via POST /api/projects/:slug/duplicate |
| Project ports edit | tests/project-ports.test.ts |
Real Docker published-ports editing: PUT /api/projects/:slug/ports (editor+, audited) persists a validated+deduped (cap 50, no privileged/reserved ≤65535) set into meta immediately — applied only on the next explicit recreate (Docker can't rebind live), honest needsRecreate; self-exclusion vs sibling conflicts, 409+taken[], stale LIVE bindings of edited-but-not-recreated siblings and stopped containers' baked HostConfig.PortBindings (which the cheap docker list misses — inspected individually) still block the claim; snapshot manifest serializes the edited meta while stale; access matrix (non-member viewer 403 / viewer member 403 / editor member 200), 404 unknown |
| Project limits | tests/project-limits.test.ts: 12 live Docker tests — meta-first CPU/memory edit (PUT /api/projects/:slug/limits, editor+, audited project-limits) → applied on the next explicit recreate (cgroup caps can't rebind live), honest needsRecreate that reads clean after recreate (re-persist → false), creation-time limits applied immediately, duplicate inherits source limits, snapshot manifest deliberately excludes limits (restore = fresh machine), partial-update contract ('cpu' in body semantics, memory-only PUT preserves cpu), null removal + full clear, non-canonical round-trip normalization (2500m/1G → the exact inspected form, never an endless recreate loop), MemorySwap pinned to the memory total (docker inspect asserts MemorySwap === Memory — swap disabled, not -1/unlimited), stop/start keeps baked caps, access matrix 403/403/200, 404 unknown + tests/project-limits-core.test.ts: 31 offline unit tests (parse/ceilings/merge/round-trip) |
|
| Static site serve (core) | tests/serve-core.test.ts |
17 offline units on the import-free serve-core.ts (like janitor-core/snapshots-schedule): sanitizeServeConfig port matrix (explicit published port honored, junk/0/non-published → 400, no-port → first published, prev.port kept while still published / re-defaulted once dropped, empty published set → 400, null/empty-string treated absent), exact argv ['python3','-m','http.server',port,'-d','/workspace'] (no shell chars), deriveServeState honesty (disabled → inactive with no port/url, probe-null config-only, probe ok → active, refused → error, configured port no longer published → hostPort/url omitted), serveUrl host/hostPort forms |
| Static site serve (Docker) | tests/project-serve.test.ts |
Real Docker roundtrip: POST start on a published port → live host probe active + marker index.html served over the host port; list is config-only by design (enabled ticks, active honestly false — no N+1 probes); stop flips enabled:false and the port stops answering; restart persistence (stop→start auto re-runs via ensureServeRunning) + recreate persistence (prev-meta merge, auto re-run); validation matrix (non-published port 400, no-ports project 400, stopped container 409 — rejected edits never disturb a running serve); access matrix (outsider viewer 403 on all routes / viewer member 403 on writes + 200 status); 404 on all three routes; in-use-port 409 (a live non-SO_REUSEADDR holder inside the container forces the EADDRINUSE the 409 path relies on; failed start persists neither enabled nor active) |
| Project snapshots | tests/project-snapshots.test.ts |
Real Docker roundtrip: create source (file+notes+canvas+env+meta) → GET /projects/:slug/export (gzip magic + attachment filename) → POST /projects/import multipart → restored copy: files/notes/canvas/env/description preserved (canvas nodes/edges ids survive losslessly), ports fresh while source holds them, manifest ports reused once the source is gone, 404 unknown, non-member viewer export/import 403, garbage/empty uploads 400, hand-crafted ../-traversal tar 400 |
| Snapshot automation | tests/project-snapshots-auto.test.ts |
Scheduled server-side backups: defaults + partial-config validation, capture-now (archive on disk + lastSnapshotAt stamp), retention pruning to keep, download (gzip+filename) → delete → 404, restore filed snapshot into a brand-new project (files/notes/canvas/description), member-viewer read-only vs outsider 403 matrix, and pure computeDueSnapshots interval rules (import-free snapshots-schedule.ts, like janitor-core) |
| Project team & access | tests/team-access.test.ts |
Real Docker: owner creates project → creates editor+viewer users → adds members → permission matrix (viewer read-only/403s, editor read+write+stop/start, member add denied, viewer duplicate 403), remove rules, outsider 403, global-editor matrix (non-member editor reads/writes any project 200, but add-member/transfer-owner/delete stay 403), sudo transfer contract (missing password 400 → wrong password 401; the 403 gate opens for owner/system-admin/admin members and closes for others; success path real-login self-skips and asserts deterministic ownership: new owner + former owner both explicit admin members), self-transfer 400, owner-delete guard (refuses 400 while the user owns projects), then deletes project + temp users |
| Providers/Agents/Chat CRUD | tests/providers-agents-chat.test.ts |
Full CRUD + sessions + templates + the F1 access matrix on the chat surface: GET /api/chat/context folded once to the canonical slug BEFORE the gate (admin/member 200, outsider viewer 403 with no payload, slug!/uppercase/space-padded near-misses fold then 403, junk → 400 Project slug is invalid, unknown canonical slug keeps the legacy ABSENT-meta fallback 200, project=all open to every authenticated user) and the chat-session routes gated the same way (outsider 403 on all four, member viewer reads/200 but mutations 403, admin full CRUD, project=.. 400 before any store work, unscoped/global rows stay ungated) |
| Providers lock & backup | tests/providers-lock.test.ts |
Lock flow E2E (needs WSD_TEST_ACCOUNT_PASSWORD; self-skips without it) |
| Providers page journey | tests/providers-page-journey.test.ts |
Live-audit gaps: detection_required shape, duplicate guards, masked-key echo (POST+PUT), chat open while locked, cross-session replay via REAL logins, cooldown bans even the correct password (isolated server only; canary self-skips lock sections while the 15-min ban from a previous run is active) |
| Secret box (at-rest crypto) | tests/crypto.test.ts |
AES-256-GCM roundtrip, fresh-IV, tamper→empty, mask-without-decrypt + API-level sealing of providers.json (file assertions when WSD_TEST_PROVIDERS_FILE points at the server's data dir; set WSD_DATA_DIR to the same dir so the test process shares the server's salt) |
| Workspace janitor | tests/janitor.test.ts |
Orphan archiving, live-project safety, dot-dir skip, archive purge (WSD_ARCHIVE_DAYS=0) — offline, temp dirs only |
| Trash / archive mgmt | tests/archive-core.test.ts + tests/archive-api.test.ts |
7 offline (list/parse/traversal/copyTree incl. symlink-skip) + 20 real-Docker: list/?fresh=1/delete/empty, restore roundtrip + idempotency (404 on consumed), busy-port 409, slug-collision -1 dedup, access matrix 401/403/403/200, ../-traversal + injection rejection |
| Storage metrics (core) | tests/storage-core.test.ts |
Pure walk rules: nested-tree byte sums, symlink counts its link entry — never the target's bytes nor double-counted (assertion is Linux-correct: scans the link-only dir, so the symlink branch actually runs on CI instead of self-skipping on Windows without symlink perms), empty/missing dirs → 0, soft-deadline truncated stop, cleanSlug parity |
| Opencode Studio | tests/opencode-studio.test.ts |
Full baked roster (28 agents / 20 skills / 8 slash commands), agent+skill+command CRUD lifecycles, kebab-name + traversal rejection, frontmatter-required command writes, roster integrity check (every baked file: frontmatter present, zero NUL bytes, description non-empty — regression guard after a skill once shipped as pure NULs), config merge preserves keys / rejects junk, version-info shape (against running container) |
| VS Code image hardening | tests/ide-hardening.test.ts |
21 live-container rows over ide-hardening.sh (the single enforcement point): the shipped install (verify green, vscode.git-base@10.0.0 present + really declared as a dependency, every shipped @github package stubbed and the whole tree <2 MiB and ELF-free, no Copilot dir and no github-copilot process, vscode.github-authentication@0.0.2 installed + captured with an empty dependency edge and no Copilot reference, built-in count in the 30–50 band with the two dirs the old unprefixed delete-list missed proven pruned, the shadowing volume carrying the managed keys plus a todo-tree.ripgrep that points at an executable) + enforcement against synthetic trees in /tmp driven by WSD_CODE_SERVER_ROOT/WSD_IDE_BUILTIN_DIR so a regression can be planted, rejected and undone without ever writing the live install: capture proves the closure is transitive (vscode.git-base is not a name anyone typed), apply stubs a fabricated @github/copilot-fabricated-2099 the list cannot know (name-agnostic) while preserving a spawned package's real bin name, the keep-list prunes a new built-in plus both historical no-ops, repeated apply stays idempotent, six verify refusals (ELF payload / imported package >64 KiB / tree total >2 MiB / git-base deleted / Copilot dir back / @github deleted instead of stubbed) each healed back to green, and an absent extensions tree no-ops with exit 0 in every mode (the component-updates fake-deb contract). It reads the script's verdicts off stderr through spawnSync — execFileSync hands back stdout alone and would have made every refusal row vacuous |
| Agent delegation (core) | tests/opencode-delegate-core.test.ts |
13 offline units on the import-free opencode-delegate-core.ts (like serve-core/janitor-core): agentCapability safe-by-default classification (edit: deny|false|no under permission: → readonly — including the flow form permission: {edit: deny}; a bare top-level edit: deny, a description that merely mentions it, a typo key or an upper-case DENY → write, so a permission-less agent can never sneak past the editor gate), sanitizeDelegatePrompt (trim; empty/null/junk → 400 HttpError; 20 000-char truncation, not drop), buildDelegatePrompt (forces /workspace/WSD_PROJECT.md + WSD_CANVAS.md first, embeds the task, per-agent keyword template review/test/debug/plan/generic, slug fallback when the name is missing), delegateEntryId (d-<6>-<6> uniqueness across a batch), capDelegations (keeps the newest, junk/null-tolerant), validAgentName (kebab-case roster names only — path/space/junk rejected) |
| Security | tests/security.test.ts |
Path traversal, upload sanitization, malformed auth headers |
| Workspace paths (core) | tests/workspace-paths-core.test.ts |
50 offline units on the import-free workspace-paths-core.ts (like janitor-core/serve-core): the realpath'd root, slug refusal matrix (./../separators → 400, dotted my.project_2 still resolves), traversal + leading-slash-is-relative, mustExist default vs create flows, file.txt/child.txt → 400 not a later mkdir 500, the link group — an in-workspace link refused anyway, an outside link unwalkable/uncreatable through, a link LEAF refused unless allowLinkLeaf, workspaceLinkAt truth table, a linked project dir itself refused, and the forced-swap witness that proves O_EXCL beats a racer — the upload-path group (parseUploadPaths JSON-text parsing incl. the numeric-filename case, junk→{}, uploadRelativePath shape filter, the pair that proves a client-chosen folder still cannot traverse a planted link) — plus the link-free rename group (renameContainedPath: pin honesty on platforms without /proc/self/fd, ordinary nested rename, occupied/dangling destination 409, source-leaf and destination-intermediate link refusals, traversal/missing/no-op, generic no-path errors) — plus the link-free reads group on readContainedFile (regular read with TRUE size, maxBytes cap with honest truncation, the hard-ceiling clamp, missing/traversal/directory/root/unknown-slug refusal messages, a planted leaf link never yields the target bytes — the follow-witness asserts only where the platform resolves a file link — and a planted intermediate directory link refused before the open). Plus the openContainedForRead (descriptor handle) group (verified fd + true size from the fstat, the after-the-open name swap that cannot redirect the stream — the /file/raw contract, the link/directory/missing/traversal refusal matrix, and readContainedFile proven as the thin bounded wrapper that leaks no descriptor). Junctions on Windows, real symlinks on POSIX; t.skip only when the platform cannot create one |
| Workspace paths (consumers) | tests/workspace-paths-consumers.test.ts |
25 offline units proving each CONSUMER routes through the primitive (a unit test of the primitive alone cannot): Files-tab write/rename into a new nested folder (the regression the old guard caused), a listed link reported as type: 'link' with its own lstat size and not counted as a dir, deleting a link removes the LINK and never its target, write/read/delete THROUGH a link refused with the target untouched, link-leaf clobber refused, the rename route REFUSES a planted-link destination (old code wrote through it) and answers generic non-leaking errors; copyWorkspaceTree skips a planted source link (the exfiltration primitive) and throws on a linked DESTINATION; agent tools answer with the not-found marker and never the target's content (tree omits links); project-context.safeWorkspaceDir refuses a linked base but still tolerates a not-yet-created workspace; project-reviews.fileExists is a containment check (a thread pinned on a planted link reports false even though the target exists); and a planted WSD_PROJECT.md -> <secret> link fed to getProjectContext (docker source stubbed offline) answers the (refused — not a regular file) goals marker with zero target bytes, target untouched |
| Chat sessions store (traversal) | tests/chat-sessions-core.test.ts |
5 offline units on the import-free chat-sessions.ts + chat-store.ts pair: dot-only slugs (./../junk) fold to global/chat-unspecified on BOTH writers (the two sanitizers are deliberately duplicated with cross-reference comments, no shared import), a poisoned '..' chatId index row is structurally refused by chatDirFor (2-segment path-under-chats proof) so deleteSession cannot rmSync the chats dir, a poisoned '..'/'..' pair cannot wipe DATA_DIR (sentinel survives), and chatStore.append with dot ids lands in chats/chat-unspecified/chat-unspecified/events.jsonl with the escape target absent. Non-vacuity proven: the same suite fails 0/5 with the fix stashed |
| Link-free writes/renames (live) | tests/live-linkfree-proof.mjs |
Ad-hoc, NOT part of node --test (needs the container + mutates /app/data + slow); npm run test:live-linkfree runs it after a rebuild. 17 rows against the running container: new nested folder + file (the regression), planted WSD_CANVAS.md link refused with the target untouched, canvas store .. escape absent, 25 raced uploads + 25 raced Files-tab writes with an intact canary, refused link write answers 4xx not 500, control writes still green, agent-tool nested path creatable, the replayed OLD rename check-then-use DID move a file into another project (same-device victim, honest witness) while the route REFUSES the identical swap, 60 renames under a real concurrent racer never change the victim's md5 nor add a file, 60 raced renames aimed at /app/data/jwt.secret leave its md5 identical (the kernel additionally EXDEV-separates the data volume on Docker Desktop — that is an accident of the layout, not a control, so the witness uses a same-device project), ordinary rename still works, a VIEWER sees fileExists=false for a planted link, and a snapshot export ships no link entry with the target untouched |
| JWT signing secret | tests/jwt-secret.test.ts |
16 offline units (import-free jwt-secret-core.ts rules + the real resolver): the classify matrix (both public literals refused known-weak, blank/whitespace/non-string missing, <32 too-short, padding/case variants still refused, strong values accepted), and the resolution itself — the secret is generated once into <data>/jwt.secret (0600, checked on POSIX) and stable across a simulated restart, a weak/blank/short env value is refused rather than accepted, a weak persisted file is replaced, a strong env value wins over the persisted one, and — the regression guard for the RCE chain — a token signed with either public literal verifies as neither a session (verifyToken) nor an embed credential (verifyEmbedToken), while a token signed with the resolved secret verifies, so a signer/verifier split cannot pass |
| Smoke | tests/smoke.test.ts |
Health, core endpoints, project roundtrip |
| Project notes | tests/project-notes.test.ts |
Notes CRUD + validation (junk body 400, cap 300, truncation, kind normalization) + AI-context injection ([Developer notes] section ordering, done-items omitted) + 'all'-brief per-project counts |
| Project activity feed | tests/activity-core.test.ts (21 offline units on the import-free activity-core.ts: the 38-event vocabulary, whitelist rejection, sanitizeDetails field/cap rules, append/cap ceiling, legacy backfill + kebab map, newest-first paging, entryId format) + tests/project-activity.test.ts (real Docker: created/started/stopped attribution, config/content/team/snapshots/ports/limits events with details, project list & detail carry the same feed — lastTouched-readable append order, pagination + honest total, access matrix 401/403/200/200, 404 unknown + 404 after delete, per-project cleanup) |
|
| Project intelligence digest (core) | tests/digest-core.test.ts |
29 offline units on the import-free digest-core.ts (like janitor-core/snapshots-schedule): sanitizeDigestConfig partial-merge matrix (junk intervalMin falls back to prev then the 1440 default, all five whitelisted intervals accepted, 999/'60'/NaN/null rejected; enabled accepts booleans + 'true'/'false', 'yes'/1/0 keep prev), computeDueDigests (exactly-at-interval is due, just-inside is not, missing/blank/null lastDigestAt = never-digested = due, disabled + config-less skipped), buildDigestPrompt (BOTH untrusted markers present and ordered, the user-controlled name/slug appear only inside the framed block and never in the trusted prefix, the forced ## Summary/## Blockers/## Next steps headings, the DATA, never instructions ground rule, DIGEST_PROMPT_MAX cap), parseDigestOutput (plain + bold headings, -/*/+ and triple-backtick fences, missing sections → empty arrays with leftover text as summary, empty string → empty digest, garbage/no-heading → raw summary and never throws on null/number/undefined, item cap MAX_DIGEST_ITEMS, summary cap DIGEST_OUTPUT_MAX), buildDigestSourceSignature (equal→equal, each of the five parts changed→different, null/junk tolerated), hasDigestSignal (any non-empty flag → true, all-empty/junk → false), capDigests (keeps newest max, 0→empty, junk→empty) + digestEntryId g-<6>-<6> unique across 500, and safeDigestErrorLabel (recognises the four cases, an upstream sk-live-…/URL message collapses to provider error with no key/URL fragment, junk/null always yields a non-empty string) |
| Project intelligence digest (Docker) | tests/project-digest.test.ts |
Real Docker: all six routes 401 without a token; a fresh project's default disabled schedule + empty history; config round-trip (valid merge, junk intervalMin falls back to prev, partial update preserves the other key, persisted read agrees); the honest generate contract (never 500 — 201 + a running entry that settles to done/failed, or an explicit 503 that persists no entry, or 409/429 under contention); access matrix (member viewer reads list/config/entry, 403 on PUT/POST/DELETE; member editor writes; non-member viewer 403 on all six; unknown-but-canonical project mutations 404); activity feed records digest_config and, when a generation ran, digest_generated; delete 200 → GET 404 + unknown id 404; self-cleans its project + editor + viewer |
| Planning suggestions | tests/planning-suggest-core.test.ts (35 offline units on the import-free planning-suggest-core.ts, like digest-core/serve-core) + tests/planning-suggest.test.ts (9 real-Docker): the six rules — goal_without_task fires for an OPEN goal with no matching card (done goal or a case/whitespace-insensitive matching CARD suppresses; a matching STICKY does not), untracked_idea (any node type counts as tracked, done ideas never fire), stale_bug (open bug past STALE_BUG_DAYS = 14 — exactly at the boundary is NOT stale, +1 ms is; fresh/done/unparseable-createdAt never), orphan_card (a CARD with no edge fires even when done; a lone sticky never), duplicate_item (note↔note and note↔card equal-normalized pairs fire; card↔card out of scope), empty_board (notes + zero nodes fire; any node or no notes do not) — plus computeSuggestions (dedupe by key first-rule-wins, severity high→med→low stable sort, MAX_SUGGESTIONS cap, junk/null → [] never throws), normalizeText/contentKey stability + MIN_CONTAINMENT_LEN, sanitizeDismissed/applyDismissals caps, suggestEntryId s-<6>-<6> uniqueness across 500; live: all three routes 401 without a token, seeded subjects yield the expected rule kinds, access matrix (member viewer reads, editor writes, outsider 403, unknown project 404), dismiss roundtrip (survives recompute, unknown key 404, junk 400), accept roundtrip (note lands via the notes store, card via the canvas store), empty_board accept seeds one card per open note — never a blank card — chained with edges while auto-dismissing ONLY the self-created duplicate_item keys, the all-done-notes fallback, and the suggestion_accepted activity row; self-cleans its project + users |
|
| Project canvas | tests/project-canvas.test.ts |
Visual-planning whiteboard: GET default-empty vs seeded roundtrip, viewer read-only/editor writes/non-member viewer 403 matrix, junk body 400 + node/edge caps + bounds normalization, node id churn on alternating writes (fresh ids each save — canvases never merge garbage from stale clients), PUT audits canvas-save, canvasEditedAt appears on the project list after a save, the [Planning canvas] AI-context block renders sticky notes + task cards flat text (empty canvas → absent section), and the 'all' brief appends — board: N nodes once a project has a board. Saving a board also mirrors a flat WSD_CANVAS.md into the project workspace (removed when the board empties) so the IDE + opencode agents can read the plan alongside WSD_PROJECT.md; the mirror is excluded from the file-context scan (project-context.ts) since it is already rendered as the [Planning canvas] block. The mirror write is best-effort (a failure never fails a board save) but no longer silent — it logs [canvas] mirror refresh failed … with the errno server-side; its roster sockets broadcast once per join with no rebroadcast, so the test attaches its listeners the moment each socket opens (a late attach could miss the join-time frames — the intermittent roster failure this fixed was entirely in test code) |
| WebSocket matrix | tests/websocket.test.ts |
6 endpoints × {no token→401, valid→open, invalid→401}; every describe creates its OWN dedicated throwaway probe project (ws-*, torn down in after) — real user projects are NEVER reused (a reused project once absorbed member_added/member_removed activity entries that live forever, plus uploads/files) |
| Team chat (V1) | tests/chat-team-core.test.ts (23 offline) + tests/chat-team-api.test.ts (14 real-Docker): channel id discipline/sort/labels, message sanitize/normalize/cap-prune/search/pin, suite self-cleans its channels in after() + deleting a user cascade-removes their direct channels (removeUserChannels in chat-team-store) so accumulated test DMs never leak onto a shared server; API: 401 matrix, manual create 201/dup 409/junk 400, traversal-safe channel ids, message send+@mentions, replyTo dangling-ref drop, case-insensitive channel-scoped search, pin roundtrip, unread via read positions, delete by admin+creator only + kind guard, idempotent direct DM + self-chat 400, project auto-channel created-with/deleted-with project, access matrix (project channel mirrors membership; manual team-wide), attachment upload/authed-download/oversize 400, presence shape |
|
| Crash alerts (detector) | tests/project-alerts.test.ts |
14 offline units on the import-free alerts-core.ts classifyCrash rule (like janitor-core/snapshots-schedule): explicit stop never alarms (requestedStop masks even OOM/non-zero), OOM-kill → oom fire-once, non-zero hard exit → exited, clean exit 0 is never a crash, running container with moved StartEpoch/RestartCount → restart (silent auto-restart detection), re-fire only on a NEW start epoch (an ongoing crash cycle never spams), watch always refreshed on clean states |
| Webhooks & notifications | tests/webhooks.test.ts |
Real Docker + local HTTP receiver (host.docker.internal, since the sender runs inside the container): store CRUD + validation (name/url required, ftp + cloud-metadata URLs 400, jelly events sanitized), signing secret masked as hasSecret, never echoed, HMAC-SHA256 X-Madar-Signature over the raw body verified on every delivery, lifecycle created/stopped/deleted + manual snapshot-saved capture deliveries, event filtering (a crash-only webhook receives zero lifecycle traffic), manual Test endpoint (saved id + raw url; 400/404 on junk), access matrix (viewer/editor 403 on all five routes), unknown id 404, 51st-webhook cap 400, delete-for-good; crash decision coverage lives in the offline project-alerts suite (the wire path is shared) |
| Storage metrics (disk) | tests/storage.test.ts |
Real-Docker GET /api/storage: created project appears with workspaceBytes > 0 after a seeded file upload + a docker.perProject writable-layer entry, snapshot capture grows snapshotBytes, cache contract (back-to-back reads share one scan with an identical generatedAt; ?fresh=1 rescans), delete invalidates the cache (project vanishes from the listing), route 401 without token + viewer/editor 200 read-only shape contract. The pure walk rules are covered offline by tests/storage-core.test.ts (nested-tree byte sums, symlinks never pull the target's bytes, empty/missing dirs → 0, soft-deadline truncated stop, cleanSlug parity with saveMeta) |
| Embedded surfaces (access + status) | tests/embedded-access.test.ts |
45 live-Docker rows: both status routes (401 without a token — ?fresh=1 is not an auth bypass —, authenticated shape, ?fresh=1 + ?fresh=true serve the identical shape, no password key — asserted absent via Object.hasOwn and via the raw body never containing the substring, the probe ceiling structurally above the measured 1.85 s cold answer) + the host path is admin-only (a non-admin keeps the exact payload shape, state/source/verification and a path-scrubbed hint, but hostPath is <redacted> and the raw body never contains the real path — asserted on BOTH routes, with the admin-side host-path precondition asserted rather than assumed, since an unresolved path used to make every redaction row pass vacuously) + POST /api/opencode/open input contract (no body / falsy slug → 400, junk truthy slug / unknown / whitespace-only → 404) + the editor gate (viewer member 403, non-member viewer 403, editor member 200 {ok,directory}, global-editor 200, repeat calls stay 200, a viewer still refused after the workspace was seeded) + the 403-real vs 404-fabricated discrimination (a fabricated slug clears the loose legacy gate but 404s on existence BEFORE ensureOpencodeSession runs, so a viewer can never promote the loose gate into a write) + the no-side-effect rows (a refused call creates no .git, registers no opencode session, and never leaks the workspace path) + both audit events + the embed credential itself (POST /api/embed/session mints HttpOnly/SameSite=Strict/Path=/ with NO Secure on the plain-HTTP LAN and reports the PROXY port, a viewer mint is 403 and expires the cookie, a re-mint holding a valid same-user cookie is not audited twice; POST /api/embed/session/clear works with no Authorization header at all, is idempotent, and is never audited). The near-miss slug matrix behind the canonicalize-before-gate rule (exact / slug! / uppercase / space-padded → 403, slug..x → 404, {} → 400, with an admin+unknown-slug → 404 control) was proven live against the running container on two projects. Precondition discipline: tri-state container/file probes (present / absent / no answer) hard-skip the filesystem rows when docker genuinely cannot answer instead of reading a dead daemon as "file missing", and every side-effect row skips when the precondition that produced it skipped — a prior soft-skip let an absolute-count assertion fire against pre-existing state and report a phantom "a refused viewer call created a session". Self-cleans its one project + editor + viewer in after() (residue-free: 62→62 users, 23→23 workspaces across 12 suite runs) |
| Embedded surfaces proxy (core) | tests/embed-core.test.ts |
33 offline units on the import-free embed-core.ts (like serve-core/janitor-core): the surface-ownership split (opencode owns the origin root, /ide is the IDE's single segment — /idex and /ideas stay opencode's, with the base64url directory deep link asserted), non-origin-form junk degrading to the root instead of being trusted, the Location rewrite rules that keep the browser off the internal port (absolute → root-relative with the /ide prefix re-added, protocol-relative → authority dropped, dot-relative passes through untouched so the IDE's → ./?folder= follow chain survives, unusable → null = drop, and a port-leak assertion), stripHopByHop smuggling primitives + case-insensitivity + array set-cookie survival, parseCookies (last duplicate wins, quoted unwrap), sanitizeSetCookie (an upstream cannot clobber madar_embed), and the cookie attributes (HttpOnly + SameSite=Strict + Path=/, Max-Age omitted when non-positive) plus conditional Secure — embedCookieSecure() reads X-Forwarded-Proto only when a proxy hop is trusted, so a plain-HTTP LAN install never silently loses the cookie and a spoofed header cannot harden it. One unit here caught a real bug: value.indexOf('/') found the leading slash at index 0, so a protocol-relative Location: //127.0.0.1:4096/x was returned verbatim and pointed the browser straight back at the unpublished port |
| Embedded status core | tests/embedded-status-core.test.ts |
17 offline units on the import-free embedded-status-core.ts plus the loopback probe: 11 on the cache/port core (N concurrent get() share ONE in-flight load, a fresh call is never queued as a parallel probe, TTL reuse-then-expiry on an injected clock, get({fresh}) bypass, ttlMs <= 0 disables caching while keeping the singleflight, the default TTL is the short restart-aware window, invalidate() drops the warm snapshot, a mid-flight invalidate() cannot let the stale result repopulate the cache (generation guard), a rejected load is never cached, resolveEmbeddedPort junk→fallback matrix incl. whitespace tolerance) + 6 on probeEmbeddedPort (open listener → true, an accepts-but-silent peer still true — a connect probe, not an HTTP GET, closed port → false fast with no dangling rejection, a handshake that never completes → false AT the ceiling with the socket destroyed once, the production ceiling stays above 1.85 s, any out-of-range port never throws nor rejects) |
| Host workspaces mount (core) | tests/workspaces-mount-core.test.ts |
62 offline units on the import-free workspaces-mount-core.ts: the live Docker Desktop line decoding to the exact host path (a renamed checkout decodes to the renamed path), kernel octal escapes (\040 \011 \012 \134 decoded in one left-to-right pass — an escaped backslash must not re-open an escape — applied to the drvfs, virtiofs and posix rules AND to the mount-point match, so a checkout under C:\Users\First Last\ resolves to the directory that exists, not to a \040 path that never does), volume/root-fs/unresolved refusals, classifyMountError, resolveHostDir precedence (explicit env wins, unset derives), hints, canaryToken — plus the two gate rules: mountRefusalCode (unresolvable / broken / refuted refuse; proved and unknown create) and publicMountInfo (admin verbatim, non-admin gets <redacted> hostPath and a path-scrubbed ok hint, cached object never mutated) — plus the canary-separation group (MOUNT_VERIFY_CANARY_FILE ≠ MOUNT_CANARY_FILE, both bare dotfiles, the probe argv defaults to the probe canary and never the audit one, the argv stays an argv) and the verdict group (probeExitCode reads Node's code with status only as a fallback — the old err.status read was dead code; probeVerdictFor maps only exit 1 to refuted; confirmProbeVerdict makes a lone refutation degrade to unknown so only two consecutive ones can block creation). The refuted-creation gate was additionally proven live by injecting a refuted verdict for the real host path and racing the boot probe (POST /api/projects → 500, then proved again and 201), and the canary race was proven live with 63 interleaved audit cycles during a 2.2 s probe (still proved) plus a one-shot canary deletion (still proved after the confirming look) |
Test notes:
- Tests sign their own JWT using the same resolved secret the server uses (no real password needed) — see "JWT signing secret" for the order:
JWT_SECRETenv, else the container's persisteddata/jwt.secret - Run serially (
--test-concurrency=1) — parallel runs + browser polling can trip the rate limiter - Optional real-login test activates with
WSD_TEST_USER/WSD_TEST_PASSenv vars - Every suite cleans up its own data (projects, agents, providers, sessions)
- Change-password is intentionally NOT auto-tested (would mutate the real account)
UI E2E (Playwright)
backend/tests/e2e/limits_ui.py — a browser-level suite, covering the Resource-limits UI end to end against the running container: create-modal CPU/Memory inputs → create-with-limits (cap applied immediately) → edit → honest "Recreate to apply" pending banner → blanking both fields removes the limits (banner persists while the cap is live) → CPU/RAM meta-chips on Projects/Dashboard/Planner cards. It forges an admin-session JWT from the repo JWT_SECRET (the real account has TOTP, and the UI login route always steps the FIRST user through the authenticator — see user-store.isTotpEnabled()), seeds it into localStorage['wsd.token'] before first paint, and cleans up every e2e-* project before/after. Exit 0 = all green, 1 = failures, 42 = skip. Host deps: python 3.x + pip install playwright pyjwt requests (+ playwright install chromium); server must be up. Run: python backend/tests/e2e/limits_ui.py
backend/tests/e2e/reviews_ui.py — the File-reviews UI suite: thread lifecycle end to end (create on src/app.ts with a real workspace file → reply → resolve/reopen badge flips → 'file not found' badge on a missing path → ConfirmModal thread delete with counters + API counts re-checked → viewer member sees the read-only surface with no composer/resolve/reply/delete controls). Same forged-admin-JWT + e2e-* cleanup contract as limits_ui.py; uses ONE shared browser context so two back-to-back runs stay under the global rate budget. Run: python backend/tests/e2e/reviews_ui.py
Project Structure
frontend/ — Preact + TypeScript + Vite (served at /)
backend/ — Express 5 + Node 22 + WebSocket ws
Dockerfile — Multi-stage: frontend build → backend build → runtime
Dockerfile.workspace — Ubuntu 24.04 base image for project containers
Architecture Notes
WebSocket Endpoints
| Path | Handler | Purpose |
|---|---|---|
/ws/projects/status |
ws-projects-status.ts |
Global: broadcasts all project status changes |
/ws/projects/:slug/status |
ws-project-status.ts |
Per-project: status + CPU/memory stats (3s poll) |
/ws/projects/:slug/logs |
ws-project-logs.ts |
Live Docker logs tail |
/ws/projects/:slug/terminal |
ws-terminal.ts |
xterm.js terminal |
/ws/chat/:slug/:chatId |
ws-chat.ts |
AI chat per project |
- Project access over the chat WS (mirrors
requireProjectAccess): only project members may connect to a project's chat room (outsiders are closed with 1008 before any replay is sent) and only members with at least editor access may sendprompt/stop— a viewer's write attempt gets a read-onlyerrorframe while the socket stays open (they keep live read). Global editors (system roleeditor) pass as write-level on every project.globalchat remains open to every authenticated user; system admins always pass; legacy projects without membership data stay open |/ws/agent/:id/:chatId|ws-agent.ts| Agent chat with tools |
Frontend Pages
| Route | Component | Notes |
|---|---|---|
/login |
Login | Setup + login (unauthenticated only) |
/ |
Dashboard | Minimal overview: stats + quick actions |
/projects |
Projects | Cards/table, search, filter, sort, bulk ops |
/project/:slug |
Project | Detail: overview, AI chat, files, logs, Terminal (per-project shell, the SAME ProjectTerminal component — tabs/project+control modes/history/zoom/reconnect/quick commands), notes, scripts, canvas (visual planning whiteboard), Agents (AgentRunPanel — run a roster subagent on the workspace, live tail + per-project History, land results into Notes/Canvas), digest (DigestPanel — the digests tab: scheduled AI project summaries + blockers/next steps from goals/notes/canvas/activity; editor+ schedules/generates, viewer read-only), suggestions (SuggestionsPanel — the suggestions tab: rule-based accept/dismiss rows derived from notes + planning canvas; editor+ accepts (lands into Notes/Canvas) or dismisses, viewer read-only) |
/planner |
Planner | Visual planning hub: cards for every project with canvas state + last-edit recency, filter + sort; card opens the project straight on its Canvas tab (?tab=canvas deep link) |
/terminals[/:slug] |
Terminals | Legacy global terminal hub (still routable + reachable from the Dashboard quick-action tile, but no longer in the Sidebar — per-project terminals live on each project's own Terminal tab driving the SAME ProjectTerminal component: project picker, tabs, project/control modes, history, zoom, reconnect, quick commands); deep-linkable via /terminals/<slug> |
/agents |
Agents | AI agents with chat, RTL/LTR, presets |
/providers |
Providers | LLM provider config |
/team |
Team | Admin-only, unchanged: a non-admin mounts the shared AdminOnly guard panel exactly like /providers and /opencode-studio, so the route is not "available" to viewers/editors. Roster + per-user project memberships come from GET /api/users/with-memberships (admin-only); the per-project Team tab keeps the open GET /api/users route for members |
/settings |
Settings | Change password, account info, logout |
/ide |
EmbeddedIDE | code-server iframe ("VS Code" branding with official mark; no password — --auth none) behind the authenticated proxy at http://host:4097/ide (editor+, HttpOnly cookie from POST /api/embed/session); tri-state frame + honest status, keep-alive layer, ?folder= deep link (a deep subfolder selects its project in the picker — first segment, only when the roster renders it — the open folder is written back into the address bar, and a ?folder= outside /workspaces/ is stripped) |
/opencode |
Opencode | opencode web iframe behind the same authenticated proxy at http://host:4097 (editor+); editor-gated project picker (POST /api/opencode/open, viewers see none), directory-scoped ?project= deep link (honoured and written back into the address bar; a project the user cannot open is never requested — an explicit link gets an honest notice and the dead param is stripped, a remembered one is dropped silently), tri-state frame, keep-alive layer |
/opencode-studio |
OpencodeStudio | subagents/skills/commands/config CRUD + gated update button + bilingual User Guide tab |
Authentication
- Unified auth: Single user password = providers password
- Per-user identities:
POST /api/auth/loginresolves the authenticated user BY USERNAME and mints a session carrying THEIR OWNid/username/role/tv/jti(never the first admin's —login()inuser-store.ts; legacy first-adminissueSessionTokenremoved).POST /api/auth/login/verifysigns the user who completed the challenge (signLoginSession(pendingUserId)). Team member logins/multi-role access depend on this: editor/viewer tokens now really are editor/viewer (403 on admin routes), not admin - Backend: bcrypt (10 rounds) + JWT (24h expiry) stored in
data/users.json - Frontend:
auth.tsxAuthProvider context, token inlocalStorageaswsd.token - HTTP:
authMiddlewareon all routes after/api/auth/*(returns 401 without Bearer token) - WebSocket: JWT token passed via
?token=query param on upgrade, validated server-side - API helper:
api()auto-attachesAuthorizationheader; 401 responses redirect to/login - Routes:
/loginaccessible without auth, all other routes require authenticated user - Global Editor role: system
editoris the global editor —checkProjectAccess(auth.ts) treats roleeditorlike a write-level member on EVERY project without needing membership (delete/transfer-owner/member-add stayadmin-only, and user/settings/providers management isadmin-only), so an editor can act on any project the moment the account exists. UI surfaces this in Team ("Editor · global") - Chat context & session routes are project-gated (the F1 finding):
GET /api/chat/contextand the four chat-session routes (GET/POST /api/projects/:slug/chat-sessions,PUT/DELETE …/:id) authenticated but never authorized — ANY authenticated user could read another project'sgetProjectContextpayload (files, notes, goals, canvas) and list/create/delete its chat sessions; a viewer member could too, since nothing asked for even viewer access. Now the slug is folded ONCE tocanonicalProjectSlugbefore the gate (the same invariant as/api/opencode/open— near-misses likeslug!/uppercase/space-padded fold then 403, junk → 400Project slug is invalid, empty → 400Missing project query),checkProjectAccess(..., 'viewer')guards reads and'editor'guards mutations through one tiny sharedchatSessionGate(req, project, level)helper, refusals are plain 403{error:'Access denied to this project'}with no payload, downstream code uses the one canonical value,project=allstays open to every authenticated user, unknown-but-canonical slugs keep the legacy ABSENT-meta fallback 200 (/workspace not found/context), and unscoped/global session rows stay ungated. Live-proven in the F1 matrix insidetests/providers-agents-chat.test.ts(against the running container) - Members write gate (the F14 rule):
POST /api/projects/:slug/membersrefuses an ABSENT store (readMeta(slug).state === 'absent') with 404Project not found, so a request can never be answered by writing a phantom meta; but a LEGACY project that really exists on disk (meta.json present with neitherownerIdnormembers) now accepts its first member (200) — a system admin may add it, while the legacy fallback still caps theadmingate so a global editor/viewer stays 403. The first row crosses the project from the legacy open fallback into a member-restricted model; this is deliberate and narrower than the removed guard, which used to 404 the first add forever. A CORRUPT store is fail-closed (403) at the gate before the handler runs. Pinned bytests/team-members-cleanup.test.ts(F14 roundtrip) +tests/access-core-gate.test.ts(offline cap) - Deleting a user cleans their membership rows conditionally (F5):
DELETE /api/users/:idstrips the user from everymeta.membersbut writes ONLY for projects that actually carry a row for them (a read-onlymemberList(existing).some(...)probe beforeupdateMeta), and callsinvalidateProjectsCache()only when something changed. A row-less legacy store is therefore left byte-for-byte untouched instead of being rewritten intomembers: [], which would silently disable the legacy no-membership access fallback. Best-effort per project; the activity feed keeps the historical actor by design
Two-Factor Authentication (optional TOTP)
- RFC 6238 TOTP implemented from scratch in
backend/src/services/totp.ts(HMAC-SHA1, 30s steps, 6 digits, ±1 step drift) — verified against the official Appendix B vectors intests/totp.test.ts; compatible with Google Authenticator/Authy/Aegis - Login flow with 2FA on:
POST /api/auth/loginverifies credentials then returns{requires2fa:true, pendingToken}— a 5-min JWT (scope:'2fa-pending'), NOT a session. The challenge is per-user: only an account whose OWN TOTP is enabled gets diverted; everyone else gets a direct session (an editor is never blocked because the admin enabled 2FA).POST /api/auth/login/verify {pendingToken, code}exchanges it for a session signed for the user who went through the challenge; guarded by a dedicatedtotprate-limit scope (8/min/IP) - Enrollment:
POST /api/auth/2fa/setup→{secret, uri}(pending secret, disabled until proven);POST /api/auth/2fa/enable {code}activates only after a live code verifies;POST /api/auth/2fa/disable {accountPassword}requires sudo re-auth - Frontend: Login shows an authenticator-code step when
pending2fais set in AuthProvider; Settings → Two-Factor panel renders the QR (qrcodenpm pkg) + manual base32 key, activate/cancel, disable via ReAuthModal - Audit:
2fa-enabled/-failed,2fa-disabled/-failed,login-2fa-failed - Lost authenticator: self-hosted recovery = remove
totpfromdata/users.jsonand restart (documented in UI copy as admin-level escape hatch)
Providers Security Lock (optional second layer)
- Separate bcrypt password stored in
users.json(providersPasswordHash) — independent from the account password - Managed exclusively from Settings → Providers Security via a two-step sudo-style flow: pick the new lock password, then confirm identity in
ReAuthModal(shows the signed-in username, asks for the account password) - The same
ReAuthModalpattern authorizes every sensitive op: lock save/remove, backup export/import, logout-everywhere - Unlock issues a scoped JWT (
scope:'providers', 30 min) sent asX-Providers-Unlock; version counter (pv) invalidates all unlock tokens on password change - Session-bound unlock: login tokens carry a random
jti; the unlock token embeds it assidandprovidersLockMiddlewarerequires a match with the requesting session — a stolen unlock token replayed from another session is rejected (legacy tokens withoutjtiuse''consistently on both sides) - Strict instant gate (frontend): Providers page persists last-known lock state in
localStorage['wsd.providers.lockEnabled']— when a lock is known-enabled and no valid local token exists, the gate renders on first paint before any network call, so provider content can never flash. Expiry is re-checked every 1s plus onpageshow/visibilitychange(bfcache/sleep-wake safe) - Wrong-password never logs out: every intentional password-verification call (
unlockProviders, providers-password set/remove, logout-all, settings export/import, 2FA disable) passesskipAuthRedirect: trueso its 401 surfaces as an inline message instead of triggering the global session-expiry handler inapi()(which wipes the token and redirects to/login) - Unlock feedback: success shows an "Unlocked · N min" notice; the no-lock-configured case (
{unlocked:true}without a token) explains that protection is off instead of silently accepting any word; welcome modal ("Protect your API keys") shows once per browser (wsd.providers.onboardedin localStorage) - Gate never silent: when the gate appears it says why —
Auto-relocked after inactivity.(idle timer breadcrumb insessionStorage['wsd.providers.autoRelocked'], set by auth.tsx) orYour unlock window ended.(1s tick expiry detection) POST /api/providers/unlockis brute-force guarded by a dedicatedunlocklimiter scope (15/min) plus a progressive cooldown (5 consecutive failures per IP → 15-min window returning429+Retry-After, in-memory, reset on success); audited (providers-unlock/providers-unlock-failed/providers-unlock-cooldown)- Scoped tokens are never sessions:
verifyTokenrejects any JWT carrying ascopeclaim, so providers-unlock and 2FA-pending tokens cannot authenticate generic routes even though they share the signing secret - Auto-relock: Settings → Idle security offers an idle timer ('off'/5/15/30 min, stored as
wsd.providersAutoRelock) that calls relock + clears the local token after inactivity; same activity-throttle + cross-tab storage-sync machinery as auto-logout - Unlock badge: sidebar shows "🔓 Providers · Nm" only while unlocked (countdown from
expiresAt); click → confirm → instant relock - Enabling/changing the lock returns a ready-to-use unlock token — the current session stays open on the Providers page (no immediate re-entry)
POST /api/providers/relock("Lock now") bumps the version server-side — kills every outstanding unlock token across all tabs/devices; audited (providers-relock)- Unlock token lives in
localStorage(time-boxed by its expiry) so thestorageevent keeps all tabs in sync about lock state - When enabled, management endpoints return
403 {error:'providers_locked'}without a valid token - Always-open endpoints:
GET /api/providers/options(id/name/type only) andGET /api/providers/templates - Chat/agent LLM usage is server-side and never blocked by the lock
- Providers page UX states: skeleton shimmer (checking) → welcome modal explaining protection (when unconfigured, dismissible per tab session) → centered login-style unlock modal (when locked)
At-rest encryption (secret box)
- Provider API keys in
data/providers.jsonare sealed with AES-256-GCM (backend/src/services/secret-box.ts): formatenc1:<iv>:<tag>:<ct>:<last4> - Key = scrypt(
WSD_ENCRYPTION_KEYenv → falls back to the resolved signing secret, salt persisted once indata/crypto.salt, mode 0600); rotating the master secret makes old blobs open to''(upstream auth failures — re-enter keys from the UI) - Migration is automatic: on first load any plaintext key is sealed and the file rewritten; masking uses the stored
<last4>so it never decrypts; duplicate detection opens values transparently - Backup export strips keys entirely (unchanged), so ciphertext never leaves the server either
- Lost/rotated key recovery: delete
crypto.saltonly if starting fresh — otherwise restore the previous env value; plaintext keys pasted by users are re-sealed on next save
Security hardening (post-audit)
- Chat session stores are traversal-safe:
data/chat-sessions.json+ the per-sessionchats/<slug>/<chatId>/events.jsonltree accepted dot-only ids — a poisoned'..'chatId madedeleteSession'srmSynctarget the wholechats/dir, and a'..'/'..'pair escaped toDATA_DIRitself. Both writers now fold dot-only values to stable fallbacks (global/chat-unspecified— deliberately duplicated 3-line rules inchat-sessions.tsANDchat-store.tswith cross-reference comments, no shared import so each stays import-free),chatDirForstructurally refuses any pair that is not exactly 2 non-dot segments underchats/BEFORE any rm/splice/save, anddeleteSessionnever removes a directory outside its own. Covered bytests/chat-sessions-core.test.ts(5 offline units; non-vacuity proven — the same suite fails 0/5 with the fix stashed) WSD_TRUST_PROXY=1opts into trusting one reverse-proxy hop forreq.ip; default is OFF so directly-published ports can't spoofX-Forwarded-Forpast the rate limiters- SSRF guard on provider detection/testing: hosts must be http(s); cloud-metadata endpoints (
169.254.x,metadata.google.internal,100.100.100.200, link-local) are refused before any fetch — private LAN/loopback stays allowed (local Ollama is a core feature) - Masked-key echo guard rejects both pure bullets and the displayed
<8 bullets><last4>shape - Provider ids are own-property checked (
__proto__etc. → 404);providers.json/users.json/audit.jsonpersist with mode 0600 - Startup banner reports the RESOLVED signing secret's origin (see "JWT signing secret" below) — an env value logs one informational line, a generated one prints the warning box naming the file and the command to run
- CORS is opt-in via
WSD_CORS_ORIGINS(comma-separated allowlist) — no env set means no ACAO headers at all; the UI is always same-origin (vite dev proxies/api+/ws), so a wildcard would only ever help attacker sites read the authenticated API - Provider health-check cache keys hash the API key with SHA-256 — raw secrets never sit in memory-map keys
- Chat markdown href filter is a scheme whitelist (
http:/https:/mailto:/#//) after stripping control chars, sojava\tscript:obfuscation can't reach the DOM
Workspace files & the meta store (audit hardening)
- Path containment — ONE primitive (
services/workspace-paths-core.ts) — the single enforcement point for every project-workspace path and the only place that answers "is this inside the workspace". Its import graph is{fs, path} → project-slug-core, sonode --testloads it offline (like janitor-core / archive-core / serve-core). The rules: the workspace root is realpath-ed (realWorkspaceRoot) and every decision is made against that, never the raw env value; the
Truncated - read the full file at https://github.com/20057AMO/madar/blob/3e40681a51cd1e5e830cad3a5dc2d4cd193d5c7a/AGENTS.md.
