From 6a89ba25f571f41ac980aae8189a08cbbc25feca Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Mon, 27 Apr 2026 23:47:22 +0900 Subject: [PATCH 1/7] chore(ai-harness): introduce .ai/ agent harness pilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up a team-shared AI agent harness in this repo as the pilot for a broader internal initiative. The harness is committed, multi-agent, and reproducible from a fresh clone. What lands: - `AGENTS.md` becomes the canonical prose-rules file; `CLAUDE.md` is a one-line `@AGENTS.md` shim. Codex, Cursor, Windsurf, VS Code Copilot read AGENTS.md natively; Claude Code via the shim. - `.ai/` directory holds: `LANGUAGE.md` (vocabulary), `plans/` (committed historical plans, migrated from `docs/plans/`), `research/` (date-prefixed artifacts), `memory/` (team product knowledge — product / architecture / stack / lessons / topics / integrations / initiatives), and `skills/` (vendored superpowers v5.0.7, MIT, Jesse Vincent). - `.claude/settings.json` disables the global `superpowers` plugin per project so the vendored copy is the single source of truth. - Symlinks `.claude/skills`, `.agents/skills`, `.cursor/skills` all point to `../.ai/skills`, giving Claude Code, Codex (`.agents/skills/` is the Codex spec path), and Cursor 2.4+ the same skill set without duplication. - `.codex/config.toml` is committed with the project's nx-mcp setup. - Per-package vocabulary cleanup: `apps/cli/AGENTS.md`, `apps/server/AGENTS.md`, and `docs/specs/SPEC-013-mintlify-docs-site.md` updated to drop "device flow" (the CLI uses an OAuth2 loopback redirect, RFC 8252) and to remove references to a non-existent `migrate` CLI command. - `apps/server/AGENTS.md` corrected to reflect that sessions live in Postgres, not Redis (Redis is provisioned for caching/queues but not currently a session store). - `.gitignore` adds `.vercel/`, `*.tsbuildinfo`, the personal local-only paths under `.ai/skills/`, and `.claude/settings.local.json`. - `.prettierignore` excludes vendored `.ai/skills/` and historical `.ai/plans/` so bumps and migrated content stay verbatim. Memory bank shape borrows from Cline's pattern but drops the solo-developer-state files (`active-context.md`, `progress.md`) that cause merge conflicts on concurrent PRs. Stable knowledge lives in single-purpose files; volatile state stays in Jira / `ROADMAP_TASKS.md` / GitHub Issues. Out of scope for this pilot: the generic multi-project installer skill (`/install-dot-ai`-style), an evals suite, hooks for deterministic rule enforcement, an org-level cross-project memory layer. Those are the Phase 2 wishlist if and when the harness proves itself in MDCMS. --- .agents/skills | 1 + .ai/LANGUAGE.md | 53 + .ai/memory/README.md | 64 + .ai/memory/architecture.md | 56 + .ai/memory/initiatives/README.md | 30 + .ai/memory/integrations/README.md | 19 + .ai/memory/integrations/docker-stack.md | 52 + .ai/memory/integrations/github-actions.md | 39 + .ai/memory/integrations/nx-mcp.md | 31 + .ai/memory/lessons.md | 19 + .ai/memory/product.md | 33 + .ai/memory/stack.md | 57 + .ai/memory/topics/README.md | 26 + .ai/memory/topics/auth-flow.md | 50 + .ai/memory/topics/module-system.md | 45 + .ai/memory/topics/multi-tenancy.md | 49 + .ai/memory/topics/push-pull-sync.md | 56 + .ai/memory/topics/schema-sync.md | 35 + .ai/plans/2026-03-10-cms-16-design.md | 211 +++ .ai/plans/2026-03-10-cms-16.md | 383 ++++++ .ai/plans/2026-03-11-cms-17-design.md | 364 ++++++ .ai/plans/2026-03-11-cms-17.md | 374 ++++++ ...-03-11-cms-19-project-boundaries-design.md | 104 ++ .../2026-03-11-cms-19-project-boundaries.md | 279 ++++ ...cms-20-locale-translation-groups-design.md | 183 +++ ...-03-11-cms-20-locale-translation-groups.md | 463 +++++++ ...ms-23-24-restore-version-history-design.md | 182 +++ ...03-12-cms-23-24-restore-version-history.md | 225 ++++ .ai/plans/2026-03-12-cms-38-csrf-design.md | 128 ++ .ai/plans/2026-03-12-cms-38.md | 348 +++++ .../2026-03-12-content-api-refactor-design.md | 115 ++ .ai/plans/2026-03-12-content-api-refactor.md | 224 ++++ .../2026-03-13-cms-39-login-backoff-design.md | 149 +++ .ai/plans/2026-03-13-cms-39-login-backoff.md | 374 ++++++ .ai/plans/2026-03-14-cms-33-design.md | 152 +++ .../2026-03-14-cms-33-module-bootstrap.md | 360 ++++++ ...6-03-14-cms-34-action-catalog-bootstrap.md | 340 +++++ .ai/plans/2026-03-14-cms-34-design.md | 191 +++ ...26-03-14-week-2-progress-and-sequencing.md | 202 +++ ...6-03-16-cms-27-response-envelope-design.md | 114 ++ .../2026-03-16-cms-27-response-envelope.md | 240 ++++ ...03-16-cms-35-contract-validation-design.md | 149 +++ ...6-03-16-cms-35-contract-validation-plan.md | 282 ++++ ...26-03-17-cms-25-published-default-reads.md | 150 +++ ...s-26-resolve-reference-expansion-design.md | 116 ++ ...3-17-cms-26-resolve-reference-expansion.md | 398 ++++++ ...9-reference-identity-schema-gate-design.md | 137 ++ ...8-cms-29-reference-identity-schema-gate.md | 381 ++++++ ...03-18-cms-32-content-integration-design.md | 167 +++ .../2026-03-18-cms-32-content-integration.md | 418 ++++++ ...-18-cms-40-oidc-provider-support-design.md | 130 ++ ...2026-03-18-cms-40-oidc-provider-support.md | 241 ++++ ...2026-03-20-cms-41-saml-provider-support.md | 313 +++++ .../2026-03-20-week-3-scope-and-sequencing.md | 261 ++++ ...2026-03-21-cms-60-studio-runtime-design.md | 202 +++ ...2026-03-21-cms-60-studio-runtime-loader.md | 311 +++++ ...61-studio-runtime-reconciliation-design.md | 82 ++ ...23-cms-61-studio-runtime-reconciliation.md | 47 + ...-cms-62-studio-runtime-hardening-design.md | 168 +++ ...23-cms-62-studio-runtime-hardening-plan.md | 436 +++++++ ...s-68-local-mdx-component-catalog-design.md | 161 +++ ...3-24-cms-68-local-mdx-component-catalog.md | 418 ++++++ ...ms-69-typescript-prop-extraction-design.md | 162 +++ ...03-25-cms-69-typescript-prop-extraction.md | 383 ++++++ ...0-prop-type-form-control-mapping-design.md | 124 ++ ...5-cms-70-prop-type-form-control-mapping.md | 408 ++++++ ...udio-ui-runtime-mock-integration-design.md | 179 +++ ...3-25-studio-ui-runtime-mock-integration.md | 440 +++++++ ...1-72-widget-hints-custom-editors-design.md | 186 +++ ...6-cms-71-72-widget-hints-custom-editors.md | 262 ++++ ...ms-73-tiptap-baseline-nested-mdx-design.md | 230 ++++ ...03-26-cms-73-tiptap-baseline-nested-mdx.md | 234 ++++ .../2026-03-26-cms-74-mdx-component-design.md | 203 +++ .../2026-03-26-cms-74-mdx-component-plan.md | 240 ++++ ...6-cms-85-sdk-read-client-implementation.md | 121 ++ .../2026-03-26-cms-85-sdk-spec-alignment.md | 52 + ...-03-26-compose-dev-studio-runtime-watch.md | 58 + ...udio-example-mdx-demo-components-design.md | 166 +++ ...3-26-studio-example-mdx-demo-components.md | 180 +++ ...27-cms-56-cms-133-editor-publish-design.md | 247 ++++ ...026-03-27-cms-56-cms-133-editor-publish.md | 315 +++++ ...6-03-27-studio-example-sdk-content-demo.md | 104 ++ .ai/plans/2026-03-31-sdk-post-mvp-design.md | 47 + .../2026-03-31-studio-schema-guard-design.md | 173 +++ .ai/plans/2026-03-31-studio-schema-guard.md | 396 ++++++ .ai/plans/2026-04-05-studio-review-preview.md | 316 +++++ ...6-04-06-cms-131-content-overview-design.md | 63 + .../2026-04-06-cms-131-content-overview.md | 156 +++ ...6-137-138-144-studio-truthfulness-audit.md | 166 +++ ...6-04-10-cms-67-environment-field-badges.md | 141 ++ ...2026-04-10-mdcms-integration-audit-plan.md | 358 +++++ ...26-04-10-real-scenario-mdcms-audit-plan.md | 312 +++++ .ai/plans/2026-04-12-conversation-export.md | 332 +++++ ...-15-grouped-content-list-implementation.md | 49 + .ai/research/.gitkeep | 0 .ai/skills/LICENSE.superpowers | 21 + .ai/skills/README.md | 41 + .ai/skills/brainstorming/SKILL.md | 164 +++ .../brainstorming/scripts/frame-template.html | 214 +++ .ai/skills/brainstorming/scripts/helper.js | 88 ++ .ai/skills/brainstorming/scripts/server.cjs | 354 +++++ .../brainstorming/scripts/start-server.sh | 148 +++ .../brainstorming/scripts/stop-server.sh | 56 + .../spec-document-reviewer-prompt.md | 49 + .ai/skills/brainstorming/visual-companion.md | 287 ++++ .../dispatching-parallel-agents/SKILL.md | 182 +++ .ai/skills/executing-plans/SKILL.md | 70 + .../finishing-a-development-branch/SKILL.md | 200 +++ .ai/skills/receiving-code-review/SKILL.md | 213 +++ .ai/skills/requesting-code-review/SKILL.md | 105 ++ .../requesting-code-review/code-reviewer.md | 146 +++ .../subagent-driven-development/SKILL.md | 277 ++++ .../code-quality-reviewer-prompt.md | 26 + .../implementer-prompt.md | 113 ++ .../spec-reviewer-prompt.md | 61 + .../systematic-debugging/CREATION-LOG.md | 119 ++ .ai/skills/systematic-debugging/SKILL.md | 296 +++++ .../condition-based-waiting-example.ts | 158 +++ .../condition-based-waiting.md | 115 ++ .../systematic-debugging/defense-in-depth.md | 122 ++ .../systematic-debugging/find-polluter.sh | 63 + .../root-cause-tracing.md | 169 +++ .../systematic-debugging/test-academic.md | 14 + .../systematic-debugging/test-pressure-1.md | 58 + .../systematic-debugging/test-pressure-2.md | 68 + .../systematic-debugging/test-pressure-3.md | 69 + .ai/skills/test-driven-development/SKILL.md | 371 ++++++ .../testing-anti-patterns.md | 299 +++++ .ai/skills/using-git-worktrees/SKILL.md | 218 ++++ .ai/skills/using-superpowers/SKILL.md | 117 ++ .../references/codex-tools.md | 100 ++ .../references/copilot-tools.md | 52 + .../references/gemini-tools.md | 33 + .../verification-before-completion/SKILL.md | 139 ++ .ai/skills/writing-plans/SKILL.md | 152 +++ .../plan-document-reviewer-prompt.md | 49 + .ai/skills/writing-skills/SKILL.md | 655 ++++++++++ .../anthropic-best-practices.md | 1150 +++++++++++++++++ .../examples/CLAUDE_MD_TESTING.md | 189 +++ .../writing-skills/graphviz-conventions.dot | 172 +++ .../writing-skills/persuasion-principles.md | 187 +++ .ai/skills/writing-skills/render-graphs.js | 168 +++ .../testing-skills-with-subagents.md | 384 ++++++ .claude/settings.json | 6 + .claude/skills | 1 + .codex/config.toml | 3 + .cursor/skills | 1 + .gitignore | 19 +- .prettierignore | 6 + AGENTS.md | 223 +++- CLAUDE.md | 1 + apps/cli/AGENTS.md | 2 +- apps/server/AGENTS.md | 2 +- docs/specs/SPEC-013-mintlify-docs-site.md | 2 +- 154 files changed, 26942 insertions(+), 16 deletions(-) create mode 120000 .agents/skills create mode 100644 .ai/LANGUAGE.md create mode 100644 .ai/memory/README.md create mode 100644 .ai/memory/architecture.md create mode 100644 .ai/memory/initiatives/README.md create mode 100644 .ai/memory/integrations/README.md create mode 100644 .ai/memory/integrations/docker-stack.md create mode 100644 .ai/memory/integrations/github-actions.md create mode 100644 .ai/memory/integrations/nx-mcp.md create mode 100644 .ai/memory/lessons.md create mode 100644 .ai/memory/product.md create mode 100644 .ai/memory/stack.md create mode 100644 .ai/memory/topics/README.md create mode 100644 .ai/memory/topics/auth-flow.md create mode 100644 .ai/memory/topics/module-system.md create mode 100644 .ai/memory/topics/multi-tenancy.md create mode 100644 .ai/memory/topics/push-pull-sync.md create mode 100644 .ai/memory/topics/schema-sync.md create mode 100644 .ai/plans/2026-03-10-cms-16-design.md create mode 100644 .ai/plans/2026-03-10-cms-16.md create mode 100644 .ai/plans/2026-03-11-cms-17-design.md create mode 100644 .ai/plans/2026-03-11-cms-17.md create mode 100644 .ai/plans/2026-03-11-cms-19-project-boundaries-design.md create mode 100644 .ai/plans/2026-03-11-cms-19-project-boundaries.md create mode 100644 .ai/plans/2026-03-11-cms-20-locale-translation-groups-design.md create mode 100644 .ai/plans/2026-03-11-cms-20-locale-translation-groups.md create mode 100644 .ai/plans/2026-03-12-cms-23-24-restore-version-history-design.md create mode 100644 .ai/plans/2026-03-12-cms-23-24-restore-version-history.md create mode 100644 .ai/plans/2026-03-12-cms-38-csrf-design.md create mode 100644 .ai/plans/2026-03-12-cms-38.md create mode 100644 .ai/plans/2026-03-12-content-api-refactor-design.md create mode 100644 .ai/plans/2026-03-12-content-api-refactor.md create mode 100644 .ai/plans/2026-03-13-cms-39-login-backoff-design.md create mode 100644 .ai/plans/2026-03-13-cms-39-login-backoff.md create mode 100644 .ai/plans/2026-03-14-cms-33-design.md create mode 100644 .ai/plans/2026-03-14-cms-33-module-bootstrap.md create mode 100644 .ai/plans/2026-03-14-cms-34-action-catalog-bootstrap.md create mode 100644 .ai/plans/2026-03-14-cms-34-design.md create mode 100644 .ai/plans/2026-03-14-week-2-progress-and-sequencing.md create mode 100644 .ai/plans/2026-03-16-cms-27-response-envelope-design.md create mode 100644 .ai/plans/2026-03-16-cms-27-response-envelope.md create mode 100644 .ai/plans/2026-03-16-cms-35-contract-validation-design.md create mode 100644 .ai/plans/2026-03-16-cms-35-contract-validation-plan.md create mode 100644 .ai/plans/2026-03-17-cms-25-published-default-reads.md create mode 100644 .ai/plans/2026-03-17-cms-26-resolve-reference-expansion-design.md create mode 100644 .ai/plans/2026-03-17-cms-26-resolve-reference-expansion.md create mode 100644 .ai/plans/2026-03-17-cms-28-cms-29-reference-identity-schema-gate-design.md create mode 100644 .ai/plans/2026-03-17-cms-28-cms-29-reference-identity-schema-gate.md create mode 100644 .ai/plans/2026-03-18-cms-32-content-integration-design.md create mode 100644 .ai/plans/2026-03-18-cms-32-content-integration.md create mode 100644 .ai/plans/2026-03-18-cms-40-oidc-provider-support-design.md create mode 100644 .ai/plans/2026-03-18-cms-40-oidc-provider-support.md create mode 100644 .ai/plans/2026-03-20-cms-41-saml-provider-support.md create mode 100644 .ai/plans/2026-03-20-week-3-scope-and-sequencing.md create mode 100644 .ai/plans/2026-03-21-cms-60-studio-runtime-design.md create mode 100644 .ai/plans/2026-03-21-cms-60-studio-runtime-loader.md create mode 100644 .ai/plans/2026-03-23-cms-61-studio-runtime-reconciliation-design.md create mode 100644 .ai/plans/2026-03-23-cms-61-studio-runtime-reconciliation.md create mode 100644 .ai/plans/2026-03-23-cms-62-studio-runtime-hardening-design.md create mode 100644 .ai/plans/2026-03-23-cms-62-studio-runtime-hardening-plan.md create mode 100644 .ai/plans/2026-03-24-cms-68-local-mdx-component-catalog-design.md create mode 100644 .ai/plans/2026-03-24-cms-68-local-mdx-component-catalog.md create mode 100644 .ai/plans/2026-03-25-cms-69-typescript-prop-extraction-design.md create mode 100644 .ai/plans/2026-03-25-cms-69-typescript-prop-extraction.md create mode 100644 .ai/plans/2026-03-25-cms-70-prop-type-form-control-mapping-design.md create mode 100644 .ai/plans/2026-03-25-cms-70-prop-type-form-control-mapping.md create mode 100644 .ai/plans/2026-03-25-studio-ui-runtime-mock-integration-design.md create mode 100644 .ai/plans/2026-03-25-studio-ui-runtime-mock-integration.md create mode 100644 .ai/plans/2026-03-26-cms-71-72-widget-hints-custom-editors-design.md create mode 100644 .ai/plans/2026-03-26-cms-71-72-widget-hints-custom-editors.md create mode 100644 .ai/plans/2026-03-26-cms-73-tiptap-baseline-nested-mdx-design.md create mode 100644 .ai/plans/2026-03-26-cms-73-tiptap-baseline-nested-mdx.md create mode 100644 .ai/plans/2026-03-26-cms-74-mdx-component-design.md create mode 100644 .ai/plans/2026-03-26-cms-74-mdx-component-plan.md create mode 100644 .ai/plans/2026-03-26-cms-85-sdk-read-client-implementation.md create mode 100644 .ai/plans/2026-03-26-cms-85-sdk-spec-alignment.md create mode 100644 .ai/plans/2026-03-26-compose-dev-studio-runtime-watch.md create mode 100644 .ai/plans/2026-03-26-studio-example-mdx-demo-components-design.md create mode 100644 .ai/plans/2026-03-26-studio-example-mdx-demo-components.md create mode 100644 .ai/plans/2026-03-27-cms-56-cms-133-editor-publish-design.md create mode 100644 .ai/plans/2026-03-27-cms-56-cms-133-editor-publish.md create mode 100644 .ai/plans/2026-03-27-studio-example-sdk-content-demo.md create mode 100644 .ai/plans/2026-03-31-sdk-post-mvp-design.md create mode 100644 .ai/plans/2026-03-31-studio-schema-guard-design.md create mode 100644 .ai/plans/2026-03-31-studio-schema-guard.md create mode 100644 .ai/plans/2026-04-05-studio-review-preview.md create mode 100644 .ai/plans/2026-04-06-cms-131-content-overview-design.md create mode 100644 .ai/plans/2026-04-06-cms-131-content-overview.md create mode 100644 .ai/plans/2026-04-08-cms-135-136-137-138-144-studio-truthfulness-audit.md create mode 100644 .ai/plans/2026-04-10-cms-67-environment-field-badges.md create mode 100644 .ai/plans/2026-04-10-mdcms-integration-audit-plan.md create mode 100644 .ai/plans/2026-04-10-real-scenario-mdcms-audit-plan.md create mode 100644 .ai/plans/2026-04-12-conversation-export.md create mode 100644 .ai/plans/2026-04-15-grouped-content-list-implementation.md create mode 100644 .ai/research/.gitkeep create mode 100644 .ai/skills/LICENSE.superpowers create mode 100644 .ai/skills/README.md create mode 100644 .ai/skills/brainstorming/SKILL.md create mode 100644 .ai/skills/brainstorming/scripts/frame-template.html create mode 100644 .ai/skills/brainstorming/scripts/helper.js create mode 100644 .ai/skills/brainstorming/scripts/server.cjs create mode 100755 .ai/skills/brainstorming/scripts/start-server.sh create mode 100755 .ai/skills/brainstorming/scripts/stop-server.sh create mode 100644 .ai/skills/brainstorming/spec-document-reviewer-prompt.md create mode 100644 .ai/skills/brainstorming/visual-companion.md create mode 100644 .ai/skills/dispatching-parallel-agents/SKILL.md create mode 100644 .ai/skills/executing-plans/SKILL.md create mode 100644 .ai/skills/finishing-a-development-branch/SKILL.md create mode 100644 .ai/skills/receiving-code-review/SKILL.md create mode 100644 .ai/skills/requesting-code-review/SKILL.md create mode 100644 .ai/skills/requesting-code-review/code-reviewer.md create mode 100644 .ai/skills/subagent-driven-development/SKILL.md create mode 100644 .ai/skills/subagent-driven-development/code-quality-reviewer-prompt.md create mode 100644 .ai/skills/subagent-driven-development/implementer-prompt.md create mode 100644 .ai/skills/subagent-driven-development/spec-reviewer-prompt.md create mode 100644 .ai/skills/systematic-debugging/CREATION-LOG.md create mode 100644 .ai/skills/systematic-debugging/SKILL.md create mode 100644 .ai/skills/systematic-debugging/condition-based-waiting-example.ts create mode 100644 .ai/skills/systematic-debugging/condition-based-waiting.md create mode 100644 .ai/skills/systematic-debugging/defense-in-depth.md create mode 100755 .ai/skills/systematic-debugging/find-polluter.sh create mode 100644 .ai/skills/systematic-debugging/root-cause-tracing.md create mode 100644 .ai/skills/systematic-debugging/test-academic.md create mode 100644 .ai/skills/systematic-debugging/test-pressure-1.md create mode 100644 .ai/skills/systematic-debugging/test-pressure-2.md create mode 100644 .ai/skills/systematic-debugging/test-pressure-3.md create mode 100644 .ai/skills/test-driven-development/SKILL.md create mode 100644 .ai/skills/test-driven-development/testing-anti-patterns.md create mode 100644 .ai/skills/using-git-worktrees/SKILL.md create mode 100644 .ai/skills/using-superpowers/SKILL.md create mode 100644 .ai/skills/using-superpowers/references/codex-tools.md create mode 100644 .ai/skills/using-superpowers/references/copilot-tools.md create mode 100644 .ai/skills/using-superpowers/references/gemini-tools.md create mode 100644 .ai/skills/verification-before-completion/SKILL.md create mode 100644 .ai/skills/writing-plans/SKILL.md create mode 100644 .ai/skills/writing-plans/plan-document-reviewer-prompt.md create mode 100644 .ai/skills/writing-skills/SKILL.md create mode 100644 .ai/skills/writing-skills/anthropic-best-practices.md create mode 100644 .ai/skills/writing-skills/examples/CLAUDE_MD_TESTING.md create mode 100644 .ai/skills/writing-skills/graphviz-conventions.dot create mode 100644 .ai/skills/writing-skills/persuasion-principles.md create mode 100755 .ai/skills/writing-skills/render-graphs.js create mode 100644 .ai/skills/writing-skills/testing-skills-with-subagents.md create mode 100644 .claude/settings.json create mode 120000 .claude/skills create mode 100644 .codex/config.toml create mode 120000 .cursor/skills create mode 100644 CLAUDE.md diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 00000000..6838a116 --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +../.ai/skills \ No newline at end of file diff --git a/.ai/LANGUAGE.md b/.ai/LANGUAGE.md new file mode 100644 index 00000000..38e51d74 --- /dev/null +++ b/.ai/LANGUAGE.md @@ -0,0 +1,53 @@ +# MDCMS vocabulary + +Use these terms exactly. Do not coin synonyms. If a needed concept is missing, add it here in the same change that introduces it to the code. + +## Domain terms + +| Term | Meaning | Don't say | +| ---------------- | ---------------------------------------------------------------------------------- | --------------------------- | +| **Document** | A single piece of editable content (a row in `documents`) | entry, item, record, post | +| **Content type** | A schema definition for documents (e.g. `BlogPost`) | model, collection, kind | +| **Field** | A typed property on a content type | column, attribute, property | +| **Reference** | A field whose value points to another document | relation, link, fk | +| **Project** | A top-level isolation boundary (a tenant) | workspace, org, site | +| **Environment** | A named state within a project (e.g. `draft`, `prod`) | branch, stage | +| **Locale** | A language/region pair on a translatable document | language, i18n target | +| **Schema** | The combined typed surface (content types + fields) for a project | config, definition | +| **Module** | A first-party or third-party extension that mounts server, CLI, or Studio surfaces | plugin, addon, extension | +| **Manifest** | A module's declaration file (`manifest.ts`) | descriptor, config | + +## Operations + +| Term | Meaning | Don't say | +| --------------- | -------------------------------------------------------------------- | -------------------------------- | +| **Pull** | Fetch documents from server to local files (CLI direction) | download, sync down, fetch | +| **Push** | Upload local file changes to server | upload, sync up, deploy | +| **Sync** | Two-sided reconciliation (push + pull with conflict resolution) | use only when actually two-sided | +| **Publish** | Move a draft document to the published environment | release, ship | +| **Schema sync** | Reconcile local `mdcms.config.ts` schema with server schema registry | schema migrate, schema deploy | + +## Authorization + +| Term | Meaning | Don't say | +| ----------------------- | ------------------------------------------------------------------------------------- | ----------------------------------- | +| **API key** | Long-lived bearer token for non-interactive clients | token, secret | +| **Session** | Browser-based interactive auth | cookie, login | +| **Loopback OAuth flow** | CLI's browser-based auth handoff using OAuth2 with a localhost callback (`127.0.0.1`) | device flow, OAuth flow, login flow | +| **Scope** | A permission claim attached to a key or session | permission, role, capability | + +## Codebase shape + +| Term | Meaning | Don't say | +| ----------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------- | +| **Workspace** | A Bun + Nx package in the monorepo | project (overloaded), package (use only for npm packages) | +| **`@mdcms/source` condition** | Custom export condition that resolves to TypeScript source for dev | source export, dev export | +| **Studio** | The embeddable React component (`@mdcms/studio`) | admin UI, dashboard, panel | +| **Studio review** | `apps/studio-review` — internal contract-consumer for preview mocks | studio test, review app | + +## Forbidden + +- "Entry" — always use **document**. +- "Workspace" when referring to **project** — they're different concepts. ("Workspace" is fine when referring to a Bun + Nx package in the monorepo.) +- "Plugin" when referring to a first-party **module** — modules are first-class. (Third-party concepts that genuinely call themselves plugins, e.g. TipTap plugins, can keep that name.) +- "Device flow" for the CLI auth handoff — it is a **loopback OAuth flow** (RFC 8252), not RFC 8628 device authorization grant. diff --git a/.ai/memory/README.md b/.ai/memory/README.md new file mode 100644 index 00000000..928a885d --- /dev/null +++ b/.ai/memory/README.md @@ -0,0 +1,64 @@ +# Memory + +Persistent team knowledge about MDCMS — the product, the architecture, the integrations, the major efforts in flight, and the lessons we've already learned. Read this when you need _durable_ knowledge. For _volatile_ state (what someone is working on right now, current sprint, blockers), use Jira / `ROADMAP_TASKS.md` / GitHub Issues — those are the right tools for that, and trying to mirror them here causes merge conflicts on every concurrent PR. + +## Layout + +| Path | Contents | Update cadence | +| ------------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------- | +| [`product.md`](product.md) | Vision, audience, scope, why MDCMS exists | Rarely — only when product itself changes | +| [`architecture.md`](architecture.md) | System patterns, invariants, hard rules | When architecture decisions change | +| [`stack.md`](stack.md) | Runtime, deps, infrastructure, constraints | On dependency upgrades or infra changes | +| [`lessons.md`](lessons.md) | Append-only dev-time pitfalls | Append on discovery | +| [`topics/`](topics/) | Cross-cutting domain knowledge — auth flow, sync, multi-tenancy, etc. | When a topic stabilizes or changes meaningfully | +| [`integrations/`](integrations/) | External systems we depend on (Jira, Nx-MCP, Docker, GitHub Actions) | When config or auth changes | +| [`initiatives/`](initiatives/) | One file per major team effort — active and completed | When an initiative kicks off, hits a milestone, or wraps | + +Vocabulary lives at [`../LANGUAGE.md`](../LANGUAGE.md). Architecture decision rationale lives at `docs/adrs/` (canonical product docs). Specs live at `docs/specs/`. Per-package guidance lives at `apps/*/AGENTS.md` and `packages/*/AGENTS.md`. + +## How an agent should read this + +``` +First-time / onboarding + └─ product.md → architecture.md → stack.md → skim topics/ index → skim initiatives/ active + +Starting a feature + └─ architecture.md (invariants) → topics/ (relevant cross-cutting) → initiatives/ (does this fit something?) + → docs/specs/.md (canonical scope, NOT in memory/) + +Debugging a thing that broke + └─ lessons.md (have we seen this?) → topics/ (cross-cutting flow) → integrations/ (external system?) + +Wondering why something is the way it is + └─ architecture.md (patterns) → docs/adrs/ (decision rationale) → initiatives/ (which effort drove this?) +``` + +## How to update + +- **product.md / architecture.md / stack.md** — directly, in the PR that changes the underlying reality. Docs, not state. Reviewed in PR. +- **lessons.md** — append at the bottom on discovery. Lead with the rule, then `Why:` and `How to apply:`. +- **topics/** — add a new file when a cross-cutting concept stabilizes; update existing files when the flow changes. One topic per file. +- **integrations/** — add a new file when integrating a new external system; update on config/auth changes. +- **initiatives/** — create on kickoff, update on milestones, mark `Status: completed` on wrap. One initiative per file (no shared write surface = no merge conflicts). + +## Why this shape (and not something more sophisticated) + +Two reference systems we considered: + +- **Hermes Agent** — minimalist 2-file (MEMORY.md + USER.md, ~3.6KB total), frozen-snapshot system-prompt injection, FTS5 over session history, designed for solo use. +- **Magic Context** — sophisticated 4-layer SQLite + vector embeddings + 5 access tools, designed for solo use. + +Both are user-local. Neither is committed to a team git repo. SQLite databases don't merge cleanly; user-home memory doesn't share with the team. + +Our system trades runtime sophistication (no semantic search yet, no auto-injection beyond `@AGENTS.md`) for **team-shareability and version control**. If we later want semantic search, the right move is an MCP server that indexes these markdown files — the files stay the source of truth. + +## What this is _not_ + +- Not a status report (Jira / GitHub Issues). +- Not a todo list (`ROADMAP_TASKS.md`). +- Not a spec (`docs/specs/`). +- Not a changelog (git log + PR descriptions). +- Not a runbook (operator docs go in package READMEs). +- Not a personal journal (Hermes-style USER.md belongs in user-home, not the repo). + +This is the **shared mental model** the team and its agents maintain about MDCMS. diff --git a/.ai/memory/architecture.md b/.ai/memory/architecture.md new file mode 100644 index 00000000..8d4f99b7 --- /dev/null +++ b/.ai/memory/architecture.md @@ -0,0 +1,56 @@ +# System patterns + +Architecture invariants and key technical decisions. Update when one changes. + +## Source of truth + +- **Database is canonical**, not the filesystem. The server is the only thing that owns truth. +- The CLI's local files are a working copy. Pull/push reconciles against the server, not vice versa. +- Schema (content types + fields) lives in the server's schema registry. The local `mdcms.config.ts` is the developer's authoring surface; `schema sync` reconciles it to the registry. + +## Module system + +- First-party modules live under `packages/modules//`. +- Each module ships `manifest.ts`, `server/index.ts`, `cli/index.ts`. Some also ship `studio/index.tsx`. +- The `installedModules` registry in `packages/modules/src/index.ts` is **deterministic and sorted by `manifest.id`**. Don't introduce ordering coupling. +- Server and CLI each have their own `module-loader.ts`. Both auto-mount at startup. +- Cross-module dependencies go through declared interfaces in `@mdcms/shared`, never direct imports between module packages. + +## Package boundaries (hard rules) + +- `@mdcms/shared` exports types, validators, pure utilities. **No runtime side effects, no HTTP, no DB.** +- `@mdcms/sdk` is read-only. Bearer-token client. **No write methods.** +- `@mdcms/cli` owns push/pull/sync logic and the loopback OAuth flow. +- `@mdcms/studio` runs inside the host app's process — embedded React component, not a separate page. +- `@mdcms/server` is the only thing that talks to the database. + +## Conditional exports + +Every package uses `@mdcms/source` as a custom condition pointing to TypeScript source for development. `import` and `default` point to `dist/` for production. **Don't break this convention** — dev-time source imports rely on it. + +## Validation + +- All inputs validated with **Zod 4** at module boundaries. +- Content schemas use **Standard Schema** for ecosystem interop. +- No double-validation; once a value is parsed, downstream code trusts the type. + +## Tests + +- **`*.test.ts`** — unit tests, co-located with source. +- **`*.contract.test.ts`** — Drizzle schema validated against actual SQL migrations. Catches drift between ORM definitions and migration outputs. +- **Integration:** `bun run integration` runs Docker health + migration check. +- **CI gate:** `bun run ci:required`. + +## Studio review app + +`apps/studio-review` is a maintained internal consumer of Studio + backend contracts, used to keep preview mocks aligned. Whenever a contract changes, update `apps/studio-review` handlers/fixtures/tests in the same commit. Don't let it drift. + +## Standalone specs + +Files under `docs/specs/` are **standalone canonical product documentation**. No roadmap task IDs, no `ROADMAP_TASKS.md` references, no "this task" language. Spec rationale either stays self-contained or moves to an ADR. + +## Multi-tenant boundaries + +- **Project** is the isolation unit. Every persistable entity carries `project_id`. +- **Environment** is a state within a project (e.g. `draft`, `prod`). Reads default to the published environment unless explicit. +- Tenant scoping is enforced at the route layer — every authenticated request resolves a project context before reaching domain code. diff --git a/.ai/memory/initiatives/README.md b/.ai/memory/initiatives/README.md new file mode 100644 index 00000000..c9875a4e --- /dev/null +++ b/.ai/memory/initiatives/README.md @@ -0,0 +1,30 @@ +# Initiatives + +A file per major team effort — active and completed. Initiatives are bigger than tasks (which live in Jira / `ROADMAP_TASKS.md`) and longer-running than features. Examples: a multi-quarter platform migration, an open-source release, a customer-driven SLA push. + +## File format + +`YYYY-MM-DD-kebab-case-name.md` for the start date. + +Each file has these sections: + +- **Status:** `active` | `completed` | `paused` | `abandoned` +- **Goal** — one paragraph, what success looks like +- **Why** — the constraint or opportunity that drove this +- **Scope** — in / out, with explicit "out" list +- **Key decisions** — bullets, with cross-refs to ADRs/specs/PRs +- **Outcome** (added when status moves off `active`) — what actually shipped, what lessons came out + +Keep each file under ~200 lines. If it grows beyond that, split it into a parent + sub-initiatives. + +## Active + +(none yet) + +## Completed + +(none yet) + +## Why initiatives instead of just commits + Jira + +Jira tickets are scoped tasks. Commits are atomic changes. Neither captures the **why** of a multi-week / multi-month effort, the **scope boundary** that prevents drift, or the **outcomes** in a queryable form. A future agent (or new team member) asking "why did MDCMS adopt X?" gets a better answer from an initiative file than from twenty Jira tickets and forty commits. diff --git a/.ai/memory/integrations/README.md b/.ai/memory/integrations/README.md new file mode 100644 index 00000000..157e7d61 --- /dev/null +++ b/.ai/memory/integrations/README.md @@ -0,0 +1,19 @@ +# Integrations + +External systems MDCMS depends on or integrates with. One file per system. Document what we use it for, how it's configured, and what failure modes look like — so a new contributor (or agent) can debug or replicate the setup without reverse-engineering it from config files. + +## Index + +- [`nx-mcp.md`](nx-mcp.md) — Nx MCP server for codebase navigation, configured in `.codex/config.toml`. +- [`docker-stack.md`](docker-stack.md) — Local infrastructure (postgres, redis, minio, mailhog) via `docker-compose.yml`. +- [`github-actions.md`](github-actions.md) — CI gates and workflow files in `.github/workflows/`. + +## Format + +For each integration: + +1. **What it is + why we use it.** +2. **Configuration** — where the config lives, what's in it, what's local-only vs committed. +3. **How agents interact with it** (if applicable) — MCP servers, CLI tools, auth setup. +4. **Failure modes** — common breakages and how to recognize them. +5. **Cross-refs.** diff --git a/.ai/memory/integrations/docker-stack.md b/.ai/memory/integrations/docker-stack.md new file mode 100644 index 00000000..a93d5997 --- /dev/null +++ b/.ai/memory/integrations/docker-stack.md @@ -0,0 +1,52 @@ +# Docker stack + +## What it is + why + +Local infrastructure for development. MDCMS depends on PostgreSQL, Redis, MinIO (S3-compatible object storage), and MailHog (SMTP catch-all for dev email testing). All four run via `docker-compose.yml` at the repo root. + +## Configuration + +```bash +docker compose up -d --build # Bring up the stack +docker compose down # Tear down +docker compose logs -f # Tail a service +``` + +Services: + +- **postgres** — PostgreSQL 16. Used for content, auth, sessions, and audit logs. +- **redis** — Provisioned for caching, queues, and rate-limiting. Not currently a session store. +- **minio** — Media uploads (S3-compatible). +- **mailhog** — Dev-only SMTP capture; web UI at `localhost:8025`. + +The server expects this stack on default ports. Ports and credentials live in `docker-compose.yml` and the server's `.env` (local-only). + +## How agents interact + +Mostly indirectly — agents run server commands that assume the stack is up: + +```bash +bun --cwd apps/server run start # Server on :4000, expects docker stack up +bun run integration # Runs Docker health + migration check +``` + +For a clean re-run during debugging: + +```bash +docker compose down -v && docker compose up -d --build +``` + +The `-v` flag drops volumes — useful when starting from a clean DB state, **not safe** if you want to preserve seeded content. + +## Failure modes + +- **Port conflicts.** If Postgres 5432 or Redis 6379 are already in use locally, `docker compose up` fails. Check with `lsof -i :5432`. +- **Volume corruption from killed containers.** `docker compose down -v` resets state. +- **Stale image after dependency upgrades.** `--build` rebuilds; without it, you might run an outdated server image. +- **MinIO bucket missing.** First-time setup may need explicit bucket creation (`mc mb local/mdcms`); check server logs for `NoSuchBucket`. + +## Cross-refs + +- Compose file: `docker-compose.yml` +- Per-package: `apps/server/AGENTS.md` +- Related: [`../stack.md`](../stack.md) for the broader stack context. diff --git a/.ai/memory/integrations/github-actions.md b/.ai/memory/integrations/github-actions.md new file mode 100644 index 00000000..f607e2ba --- /dev/null +++ b/.ai/memory/integrations/github-actions.md @@ -0,0 +1,39 @@ +# GitHub Actions + +## What it is + why + +CI gates running on push and PR. Defined in `.github/workflows/`. The required gate is `bun run ci:required` — anything that fails this blocks merge. + +## Configuration + +Workflow files live at `.github/workflows/*.yml`. Each workflow declares triggers (push, pull_request, schedule) and jobs. + +## Required gate + +`bun run ci:required` runs: + +1. `bun run format:check` — Prettier check. +2. `bun run check` — Build + typecheck combined. +3. `bun run unit` — Unit tests via `bun test` orchestrated by Nx. +4. `bun run integration` — Docker health + migration check. + +A pre-push git hook also runs this locally to catch failures before the push reaches CI. + +## How agents interact + +- Read workflow files to understand what CI runs. +- Don't push if `ci:required` is failing locally — the pre-push hook will block, and even if bypassed, the PR will fail. +- Use `gh pr checks` or `gh run list` to inspect a PR's CI status without leaving the terminal. + +## Failure modes + +- **Format check failing** — almost always a missed `bun run format` before commit. Run it, commit the diff. +- **Typecheck failing on a PR but not locally** — usually means dependencies got out of sync; `bun install` and re-run. +- **Integration step timing out** — Docker stack startup is slow on cold caches. Local runs may pass while CI fails. Inspect the run logs for the specific service that didn't come up. +- **Pre-push hook blocked.** Don't bypass it (`--no-verify`) without understanding why it failed; usually it's surfacing a real issue. + +## Cross-refs + +- Workflows: `.github/workflows/` +- AGENTS.md "Working in this repo" section — describes the pre-push hook. +- Per-package AGENTS.md — package-specific test/build commands. diff --git a/.ai/memory/integrations/nx-mcp.md b/.ai/memory/integrations/nx-mcp.md new file mode 100644 index 00000000..0511248b --- /dev/null +++ b/.ai/memory/integrations/nx-mcp.md @@ -0,0 +1,31 @@ +# Nx MCP + +## What it is + why + +`nx-mcp` is an MCP server that exposes Nx workspace operations (project graph, target running, generator listing) to AI agents. It lets agents reason about the monorepo's structure and run Nx commands without invoking the Nx CLI directly. + +## Configuration + +Defined in `.codex/config.toml` (committed): + +```toml +[mcp_servers.nx-mcp] +command = "npx" +args = [ "nx-mcp@latest", "--minimal" ] +``` + +The `--minimal` flag scopes the server to lightweight operations (project-graph reads) without enabling heavier features. + +## How agents interact + +Codex CLI loads the MCP server on session start. Tools exposed include workspace project listing, target running, and generator listing. + +Other agent harnesses (Claude Code, Cursor) load MCP servers from their own per-tool configuration files outside the repo. + +## Failure modes + +- **`npx nx-mcp@latest` fails to install** — usually a network issue. Run `npx nx-mcp@latest --help` manually to surface the underlying error. + +## Cross-refs + +- `.codex/config.toml` — committed Codex MCP config diff --git a/.ai/memory/lessons.md b/.ai/memory/lessons.md new file mode 100644 index 00000000..b4cfcddd --- /dev/null +++ b/.ai/memory/lessons.md @@ -0,0 +1,19 @@ +# Lessons + +Append a new entry whenever you discover a non-obvious pitfall. Lead with the rule, then a `Why:` line, then a `How to apply:` line. Keep entries one short paragraph each — link to a commit or PR for full context if needed. + +Entries are reverse-chronological (newest first). + +--- + + diff --git a/.ai/memory/product.md b/.ai/memory/product.md new file mode 100644 index 00000000..aa5b4390 --- /dev/null +++ b/.ai/memory/product.md @@ -0,0 +1,33 @@ +# Project brief + +## What MDCMS is + +A collaborative CMS built around Markdown/MDX for React-based frameworks. The database is the source of truth (not the filesystem). Editors work in a browser-based Studio, developers work with local `.md`/`.mdx` files synced via CLI, and consumer applications fetch via SDK or REST. All three surfaces share the same data layer, validation, permissions, and version history. + +## Why it exists + +Existing headless CMSes force one of two compromises: filesystem-based tools (Contentlayer, MDX bundlers) lose multi-user collaboration and permissions; database-first tools (Sanity, Contentful, Strapi) lose the developer-friendly file editing flow. MDCMS keeps both — the database is canonical, but the local file experience is real, not a sync hack. + +## Who it's for + +- **Developers** building React/Next.js/Remix sites who want to edit content in their editor and ship it through git-like workflows. +- **Editors** in those teams who need a real GUI for content work — Studio is for them. +- **AI agents** that want a typed, scoped HTTP API to read and write content without scraping a UI. + +The core thesis is that **none of the three should block the others**. An editor publishing a page and an agent rewriting 500 posts at once go through the same validation, the same permissions, and the same version history. + +## Core architecture + +- **`apps/server`** is the canonical source of truth. Elysia + PostgreSQL + Drizzle. Every read/write hits this. +- **`apps/cli`** owns push/pull/sync — file ↔ database reconciliation, auth via loopback OAuth flow. +- **`packages/studio`** is an embeddable React component the host app mounts at a catch-all route. +- **`packages/sdk`** is a thin read-only client. +- **`packages/shared`** holds Zod contracts and types every other package imports. +- **`packages/modules`** is the first-party module registry — both server-side (`installedModules`) and CLI-side discovery is deterministic and ordered. + +## Out of scope (current) + +- Real-time multi-user collaboration via CRDTs — Post-MVP. +- Live preview (real-time content rendering in the consumer frontend) — upcoming. +- MCP integration for agent-driven content operations — upcoming. +- Multiple spaces (team-scoped content organization) — upcoming. diff --git a/.ai/memory/stack.md b/.ai/memory/stack.md new file mode 100644 index 00000000..3b0d4bd9 --- /dev/null +++ b/.ai/memory/stack.md @@ -0,0 +1,57 @@ +# Stack + +Runtime, dependencies, and infrastructure. Update when any of them change. + +## Runtime + tooling + +- **Bun** is the package manager AND the test runner (`bun test`). +- **Nx 22.5** orchestrates tasks across the monorepo with `@nx/js/typescript` plugin. +- **TypeScript 5.9**, strict mode, `nodenext` module resolution, `composite` projects (project references). + +## Backend + +- **Elysia** (HTTP framework) running on Bun. +- **Drizzle ORM** with `postgres.js` driver against **PostgreSQL 16**. Sessions, content, auth, and audit logs all live in Postgres. +- **Redis** is provisioned in the dev stack (`REDIS_URL` env var); reserved for future use (caching, queues, rate-limiting). Not currently a session store. +- **MinIO** (S3-compatible) for media. + +## Frontend + +- **React** for Studio. +- **TanStack Query** for client-side data fetching. +- **TanStack Router** for typed routing in the dashboard. +- **TipTap** for the editor with MDX component support. + +## Validation + +- **Zod 4** for runtime validation. +- **Standard Schema** for content type definitions (ecosystem interop). + +## Infrastructure (dev) + +- `docker compose up -d --build` brings up postgres, redis, minio, mailhog. +- Server runs on port 4000. + +## Custom export condition + +`@mdcms/source` resolves to TypeScript source files during development. Production builds resolve through `import`/`default` to `dist/`. Every package's `package.json` exports must include this condition. + +## Constraints worth knowing + +- **Bun-only.** Do not introduce Node-only dependencies that don't run on Bun. +- **No runtime ORM relationships across modules.** First-party modules in `packages/modules//` use foreign-key IDs only — never direct relations between modules. (Hard rule from architecture.) +- **Tenant scoping is mandatory.** Every persistable row carries `project_id` (or equivalent boundary key). Queries must filter on it. +- **Pre-push hook** runs `bun run ci:required` — typecheck + format check + unit tests + integration must all pass. +- **Pre-commit checks:** `bun run format:check` and `bun run check`. + +## Things that are NOT in the stack (yet) + +- No CRDT library (real-time collab is Post-MVP). +- No MCP server (AI agent integration is upcoming, separate work). +- No live preview pipeline (upcoming). + +## Repository services + +- Issue tracker: GitHub Issues. +- CI: GitHub Actions (see `.github/workflows/`). +- Docs deploy: `docs.mdcms.ai` (separate pipeline). diff --git a/.ai/memory/topics/README.md b/.ai/memory/topics/README.md new file mode 100644 index 00000000..6e3e8a23 --- /dev/null +++ b/.ai/memory/topics/README.md @@ -0,0 +1,26 @@ +# Topics + +Cross-cutting domain knowledge — things that don't belong to a single package. Per-package details live in `apps/*/AGENTS.md` and `packages/*/AGENTS.md`. Architectural decisions live in `docs/adrs/`. Specs live in `docs/specs/`. **Topics here are integration-level**: how concepts flow across packages, what guarantees the system makes, what's idiomatic. + +## Format + +One file per topic. Filename `kebab-case.md`. Each file should answer four questions: + +1. **What is it?** — one paragraph; the concept and its boundaries. +2. **How does it work?** — the actual flow, with cross-refs to packages and files. +3. **What guarantees / invariants?** — what must always be true. +4. **Cross-refs.** — pointers to specs, ADRs, code, and other topics. + +Keep each file under ~150 lines. If it grows beyond that, split it. + +## Index + +- [`auth-flow.md`](auth-flow.md) — How session / API key / loopback-OAuth auth weaves across `apps/server`, `apps/cli`, and `packages/studio`. +- [`push-pull-sync.md`](push-pull-sync.md) — The CLI's file ↔ database reconciliation lifecycle. +- [`schema-sync.md`](schema-sync.md) — How `mdcms.config.ts` definitions reach the server schema registry. +- [`multi-tenancy.md`](multi-tenancy.md) — Project + environment scoping rules across the data layer. +- [`module-system.md`](module-system.md) — How first-party modules mount surfaces in server, CLI, and Studio. + +## When to add a topic + +When you find yourself explaining a cross-cutting concept twice — to a teammate, to an AI agent, in a PR description — that's the signal. Write it down here once and link from the next conversation. diff --git a/.ai/memory/topics/auth-flow.md b/.ai/memory/topics/auth-flow.md new file mode 100644 index 00000000..ba2eef07 --- /dev/null +++ b/.ai/memory/topics/auth-flow.md @@ -0,0 +1,50 @@ +# Auth flow + +## What it is + +Three authentication modes coexist in MDCMS, each suited to a different surface: + +- **Session** — browser-based, used by Studio. Server-issued, cookie-bound, stored in the Postgres `sessions` table. +- **API key** — long-lived bearer token. Used by SDK consumers and any non-interactive client. Carries scopes. +- **Loopback OAuth flow** — CLI's browser-based auth handoff using OAuth2 with a localhost callback (RFC 8252). Trades a one-time authorization code (with `state` validation) for an API key stored in the user's local CLI config. + +All three resolve to the same internal concept downstream: an authenticated principal with a project context and a set of scopes. + +## How it works + +### Session (Studio) + +Browser sessions are persisted server-side; the `sessions` table in `apps/server/src/lib/db/schema.ts` holds them with a unique-token index. Studio fetches the active principal on mount and caches it in its TanStack Query layer; writes carry CSRF protection. + +### API key (SDK / non-interactive) + +Clients construct `createClient({ url, apiKey, project, environment })` and send `Authorization: Bearer ` on every request. The server resolves `key → principal → project ACL → scopes → request`. The SDK is read-only by design; writes go directly to the server. + +### Loopback OAuth flow (CLI) + +1. `mdcms login` starts a local HTTP listener (`createCallbackListener` in `apps/cli/src/lib/login.ts`) bound to `127.0.0.1` on an ephemeral port. Callback path is `/callback`. +2. CLI opens a browser to the server's authorization page with the `redirectUri` (e.g. `http://127.0.0.1:54321/callback`), a one-time `state` value, and a server-issued `challengeId`. +3. User authenticates in the browser. Server redirects to the loopback URL with `code` + `state`. +4. The local listener validates that the inbound `state` matches the value the CLI sent (CSRF protection). +5. CLI exchanges the `code` with the server for an API key. +6. Key is stored in the user's CLI credential store. Subsequent CLI commands use it as a bearer token. + +If the listener doesn't receive a callback within a timeout, the CLI surfaces "Timed out waiting for browser callback. Please retry `mdcms login`." + +## Guarantees / invariants + +- Every authenticated request resolves a project context **before** reaching domain code. No cross-tenant leak via missing scoping. +- API keys carry scopes; sessions inherit scopes from the user's role. +- The loopback flow validates `state` to defeat CSRF; binding to `127.0.0.1` confines redirect targets to the local machine. +- API keys are revocable; sessions are revocable; both invalidate immediately on the server. + +## Cross-refs + +- Spec: `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` +- Per-package: `apps/server/AGENTS.md`, `apps/cli/AGENTS.md` +- Implementation: `apps/cli/src/lib/login.ts` (loopback listener), `apps/server/src/lib/auth.ts`, `apps/server/src/lib/db/schema.ts` (sessions table) +- Related topic: [`multi-tenancy.md`](multi-tenancy.md) for how `project_id` scoping interacts with auth + +## Open extensions + +OIDC and SAML provider support are upcoming-work items; see `docs/specs/` for the current spec inventory and check whether a dedicated spec has landed before assuming behavior. diff --git a/.ai/memory/topics/module-system.md b/.ai/memory/topics/module-system.md new file mode 100644 index 00000000..1fb7b717 --- /dev/null +++ b/.ai/memory/topics/module-system.md @@ -0,0 +1,45 @@ +# Module system + +## What it is + +The extensibility mechanism. New capabilities — server actions, CLI commands, Studio UI surfaces, content types, validation hooks — are added as **modules** rather than by patching core code. First-party modules (e.g. `core.system`, `domain.content`) live in `packages/modules//`. Third-party modules are external npm packages that register against the same contract. + +## How it works + +### Module shape + +Every module ships: + +- `manifest.ts` — metadata (id, version, capabilities, dependencies). +- `server/index.ts` — server-side surfaces (HTTP routes, event handlers, jobs). +- `cli/index.ts` — CLI subcommands. +- Optional `studio/index.tsx` — UI surfaces. + +### Registration + +- The `installedModules` registry in `packages/modules/src/index.ts` is a deterministic, sorted-by-`manifest.id` array. +- Server and CLI each have their own `module-loader.ts` that walks the registry at startup and mounts each module's surfaces. +- Studio loads its module bundle from the server at runtime — the host app doesn't bundle modules at build time. + +### Cross-module dependencies + +- Modules **must not** create direct ORM relationships across module boundaries. Use foreign-key IDs only. +- Cross-module communication goes through declared interfaces in `@mdcms/shared`, never direct imports between module packages. +- A module can declare it depends on another module's interface; the loader fails fast if a dependency is missing. + +## Guarantees / invariants + +- **Deterministic load order.** Same registry → same load order across machines. No environment-dependent ordering. +- **No cross-module ORM relations.** Hard rule. Enforced by code review (and ideally a lint rule eventually). +- **Server / CLI / Studio share the same registry source of truth.** The list of installed modules is one decision, not three. +- **Modules don't patch core.** If a feature would require modifying core, it's not a module — promote to core or rethink the abstraction. + +## Cross-refs + +- Spec: `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- Per-package: `packages/modules/`, `apps/server/AGENTS.md`, `apps/cli/AGENTS.md` +- Related: [`schema-sync.md`](schema-sync.md) — content types are typically delivered via modules + +## Why this matters + +A CMS without an extensibility story becomes a monolith customers fork to extend. The module contract forces "it's a module or it's core" decisions early, which keeps the core surface small. diff --git a/.ai/memory/topics/multi-tenancy.md b/.ai/memory/topics/multi-tenancy.md new file mode 100644 index 00000000..20141fb0 --- /dev/null +++ b/.ai/memory/topics/multi-tenancy.md @@ -0,0 +1,49 @@ +# Multi-tenancy + +## What it is + +Project is the top-level isolation boundary in MDCMS. Every **tenant-scoped** entity — documents, content types, schemas, API keys, audit-log entries — carries a `project_id`. (User-bound entities like sessions and accounts are not project-scoped; they resolve a project context per request based on the explicit project parameter or the API key's binding.) Within a project, **environment** (e.g. `draft`, `prod`) is a state dimension that further scopes reads and writes. + +This is the multi-tenant model — a single MDCMS server hosts arbitrary projects, and tenant code never sees data from another tenant. + +## How it works + +### Project resolution + +1. Every authenticated request resolves a project context **before** reaching domain code. +2. Project comes from one of: + - Explicit header / parameter (SDK clients pass `project` in `createClient`). + - The api key's bound project (api keys are project-scoped). + - The session's active project (Studio). +3. If a request can't resolve a project, it errors at the route layer — no domain code runs without a tenant. + +### Environment scoping + +1. Reads default to the **published environment** unless the request explicitly opts into another. +2. Writes target a specific environment — usually `draft` for editorial workflows, `prod` after publish. +3. Publishing is a state transition that copies/promotes a draft document into the prod environment. +4. Locales further scope translatable documents — a single document has a per-locale variant, all sharing the same project + environment context. + +### Storage layer + +- Every persistable row has a `project_id` (foreign key) plus, where relevant, `environment` and `locale` columns. +- Drizzle queries **must** filter by `project_id`. There's no global query path. +- Indexes are composite, leading with `project_id`. + +## Guarantees / invariants + +- **No cross-tenant data leak.** A misconfigured query that omits `project_id` is a bug; should be caught in code review and ideally by lint rules. +- **API keys are project-bound.** A leaked key compromises one project, not all projects on the server. +- **Project deletion cascades.** Deleting a project removes all its documents, schemas, audit entries, and api keys. +- **Environment isolation is logical, not physical.** All environments share the same database tables; isolation comes from the `environment` column, not separate schemas/databases. + +## Cross-refs + +- Spec: `docs/specs/SPEC-001-platform-overview-and-scope.md`, `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- Per-package: `apps/server/AGENTS.md` +- Related: [`auth-flow.md`](auth-flow.md) for how project resolution interacts with each auth mode +- Related: [`push-pull-sync.md`](push-pull-sync.md) for how environment scoping affects CLI operations + +## Future scope + +**Multiple spaces** (team-scoped content organization within a project) is on the upcoming-work list. The shape and exact column names are not yet specified — defer to the relevant spec under `docs/specs/` once it lands. Until then, don't assume a particular schema or boundary; just keep tenant-scoping code shaped for additional future scopes without restructuring. diff --git a/.ai/memory/topics/push-pull-sync.md b/.ai/memory/topics/push-pull-sync.md new file mode 100644 index 00000000..c54eed89 --- /dev/null +++ b/.ai/memory/topics/push-pull-sync.md @@ -0,0 +1,56 @@ +# Push / pull sync + +## What it is + +The CLI's file ↔ database reconciliation. The database is canonical (server-side); local `.md`/`.mdx` files are a working copy. The two operations are deliberately one-directional: + +- **`mdcms pull`** — fetch documents from the server to local files. Overwrites local with server state for the targeted scope. +- **`mdcms push`** — upload local file changes back to the server. Conflict-checked against the server's current state via document version headers. + +The full CLI command set is `init`, `login`, `logout`, `pull`, `push`, `schema-sync`, `status` (registered in `apps/cli/src/lib/framework.ts`). + +## How it works + +### Pull + +1. CLI authenticates with the stored API key from the credential store. +2. CLI requests document set scoped to project + environment + locale + content-type filters. +3. Server returns documents with their current version metadata. +4. CLI writes `.md`/`.mdx` files to the configured local directory (`mdcms.config.ts` `contentDir`). +5. Each file's frontmatter carries metadata: id, version, environment, locale, references. + +### Push + +1. CLI scans local files in `contentDir`. +2. For each changed file, CLI sends the request with version-tracking headers: + - `x-mdcms-project` and `x-mdcms-environment` for routing context + - `x-mdcms-schema-hash` to pin against the schema the file was authored under + - `x-mdcms-draft-revision` and `x-mdcms-published-version` to detect server-side drift since the last pull +3. Server validates against the current schema, applies edits, returns the new version. +4. CLI updates local cache to the new version. +5. If the server's version moved since the last pull, the request is rejected as a conflict; CLI surfaces a structured conflict and stops. User resolves manually. + +### Status + +`mdcms status` shows pending local changes and known conflicts without writing anything. + +## Guarantees / invariants + +- **Database is the source of truth.** Pull overwrites local; push commits to server with version checks. +- **No silent merge.** Conflicts halt push; resolution is explicit. +- **Schema-checked at push.** The `x-mdcms-schema-hash` header pins the request against the schema the file was authored under (see ADR-006). Server rejects pushes against stale schemas before any partial write. +- **Environment + locale isolation.** Pull/push are scoped — pulling `prod` never overwrites `draft` and vice versa. + +## Cross-refs + +- Spec: `docs/specs/SPEC-008-cli-and-sdk.md`, `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` +- ADR: `docs/adrs/ADR-006-schema-hash-pinning-for-write-clients.md` +- Per-package: `apps/cli/AGENTS.md` +- Implementation: `apps/cli/src/lib/push.ts`, `apps/cli/src/lib/pull.ts`, `apps/cli/src/lib/framework.ts` +- Related: [`schema-sync.md`](schema-sync.md) for the schema-side equivalent flow + +## What this is _not_ + +- Not a CRDT or real-time collab system — that's Post-MVP. +- Not git. Doesn't track history client-side beyond the last-pull cache. Server holds the version history. +- Not symmetric — pull and push are one-way each. There's no "merge" command. diff --git a/.ai/memory/topics/schema-sync.md b/.ai/memory/topics/schema-sync.md new file mode 100644 index 00000000..659bdb1a --- /dev/null +++ b/.ai/memory/topics/schema-sync.md @@ -0,0 +1,35 @@ +# Schema sync + +## What it is + +How content type definitions in the user's `mdcms.config.ts` reach the server's schema registry. Schema is what gives MDCMS its typed editing surface — Studio forms, validation rules, and component catalogs are all generated from it. + +Schema sync is **schema-first**: developers edit `mdcms.config.ts`, run `mdcms schema sync`, and the server's registry catches up. The server never invents schema unilaterally. + +## How it works + +1. Developer edits `mdcms.config.ts` (defines content types, fields, references via `defineConfig`, `defineType`, `reference` from `@mdcms/shared`). +2. `mdcms schema sync` parses the config, computes a content-addressable hash of the resulting Standard Schema definitions, and POSTs to the server's schema endpoint. +3. Server compares incoming hash with the registry's current hash for that project. +4. If matched → no-op, success. +5. If different → server validates the new schema (no breaking changes without an explicit override flag), persists the new schema record, and updates the active hash. +6. Subsequent reads/writes use the new schema. Documents authored against an old hash get migrated lazily or rejected based on the change type. + +## Guarantees / invariants + +- **Schema hash pinning** for write clients (per ADR-006). Writes carry the schema hash they were authored against; mismatch is detected and surfaced. +- **No silent breaking changes.** Removing a required field or changing a type without a migration path requires an explicit override. +- **Standard Schema interop.** Internal representation uses Standard Schema so adapters into Zod, Valibot, Arktype etc. work out of the box. +- **Project-scoped.** Schema is per project (multi-tenant boundary). Two projects with identical schemas are still separate registry entries. + +## Cross-refs + +- Spec: `docs/specs/SPEC-004-schema-system-and-sync.md` +- ADR: `docs/adrs/ADR-006-schema-hash-pinning-for-write-clients.md` +- Per-package: `apps/cli/AGENTS.md`, `packages/shared/AGENTS.md` +- Related: [`push-pull-sync.md`](push-pull-sync.md) — schema sync runs separately from content push/pull + +## What this is _not_ + +- Not a database migration tool. The server's Drizzle migrations are independent. +- Not pushed automatically by Studio or SDK consumers. Studio reads the active schema; SDK reads documents typed against it. Schema authoring is CLI-only. diff --git a/.ai/plans/2026-03-10-cms-16-design.md b/.ai/plans/2026-03-10-cms-16-design.md new file mode 100644 index 00000000..07b38f04 --- /dev/null +++ b/.ai/plans/2026-03-10-cms-16-design.md @@ -0,0 +1,211 @@ +# CMS-16 Environment Overlay Resolver Design + +## Scope + +Implement the shared resolver foundation for environment-specific schema overlays in `mdcms.config.ts`. + +This design is intentionally limited to resolver behavior in shared config parsing and CLI config loading. It does not add schema registry endpoints or thread resolved schemas into server content read/write paths yet. + +## Spec Delta + +- No `SPEC.md` change is required for this design. +- This work implements the existing spec and roadmap contract for: + - environment overlays via `add`, `modify`, and `omit` + - field-level `.env()` sugar + - environment inheritance via `extends` + +## Goals + +- Produce deterministic resolved schemas per environment. +- Preserve the current package boundaries by keeping the authoring and resolver contract in `@mdcms/shared`. +- Keep the output shape reusable for `CMS-17` schema sync and later server-side validation consumers. +- Fail invalid authoring configurations with actionable `INVALID_CONFIG` errors. + +## Non-Goals + +- No schema registry persistence or hash endpoints. +- No server runtime consumption of resolved schemas yet. +- No changes to content API behavior in this task. + +## Authoring Contract + +### Base Types + +`defineType(name, definition)` continues to define the base type shared across environments. + +### Overlay Authoring + +Each type definition exposes `.extend(...)` for environment-local overlays: + +```ts +const blogPost = defineType("BlogPost", { + directory: "content/blog", + localized: true, + fields: { + title: z.string().min(1).max(200), + slug: z.string().regex(/^[a-z0-9-]+$/), + author: reference("Author"), + tags: z.array(z.string()).default([]), + featured: z.boolean().default(false).env("staging", "preview"), + abTestVariant: z.enum(["control", "a", "b"]).optional().env("preview"), + }, +}); + +export default defineConfig({ + project: "marketing-site", + serverUrl: "http://localhost:4000", + contentDirectories: ["content"], + types: [blogPost], + locales: { + default: "en-US", + supported: ["en-US", "fr"], + }, + environments: { + production: {}, + staging: { + extends: "production", + types: { + BlogPost: blogPost.extend({ + modify: { + tags: z.array(z.string()).min(1), + }, + }), + }, + }, + preview: { + extends: "staging", + types: { + BlogPost: blogPost.extend({ + add: { + reviewerNotes: z.string().optional(), + }, + omit: ["slug"], + }), + }, + }, + }, +}); +``` + +## Resolver Model + +The resolver runs in three phases inside the shared config contract. + +### 1. Parse Authoring Input + +- Parse base config fields as in `CMS-15`. +- Parse type definitions and Standard Schema-compatible fields. +- Parse environment overlay definitions: + - `extends?: string` + - `types?: Record` +- Attach MDCMS metadata for: + - reference fields + - `.env()` sugar targets + +### 2. Normalize Overlay Sources + +- Convert explicit `extend({ add, modify, omit })` payloads into a normalized internal overlay shape. +- Expand field-level `.env(...targets)` metadata into synthetic environment `add` overlays for the owning type. +- Reject conflicts where the same environment/type/field is introduced both by sugar expansion and explicit overlay input. + +### 3. Resolve Environments + +- Resolve each environment independently but deterministically. +- Apply inheritance parent-first, then child overlay. +- Each environment level contributes only its incremental changes. +- The resolved output includes fully materialized per-environment type definitions and field maps in stable order. + +## Deterministic Rules + +- Base type fields are the source schema for environments with no overlays. +- `.env()` fields do not exist in the base resolved field set. They are only added to the targeted environments. +- `extends` chains resolve from ancestor to descendant. +- `add` may only introduce a field that does not yet exist in the inherited schema at that resolution point. +- `modify` may only target a field that already exists in the inherited schema at that resolution point. +- `omit` may only remove a field that already exists in the inherited schema at that resolution point. +- Environment names, type names, overlay maps, and resolved field names are sorted before output to make hashing and snapshots deterministic. + +## Error Handling + +All authoring failures are surfaced as `RuntimeError` with `code: "INVALID_CONFIG"`. + +### Environment Graph Errors + +- unknown `extends` target +- self-referential `extends` +- circular inheritance chains + +Error messages must include the environment name and, for cycles, the full discovered chain. + +### Overlay Target Errors + +- overlay references an unknown type +- `add` targets an already-present field +- `modify` targets a missing field +- `omit` targets a missing field + +Error messages must include environment, type, and field context. + +### Sugar Conflicts + +- `.env()` expansion and explicit overlay both try to define the same added field for the same environment/type + +The diagnostic must direct the user to keep one source of truth. + +## Public Output Shape + +`parseMdcmsConfig(...)` should continue to return the normalized base config and also expose deterministic resolved environment output for downstream tasks. + +Expected additions: + +- parsed `environments` definitions +- `resolvedEnvironments` map keyed by environment name +- resolved type definitions that preserve: + - name + - directory + - localized + - resolved `fields` + - resolved `referenceFields` + +This keeps `CMS-17` from needing to re-implement overlay resolution. + +## Documentation + +Point-of-use docs should be updated in: + +- `packages/shared/README.md` +- `apps/cli/README.md` + +Documentation should explain: + +- how `extends` works +- when to use `add` vs `modify` vs `omit` +- how `.env()` expands +- that this task only defines resolver behavior, with server consumption deferred to later tasks + +## Testing + +### Shared Unit Tests + +Expand `packages/shared/src/lib/contracts/config.test.ts` with: + +- deterministic per-environment resolution +- multi-hop inheritance resolution +- `.env()` sugar expansion +- sugar vs explicit overlay conflict rejection +- missing parent rejection +- self-cycle rejection +- multi-node cycle rejection +- invalid `add` +- invalid `modify` +- invalid `omit` + +### CLI Coverage + +Extend `apps/cli/src/lib/config.test.ts` to prove CLI config loading returns the new resolved environment output unchanged from the shared parser. + +## Implementation Notes + +- Keep the resolver library-agnostic at the public boundary by continuing to accept Standard Schema-compatible field validators. +- Use schema metadata rather than introducing server-only coupling. +- Do not thread resolved schemas into server content APIs in this task. diff --git a/.ai/plans/2026-03-10-cms-16.md b/.ai/plans/2026-03-10-cms-16.md new file mode 100644 index 00000000..e3128e65 --- /dev/null +++ b/.ai/plans/2026-03-10-cms-16.md @@ -0,0 +1,383 @@ +# CMS-16 Environment Overlay Resolver Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement deterministic environment overlay resolution for `mdcms.config.ts`, including `add` / `modify` / `omit`, `.env()` sugar, and `extends` inheritance, without wiring resolved schemas into server content APIs yet. + +**Architecture:** Extend the shared config contract in `@mdcms/shared` so base type definitions, environment overlays, and field metadata are parsed and normalized in one place. Resolve overlays parent-first into stable per-environment type maps, then expose that output through the existing CLI config loader so later schema-sync tasks can consume a canonical resolved snapshot. + +**Tech Stack:** TypeScript, Bun test runner, Zod 4 metadata, Nx monorepo package boundaries + +--- + +### Task 1: Add Shared Overlay Authoring Surface + +**Files:** + +- Modify: `packages/shared/src/lib/contracts/config.ts` +- Test: `packages/shared/src/lib/contracts/config.test.ts` + +**Step 1: Write the failing test** + +Add a shared config test that authors environment overlays with `.extend({ add, modify, omit })` and asserts `parseMdcmsConfig(...)` returns parsed environment metadata plus deterministic `resolvedEnvironments`. + +```ts +test("parseMdcmsConfig resolves environment overlays and env sugar deterministically", () => { + const blogPost = defineType("BlogPost", { + directory: "content/blog", + localized: true, + fields: { + title: z.string(), + slug: z.string(), + tags: z.array(z.string()).default([]), + featured: z.boolean().default(false).env("staging", "preview"), + }, + }); + + const parsed = parseMdcmsConfig( + defineConfig({ + project: "marketing-site", + serverUrl: "http://localhost:4000", + contentDirectories: ["content"], + locales: { default: "en-US", supported: ["en-US"] }, + types: [blogPost], + environments: { + production: {}, + staging: { + extends: "production", + types: { + BlogPost: blogPost.extend({ + modify: { + tags: z.array(z.string()).min(1), + }, + }), + }, + }, + }, + }), + ); + + assert.deepEqual(Object.keys(parsed.resolvedEnvironments), [ + "production", + "staging", + ]); + assert.equal( + parsed.resolvedEnvironments.staging.types.BlogPost.fields.featured !== + undefined, + true, + ); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test packages/shared/src/lib/contracts/config.test.ts` +Expected: FAIL because `defineType(...).extend`, `.env(...)`, `environments`, or `resolvedEnvironments` do not exist yet. + +**Step 3: Write minimal implementation** + +In `packages/shared/src/lib/contracts/config.ts`: + +- add authoring types for environment overlays +- update `defineType(...)` to return an object with `.extend(...)` +- add metadata helpers for `.env(...)` +- parse `config.environments` +- add parsed/resolved config output types + +Representative structure: + +```ts +export type MdcmsTypeOverlay = { + add?: Record; + modify?: Record; + omit?: string[]; +}; + +export type MdcmsEnvironmentDefinition = { + extends?: string; + types?: Record; +}; + +export type ParsedResolvedEnvironment = { + types: Record; +}; + +function createTypeDefinition< + TName extends string, + TFields extends Record, +>( + name: TName, + definition: { + directory?: string; + localized?: boolean; + fields: TFields; + }, +): MdcmsTypeDefinition { + return { + name, + ...definition, + extend(overlay) { + return overlay; + }, + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test packages/shared/src/lib/contracts/config.test.ts` +Expected: PASS for the new resolution-path test, even if later failure-path tests are still missing. + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/contracts/config.ts packages/shared/src/lib/contracts/config.test.ts +git commit -m "feat(shared): add environment overlay config surface" +``` + +### Task 2: Implement Resolver Validation and Error Paths + +**Files:** + +- Modify: `packages/shared/src/lib/contracts/config.ts` +- Test: `packages/shared/src/lib/contracts/config.test.ts` + +**Step 1: Write the failing tests** + +Add targeted tests for: + +- missing `extends` target +- self-cycle +- multi-node cycle +- `.env()` plus explicit `add` conflict +- invalid `add` +- invalid `modify` +- invalid `omit` + +Example failure test: + +```ts +test("parseMdcmsConfig rejects circular extends chains", () => { + assert.throws( + () => + parseMdcmsConfig( + defineConfig({ + project: "marketing-site", + serverUrl: "http://localhost:4000", + contentDirectories: ["content"], + types: [defineType("Page", { fields: { title: z.string() } })], + environments: { + staging: { extends: "preview" }, + preview: { extends: "staging" }, + }, + }), + ), + (error: unknown) => + error instanceof RuntimeError && + error.code === "INVALID_CONFIG" && + error.message.includes("staging") && + error.message.includes("preview"), + ); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test packages/shared/src/lib/contracts/config.test.ts` +Expected: FAIL because cycles/conflicts are not yet rejected with stable diagnostics. + +**Step 3: Write minimal implementation** + +Add resolver helpers in `packages/shared/src/lib/contracts/config.ts`: + +- `parseEnvironments(...)` +- `extractEnvFieldTargets(...)` +- `expandEnvSugar(...)` +- `resolveEnvironmentChain(...)` +- `applyTypeOverlay(...)` + +Representative core logic: + +```ts +function applyTypeOverlay( + typeConfig: ParsedMdcmsTypeDefinition, + overlay: ParsedTypeOverlay, + context: { environment: string; typeName: string }, +): ParsedMdcmsTypeDefinition { + const fields = { ...typeConfig.fields }; + + for (const [fieldName, schema] of Object.entries(overlay.add)) { + if (fieldName in fields) { + throw invalidConfig( + `environments.${context.environment}.types.${context.typeName}.add.${fieldName}`, + "cannot add a field that already exists in the inherited schema.", + ); + } + fields[fieldName] = schema; + } + + for (const [fieldName, schema] of Object.entries(overlay.modify)) { + if (!(fieldName in fields)) { + throw invalidConfig( + `environments.${context.environment}.types.${context.typeName}.modify.${fieldName}`, + "cannot modify a field that does not exist in the inherited schema.", + ); + } + fields[fieldName] = schema; + } + + for (const fieldName of overlay.omit) { + if (!(fieldName in fields)) { + throw invalidConfig( + `environments.${context.environment}.types.${context.typeName}.omit`, + `cannot omit unknown field "${fieldName}".`, + ); + } + delete fields[fieldName]; + } + + return { + ...typeConfig, + fields, + referenceFields: extractReferenceFields(fields), + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test packages/shared/src/lib/contracts/config.test.ts` +Expected: PASS for overlay matrix and failure diagnostics. + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/contracts/config.ts packages/shared/src/lib/contracts/config.test.ts +git commit -m "feat(shared): resolve environment overlay inheritance" +``` + +### Task 3: Thread Resolved Config Through CLI and Docs + +**Files:** + +- Modify: `apps/cli/src/lib/config.ts` +- Modify: `apps/cli/src/lib/config.test.ts` +- Modify: `packages/shared/README.md` +- Modify: `apps/cli/README.md` + +**Step 1: Write the failing test** + +Extend the CLI config loader test to assert `loadCliConfig(...)` exposes the resolved environment output. + +```ts +test("loadCliConfig returns resolved environment schemas from the shared parser", async () => { + const { cwd } = await writeConfigFile(` + import { defineConfig, defineType } from "@mdcms/cli"; + import { z } from "zod"; + + const post = defineType("Post", { + directory: "content/posts", + fields: { + title: z.string(), + featured: z.boolean().env("staging"), + }, + }); + + export default defineConfig({ + project: "marketing-site", + serverUrl: "http://localhost:4000", + contentDirectories: ["content"], + types: [post], + environments: { + production: {}, + staging: {}, + }, + }); + `); + + const { config } = await loadCliConfig({ cwd }); + assert.equal( + config.resolvedEnvironments?.staging.types.Post.fields.featured !== + undefined, + true, + ); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/cli/src/lib/config.test.ts` +Expected: FAIL because CLI config types do not yet expose the resolved environment output. + +**Step 3: Write minimal implementation** + +- update `CliConfig` in `apps/cli/src/lib/config.ts` to include parsed `environments` and `resolvedEnvironments` +- keep `loadCliConfig(...)` delegating to `parseMdcmsConfig(...)` +- document the overlay authoring contract and resolved output usage in both READMEs + +Representative type update: + +```ts +export type CliConfig = Pick< + ParsedMdcmsConfig, + | "serverUrl" + | "project" + | "environment" + | "contentDirectories" + | "locales" + | "types" + | "components" + | "environments" + | "resolvedEnvironments" +>; +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/cli/src/lib/config.test.ts` +Expected: PASS with resolved environment data available to CLI consumers. + +**Step 5: Commit** + +```bash +git add apps/cli/src/lib/config.ts apps/cli/src/lib/config.test.ts packages/shared/README.md apps/cli/README.md +git commit -m "docs(cli): document environment overlay config resolution" +``` + +### Task 4: Verification Sweep + +**Files:** + +- Modify: none unless verification exposes breakage +- Test: `packages/shared/src/lib/contracts/config.test.ts` +- Test: `apps/cli/src/lib/config.test.ts` + +**Step 1: Run focused tests** + +Run: `bun test packages/shared/src/lib/contracts/config.test.ts apps/cli/src/lib/config.test.ts` +Expected: PASS + +**Step 2: Run workspace formatting check** + +Run: `bun run format:check` +Expected: PASS + +**Step 3: Run workspace baseline check** + +Run: `bun run check` +Expected: PASS + +**Step 4: Confirm git hygiene** + +Run: `git status --short` +Expected: + +- only task-related tracked files are staged/modified +- `.claude/`, `.codex/`, `CLAUDE.md`, `AGENTS.md`, `SPEC.md`, `ROADMAP_TASKS.md`, `EXTENSIBILITY_APPROACH_COMPARISON.md`, and `mcp_servers.json` remain untracked and unstaged + +**Step 5: Commit final cleanup if needed** + +```bash +git add packages/shared/src/lib/contracts/config.ts packages/shared/src/lib/contracts/config.test.ts apps/cli/src/lib/config.ts apps/cli/src/lib/config.test.ts packages/shared/README.md apps/cli/README.md +git commit -m "feat(shared): finalize environment overlay resolver" +``` diff --git a/.ai/plans/2026-03-11-cms-17-design.md b/.ai/plans/2026-03-11-cms-17-design.md new file mode 100644 index 00000000..be3726be --- /dev/null +++ b/.ai/plans/2026-03-11-cms-17-design.md @@ -0,0 +1,364 @@ +# CMS-17 Schema Registry Endpoints Design + +## Scope + +Implement the schema registry foundation for: + +- `GET /api/v1/schema` +- `GET /api/v1/schema/:type` +- `PUT /api/v1/schema` + +This task covers registry persistence, read/write API contracts, hash tracking, +and deterministic error handling for raw and resolved schema snapshots. + +## Spec Delta + +`SPEC.md` already defines the route surface, auth scopes, target routing, and +the top-level `PUT /api/v1/schema` success envelope. This design defines the +missing contract details that must be reflected in `SPEC.md` before or alongside +implementation: + +- `SchemaRegistryEntry` response shape +- registry storage model +- descriptive snapshot limitations +- deterministic `INVALID_INPUT` vs `SCHEMA_INCOMPATIBLE` semantics + +## Goals + +- Persist the latest schema sync state per `(project, environment)`. +- Return one registry entry per schema type for the target environment. +- Support stable hash comparison and sync metadata reads. +- Fail unsupported or unserializable schema features deterministically. +- Reserve `409 SCHEMA_INCOMPATIBLE` for migration-requiring schema conflicts + rather than malformed payloads. + +## Non-Goals + +- No schema sync history beyond the latest state per target environment. +- No exact reconstruction of executable Zod or Standard Schema validators. +- No content migration execution in this task. +- No Studio schema editing UI. + +## Chosen Model + +### Registry Read Model + +The registry is type-centric. + +- `GET /api/v1/schema` returns one `SchemaRegistryEntry` per type for the target + `(project, environment)`. +- `GET /api/v1/schema/:type` returns a single `SchemaRegistryEntry`. +- `PUT /api/v1/schema` writes environment-level sync metadata and derives the + per-type registry rows from the uploaded resolved schema snapshot. + +### Sync History + +Sync history is out of scope for `CMS-17`. + +The registry stores only the latest sync state per `(project, environment)`. +Current spec and roadmap text require read/write registry behavior and hash +comparison, but do not require historical sync records. + +## Snapshot Fidelity + +The schema registry stores descriptive JSON snapshots, not executable validator +objects. + +This is an explicit limitation of `CMS-17`. + +- Stored schema data is suitable for: + - hash comparison + - schema mismatch detection + - read-only Studio schema display + - future introspection based on descriptive metadata +- Stored schema data is not suitable for exact reconstruction of runtime + validator behavior. + +### Unsupported Features + +The registry must reject schema payloads that depend on executable or otherwise +unserializable behavior that cannot be represented losslessly in the descriptive +snapshot model. + +Examples include: + +- `.refine()` +- `.superRefine()` +- `transform` +- `preprocess` +- arbitrary functions +- non-JSON values +- any custom validator behavior that cannot be preserved in the registry JSON + +These failures are `INVALID_INPUT` (`400`), not compatibility conflicts. + +## Public API Contract + +### PUT `/api/v1/schema` + +Request body: + +```ts +{ + rawConfigSnapshot: Record; + resolvedSchema: Record>; + schemaHash: string; + extractedComponents?: unknown; +} +``` + +Behavior: + +- validates the request body shape +- validates that the payload can be represented by the descriptive registry + model +- validates compatibility against the currently stored registry and current + content rows +- upserts the environment-level sync row +- replaces the environment's derived per-type registry rows in a single + transaction + +Success response: + +```ts +{ + data: { + schemaHash: string; + syncedAt: string; + affectedTypes: string[]; + }; +} +``` + +### GET `/api/v1/schema` + +Success response: + +```ts +{ + data: SchemaRegistryEntry[]; +} +``` + +### GET `/api/v1/schema/:type` + +Success response: + +```ts +{ + data: SchemaRegistryEntry; +} +``` + +### SchemaRegistryEntry + +```ts +type SchemaRegistryEntry = { + type: string; + directory: string; + localized: boolean; + schemaHash: string; + syncedAt: string; + resolvedSchema: Record; +}; +``` + +Notes: + +- `schemaHash` and `syncedAt` are duplicated across entries for a given target + environment. This keeps the current route contract simple without introducing + a new top-level metadata envelope. +- `rawConfigSnapshot` is stored at the environment sync level and is not + returned by `GET` endpoints. + +## Storage Model + +Use two tables. + +### 1. Environment-Level Sync Table + +One row per `(project_id, environment_id)`. + +Suggested shape: + +```ts +schema_syncs = { + id: uuid, + projectId: uuid, + environmentId: uuid, + schemaHash: text, + rawConfigSnapshot: jsonb, + extractedComponents: jsonb | null, + syncedAt: timestamptz, +}; +``` + +Constraints and indexing: + +- unique on `(project_id, environment_id)` +- composite foreign key to `(environments.id, environments.project_id)` +- scope index on `(project_id, environment_id)` + +### 2. Per-Type Registry Table + +One row per `(project_id, environment_id, schema_type)`. + +Suggested shape: + +```ts +schema_registry_entries = { + id: uuid, + projectId: uuid, + environmentId: uuid, + schemaType: text, + directory: text, + localized: boolean, + schemaHash: text, + resolvedSchema: jsonb, + syncedAt: timestamptz, +}; +``` + +Constraints and indexing: + +- unique on `(project_id, environment_id, schema_type)` +- composite foreign key to `(environments.id, environments.project_id)` +- scope index on `(project_id, environment_id, schema_type)` + +## Validation and Errors + +### `400 INVALID_INPUT` + +Used when the request is malformed or the registry cannot preserve the uploaded +schema payload in its descriptive snapshot model. + +Examples: + +- missing or malformed top-level request fields +- `resolvedSchema` is not a type map +- a type entry is missing required descriptive fields +- unsupported or unserializable validator features +- non-JSON values + +Diagnostics must be actionable and point to the failing type/field/path when +possible. + +### `409 SCHEMA_INCOMPATIBLE` + +Used only when the payload is valid and serializable, but applying it would +conflict with existing content and should require a migration first. + +For `CMS-17`, compatibility checks stay structural and coarse-grained. + +Examples: + +- removing a type that still has documents in the target environment +- changing localization mode in a way that conflicts with existing documents +- removing a supported locale that still has documents +- making a previously optional or absent field required for a type that already + has documents +- changing a field's coarse kind in a clearly breaking way for existing content + such as: + - scalar to array + - array to scalar + - object to scalar + - reference target `Author` to `Category` + +Anything the registry cannot structurally reason about stays out of `409` and is +either accepted or rejected as `400` depending on serializability. + +## Authorization and Routing + +- `/api/v1/schema*` remains explicitly scoped by `(project, environment)` via + the existing target routing guard. +- `GET` routes require `schema:read`. +- `PUT` requires `schema:write`. +- Session-based RBAC mapping must be extended so privileged Studio users are + actually constrained by the schema scopes already defined for API keys. + +## Server Architecture + +Add a dedicated schema API module: + +- `apps/server/src/lib/schema-api.ts` + +Responsibilities: + +- request parsing and validation +- route mounting +- compatibility checks +- DB-backed registry store +- API response shaping + +Mount it from the shared server bootstrap alongside auth, content, and +collaboration routes. + +Shared request/response contracts should live in: + +- `packages/shared/src/lib/contracts/schema.ts` + +## Persistence Rules + +- schema sync never mutates content rows +- `PUT /api/v1/schema` replaces only registry state for the target environment +- all writes happen transactionally: + - upsert environment sync row + - delete stale type rows for the target environment + - insert replacement type rows + +## Documentation + +Point-of-use docs should be updated in: + +- `SPEC.md` +- `apps/cli/README.md` +- `packages/shared/README.md` + +The docs must explicitly call out: + +- the descriptive snapshot limitation +- unsupported validator features +- the `400` vs `409` error split +- that schema sync updates the registry only and does not mutate content rows + +## Testing + +### Shared Contract Tests + +Add shared tests for: + +- request and response shape validation +- serializer rejection of unsupported features +- deterministic hash and entry ordering behavior where applicable + +### Server API Contract Tests + +Add endpoint tests covering: + +- `GET /schema` success +- `GET /schema/:type` success +- `GET /schema/:type` not found +- `PUT /schema` success +- `PUT /schema` malformed request -> `400` +- `PUT /schema` unsupported/unserializable schema -> `400` +- `PUT /schema` compatibility conflict -> `409` +- explicit target routing enforcement +- auth scope enforcement for both API keys and sessions + +### Database Contract Tests + +Add DB contract coverage for: + +- new schema registry tables +- unique constraints +- composite environment/project foreign keys +- scope indexes + +## Implementation Notes + +- The server should treat uploaded schema payloads as descriptive data, not as a + source of executable validators. +- The serializer contract should be strict and fail closed when it encounters + unsupported features. +- Compatibility checks should stay minimal, deterministic, and structural in + `CMS-17`. diff --git a/.ai/plans/2026-03-11-cms-17.md b/.ai/plans/2026-03-11-cms-17.md new file mode 100644 index 00000000..78d45b6a --- /dev/null +++ b/.ai/plans/2026-03-11-cms-17.md @@ -0,0 +1,374 @@ +# CMS-17 Schema Registry Endpoints Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement `/api/v1/schema`, `/api/v1/schema/:type`, and `PUT /api/v1/schema` with latest-state registry persistence, schema hash tracking, deterministic `400` vs `409` behavior, and target-scoped auth/routing. + +**Architecture:** Add a new shared schema contract layer for registry payload validation and descriptive snapshot serialization, persist latest sync metadata plus derived per-type rows in new server DB tables, and expose a dedicated server route module wired alongside auth/content routes. Keep the registry descriptive only: unsupported executable validator features fail closed with `INVALID_INPUT`, while coarse migration-requiring conflicts return `SCHEMA_INCOMPATIBLE`. + +**Tech Stack:** Bun, Nx, TypeScript, Drizzle ORM/Postgres, Elysia, shared `RuntimeError` envelopes, existing target-routing/auth infrastructure. + +--- + +### Task 0: Lock The Approved Spec Delta Locally + +**Files:** + +- Modify: `SPEC.md` (local-only, do not stage/commit) +- Reference: `docs/plans/2026-03-11-cms-17-design.md` + +**Step 1: Add the missing schema registry contract details to the local spec** + +Add the approved details from the design doc: + +- `SchemaRegistryEntry` shape +- latest-state registry model +- descriptive snapshot limitation +- unsupported feature rejection semantics +- `INVALID_INPUT` vs `SCHEMA_INCOMPATIBLE` + +**Step 2: Verify the local spec contains the approved contract** + +Run: `rg -n "SchemaRegistryEntry|SCHEMA_INCOMPATIBLE|descriptive snapshot|unsupported" SPEC.md` + +Expected: matching lines for the new schema registry contract language. + +**Step 3: Confirm the spec file remains unstaged** + +Run: `git status --short` + +Expected: `SPEC.md` remains untracked or unstaged per repo rules. + +### Task 1: Add Shared Schema Registry Contracts And Serializer Guards + +**Files:** + +- Create: `packages/shared/src/lib/contracts/schema.ts` +- Create: `packages/shared/src/lib/contracts/schema.test.ts` +- Modify: `packages/shared/src/index.ts` + +**Step 1: Write failing shared tests for the public schema registry contract** + +Cover: + +- `SchemaRegistryEntry` validation +- `PUT /schema` request payload validation +- serializer rejection of unsupported features +- deterministic `INVALID_INPUT` diagnostics for unsupported/unserializable paths + +**Step 2: Run the new shared contract test to verify it fails** + +Run: `bun test packages/shared/src/lib/contracts/schema.test.ts` + +Expected: FAIL because the shared schema registry contract does not exist yet. + +**Step 3: Implement the minimal shared contract surface** + +In `schema.ts` add: + +- JSON-ish value helpers/types for registry payloads +- validators/parsers for: + - schema sync request body + - schema registry entry + - optional extracted components payload +- descriptive snapshot serializer guards that reject unsupported features with + actionable `RuntimeError({ code: "INVALID_INPUT" })` + +**Step 4: Export the new shared contract** + +Update `packages/shared/src/index.ts` to export `./lib/contracts/schema.js`. + +**Step 5: Re-run the shared contract test** + +Run: `bun test packages/shared/src/lib/contracts/schema.test.ts` + +Expected: PASS. + +**Step 6: Typecheck shared** + +Run: `bun x tsc -p packages/shared/tsconfig.lib.json --noEmit` + +Expected: PASS. + +**Step 7: Commit** + +```bash +git add packages/shared/src/lib/contracts/schema.ts packages/shared/src/lib/contracts/schema.test.ts packages/shared/src/index.ts +git commit -m "feat(shared): add schema registry contracts" +``` + +### Task 2: Add Schema Registry Tables, Migration, And DB Contract Coverage + +**Files:** + +- Modify: `apps/server/src/lib/db/schema.ts` +- Modify: `apps/server/src/lib/db/schema.contract.test.ts` +- Create: `apps/server/drizzle/0007_.sql` +- Modify: `apps/server/drizzle/meta/_journal.json` +- Create or Modify: `apps/server/drizzle/meta/0007_snapshot.json` + +**Step 1: Extend the DB contract test with failing expectations** + +Add assertions for: + +- `schema_syncs` columns, uniques, FK, indexes +- `schema_registry_entries` columns, uniques, FK, indexes + +**Step 2: Run the DB contract test to verify it fails** + +Run: `bun test apps/server/src/lib/db/schema.contract.test.ts` + +Expected: FAIL because the new tables/migration artifacts do not exist. + +**Step 3: Add the new Drizzle table definitions** + +In `schema.ts`, add: + +- `schemaSyncs` +- `schemaRegistryEntries` + +Follow the existing environment-scoped pattern: + +- `projectId` +- `environmentId` +- composite FK back to `environments` +- unique constraints for latest sync row and per-type row uniqueness + +**Step 4: Add the new migration artifacts** + +Create the next migration after `0006_concerned_logan.sql` and update the +journal/meta snapshot so the contract test sees the new schema. + +**Step 5: Re-run the DB contract test** + +Run: `bun test apps/server/src/lib/db/schema.contract.test.ts` + +Expected: PASS. + +**Step 6: Typecheck server DB code** + +Run: `bun x tsc -p apps/server/tsconfig.lib.json --noEmit` + +Expected: PASS. + +**Step 7: Commit** + +```bash +git add apps/server/src/lib/db/schema.ts apps/server/src/lib/db/schema.contract.test.ts apps/server/drizzle +git commit -m "feat(server): add schema registry tables" +``` + +### Task 3: Add Registry Persistence And Compatibility Logic + +**Files:** + +- Create: `apps/server/src/lib/schema-api.ts` +- Create: `apps/server/src/lib/schema-api.test.ts` +- Reference: `apps/server/src/lib/content-api.ts` + +**Step 1: Write failing schema API/store tests for the core success path** + +Cover: + +- `PUT /api/v1/schema` persists one sync row plus derived type rows +- `GET /api/v1/schema` returns one entry per type +- `GET /api/v1/schema/:type` returns the correct type + +Use the same testing style as `content-api.test.ts`. + +**Step 2: Run the new server test to verify it fails** + +Run: `bun test apps/server/src/lib/schema-api.test.ts` + +Expected: FAIL because the route module/store does not exist yet. + +**Step 3: Implement the registry store and route module** + +In `schema-api.ts`: + +- add a DB-backed registry store abstraction +- parse target scope from request +- validate `PUT` request payload with the shared contract +- transactionally: + - resolve scope ids without auto-creating project/environment + - upsert the environment-level sync row + - replace derived per-type rows for that environment +- shape success responses as `{ data: ... }` + +**Step 4: Implement structural compatibility checks** + +Still in `schema-api.ts`, add compatibility logic for `409 SCHEMA_INCOMPATIBLE` +covering the approved coarse rules: + +- deleting a type that still has documents +- incompatible localization changes +- removing a supported locale that still has documents +- introducing required fields against existing documents +- clearly breaking coarse field-kind changes + +Keep anything unsupported or unserializable in the `400 INVALID_INPUT` path. + +**Step 5: Re-run the schema API test** + +Run: `bun test apps/server/src/lib/schema-api.test.ts` + +Expected: PASS for the initial success-path and compatibility coverage. + +**Step 6: Commit** + +```bash +git add apps/server/src/lib/schema-api.ts apps/server/src/lib/schema-api.test.ts +git commit -m "feat(server): add schema registry API" +``` + +### Task 4: Wire Schema Routes Into Server Runtime And Auth + +**Files:** + +- Modify: `apps/server/src/lib/runtime-with-modules.ts` +- Modify: `apps/server/src/lib/auth.ts` +- Modify: `apps/server/src/lib/rbac.ts` +- Modify: `apps/server/src/lib/auth.test.ts` +- Modify: `apps/server/src/lib/target-routing-guard.test.ts` + +**Step 1: Add failing auth/routing tests** + +Cover: + +- missing target routing for `/api/v1/schema` +- mismatched target routing +- API key `schema:read` required for `GET` +- API key `schema:write` required for `PUT` +- session-based RBAC gating for schema routes + +**Step 2: Run the affected auth/routing tests to verify they fail** + +Run: + +- `bun test apps/server/src/lib/auth.test.ts` +- `bun test apps/server/src/lib/target-routing-guard.test.ts` + +Expected: FAIL because schema routes are not yet mounted into auth coverage or +RBAC action mapping. + +**Step 3: Extend RBAC action mapping for schema routes** + +Update `rbac.ts` and `auth.ts` so sessions can be authorized for: + +- `schema:read` +- `schema:write` + +Preserve deny-by-default behavior. + +**Step 4: Mount the schema API routes in server bootstrap** + +Update `runtime-with-modules.ts` to mount the new schema route module with the +same `authorizeRequest` surface used by content routes. + +**Step 5: Re-run auth and routing tests** + +Run: + +- `bun test apps/server/src/lib/auth.test.ts` +- `bun test apps/server/src/lib/target-routing-guard.test.ts` + +Expected: PASS. + +**Step 6: Commit** + +```bash +git add apps/server/src/lib/runtime-with-modules.ts apps/server/src/lib/auth.ts apps/server/src/lib/rbac.ts apps/server/src/lib/auth.test.ts apps/server/src/lib/target-routing-guard.test.ts +git commit -m "feat(server): enforce schema route auth" +``` + +### Task 5: Document The Public Contract And Registry Limitations + +**Files:** + +- Modify: `packages/shared/README.md` +- Modify: `apps/cli/README.md` +- Modify: `apps/server/src/lib/schema-api.ts` + +**Step 1: Add or update point-of-use documentation** + +Document: + +- schema registry endpoint behavior +- latest-state sync model +- descriptive snapshot limitation +- unsupported feature rejection +- `400 INVALID_INPUT` vs `409 SCHEMA_INCOMPATIBLE` +- registry-only writes during schema sync + +Add short code comments in `schema-api.ts` for non-obvious compatibility and +serialization logic. + +**Step 2: Format the changed docs/source files** + +Run: `bun x prettier --write packages/shared/README.md apps/cli/README.md apps/server/src/lib/schema-api.ts` + +Expected: files rewritten or confirmed already formatted. + +**Step 3: Commit** + +```bash +git add packages/shared/README.md apps/cli/README.md apps/server/src/lib/schema-api.ts +git commit -m "docs: describe schema registry limits" +``` + +### Task 6: Final Verification Sweep + +**Files:** + +- Verify all files touched above + +**Step 1: Run focused tests for this task** + +Run: + +- `bun test packages/shared/src/lib/contracts/schema.test.ts` +- `bun test apps/server/src/lib/db/schema.contract.test.ts` +- `bun test apps/server/src/lib/schema-api.test.ts` +- `bun test apps/server/src/lib/auth.test.ts` +- `bun test apps/server/src/lib/target-routing-guard.test.ts` + +Expected: PASS. + +**Step 2: Run touched package typechecks** + +Run: + +- `bun x tsc -p packages/shared/tsconfig.lib.json --noEmit` +- `bun x tsc -p apps/server/tsconfig.lib.json --noEmit` + +Expected: PASS. + +**Step 3: Run repo formatting and build/typecheck gate** + +Run: + +- `bun run format:check` +- `bun run check` + +Expected: PASS. + +**Step 4: Verify git hygiene** + +Run: `git status --short` + +Expected: + +- task files staged or committed as intended +- local-only files remain unstaged/uncommitted: + - `.claude/` + - `.codex/` + - `AGENTS.md` + - `CLAUDE.md` + - `SPEC.md` + - `ROADMAP_TASKS.md` + - `EXTENSIBILITY_APPROACH_COMPARISON.md` + - `mcp_servers.json` + +**Step 5: Request code review** + +Use the repo review workflow on the final diff before merge or handoff. diff --git a/.ai/plans/2026-03-11-cms-19-project-boundaries-design.md b/.ai/plans/2026-03-11-cms-19-project-boundaries-design.md new file mode 100644 index 00000000..10bcefe6 --- /dev/null +++ b/.ai/plans/2026-03-11-cms-19-project-boundaries-design.md @@ -0,0 +1,104 @@ +# CMS-19 Project Boundaries Design + +## Scope + +Implement CMS-19 strictly within the existing server/runtime surface: + +- keep project identity backed by `projects.slug` +- preserve nullable `organization_id` +- centralize project/environment scope resolution for server stores +- prove cross-project isolation for content, schema, and environment operations + +Out of scope: + +- project CRUD endpoints +- auth or RBAC behavior changes +- Studio project switcher work +- new media or webhook runtime APIs + +## Approved Approach + +Use an internal repository-style refactor, not a public API expansion. + +The existing project/environment lookup and provisioning logic is currently split +between: + +- `apps/server/src/lib/project-provisioning.ts` +- `apps/server/src/lib/content-api.ts` +- `apps/server/src/lib/schema-api.ts` +- `apps/server/src/lib/environments-api.ts` + +CMS-19 will consolidate that logic into one server-owned module so each store +resolves scope through the same path. + +## Architecture + +The internal project repository owns: + +- `findProjectBySlug` +- `ensureProjectProvisioned` +- `resolveProjectEnvironmentScope(project, environment, { createIfMissing })` +- `requireProjectEnvironmentScope(...)` +- `requireEnvironmentInProject(project, environmentId)` + +The route/store layers keep responsibility for mapping repository results into +endpoint-specific `RuntimeError` responses. + +## Data Flow + +### Content store + +- replace the store-local `resolveScopeIds` helper with repository-backed scope + resolution +- keep document reads and writes scoped by `(projectId, environmentId)` +- keep wrong-project `documentId` access returning `NOT_FOUND` + +### Schema store + +- reuse the same repository helper for project/environment resolution +- keep schema list/get/sync fully isolated to the resolved scope + +### Environment store + +- keep project-routed list/create/delete contracts unchanged +- use the repository for provisioning and environment ownership checks + +## Provisioning Rules + +Preserve current behavior where it already exists: + +- environment creation may provision the project row and default `production` + environment transactionally +- content creation keeps its current repository-backed scope resolution behavior + +CMS-19 should not broaden or normalize provisioning semantics beyond the current +contract surface. + +## Error Handling + +Public error contracts remain unchanged. + +- content document operations keep returning `NOT_FOUND` for out-of-scope + documents +- schema sync keeps returning `NOT_FOUND` when the routed project/environment + pair does not exist +- environment delete keeps returning `NOT_FOUND` when the target environment ID + is not owned by the routed project + +Repository helpers return typed records or `undefined`; they do not raise +endpoint-specific errors themselves. + +## Testing + +Add or tighten database-backed tests for: + +- content cross-project isolation +- schema registry cross-project isolation +- environment deletion with an environment ID that belongs to another project +- repository provisioning and scope resolution behavior + +## Repo Policy Note + +This design file is intentionally stored in `docs/plans/` as a local planning +artifact and should remain untracked per the repository instructions in +`AGENTS.md`. diff --git a/.ai/plans/2026-03-11-cms-19-project-boundaries.md b/.ai/plans/2026-03-11-cms-19-project-boundaries.md new file mode 100644 index 00000000..fee12b2b --- /dev/null +++ b/.ai/plans/2026-03-11-cms-19-project-boundaries.md @@ -0,0 +1,279 @@ +# CMS-19 Project Boundaries Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Centralize project/environment scope resolution in the server and verify cross-project isolation for CMS content, schema, and environment flows. + +**Architecture:** Expand the existing project provisioning module into a small internal repository that owns project lookup, default provisioning, and project/environment scope resolution. Refactor the content, schema, and environment stores to depend on that shared module while keeping public routes, payloads, and error contracts unchanged. + +**Tech Stack:** Bun, TypeScript, Elysia route handlers, Drizzle ORM, postgres.js, node:test, Nx + +--- + +### Task 1: Build the Internal Project Repository + +**Files:** + +- Modify: `apps/server/src/lib/project-provisioning.ts` +- Create: `apps/server/src/lib/project-provisioning.test.ts` + +**Step 1: Write the failing test** + +```ts +test("project provisioning resolves project/environment scope within one project", async () => { + const result = await resolveProjectEnvironmentScope(db, { + project: "marketing-site", + environment: "production", + createIfMissing: true, + }); + + assert.equal(result?.project.slug, "marketing-site"); + assert.equal(result?.environment.name, "production"); +}); + +test("project provisioning rejects environment ownership from another project", async () => { + const result = await requireEnvironmentInProject(db, { + project: "marketing-site", + environmentId: docsEnvironment.id, + }); + + assert.equal(result, undefined); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/project-provisioning.test.ts` +Expected: FAIL because the new repository helpers and test file do not exist yet. + +**Step 3: Write minimal implementation** + +```ts +export async function resolveProjectEnvironmentScope( + db: DrizzleDatabase, + input: { + project: string; + environment: string; + createIfMissing?: boolean; + }, +) { + const project = input.createIfMissing + ? await ensureProjectProvisioned(db, { project: input.project }) + : await findProjectBySlug(db, input.project); + + // Load the concrete project row, then the environment row scoped by projectId. + // Return typed records or undefined; do not throw route-specific RuntimeErrors. +} + +export async function requireEnvironmentInProject( + db: DrizzleDatabase, + input: { project: string; environmentId: string }, +) { + // Resolve the project by slug, then load environments.id scoped by projectId. +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/project-provisioning.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/project-provisioning.ts apps/server/src/lib/project-provisioning.test.ts +git commit -m "feat(server): add project scope repository helpers" +``` + +### Task 2: Refactor Content Store to Use Shared Scope Resolution + +**Files:** + +- Modify: `apps/server/src/lib/content-api.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing test** + +```ts +testWithDatabase("content API isolates documents across projects", async () => { + const doc = await createDocument({ + project: "marketing-site", + environment: "production", + }); + + const response = await handler( + new Request(`http://localhost/api/v1/content/${doc.documentId}`, { + headers: { + "x-mdcms-project": "docs-site", + "x-mdcms-environment": "production", + }, + }), + ); + + assert.equal(response.status, 404); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL because the database-backed content test does not exist yet or because content scope resolution is still duplicated. + +**Step 3: Write minimal implementation** + +```ts +const scopeIds = await resolveProjectEnvironmentScope(db, { + project: scope.project, + environment: scope.environment, + createIfMissing, +}); + +if (!scopeIds) { + return undefined; +} + +// Keep all document queries filtered by both projectId and environmentId. +``` + +Add one wrong-project write-path assertion as well, preferably `PUT` or `DELETE`, +to prove scoped mutation denial returns `NOT_FOUND`. + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): enforce shared content project scope resolution" +``` + +### Task 3: Refactor Schema and Environment Stores to Use the Repository + +**Files:** + +- Modify: `apps/server/src/lib/schema-api.ts` +- Modify: `apps/server/src/lib/schema-api.test.ts` +- Modify: `apps/server/src/lib/environments-api.ts` +- Modify: `apps/server/src/lib/environments-api.test.ts` + +**Step 1: Write the failing tests** + +```ts +testWithDatabase( + "schema API isolates registry state across projects", + async () => { + await syncSchema({ project: "marketing-site", environment: "production" }); + + const response = await handler( + new Request("http://localhost/api/v1/schema", { + headers: { + "x-mdcms-project": "docs-site", + "x-mdcms-environment": "production", + }, + }), + ); + + const body = await response.json(); + assert.deepEqual(body.data, []); + }, +); + +testWithDatabase( + "environment delete returns not found for foreign project environment ids", + async () => { + const response = await handler( + new Request( + `http://localhost/api/v1/environments/${docsEnvironmentId}?project=marketing-site`, + { method: "DELETE", headers: { cookie } }, + ), + ); + + assert.equal(response.status, 404); + }, +); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/schema-api.test.ts apps/server/src/lib/environments-api.test.ts` +Expected: FAIL because the tests are new and the stores still resolve project scope independently. + +**Step 3: Write minimal implementation** + +```ts +const scopeIds = await resolveProjectEnvironmentScope(db, { + project: scope.project, + environment: scope.environment, +}); + +const environmentRow = await requireEnvironmentInProject(db, { + project: normalizedProject, + environmentId: normalizedEnvironmentId, +}); +``` + +Keep the current route/store error mapping: + +- schema sync missing scope => `NOT_FOUND` +- environment delete foreign ID => `NOT_FOUND` + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/schema-api.test.ts apps/server/src/lib/environments-api.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/schema-api.ts apps/server/src/lib/schema-api.test.ts apps/server/src/lib/environments-api.ts apps/server/src/lib/environments-api.test.ts +git commit -m "feat(server): share project scope resolution across schema and environments" +``` + +### Task 4: Verify the Whole CMS-19 Slice + +**Files:** + +- Modify: `apps/server/src/lib/project-provisioning.ts` +- Modify: `apps/server/src/lib/content-api.ts` +- Modify: `apps/server/src/lib/schema-api.ts` +- Modify: `apps/server/src/lib/environments-api.ts` +- Modify: `apps/server/src/lib/project-provisioning.test.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` +- Modify: `apps/server/src/lib/schema-api.test.ts` +- Modify: `apps/server/src/lib/environments-api.test.ts` + +**Step 1: Run targeted server tests** + +Run: `bun test apps/server/src/lib/project-provisioning.test.ts apps/server/src/lib/content-api.test.ts apps/server/src/lib/schema-api.test.ts apps/server/src/lib/environments-api.test.ts` +Expected: PASS + +**Step 2: Run workspace formatting check** + +Run: `bun run format:check` +Expected: PASS + +**Step 3: Run workspace baseline check** + +Run: `bun run check` +Expected: PASS + +**Step 4: Confirm local-only files remain unstaged** + +Run: `git status --short` +Expected: `.claude/`, `.codex/`, `AGENTS.md`, `CLAUDE.md`, `ROADMAP_TASKS.md`, `EXTENSIBILITY_APPROACH_COMPARISON.md`, `mcp_servers.json`, and `docs/plans/` remain untracked or unstaged as required. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/project-provisioning.ts apps/server/src/lib/content-api.ts apps/server/src/lib/schema-api.ts apps/server/src/lib/environments-api.ts apps/server/src/lib/project-provisioning.test.ts apps/server/src/lib/content-api.test.ts apps/server/src/lib/schema-api.test.ts apps/server/src/lib/environments-api.test.ts +git commit -m "feat(server): implement CMS-19 project-scoped data boundaries" +``` + +## Execution Notes + +- Use @superpowers:test-driven-development for each code change: test first, confirm failure, then implement the minimum fix. +- Use @superpowers:verification-before-completion before claiming CMS-19 is done. +- Keep `docs/plans/` artifacts untracked per `AGENTS.md`; do not include them in any commit. diff --git a/.ai/plans/2026-03-11-cms-20-locale-translation-groups-design.md b/.ai/plans/2026-03-11-cms-20-locale-translation-groups-design.md new file mode 100644 index 00000000..324e3021 --- /dev/null +++ b/.ai/plans/2026-03-11-cms-20-locale-translation-groups-design.md @@ -0,0 +1,183 @@ +# CMS-20 Locale Translation Groups Design + +## Scope + +Implement CMS-20 inside the existing server content API surface. + +In scope: + +- add translation-group creation semantics to `POST /api/v1/content` +- preserve the existing response shape (`translationGroupId` is already exposed) +- enforce project/environment scoping for variant creation +- add deterministic conflict handling for duplicate locales within a translation + group +- document the new operator-facing create contract in the server package docs + +Out of scope: + +- new routes for locale variants +- Studio locale switcher UX and optional prefill flow (CMS-63) +- clone/promote remapping behavior (later environment tasks) +- full schema-aware validation for every content write path +- migrations or table/index changes; the database already has the required + `translation_group_id` column and uniqueness index from CMS-11/CMS-12 + +## Spec Delta Summary + +Confirmed contract delta for this task: + +- `POST /api/v1/content` gains optional `sourceDocumentId` +- omitted `sourceDocumentId` keeps existing "create a brand new logical + document" semantics +- provided `sourceDocumentId` creates a new locale variant in the same + translation group as the source document +- `sourceDocumentId` is valid for any non-deleted source document in the routed + `project` and `environment` +- the server derives `translation_group_id` from the source document; callers do + not send raw `translationGroupId` + +This closes the gap between: + +- the data model requirement that localized variants share a + `translation_group_id` +- the Studio UX requirement that users can switch to an untranslated locale and + create the variant +- the canonical content create contract, which previously had no way to express + "create a sibling translation of this existing document" + +## Approved Approach + +Keep one create endpoint and add an explicit variant-creation mode. + +`POST /api/v1/content` now has two behaviors: + +1. **New logical document** + - request omits `sourceDocumentId` + - server generates a new `document_id` + - server generates a new `translation_group_id` + +2. **New locale variant** + - request includes `sourceDocumentId` + - server resolves the source document within the routed scope + - server generates a new `document_id` + - server reuses the source document's `translation_group_id` + +This keeps the route surface minimal while making variant creation explicit and +safe. The request does not accept raw `translationGroupId`, because that would +let callers bypass scope and type checks. + +## Request Contract + +`POST /api/v1/content` continues to accept: + +- `path` +- `type` +- `locale` +- `format` +- `frontmatter` +- `body` +- optional actor fields + +New optional field: + +- `sourceDocumentId` + +Rules: + +- callers must still send the full draft payload; the server does not + auto-prefill content from the source document +- Studio can implement optional prefill client-side later by reading the source + draft/published document and submitting copied content when desired +- `path` remains explicit input; the server does not infer translation groups + from `path` + +## Validation and Data Flow + +### Variant creation + +When `sourceDocumentId` is present: + +1. resolve the source document inside the routed `project` and `environment` +2. reject missing or soft-deleted sources with `NOT_FOUND` +3. require request `type` to match the source document's `schema_type` +4. resolve the source type's schema registry entry in the same scope +5. reject variant creation for non-localized schema types with `INVALID_INPUT` +6. validate that the requested locale does not already exist in the source + translation group for a non-deleted document +7. create the new row with: + - fresh `document_id` + - inherited `translation_group_id` + - caller-provided `path`, `locale`, `format`, `frontmatter`, and `body` + +### Locale policy enforcement + +CMS-20 adds schema-backed locale validation only where it is required for the +new translation-group behavior. + +- in variant-creation mode, the server uses scoped schema registry data to + verify that the type is localized and that the requested locale is allowed for + the environment's synced locale configuration +- CMS-20 does **not** broaden content writes into full schema-aware validation + for every create/update path; that stays aligned with later content-core work + +This keeps the task scoped while preventing invalid translation-group writes. + +## Error Handling + +Existing deterministic errors remain unchanged where they still apply: + +- `CONTENT_PATH_CONFLICT` (`409`) remains the `(project, environment, locale, +path)` uniqueness error +- `NOT_FOUND` (`404`) remains the missing/out-of-scope/soft-deleted source + error for `sourceDocumentId` +- `INVALID_INPUT` (`400`) covers malformed or invalid variant requests, such as: + - `sourceDocumentId` type mismatch + - `sourceDocumentId` used for a non-localized type + - locale rejected by the synced supported-locale set + +New deterministic conflict: + +- `TRANSLATION_VARIANT_CONFLICT` (`409`) when the translation group already has + a non-deleted document in the requested locale + +The DB-backed store should map the existing unique index on +`(project_id, environment_id, translation_group_id, locale)` to +`TRANSLATION_VARIANT_CONFLICT` so race conditions still return the same public +error code. + +## Testing + +Add coverage in `apps/server/src/lib/content-api.test.ts` for: + +- in-memory create flow reusing `translationGroupId` when `sourceDocumentId` is + provided +- duplicate locale rejection within one translation group +- DB-backed variant creation reusing `translationGroupId` while generating a new + `documentId` +- rejection of `sourceDocumentId` from another project/environment +- rejection of soft-deleted sources +- rejection of non-localized source types +- rejection of unsupported locales for localized types + +Verification commands: + +- `bun test apps/server/src/lib/content-api.test.ts` +- `bun run format:check` +- `bun run check` + +## Documentation + +Document the new create contract in `apps/server/README.md` at the point of +use: + +- `POST /api/v1/content` supports optional `sourceDocumentId` +- omitted `sourceDocumentId` creates a new logical document +- provided `sourceDocumentId` creates a locale variant in the source + translation group +- duplicate locale conflicts return `TRANSLATION_VARIANT_CONFLICT` + +## Repo Policy Note + +This design file is intentionally stored in `docs/plans/` as a local planning +artifact and should remain untracked per the repository instructions in +`AGENTS.md`. diff --git a/.ai/plans/2026-03-11-cms-20-locale-translation-groups.md b/.ai/plans/2026-03-11-cms-20-locale-translation-groups.md new file mode 100644 index 00000000..49960345 --- /dev/null +++ b/.ai/plans/2026-03-11-cms-20-locale-translation-groups.md @@ -0,0 +1,463 @@ +# CMS-20 Locale Translation Groups Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extend the server content create endpoint so locale variants can be created in the same translation group using `sourceDocumentId`, with deterministic validation and conflict handling. + +**Architecture:** Keep the existing `POST /api/v1/content` route and add an explicit variant-creation mode inside `apps/server/src/lib/content-api.ts`. Reuse the existing documents table and unique translation-locale index, add scoped source-document and schema-registry lookups in the DB-backed store, and cover the new behavior through in-memory and DB-backed content API tests. + +**Tech Stack:** Bun, TypeScript, Elysia route handlers, Drizzle ORM, postgres.js, node:test, Nx + +--- + +### Task 1: Add the Variant-Creation Contract to the Route and In-Memory Store + +**Files:** + +- Modify: `apps/server/src/lib/content-api.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing tests** + +```ts +test("content API creates a locale variant in the same translation group when sourceDocumentId is provided", async () => { + const handler = createHandler(); + + const createSourceResponse = await handler( + new Request("http://localhost/api/v1/content", { + method: "POST", + headers: { + ...scopeHeaders, + "content-type": "application/json", + }, + body: JSON.stringify({ + path: "blog/hello-world", + type: "BlogPost", + locale: "en", + format: "md", + frontmatter: { slug: "hello-world" }, + body: "english", + }), + }), + ); + const source = (await createSourceResponse.json()) as { + data: { documentId: string; translationGroupId: string }; + }; + + const createVariantResponse = await handler( + new Request("http://localhost/api/v1/content", { + method: "POST", + headers: { + ...scopeHeaders, + "content-type": "application/json", + }, + body: JSON.stringify({ + path: "blog/hello-world", + type: "BlogPost", + locale: "fr", + format: "md", + frontmatter: { slug: "hello-world" }, + body: "french", + sourceDocumentId: source.data.documentId, + }), + }), + ); + const variant = (await createVariantResponse.json()) as { + data: { documentId: string; translationGroupId: string; locale: string }; + }; + + assert.equal(createVariantResponse.status, 200); + assert.notEqual(variant.data.documentId, source.data.documentId); + assert.equal(variant.data.translationGroupId, source.data.translationGroupId); + assert.equal(variant.data.locale, "fr"); +}); + +test("content API rejects a duplicate locale inside one translation group", async () => { + const handler = createHandler(); + + // Create source document, then create first fr variant. + // Second fr variant request should fail with TRANSLATION_VARIANT_CONFLICT. +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL because `ContentWritePayload` does not accept `sourceDocumentId` +yet and both stores always generate a new `translationGroupId` on create. + +**Step 3: Write minimal implementation** + +```ts +type ContentWritePayload = { + path?: string; + type?: string; + locale?: string; + format?: string; + frontmatter?: Record; + body?: string; + createdBy?: string; + updatedBy?: string; + sourceDocumentId?: string; +}; + +function findTranslationLocaleConflict( + store: Map, + input: { + translationGroupId: string; + locale: string; + documentId?: string; + }, +) { + for (const candidate of store.values()) { + if ( + candidate.documentId !== input.documentId && + candidate.translationGroupId === input.translationGroupId && + candidate.locale === input.locale && + candidate.isDeleted === false + ) { + return candidate; + } + } + + return undefined; +} + +// In create(...): +const sourceDocumentId = parseOptionalString( + payload.sourceDocumentId, + "sourceDocumentId", +); +const source = + sourceDocumentId !== undefined ? store.get(sourceDocumentId) : undefined; + +if (sourceDocumentId && (!source || source.isDeleted)) { + throw new RuntimeError({ + code: "NOT_FOUND", + message: "Source document not found.", + statusCode: 404, + details: { sourceDocumentId }, + }); +} + +if (source && source.type !== type) { + throw new RuntimeError({ + code: "INVALID_INPUT", + message: 'Field "type" must match the source document type.', + statusCode: 400, + details: { field: "type", sourceDocumentId }, + }); +} + +const translationGroupId = source?.translationGroupId ?? randomUUID(); +const translationConflict = findTranslationLocaleConflict(store, { + translationGroupId, + locale, +}); + +if (translationConflict) { + throw new RuntimeError({ + code: "TRANSLATION_VARIANT_CONFLICT", + message: + "A non-deleted document for this translation group and locale already exists.", + statusCode: 409, + details: { + sourceDocumentId, + translationGroupId, + locale, + conflictDocumentId: translationConflict.documentId, + }, + }); +} +``` + +Update the route handler so `sourceDocumentId` passes through unchanged in the +JSON body and the response stays on the existing `ContentDocument` shape. + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS for the new in-memory variant-creation coverage. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): add content variant creation contract" +``` + +### Task 2: Add DB-Backed Translation-Group Reuse and Scoped Source Resolution + +**Files:** + +- Modify: `apps/server/src/lib/content-api.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing database-backed tests** + +```ts +testWithDatabase( + "content API reuses the source translation group in the database store", + async () => { + const { handler, dbConnection } = createServerRequestHandlerWithModules({ + env: dbEnv, + logger, + }); + + // Create owner session, create source document, then create variant with + // sourceDocumentId and assert: + // - 200 response + // - new documentId + // - same translationGroupId + }, +); + +testWithDatabase( + "content API rejects sourceDocumentId from another routed scope", + async () => { + // Create source in marketing-site/production. + // Reuse it from docs-site/production and assert 404 NOT_FOUND. + }, +); + +testWithDatabase( + "content API rejects soft-deleted sourceDocumentId values", + async () => { + // Create source, soft-delete it, then attempt variant creation and assert + // 404 NOT_FOUND. + }, +); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL because the DB-backed store still creates a fresh +`translationGroupId` and does not resolve `sourceDocumentId`. + +**Step 3: Write minimal implementation** + +```ts +async function findTranslationLocaleConflict( + scopeIds: { projectId: string; environmentId: string }, + input: { + translationGroupId: string; + locale: string; + documentId?: string; + }, +) { + const conditions = [ + eq(documents.projectId, scopeIds.projectId), + eq(documents.environmentId, scopeIds.environmentId), + eq(documents.translationGroupId, input.translationGroupId), + eq(documents.locale, input.locale), + eq(documents.isDeleted, false), + ]; + + if (input.documentId) { + conditions.push(ne(documents.documentId, input.documentId)); + } + + return db.query.documents.findFirst({ where: and(...conditions) }); +} + +const sourceDocumentId = parseOptionalString( + payload.sourceDocumentId, + "sourceDocumentId", +); +const source = sourceDocumentId + ? await db.query.documents.findFirst({ + where: and( + eq(documents.projectId, scopeIds.projectId), + eq(documents.environmentId, scopeIds.environmentId), + eq(documents.documentId, sourceDocumentId), + eq(documents.isDeleted, false), + ), + }) + : undefined; + +if (sourceDocumentId && !source) { + throw new RuntimeError({ + code: "NOT_FOUND", + message: "Source document not found.", + statusCode: 404, + details: { sourceDocumentId }, + }); +} + +const translationGroupId = source?.translationGroupId ?? randomUUID(); +``` + +Catch the unique-index race on +`uniq_documents_active_translation_locale` and map it to +`TRANSLATION_VARIANT_CONFLICT`, while keeping +`uniq_documents_active_path` mapped to `CONTENT_PATH_CONFLICT`. + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS for the new DB-backed source-resolution and translation-group +reuse cases. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): reuse translation groups for content variants" +``` + +### Task 3: Enforce Localized-Type and Supported-Locale Rules for Variant Creation + +**Files:** + +- Modify: `apps/server/src/lib/content-api.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing database-backed tests** + +```ts +testWithDatabase( + "content API rejects sourceDocumentId for non-localized schema types", + async () => { + // Seed schema_registry_entries.localized = false for Author. + // Create Author source document, then attempt variant creation with + // sourceDocumentId and assert 400 INVALID_INPUT. + }, +); + +testWithDatabase( + "content API rejects variant locales outside the synced supported locale set", + async () => { + // Seed schema_syncs.rawConfigSnapshot.locales.supported = ["en", "fr"] and + // schema_registry_entries.localized = true for BlogPost. + // Create source in en, then request locale "de" and assert 400 INVALID_INPUT. + }, +); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL because the DB-backed store does not yet consult schema registry +or synced locale config when `sourceDocumentId` is used. + +**Step 3: Write minimal implementation** + +```ts +async function readSupportedLocalesForScope(scopeIds: ScopeIds) { + const syncRow = await db.query.schemaSyncs.findFirst({ + where: and( + eq(schemaSyncs.projectId, scopeIds.projectId), + eq(schemaSyncs.environmentId, scopeIds.environmentId), + ), + }); + + const rawConfigSnapshot = syncRow?.rawConfigSnapshot; + const locales = isRecord(rawConfigSnapshot) + ? readSupportedLocales(rawConfigSnapshot as JsonObject) + : undefined; + + return locales; +} + +async function assertVariantLocalePolicy( + scopeIds: ScopeIds, + input: { type: string; locale: string; sourceDocumentId: string }, +) { + const schemaEntry = await db.query.schemaRegistryEntries.findFirst({ + where: and( + eq(schemaRegistryEntries.projectId, scopeIds.projectId), + eq(schemaRegistryEntries.environmentId, scopeIds.environmentId), + eq(schemaRegistryEntries.schemaType, input.type), + ), + }); + + if (!schemaEntry || !schemaEntry.localized) { + throw new RuntimeError({ + code: "INVALID_INPUT", + message: + "Locale variants can only be created for localized schema types.", + statusCode: 400, + details: { + field: "sourceDocumentId", + sourceDocumentId: input.sourceDocumentId, + }, + }); + } + + const supportedLocales = await readSupportedLocalesForScope(scopeIds); + if (!supportedLocales?.has(input.locale)) { + throw new RuntimeError({ + code: "INVALID_INPUT", + message: `Field "locale" must be one of the synced supported locales.`, + statusCode: 400, + details: { field: "locale", locale: input.locale }, + }); + } +} +``` + +Call the helper only in variant-creation mode. Do not broaden CMS-20 into full +schema-aware validation for every generic create/update request. + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS for non-localized and unsupported-locale variant failures. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): validate locale policy for content variants" +``` + +### Task 4: Document the Contract and Verify the CMS-20 Slice + +**Files:** + +- Modify: `apps/server/README.md` +- Modify: `apps/server/src/lib/content-api.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Update point-of-use docs** + +Add to the Content API section in `apps/server/README.md`: + +```md +- `POST /api/v1/content` accepts optional `sourceDocumentId`. +- Omitting `sourceDocumentId` creates a new logical document with a fresh + `translationGroupId`. +- Providing `sourceDocumentId` creates a locale variant in the same translation + group and may return `TRANSLATION_VARIANT_CONFLICT` when that locale already + exists in the group. +``` + +**Step 2: Run targeted server tests** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS + +**Step 3: Run workspace formatting check** + +Run: `bun run format:check` +Expected: PASS + +**Step 4: Run workspace baseline check** + +Run: `bun run check` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/server/README.md apps/server/src/lib/content-api.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): document cms-20 locale variant contract" +``` + +## Notes + +- `docs/plans/` is local-only in this repository. Do not stage or commit the + design note or this implementation plan. +- No migration files are expected for CMS-20; the necessary columns and unique + indexes already exist. diff --git a/.ai/plans/2026-03-12-cms-23-24-restore-version-history-design.md b/.ai/plans/2026-03-12-cms-23-24-restore-version-history-design.md new file mode 100644 index 00000000..6a68386e --- /dev/null +++ b/.ai/plans/2026-03-12-cms-23-24-restore-version-history-design.md @@ -0,0 +1,182 @@ +# CMS-23 + CMS-24 Restore and Version History Design + +## Scope + +Implement the bundled `CMS-23` and `CMS-24` server-side content behaviors +inside the existing content API surface in `apps/server`. + +In scope: + +- add exact-undelete trash restore to `POST /api/v1/content/:documentId/restore` +- add immutable version history read endpoints +- add historical version restore to + `POST /api/v1/content/:documentId/versions/:version/restore` +- preserve project/environment scoping, deterministic conflict handling, and + append-only version history guarantees +- document the new API contract at the point of use in the server README + +Out of scope: + +- Studio trash/version-history UI +- CLI commands for restore or history +- schema-aware field validation beyond the current content write contract +- collaboration merge semantics +- new tables or indexes; the existing `documents` and `document_versions` + schema already support the required behavior + +## Spec Delta Summary + +Confirmed contract delta for this bundled implementation: + +- `POST /api/v1/content/:documentId/restore` is an exact undelete of the + current head row only +- trash restore does **not** accept `targetStatus` +- trash restore does **not** append a new `document_versions` row +- trash restore preserves the current head state, including any existing + `publishedVersion`, and only clears `isDeleted` +- `POST /api/v1/content/:documentId/versions/:version/restore` is the only + restore flow that accepts `targetStatus=draft|published` +- `targetStatus=draft` updates mutable head content only +- `targetStatus=published` appends a fresh immutable version row at HEAD and + updates `publishedVersion` + +This resolves the ambiguity between the current `SPEC-003` prose and endpoint +table by keeping plain trash restore as undelete-only and leaving publish-style +restoration on the version-restore endpoint. + +## Approved Approach + +Keep the work inside the existing content API module and extend the current +store abstraction. + +The implementation will: + +1. extend `ContentStore` with `restore`, `listVersions`, `getVersion`, and + `restoreVersion` +2. keep in-memory and DB-backed stores aligned so HTTP tests exercise the same + public contract in both modes +3. reuse the existing publish flow semantics for restore-to-published so the + version history remains linear and append-only +4. centralize path-conflict handling so both trash restore and version restore + return the same deterministic `CONTENT_PATH_CONFLICT` error shape + +This keeps the task scoped to the existing server boundary while preserving the +behavioral guarantees already established by the content API. + +## Request Contract + +### `POST /api/v1/content/:documentId/restore` + +- no request body fields are required +- exact undelete of the current head row +- clears `isDeleted` +- preserves current head content and `publishedVersion` +- does not append version history + +### `GET /api/v1/content/:documentId/versions` + +- returns immutable publish history for the routed document +- response items are version summaries derived from `document_versions` + +### `GET /api/v1/content/:documentId/versions/:version` + +- returns one immutable snapshot from `document_versions` + +### `POST /api/v1/content/:documentId/versions/:version/restore` + +- accepts optional `targetStatus` enum: `draft` or `published` +- default is `draft` +- `draft` updates the mutable head from the chosen immutable snapshot and sets + `hasUnpublishedChanges = true` +- `published` updates the mutable head from the chosen immutable snapshot, + appends a new immutable publish row, and updates `publishedVersion` + +## Validation and Data Flow + +### Trash restore + +When restoring a soft-deleted document: + +1. resolve the document within the routed `project` and `environment` +2. reject missing documents with `NOT_FOUND` +3. reject already-active documents by returning the current active head + semantics only if explicitly supported; for this task, only deleted documents + are restorable +4. check whether another active document already occupies the same + `(project, environment, locale, path)` tuple +5. if no conflict exists, clear `isDeleted` and return the restored head + +### Version history reads + +1. resolve the document within the routed scope +2. reject missing or soft-deleted targets with `NOT_FOUND` +3. read immutable rows from `document_versions` for that `documentId` +4. return descending version order for list responses + +### Version restore + +When restoring a historical version: + +1. resolve the head document within the routed scope +2. resolve the requested immutable version row for the same `documentId` +3. reject malformed versions with `INVALID_INPUT` +4. reject missing snapshots with `NOT_FOUND` +5. check active path uniqueness against the snapshot path and locale +6. update the mutable head from the snapshot +7. for `targetStatus=draft`, set `hasUnpublishedChanges = true` and do not + append a version row +8. for `targetStatus=published`, append a fresh immutable version row and set + `publishedVersion` to the new HEAD version number + +## Error Handling + +Deterministic errors for the bundled work: + +- `INVALID_INPUT` (`400`) + - malformed `version` path parameter + - invalid `targetStatus` +- `NOT_FOUND` (`404`) + - document missing in the routed scope + - requested immutable version missing for the document +- `CONTENT_PATH_CONFLICT` (`409`) + - trash restore or version restore would reactivate/update the head into an + active `(project, environment, locale, path)` collision + +Conflict details should include: + +- `path` +- `locale` +- `conflictDocumentId` when available + +## Testing + +Add coverage in `apps/server/src/lib/content-api.test.ts` for: + +- in-memory trash restore exact undelete +- in-memory trash restore conflict handling +- in-memory version history listing and single-version fetch +- in-memory draft version restore +- in-memory published version restore appending a fresh HEAD version +- DB-backed trash restore conflict handling +- DB-backed published version restore preserving linear append-only history + +Verification commands: + +- `bun test apps/server/src/lib/content-api.test.ts` +- `bun run format:check` +- `bun run check` + +## Documentation + +Document the new content contracts in `apps/server/README.md`: + +- trash restore is exact undelete of the current head +- version history endpoints expose immutable publish snapshots +- version restore supports `targetStatus=draft|published` +- restore conflicts return `CONTENT_PATH_CONFLICT` + +## Repo Policy Note + +This design file is intentionally stored in `docs/plans/` as a local planning +artifact and should remain untracked per the repository instructions in +`AGENTS.md`. diff --git a/.ai/plans/2026-03-12-cms-23-24-restore-version-history.md b/.ai/plans/2026-03-12-cms-23-24-restore-version-history.md new file mode 100644 index 00000000..f02b6508 --- /dev/null +++ b/.ai/plans/2026-03-12-cms-23-24-restore-version-history.md @@ -0,0 +1,225 @@ +# CMS-23 + CMS-24 Restore and Version History Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add exact trash restore, immutable version-history reads, and +historical version restore semantics to the server content API. + +**Architecture:** Extend the existing content store contract inside +`apps/server/src/lib/content-api.ts` so the in-memory and DB-backed stores both +implement the same restore/version behaviors. Keep route orchestration in the +current content API module, reuse existing publish semantics for +restore-to-published, and cover the new behavior primarily through +HTTP-level tests in `apps/server/src/lib/content-api.test.ts`. + +**Tech Stack:** Bun, TypeScript, Elysia route handlers, Drizzle ORM, +postgres.js, node:test, Nx + +--- + +### Task 1: Add Failing Tests for Trash Restore and Version History + +**Files:** + +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing tests** + +```ts +test("content API restore undeletes the current head without appending a version", async () => { + const handler = createHandler(); + + // Create, publish, soft-delete, restore, then assert: + // - restore returns 200 + // - isDeleted is false + // - publishedVersion is unchanged + // - version list length is still 1 +}); + +test("content API returns version history summaries and individual snapshots", async () => { + const handler = createHandler(); + + // Create, publish twice, then assert GET /versions and GET /versions/:version. +}); + +test("content API restores a historical version to draft state by default", async () => { + const handler = createHandler(); + + // Publish v1, update + publish v2, restore v1 with no targetStatus, then + // assert head content matches v1 and history length remains 2. +}); + +test("content API restores a historical version to published state when requested", async () => { + const handler = createHandler(); + + // Publish v1, publish v2, restore v1 with targetStatus=published, then + // assert a new version 3 exists and publishedVersion is 3. +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL because the restore and version-history routes do not exist yet. + +**Step 3: Write minimal implementation** + +```ts +type ContentVersionSummary = { + version: number; + path: string; + locale: string; + type: string; + format: ContentFormat; + publishedAt: string; + publishedBy: string; + changeSummary?: string; +}; + +type ContentVersionDocument = ContentVersionSummary & { + documentId: string; + translationGroupId: string; + frontmatter: Record; + body: string; +}; + +type ContentRestoreVersionPayload = { + targetStatus?: string; + actorId?: unknown; + changeSummary?: unknown; + change_summary?: unknown; +}; +``` + +Extend the in-memory store and route handlers just enough to make the new tests +pass before touching the DB-backed parity cases. + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS for the new in-memory restore/version coverage. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.test.ts apps/server/src/lib/content-api.ts +git commit -m "feat(server): add content restore and version history routes" +``` + +### Task 2: Add DB-Backed Restore and Version-Restore Behavior + +**Files:** + +- Modify: `apps/server/src/lib/content-api.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing DB-backed tests** + +```ts +testWithDatabase( + "content API DB restore returns CONTENT_PATH_CONFLICT when undelete collides with an active path", + async () => { + // Create doc A, soft-delete it, create doc B with the same path/locale, + // restore doc A, then assert 409 CONTENT_PATH_CONFLICT. + }, +); + +testWithDatabase( + "content API DB restore version with targetStatus=published appends a new immutable version", + async () => { + // Publish v1, publish v2, restore v1 to published, then assert: + // - publishedVersion is 3 + // - document_versions now has 3 rows + // - version 3 body/frontmatter/path match v1 + }, +); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL because the DB-backed store does not yet implement restore or +version-history operations. + +**Step 3: Write minimal implementation** + +```ts +async restore(scope, documentId) { + // Resolve scope ids and current document row. + // Reject missing rows with NOT_FOUND. + // Check active path uniqueness excluding the current document id. + // Clear isDeleted and return the updated head row. +} + +async restoreVersion(scope, documentId, version, input) { + // Resolve document + immutable version snapshot in one transaction. + // Check path uniqueness against the snapshot path/locale. + // Update the head row from the snapshot. + // If targetStatus === "published", append a fresh version row and set + // publishedVersion to the new version number. +} +``` + +Map unique/path conflicts to `CONTENT_PATH_CONFLICT` with consistent details in +both restore flows. + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS for the new DB-backed restore/version tests. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): add DB-backed restore semantics" +``` + +### Task 3: Document the Operator-Facing Contract and Run Verification + +**Files:** + +- Modify: `apps/server/README.md` + +**Step 1: Write the failing doc expectation** + +```md +- `POST /api/v1/content/:documentId/restore` undeletes the current head only. +- `GET /api/v1/content/:documentId/versions` lists immutable publish history. +- `GET /api/v1/content/:documentId/versions/:version` returns one immutable snapshot. +- `POST /api/v1/content/:documentId/versions/:version/restore` restores a snapshot to draft or published state. +``` + +**Step 2: Run check to verify docs are missing** + +Run: `rg -n "documentId/restore|versions/:version/restore" apps/server/README.md` +Expected: missing or incomplete entries for the new restore/version contract. + +**Step 3: Write minimal implementation** + +Add a short API contract section in `apps/server/README.md` describing the new +endpoints, the exact-undelete trash restore behavior, and the `CONTENT_PATH_CONFLICT` +restore error. + +**Step 4: Run verification** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS + +Run: `bun run format:check` +Expected: PASS + +Run: `bun run check` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/server/README.md apps/server/src/lib/content-api.ts apps/server/src/lib/content-api.test.ts +git commit -m "docs(server): document restore and version history contract" +``` + +## Repo Policy Note + +This plan file is intentionally stored in `docs/plans/` as a local planning +artifact and should remain untracked per `AGENTS.md`. diff --git a/.ai/plans/2026-03-12-cms-38-csrf-design.md b/.ai/plans/2026-03-12-cms-38-csrf-design.md new file mode 100644 index 00000000..cd8318fe --- /dev/null +++ b/.ai/plans/2026-03-12-cms-38-csrf-design.md @@ -0,0 +1,128 @@ +# CMS-38 CSRF Enforcement Design + +Date: 2026-03-12 +Status: approved in chat, local planning artifact only + +## Goal + +Implement CSRF enforcement for session-authenticated, state-changing Studio API requests without changing API-key behavior. + +## Source Of Truth + +- Roadmap task: `CMS-38` in `ROADMAP_TASKS.md` +- Owning auth spec: `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` +- Related endpoint specs: + - `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` + - `docs/specs/SPEC-004-schema-system-and-sync.md` + - `docs/specs/SPEC-009-i18n-and-environments.md` + - `docs/specs/SPEC-006-studio-runtime-and-ui.md` + +## Approved Spec Delta + +Add a concrete CSRF contract to `SPEC-005`: + +- Session-authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests require CSRF validation. +- The server issues a readable cookie named `mdcms_csrf`. +- The client must echo the same value in the `x-mdcms-csrf-token` request header. +- A state-changing session request is accepted only when: + - the Studio session cookie is valid, + - the `mdcms_csrf` cookie is present, + - the `x-mdcms-csrf-token` header is present, + - the cookie value and header value match. +- API-key authenticated requests are exempt from CSRF enforcement. +- Read-only requests (`GET`, `HEAD`, `OPTIONS`) are exempt. +- Public auth routes are exempt: + - `/api/v1/auth/login` + - `/api/v1/auth/sign-up/email` + - `/api/v1/auth/sign-in/email` + - `/api/v1/auth/cli/login/*` +- CSRF failures return deterministic authz semantics: + - status `403` + - code `FORBIDDEN` + - message `Valid CSRF token is required for session-authenticated state-changing requests.` + +## Server Design + +Keep CSRF enforcement server-side in `apps/server/src/lib/auth.ts`, with a shared helper used by first-party mutation handlers. + +### Token Lifecycle + +- Mint a random CSRF token on successful `POST /api/v1/auth/login`. +- Also mint and return the token on successful session bootstrap reads: + - `GET /api/v1/auth/session` + - `GET /api/v1/auth/get-session` +- Clear the CSRF cookie on: + - `POST /api/v1/auth/logout` + - `POST /api/v1/auth/sign-out` + +This lets the Studio recover a token when the browser already has a valid session cookie. + +### Enforcement Rule + +The shared helper should: + +1. Skip CSRF validation when the request is not state-changing. +2. Skip CSRF validation when the request authenticates with `Authorization: Bearer ...` because that is an API-key flow, not a cookie-backed Studio session. +3. If no bearer token is present, check whether a valid session exists. +4. If there is a valid session and the request is state-changing, require matching `mdcms_csrf` cookie and `x-mdcms-csrf-token` header values. +5. If there is no valid session, do not transform the failure; let the existing auth path return `401`. + +This preserves current `401` vs `403` behavior and keeps the scope explicit. + +## Endpoint Coverage + +Apply the guard only to first-party state-changing routes that can be session-authenticated: + +- Auth mutation routes in `apps/server/src/lib/auth.ts` + - `POST /api/v1/auth/logout` + - `POST /api/v1/auth/api-keys` + - `POST /api/v1/auth/api-keys/:keyId/revoke` + - `POST /api/v1/auth/users/:userId/sessions/revoke-all` +- Content mutations in `apps/server/src/lib/content-api/routes.ts` +- Schema sync mutation in `apps/server/src/lib/schema-api.ts` +- Environment mutations in `apps/server/src/lib/environments-api.ts` + +Do not apply the guard to: + +- login and sign-up routes +- CLI browser-login routes +- API-key self-revoke route +- read-only routes + +## Testing Strategy + +Primary acceptance tests: + +- session mutation succeeds with matching CSRF cookie and header +- session mutation fails when header is missing +- session mutation fails when CSRF cookie is missing +- session mutation fails when values mismatch +- API-key mutation remains allowed without CSRF +- login/session bootstrap routes emit `mdcms_csrf` +- logout/sign-out clear `mdcms_csrf` + +Route-level regression coverage: + +- content mutation test in `apps/server/src/lib/content-api.test.ts` +- schema sync test in `apps/server/src/lib/schema-api.test.ts` +- environment mutation test in `apps/server/src/lib/environments-api.test.ts` + +## Documentation + +- Update `SPEC-005` with the concrete CSRF contract. +- Add a short code comment near the shared guard in `auth.ts` explaining the session-only enforcement rule. + +## Validation + +Run from workspace root: + +- `bun test apps/server/src/lib/auth.test.ts` +- `bun test apps/server/src/lib/content-api.test.ts` +- `bun test apps/server/src/lib/schema-api.test.ts` +- `bun test apps/server/src/lib/environments-api.test.ts` +- `bun run format:check` +- `bun run check` + +## Notes + +- `docs/plans/` is intentionally local-only in this repository and should remain untracked. diff --git a/.ai/plans/2026-03-12-cms-38.md b/.ai/plans/2026-03-12-cms-38.md new file mode 100644 index 00000000..768ba228 --- /dev/null +++ b/.ai/plans/2026-03-12-cms-38.md @@ -0,0 +1,348 @@ +# CMS-38 CSRF Enforcement Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Enforce CSRF on session-authenticated state-changing Studio API requests while keeping API-key mutation flows unchanged. + +**Architecture:** Add one shared CSRF utility layer in the server auth module, issue a readable CSRF cookie alongside session bootstrap responses, and invoke the guard from first-party mutation handlers that accept session auth. Keep failures deterministic and scoped to the endpoints defined in the auth, content, schema, and environment specs. + +**Tech Stack:** Bun, TypeScript, Elysia, better-auth, Drizzle, Bun test + +--- + +### Task 1: Codify The Contract In The Owning Spec + +**Files:** + +- Modify: `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` + +**Step 1: Add the normative CSRF contract text** + +Insert a short normative subsection under Session Security and update the auth endpoint table notes with: + +```md +- Session-authenticated POST/PUT/PATCH/DELETE requests require CSRF validation. +- The server sets a readable `mdcms_csrf` cookie. +- Clients must echo that value in `x-mdcms-csrf-token`. +- API-key requests are exempt. +- CSRF failures return `FORBIDDEN` (`403`). +``` + +**Step 2: Verify the spec matches the approved design** + +Check these exact behaviors are present: + +- cookie name: `mdcms_csrf` +- header name: `x-mdcms-csrf-token` +- protected methods: `POST`, `PUT`, `PATCH`, `DELETE` +- exemptions: read-only methods, API-key requests, login/sign-up/CLI login routes + +**Step 3: Commit the spec change separately if using commit checkpoints** + +```bash +git add docs/specs/SPEC-005-auth-authorization-and-request-routing.md +git commit -m "docs: define CSRF contract for Studio mutations" +``` + +### Task 2: Add Failing Auth-Level CSRF Tests + +**Files:** + +- Modify: `apps/server/src/lib/auth.test.ts` + +**Step 1: Add helpers for CSRF cookie extraction and header injection** + +Add small helpers near the existing auth test utilities: + +```ts +function extractCookieValue( + setCookie: string, + name: string, +): string | undefined { + return setCookie + .split(/,\s*/) + .flatMap((chunk) => chunk.split(";")) + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name}=`)) + ?.slice(name.length + 1); +} +``` + +**Step 2: Write failing tests for the approved contract** + +Add tests that assert: + +- login returns a `set-cookie` containing `mdcms_csrf=` +- `GET /api/v1/auth/session` reissues `mdcms_csrf=` +- `POST /api/v1/auth/api-keys` with session cookie but no CSRF header returns `403` +- the same route with mismatched header returns `403` +- the same route with matching cookie/header returns `200` +- `POST /api/v1/auth/logout` clears `mdcms_csrf` + +Example request shape: + +```ts +new Request("http://localhost/api/v1/auth/api-keys", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${sessionCookie}; mdcms_csrf=${csrfToken}`, + "x-mdcms-csrf-token": csrfToken, + }, + body: JSON.stringify(payload), +}); +``` + +**Step 3: Run the focused auth test file and confirm failure** + +Run: `bun test apps/server/src/lib/auth.test.ts` + +Expected: FAIL on the new CSRF assertions because the server does not issue or enforce the token yet. + +**Step 4: Commit the red test stage if using TDD checkpoints** + +```bash +git add apps/server/src/lib/auth.test.ts +git commit -m "test(server): add CMS-38 auth csrf coverage" +``` + +### Task 3: Implement CSRF Utilities And Token Issuance + +**Files:** + +- Modify: `apps/server/src/lib/auth.ts` + +**Step 1: Add CSRF constants and cookie helpers** + +Add constants near the existing auth constants: + +```ts +const CSRF_COOKIE_NAME = "mdcms_csrf"; +const CSRF_HEADER_NAME = "x-mdcms-csrf-token"; +``` + +Add helpers for: + +- generating a token with `randomBytes` +- serializing the readable CSRF cookie +- serializing a clearing cookie +- reading a named cookie from the request + +**Step 2: Add a shared enforcement helper** + +Create a helper with behavior like: + +```ts +async function requireCsrfForSessionMutation(request: Request): Promise { + if (!isStateChangingMethod(request.method)) return; + if (extractBearerToken(request.headers.get("authorization"))) return; + + const session = await getSessionIfAvailable(request); + if (!session) return; + + const cookieToken = readCookie(request, CSRF_COOKIE_NAME); + const headerToken = request.headers.get(CSRF_HEADER_NAME)?.trim(); + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + throw new RuntimeError({ + code: "FORBIDDEN", + message: + "Valid CSRF token is required for session-authenticated state-changing requests.", + statusCode: 403, + }); + } +} +``` + +Include a short code comment explaining why API-key requests are exempt. + +**Step 3: Issue CSRF cookies on session bootstrap responses** + +Update: + +- `login()` to include a CSRF cookie alongside the session cookie +- `/api/v1/auth/session` and `/api/v1/auth/get-session` responses to set `mdcms_csrf` +- `logout()` to clear `mdcms_csrf` +- `handleAuthRequest` sign-out path to clear `mdcms_csrf` if the route is `/api/v1/auth/sign-out` + +If multiple cookies are set in one response, preserve the session cookie and append the CSRF cookie in the same `set-cookie` header string format already used by the server. + +**Step 4: Re-run auth tests** + +Run: `bun test apps/server/src/lib/auth.test.ts` + +Expected: auth-level CSRF tests now pass or narrow failures to downstream route coverage still awaiting integration. + +**Step 5: Commit the auth implementation** + +```bash +git add apps/server/src/lib/auth.ts apps/server/src/lib/auth.test.ts +git commit -m "feat(server): add session csrf token handling" +``` + +### Task 4: Enforce CSRF On Auth Mutation Routes + +**Files:** + +- Modify: `apps/server/src/lib/auth.ts` + +**Step 1: Call the shared CSRF guard at the top of protected auth mutations** + +Add `await options.authService.requireCsrfProtection(request)` or equivalent shared helper call before: + +- `POST /api/v1/auth/logout` +- `POST /api/v1/auth/users/:userId/sessions/revoke-all` +- `POST /api/v1/auth/api-keys` +- `POST /api/v1/auth/api-keys/:keyId/revoke` + +Do not enforce the guard on: + +- `POST /api/v1/auth/login` +- better-auth sign-up/sign-in +- CLI login routes +- `POST /api/v1/auth/api-keys/self/revoke` + +**Step 2: Run auth tests again** + +Run: `bun test apps/server/src/lib/auth.test.ts` + +Expected: PASS + +**Step 3: Commit the route enforcement change** + +```bash +git add apps/server/src/lib/auth.ts +git commit -m "feat(server): enforce csrf on auth session mutations" +``` + +### Task 5: Enforce CSRF On Content Mutations + +**Files:** + +- Modify: `apps/server/src/lib/auth.ts` +- Modify: `apps/server/src/lib/content-api/routes.ts` +- Modify: `apps/server/src/lib/content-api/types.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Expose the CSRF guard through the existing route authorizer surface** + +Prefer the smallest change that avoids duplicating auth internals: + +- either extend the auth service with a dedicated `requireCsrfProtection(request)` method +- or extend `MountContentApiRoutesOptions` with a separate `requireCsrf` callback + +Keep the route contract explicit rather than reaching into auth internals from content routes. + +**Step 2: Add failing content-route tests** + +Add tests for a representative session-backed mutation such as `POST /api/v1/content`: + +- missing CSRF header returns `403` +- matching CSRF cookie/header returns `200` +- API-key request still succeeds without CSRF when scopes allow it + +**Step 3: Wire the guard into all content mutations** + +Invoke the guard before authorization/store calls in: + +- `POST /api/v1/content` +- `PUT /api/v1/content/:documentId` +- `POST /api/v1/content/:documentId/publish` +- `POST /api/v1/content/:documentId/unpublish` +- `DELETE /api/v1/content/:documentId` +- `POST /api/v1/content/:documentId/restore` +- `POST /api/v1/content/:documentId/versions/:version/restore` + +**Step 4: Run the focused content test file** + +Run: `bun test apps/server/src/lib/content-api.test.ts` + +Expected: PASS + +**Step 5: Commit the content enforcement** + +```bash +git add apps/server/src/lib/auth.ts apps/server/src/lib/content-api/routes.ts apps/server/src/lib/content-api/types.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): enforce csrf on content mutations" +``` + +### Task 6: Enforce CSRF On Schema And Environment Mutations + +**Files:** + +- Modify: `apps/server/src/lib/runtime-with-modules.ts` +- Modify: `apps/server/src/lib/schema-api.ts` +- Modify: `apps/server/src/lib/schema-api.test.ts` +- Modify: `apps/server/src/lib/environments-api.ts` +- Modify: `apps/server/src/lib/environments-api.test.ts` + +**Step 1: Extend the route mount options if needed** + +If schema/environment route mounts only receive authz callbacks today, add a parallel CSRF callback so they can opt into the shared guard without re-implementing auth logic. + +**Step 2: Add failing schema/environment tests** + +Add one representative session-backed mutation test per surface: + +- `PUT /api/v1/schema` without CSRF returns `403` +- `POST /api/v1/environments` without CSRF returns `403` +- matching CSRF cookie/header succeeds + +**Step 3: Implement route-level enforcement** + +Invoke the shared guard before the existing mutation logic in: + +- `PUT /api/v1/schema` +- `POST /api/v1/environments` +- `DELETE /api/v1/environments/:id` + +**Step 4: Run the focused test files** + +Run: `bun test apps/server/src/lib/schema-api.test.ts` +Expected: PASS + +Run: `bun test apps/server/src/lib/environments-api.test.ts` +Expected: PASS + +**Step 5: Commit the schema/environment enforcement** + +```bash +git add apps/server/src/lib/runtime-with-modules.ts apps/server/src/lib/schema-api.ts apps/server/src/lib/schema-api.test.ts apps/server/src/lib/environments-api.ts apps/server/src/lib/environments-api.test.ts +git commit -m "feat(server): enforce csrf on schema and environment mutations" +``` + +### Task 7: Final Verification And Hygiene + +**Files:** + +- Verify only task-scoped changes are staged + +**Step 1: Run the touched test files together** + +Run: `bun test apps/server/src/lib/auth.test.ts apps/server/src/lib/content-api.test.ts apps/server/src/lib/schema-api.test.ts apps/server/src/lib/environments-api.test.ts` + +Expected: PASS + +**Step 2: Run repository validation commands** + +Run: `bun run format:check` +Expected: PASS + +Run: `bun run check` +Expected: PASS + +**Step 3: Inspect git status** + +Run: `git status --short` + +Expected: + +- only task-scoped source/spec changes are tracked +- `docs/plans/` remains untracked +- local-only files from `AGENTS.md` stay unstaged + +**Step 4: Create the final task-scoped commit if needed** + +```bash +git add apps/server/src/lib/auth.ts apps/server/src/lib/auth.test.ts apps/server/src/lib/content-api/routes.ts apps/server/src/lib/content-api/types.ts apps/server/src/lib/content-api.test.ts apps/server/src/lib/runtime-with-modules.ts apps/server/src/lib/schema-api.ts apps/server/src/lib/schema-api.test.ts apps/server/src/lib/environments-api.ts apps/server/src/lib/environments-api.test.ts docs/specs/SPEC-005-auth-authorization-and-request-routing.md +git commit -m "feat(server): implement CMS-38 csrf enforcement" +``` diff --git a/.ai/plans/2026-03-12-content-api-refactor-design.md b/.ai/plans/2026-03-12-content-api-refactor-design.md new file mode 100644 index 00000000..64c516d9 --- /dev/null +++ b/.ai/plans/2026-03-12-content-api-refactor-design.md @@ -0,0 +1,115 @@ +# Content API Refactor Design + +## Scope + +Refactor `apps/server/src/lib/content-api.ts` into smaller internal modules +without changing any behavior, public exports, route contracts, or existing +test expectations. + +In scope: + +- keep `apps/server/src/lib/content-api.ts` as the stable public entrypoint +- extract shared content API types into an internal module +- extract parsing/routing helpers into an internal module +- extract response and row conversion helpers into an internal module +- extract the in-memory content store into its own module +- extract the DB-backed content store into its own module +- extract route mounting into its own module +- preserve the current test suite as the regression guard + +Out of scope: + +- endpoint behavior changes +- route path or payload changes +- new tests for new behavior +- cleanup refactors unrelated to file decomposition +- broader server architecture changes + +## Spec Delta Summary + +There is no product-spec delta for this work. This is a no-behavior-change +internal refactor of the current server implementation. + +The contract that must remain unchanged is the existing public surface exported +by `apps/server/src/lib/content-api.ts`: + +- `createInMemoryContentStore` +- `createDatabaseContentStore` +- `mountContentApiRoutes` + +Acceptance for this refactor depends on preserving: + +- existing content API runtime behavior +- existing request/response contracts +- existing test outcomes + +## Approved Approach + +Use a thin facade entrypoint and move internals into +`apps/server/src/lib/content-api/`. + +Target structure: + +- `content-api.ts` +- `content-api/types.ts` +- `content-api/parsing.ts` +- `content-api/responses.ts` +- `content-api/in-memory-store.ts` +- `content-api/database-store.ts` +- `content-api/routes.ts` + +This is the safest split because it reduces the file size quickly while keeping +the rest of the codebase importing the same top-level module path. + +## Migration Strategy + +1. Extract pure/shared code first: + - content types + - payload/query types + - parsing helpers + - response serializers +2. Extract `createInMemoryContentStore` and keep its private helper functions + local to that module. +3. Extract `createDatabaseContentStore` and keep DB-only helper functions local + to that module. +4. Extract `mountContentApiRoutes` last, after shared helpers and store exports + are already stable. +5. Reduce `content-api.ts` to a thin re-export facade. + +## Guardrails + +- no public API changes +- no route changes +- no behavior changes +- no test rewrites beyond import-stability needs +- no opportunistic cleanups mixed into the extraction + +## Risks + +- accidental type cycles between shared helpers and store modules +- import path mistakes when moving helpers +- behavioral drift between in-memory and DB stores if helpers are duplicated +- route authorization regressions if helper ownership changes + +## Mitigation + +- keep shared types/helpers in dedicated internal modules +- keep store-specific helpers private to the relevant store module +- extract routes last +- run the existing content API test file after each major extraction step + +## Testing + +Primary regression gate: + +- `bun test apps/server/src/lib/content-api.test.ts` + +Full validation after the refactor: + +- `bun run format:check` +- `bun run check` + +## Repo Policy Note + +This design file is intentionally stored in `docs/plans/` as a local planning +artifact and should remain untracked per `AGENTS.md`. diff --git a/.ai/plans/2026-03-12-content-api-refactor.md b/.ai/plans/2026-03-12-content-api-refactor.md new file mode 100644 index 00000000..85994c76 --- /dev/null +++ b/.ai/plans/2026-03-12-content-api-refactor.md @@ -0,0 +1,224 @@ +# Content API Refactor Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Break `apps/server/src/lib/content-api.ts` into smaller internal +modules while keeping the same public entrypoint and preserving all current +behavior. + +**Architecture:** Keep `apps/server/src/lib/content-api.ts` as the only public +import surface and move its internals into `apps/server/src/lib/content-api/`. +Extract shared pure helpers first, then the in-memory store, DB-backed store, +and route mounting, with the existing content API test suite acting as the +behavioral safety net throughout the refactor. + +**Tech Stack:** Bun, TypeScript, Elysia route handlers, Drizzle ORM, +postgres.js, node:test, Nx + +--- + +### Task 1: Extract Shared Types and Helper Modules + +**Files:** + +- Create: `apps/server/src/lib/content-api/types.ts` +- Create: `apps/server/src/lib/content-api/parsing.ts` +- Create: `apps/server/src/lib/content-api/responses.ts` +- Modify: `apps/server/src/lib/content-api.ts` +- Test: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing test** + +Use the existing regression suite unchanged as the failing test: + +```bash +bun test apps/server/src/lib/content-api.test.ts +``` + +Expected after the extraction starts but before imports are fixed: +FAIL due to missing exports or moved helper references. + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL for import/reference errors caused by the in-progress extraction. + +**Step 3: Write minimal implementation** + +- move shared types into `types.ts` +- move input/query parsing helpers into `parsing.ts` +- move response serializers and row conversion helpers into `responses.ts` +- update `content-api.ts` to import and re-export the same public API surface + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS with no behavior changes. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.ts apps/server/src/lib/content-api/types.ts apps/server/src/lib/content-api/parsing.ts apps/server/src/lib/content-api/responses.ts +git commit -m "refactor(server): extract shared content api helpers" +``` + +### Task 2: Extract the In-Memory Content Store + +**Files:** + +- Create: `apps/server/src/lib/content-api/in-memory-store.ts` +- Modify: `apps/server/src/lib/content-api.ts` +- Test: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing test** + +Use the existing in-memory store coverage in: + +- `apps/server/src/lib/content-api.test.ts` + +The same test command is the failing test for extraction errors. + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL while `createInMemoryContentStore` is partially moved and imports +are not yet wired correctly. + +**Step 3: Write minimal implementation** + +- move `createInMemoryContentStore` and its private helper functions into + `in-memory-store.ts` +- keep helper ownership local to the in-memory store module +- keep `content-api.ts` re-exporting `createInMemoryContentStore` + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS, including all in-memory route/store behavior. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.ts apps/server/src/lib/content-api/in-memory-store.ts +git commit -m "refactor(server): extract in-memory content store" +``` + +### Task 3: Extract the Database Content Store + +**Files:** + +- Create: `apps/server/src/lib/content-api/database-store.ts` +- Modify: `apps/server/src/lib/content-api.ts` +- Test: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing test** + +Use the existing DB-backed content API coverage in: + +- `apps/server/src/lib/content-api.test.ts` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL while the DB store is partially moved and helper imports are not +fully wired. + +**Step 3: Write minimal implementation** + +- move `createDatabaseContentStore` and DB-only helpers into + `database-store.ts` +- keep DB helper ownership local to the DB store module +- keep `content-api.ts` re-exporting `createDatabaseContentStore` + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS, including DB-backed create/update/publish/restore/history +behavior. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.ts apps/server/src/lib/content-api/database-store.ts +git commit -m "refactor(server): extract database content store" +``` + +### Task 4: Extract Route Mounting and Reduce the Entry Point to a Facade + +**Files:** + +- Create: `apps/server/src/lib/content-api/routes.ts` +- Modify: `apps/server/src/lib/content-api.ts` +- Test: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing test** + +Use the existing HTTP-level content API tests in: + +- `apps/server/src/lib/content-api.test.ts` + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL while route mounting is partially moved and imports are in flux. + +**Step 3: Write minimal implementation** + +- move `mountContentApiRoutes` into `routes.ts` +- import shared parsing/response helpers from the internal modules +- reduce `content-api.ts` to a thin facade that re-exports: + - `createInMemoryContentStore` + - `createDatabaseContentStore` + - `mountContentApiRoutes` + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS with the same route behavior as before. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.ts apps/server/src/lib/content-api/routes.ts +git commit -m "refactor(server): split content api entrypoint" +``` + +### Task 5: Final Verification + +**Files:** + +- Modify: `apps/server/src/lib/content-api.ts` +- Create: `apps/server/src/lib/content-api/*.ts` +- Test: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Run targeted regression suite** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS + +**Step 2: Run format verification** + +Run: `bun run format:check` +Expected: PASS + +**Step 3: Run workspace verification** + +Run: `bun run check` +Expected: PASS + +**Step 4: Review staged diff** + +Run: `git diff -- apps/server/src/lib/content-api.ts apps/server/src/lib/content-api/` +Expected: Only file decomposition and import rewiring; no contract drift. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.ts apps/server/src/lib/content-api +git commit -m "refactor(server): decompose content api module" +``` + +## Repo Policy Note + +This plan file is intentionally stored in `docs/plans/` as a local planning +artifact and should remain untracked per `AGENTS.md`. diff --git a/.ai/plans/2026-03-13-cms-39-login-backoff-design.md b/.ai/plans/2026-03-13-cms-39-login-backoff-design.md new file mode 100644 index 00000000..19133182 --- /dev/null +++ b/.ai/plans/2026-03-13-cms-39-login-backoff-design.md @@ -0,0 +1,149 @@ +# CMS-39 Login Backoff And Failed-Attempt Throttling Design + +Date: 2026-03-13 +Status: approved in chat, local planning artifact only + +## Goal + +Implement deterministic failed-password throttling for MDCMS password-entry flows, with exponential backoff that becomes observable as `429 AUTH_BACKOFF_ACTIVE` responses. + +## Source Of Truth + +- Roadmap task: `CMS-39` in `ROADMAP_TASKS.md` +- Owning auth spec: `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` +- Server auth implementation: `apps/server/src/lib/auth.ts` +- Server auth docs: `apps/server/README.md` + +## Approved Spec Delta + +Add a concrete throttling contract to `SPEC-005`: + +- Failed password login attempts use exponential backoff. +- The protected password-entry routes are: + - `POST /api/v1/auth/login` + - `POST /api/v1/auth/cli/login/authorize` when credentials are submitted +- Backoff state is keyed by normalized email for MVP scope. +- Invalid credentials without an active backoff return: + - status `401` + - code `AUTH_INVALID_CREDENTIALS` +- Requests made while backoff is active return: + - status `429` + - code `AUTH_BACKOFF_ACTIVE` + - header `Retry-After: ` +- Backoff resets on successful password sign-in. +- Backoff also resets after a quiet window of 15 minutes without failed attempts. +- Exponential schedule for MVP: + - attempt 1 sets next delay to `1s` + - attempt 2 sets next delay to `2s` + - attempt 3 sets next delay to `4s` + - attempt 4 sets next delay to `8s` + - attempt 5 sets next delay to `16s` + - attempt 6+ cap at `32s` + +## Server Design + +Keep Better Auth responsible for credential verification and session issuance, but own the throttling contract in MDCMS. + +### Why Not Better Auth Rate Limiting + +- Better Auth has a built-in rate limiter, but MDCMS currently calls `auth.api.signInEmail(...)` server-side in its own wrapper. +- Better Auth documents that server-side `auth.api` requests are not rate limited. +- The built-in limiter is request-path based, while `CMS-39` needs failed-attempt-aware exponential backoff with MDCMS-owned status codes and reset rules. + +### Persistence Model + +Persist throttle state in Postgres so behavior is deterministic across process restarts and test runs. + +Proposed table shape: + +- `login_key` text primary/unique key +- `failure_count` integer +- `first_failed_at` timestamp +- `last_failed_at` timestamp +- `next_allowed_at` timestamp +- `created_at` timestamp +- `updated_at` timestamp + +`login_key` should be the normalized email used for password login attempts. + +### Request Flow + +For `POST /api/v1/auth/login` and password-backed CLI authorize: + +1. Normalize the email. +2. Read the throttle row for that login key. +3. If `next_allowed_at > now`, reject immediately with `429 AUTH_BACKOFF_ACTIVE` and `Retry-After`. +4. If the quiet window has elapsed, treat the row as reset before processing this attempt. +5. Call Better Auth to verify credentials. +6. On success: + - delete or reset the throttle row + - continue with existing session issuance behavior +7. On invalid credentials: + - increment `failure_count` + - compute the next delay using the capped exponential schedule + - set `last_failed_at` and `next_allowed_at` + - return the existing `401 AUTH_INVALID_CREDENTIALS` + +This keeps credential failures and active lockout as separate deterministic states. + +## Endpoint Coverage + +Apply the throttle guard only to password-entry routes owned by `SPEC-005`: + +- `POST /api/v1/auth/login` +- `POST /api/v1/auth/cli/login/authorize` when `email` and `password` are present + +Do not apply it to: + +- `GET /api/v1/auth/cli/login/authorize` +- API key flows +- session reads +- logout/revoke routes +- future SSO providers + +## Error Handling + +- Active backoff returns: + - HTTP `429` + - code `AUTH_BACKOFF_ACTIVE` + - message `Too many failed login attempts. Retry after seconds.` + - `Retry-After` response header +- Invalid credentials outside active backoff keep the existing: + - HTTP `401` + - code `AUTH_INVALID_CREDENTIALS` +- Internal persistence failures remain `500 INTERNAL_ERROR` + +## Testing Strategy + +Primary acceptance coverage should prove: + +- repeated failed password attempts create exponential backoff state +- login requests made during backoff return `429` with `Retry-After` +- successful login clears backoff state +- quiet-window expiry resets the counter +- CLI browser authorize shares the same throttling behavior when credentials are posted + +Suggested test technique: + +- add focused auth tests in `apps/server/src/lib/auth.test.ts` +- inject or control the clock in auth throttling helpers, or temporarily stub `Date.now()` in tests +- validate both response semantics and persisted DB state where that gives better signal + +## Documentation + +- Update `SPEC-005` with the concrete `429` contract. +- Update `apps/server/README.md` with the new auth abuse behavior and operator-visible response semantics. +- Add a short code comment near the throttling helper in `auth.ts` explaining why MDCMS owns this logic instead of relying on Better Auth rate limiting. + +## Validation + +Run from workspace root: + +- `bun test apps/server/src/lib/auth.test.ts` +- `bun test apps/server/src/lib/db/schema.contract.test.ts` +- `bun run format:check` +- `bun run check` + +## Notes + +- `docs/plans/` is intentionally local-only in this repository and should remain untracked. diff --git a/.ai/plans/2026-03-13-cms-39-login-backoff.md b/.ai/plans/2026-03-13-cms-39-login-backoff.md new file mode 100644 index 00000000..6ff662fb --- /dev/null +++ b/.ai/plans/2026-03-13-cms-39-login-backoff.md @@ -0,0 +1,374 @@ +# CMS-39 Login Backoff And Failed-Attempt Throttling Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add deterministic password-login throttling with persisted exponential backoff, `429 AUTH_BACKOFF_ACTIVE` responses during active lockout, and reset-on-success behavior for the MDCMS auth flows owned by `SPEC-005`. + +**Architecture:** Update `SPEC-005` first, then add a small persisted throttle table in the server schema so backoff state survives process restarts and can be tested deterministically. Route both `POST /api/v1/auth/login` and the credential-submission branch of `POST /api/v1/auth/cli/login/authorize` through a shared auth helper that checks active backoff, records failures, clears state on success, and surfaces `Retry-After` on `429`. + +**Tech Stack:** Bun, TypeScript, Elysia, better-auth, Drizzle, Postgres, Bun test + +--- + +### Task 1: Codify The Contract In The Owning Spec + +**Files:** + +- Modify: `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` +- Modify: `apps/server/README.md` + +**Step 1: Update the normative Session Security text** + +Add the approved throttling contract under the existing Session Security section: + +```md +- Failed password login attempts apply exponential backoff keyed by normalized email. +- Active backoff rejects password-entry requests with `AUTH_BACKOFF_ACTIVE` (`429`) and `Retry-After`. +- Successful password sign-in resets the stored backoff state. +``` + +**Step 2: Update the auth endpoint table** + +Modify the deterministic error column for: + +- `POST /api/v1/auth/login` +- `POST /api/v1/auth/cli/login/authorize` + +so the table includes `AUTH_BACKOFF_ACTIVE` (`429`) in addition to `AUTH_INVALID_CREDENTIALS` (`401)`. + +**Step 3: Add the operator-facing server README note** + +Document: + +- which routes are protected +- that `Retry-After` is emitted on active backoff +- that Better Auth rate limiting is not relied on for this contract + +**Step 4: Verify the spec delta against CMS-39 acceptance** + +Confirm these exact points are present before code changes: + +- observable backoff via `429` +- deterministic error code `AUTH_BACKOFF_ACTIVE` +- protected flows limited to password-entry routes +- reset rules: success and quiet-window expiry + +**Step 5: Commit the docs checkpoint if using commit gates** + +```bash +git add docs/specs/SPEC-005-auth-authorization-and-request-routing.md apps/server/README.md +git commit -m "docs: define login backoff contract" +``` + +### Task 2: Add Schema Coverage For Persisted Throttle State + +**Files:** + +- Modify: `apps/server/src/lib/db/schema.ts` +- Create: `apps/server/drizzle/0008_.sql` +- Modify: `apps/server/drizzle/meta/_journal.json` +- Create: `apps/server/drizzle/meta/0008_snapshot.json` +- Modify: `apps/server/src/lib/db/schema.contract.test.ts` + +**Step 1: Add the new Drizzle table definition** + +Add a table like: + +```ts +export const authLoginBackoffs = pgTable( + "auth_login_backoffs", + { + id: uuid().defaultRandom().primaryKey(), + loginKey: text().notNull(), + failureCount: integer().notNull().default(0), + firstFailedAt: timestamp({ withTimezone: true }).notNull(), + lastFailedAt: timestamp({ withTimezone: true }).notNull(), + nextAllowedAt: timestamp({ withTimezone: true }).notNull(), + createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp({ withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + unique("uniq_auth_login_backoffs_login_key").on(table.loginKey), + index("idx_auth_login_backoffs_next_allowed").on(table.nextAllowedAt), + ], +); +``` + +If the team prefers no surrogate `id`, use `loginKey` as the primary key instead, but keep the final schema and migration aligned. + +**Step 2: Write the SQL migration and snapshot** + +Create the migration that adds `auth_login_backoffs` plus the required named index/constraint, then update the journal and latest snapshot under `apps/server/drizzle/meta/`. + +**Step 3: Extend the schema contract test** + +Add `public.auth_login_backoffs` to the expected tables and assert the named unique/index entries: + +```ts +"public.auth_login_backoffs": [ + "id", + "login_key", + "failure_count", + "first_failed_at", + "last_failed_at", + "next_allowed_at", + "created_at", + "updated_at", +] +``` + +**Step 4: Run the schema contract test** + +Run: `bun test apps/server/src/lib/db/schema.contract.test.ts` + +Expected: FAIL until the new table, migration artifacts, and snapshot assertions all match. + +**Step 5: Commit the schema checkpoint** + +```bash +git add apps/server/src/lib/db/schema.ts apps/server/drizzle apps/server/src/lib/db/schema.contract.test.ts +git commit -m "feat(server): add persisted login backoff table" +``` + +### Task 3: Add Red Auth Tests For Backoff Semantics + +**Files:** + +- Modify: `apps/server/src/lib/auth.test.ts` + +**Step 1: Add clock-control helpers for auth throttling tests** + +Prefer a narrow helper local to this test file: + +```ts +async function withMockedNow( + value: number, + run: () => Promise, +): Promise { + const originalNow = Date.now; + Date.now = () => value; + try { + return await run(); + } finally { + Date.now = originalNow; + } +} +``` + +If `auth.ts` already accepts a clock dependency by the time you implement this, use that instead of monkey-patching. + +**Step 2: Add failing tests for direct login** + +Add tests that prove: + +- first invalid password attempt returns `401 AUTH_INVALID_CREDENTIALS` +- immediate retry returns `429 AUTH_BACKOFF_ACTIVE` +- `Retry-After` reflects the stored delay +- advancing beyond the delay allows another credential check +- successful login clears the backoff row + +Example assertion shape: + +```ts +assert.equal(response.status, 429); +assert.equal(body.error.code, "AUTH_BACKOFF_ACTIVE"); +assert.equal(response.headers.get("retry-after"), "1"); +``` + +**Step 3: Add failing tests for CLI authorize** + +Cover the credential-submission branch of `POST /api/v1/auth/cli/login/authorize`: + +- invalid credentials return `401` +- immediate retry while locked returns `429` +- active lockout does not mutate the challenge into an authorized state + +**Step 4: Run the focused auth suite** + +Run: `bun test apps/server/src/lib/auth.test.ts` + +Expected: FAIL on the new throttling assertions because no backoff state exists yet. + +**Step 5: Commit the red-test checkpoint** + +```bash +git add apps/server/src/lib/auth.test.ts +git commit -m "test(server): add CMS-39 login backoff coverage" +``` + +### Task 4: Implement Shared Login Backoff Helpers + +**Files:** + +- Modify: `apps/server/src/lib/auth.ts` + +**Step 1: Add constants and helper functions near the auth constants** + +Add small, explicit constants: + +```ts +const LOGIN_BACKOFF_RESET_WINDOW_MS = 15 * 60 * 1000; +const LOGIN_BACKOFF_DELAYS_SECONDS = [1, 2, 4, 8, 16, 32] as const; +``` + +Add helpers for: + +- normalizing email to a throttle key +- reading the current backoff row +- deciding whether the row has expired into a fresh window +- computing the next delay +- computing `Retry-After` +- upserting failure state +- clearing state on success + +**Step 2: Add a deterministic runtime error for active backoff** + +Throw a `RuntimeError` like: + +```ts +throw new RuntimeError({ + code: "AUTH_BACKOFF_ACTIVE", + message: `Too many failed login attempts. Retry after ${retryAfterSeconds} seconds.`, + statusCode: 429, +}); +``` + +Keep the header emission path separate so the response also includes: + +```ts +{ "retry-after": String(retryAfterSeconds) } +``` + +**Step 3: Route password login through the helper flow** + +Refactor `loginWithEmailPassword(...)` so it: + +1. normalizes the email +2. checks for active backoff before calling Better Auth +3. calls `auth.api.signInEmail(...)` +4. on invalid credentials, records the next backoff state and rethrows `AUTH_INVALID_CREDENTIALS` +5. on success, clears the backoff row before returning the session data + +Avoid changing unrelated session, CSRF, API key, or SSO behavior. + +**Step 4: Re-run the focused auth suite** + +Run: `bun test apps/server/src/lib/auth.test.ts` + +Expected: the direct-login throttling tests now pass; any remaining failures should be limited to route-response header plumbing or CLI-path integration. + +**Step 5: Commit the helper implementation** + +```bash +git add apps/server/src/lib/auth.ts +git commit -m "feat(server): add shared login backoff helpers" +``` + +### Task 5: Surface `429` Headers On Both Password Routes + +**Files:** + +- Modify: `apps/server/src/lib/auth.ts` + +**Step 1: Extend the auth service result shape if needed** + +If `loginWithEmailPassword(...)` needs to return metadata for callers, keep it minimal: + +```ts +type LoginResult = { + session: StudioSession; + setCookie: string; +}; +``` + +For throttled requests, prefer central runtime-error handling with support for extra response headers rather than returning unions from every auth path. + +**Step 2: Ensure `executeWithRuntimeErrorsHandled(...)` can emit `Retry-After`** + +If the runtime error handler already supports extra headers, reuse that. If not, extend the smallest shared error path so a `RuntimeError` can carry response headers without changing unrelated API behavior. + +**Step 3: Verify both routes now share the same throttle behavior** + +Confirm: + +- `POST /api/v1/auth/login` +- password-backed `POST /api/v1/auth/cli/login/authorize` + +both return `429 AUTH_BACKOFF_ACTIVE` with `Retry-After` when blocked. + +**Step 4: Run auth tests again** + +Run: `bun test apps/server/src/lib/auth.test.ts` + +Expected: PASS + +**Step 5: Commit the response-plumbing checkpoint** + +```bash +git add apps/server/src/lib/auth.ts +git commit -m "feat(server): return retry-after for throttled logins" +``` + +### Task 6: Finish Docs, Formatting, And Validation + +**Files:** + +- Modify: `apps/server/README.md` +- Modify: `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` +- Modify: `apps/server/src/lib/auth.ts` +- Modify: `apps/server/src/lib/auth.test.ts` +- Modify: `apps/server/src/lib/db/schema.ts` +- Modify: `apps/server/src/lib/db/schema.contract.test.ts` +- Modify: `apps/server/drizzle/...` + +**Step 1: Add the point-of-use code comment** + +Place one short comment near the throttling helper in `auth.ts` explaining: + +```ts +// MDCMS owns failed-attempt backoff here because server-side auth.api calls are outside Better Auth's built-in rate limiter. +``` + +**Step 2: Run the focused verification commands** + +Run: + +```bash +bun test apps/server/src/lib/auth.test.ts +bun test apps/server/src/lib/db/schema.contract.test.ts +``` + +Expected: PASS + +**Step 3: Run workspace-level verification** + +Run: + +```bash +bun run format:check +bun run check +``` + +Expected: PASS + +If `bun run check` is too broad for local-only blockers unrelated to this task, capture the exact failure and stop before claiming success. + +**Step 4: Inspect git status for task scope hygiene** + +Run: + +```bash +git status --short +``` + +Confirm: + +- only `CMS-39`-relevant tracked files are modified +- local-only paths such as `docs/plans/`, `AGENTS.md`, `ROADMAP_TASKS.md`, `.claude/`, and `.codex/` are not staged + +**Step 5: Commit the final task-scoped implementation** + +```bash +git add docs/specs/SPEC-005-auth-authorization-and-request-routing.md apps/server/README.md apps/server/src/lib/auth.ts apps/server/src/lib/auth.test.ts apps/server/src/lib/db/schema.ts apps/server/src/lib/db/schema.contract.test.ts apps/server/drizzle +git commit -m "feat(server): implement login backoff throttling" +``` diff --git a/.ai/plans/2026-03-14-cms-33-design.md b/.ai/plans/2026-03-14-cms-33-design.md new file mode 100644 index 00000000..78744a5a --- /dev/null +++ b/.ai/plans/2026-03-14-cms-33-design.md @@ -0,0 +1,152 @@ +# CMS-33 Design + +Date: 2026-03-14 +Task: CMS-33 - Implement server module bootstrap + manifest/dependency/collision validation + +## Context + +Roadmap source: + +- `ROADMAP_TASKS.md` CMS-33 + +Owning spec: + +- `docs/specs/SPEC-002-system-architecture-and-extensibility.md` + +Relevant upstream tasks: + +- CMS-9 shared extensibility contracts +- CMS-10 unified module topology +- CMS-13 scoped content DAL helpers + +Relevant downstream tasks: + +- CMS-34 action registry + action catalog + Studio bootstrap endpoints +- CMS-35 extensibility contract validation suite + +## Spec Delta Summary + +No new spec file changes were present in the workspace. CMS-33 is already specified by the owning spec and roadmap. + +The clarified contract for this task is: + +- server startup validates a full module dependency graph +- missing `dependsOn` targets fail startup before route registration +- dependency cycles fail startup +- startup emits one deterministic aggregated validation report instead of stopping at the first violation +- explicit composition-root dependency wiring remains required; no DI container or service locator runtime patterns are introduced + +## Chosen Approach + +Chosen approach: replace the permissive shared module loader path with a strict shared bootstrap pipeline used by both server and CLI internals, while anchoring the user-visible acceptance behavior to server startup. + +Why this approach: + +- it is the cleanest long-term architecture +- it prevents server and CLI from diverging on dependency ordering and validation semantics +- it keeps the hard logic in `@mdcms/shared`, which CMS-34 and CMS-35 can reuse directly + +## Architecture + +The current shared loader/report path is permissive: invalid or incompatible modules become "skipped" entries. CMS-33 requires fail-fast startup validation, so the shared runtime layer becomes the source of truth for strict bootstrap planning. + +The shared bootstrap pipeline will: + +1. Validate module package shape and manifest compatibility. +2. Index modules by `manifest.id`. +3. Detect duplicate module IDs. +4. Validate dependency existence for `dependsOn`. +5. Detect dependency cycles. +6. Compute deterministic topological order, using `manifest.id` as the stable tie-breaker. +7. Project the ordered module graph into runtime-specific plans. +8. Validate runtime-surface collisions, including duplicate server `action.id`. +9. Return either a deterministic ordered plan or a deterministic aggregated bootstrap failure. + +Server and CLI loaders become thin wrappers over the shared strict planner. + +Server-specific behavior for CMS-33: + +- startup throws before action collection +- startup throws before module route registration +- mounted modules continue to receive explicitly assembled dependencies from the composition root + +CLI behavior in this task: + +- consume the same strict planner for ordering and internal consistency +- avoid changing CLI operator-facing semantics unless the refactor makes a small documentation update necessary + +## Failure Model + +Bootstrap violations are collected into one deterministic failure payload with: + +- one stable top-level runtime error code +- a stable, sorted list of violation entries +- machine-usable `details` for unit and integration tests + +Violation kinds expected in scope: + +- `INVALID_PACKAGE` +- `INCOMPATIBLE_MANIFEST` +- `DUPLICATE_MODULE_ID` +- `MISSING_DEPENDENCY` +- `DEPENDENCY_CYCLE` +- `DUPLICATE_ACTION_ID` + +Missing runtime surface is not treated as a bootstrap failure by itself. It only means that a module does not participate in that runtime. + +## Files In Scope + +Shared runtime core: + +- `packages/shared/src/lib/runtime/module-loader-core.ts` +- `packages/shared/src/lib/runtime/module-loader-core.test.ts` +- `packages/shared/src/lib/runtime/index.ts` +- `packages/shared/README.md` + +Server integration: + +- `apps/server/src/lib/module-loader.ts` +- `apps/server/src/lib/module-loader.test.ts` +- `apps/server/src/lib/runtime-with-modules.ts` +- `apps/server/src/lib/runtime-with-modules.test.ts` +- `apps/server/README.md` + +CLI internal alignment: + +- `apps/cli/src/lib/module-loader.ts` +- `apps/cli/src/lib/module-loader.test.ts` +- `apps/cli/src/lib/runtime-with-modules.ts` + +## Test Strategy + +Shared unit coverage: + +- duplicate module IDs +- missing dependencies +- dependency cycle detection +- deterministic topological ordering +- duplicate server action IDs +- deterministic aggregation and sorting of bootstrap errors + +Server integration coverage: + +- invalid graph fails before route registration +- duplicate action IDs fail before action collection +- explicit dependency wiring is still passed through `mount(app, deps)` + +CLI regression coverage: + +- deterministic ordering still holds through the new shared bootstrap pipeline +- runtime-specific extraction still returns expected aliases/formatters/hooks + +## Documentation + +Document at point of use: + +- shared README describes the strict bootstrap planner and deterministic failure semantics +- server README states that module bootstrap now fails startup on manifest, dependency, cycle, and action ID collisions + +## Constraints + +- `docs/plans/` is local-only in this repository and must remain untracked +- user explicitly requested staying in the main workdir instead of creating a worktree diff --git a/.ai/plans/2026-03-14-cms-33-module-bootstrap.md b/.ai/plans/2026-03-14-cms-33-module-bootstrap.md new file mode 100644 index 00000000..f1ce9ec7 --- /dev/null +++ b/.ai/plans/2026-03-14-cms-33-module-bootstrap.md @@ -0,0 +1,360 @@ +# CMS-33 Strict Module Bootstrap Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace permissive module loading with a strict shared bootstrap pipeline and make server startup fail fast on manifest, dependency, cycle, compatibility, and action ID violations before any module routes are registered. + +**Architecture:** Build a runtime-agnostic bootstrap planner in `@mdcms/shared` that validates module packages, computes deterministic dependency ordering, and returns either an ordered runtime plan or an aggregated bootstrap failure. Refactor server and CLI loaders to consume that planner; activate fail-fast startup semantics in the server while preserving explicit composition-root dependency wiring. + +**Tech Stack:** Bun, Nx, TypeScript, node:test, Zod, Elysia + +--- + +### Task 1: Replace permissive shared load reports with a strict bootstrap planner + +**Files:** + +- Modify: `packages/shared/src/lib/runtime/module-loader-core.ts` +- Modify: `packages/shared/src/lib/runtime/module-loader-core.test.ts` +- Modify: `packages/shared/src/lib/runtime/index.ts` +- Test: `packages/shared/src/lib/runtime/module-loader-core.test.ts` + +**Step 1: Write the failing shared bootstrap tests** + +Add tests to `packages/shared/src/lib/runtime/module-loader-core.test.ts` for: + +- duplicate `manifest.id` +- missing `dependsOn` +- dependency cycle detection +- deterministic topological ordering with `manifest.id` tie-breaking +- duplicate server `action.id` +- deterministic aggregated violation ordering + +Use helpers like: + +```ts +const alpha = createModule("alpha", { + server: true, + dependsOn: ["core.system"], +}); + +const beta = createModule("beta", { + server: true, + dependsOn: ["alpha"], +}); + +const plan = buildRuntimeModulePlan([beta, alpha, coreSystem], { + coreVersion: "1.0.0", + runtime: "server", + surface: "server", + logger: createNoopLogger(), +}); + +assert.equal(plan.ok, true); +assert.deepEqual(plan.moduleIds, ["core.system", "alpha", "beta"]); +``` + +And failing cases like: + +```ts +const plan = buildRuntimeModulePlan([a, b], { + coreVersion: "1.0.0", + runtime: "server", + surface: "server", + logger: createNoopLogger(), +}); + +assert.equal(plan.ok, false); +assert.deepEqual( + plan.violations.map((entry) => entry.code), + ["DUPLICATE_MODULE_ID"], +); +``` + +**Step 2: Run the shared test file to verify it fails** + +Run: + +```bash +bun test packages/shared/src/lib/runtime/module-loader-core.test.ts +``` + +Expected: FAIL because `buildRuntimeModulePlan(...)` and strict bootstrap semantics do not exist yet. + +**Step 3: Implement the strict shared planner** + +In `packages/shared/src/lib/runtime/module-loader-core.ts`, replace the skip-oriented model with planner-first types and logic such as: + +```ts +export type ModuleBootstrapViolationCode = + | "INVALID_PACKAGE" + | "INCOMPATIBLE_MANIFEST" + | "DUPLICATE_MODULE_ID" + | "MISSING_DEPENDENCY" + | "DEPENDENCY_CYCLE" + | "DUPLICATE_ACTION_ID"; + +export type ModuleBootstrapViolation = { + code: ModuleBootstrapViolationCode; + moduleId: string; + details: string; +}; + +export type RuntimeModulePlan = + | { + ok: true; + moduleIds: readonly string[]; + loaded: readonly LoadedModule[]; + } + | { + ok: false; + violations: readonly ModuleBootstrapViolation[]; + }; +``` + +Implementation rules: + +- validate package shape and manifest compatibility first +- collect duplicate module IDs before graph traversal +- validate every `dependsOn` target exists +- detect cycles with DFS or Kahn-based cycle detection +- compute deterministic topological ordering using `manifest.id` as tie-breaker +- filter to modules exposing the requested runtime surface +- validate duplicate server `action.id` after ordering +- sort violations deterministically by code, then module ID, then details + +Export the planner from `packages/shared/src/lib/runtime/index.ts`. + +**Step 4: Re-run the shared test file** + +Run: + +```bash +bun test packages/shared/src/lib/runtime/module-loader-core.test.ts +``` + +Expected: PASS. + +**Step 5: Commit the shared bootstrap planner** + +```bash +git add packages/shared/src/lib/runtime/module-loader-core.ts packages/shared/src/lib/runtime/module-loader-core.test.ts packages/shared/src/lib/runtime/index.ts +git commit -m "refactor(shared): add strict module bootstrap planner" +``` + +### Task 2: Refactor server loaders to fail fast before route registration + +**Files:** + +- Modify: `apps/server/src/lib/module-loader.ts` +- Modify: `apps/server/src/lib/module-loader.test.ts` +- Modify: `apps/server/src/lib/runtime-with-modules.ts` +- Modify: `apps/server/src/lib/runtime-with-modules.test.ts` +- Test: `apps/server/src/lib/module-loader.test.ts` +- Test: `apps/server/src/lib/runtime-with-modules.test.ts` + +**Step 1: Write failing server tests** + +Update server tests to assert: + +- duplicate action IDs cause startup failure +- missing dependency targets cause startup failure +- failures happen before `mountLoadedServerModules(...)` +- explicit deps are still passed into `mount(app, deps)` + +Add a mount-guard test like: + +```ts +let mounted = false; + +const invalid = createServerModule("broken", { + dependsOn: ["missing.core"], + onMount: () => { + mounted = true; + }, +}); + +assert.throws( + () => + createServerRequestHandlerWithModules({ + env, + logger, + moduleLoadReport: buildServerModuleLoadReport([invalid], { + coreVersion: "1.0.0", + logger, + }), + }), + /MISSING_DEPENDENCY/, +); + +assert.equal(mounted, false); +``` + +**Step 2: Run the server loader tests to verify they fail** + +Run: + +```bash +bun test apps/server/src/lib/module-loader.test.ts apps/server/src/lib/runtime-with-modules.test.ts +``` + +Expected: FAIL because server code still treats invalid or incompatible modules as skip/report data instead of throwing before startup proceeds. + +**Step 3: Implement strict server bootstrap consumption** + +In `apps/server/src/lib/module-loader.ts`: + +- replace `buildServerModuleLoadReport(...)` internals to call the shared strict planner +- return a validated ordered plan for server modules +- throw one `RuntimeError` for bootstrap failures with a stable top-level code such as `INVALID_MODULE_BOOTSTRAP` + +In `apps/server/src/lib/runtime-with-modules.ts`: + +- build the server module plan before collecting actions +- build the plan before mounting routes +- keep `moduleDeps` explicit and continue passing them directly into each module `mount` + +Shape the failure like: + +```ts +throw new RuntimeError({ + code: "INVALID_MODULE_BOOTSTRAP", + message: "Server module bootstrap failed.", + statusCode: 500, + details: { + violations, + }, +}); +``` + +**Step 4: Re-run the server loader tests** + +Run: + +```bash +bun test apps/server/src/lib/module-loader.test.ts apps/server/src/lib/runtime-with-modules.test.ts +``` + +Expected: PASS. + +**Step 5: Commit the server fail-fast refactor** + +```bash +git add apps/server/src/lib/module-loader.ts apps/server/src/lib/module-loader.test.ts apps/server/src/lib/runtime-with-modules.ts apps/server/src/lib/runtime-with-modules.test.ts +git commit -m "feat(server): fail fast on invalid module bootstrap" +``` + +### Task 3: Move CLI loaders onto the same strict ordered planner + +**Files:** + +- Modify: `apps/cli/src/lib/module-loader.ts` +- Modify: `apps/cli/src/lib/module-loader.test.ts` +- Modify: `apps/cli/src/lib/runtime-with-modules.ts` +- Test: `apps/cli/src/lib/module-loader.test.ts` + +**Step 1: Write failing CLI tests around the new shared semantics** + +Adjust CLI tests so they assert: + +- deterministic ordered module IDs still hold under the strict planner +- aliases, output formatters, and preflight hooks are collected from ordered loaded CLI modules +- invalid-package or missing-dependency planner failures surface predictably if the CLI wrapper receives an invalid candidate set + +**Step 2: Run the CLI loader test to verify it fails** + +Run: + +```bash +bun test apps/cli/src/lib/module-loader.test.ts +``` + +Expected: FAIL because the CLI loader still depends on the old skip-report shape. + +**Step 3: Refactor CLI loaders to consume the shared planner** + +In `apps/cli/src/lib/module-loader.ts` and `apps/cli/src/lib/runtime-with-modules.ts`: + +- swap old report-builder usage for the strict shared planner +- keep the runtime-specific extraction for aliases, output formatters, and preflight hooks +- preserve existing CLI runtime shape as much as possible so CMS-33 does not become a CLI behavior task + +**Step 4: Re-run the CLI loader test** + +Run: + +```bash +bun test apps/cli/src/lib/module-loader.test.ts +``` + +Expected: PASS. + +**Step 5: Commit the CLI planner alignment** + +```bash +git add apps/cli/src/lib/module-loader.ts apps/cli/src/lib/module-loader.test.ts apps/cli/src/lib/runtime-with-modules.ts +git commit -m "refactor(cli): align module bootstrap with shared planner" +``` + +### Task 4: Update documentation and verify the full task scope + +**Files:** + +- Modify: `packages/shared/README.md` +- Modify: `apps/server/README.md` +- Test: `packages/shared/src/lib/runtime/module-loader-core.test.ts` +- Test: `apps/server/src/lib/module-loader.test.ts` +- Test: `apps/server/src/lib/runtime-with-modules.test.ts` +- Test: `apps/cli/src/lib/module-loader.test.ts` + +**Step 1: Update point-of-use docs** + +Document: + +- the strict bootstrap planner and deterministic violation model in `packages/shared/README.md` +- server startup fail-fast semantics in `apps/server/README.md` + +Keep CLI README changes minimal unless a user-facing behavior description actually changes. + +**Step 2: Run focused tests** + +Run: + +```bash +bun test packages/shared/src/lib/runtime/module-loader-core.test.ts +bun test apps/server/src/lib/module-loader.test.ts apps/server/src/lib/runtime-with-modules.test.ts +bun test apps/cli/src/lib/module-loader.test.ts +``` + +Expected: PASS. + +**Step 3: Run required repo checks** + +Run: + +```bash +bun run format:check +bun run check +``` + +Expected: PASS. + +**Step 4: Review git status for task scope hygiene** + +Run: + +```bash +git status --short +``` + +Expected: + +- only CMS-33 code/docs changes are staged +- local-only paths remain unstaged and uncommitted, including `docs/plans/` + +**Step 5: Commit the docs and verification pass** + +```bash +git add packages/shared/README.md apps/server/README.md +git commit -m "docs: record strict module bootstrap behavior" +``` diff --git a/.ai/plans/2026-03-14-cms-34-action-catalog-bootstrap.md b/.ai/plans/2026-03-14-cms-34-action-catalog-bootstrap.md new file mode 100644 index 00000000..923a209d --- /dev/null +++ b/.ai/plans/2026-03-14-cms-34-action-catalog-bootstrap.md @@ -0,0 +1,340 @@ +# CMS-34 Action Catalog and Studio Bootstrap Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Publish the canonical module action catalog and the MVP `module`-mode Studio bootstrap/runtime asset endpoints from the server without pulling loader execution work forward from CMS-60. + +**Architecture:** Extend the existing server request-handler composition so it publishes two canonical backend contracts from startup-prepared data: a deterministic filtered action registry and a cached Studio runtime publication snapshot. Keep shared contract validation in `@mdcms/shared`, artifact generation in `@mdcms/studio`, and HTTP publication/error handling in `@mdcms/server`. + +**Tech Stack:** Bun, Nx, TypeScript, node:test, Elysia, Zod, filesystem APIs + +--- + +### Task 1: Add a server-owned Studio runtime publication helper + +**Files:** + +- Create: `apps/server/src/lib/studio-bootstrap.ts` +- Create: `apps/server/src/lib/studio-bootstrap.test.ts` +- Modify: `apps/server/src/index.ts` +- Modify: `apps/server/package.json` +- Test: `apps/server/src/lib/studio-bootstrap.test.ts` + +**Step 1: Write the failing publication-helper tests** + +Add tests to `apps/server/src/lib/studio-bootstrap.test.ts` for: + +- building one validated `StudioBootstrapManifest` +- returning `mode: "module"` in the manifest +- resolving the active `buildId` and asset root +- returning asset metadata for an existing runtime file +- returning `undefined` for an unknown `buildId` or missing asset path + +Use a temp directory and a tiny fixture source file so the helper can call the real Studio artifact builder. Model the assertions like: + +```ts +const publication = await createStudioRuntimePublication({ + sourceFile, + outDir, + studioVersion: "1.2.3", +}); + +assert.equal(publication.manifest.mode, "module"); +assert.equal(publication.manifest.buildId, publication.buildId); + +const asset = await publication.getAsset({ + buildId: publication.buildId, + assetPath: publication.entryFile, +}); + +assert.equal(asset?.contentType, "text/javascript; charset=utf-8"); +assert.equal(asset?.absolutePath.endsWith(publication.entryFile), true); +``` + +**Step 2: Run the new publication-helper test file** + +Run: + +```bash +bun test apps/server/src/lib/studio-bootstrap.test.ts +``` + +Expected: FAIL because the helper does not exist yet. + +**Step 3: Implement the publication helper** + +Create `apps/server/src/lib/studio-bootstrap.ts` with a small server-owned abstraction around `buildStudioRuntimeArtifacts(...)`, for example: + +```ts +export type StudioRuntimePublication = { + buildId: string; + entryFile: string; + manifest: StudioBootstrapManifest; + getAsset: (input: { + buildId: string; + assetPath: string; + }) => Promise; +}; +``` + +Implementation rules: + +- call `buildStudioRuntimeArtifacts(...)` once +- force `mode: "module"` unless a future spec changes this task contract +- validate `manifest` with shared validators before returning +- normalize asset paths so callers cannot escape the active build root +- return `undefined` instead of throwing for unknown build IDs or missing files + +Also: + +- export the helper from `apps/server/src/index.ts` +- add `@mdcms/studio` as a workspace dependency in `apps/server/package.json` + +**Step 4: Re-run the publication-helper test file** + +Run: + +```bash +bun test apps/server/src/lib/studio-bootstrap.test.ts +``` + +Expected: PASS. + +**Step 5: Commit the publication helper** + +```bash +git add apps/server/src/lib/studio-bootstrap.ts apps/server/src/lib/studio-bootstrap.test.ts apps/server/src/index.ts apps/server/package.json +git commit -m "feat(server): add studio runtime publication helper" +``` + +### Task 2: Expose `/api/v1/studio/bootstrap` and `/api/v1/studio/assets/:buildId/*` from the shared server handler + +**Files:** + +- Modify: `apps/server/src/lib/server.ts` +- Modify: `apps/server/src/lib/http-utils.ts` +- Modify: `apps/server/src/lib/health.test.ts` +- Test: `apps/server/src/lib/health.test.ts` + +**Step 1: Write the failing server contract tests** + +Extend `apps/server/src/lib/health.test.ts` with tests for: + +- `GET /api/v1/studio/bootstrap` returns `200` with the prepared manifest +- `GET /api/v1/studio/assets/:buildId/*` returns the expected JavaScript bytes and content type +- unknown `buildId` returns a `404 NOT_FOUND` envelope +- missing asset under a known build returns a `404 NOT_FOUND` envelope + +Use a stubbed publication object so the tests stay fast and isolated: + +```ts +const handler = createServerRequestHandler({ + env: baseEnv, + studioRuntimePublication: { + manifest, + buildId: "abc123", + entryFile: "studio-runtime.abc123.mjs", + getAsset: async ({ buildId, assetPath }) => + buildId === "abc123" && assetPath === "studio-runtime.abc123.mjs" + ? { + body: "export const marker = 'runtime';\n", + contentType: "text/javascript; charset=utf-8", + } + : undefined, + }, +}); +``` + +**Step 2: Run the shared server contract tests** + +Run: + +```bash +bun test apps/server/src/lib/health.test.ts +``` + +Expected: FAIL because `createServerRequestHandler(...)` does not support Studio runtime publication routes yet. + +**Step 3: Implement Studio publication routes in the server handler** + +Modify `apps/server/src/lib/server.ts` to accept a new option such as: + +```ts +studioRuntimePublication?: StudioRuntimePublication; +``` + +Then: + +- mount `GET /api/v1/studio/bootstrap` +- mount `GET /api/v1/studio/assets/:buildId/*` +- return plain asset responses for successful asset requests +- convert missing publication or missing files into the standard `NOT_FOUND` server envelope + +Use helper functions in `apps/server/src/lib/http-utils.ts` to create non-JSON responses cleanly, for example: + +```ts +export function createTextResponse( + body: string, + statusCode: number, + contentType: string, +): Response; +``` + +Implementation rules: + +- keep `/api/v1` as the only supported base path +- keep action catalog behavior unchanged +- keep bootstrap and asset routes public per spec +- keep error normalization inside the existing handler flow + +**Step 4: Re-run the server contract tests** + +Run: + +```bash +bun test apps/server/src/lib/health.test.ts +``` + +Expected: PASS. + +**Step 5: Commit the server route publication work** + +```bash +git add apps/server/src/lib/server.ts apps/server/src/lib/http-utils.ts apps/server/src/lib/health.test.ts +git commit -m "feat(server): publish studio bootstrap endpoints" +``` + +### Task 3: Wire module runtime composition to publish actions and Studio runtime together + +**Files:** + +- Modify: `apps/server/src/lib/runtime-with-modules.ts` +- Modify: `apps/server/src/lib/runtime-with-modules.test.ts` +- Test: `apps/server/src/lib/runtime-with-modules.test.ts` + +**Step 1: Write the failing integration tests** + +Extend `apps/server/src/lib/runtime-with-modules.test.ts` so it verifies: + +- bundled module actions still appear at `GET /api/v1/actions` +- bundled module probe routes still return `200` +- `GET /api/v1/studio/bootstrap` returns a validated manifest from the composed runtime +- `GET /api/v1/studio/assets/:buildId/` returns `200` + +Inject a temp output directory into the runtime setup so the test does not depend on repo-global artifact paths. + +**Step 2: Run the runtime integration tests** + +Run: + +```bash +bun test apps/server/src/lib/runtime-with-modules.test.ts +``` + +Expected: FAIL because the composed runtime does not prepare or pass a Studio publication snapshot yet. + +**Step 3: Implement startup publication wiring** + +Modify `apps/server/src/lib/runtime-with-modules.ts` to: + +- build the Studio runtime publication snapshot during server startup +- pass that snapshot into `createServerRequestHandler(...)` +- keep module action collection and route mounting behavior intact + +Recommended shape: + +```ts +const studioRuntimePublication = + awaitOrBuildStudioRuntimePublication(...); + +const handler = createServerRequestHandler({ + ..., + actions, + studioRuntimePublication, + configureApp: (app) => { + mountLoadedServerModules(app, moduleDeps, moduleLoadReport); + }, +}); +``` + +If the current constructor shape is synchronous, refactor only as much as needed to prepare the publication snapshot without widening scope beyond this task. Prefer explicit inputs such as optional `studioRuntimePublication` or `studioRuntimeOptions` over hidden global state. + +**Step 4: Re-run the runtime integration tests** + +Run: + +```bash +bun test apps/server/src/lib/runtime-with-modules.test.ts +``` + +Expected: PASS. + +**Step 5: Commit the runtime composition wiring** + +```bash +git add apps/server/src/lib/runtime-with-modules.ts apps/server/src/lib/runtime-with-modules.test.ts +git commit -m "feat(server): compose module actions with studio publication" +``` + +### Task 4: Document the new contracts and run workspace verification + +**Files:** + +- Modify: `apps/server/README.md` +- Modify: `packages/studio/README.md` + +**Step 1: Update point-of-use documentation** + +Document in `apps/server/README.md`: + +- `GET /api/v1/studio/bootstrap` +- `GET /api/v1/studio/assets/:buildId/*` +- immutable `buildId` asset semantics +- MVP bootstrap mode fixed to `module` + +Document in `packages/studio/README.md`: + +- runtime artifacts are built in `@mdcms/studio` +- publication now happens from `@mdcms/server` +- loader execution/verification remains deferred to CMS-60 + +**Step 2: Run focused tests** + +Run: + +```bash +bun test apps/server/src/lib/studio-bootstrap.test.ts apps/server/src/lib/health.test.ts apps/server/src/lib/runtime-with-modules.test.ts +``` + +Expected: PASS. + +**Step 3: Run format and workspace checks** + +Run: + +```bash +bun run format:check +bun run check +``` + +Expected: PASS. + +**Step 4: Review git status** + +Run: + +```bash +git status --short +``` + +Expected: + +- only task-scoped source/doc changes are staged or ready to stage +- local-only paths such as `docs/plans/`, `AGENTS.md`, `ROADMAP_TASKS.md`, `.claude/`, and `.codex/` remain unstaged and uncommitted + +**Step 5: Commit the docs and final task slice** + +```bash +git add apps/server/README.md packages/studio/README.md +git commit -m "docs: publish cms-34 runtime contract notes" +``` diff --git a/.ai/plans/2026-03-14-cms-34-design.md b/.ai/plans/2026-03-14-cms-34-design.md new file mode 100644 index 00000000..61a53509 --- /dev/null +++ b/.ai/plans/2026-03-14-cms-34-design.md @@ -0,0 +1,191 @@ +# CMS-34 Design + +Date: 2026-03-14 +Task: CMS-34 - Implement action registry + action catalog + Studio bootstrap endpoints + +## Context + +Roadmap source: + +- `ROADMAP_TASKS.md` CMS-34 + +Owning specs: + +- `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` + +Relevant upstream tasks: + +- CMS-5 typed action catalog contract baseline +- CMS-9 shared extensibility contracts +- CMS-10 Studio runtime artifact topology +- CMS-21 content API baseline +- CMS-33 strict server module bootstrap + +Relevant downstream tasks: + +- CMS-35 contract validation suite for extensibility +- CMS-60 `@mdcms/studio` runtime loader + composition surfaces + +## Spec Delta Summary + +No spec file edits were present in the workspace for this task. During planning, the unresolved Studio execution-mode decision in the owning specs was clarified for MVP execution: + +- `/api/v1/studio/bootstrap` must always publish `mode: "module"` in MVP +- `iframe` mode remains deferred to later spec/task work + +That clarification affects the normative Studio bootstrap contract used by `CMS-34` acceptance criteria 1 and 5. + +## Chosen Approach + +Chosen approach: keep shared contracts as the source of truth, add server-side publication of canonical action and Studio runtime metadata, and avoid introducing loader-side runtime execution work that belongs to CMS-60. + +Why this approach: + +- it stays inside `CMS-34` scope +- it builds directly on the existing strict module bootstrap and Studio artifact builder +- it gives downstream Studio loader work a stable backend contract without inventing extra surfaces + +## Architecture + +`CMS-34` is primarily a server publication task. + +The server already has: + +- strict module bootstrap in `apps/server/src/lib/module-loader.ts` +- action catalog route contract source in `apps/server/src/lib/action-catalog-contract.ts` +- runtime composition in `apps/server/src/lib/runtime-with-modules.ts` +- Studio artifact generation in `packages/studio/src/lib/build-runtime.ts` + +This task adds a server-owned publication layer with two responsibilities: + +1. Derive one deterministic in-memory action registry from mounted server module surfaces. +2. Derive one deterministic Studio runtime publication snapshot at startup and expose it over HTTP. + +Recommended shape: + +- keep action metadata validation and ordering in `apps/server/src/lib/server.ts` +- add a server-side Studio publication helper in a new file such as `apps/server/src/lib/studio-bootstrap.ts` +- export any new server-owned route contract helpers from `apps/server/src/index.ts` +- keep runtime artifact generation in `@mdcms/studio`; server depends on that package to publish the built artifact + +This keeps ownership clean: + +- `@mdcms/shared`: contract types and validators +- `@mdcms/studio`: runtime artifact builder +- `@mdcms/server`: HTTP routes, startup publication, filtering, and asset serving + +## Data Flow + +Server startup flow: + +1. Load server modules through the strict bootstrap path. +2. Collect server actions from the loaded module report. +3. Validate and sort the action catalog deterministically by `id`. +4. Build one Studio runtime artifact set using the existing builder, producing: + - `manifest` + - `buildId` + - `entryFile` + - `entryPath` + - `entryUrl` +5. Cache that Studio publication snapshot in server memory for request handling. + +Request flow: + +- `GET /api/v1/actions` + - returns the filtered deterministic list of action catalog items +- `GET /api/v1/actions/:id` + - returns one filtered action item or `NOT_FOUND` +- `GET /api/v1/studio/bootstrap` + - returns the cached validated `StudioBootstrapManifest` +- `GET /api/v1/studio/assets/:buildId/*` + - serves only files under the cached immutable artifact root for the current `buildId` + +The server should not rebuild the runtime artifact on every request. Publication happens once at startup and request handlers read the prepared snapshot. + +## Visibility and Authorization Handling + +The current action metadata contract exposes advisory `permissions`, plus optional `studio` and `cli` visibility metadata. It does not define a full executable authorization model for catalog filtering. + +For `CMS-34`, the server should keep action visibility filtering injectable and deterministic: + +- continue using `isActionVisible` in `createServerRequestHandler(...)` +- ensure list/detail responses use the same filtering decision +- avoid adding new action contract fields or embedding executable metadata + +This keeps the backend contract aligned with the current spec and leaves stricter policy evolution to later auth/runtime tasks. + +## Failure Model + +Startup failures: + +- invalid action catalog item shape fails startup +- duplicate action IDs fail startup +- invalid Studio bootstrap manifest fails startup +- missing Studio runtime artifact output fails startup + +Request failures: + +- `/api/v1/actions/:id` returns `404` for missing or filtered actions +- `/api/v1/studio/bootstrap` returns `404` only when no runtime publication is available +- `/api/v1/studio/assets/:buildId/*` returns `404` for unknown build IDs or missing files + +Error envelopes should remain normalized through existing server error handling. + +## Files In Scope + +Server runtime: + +- `apps/server/src/lib/server.ts` +- `apps/server/src/lib/runtime-with-modules.ts` +- `apps/server/src/lib/action-catalog-contract.ts` +- `apps/server/src/lib/http-utils.ts` +- `apps/server/src/index.ts` + +New server publication helper: + +- `apps/server/src/lib/studio-bootstrap.ts` +- `apps/server/src/lib/studio-bootstrap.test.ts` + +Server verification: + +- `apps/server/src/lib/health.test.ts` +- `apps/server/src/lib/runtime-with-modules.test.ts` + +Dependency and docs: + +- `apps/server/package.json` +- `apps/server/README.md` +- `packages/studio/README.md` + +## Test Strategy + +Server contract tests: + +- deterministic ordering for `/api/v1/actions` +- detail lookup and hidden-action `404` behavior +- inline request/response schema preservation in catalog payloads +- `/api/v1/studio/bootstrap` response validates against shared manifest contract +- bootstrap manifest always reports `mode: "module"` +- `/api/v1/studio/assets/:buildId/*` serves the expected immutable file for the active build +- mismatched build IDs and missing asset paths return `404` + +Runtime integration tests: + +- module probe routes remain mounted and reachable +- `createServerRequestHandlerWithModules(...)` still surfaces the bundled module actions +- server startup prepares one runtime publication snapshot and exposes it through the endpoint layer + +## Documentation + +Document at point of use: + +- `apps/server/README.md` should describe the new Studio runtime publication endpoints and deterministic asset semantics +- `packages/studio/README.md` should note that runtime artifacts are now published by the server while loader execution remains deferred to CMS-60 + +## Constraints + +- `docs/plans/` is local-only in this repository and must remain untracked +- the brainstorming skill normally wants the design doc committed, but repository policy forbids committing `docs/plans/` +- no Studio loader/runtime execution work should be pulled forward from CMS-60 diff --git a/.ai/plans/2026-03-14-week-2-progress-and-sequencing.md b/.ai/plans/2026-03-14-week-2-progress-and-sequencing.md new file mode 100644 index 00000000..adee1308 --- /dev/null +++ b/.ai/plans/2026-03-14-week-2-progress-and-sequencing.md @@ -0,0 +1,202 @@ +# Week 2 Progress and Recommended Completion Sequence + +Date: 2026-03-14 +Sources: + +- Week 2 plan read from the provided screenshot +- Jira status snapshot from `blazity.atlassian.net` +- Dependency and acceptance criteria references from `ROADMAP_TASKS.md` and the owning specs catalog in `docs/specs/README.md` + +## Week 2 Scope From The Screenshot + +Week 2 is labeled `Core Features` and includes six streams: + +| Stream | Label from screenshot | Jira tasks | +| ------ | ----------------------- | ------------------------------------------------------------------------------ | +| D | Project/Env/i18n (9d) | CMS-18, CMS-19, CMS-20 | +| F | Extensibility (12d) | CMS-9, CMS-10, CMS-33, CMS-34, CMS-35 | +| G | Content CRUD (22d) | CMS-21, CMS-22, CMS-23, CMS-24, CMS-25, CMS-26, CMS-27, CMS-28, CMS-29, CMS-32 | +| I | Authz + API Keys (9.5d) | CMS-42, CMS-43, CMS-44 | +| J | OIDC + SAML (8d) | CMS-40, CMS-41 | +| L | Editor Core (7.5d) | CMS-51, CMS-52 | + +Total Week 2 tasks: 25 + +## Jira Snapshot + +Snapshot date: 2026-03-14 + +Overall status: + +| Status | Count | +| ----------- | ----- | +| Done | 13 | +| To Do | 12 | +| In Progress | 0 | +| Total | 25 | + +Overall completion: 52% + +### Progress By Stream + +| Stream | Total | Done | Remaining | Progress | +| -------------------- | ----: | ---: | --------: | -------: | +| D - Project/Env/i18n | 3 | 3 | 0 | 100% | +| F - Extensibility | 5 | 2 | 3 | 40% | +| G - Content CRUD | 10 | 4 | 6 | 40% | +| I - Authz + API Keys | 3 | 3 | 0 | 100% | +| J - OIDC + SAML | 2 | 0 | 2 | 0% | +| L - Editor Core | 2 | 1 | 1 | 50% | + +## Completed Tasks + +| Task | Summary | +| ------ | ----------------------------------------------------------------------------------------------- | +| CMS-9 | Define shared extensibility contracts in `@mdcms/shared` + Studio runtime contracts | +| CMS-10 | Introduce unified module topology (server/cli) + Studio runtime artifact topology | +| CMS-18 | Environments API CRUD + default `production` provisioning | +| CMS-19 | Implement project model and project-scoped data boundaries | +| CMS-20 | Implement locale data model and translation-group operations | +| CMS-21 | Implement content CRUD/list endpoints with filters/sort/pagination | +| CMS-22 | Implement draft/publish/unpublish semantics | +| CMS-23 | Implement soft-delete/trash/restore endpoints and conflict errors | +| CMS-24 | Implement version history endpoints + restore (`targetStatus=draft/published`) | +| CMS-42 | API key lifecycle (create once reveal, hash at rest, revoke, expiry, labels) | +| CMS-43 | API key operation scopes and deny-by-default authorization | +| CMS-44 | RBAC engine (Owner/Admin/Editor/Viewer; global + project + folder prefix; most permissive wins) | +| CMS-51 | Integrate TipTap baseline and markdown parsing/serialization pipeline | + +## Remaining Tasks + +| Stream | Task | Summary | Jira status | Formal dependencies | +| ------ | ------ | ---------------------------------------------------------------------------- | ----------- | ------------------- | +| F | CMS-33 | Implement server module bootstrap + manifest/dependency/collision validation | To Do | CMS-10, CMS-13 | +| F | CMS-34 | Implement action registry + action catalog + Studio bootstrap endpoints | To Do | CMS-33, CMS-21 | +| F | CMS-35 | Contract validation suite for extensibility | To Do | CMS-34, CMS-6 | +| G | CMS-25 | Enforce published-default reads and draft-only behavior via `draft=true` | To Do | CMS-21, CMS-22 | +| G | CMS-26 | Implement `resolve=` reference expansion scoped to target environment | To Do | CMS-21 | +| G | CMS-27 | Standardize API response envelope + pagination metadata | To Do | CMS-21, CMS-2 | +| G | CMS-28 | Enforce reference field identity contract (env-local `document_id`) | To Do | CMS-20, CMS-26 | +| G | CMS-29 | Enforce schema-hash mismatch protections on write paths | To Do | CMS-17, CMS-21 | +| G | CMS-32 | Full integration suite for lifecycle, routing, uniqueness, and restore paths | To Do | CMS-21..CMS-27 | +| J | CMS-40 | OIDC provider support with fixture matrix | To Do | CMS-36 | +| J | CMS-41 | SAML beta-flag integration path | To Do | CMS-40 | +| L | CMS-52 | Implement MDX-aware markdown tokenizer/renderer integration for editor core | To Do | CMS-51 | + +Cross-phase prerequisite check performed for the remaining tasks: + +| External dependency | Status | +| ------------------- | ------ | +| CMS-2 | Done | +| CMS-6 | Done | +| CMS-13 | Done | +| CMS-17 | Done | +| CMS-36 | Done | + +Conclusion: none of the remaining Week 2 items are currently blocked by the obvious upstream dependencies outside Week 2. + +## Recommended Completion Order + +### Recommended Single-Thread Sequence + +If one team is mainly finishing these serially, this is the cleanest order: + +1. CMS-33 +2. CMS-34 +3. CMS-35 +4. CMS-27 +5. CMS-25 +6. CMS-26 +7. CMS-28 +8. CMS-29 +9. CMS-32 +10. CMS-40 +11. CMS-41 +12. CMS-52 + +### Why This Order + +1. **Finish the extensibility chain first (`CMS-33 -> CMS-34 -> CMS-35`).** + This is the tightest unfinished dependency chain in Week 2 and it has the highest downstream leverage. `CMS-34` is a platform surface that later Studio and CLI work depends on. Closing this chain early stabilizes module loading, action catalog behavior, and the Studio bootstrap contract before more features stack on top. + +2. **Lock the content API contract before adding the remaining behavior (`CMS-27`).** + `CMS-27` standardizes the envelope and pagination metadata across content APIs. Doing that early reduces rework in tests and client-facing responses while finishing the rest of content core. + +3. **Complete the remaining content behavior in dependency order (`CMS-25`, `CMS-26`, `CMS-28`, `CMS-29`).** + `CMS-25` closes the published-vs-draft read semantics. `CMS-26` must land before `CMS-28`. `CMS-29` is independent once the write path exists and should be completed before the content test gate so mismatch protections are covered in the final behavior set. + +4. **Run the integration gate only after the content surface is actually stable (`CMS-32`).** + Formally, `CMS-32` depends on `CMS-21..CMS-27`. Even so, it is better to schedule it after `CMS-28` and `CMS-29` as well, so the suite reflects the near-final content model and avoids a second round of integration test churn. + +5. **Then finish the narrower verticals (`CMS-40 -> CMS-41`, then `CMS-52`).** + `CMS-40` and `CMS-41` are a self-contained auth chain with no unresolved upstream blockers. `CMS-52` is also isolated because `CMS-51` is already done. These should follow once the broader platform and content contracts are less likely to shift under them. + +## Recommended Parallel Execution Plan + +If the team can run multiple tracks at once, this is the better delivery plan: + +### Track A: Extensibility Foundation + +1. CMS-33 +2. CMS-34 +3. CMS-35 + +Why: + +- Strict dependency chain +- High downstream fan-out into later Studio and CLI work +- Best candidate for concentrated ownership by one engineer/pair + +### Track B: Content Core Closure + +1. CMS-27 +2. CMS-25 +3. CMS-26 +4. CMS-28 +5. CMS-29 +6. CMS-32 + +Why: + +- `CMS-27` locks response contracts first +- `CMS-26` is needed before `CMS-28` +- `CMS-32` is the gate and should be last in the track + +### Track C: Auth Integrations + +1. CMS-40 +2. CMS-41 + +Why: + +- Clean two-step dependency chain +- No current cross-track blocker after `CMS-36` +- Can proceed independently while platform/content work continues + +### Track D: Editor Completion + +1. CMS-52 + +Why: + +- Only depends on `CMS-51`, which is already done +- Narrow surface area +- Safe to run in parallel unless editor contract changes are expected from unresolved platform work + +## Suggested Near-Term Milestone + +The most useful near-term checkpoint is: + +1. `CMS-33`, `CMS-34`, `CMS-35` complete +2. `CMS-27`, `CMS-25`, `CMS-26`, `CMS-28`, `CMS-29` complete +3. `CMS-32` green and gating + +That checkpoint would leave Week 2 with: + +- Extensibility closed +- Content core closed and regression-gated +- Only the auth integration pair (`CMS-40`, `CMS-41`) and the final editor MDX enhancement (`CMS-52`) still open + +## Short Recommendation + +If the goal is to reduce future rework, finish the **extensibility chain first**, then close the **remaining content contract and behavior work**, and only after that spend cycles on **OIDC/SAML** and **MDX editor** completion. If the team has parallel capacity, run **OIDC/SAML** and **CMS-52** beside the main platform/content work instead of waiting for them. diff --git a/.ai/plans/2026-03-16-cms-27-response-envelope-design.md b/.ai/plans/2026-03-16-cms-27-response-envelope-design.md new file mode 100644 index 00000000..4e26b041 --- /dev/null +++ b/.ai/plans/2026-03-16-cms-27-response-envelope-design.md @@ -0,0 +1,114 @@ +# CMS-27 Response Envelope Design + +## Summary + +CMS-27 standardizes content API response envelopes and pagination metadata. +The existing `/api/v1/content` list route already returns the target shape, but +`GET /api/v1/content/:documentId/versions` still returns a bare `{ data: [] }` +payload in the owning content spec and server implementation. + +The approved design treats the versions history endpoint as an in-scope list +response for CMS-27. It will adopt the same pagination contract as +`GET /api/v1/content`: + +- `limit` default `20` +- `limit` max `100` +- `offset` default `0` +- success envelope `{ data, pagination: { total, limit, offset, hasMore } }` +- newest-first version ordering remains unchanged + +## Spec Delta + +- Update `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` + so `GET /api/v1/content/:documentId/versions` accepts `limit` and `offset`. +- Change that endpoint's success contract from + `{ data: DocumentVersionSummary[] }` to + `{ data: DocumentVersionSummary[], pagination: { total, limit, offset, hasMore } }`. +- Keep the shared list-envelope guidance in + `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` unchanged. + +## Architecture + +The implementation stays scoped to content APIs but extracts the content-facing +response types into `@mdcms/shared` so the server and CLI stop duplicating +contract shapes. + +Add a new shared contract module for: + +- `ApiDataEnvelope` +- `ApiPaginatedEnvelope` +- `PaginationMetadata` +- `ContentDocumentResponse` +- `ContentVersionSummaryResponse` +- `ContentVersionDocumentResponse` + +These remain type-only exports. Runtime route ownership, parsing, and +authorization stay in `@mdcms/server`. + +## Server Changes + +- Update the content store interface so `listVersions(...)` returns + `{ rows, total, limit, offset }`, matching the existing `list(...)` contract. +- Parse `limit` and `offset` for the versions listing route with the same + validation rules already used for `/api/v1/content`. +- Add a small route-local helper for building the standard paginated envelope. +- Keep single-document routes shaped as `{ data: ... }`, but type them against + the shared content response contracts. + +## Store Changes + +Both content store implementations must paginate version history after applying +the existing newest-first sort: + +- `apps/server/src/lib/content-api/in-memory-store.ts` +- `apps/server/src/lib/content-api/database-store.ts` + +Each store should return: + +- `rows` +- `total` +- `limit` +- `offset` + +No persistence schema changes are required. + +## Consumer Changes + +`apps/cli/src/lib/pull.ts` and `apps/cli/src/lib/push.ts` currently duplicate +content API payload shapes. They should import the shared content response +types where those shapes already match current server behavior. + +This keeps CMS-27 scoped to content contracts without turning the task into a +monorepo-wide response abstraction effort. + +## Error Handling + +No error semantics change in CMS-27: + +- invalid `limit` or `offset` stays `INVALID_QUERY_PARAM` (`400`) +- missing document stays `NOT_FOUND` (`404`) +- target-routing and authorization failures stay unchanged + +The task changes envelope consistency and pagination metadata only. + +## Testing + +Add or update contract coverage for: + +- versions list response envelope shape +- versions pagination defaults +- versions pagination with custom `limit` and `offset` +- `hasMore` computation + +Keep existing content single-document response assertions aligned with the +shared content response type definitions. + +## Documentation + +Document the public contract changes in: + +- `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` +- `apps/server/README.md` + +`docs/plans/` is local-only in this repository, so this design note is not +intended to be committed. diff --git a/.ai/plans/2026-03-16-cms-27-response-envelope.md b/.ai/plans/2026-03-16-cms-27-response-envelope.md new file mode 100644 index 00000000..76aa62a1 --- /dev/null +++ b/.ai/plans/2026-03-16-cms-27-response-envelope.md @@ -0,0 +1,240 @@ +# CMS-27 Response Envelope Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Standardize content API response contracts for CMS-27 by adding shared content envelope types and paginating the versions history list endpoint. + +**Architecture:** Keep behavior ownership in `@mdcms/server`, but move the content API response shapes into `@mdcms/shared` so server and CLI consumers use one canonical contract. Extend `GET /api/v1/content/:documentId/versions` to use the same `limit`/`offset` and `{ data, pagination }` envelope rules as the main content list endpoint. + +**Tech Stack:** Bun, Nx, TypeScript, Zod, Elysia-style route handlers, Drizzle, Bun test + +--- + +### Task 1: Lock The Spec And Shared Contract Surface + +**Files:** + +- Create: `packages/shared/src/lib/contracts/content-api.ts` +- Modify: `packages/shared/src/index.ts` +- Modify: `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` +- Test: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing test** + +Update the versions-list contract assertion in `apps/server/src/lib/content-api.test.ts` so it expects: + +```ts +assert.equal(Array.isArray(versionsBody.data), true); +assert.deepEqual(versionsBody.pagination, { + total: 1, + limit: 20, + offset: 0, + hasMore: false, +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun --cwd apps/server test ./src/lib/content-api.test.ts` +Expected: FAIL because `GET /api/v1/content/:documentId/versions` currently returns no `pagination` field. + +**Step 3: Write minimal implementation** + +Create `packages/shared/src/lib/contracts/content-api.ts` with the canonical content response types: + +```ts +export type ApiDataEnvelope = { data: T }; + +export type PaginationMetadata = { + total: number; + limit: number; + offset: number; + hasMore: boolean; +}; + +export type ApiPaginatedEnvelope = { + data: T[]; + pagination: PaginationMetadata; +}; +``` + +Also export content-specific response shapes from that module and re-export it from `packages/shared/src/index.ts`. Update `SPEC-003` so the versions listing endpoint explicitly accepts `limit`/`offset` and returns the paginated envelope. + +**Step 4: Run test to verify it still fails for the right reason** + +Run: `bun --cwd apps/server test ./src/lib/content-api.test.ts` +Expected: FAIL remains isolated to route/store behavior, with shared contract types and spec text now in place. + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/contracts/content-api.ts packages/shared/src/index.ts docs/specs/SPEC-003-content-storage-versioning-and-migrations.md apps/server/src/lib/content-api.test.ts +git commit -m "feat: define shared content response contracts" +``` + +### Task 2: Paginate The Versions Route And In-Memory Store + +**Files:** + +- Modify: `apps/server/src/lib/content-api/types.ts` +- Modify: `apps/server/src/lib/content-api/routes.ts` +- Modify: `apps/server/src/lib/content-api/in-memory-store.ts` +- Modify: `apps/server/src/lib/content-api/responses.ts` +- Test: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing test** + +Extend `apps/server/src/lib/content-api.test.ts` with a versions-pagination scenario that creates multiple published versions and asserts: + +```ts +const response = await handler( + new Request( + `http://localhost/api/v1/content/${created.data.documentId}/versions?limit=1&offset=1`, + { headers: scopeHeaders }, + ), +); + +assert.equal(response.status, 200); +assert.equal(body.data.length, 1); +assert.equal(body.pagination.total, 3); +assert.equal(body.pagination.limit, 1); +assert.equal(body.pagination.offset, 1); +assert.equal(body.pagination.hasMore, true); +assert.equal(body.data[0]?.version, 2); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun --cwd apps/server test ./src/lib/content-api.test.ts` +Expected: FAIL because the versions route ignores `limit`/`offset` and returns an array without pagination metadata. + +**Step 3: Write minimal implementation** + +- Change `listVersions(...)` in `apps/server/src/lib/content-api/types.ts` to return: + +```ts +Promise<{ + rows: ContentVersionSummary[]; + total: number; + limit: number; + offset: number; +}>; +``` + +- In `apps/server/src/lib/content-api/routes.ts`, parse `limit` and `offset` from the query and return: + +```ts +{ + data: versions.rows.map((version) => toVersionSummaryResponse(version)), + pagination: { + total: versions.total, + limit: versions.limit, + offset: versions.offset, + hasMore: versions.offset + versions.limit < versions.total, + }, +} +``` + +- In `apps/server/src/lib/content-api/in-memory-store.ts`, sort versions newest-first, slice by `offset` and `limit`, and return metadata with `rows`. +- In `apps/server/src/lib/content-api/responses.ts`, type the response mappers against the new shared contract types. + +**Step 4: Run test to verify it passes** + +Run: `bun --cwd apps/server test ./src/lib/content-api.test.ts` +Expected: PASS for the updated envelope and pagination assertions in the in-memory-backed handler tests. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api/types.ts apps/server/src/lib/content-api/routes.ts apps/server/src/lib/content-api/in-memory-store.ts apps/server/src/lib/content-api/responses.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat: paginate content version history responses" +``` + +### Task 3: Match The Database Store And CLI Consumers To The Shared Contract + +**Files:** + +- Modify: `apps/server/src/lib/content-api/database-store.ts` +- Modify: `apps/cli/src/lib/pull.ts` +- Modify: `apps/cli/src/lib/push.ts` +- Test: `apps/server/src/lib/content-api.test.ts` +- Test: `apps/cli/src/lib/pull.test.ts` +- Test: `apps/cli/src/lib/push.test.ts` + +**Step 1: Write the failing test** + +Add a DB-backed versions listing assertion in `apps/server/src/lib/content-api.test.ts` that exercises the existing database store path and expects paginated metadata for the versions endpoint. Also switch CLI test fixtures and parsing call sites to the shared response types so type errors surface during test/typecheck. + +**Step 2: Run test to verify it fails** + +Run: `bun --cwd apps/server test ./src/lib/content-api.test.ts` +Expected: FAIL in the DB-backed versions listing path because `database-store.ts` still returns a plain array. + +Run: `bun --cwd apps/cli test ./src/lib/pull.test.ts ./src/lib/push.test.ts` +Expected: either PASS with type drift still unaddressed or FAIL/typecheck errors once shared types are wired into the CLI call sites. + +**Step 3: Write minimal implementation** + +- Update `apps/server/src/lib/content-api/database-store.ts` to return `{ rows, total, limit, offset }` for `listVersions(...)` after newest-first sort and slicing. +- Replace the inline content payload and envelope types in `apps/cli/src/lib/pull.ts` and `apps/cli/src/lib/push.ts` with imports from `@mdcms/shared` where they match the current server contract. + +**Step 4: Run tests to verify they pass** + +Run: `bun --cwd apps/server test ./src/lib/content-api.test.ts` +Expected: PASS for both in-memory and database-backed versions list pagination behavior. + +Run: `bun --cwd apps/cli test ./src/lib/pull.test.ts ./src/lib/push.test.ts` +Expected: PASS with CLI consumers still parsing the standardized content contract correctly. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api/database-store.ts apps/cli/src/lib/pull.ts apps/cli/src/lib/push.ts apps/server/src/lib/content-api.test.ts apps/cli/src/lib/pull.test.ts apps/cli/src/lib/push.test.ts +git commit -m "refactor: reuse shared content API contracts" +``` + +### Task 4: Update Operator Docs And Run Full Verification + +**Files:** + +- Modify: `apps/server/README.md` +- Test: `package.json` workspace scripts + +**Step 1: Write the doc update** + +Update `apps/server/README.md` so the content endpoint docs explicitly state: + +- `GET /api/v1/content/:documentId/versions` accepts `limit` and `offset` +- versions history returns `{ data, pagination }` +- default `limit` is `20` and max `100` + +**Step 2: Run focused verification** + +Run: `bun --cwd apps/server test ./src/lib/content-api.test.ts` +Expected: PASS + +Run: `bun --cwd packages/shared test ./src` +Expected: PASS + +Run: `bun --cwd apps/cli test ./src/lib/pull.test.ts ./src/lib/push.test.ts` +Expected: PASS + +**Step 3: Run workspace verification** + +Run: `bun run format:check` +Expected: PASS + +Run: `bun run check` +Expected: PASS + +**Step 4: Confirm git hygiene** + +Run: `git status --short` +Expected: modified task files only; local-only paths such as `AGENTS.md`, `ROADMAP_TASKS.md`, `.codex/`, `.claude/`, and `docs/plans/` must not be staged. + +**Step 5: Commit** + +```bash +git add apps/server/README.md +git commit -m "docs: document paginated content version responses" +``` diff --git a/.ai/plans/2026-03-16-cms-35-contract-validation-design.md b/.ai/plans/2026-03-16-cms-35-contract-validation-design.md new file mode 100644 index 00000000..72cb6188 --- /dev/null +++ b/.ai/plans/2026-03-16-cms-35-contract-validation-design.md @@ -0,0 +1,149 @@ +# CMS-35 Contract Validation Suite Design + +**Task:** CMS-35 - Contract validation suite for extensibility + +**Date:** 2026-03-16 + +## Scope Baseline + +Owning specs: + +- `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- `docs/specs/README.md` + +Roadmap source: + +- `ROADMAP_TASKS.md` (`CMS-35`) + +No spec delta is required for this task. CMS-35 hardens existing extensibility +contracts and registry behavior already defined in the owning specs. + +## Goal + +Add a CI-gated, fixture-driven contract validation suite for extensibility that: + +- validates module manifests, action catalog payloads, and Studio bootstrap + manifests with positive and negative fixtures +- proves deterministic ordering and fail-fast collision behavior for server and + CLI registries +- covers authorization-filtered action catalog behavior without pulling the full + Studio loader into scope +- keeps failure output actionable for contributors and operators + +## In Scope + +- Contract validation for: + - `ModuleManifest` + - `ActionCatalogItem[]` + - `StudioBootstrapManifest` +- Deterministic ordering tests for: + - server module loading and action collection + - CLI module loading and merged aliases/formatters/hooks +- Collision tests for: + - duplicate module IDs + - duplicate action IDs + - conflicting server action routes via `(method, path)` collisions +- Studio bootstrap publication verification for: + - manifest shape + - compatibility bounds + - asset-byte integrity against `integritySha256` + - placeholder signature/key invariants used by the current builder +- Authorization coverage for: + - hidden actions omitted from `/api/v1/actions` + - hidden actions omitted from `/api/v1/actions/:id` + - forced access still rejected by protected server routes + +## Out Of Scope + +- Full `@mdcms/studio` runtime loader implementation +- Loader-side network fetch and runtime execution +- Runtime fallback / rollback / kill-switch behavior +- Final production integrity/signature enforcement behavior for runtime loading +- New extensibility surface contracts beyond those already owned by `SPEC-002` + and `SPEC-006` + +Those behaviors remain scheduled for later work, especially CMS-60 through +CMS-62. + +## Chosen Approach + +Use a fixture-driven contract suite with one small pure verification helper for +Studio bootstrap publications. + +Why this approach: + +- It matches CMS-35 acceptance criteria more directly than scattered ad hoc + tests. +- It keeps CMS-35 in Phase 2 without dragging in loader/runtime execution. +- It creates reusable validation seams that later Studio loader tasks can call + instead of re-deriving integrity and compatibility checks. + +## Package Responsibilities + +### `packages/shared` + +- Own manifest and action-catalog contract fixtures/tests. +- Extend module planner collision detection to include duplicate server action + routes based on `(method, path)`. +- Keep most fixtures local to tests unless a helper must be shared. + +### `packages/studio` + +- Add a pure bootstrap publication verifier. +- Test valid and invalid bootstrap publications with deterministic fixtures. +- Document that verification coverage exists before loader execution is added. + +### `apps/server` + +- Add server-facing tests for authorization-filtered action catalog behavior. +- Add startup failure tests for duplicate action routes. + +### `apps/cli` + +- Prove deterministic merge order for CLI aliases, output formatters, and + preflight hooks across shuffled module inputs. + +### `packages/modules` + +- No production changes expected unless a very small test helper becomes + necessary. + +## Route Collision Interpretation + +CMS-35 acceptance requires collision coverage for conflicting routes, but the +current extensibility contract does not expose an independent route manifest for +arbitrary `mount()` behavior. + +To keep scope aligned with existing contracts, CMS-35 interprets route +collisions as duplicate server action route declarations: + +- same HTTP method +- same action `path` + +This covers the route metadata the extensibility system already owns without +inventing a broader route-introspection contract. + +## Verification Strategy + +Planned validation flow: + +- `packages/shared` unit tests validate positive and negative contract fixtures. +- `packages/shared` and app-level module-loader tests validate deterministic + ordering and collision reporting. +- `packages/studio` unit tests validate bootstrap publication verification with + positive and negative fixtures. +- Existing package `bun test ./src` targets remain the CI entrypoint; no new CI + orchestration layer is introduced in CMS-35. + +## Operator / Contributor Output + +Failure output should remain explicit about: + +- which manifest/action/bootstrap payload failed +- which module or action caused the collision +- which `(method, path)` route pair is duplicated +- which integrity/signature/compatibility condition failed + +The suite should favor deterministic ordering of violations so failures are +stable across repeated runs. diff --git a/.ai/plans/2026-03-16-cms-35-contract-validation-plan.md b/.ai/plans/2026-03-16-cms-35-contract-validation-plan.md new file mode 100644 index 00000000..13d8a66d --- /dev/null +++ b/.ai/plans/2026-03-16-cms-35-contract-validation-plan.md @@ -0,0 +1,282 @@ +# CMS-35 Contract Validation Suite Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a CI-gated extensibility contract suite for module manifests, action catalog payloads, Studio bootstrap publications, and deterministic server/CLI registry behavior without implementing the full Studio loader. + +**Architecture:** Keep contract ownership where it already lives. `@mdcms/shared` continues to own manifest and action-catalog validators plus runtime module planning. `@mdcms/studio` adds a pure bootstrap publication verifier for build outputs. `@mdcms/server` and `@mdcms/cli` extend tests around authorization-filtering, route collisions, and deterministic registry merging without introducing new runtime/plugin abstractions. + +**Tech Stack:** Bun, Nx, TypeScript, Bun test, Elysia, Zod + +--- + +### Task 1: Shared Contract Fixtures And Route Collision Detection + +**Files:** + +- Modify: `packages/shared/src/lib/contracts/extensibility.test.ts` +- Modify: `packages/shared/src/lib/contracts/action-catalog.test.ts` +- Modify: `packages/shared/src/lib/runtime/module-loader-core.ts` +- Modify: `packages/shared/src/lib/runtime/module-loader-core.test.ts` +- Optional create: `packages/shared/src/lib/contracts/extensibility-test-fixtures.ts` + +**Step 1: Add positive and negative manifest fixtures** + +Add fixture values that cover: + +- valid manifest +- empty `id` +- invalid `apiVersion` +- duplicate `dependsOn` +- invalid semver bounds +- inverted `minCoreVersion` / `maxCoreVersion` + +Keep fixtures local to tests unless re-use between multiple test files becomes noisy. + +**Step 2: Add positive and negative action catalog fixtures** + +Add fixture values that cover: + +- valid flattened metadata +- invalid `permissions` +- invalid `studio.form.mode` +- invalid `cli.inputMode` +- non-object `requestSchema` +- non-object `responseSchema` +- invalid list entries in array payloads + +**Step 3: Extend runtime module planning to detect route collisions** + +Update `buildRuntimeModulePlan(...)` in `packages/shared/src/lib/runtime/module-loader-core.ts` to treat duplicate server action route declarations as bootstrap violations when two server actions share the same: + +- `method` +- `path` + +Add a new deterministic violation code only if needed; otherwise reuse the existing violation structure with a route-specific details payload. + +**Step 4: Add deterministic route-collision tests** + +In `packages/shared/src/lib/runtime/module-loader-core.test.ts`, add tests proving: + +- duplicate route pairs fail planning +- collisions are reported deterministically across repeated runs +- duplicate action IDs and duplicate route pairs are both preserved in violation output + +**Step 5: Run the shared package tests** + +Run: + +```bash +bun test ./packages/shared/src +``` + +Expected: + +- all existing shared tests pass +- new contract and route-collision tests pass + +**Step 6: Commit checkpoint** + +```bash +git add packages/shared/src/lib/contracts/extensibility.test.ts packages/shared/src/lib/contracts/action-catalog.test.ts packages/shared/src/lib/runtime/module-loader-core.ts packages/shared/src/lib/runtime/module-loader-core.test.ts +git commit -m "test(shared): add extensibility contract fixtures" +``` + +Do not stage `docs/plans/` because repository rules keep those files untracked. + +### Task 2: Server And CLI Registry Regression Coverage + +**Files:** + +- Modify: `apps/server/src/lib/module-loader.test.ts` +- Modify: `apps/server/src/lib/runtime-with-modules.test.ts` +- Modify: `apps/server/src/lib/health.test.ts` +- Modify: `apps/cli/src/lib/module-loader.test.ts` +- Modify: `apps/cli/src/lib/runtime-with-modules.test.ts` + +**Step 1: Add server route-collision startup tests** + +Extend `apps/server/src/lib/module-loader.test.ts` so server bootstrap fails when two modules expose distinct action IDs but the same `(method, path)` pair. + +Cover: + +- deterministic violation ordering +- actionable error details containing the route pair and both owners where possible + +**Step 2: Add authorization-filter enforcement coverage** + +In `apps/server/src/lib/runtime-with-modules.test.ts` or `apps/server/src/lib/health.test.ts`, add a protected route fixture proving: + +- an unauthorized action is hidden from `/api/v1/actions` +- the hidden action also returns `404` from `/api/v1/actions/:id` +- forcing the actual route still fails by server authorization, proving visibility metadata is advisory only + +Prefer a local test route with an explicit `authorize` gate instead of altering production module behavior. + +**Step 3: Expand CLI deterministic merge-order coverage** + +In CLI tests, prove merged: + +- `actionAliases` +- `outputFormatters` +- `preflightHooks` + +stay stable when input modules are shuffled but dependencies require a specific load order. + +**Step 4: Run targeted server and CLI tests** + +Run: + +```bash +bun test ./apps/server/src/lib/module-loader.test.ts +bun test ./apps/server/src/lib/runtime-with-modules.test.ts +bun test ./apps/server/src/lib/health.test.ts +bun test ./apps/cli/src/lib/module-loader.test.ts +bun test ./apps/cli/src/lib/runtime-with-modules.test.ts +``` + +Expected: + +- new route-collision and authorization tests pass +- deterministic ordering assertions stay stable + +**Step 5: Commit checkpoint** + +```bash +git add apps/server/src/lib/module-loader.test.ts apps/server/src/lib/runtime-with-modules.test.ts apps/server/src/lib/health.test.ts apps/cli/src/lib/module-loader.test.ts apps/cli/src/lib/runtime-with-modules.test.ts +git commit -m "test(server-cli): harden extensibility registry coverage" +``` + +### Task 3: Studio Bootstrap Publication Verification + +**Files:** + +- Modify: `packages/studio/src/lib/build-runtime.ts` +- Modify: `packages/studio/src/lib/build-runtime.test.ts` +- Modify: `packages/studio/src/index.ts` +- Modify: `packages/studio/README.md` +- Optional create: `packages/studio/src/lib/bootstrap-verification.ts` + +**Step 1: Add a pure bootstrap publication verifier** + +Implement a helper that accepts: + +- a `StudioBootstrapManifest` +- the built runtime bytes +- expected loader compatibility input + +It should validate: + +- manifest shape +- compatibility fields +- `integritySha256` matches asset bytes +- placeholder signature/key format remains internally consistent for the current builder + +This helper must not fetch URLs or execute runtime code. + +**Step 2: Add positive and negative bootstrap fixtures** + +Cover: + +- valid publication +- incompatible `minStudioPackageVersion` +- incompatible `minHostBridgeVersion` +- integrity mismatch from mutated runtime bytes +- invalid placeholder signature +- invalid placeholder key id +- invalid manifest shape + +**Step 3: Export only if genuinely useful** + +If later tasks will clearly re-use the helper, export it through `packages/studio/src/index.ts`. Otherwise keep it internal to avoid widening public API unnecessarily. + +**Step 4: Document the verification boundary** + +Update `packages/studio/README.md` to state: + +- build outputs now have contract verification coverage +- loader-side fetch/execution is still deferred to CMS-60+ + +**Step 5: Run studio tests** + +Run: + +```bash +bun test ./packages/studio/src +``` + +Expected: + +- existing Studio tests pass +- bootstrap publication verification tests pass + +**Step 6: Commit checkpoint** + +```bash +git add packages/studio/src/lib/build-runtime.ts packages/studio/src/lib/build-runtime.test.ts packages/studio/src/index.ts packages/studio/README.md +git commit -m "test(studio): verify bootstrap publications" +``` + +### Task 4: Workspace Verification And Completion + +**Files:** + +- No new code files expected + +**Step 1: Run task-specific verification** + +Run: + +```bash +bun test ./packages/shared/src +bun test ./apps/server/src +bun test ./apps/cli/src +bun test ./packages/studio/src +``` + +Expected: + +- all touched package tests pass +- no new flaky behavior appears + +**Step 2: Run required workspace validation** + +Run: + +```bash +bun run format:check +bun run check +``` + +Expected: + +- formatting check passes +- build and typecheck pass across the workspace + +**Step 3: Inspect git status** + +Run: + +```bash +git status --short +``` + +Expected: + +- only task-scoped source changes are staged or modified +- local-only paths remain unstaged and uncommitted: + - `.claude/` + - `.codex/` + - `AGENTS.md` + - `CLAUDE.md` + - `ROADMAP_TASKS.md` + - `EXTENSIBILITY_APPROACH_COMPARISON.md` + - `mcp_servers.json` + - `docs/plans/` + +**Step 4: Final commit** + +```bash +git add packages/shared/src apps/server/src apps/cli/src packages/studio/src packages/studio/README.md +git commit -m "test: add cms-35 extensibility contract validation suite" +``` diff --git a/.ai/plans/2026-03-17-cms-25-published-default-reads.md b/.ai/plans/2026-03-17-cms-25-published-default-reads.md new file mode 100644 index 00000000..24401be7 --- /dev/null +++ b/.ai/plans/2026-03-17-cms-25-published-default-reads.md @@ -0,0 +1,150 @@ +# CMS-25 Published-Default Reads Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Enforce the CMS-25 visibility contract so content reads default to published snapshots, draft reads require `draft=true`, and draft list reads do not leak deleted documents unless explicitly requested. + +**Architecture:** Keep the existing route-level `draft=true` scope selection and published-vs-draft read split, but centralize list-visibility rules so the in-memory and database stores apply the same behavior. Drive the change with server contract tests first, then refactor the shared filtering logic with the narrowest production diff needed to satisfy the spec. + +**Tech Stack:** Bun, TypeScript, Node test runner, Elysia route handlers, Drizzle ORM, postgres.js + +--- + +### Task 1: Lock the visibility contract with failing tests + +**Files:** + +- Modify: `apps/server/src/lib/content-api.test.ts` +- Modify: `apps/server/src/lib/auth.test.ts` + +**Step 1: Write the failing test** + +Add focused tests that prove: + +- `GET /api/v1/content` returns only published snapshots by default. +- `GET /api/v1/content?draft=true` returns mutable heads for published and unpublished docs. +- `GET /api/v1/content?draft=true` excludes deleted docs unless `isDeleted=true` is explicitly requested. +- API-key list reads require `content:read:draft` when `draft=true`. + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts apps/server/src/lib/auth.test.ts` +Expected: FAIL because draft list reads currently include deleted rows and/or missing list-level auth coverage is not enforced by tests yet. + +**Step 3: Write minimal implementation** + +Do not change production code in this task. + +**Step 4: Run test to verify it still fails for the intended reason** + +Run: `bun test apps/server/src/lib/content-api.test.ts apps/server/src/lib/auth.test.ts` +Expected: FAIL with the new visibility-contract assertion, not with a malformed test. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.test.ts apps/server/src/lib/auth.test.ts +git commit -m "test(server): add CMS-25 visibility contract coverage" +``` + +### Task 2: Refactor read visibility across both stores + +**Files:** + +- Modify: `apps/server/src/lib/content-api/database-store.ts` +- Modify: `apps/server/src/lib/content-api/in-memory-store.ts` +- Modify: `apps/server/src/lib/content-api/types.ts` + +**Step 1: Write the failing test** + +Use the failing tests from Task 1 as the active red state. Do not add more production assumptions than those tests require. + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/server/src/lib/content-api.test.ts apps/server/src/lib/auth.test.ts` +Expected: FAIL on the CMS-25 visibility assertions. + +**Step 3: Write minimal implementation** + +Add a shared visibility decision for list reads that: + +- keeps published-default list behavior unchanged, +- returns draft heads only when `draft=true`, +- excludes deleted docs from draft list reads unless `isDeleted=true`, +- still allows explicit `isDeleted=true/false` filtering for trash and non-trash views. + +Keep single-document behavior unchanged except where already covered by the existing spec/tests. + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts apps/server/src/lib/auth.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api/database-store.ts apps/server/src/lib/content-api/in-memory-store.ts apps/server/src/lib/content-api/types.ts apps/server/src/lib/content-api.test.ts apps/server/src/lib/auth.test.ts +git commit -m "feat(server): enforce CMS-25 draft visibility contract" +``` + +### Task 3: Verify task-scoped behavior and workspace health + +**Files:** + +- Modify: `apps/cli/src/lib/pull.test.ts` (only if the server contract change requires adjusted CLI assumptions) + +**Step 1: Write the failing test** + +Only if needed, add a CLI pull regression test showing that default pull still requests `draft=true` and published pull still requests published snapshots. + +**Step 2: Run test to verify it fails** + +Run: `bun test apps/cli/src/lib/pull.test.ts` +Expected: FAIL only if a CLI regression or expectation mismatch is revealed. + +**Step 3: Write minimal implementation** + +Adjust CLI expectations only if the server contract refactor changes observable request semantics. Do not widen scope beyond CMS-25. + +**Step 4: Run test to verify it passes** + +Run: `bun test apps/cli/src/lib/pull.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/cli/src/lib/pull.test.ts +git commit -m "test(cli): keep pull aligned with CMS-25 visibility rules" +``` + +### Task 4: Final verification + +**Files:** + +- No code changes expected + +**Step 1: Run targeted verification** + +Run: `bun test apps/server/src/lib/content-api.test.ts apps/server/src/lib/auth.test.ts apps/cli/src/lib/pull.test.ts` +Expected: PASS + +**Step 2: Run workspace checks required by the repo workflow** + +Run: `bun run format:check` +Expected: PASS + +Run: `bun run check` +Expected: PASS + +**Step 3: Confirm local-only paths stay unstaged** + +Run: `git status --short` +Expected: `docs/plans/`, `ROADMAP_TASKS.md`, `AGENTS.md`, and other local-only files remain untracked and unstaged. + +**Step 4: Commit** + +```bash +git add +git commit -m "feat(server): complete CMS-25" +``` diff --git a/.ai/plans/2026-03-17-cms-26-resolve-reference-expansion-design.md b/.ai/plans/2026-03-17-cms-26-resolve-reference-expansion-design.md new file mode 100644 index 00000000..785f2118 --- /dev/null +++ b/.ai/plans/2026-03-17-cms-26-resolve-reference-expansion-design.md @@ -0,0 +1,116 @@ +# CMS-26 Resolve Reference Expansion Design + +## Context + +CMS-26 owns `resolve=` reference expansion for content reads. The current owning +content spec defines `resolve` only as a list query parameter, while the SDK +docs already show `resolve` on single-document reads. The approved design for +this task closes that gap and defines deterministic read-time behavior for +missing or invalid references. + +## Approved Decisions + +### Endpoint Scope + +`resolve` is supported on every endpoint that returns a full content payload: + +- `GET /api/v1/content` +- `GET /api/v1/content/:documentId` +- `GET /api/v1/content/:documentId/versions/:version` + +`resolve` is not added to `GET /api/v1/content/:documentId/versions` because +that endpoint returns summaries, not full document payloads. + +### Resolution Mode + +- Resolution is shallow only. +- The server expands exactly the fields named in `resolve`. +- The server does not recurse into references inside resolved documents. + +### Scope And Visibility + +- Resolution is always constrained to the explicit `(project, environment)` + request target. +- Published reads resolve against published-visible targets. +- `draft=true` reads resolve against draft-visible targets. +- Version snapshot reads resolve referenced targets using published-visible + reads because that endpoint does not expose `draft=true`. + +### Response Shape + +- Requested reference fields are replaced inline in the returned payload. +- Successfully resolved fields become embedded content document objects. +- Unresolved fields become `null`. +- Each returned document may include an optional top-level `resolveErrors` map. +- `resolveErrors` keys use full field paths, for example + `frontmatter.author` or `frontmatter.hero.author`. +- `resolveErrors` is omitted when there are no resolution failures. + +Recommended unresolved shape: + +```json +{ + "resolveErrors": { + "frontmatter.author": { + "code": "REFERENCE_NOT_FOUND", + "message": "Referenced document could not be resolved in the target project/environment.", + "ref": { + "documentId": "uuid", + "type": "Author" + } + } + } +} +``` + +### Validation And Error Semantics + +- `resolve` accepts repeated query params or equivalent string-array forms. +- Each requested field path must map to a reference field in the resolved schema + for the returned document type. +- Unknown fields, non-reference fields, or fields excluded from the target + environment schema fail deterministically with `INVALID_QUERY_PARAM` (`400`). +- Stored target type metadata remains enforced during expansion. +- Read-time failures for secondary referenced targets do not fail the primary + content read. They yield `null` plus `resolveErrors`. +- `resolveErrors[*].code` must distinguish at least: + - `REFERENCE_NOT_FOUND` + - `REFERENCE_DELETED` + - `REFERENCE_TYPE_MISMATCH` + - `REFERENCE_FORBIDDEN` + +## Required Spec Delta + +### SPEC-003 + +Update the content spec to: + +- add `resolve` to `GET /api/v1/content/:documentId` +- add `resolve` to `GET /api/v1/content/:documentId/versions/:version` +- define shallow-only semantics +- define top-level `resolveErrors` +- define deterministic validation and unresolved-reference behavior + +### SPEC-008 + +Keep the SDK example aligned with the now-normative contract for single-document +reads using `resolve`. + +## Verification Expectations + +CMS-26 implementation should verify: + +- list, single-document, and immutable-version reads all honor the same + `resolve` contract +- version-summary reads remain unchanged +- published reads do not leak draft-only referenced content +- `draft=true` reads can resolve draft-visible referenced content +- invalid `resolve` paths fail with `INVALID_QUERY_PARAM` +- missing, deleted, type-mismatched, and forbidden referenced targets produce + `null` plus `resolveErrors` +- all reference expansion remains bound to the explicit routed environment + +## Notes + +This design note is local workspace documentation under `docs/plans/` and +should remain untracked per repository rules. diff --git a/.ai/plans/2026-03-17-cms-26-resolve-reference-expansion.md b/.ai/plans/2026-03-17-cms-26-resolve-reference-expansion.md new file mode 100644 index 00000000..b55be415 --- /dev/null +++ b/.ai/plans/2026-03-17-cms-26-resolve-reference-expansion.md @@ -0,0 +1,398 @@ +# CMS-26 Resolve Reference Expansion Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement shallow `resolve=` reference expansion across all full +content read endpoints, with environment-scoped validation and deterministic +`resolveErrors` metadata for unresolved targets. + +**Architecture:** Keep base content reads in the existing content store and add +a dedicated server-side reference-resolution layer for read responses. Validate +requested field paths against schema registry snapshots for the routed +environment, fetch referenced targets through existing content-store reads, and +surface failures as `null` plus top-level `resolveErrors` keyed by full field +paths. + +**Tech Stack:** Bun, Nx, TypeScript, Elysia, Drizzle, Postgres, Zod, Node test + +--- + +### Task 1: Update The Normative Contract + +**Files:** + +- Modify: `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` +- Modify: `docs/specs/SPEC-008-cli-and-sdk.md` +- Modify: `packages/shared/src/lib/contracts/content-api.ts:17-56` + +**Step 1: Update the owning content spec** + +Add the approved `resolve` contract to the endpoint table and query-parameter +sections in `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md`. +Document support for: + +- `GET /api/v1/content` +- `GET /api/v1/content/:documentId` +- `GET /api/v1/content/:documentId/versions/:version` + +Document these rules explicitly: + +- shallow-only resolution +- top-level optional `resolveErrors` +- full-path error keys +- `null` for unresolved fields +- `INVALID_QUERY_PARAM` for unknown/non-reference/excluded paths + +**Step 2: Align the SDK spec** + +Update `docs/specs/SPEC-008-cli-and-sdk.md` so its `cms.get(..., { resolve })` +example and surrounding prose match the normative contract without adding any +new SDK behavior beyond CMS-26. + +**Step 3: Extend the shared API response types** + +Update `packages/shared/src/lib/contracts/content-api.ts` so +`ContentDocumentResponse` and `ContentVersionDocumentResponse` can carry +optional resolution metadata: + +```ts +export type ContentResolveError = { + code: + | "REFERENCE_NOT_FOUND" + | "REFERENCE_DELETED" + | "REFERENCE_TYPE_MISMATCH" + | "REFERENCE_FORBIDDEN"; + message: string; + ref: { + documentId: string; + type: string; + }; +}; + +export type ResolveErrorsMap = Record; +``` + +Then add: + +```ts +resolveErrors?: ResolveErrorsMap; +``` + +to the full-document response types only. + +**Step 4: Run typecheck** + +Run: `bun run typecheck` + +Expected: PASS for shared contract updates before server implementation begins. + +**Step 5: Commit** + +```bash +git add docs/specs/SPEC-003-content-storage-versioning-and-migrations.md docs/specs/SPEC-008-cli-and-sdk.md packages/shared/src/lib/contracts/content-api.ts +git commit -m "docs: define cms-26 resolve contract" +``` + +Do not stage or commit anything under `docs/plans/`. + +### Task 2: Add Failing CMS-26 Integration Coverage + +**Files:** + +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing tests** + +Add targeted DB-backed tests in `apps/server/src/lib/content-api.test.ts` for: + +- list reads resolve a valid reference inline +- single-document reads accept `resolve` and resolve inline +- immutable version reads accept `resolve` and resolve inline +- `GET /api/v1/content/:documentId/versions` ignores `resolve` support and + remains summary-only +- invalid `resolve` field path returns `INVALID_QUERY_PARAM` +- non-reference `resolve` field path returns `INVALID_QUERY_PARAM` +- missing referenced target returns `null` plus + `resolveErrors["frontmatter.author"].code === "REFERENCE_NOT_FOUND"` +- deleted referenced target returns `REFERENCE_DELETED` +- type mismatch between stored ref metadata and fetched target returns + `REFERENCE_TYPE_MISMATCH` +- forbidden referenced target returns `REFERENCE_FORBIDDEN` +- published reads do not expand draft-only referenced targets +- `draft=true` reads can expand draft-visible referenced targets + +Seed schema registry entries with reference metadata in the test fixture, for +example: + +```ts +const resolvedSchema = { + type: "BlogPost", + directory: "content/blog", + localized: true, + fields: { + author: { + kind: "string", + required: false, + nullable: true, + reference: { targetType: "Author" }, + }, + }, +}; +``` + +Also add at least one nested path fixture, such as `frontmatter.hero.author`, +to verify full-path keys in `resolveErrors`. + +**Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +bun test apps/server/src/lib/content-api.test.ts --test-name-pattern "resolve|reference" +``` + +Expected: FAIL because the routes currently accept `resolve` only on list reads +and do not perform any reference expansion. + +**Step 3: Commit the failing tests** + +```bash +git add apps/server/src/lib/content-api.test.ts +git commit -m "test: codify cms-26 resolve behavior" +``` + +### Task 3: Implement Query Parsing And Schema Path Validation + +**Files:** + +- Modify: `apps/server/src/lib/content-api/types.ts:54-190` +- Modify: `apps/server/src/lib/content-api/parsing.ts:1-240` +- Create: `apps/server/src/lib/content-api/reference-resolution.ts` +- Modify: `apps/server/src/lib/content-api.ts` +- Modify: `apps/server/src/lib/runtime-with-modules.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Extend the route dependencies and query parsing** + +Update `apps/server/src/lib/content-api/types.ts` so route options can load the +resolved schema for a type in the routed scope: + +```ts +import type { SchemaRegistryTypeSnapshot } from "@mdcms/shared"; + +export type ContentSchemaLookup = ( + scope: ContentScope, + type: string, +) => Promise; + +export type MountContentApiRoutesOptions = { + store: ContentStore; + authorize: ContentRequestAuthorizer; + requireCsrf: ContentRequestCsrfProtector; + getSchemaSnapshot: ContentSchemaLookup; +}; +``` + +In `apps/server/src/lib/content-api/parsing.ts`, add a parser that normalizes +`resolve` from `string | string[] | undefined` into a deduplicated string array +and rejects empty segments. + +**Step 2: Create the schema-aware resolver helpers** + +Create `apps/server/src/lib/content-api/reference-resolution.ts` with pure +helpers to: + +- normalize field paths to full `frontmatter.*` paths +- walk `SchemaRegistryTypeSnapshot.fields` +- verify each requested path points at a reference field +- extract `{ documentId, type }` from the stored reference value +- build deterministic `RuntimeError` instances for invalid query paths + +Suggested shape: + +```ts +export type ParsedResolveField = { + requestPath: string; + frontmatterPath: string; + segments: string[]; + targetType: string; +}; + +export function parseResolveFields( + rawResolve: string | string[] | undefined, + schema: SchemaRegistryTypeSnapshot, +): ParsedResolveField[] { + // normalize, validate, dedupe, throw INVALID_QUERY_PARAM on invalid input +} +``` + +**Step 3: Wire schema lookup into the real server runtime** + +Update `apps/server/src/lib/runtime-with-modules.ts` so +`mountContentApiRoutes()` receives `getSchemaSnapshot`. The callback should read +`schema_registry_entries.resolved_schema` for the routed `(project, environment, +type)` tuple using the existing database connection. + +Keep the `createHandler()` helper in `apps/server/src/lib/content-api.test.ts` +working by passing a simple stub that returns `undefined` when tests do not +exercise `resolve`. + +**Step 4: Run the targeted tests** + +Run: + +```bash +bun test apps/server/src/lib/content-api.test.ts --test-name-pattern "resolve|reference" +``` + +Expected: FAIL later in the flow because routes still do not perform reference +expansion, but invalid-path cases should now be able to compile against the new +helpers. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api/types.ts apps/server/src/lib/content-api/parsing.ts apps/server/src/lib/content-api/reference-resolution.ts apps/server/src/lib/content-api.ts apps/server/src/lib/runtime-with-modules.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): add cms-26 resolve validation scaffolding" +``` + +### Task 4: Resolve References In Read Responses + +**Files:** + +- Modify: `apps/server/src/lib/content-api/routes.ts:1-240` +- Modify: `apps/server/src/lib/content-api/responses.ts:19-70` +- Modify: `apps/server/src/lib/content-api/reference-resolution.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the response-level resolution flow** + +In `apps/server/src/lib/content-api/reference-resolution.ts`, add a helper that +resolves requested references for a returned document by: + +- validating the root document schema +- reading the stored ref payload from `frontmatter` +- fetching the target through `store.getById(scope, documentId, { draft })` +- authorizing secondary reads against the resolved target path +- returning an updated document payload plus `resolveErrors` + +Suggested shape: + +```ts +export async function resolveDocumentReferences(input: { + document: ContentDocument | ContentVersionDocument; + scope: ContentScope; + resolveFields: ParsedResolveField[]; + draft: boolean; + getById: ContentStore["getById"]; + authorizeDocumentPath: (path: string) => Promise; +}): Promise<{ + frontmatter: Record; + resolveErrors?: ResolveErrorsMap; +}> { + // replace successful fields inline + // set null + resolveErrors for missing/deleted/type mismatch/forbidden +} +``` + +Use full field paths like `frontmatter.author` and `frontmatter.hero.author` +when populating `resolveErrors`. + +**Step 2: Apply resolution to all full-document read endpoints** + +Update `apps/server/src/lib/content-api/routes.ts` so these endpoints parse and +apply `resolve`: + +- `GET /api/v1/content` +- `GET /api/v1/content/:documentId` +- `GET /api/v1/content/:documentId/versions/:version` + +Do not change `GET /api/v1/content/:documentId/versions`. + +For list reads, resolve each returned document independently after the primary +authorization checks succeed. For immutable version reads, resolve referenced +targets via published-mode reads. + +**Step 3: Serialize `resolveErrors`** + +Update `apps/server/src/lib/content-api/responses.ts` so +`toDocumentResponse()` and `toVersionDocumentResponse()` include +`resolveErrors` when present and omit it when absent. + +**Step 4: Run the targeted tests to verify they pass** + +Run: + +```bash +bun test apps/server/src/lib/content-api.test.ts --test-name-pattern "resolve|reference" +``` + +Expected: PASS for the new CMS-26 cases. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api/routes.ts apps/server/src/lib/content-api/responses.ts apps/server/src/lib/content-api/reference-resolution.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): implement cms-26 reference expansion" +``` + +### Task 5: Verify Adjacent Behavior And Finish + +**Files:** + +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Add any missing regression coverage** + +Fill any remaining gaps found while implementing, especially: + +- published-default reads still behave like CMS-25 +- immutable version reads still return unchanged base snapshot fields +- version-summary endpoint stays summary-only +- non-`resolve` reads still omit `resolveErrors` + +**Step 2: Run the focused server test file** + +Run: + +```bash +bun test apps/server/src/lib/content-api.test.ts +``` + +Expected: PASS + +**Step 3: Run workspace validation required by repo policy** + +Run: + +```bash +bun run format:check +bun run check +``` + +Expected: PASS + +**Step 4: Inspect git status** + +Run: + +```bash +git status --short +``` + +Expected: + +- code/spec files for CMS-26 are staged or committed as intended +- local-only paths remain unstaged and untracked: + - `AGENTS.md` + - `ROADMAP_TASKS.md` + - `docs/plans/` + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.test.ts +git commit -m "test: finalize cms-26 regression coverage" +``` + +Do not stage or commit anything under `docs/plans/`. diff --git a/.ai/plans/2026-03-17-cms-28-cms-29-reference-identity-schema-gate-design.md b/.ai/plans/2026-03-17-cms-28-cms-29-reference-identity-schema-gate-design.md new file mode 100644 index 00000000..a97b1f45 --- /dev/null +++ b/.ai/plans/2026-03-17-cms-28-cms-29-reference-identity-schema-gate-design.md @@ -0,0 +1,137 @@ +# CMS-28 CMS-29 Reference Identity And Schema Hash Gate Design + +## Context + +CMS-28 and CMS-29 both land on the same write-path seam in the content API. +CMS-28 needs reference fields to persist stable environment-local document +identities, while CMS-29 needs the server to reject draft-content writes when +the client's local schema hash is missing, unsynced, or stale for the target +environment. + +The approved design combines both tasks so the schema gate runs before any +write-side reference validation. That lets CMS-28 rely on a synced target +schema instead of carrying a permissive fallback that CMS-29 would immediately +remove. + +## Approved Decisions + +### Endpoint Scope + +Schema-hash gating applies only to: + +- `POST /api/v1/content` +- `PUT /api/v1/content/:documentId` + +It does not apply to `DELETE`, `restore`, `restore version`, `publish`, or +`unpublish`. + +### Transport + +Clients send the local schema hash in a dedicated header: + +```http +x-mdcms-schema-hash: +``` + +The hash is request metadata, not content data, so it stays out of JSON bodies. + +### Deterministic Errors + +- Missing or blank header returns `SCHEMA_HASH_REQUIRED` (`400`). +- Missing target schema sync record returns `SCHEMA_NOT_SYNCED` (`409`). +- Non-matching client and server hashes return `SCHEMA_HASH_MISMATCH` (`409`). + +Recommended detail payloads: + +- `SCHEMA_HASH_REQUIRED`: `{ field: "x-mdcms-schema-hash" }` +- `SCHEMA_NOT_SYNCED`: `{ project, environment }` +- `SCHEMA_HASH_MISMATCH`: + `{ project, environment, clientSchemaHash, serverSchemaHash }` + +### Stored Reference Shape + +The current spec set is inconsistent about whether stored reference values are +plain IDs or rich objects. The approved contract resolves that in favor of the +simpler shape: + +- stored `frontmatter` reference values are plain env-local `document_id` UUID + strings +- target type metadata remains schema-owned through `reference("TypeName")` +- read-time `resolve=` expansion still turns stored IDs into inline documents + +### Write Validation Order + +For `POST /api/v1/content` and `PUT /api/v1/content/:documentId`, the server +must: + +1. resolve target routing +2. authorize the request +3. validate `x-mdcms-schema-hash` +4. load the target environment's schema sync record +5. reject `SCHEMA_HASH_REQUIRED`, `SCHEMA_NOT_SYNCED`, or + `SCHEMA_HASH_MISMATCH` when applicable +6. load the target environment's resolved schema for the effective content type +7. validate reference-bearing fields in `frontmatter` +8. persist the draft content + +### Reference Validation Rules + +- Validation walks nested object fields and arrays recursively. +- Only fields declared as `reference(...)` in the resolved schema receive + special validation. +- Each stored reference value must be a UUID string that resolves to a + non-deleted document in the same routed `(project, environment)` scope. +- The referenced document's type must match the schema-declared target type. +- Invalid shape, malformed UUID, missing target, deleted target, out-of-scope + target, and type mismatch all fail the write with `INVALID_INPUT` (`400`). +- If the effective content type does not exist in the target environment's + resolved schema, the write fails with `INVALID_INPUT` (`400`). +- This combined work does not introduce full schema validation for every + non-reference field. It only hardens reference identity plus the schema-hash + write gate. + +## Required Spec Delta + +### SPEC-004 + +Update `docs/specs/SPEC-004-schema-system-and-sync.md` to define the schema +gate for content draft-write endpoints: + +- required `x-mdcms-schema-hash` header +- `SCHEMA_HASH_REQUIRED` (`400`) +- `SCHEMA_NOT_SYNCED` (`409`) +- `SCHEMA_HASH_MISMATCH` (`409`) +- gate runs before content validation and persistence +- stored references are plain env-local `document_id` strings + +### SPEC-003 + +Update `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` to: + +- state that reference fields persist plain env-local `document_id` UUID + strings +- keep `resolve=` as read-time expansion only +- add `x-mdcms-schema-hash` to the `POST /api/v1/content` and + `PUT /api/v1/content/:documentId` request contracts +- add `SCHEMA_HASH_REQUIRED`, `SCHEMA_NOT_SYNCED`, and + `SCHEMA_HASH_MISMATCH` to those endpoint tables +- keep `INVALID_INPUT` (`400`) for reference-validation failures + +## Verification Expectations + +Implementation should verify: + +- create and update reject missing schema hash headers +- create and update reject unsynced target environments +- create and update reject mismatched client/server schema hashes +- matching schema hashes allow writes to continue normally +- reference writes accept valid env-local UUID strings +- reference writes reject malformed UUIDs +- reference writes reject missing, deleted, wrong-type, and out-of-scope targets +- nested object references and arrays of references are both validated +- existing `resolve=` read behavior continues to work with stored string IDs + +## Notes + +This design note is local workspace documentation under `docs/plans/` and +should remain untracked per repository rules. diff --git a/.ai/plans/2026-03-17-cms-28-cms-29-reference-identity-schema-gate.md b/.ai/plans/2026-03-17-cms-28-cms-29-reference-identity-schema-gate.md new file mode 100644 index 00000000..613e34ea --- /dev/null +++ b/.ai/plans/2026-03-17-cms-28-cms-29-reference-identity-schema-gate.md @@ -0,0 +1,381 @@ +# CMS-28 CMS-29 Reference Identity And Schema Hash Gate Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement CMS-28 and CMS-29 together so content create/update writes +require a matching target-environment schema hash and persist reference fields +as validated env-local `document_id` UUID strings. + +**Architecture:** Keep the schema-hash gate at the route layer because it is an +HTTP contract keyed off request headers and target-environment sync state. Keep +reference validation in reusable content-store helpers so DB-backed routes, +in-memory tests, and future write flows all enforce the same storage contract +after the schema gate passes. + +**Tech Stack:** Bun, Nx, TypeScript, Elysia, Drizzle, Postgres, Zod, Node test + +--- + +### Task 1: Publish The Normative Contract + +**Files:** + +- Modify: `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` +- Modify: `docs/specs/SPEC-004-schema-system-and-sync.md` +- Modify: `apps/server/README.md` + +**Step 1: Update the schema-system spec** + +Add the approved CMS-29 write-gate contract to +`docs/specs/SPEC-004-schema-system-and-sync.md`. Document: + +- `x-mdcms-schema-hash` on `POST /api/v1/content` +- `x-mdcms-schema-hash` on `PUT /api/v1/content/:documentId` +- `SCHEMA_HASH_REQUIRED` (`400`) +- `SCHEMA_NOT_SYNCED` (`409`) +- `SCHEMA_HASH_MISMATCH` (`409`) +- stored reference values are plain env-local `document_id` strings + +**Step 2: Update the content spec** + +Update `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` so +the reference section and endpoint table match the approved contract. Keep +`resolve=` as read-time expansion only and keep `INVALID_INPUT` (`400`) as the +reference-validation failure code. + +**Step 3: Update the point-of-use server docs** + +Add a short operator-facing note to `apps/server/README.md` covering: + +- required `x-mdcms-schema-hash` on content create/update +- the three schema-gate errors +- stored reference fields are env-local document ID strings + +**Step 4: Run format check for the doc edits** + +Run: + +```bash +bun run format:check +``` + +Expected: PASS or only unrelated pre-existing failures outside these doc edits. + +**Step 5: Commit** + +```bash +git add docs/specs/SPEC-003-content-storage-versioning-and-migrations.md docs/specs/SPEC-004-schema-system-and-sync.md apps/server/README.md +git commit -m "docs: define cms-28 and cms-29 content write contract" +``` + +Do not stage or commit anything under `docs/plans/`. + +### Task 2: Add Failing Schema-Gate Coverage + +**Files:** + +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Add failing route tests for missing and stale schema state** + +Add DB-backed route tests in `apps/server/src/lib/content-api.test.ts` for: + +- missing `x-mdcms-schema-hash` returns `SCHEMA_HASH_REQUIRED` +- missing target schema sync record returns `SCHEMA_NOT_SYNCED` +- mismatched header and server hash returns `SCHEMA_HASH_MISMATCH` +- matching hash allows create/update to proceed + +Reuse the existing schema-seeding helpers where possible, and add one helper +that can seed a known `schemaHash` for the target environment. + +**Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +bun test apps/server/src/lib/content-api.test.ts --test-name-pattern "schema hash|SCHEMA_HASH|not synced" +``` + +Expected: FAIL because content writes do not currently read or enforce the +schema-hash header. + +**Step 3: Commit the failing tests** + +```bash +git add apps/server/src/lib/content-api.test.ts +git commit -m "test: codify cms-29 schema hash gate" +``` + +### Task 3: Implement The Route-Level Schema Hash Gate + +**Files:** + +- Create: `apps/server/src/lib/content-api/schema-hash.ts` +- Modify: `apps/server/src/lib/content-api/types.ts` +- Modify: `apps/server/src/lib/content-api/parsing.ts` +- Modify: `apps/server/src/lib/content-api/routes.ts` +- Modify: `apps/server/src/lib/runtime-with-modules.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Add a schema-sync lookup contract for content routes** + +Extend `apps/server/src/lib/content-api/types.ts` with a route dependency that +can read the current schema sync state for a routed scope: + +```ts +export type ContentSchemaSyncState = { + schemaHash: string; +}; + +export type ContentSchemaSyncLookup = ( + scope: ContentScope, +) => Promise; +``` + +Add `getSchemaSyncState` to `MountContentApiRoutesOptions`. + +**Step 2: Add header parsing helpers** + +In `apps/server/src/lib/content-api/parsing.ts`, add a small parser that reads +`x-mdcms-schema-hash`, trims it, and returns `undefined` for missing values. +Keep error creation in the gate helper so the parser stays generic. + +**Step 3: Implement the gate helper** + +Create `apps/server/src/lib/content-api/schema-hash.ts` with pure helpers to: + +- read `x-mdcms-schema-hash` +- throw `SCHEMA_HASH_REQUIRED` (`400`) on missing/blank values +- throw `SCHEMA_NOT_SYNCED` (`409`) when no sync state exists +- throw `SCHEMA_HASH_MISMATCH` (`409`) when the client hash differs from the + server hash + +Suggested entry point: + +```ts +export async function assertSchemaHashMatches(input: { + request: Request; + scope: ContentScope; + getSchemaSyncState: ContentSchemaSyncLookup; +}): Promise { + // parse header, load sync state, throw RuntimeError when needed +} +``` + +**Step 4: Wire the gate into create and update routes** + +Update `apps/server/src/lib/content-api/routes.ts` so `POST /api/v1/content` +and `PUT /api/v1/content/:documentId` call `assertSchemaHashMatches(...)` +before store writes. Do not add the gate to delete, restore, publish, or +unpublish routes. + +**Step 5: Wire the real lookup into the runtime** + +Update `apps/server/src/lib/runtime-with-modules.ts` so +`mountContentApiRoutes()` receives a `getSchemaSyncState` callback that reads +the target `(project, environment)` row from `schemaSyncs`. + +For tests that mount routes directly, provide a lightweight stub callback. + +**Step 6: Run the targeted schema-gate tests** + +Run: + +```bash +bun test apps/server/src/lib/content-api.test.ts --test-name-pattern "schema hash|SCHEMA_HASH|not synced" +``` + +Expected: PASS for the new schema-gate cases. + +**Step 7: Commit** + +```bash +git add apps/server/src/lib/content-api/schema-hash.ts apps/server/src/lib/content-api/types.ts apps/server/src/lib/content-api/parsing.ts apps/server/src/lib/content-api/routes.ts apps/server/src/lib/runtime-with-modules.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): add cms-29 schema hash gate" +``` + +### Task 4: Add Failing Reference-Identity Coverage + +**Files:** + +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Add failing create/update tests for reference writes** + +Add DB-backed tests in `apps/server/src/lib/content-api.test.ts` for: + +- valid reference UUID strings succeed on create and update when the schema hash + gate passes +- malformed UUID strings return `INVALID_INPUT` +- non-string reference values return `INVALID_INPUT` +- missing referenced targets return `INVALID_INPUT` +- deleted referenced targets return `INVALID_INPUT` +- wrong-type referenced targets return `INVALID_INPUT` +- nested object references validate recursively +- arrays of references validate recursively + +Reuse the existing CMS-26 schema fixture and extend it with at least one array +reference field so the write helper has both nested-object and array coverage. + +**Step 2: Add direct-store tests for DB and in-memory parity** + +Add focused direct-store tests proving both `createDatabaseContentStore(...)` +and `createInMemoryContentStore(...)` enforce the same reference validation +when schema snapshots are present for the target type. + +**Step 3: Run the targeted tests to verify they fail** + +Run: + +```bash +bun test apps/server/src/lib/content-api.test.ts --test-name-pattern "reference identity|reference write|cms-28" +``` + +Expected: FAIL because writes currently accept arbitrary JSON values in +`frontmatter`. + +**Step 4: Commit the failing tests** + +```bash +git add apps/server/src/lib/content-api.test.ts +git commit -m "test: codify cms-28 reference identity" +``` + +### Task 5: Implement Reusable Reference Validation + +**Files:** + +- Create: `apps/server/src/lib/content-api/reference-validation.ts` +- Modify: `apps/server/src/lib/content-api/database-store.ts` +- Modify: `apps/server/src/lib/content-api/in-memory-store.ts` +- Modify: `apps/server/src/lib/content-api/parsing.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Create a reusable reference-validation helper** + +Create `apps/server/src/lib/content-api/reference-validation.ts` with pure +schema walkers that: + +- traverse nested objects and arrays +- detect reference-bearing fields from `SchemaRegistryTypeSnapshot` +- validate that each stored value is a UUID string +- return full frontmatter paths such as `frontmatter.author` or + `frontmatter.reviewers[0]` + +Suggested shape: + +```ts +export type ReferenceValidationFailure = { + fieldPath: string; + reason: + | "invalid_shape" + | "invalid_uuid" + | "not_found" + | "deleted" + | "type_mismatch"; + targetType: string; + documentId?: string; +}; +``` + +**Step 2: Integrate the helper into the database store** + +Update `apps/server/src/lib/content-api/database-store.ts` so create/update: + +- load the effective type schema for the routed environment +- reject unknown types with `INVALID_INPUT` +- validate reference fields before insert/update +- resolve targets only within the same routed `(project, environment)` +- reject deleted or wrong-type targets with `INVALID_INPUT` + +Keep non-reference field behavior unchanged. + +**Step 3: Integrate the helper into the in-memory store** + +Update `apps/server/src/lib/content-api/in-memory-store.ts` so the in-memory +store enforces the same reference rules when schema snapshots are present. + +This keeps route tests and direct store tests aligned. + +**Step 4: Run the targeted reference tests** + +Run: + +```bash +bun test apps/server/src/lib/content-api.test.ts --test-name-pattern "reference identity|reference write|cms-28" +``` + +Expected: PASS for the new reference-write cases. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api/reference-validation.ts apps/server/src/lib/content-api/database-store.ts apps/server/src/lib/content-api/in-memory-store.ts apps/server/src/lib/content-api/parsing.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): enforce cms-28 reference identity" +``` + +### Task 6: Run Full Verification And Cleanup + +**Files:** + +- Modify: `apps/server/src/lib/content-api.test.ts` +- Modify: `apps/server/src/lib/content-api/routes.ts` +- Modify: `apps/server/src/lib/content-api/database-store.ts` +- Modify: `apps/server/src/lib/content-api/in-memory-store.ts` +- Modify: `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` +- Modify: `docs/specs/SPEC-004-schema-system-and-sync.md` +- Modify: `apps/server/README.md` + +**Step 1: Run the focused server suite** + +Run: + +```bash +bun test apps/server/src/lib/content-api.test.ts +``` + +Expected: PASS for existing content API coverage plus the new CMS-28/CMS-29 +cases. + +**Step 2: Run workspace format check** + +Run: + +```bash +bun run format:check +``` + +Expected: PASS. + +**Step 3: Run the workspace baseline check** + +Run: + +```bash +bun run check +``` + +Expected: PASS. + +**Step 4: Confirm git hygiene** + +Run: + +```bash +git status --short +``` + +Expected: + +- only task-related tracked files are staged or modified +- local-only paths such as `docs/plans/`, `ROADMAP_TASKS.md`, `AGENTS.md`, and + `.codex/` remain unstaged and uncommitted + +**Step 5: Final commit** + +```bash +git add docs/specs/SPEC-003-content-storage-versioning-and-migrations.md docs/specs/SPEC-004-schema-system-and-sync.md apps/server/README.md apps/server/src/lib/content-api/schema-hash.ts apps/server/src/lib/content-api/reference-validation.ts apps/server/src/lib/content-api/types.ts apps/server/src/lib/content-api/parsing.ts apps/server/src/lib/content-api/routes.ts apps/server/src/lib/content-api/database-store.ts apps/server/src/lib/content-api/in-memory-store.ts apps/server/src/lib/runtime-with-modules.ts apps/server/src/lib/content-api.test.ts +git commit -m "feat(server): enforce cms-28 and cms-29 content write contracts" +``` + +Do not stage or commit anything under `docs/plans/`. diff --git a/.ai/plans/2026-03-18-cms-32-content-integration-design.md b/.ai/plans/2026-03-18-cms-32-content-integration-design.md new file mode 100644 index 00000000..d060e566 --- /dev/null +++ b/.ai/plans/2026-03-18-cms-32-content-integration-design.md @@ -0,0 +1,167 @@ +# CMS-32 Content Integration Design + +Date: 2026-03-18 +Task: CMS-32 + +## Goal + +Turn the existing DB-backed content route coverage into a deterministic, CI-gated integration suite for lifecycle, routing, uniqueness, restore, schema-hash, and resolve behaviors. + +## Canonical Inputs + +- `ROADMAP_TASKS.md` CMS-32 +- `docs/specs/SPEC-003-content-storage-versioning-and-migrations.md` +- `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` +- `docs/specs/SPEC-009-i18n-and-environments.md` + +## Spec Delta + +No spec delta is required. CMS-32 only strengthens verification and CI gating for already-specified behavior. + +## Scope + +### In Scope + +- Extract DB-backed content route scenarios into a dedicated integration suite. +- Keep in-memory content route tests for fast contract and smoke coverage. +- Factor shared content test harness code into reusable support helpers. +- Add a deterministic integration runner that boots Docker Compose, waits for services, runs the DB-backed suite, and tears the stack down. +- Wire the new integration suite into the existing root `integration` command and CI job. +- Document the new local operator workflow in the server README. + +### Out of Scope + +- Replacing the content store abstraction. +- Removing `createInMemoryContentStore`. +- Reworking the entire CI workflow structure. +- Redesigning Docker Compose services beyond what is required to run the new suite. + +## Design Decisions + +### 1. Keep the In-Memory Store + +`createInMemoryContentStore` remains in the codebase. + +Its responsibility becomes: + +- fast route-level contract checks +- parsing and validation coverage +- envelope and pagination formatting checks +- basic route/store smoke coverage + +It is no longer the evidence for production persistence semantics. + +### 2. Create a Dedicated DB-Backed Content Integration Suite + +Introduce a new integration-focused test file: + +- `apps/server/src/lib/content-api.integration.test.ts` + +This suite becomes the canonical regression gate for: + +- draft/publish/unpublish lifecycle +- published-default vs `draft=true` reads +- deleted visibility behavior +- version history and immutable snapshots +- restore flows and restore conflict handling +- routing isolation across project/environment scope +- DB uniqueness and conflict mapping +- schema-hash enforcement on real write paths +- DB-backed `resolve` behavior and its failure cases + +### 3. Extract Shared Test Support + +Move reusable DB test harness code into: + +- `apps/server/src/lib/content-api-test-support.ts` + +Expected shared helpers: + +- DB connectivity probe +- authenticated test context creation +- schema registry seeding +- common scope headers +- common request helpers +- deterministic fixture/namespace helpers + +### 4. Make Fixtures Deterministic by Label + +Avoid `Date.now()` / `Math.random()` as the primary fixture naming mechanism inside the extracted integration suite. + +Each test should derive stable labels for: + +- project name +- path segments +- auth email +- schema source ids + +This keeps the suite easier to debug and more repeatable against a fresh DB in CI. + +### 5. Add a Dedicated Integration Runner + +Introduce a root script: + +- `scripts/content-api-integration-check.sh` + +Responsibilities: + +1. Start Docker Compose with build. +2. Wait for `postgres`, `db-migrate`, and `server` readiness. +3. Run the dedicated server integration test command from the host workspace. +4. Tear down the Compose stack and volumes on exit. + +Existing scripts remain: + +- `scripts/compose-health-check.sh` +- `scripts/migration-startup-check.sh` + +### 6. Strengthen the Existing Integration Gate + +Update root `package.json` so `bun run integration` covers: + +1. compose health +2. migration startup/schema validation +3. content API DB integration suite + +The GitHub Actions integration job can continue calling `bun run integration`. + +## Test Ownership Split + +### Fast Route Suite + +`apps/server/src/lib/content-api.test.ts` + +Keeps: + +- in-memory route smoke coverage +- parsing/query validation +- response envelope assertions +- routing-guard smoke behavior +- any storage-independent checks that should stay fast + +### DB Integration Suite + +`apps/server/src/lib/content-api.integration.test.ts` + +Owns: + +- lifecycle and visibility +- restore paths +- routing isolation +- uniqueness/conflict behavior +- schema-hash transport and persistence behavior +- DB-backed `resolve` +- DB race/constraint precedence mapping + +## Verification + +Task completion should be backed by: + +- `bun run format:check` +- `bun run check` +- `bun run integration` + +## Notes + +- `docs/plans/` is local-only in this repository, so this design doc should remain untracked. +- No public API contract changes are introduced; README documentation only needs to describe the new local integration workflow. diff --git a/.ai/plans/2026-03-18-cms-32-content-integration.md b/.ai/plans/2026-03-18-cms-32-content-integration.md new file mode 100644 index 00000000..89e2dd5d --- /dev/null +++ b/.ai/plans/2026-03-18-cms-32-content-integration.md @@ -0,0 +1,418 @@ +# CMS-32 Content Integration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extract a dedicated DB-backed content integration suite, keep fast in-memory route tests, and wire the new suite into the root integration gate and CI path for CMS-32. + +**Architecture:** Split content API test ownership into a fast route-contract suite and a canonical DB-backed integration suite. Reuse a shared support module for test harness concerns, then run the DB suite through a dedicated Compose-backed shell script that becomes part of the root `integration` command. + +**Tech Stack:** Bun, Nx workspace scripts, Node test runner via `bun test`, Docker Compose, Postgres, Elysia route handler tests, Drizzle-backed content store + +--- + +### Task 1: Inventory and Carve Out Shared Test Support + +**Files:** + +- Create: `apps/server/src/lib/content-api-test-support.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` +- Test: `apps/server/src/lib/content-api.test.ts` + +**Step 1: Write the failing test or import boundary** + +Move no DB-backed assertions yet. First add a support module export surface for existing helpers used by both suites: + +- `dbEnv` +- `logger` +- `scopeHeaders` +- `createDatabaseTestContext(...)` +- `seedSchemaRegistryScope(...)` +- request helper utilities needed by extracted tests + +Expected first failure: `content-api.test.ts` cannot compile until imports are updated. + +**Step 2: Run test/typecheck to verify the extraction breaks before fix** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: FAIL or compile error from moved helpers/imports. + +**Step 3: Write minimal implementation** + +Create `apps/server/src/lib/content-api-test-support.ts` by moving shared DB harness code out of `content-api.test.ts`. Keep helper signatures unchanged where possible to minimize churn. + +**Step 4: Run tests to verify the extraction passes** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS for the remaining suite or only DB-skipped tests remain skipped. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api-test-support.ts apps/server/src/lib/content-api.test.ts +git commit -m "test(server): extract content api test support" +``` + +### Task 2: Create the Dedicated DB-Backed Integration Suite Skeleton + +**Files:** + +- Create: `apps/server/src/lib/content-api.integration.test.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` +- Test: `apps/server/src/lib/content-api.integration.test.ts` + +**Step 1: Write the failing integration suite skeleton** + +Create a new integration file that imports the shared support helpers and defines at least one existing DB-backed test copied from `content-api.test.ts`. + +Expected first failure: the new file is not yet covered by a dedicated script and may expose missing imports/helpers. + +**Step 2: Run the new test file directly** + +Run: `bun test apps/server/src/lib/content-api.integration.test.ts` +Expected: FAIL from missing imports, missing helper exports, or copied code that still references old local helpers. + +**Step 3: Write minimal implementation** + +Fix imports and shared helper usage so the integration file runs. Keep `testWithDatabase` behavior for now if needed, but make the file structurally independent from `content-api.test.ts`. + +**Step 4: Re-run the new test file** + +Run: `bun test apps/server/src/lib/content-api.integration.test.ts` +Expected: PASS locally when Postgres is available; otherwise DB tests skip cleanly. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.integration.test.ts apps/server/src/lib/content-api.test.ts +git commit -m "test(server): add content api integration suite skeleton" +``` + +### Task 3: Move Canonical DB Lifecycle, Restore, and Routing Coverage + +**Files:** + +- Modify: `apps/server/src/lib/content-api.integration.test.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` +- Test: `apps/server/src/lib/content-api.integration.test.ts` + +**Step 1: Move the failing DB-backed scenarios** + +Move DB-backed tests for: + +- lifecycle and visibility +- restore flows +- routed project isolation + +Delete the moved DB-backed copies from `content-api.test.ts`. + +Expected first failure: copied tests still depend on old file-local state or helper functions. + +**Step 2: Run only the new integration file** + +Run: `bun test apps/server/src/lib/content-api.integration.test.ts` +Expected: FAIL on missing helper references or broken setup assumptions after the move. + +**Step 3: Write minimal implementation** + +Repair imports, helpers, and setup so moved tests run unchanged in behavior. + +**Step 4: Run the integration file again** + +Run: `bun test apps/server/src/lib/content-api.integration.test.ts` +Expected: PASS with DB available. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.integration.test.ts apps/server/src/lib/content-api.test.ts +git commit -m "test(server): move lifecycle restore and routing integration coverage" +``` + +### Task 4: Move DB Uniqueness, Conflict Mapping, Schema-Hash, and Resolve Coverage + +**Files:** + +- Modify: `apps/server/src/lib/content-api.integration.test.ts` +- Modify: `apps/server/src/lib/content-api.test.ts` +- Test: `apps/server/src/lib/content-api.integration.test.ts` + +**Step 1: Move the remaining DB-backed regression groups** + +Move DB-backed tests for: + +- path conflict and translation variant conflict +- soft-deleted or cross-scope source handling +- race/constraint precedence checks +- schema-hash required/mismatch/not-synced/match cases +- DB-backed `resolve` scenarios + +Expected first failure: copied tests expose helper duplication or ordering assumptions. + +**Step 2: Run the integration file** + +Run: `bun test apps/server/src/lib/content-api.integration.test.ts` +Expected: FAIL until moved imports and local helpers are normalized. + +**Step 3: Write minimal implementation** + +Normalize helper usage and trim `content-api.test.ts` down to fast route tests plus any intentionally retained storage-independent checks. + +**Step 4: Re-run the integration file** + +Run: `bun test apps/server/src/lib/content-api.integration.test.ts` +Expected: PASS with DB available. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.integration.test.ts apps/server/src/lib/content-api.test.ts +git commit -m "test(server): move db content regressions into integration suite" +``` + +### Task 5: Add Deterministic Fixture Labels in the Integration Suite + +**Files:** + +- Modify: `apps/server/src/lib/content-api-test-support.ts` +- Modify: `apps/server/src/lib/content-api.integration.test.ts` +- Test: `apps/server/src/lib/content-api.integration.test.ts` + +**Step 1: Write a failing deterministic helper expectation** + +Introduce helper usage so tests stop directly assembling names with `Date.now()` / `Math.random()` in moved CMS-32 scenarios. + +Expected first failure: compile errors until helper functions exist. + +**Step 2: Run the integration suite** + +Run: `bun test apps/server/src/lib/content-api.integration.test.ts` +Expected: FAIL on missing helper implementations. + +**Step 3: Write minimal implementation** + +Add small deterministic namespace helpers, for example: + +- `createTestNamespace(testId)` +- `scopedPath(testId, suffix)` +- `scopedEmail(testId)` + +Use a simple monotonic or label-driven strategy that is deterministic within the suite and unique per test case. + +**Step 4: Re-run the integration suite** + +Run: `bun test apps/server/src/lib/content-api.integration.test.ts` +Expected: PASS with cleaner, label-driven fixtures. + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api-test-support.ts apps/server/src/lib/content-api.integration.test.ts +git commit -m "test(server): make content integration fixtures deterministic" +``` + +### Task 6: Add a Dedicated Server Integration Command + +**Files:** + +- Modify: `apps/server/package.json` +- Test: `apps/server/package.json` + +**Step 1: Write the failing command path** + +Add a new script entry such as: + +- `test:integration:content` + +that points at `src/lib/content-api.integration.test.ts`. + +Expected first failure: the script may not exist yet when invoked. + +**Step 2: Run the new script** + +Run: `bun run --cwd apps/server test:integration:content` +Expected: FAIL before the script is added. + +**Step 3: Write minimal implementation** + +Add the script in `apps/server/package.json` using the same Bun test conventions already used in the package. + +**Step 4: Re-run the command** + +Run: `bun run --cwd apps/server test:integration:content` +Expected: PASS with DB available, or clean skips when DB is unavailable outside Compose. + +**Step 5: Commit** + +```bash +git add apps/server/package.json +git commit -m "chore(server): add content integration test command" +``` + +### Task 7: Add the Compose-Backed Integration Check Script + +**Files:** + +- Create: `scripts/content-api-integration-check.sh` +- Test: `scripts/content-api-integration-check.sh` + +**Step 1: Write the failing shell script skeleton** + +Create a script that: + +1. starts Docker Compose +2. waits for `postgres` +3. waits for `db-migrate` +4. waits for `server` +5. runs `bun run --cwd apps/server test:integration:content` +6. tears down the stack with volumes on exit + +Expected first failure: shell syntax mistakes or missing readiness helpers. + +**Step 2: Run the script** + +Run: `bash scripts/content-api-integration-check.sh` +Expected: FAIL the first time until readiness and cleanup logic is correct. + +**Step 3: Write minimal implementation** + +Model the script after the existing Compose health and migration scripts. Reuse the same waiting and cleanup patterns rather than inventing new shell behavior. + +**Step 4: Re-run the script** + +Run: `bash scripts/content-api-integration-check.sh` +Expected: PASS and execute the DB-backed suite against a fresh Compose stack. + +**Step 5: Commit** + +```bash +git add scripts/content-api-integration-check.sh +git commit -m "test(ci): add content api integration check" +``` + +### Task 8: Wire the New Check into the Root Integration Gate + +**Files:** + +- Modify: `package.json` +- Test: `package.json` + +**Step 1: Make the integration command fail until the new script is included** + +Update the root `integration` script to append `bash scripts/content-api-integration-check.sh`. + +Expected first failure: `bun run integration` now fails until the new script is correct. + +**Step 2: Run the root integration command** + +Run: `bun run integration` +Expected: FAIL if any of the three checks still break under the new chain. + +**Step 3: Write minimal implementation** + +Adjust the script ordering or shell command if needed so the root integration flow is: + +1. compose health +2. migration startup check +3. content API integration check + +**Step 4: Re-run the root integration command** + +Run: `bun run integration` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add package.json +git commit -m "ci: gate integration on content api db suite" +``` + +### Task 9: Document the Local Operator Workflow + +**Files:** + +- Modify: `apps/server/README.md` +- Test: `apps/server/README.md` + +**Step 1: Add the missing docs block** + +Document: + +- the fast suite command +- the dedicated DB-backed content integration command +- the root integration command that exercises the Compose-backed gate + +Expected first failure: no code failure; this is documentation completion tied to roadmap acceptance. + +**Step 2: Verify docs reflect real commands** + +Run: + +- `bun run --cwd apps/server test` +- `bun run --cwd apps/server test:integration:content` +- `bun run integration` + +Expected: all commands exist and docs text matches them exactly. + +**Step 3: Write minimal implementation** + +Add a concise README section under server testing or content API documentation. + +**Step 4: Re-check formatting** + +Run: `bun x prettier --check apps/server/README.md` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/server/README.md +git commit -m "docs(server): document content integration workflow" +``` + +### Task 10: Final Verification and Hygiene + +**Files:** + +- Modify: `apps/server/src/lib/content-api.test.ts` +- Modify: `apps/server/src/lib/content-api.integration.test.ts` +- Modify: `apps/server/src/lib/content-api-test-support.ts` +- Modify: `apps/server/package.json` +- Modify: `package.json` +- Modify: `apps/server/README.md` +- Create: `scripts/content-api-integration-check.sh` + +**Step 1: Run focused fast suite verification** + +Run: `bun test apps/server/src/lib/content-api.test.ts` +Expected: PASS. + +**Step 2: Run focused DB-backed suite verification** + +Run: `bun run --cwd apps/server test:integration:content` +Expected: PASS when Postgres is reachable. + +**Step 3: Run repo-level verification** + +Run: + +- `bun run format:check` +- `bun run check` +- `bun run integration` + +Expected: PASS. + +**Step 4: Verify change hygiene** + +Run: `git status --short` +Expected: + +- only task-scoped tracked file changes are present +- local-only paths like `AGENTS.md`, `ROADMAP_TASKS.md`, `.codex/`, `.claude/`, and `docs/plans/` remain unstaged/untracked + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/content-api.test.ts apps/server/src/lib/content-api.integration.test.ts apps/server/src/lib/content-api-test-support.ts apps/server/package.json package.json apps/server/README.md scripts/content-api-integration-check.sh +git commit -m "test(server): implement cms-32 content integration gate" +``` diff --git a/.ai/plans/2026-03-18-cms-40-oidc-provider-support-design.md b/.ai/plans/2026-03-18-cms-40-oidc-provider-support-design.md new file mode 100644 index 00000000..081640b3 --- /dev/null +++ b/.ai/plans/2026-03-18-cms-40-oidc-provider-support-design.md @@ -0,0 +1,130 @@ +# CMS-40 OIDC Provider Support Design + +Date: 2026-03-18 +Task: CMS-40 + +## Goal + +Specify a concrete, minimal OIDC integration contract for MDCMS that uses the Better Auth SSO plugin with startup-configured providers, canonical claims mapping, and a deterministic fixture matrix. + +## Canonical Inputs + +- `ROADMAP_TASKS.md` CMS-40 +- `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` +- `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- `docs/adrs/ADR-001-backend-framework-bun-elysia.md` + +## Spec Delta + +`SPEC-005` needs a normative OIDC section that: + +- makes the Better Auth SSO plugin the MDCMS OIDC mechanism +- fixes CMS-40 to startup-configured provider profiles only +- defines the supported provider set for this task +- defines canonical claims mapping and deny-by-default error categories +- adds the OIDC sign-in and callback routes to the auth endpoint family + +## Scope + +### In Scope + +- OIDC via Better Auth SSO plugin +- Static startup provider configuration +- Supported provider profile set: + - `okta` + - `azure-ad` + - `google-workspace` + - `auth0` +- Canonical claims normalization for MDCMS users/sessions +- Deterministic failure categories for config, sign-in initiation, callback, and missing required claims +- Fixture-matrix expectations for the four provider profiles + +### Out of Scope + +- Runtime provider registration +- Studio provider settings UI +- SAML behavior beyond preserving the CMS-41 handoff +- Arbitrary provider IDs outside the four CMS-40 profiles +- Provider-specific operator workflows beyond startup config and restart + +## Design Decisions + +### 1. Standardize on Better Auth SSO Plugin + +MDCMS should explicitly standardize on the Better Auth SSO plugin for OIDC. + +This keeps MDCMS responsible for: + +- allowed provider profiles +- startup config contract +- canonical claim mapping +- deterministic operator and test guarantees + +It leaves the protocol mechanics, callback handling, and discovery logic to Better Auth. + +### 2. Use Startup Configuration Only + +Provider definitions should be static server startup config for CMS-40. + +This avoids: + +- new admin APIs +- secret management UI +- provider CRUD persistence +- runtime validation and rollback workflows + +Changes to provider configuration should require server restart. + +### 3. Keep the Provider Set Closed + +CMS-40 should make the supported provider set explicit and finite: + +- `okta` +- `azure-ad` +- `google-workspace` +- `auth0` + +The fixture matrix then proves support for a known set rather than a vague "OIDC-compatible" promise. + +### 4. Fix the Canonical Claims Mapping + +The provider-specific OIDC profile shape should normalize to a single MDCMS user/session shape: + +- `id <- sub` +- `email <- email` +- `emailVerified <- email_verified` or `false` when missing +- `name <- name`, then `given_name + family_name`, then `preferred_username`, then `email` +- `image <- picture` or `null` + +Missing `sub` or usable `email` should fail sign-in deterministically. + +### 5. Restrict the MDCMS OIDC Surface + +CMS-40 should define MDCMS-supported usage of the Better Auth SSO plugin as: + +- initiate login with `providerId` +- callback handled under `/api/v1/auth/sso/callback/:providerId` +- relative or same-origin callback URLs only + +CMS-40 should not promise: + +- email/domain-based provider discovery +- runtime provider registration +- organization provisioning +- shared redirect URI behavior + +## Verification + +Task completion should be backed by: + +- provider-fixture integration coverage for all four provider profiles +- successful claims normalization assertions across the fixture matrix +- negative coverage for missing claims and unconfigured provider IDs +- `bun run format:check` +- `bun run check` + +## Notes + +- `docs/plans/` is local-only in this repository, so this design doc should remain untracked. +- This design intentionally prefers a closed provider matrix over a generic "any OIDC provider" promise so CMS-40 stays testable and bounded. diff --git a/.ai/plans/2026-03-18-cms-40-oidc-provider-support.md b/.ai/plans/2026-03-18-cms-40-oidc-provider-support.md new file mode 100644 index 00000000..fab62efa --- /dev/null +++ b/.ai/plans/2026-03-18-cms-40-oidc-provider-support.md @@ -0,0 +1,241 @@ +# CMS-40 OIDC Provider Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add startup-configured OIDC provider support to MDCMS using the Better Auth SSO plugin, canonical claims mapping, and a deterministic provider fixture matrix. + +**Architecture:** MDCMS keeps Better Auth SSO as the protocol and callback engine while owning a typed startup config contract, provider allowlist, claims normalization, and fixture-backed regression coverage. The implementation should validate provider config at startup, reject unsupported provider IDs by default, and keep the CMS-40 scope limited to static instance configuration. + +**Tech Stack:** Bun, Nx, TypeScript, Elysia, Better Auth, `@better-auth/sso`, Drizzle, Node test runner + +--- + +### Task 1: Lock the spec and operator contract + +**Files:** + +- Modify: `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` +- Modify: `apps/server/README.md` +- Test: none + +**Step 1: Re-read the approved CMS-40 design and current auth spec** + +Run: `sed -n '1,260p' docs/specs/SPEC-005-auth-authorization-and-request-routing.md` +Expected: current auth section and endpoint table with no OIDC startup-config contract beyond generic SSO mention + +**Step 2: Update the auth spec with the CMS-40 OIDC delta** + +Add the normative sections for: + +- Better Auth SSO plugin as the MDCMS OIDC mechanism +- startup-only provider configuration +- supported provider IDs: `okta`, `azure-ad`, `google-workspace`, `auth0` +- canonical claims mapping +- OIDC sign-in and callback endpoint contracts +- deterministic failure categories + +**Step 3: Update the server README with the operator workflow** + +Document: + +- the `MDCMS_AUTH_OIDC_PROVIDERS` startup env contract +- restart requirement after config changes +- supported provider IDs +- callback URL restrictions + +**Step 4: Run formatting checks for the touched docs** + +Run: `bun run format:check` +Expected: PASS + +**Step 5: Commit** + +```bash +git add docs/specs/SPEC-005-auth-authorization-and-request-routing.md apps/server/README.md +git commit -m "docs(auth): specify CMS-40 oidc provider contract" +``` + +### Task 2: Add typed OIDC startup config parsing + +**Files:** + +- Modify: `apps/server/src/lib/env.ts` +- Modify: `packages/shared/src/lib/runtime/env.ts` (only if shared parsing helpers are needed) +- Modify: `.env.example` +- Test: `packages/shared/src/lib/runtime/env.test.ts` or `apps/server/src/lib/*.test.ts` + +**Step 1: Write the failing env parsing test** + +Add focused tests covering: + +- valid `MDCMS_AUTH_OIDC_PROVIDERS` JSON +- invalid JSON +- unsupported `providerId` +- duplicate provider IDs +- missing required fields + +**Step 2: Run the targeted test to verify red** + +Run: `bun test packages/shared/src/lib/runtime/env.test.ts` +Expected: FAIL on missing OIDC parsing behavior + +**Step 3: Implement minimal OIDC env parsing** + +Add a typed parser for: + +- `MDCMS_AUTH_OIDC_PROVIDERS` +- provider shape: `providerId`, `issuer`, `domain`, `clientId`, `clientSecret` +- optional `scopes` +- optional `trustedOrigins` +- optional discovery overrides for endpoint and token auth method fixes + +Reject malformed or unsupported provider entries with `INVALID_ENV`. + +**Step 4: Re-run the targeted test** + +Run: `bun test packages/shared/src/lib/runtime/env.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/env.ts packages/shared/src/lib/runtime/env.ts packages/shared/src/lib/runtime/env.test.ts .env.example +git commit -m "feat(server): parse startup oidc provider config" +``` + +### Task 3: Wire Better Auth SSO into the server auth service + +**Files:** + +- Modify: `apps/server/package.json` +- Modify: `apps/server/src/lib/auth.ts` +- Test: `apps/server/src/lib/auth.test.ts` + +**Step 1: Write the failing auth-service tests** + +Add tests that prove: + +- configured provider profiles are registered at startup +- unsupported provider IDs are rejected +- callback URL validation is same-origin or relative-path only +- missing required claims fail deterministically + +**Step 2: Run the targeted auth test** + +Run: `bun test apps/server/src/lib/auth.test.ts` +Expected: FAIL on missing SSO/OIDC behavior + +**Step 3: Add the minimal Better Auth SSO wiring** + +Implement: + +- `@better-auth/sso` dependency +- plugin registration in `createAuthService(...)` +- startup provider registration from parsed env config +- canonical claims mapping +- deny-by-default provider allowlist +- callback URL validation + +Do not add runtime registration APIs or Studio settings UI. + +**Step 4: Re-run the targeted auth test** + +Run: `bun test apps/server/src/lib/auth.test.ts` +Expected: PASS for the new OIDC cases and existing auth coverage + +**Step 5: Commit** + +```bash +git add apps/server/package.json bun.lock apps/server/src/lib/auth.ts apps/server/src/lib/auth.test.ts +git commit -m "feat(server): add static oidc provider support" +``` + +### Task 4: Build the provider fixture matrix + +**Files:** + +- Create: `apps/server/src/lib/auth-oidc-fixtures.ts` or a similarly named test helper +- Modify: `apps/server/src/lib/auth.test.ts` +- Test: `apps/server/src/lib/auth.test.ts` + +**Step 1: Write the failing fixture-driven tests** + +Add one test per supported provider profile: + +- `okta` +- `azure-ad` +- `google-workspace` +- `auth0` + +Each fixture should prove: + +- startup config is accepted +- sign-in route targets the expected provider +- canonical claims mapping yields the same MDCMS user fields + +Add negative fixtures for: + +- missing `email` +- missing `sub` +- unconfigured provider ID + +**Step 2: Run the fixture tests to verify red** + +Run: `bun test apps/server/src/lib/auth.test.ts --test-name-pattern="oidc|sso"` +Expected: FAIL until fixture helpers and mappings exist + +**Step 3: Implement the minimal fixture helpers** + +Use deterministic local fixtures, not live SaaS tenants. + +The fixture helpers should: + +- create provider config inputs +- create normalized user-info payloads per provider profile +- assert the same MDCMS user/session output + +**Step 4: Re-run the fixture tests** + +Run: `bun test apps/server/src/lib/auth.test.ts --test-name-pattern="oidc|sso"` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/auth.test.ts apps/server/src/lib/auth-oidc-fixtures.ts +git commit -m "test(server): add oidc provider fixture matrix" +``` + +### Task 5: Verify the full CMS-40 slice + +**Files:** + +- Modify: none unless fixes are required +- Test: existing touched tests + +**Step 1: Run formatting** + +Run: `bun run format:check` +Expected: PASS + +**Step 2: Run workspace checks** + +Run: `bun run check` +Expected: PASS + +**Step 3: Run the targeted auth suite** + +Run: `bun test apps/server/src/lib/auth.test.ts` +Expected: PASS + +**Step 4: Inspect git status** + +Run: `git status --short` +Expected: only task-scoped tracked changes; local-only files such as `docs/plans/` remain unstaged + +**Step 5: Final commit if any verification fixes were needed** + +```bash +git add +git commit -m "test(server): finalize cms-40 verification" +``` diff --git a/.ai/plans/2026-03-20-cms-41-saml-provider-support.md b/.ai/plans/2026-03-20-cms-41-saml-provider-support.md new file mode 100644 index 00000000..3b1d9c61 --- /dev/null +++ b/.ai/plans/2026-03-20-cms-41-saml-provider-support.md @@ -0,0 +1,313 @@ +# CMS-41 SAML Provider Support Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add startup-configured SAML 2.0 provider support to MDCMS using the existing Better Auth SSO plugin, provider-presence enablement, and deterministic ACS/metadata regression coverage. + +**Architecture:** Extend the current OIDC-only startup auth pipeline into a mixed static SSO registry. Keep OIDC and SAML operator config separate in env parsing, normalize both into Better Auth `defaultSSO` providers at startup, and add SAML-specific ACS plus SP metadata coverage without introducing runtime provider management or IdP-initiated flows. + +**Tech Stack:** Bun, Nx, TypeScript, Elysia, Better Auth, `@better-auth/sso`, Drizzle, `samlify`, Node test runner + +--- + +## Spec Delta + +- `docs/specs/SPEC-005-auth-authorization-and-request-routing.md` now defines startup-configured `MDCMS_AUTH_SAML_PROVIDERS`, provider-presence enablement, canonical SAML attribute mapping, and the `/api/v1/auth/sso/saml2/sp/acs/:providerId` and `/api/v1/auth/sso/saml2/sp/metadata` endpoints. +- The affected behavior is in the server auth boundary only: startup env parsing, Better Auth SSO provider registration, shared `POST /api/v1/auth/sign-in/sso`, SAML ACS handling, and SP metadata exposure. +- Acceptance criteria that depend on this delta: + - “disabled by default” now means no SAML providers are available when `MDCMS_AUTH_SAML_PROVIDERS` is absent or empty + - “enabled instances pass login flows” now means configured SAML providers complete SP-initiated sign-in and establish the normal MDCMS session + - deterministic failures must map to `INVALID_ENV`, `SSO_PROVIDER_NOT_CONFIGURED`, `AUTH_SAML_REQUIRED_ATTRIBUTE_MISSING`, and `AUTH_PROVIDER_ERROR` + +## Conflict Note + +- `ROADMAP_TASKS.md` still says `SAML beta-flag integration path`, but the owning spec has removed `beta` and the extra enablement flag. +- Execute against the stricter spec-owned contract, not the stale roadmap wording. + +## Workspace Note + +- The current worktree already has unrelated unstaged changes in `packages/shared/`. +- Implement this plan in a fresh worktree or with strict staged-file discipline so CMS-41 stays isolated. + +### Task 1: Add typed SAML startup config parsing + +**Files:** + +- Modify: `apps/server/src/lib/env.ts` +- Modify: `apps/server/src/lib/env.test.ts` +- Modify: `.env.example` + +**Step 1: Write the failing env parsing tests** + +Add focused tests for: + +- valid `MDCMS_AUTH_SAML_PROVIDERS` parsing +- absent or blank `MDCMS_AUTH_SAML_PROVIDERS` returning `[]` +- malformed JSON +- non-array payload +- missing required SAML fields (`providerId`, `issuer`, `domain`, `entryPoint`, `cert`) +- duplicate SAML domains +- duplicate `providerId` across OIDC and SAML provider sets + +**Step 2: Run the targeted env test to verify red** + +Run: `bun test apps/server/src/lib/env.test.ts` +Expected: FAIL on missing SAML parsing/types or missing cross-protocol uniqueness checks + +**Step 3: Implement minimal SAML env parsing** + +Add a typed parser for: + +- `MDCMS_AUTH_SAML_PROVIDERS` +- required fields: `providerId`, `issuer`, `domain`, `entryPoint`, `cert` +- optional fields: `audience`, `spEntityId`, `identifierFormat`, `authnRequestsSigned`, `wantAssertionsSigned`, `attributeMapping` + +Implementation details: + +- extend `ServerEnv` with `MDCMS_AUTH_SAML_PROVIDERS` +- preserve the current OIDC parser unchanged except for shared cross-protocol `providerId` uniqueness +- keep missing or blank `MDCMS_AUTH_SAML_PROVIDERS` equivalent to “no configured SAML providers” +- keep errors as deterministic `INVALID_ENV` envelopes keyed to the offending env var + +**Step 4: Update the operator env example** + +Add a commented `MDCMS_AUTH_SAML_PROVIDERS` example to `.env.example` alongside the existing OIDC example and note that providers are enabled by presence, not by a separate flag. + +**Step 5: Re-run the targeted env test** + +Run: `bun test apps/server/src/lib/env.test.ts` +Expected: PASS + +**Step 6: Commit** + +```bash +git add apps/server/src/lib/env.ts apps/server/src/lib/env.test.ts .env.example +git commit -m "feat(server): parse startup saml provider config" +``` + +### Task 2: Extend the auth service to register static SAML providers + +**Files:** + +- Modify: `apps/server/src/lib/auth.ts` +- Modify: `apps/server/src/lib/auth.test.ts` + +**Step 1: Write the failing auth-service tests** + +Add unit-level tests proving: + +- the shared SSO sign-in payload accepts a configured SAML `providerId` +- `SSO_PROVIDER_NOT_CONFIGURED` is protocol-neutral, not OIDC-only +- static SAML provider config is converted into Better Auth SSO provider input +- the Better Auth plugin options enable the required SAML protections: + - `enableInResponseToValidation: true` + - `allowIdpInitiated: false` + - `requireTimestamps: true` + +**Step 2: Run the targeted auth test to verify red** + +Run: `bun test apps/server/src/lib/auth.test.ts` +Expected: FAIL on missing SAML provider building or OIDC-specific error/messages + +**Step 3: Implement the minimal mixed-protocol provider registry** + +Implement in `apps/server/src/lib/auth.ts`: + +- a `SamlProviderConfig` runtime mapping compatible with `@better-auth/sso` +- a builder that converts parsed SAML env config into Better Auth `defaultSSO` entries +- a combined configured-provider allowlist across OIDC and SAML +- protocol-neutral `SSO_PROVIDER_NOT_CONFIGURED` and `AUTH_PROVIDER_ERROR` messages where the code path is shared +- shared callback URL validation for both protocols +- Better Auth `sso(...)` registration with: + - all OIDC providers + - all SAML providers + - required SAML validation options from the spec + +Do not add: + +- runtime provider registration +- Studio-managed SSO settings +- IdP-initiated login +- SAML Single Logout + +**Step 4: Re-run the targeted auth test** + +Run: `bun test apps/server/src/lib/auth.test.ts` +Expected: PASS for the new provider-registry assertions and existing OIDC regressions + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/auth.ts apps/server/src/lib/auth.test.ts +git commit -m "feat(server): register static saml providers" +``` + +### Task 3: Add deterministic SAML fixture helpers + +**Files:** + +- Create: `apps/server/src/lib/auth-saml-fixtures.ts` +- Modify: `apps/server/src/lib/auth.test.ts` + +**Step 1: Write the failing fixture-driven SAML tests** + +Add integration-style coverage in `apps/server/src/lib/auth.test.ts` for: + +- configured SAML sign-in starts from `POST /api/v1/auth/sign-in/sso` +- SP metadata endpoint returns `200` for a configured SAML provider +- ACS success establishes a session and redirects to `/studio` +- missing mapped `email` or `id` becomes `AUTH_SAML_REQUIRED_ATTRIBUTE_MISSING` +- unconfigured provider returns `SSO_PROVIDER_NOT_CONFIGURED` +- unsolicited IdP response is rejected +- replayed assertion is rejected + +**Step 2: Run the targeted auth test to verify red** + +Run: `bun test apps/server/src/lib/auth.test.ts` +Expected: FAIL on missing SAML test helpers and missing ACS/metadata support + +**Step 3: Implement the SAML fixture helper** + +Create `apps/server/src/lib/auth-saml-fixtures.ts` with deterministic helpers that: + +- define a startup-valid SAML provider config +- generate reusable test certificates/private keys +- decode the SP-initiated `SAMLRequest` created by the sign-in step so tests can reuse the exact request ID +- generate signed SAML responses for: + - success + - missing email + - missing id + - replay reuse + - missing `InResponseTo` + +Prefer local deterministic XML generation over live IdP/network tests. + +**Step 4: Re-run the targeted auth test** + +Run: `bun test apps/server/src/lib/auth.test.ts` +Expected: FAIL only on missing runtime ACS/metadata behavior + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/auth-saml-fixtures.ts apps/server/src/lib/auth.test.ts +git commit -m "test(server): add saml auth fixtures" +``` + +### Task 4: Add ACS and SP metadata route handling + +**Files:** + +- Modify: `apps/server/src/lib/auth.ts` +- Modify: `apps/server/src/lib/auth.test.ts` + +**Step 1: Keep the SAML endpoint tests red and focused** + +Make sure the integration tests explicitly verify: + +- `GET /api/v1/auth/sso/saml2/sp/metadata?providerId=&format=xml` returns metadata that references the configured ACS URL +- `POST /api/v1/auth/sso/saml2/sp/acs/:providerId` accepts a valid signed response and issues the normal MDCMS session +- missing mapped attributes surface `AUTH_SAML_REQUIRED_ATTRIBUTE_MISSING` +- unsolicited or replayed responses surface deterministic failure envelopes + +**Step 2: Run the targeted auth test to confirm red** + +Run: `bun test apps/server/src/lib/auth.test.ts` +Expected: FAIL on missing route mounts or incorrect SAML error translation + +**Step 3: Implement the minimal route support** + +In `apps/server/src/lib/auth.ts`: + +- add route mounts for: + - `POST /api/v1/auth/sso/saml2/sp/acs/:providerId` + - `GET /api/v1/auth/sso/saml2/sp/metadata` +- route both through `auth.handler(...)` with the same runtime error wrapping used for existing auth endpoints +- add SAML-specific error mapping so: + - missing mapped identity fields become `AUTH_SAML_REQUIRED_ATTRIBUTE_MISSING` + - unsupported provider IDs become `SSO_PROVIDER_NOT_CONFIGURED` + - assertion validation/replay/issuer/signature failures become `AUTH_PROVIDER_ERROR` +- add post-callback user normalization for the SAML ACS path similar to the existing OIDC callback normalization, but using the SAML attribute mapping defined in the provider config + +**Step 4: Re-run the targeted auth test** + +Run: `bun test apps/server/src/lib/auth.test.ts` +Expected: PASS for the new SAML route coverage and all pre-existing OIDC regressions + +**Step 5: Commit** + +```bash +git add apps/server/src/lib/auth.ts apps/server/src/lib/auth.test.ts +git commit -m "feat(server): add saml acs and metadata routes" +``` + +### Task 5: Update operator-facing auth documentation + +**Files:** + +- Modify: `apps/server/README.md` +- Modify: `.env.example` (only if more examples are still needed after Task 1) + +**Step 1: Update the server README** + +Document: + +- `MDCMS_AUTH_SAML_PROVIDERS` +- provider-presence enablement semantics +- required and optional SAML fields +- ACS and SP metadata routes +- SP-initiated-only scope +- callback URL restrictions shared with OIDC + +**Step 2: Run formatting checks for touched docs** + +Run: `bun run format:check` +Expected: PASS + +**Step 3: Commit** + +```bash +git add apps/server/README.md .env.example +git commit -m "docs(server): document saml provider support" +``` + +### Task 6: Verify the full CMS-41 slice + +**Files:** + +- Modify: none unless verification fixes are required + +**Step 1: Run the env parser suite** + +Run: `bun test apps/server/src/lib/env.test.ts` +Expected: PASS + +**Step 2: Run the auth suite** + +Run: `bun test apps/server/src/lib/auth.test.ts` +Expected: PASS + +**Step 3: Run formatting** + +Run: `bun run format:check` +Expected: PASS + +**Step 4: Run the workspace baseline** + +Run: `bun run check` +Expected: PASS + +**Step 5: Inspect git status** + +Run: `git status --short` +Expected: + +- only CMS-41 task-scoped files are staged or modified +- unrelated local-only paths such as `.claude/`, `.codex/`, `ROADMAP_TASKS.md`, and `docs/plans/` remain unstaged + +**Step 6: Final verification commit if fixes were needed** + +```bash +git add +git commit -m "test(server): finalize cms-41 verification" +``` diff --git a/.ai/plans/2026-03-20-week-3-scope-and-sequencing.md b/.ai/plans/2026-03-20-week-3-scope-and-sequencing.md new file mode 100644 index 00000000..584c5b75 --- /dev/null +++ b/.ai/plans/2026-03-20-week-3-scope-and-sequencing.md @@ -0,0 +1,261 @@ +# Week 3 Scope and Recommended Sequencing + +Date: 2026-03-20 + +Sources: + +- Week 3 screenshot provided in chat +- `ROADMAP_TASKS.md` +- `docs/specs/README.md` + +## Jira Status Note + +Live Jira status lookup was not reliable in this session. This report covers: + +- the visible Week 3 scope from the screenshot +- roadmap task mapping +- dependency-based sequencing + +It does **not** claim current `Done` / `To Do` counts for Week 3. + +## Week 3 Screenshot Transcription + +Visible text in the Week 3 column: + +- `Week 3` +- `Platform (83d)` +- `N - Studio Content (24d)` + `CMS-56-67,75,76` +- `O - MDX Components (21d)` + `CMS-68-74` +- `P - Studio Runtime (7.5d)` + `CMS-60,61,62` +- `T - SDK (3.5d)` + `CMS-85` +- `Q - CLI Commands (27d)` + `CMS-77-87` + +## Important Screenshot Caveats + +The Week 3 screenshot is not a clean list of unique tasks. + +1. `P - Studio Runtime` duplicates `CMS-60,61,62`, which already sit inside the broader `CMS-56-67` range under `N - Studio Content`. +2. `T - SDK` duplicates `CMS-85`, which already sits inside the broader `CMS-77-87` range under `Q - CLI Commands`. + +Because of that, the screenshot appears to mix: + +- broad streams (`N`, `O`, `Q`) +- narrower callout substreams (`P`, `T`) + +For planning purposes, the unique Week 3 tasks visible in the screenshot are: + +- `CMS-56` through `CMS-87` + +## Roadmap Override To The Screenshot + +Two tasks that appear inside the screenshot ranges are explicitly re-homed by the roadmap and no longer gate MVP: + +- `CMS-58` +- `CMS-86` + +That matters because the screenshot still visually includes them: + +- `CMS-58` falls inside `CMS-56-67` +- `CMS-86` falls inside `CMS-77-87` + +For strict MVP sequencing, treat those two as **deferred** unless you intentionally pull them back in. + +## Week 3 Unique Task Set + +### Studio / Runtime / Content + +- `CMS-56` Implement publish flow + version history panel + version diff view +- `CMS-57` Implement schema mismatch banner and write-blocking read-only mode +- `CMS-58` Collaboration idempotency + round-trip fidelity suite per schema type +- `CMS-59` Implement read-only schema browsing view in Studio for non-developer users +- `CMS-60` Build `@mdcms/studio` runtime loader + composition surfaces +- `CMS-61` Execute C1/C2 Studio runtime spikes + decision gate +- `CMS-62` Implement selected Studio runtime mode + hardening regression suite +- `CMS-63` Studio locale switcher + variant creation flow +- `CMS-64` Translation status indicators in content list +- `CMS-65` Studio environment management basics +- `CMS-66` Implement Studio project switcher and project-aware access control +- `CMS-67` Implement environment-specific field badges in editor UI +- `CMS-75` Implement Studio user management UI +- `CMS-76` Implement Studio API key management UI + +### MDX Components + +- `CMS-68` Implement component registration sync contract +- `CMS-69` Implement TypeScript prop extraction and unsupported prop filtering +- `CMS-70` Implement prop type to form control mapping +- `CMS-71` Implement widget hints override system +- `CMS-72` Implement custom props editor contract and rendering lifecycle +- `CMS-73` Implement children/nested rich text support inside component nodes +- `CMS-74` Implement `MdxComponent` node view insert/edit/serialize/live preview + +### CLI / SDK + +- `CMS-77` Build CLI framework with profile/auth plumbing +- `CMS-78` Implement `cms init` full wizard flow +- `CMS-79` Implement `cms login` and `cms logout` credential lifecycle +- `CMS-80` Implement `cms pull` +- `CMS-81` Implement strict manifest contract per scope +- `CMS-82` Implement `cms push` +- `CMS-83` Implement `cms push --validate` +- `CMS-84` Implement `cms status` +- `CMS-85` Implement SDK client (`createClient`, `get/list/resolve`) + runtime schema cache invalidation +- `CMS-86` Implement `cms push` Redis invalidation +- `CMS-87` Implement `cms schema sync` CLI command + +## Key Dependency Chains Inside Week 3 + +### Studio Runtime + +- `CMS-60 -> CMS-61 -> CMS-62` + +### Locale / Translation UI + +- `CMS-63 -> CMS-64` + +### MDX Components + +- `CMS-68 -> CMS-69 -> CMS-70 -> CMS-71 -> CMS-72 -> CMS-73 -> CMS-74` + +`CMS-74` also depends on `CMS-52`, which is outside Week 3. + +### CLI Core + +- `CMS-77 -> CMS-78` +- `CMS-77 -> CMS-79` +- `CMS-77 -> CMS-80 -> CMS-81 -> CMS-82 -> CMS-83` +- `CMS-81 -> CMS-84` +- `CMS-77 -> CMS-87` + +### SDK + +- `CMS-85` is independent of the `CMS-77` CLI chain. It depends on `CMS-17` and `CMS-21`, both outside Week 3. + +### Deferred + +- `CMS-58` depends on `CMS-52` and `CMS-54` +- `CMS-86` depends on `CMS-82` and `CMS-53` + +Both are roadmap-deferred from MVP. + +## External Prerequisite Note + +Many Week 3 tasks depend on non-Week-3 tasks such as: + +- `CMS-48`, `CMS-49`, `CMS-50` +- `CMS-52` +- `CMS-17`, `CMS-18`, `CMS-19`, `CMS-20`, `CMS-24`, `CMS-42`, `CMS-44` + +This report does not claim those external prerequisites are complete. It only lays out the Week 3 structure and the internal dependency sequence. + +## Recommended Sequencing + +Week 3 is too broad for one useful single-file linear order. The practical approach is to run it in dependency-led tracks. + +### Recommended Priority Wave 1: Highest Fan-Out Foundations + +Start with the items that unlock the largest amount of Week 3 work: + +1. `CMS-60` Studio runtime loader + composition surfaces +2. `CMS-68` component registration sync contract +3. `CMS-77` CLI framework with profile/auth plumbing +4. `CMS-85` SDK client + +Why: + +- `CMS-60` unlocks the entire runtime chain (`61`, `62`) +- `CMS-68` starts the full MDX component chain (`69` through `74`) +- `CMS-77` is the CLI root for `78`, `79`, `80`, and `87` +- `CMS-85` is independent and valuable enough to run immediately in parallel + +### Recommended Priority Wave 2: Close The Long Chains + +Once Wave 1 foundations are active, continue each chain in order: + +#### Runtime chain + +1. `CMS-61` +2. `CMS-62` + +#### MDX component chain + +1. `CMS-69` +2. `CMS-70` +3. `CMS-71` +4. `CMS-72` +5. `CMS-73` +6. `CMS-74` + +#### CLI chain + +1. `CMS-79` +2. `CMS-80` +3. `CMS-87` +4. `CMS-78` +5. `CMS-81` +6. `CMS-82` +7. `CMS-83` +8. `CMS-84` + +Why this CLI ordering: + +- `CMS-79`, `CMS-80`, and `CMS-87` sit directly off `CMS-77` +- `CMS-81` cannot start until `CMS-80` +- `CMS-82` cannot start until `CMS-81` +- `CMS-83` and `CMS-84` are tail tasks off the push/manifest path +- `CMS-78` depends only on `CMS-77`, so it can move earlier or later based on resourcing + +### Recommended Priority Wave 3: Studio Content and Admin Flows + +Run the Studio content/admin tasks as a parallel product track once their external prerequisites are ready: + +- `CMS-65` Studio environment management basics +- `CMS-66` Studio project switcher +- `CMS-75` Studio user management UI +- `CMS-76` Studio API key management UI +- `CMS-63` locale switcher +- `CMS-64` translation status indicators +- `CMS-67` environment-specific field badges +- `CMS-57` schema mismatch banner / read-only mode +- `CMS-59` read-only schema browsing +- `CMS-56` publish flow + version history + diff + +Suggested order inside this track: + +1. `CMS-65` +2. `CMS-66` +3. `CMS-75` +4. `CMS-76` +5. `CMS-63` +6. `CMS-64` +7. `CMS-67` +8. `CMS-57` +9. `CMS-59` +10. `CMS-56` + +Why: + +- `65`, `66`, `75`, and `76` provide foundational project/environment/admin surfaces +- `63 -> 64` is the only strict internal chain here +- `56`, `57`, and `59` are more specialized experience layers and can safely come after the core Studio navigation/admin surfaces + +### Deferred From MVP + +Do not put these on the MVP-critical Week 3 path unless you intentionally want deferred work: + +- `CMS-58` +- `CMS-86` + +## Short Recommendation + +If the goal is to reduce rework and unblock the most Week 3 work quickly: + +1. Start `CMS-60`, `CMS-68`, `CMS-77`, and `CMS-85` +2. Then finish the runtime, MDX, and CLI chains in dependency order +3. Run the Studio content/admin tasks in parallel as prerequisite readiness allows +4. Keep `CMS-58` and `CMS-86` out of the MVP path because the roadmap explicitly moved them to deferred work diff --git a/.ai/plans/2026-03-21-cms-60-studio-runtime-design.md b/.ai/plans/2026-03-21-cms-60-studio-runtime-design.md new file mode 100644 index 00000000..6c0267de --- /dev/null +++ b/.ai/plans/2026-03-21-cms-60-studio-runtime-design.md @@ -0,0 +1,202 @@ +# CMS-60 Studio Runtime Loader Design + +Date: 2026-03-21 +Task: CMS-60 + +## Goal + +Specify a concrete MVP architecture for `@mdcms/studio` where the package acts as a thin runtime shell that loads a backend-served remote Studio application in `module` mode and hands off all in-app behavior to that remote runtime. + +## Canonical Inputs + +- `ROADMAP_TASKS.md` CMS-60 +- `docs/specs/README.md` +- `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- `packages/shared/src/lib/contracts/extensibility.ts` +- `packages/studio/src/lib/studio-component.tsx` +- `packages/studio/src/lib/remote-module.ts` +- `apps/server/src/lib/studio-bootstrap.ts` + +## Spec Delta + +`SPEC-006` and `SPEC-002` need a normative runtime-model update that: + +- makes `module` the only supported Studio execution mode for MVP +- removes `iframe` from the MVP decision surface instead of deferring that decision to a later spike +- clarifies that the shell only owns bootstrap-time loading and fatal startup failure UI +- clarifies that the remote runtime is the full Studio application after `mount(...)` succeeds +- adds `basePath` to the shell-to-remote runtime contract so deep links resolve correctly without framework-specific router adapters +- defines the runtime-internal composition registry contract and deterministic collision rules + +`ROADMAP_TASKS.md` also needs follow-up cleanup because CMS-61 currently reserves the execution-mode decision for later, which conflicts with the approved CMS-60 design direction. + +## Scope + +### In Scope + +- Thin `@mdcms/studio` shell loader behavior +- Bootstrap fetch, manifest compatibility checks, runtime integrity checks, and remote module loading +- `module`-only runtime execution +- `basePath` handoff from shell to remote app +- Remote-owned Studio app routing and UI state after successful mount +- Runtime-internal composition surfaces for: + - `routes` + - `navItems` + - `slotWidgets` + - `fieldKinds` + - `editorNodes` + - `actionOverrides` + - `settingsPanels` +- Deterministic collision and fallback behavior for those surfaces + +### Out of Scope + +- `iframe` execution mode +- Host-managed Studio routing +- SSR for the remote Studio application +- Third-party plugin marketplace semantics +- Dynamic per-request surface registration through the shell +- Kill-switch and rollback hardening beyond what CMS-60 needs for startup validation + +## Design Decisions + +### 1. The Shell Is Only a Loader Host + +`@mdcms/studio` should only: + +- fetch `/api/v1/studio/bootstrap` +- validate manifest shape, compatibility, and runtime integrity +- load the remote runtime entry +- construct the host bridge +- provide `apiBaseUrl`, auth context, and `basePath` +- call `mount(container, ctx)` + +The shell should not own Studio navigation, editor flows, route-level loading, or normal application rendering once the remote runtime mounts. + +### 2. The Remote Runtime Is the Whole Studio App + +The remote bundle should be treated as the full Studio application, not as a set of shell-registered fragments. + +Internally, the remote module can render a top-level `StudioApp` component, own its own router, and build its own composition registry, but the public boundary remains: + +```ts +type RemoteStudioModule = { + mount: (container: HTMLElement, ctx: StudioMountContext) => () => void; +}; +``` + +This keeps the shell contract minimal while still allowing the remote app to be React-driven internally. + +### 3. `module` Is the Only MVP Execution Mode + +The approved direction is to remove `iframe` from MVP rather than keeping a dual-mode abstraction. + +The bootstrap manifest can still include a `mode` field for explicitness and compatibility, but MVP should require `mode: "module"` and reject any other value deterministically. + +### 4. Remote App Owns Routing After Startup + +The remote Studio app should own browser-path syncing with the History API: + +- read `window.location` +- call `history.pushState` / `history.replaceState` +- listen to `popstate` + +No framework-specific router adapter should be required beyond the host app exposing a catch-all route that renders the shell. + +Because deep links do not reveal the Studio subtree root reliably, the shell must provide an explicit `basePath`. + +### 5. `basePath` Must Be Explicit + +The remote runtime cannot infer whether a deep link such as `/admin/content/posts` is rooted at `/admin`, `/cms/admin`, or another embedding prefix. + +`StudioMountContext` should therefore gain: + +```ts +type StudioMountContext = { + apiBaseUrl: string; + basePath: string; + auth: { mode: "cookie" | "token"; token?: string }; + hostBridge: HostBridgeV1; +}; +``` + +This is the only routing input the shell needs to provide. + +### 6. Composition Surfaces Stay Inside the Remote Runtime + +The shell should not expose host-side `registerX(...)` APIs. + +Instead, the remote Studio app should compose its own declarative registry for: + +- `routes` +- `navItems` +- `slotWidgets` +- `fieldKinds` +- `editorNodes` +- `actionOverrides` +- `settingsPanels` + +This keeps CMS-60 within the first-party runtime model already described in the specs and avoids inventing a broader plugin host API prematurely. + +### 7. Collisions Must Be Deterministic and Fail Fast + +The remote runtime should validate its registry before first real render. + +Rules: + +- `routes` use normalized path matching; `/settings` and `/settings/` conflict +- route parameter aliases that normalize to the same shape also conflict +- duplicate `fieldKinds`, `editorNodes`, `actionOverrides`, or `settingsPanels` fail startup +- `slotWidgets` require explicit numeric `priority` +- `slotWidgets` sort by `priority` descending, then `id` ascending +- `navItems` sort deterministically by explicit order, then `id` +- `settings.sidebar` entries must reference registered `settingsPanels` + +### 8. Unknown Field Kinds Fallback Safely + +Unknown or unregistered Studio field kinds should not crash the remote app. + +The remote runtime should: + +- render a safe JSON editor fallback +- emit a structured warning log with the missing field kind id +- continue rendering the rest of the editor + +## Failure Model + +### Shell-Owned Fatal Startup Failures + +The shell is responsible only for fatal startup errors: + +- bootstrap fetch failed +- bootstrap manifest invalid or incompatible +- runtime asset load/import failed +- remote `mount(...)` threw during startup + +### Remote-Owned Application Failures + +After `mount(...)` succeeds, the remote app owns all user-visible Studio states, including: + +- normal loading states +- empty states +- forbidden states +- route-level errors +- editor failures +- application navigation + +## Verification + +Completion should be backed by: + +- shell loader tests for bootstrap fetch, compatibility/integrity verification, remote import, and `mount(...)` context handoff +- shell fatal-startup tests for invalid manifest, integrity mismatch, incompatible versions, and mount failure +- remote runtime registry tests for normalized route conflicts, duplicate surface failures, deterministic slot ordering, and unknown field-kind fallback +- a deep-link integration test proving `basePath` handoff works under a non-root embed path +- `bun run format:check` +- `bun run check` + +## Notes + +- `docs/plans/` is local-only in this repository, so this design doc should remain untracked. +- The current package layout uses `packages/studio`, even though parts of the live spec still refer to `apps/studio`; implementation planning should follow the actual repository layout. diff --git a/.ai/plans/2026-03-21-cms-60-studio-runtime-loader.md b/.ai/plans/2026-03-21-cms-60-studio-runtime-loader.md new file mode 100644 index 00000000..126ad0f0 --- /dev/null +++ b/.ai/plans/2026-03-21-cms-60-studio-runtime-loader.md @@ -0,0 +1,311 @@ +# CMS-60 Studio Runtime Loader Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the shell-first `@mdcms/studio` embed with a thin module loader that boots a backend-served remote Studio application and enforces deterministic runtime composition rules. + +**Architecture:** The shell remains a small client-side host that fetches `/api/v1/studio/bootstrap`, validates the manifest and runtime bytes, creates the host bridge, passes `basePath`, and calls the remote `mount(...)` contract. The remote bundle becomes the full Studio app: it owns routing, application states, and an internal registry for `routes`, `navItems`, `slotWidgets`, `fieldKinds`, `editorNodes`, `actionOverrides`, and `settingsPanels`. + +**Tech Stack:** Bun, Nx, TypeScript, React 19, Node test runner, Elysia, `@mdcms/shared`, `@mdcms/studio` + +--- + +### Task 1: Lock the spec and operator-facing contract + +**Files:** + +- Modify: `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- Modify: `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- Modify: `packages/shared/README.md` +- Modify: `packages/studio/README.md` +- Test: none + +**Step 1: Re-read the approved design and current Studio specs** + +Run: `sed -n '1,260p' docs/specs/SPEC-006-studio-runtime-and-ui.md && sed -n '190,360p' docs/specs/SPEC-002-system-architecture-and-extensibility.md` +Expected: current specs still mention `iframe | module`, shell-owned UI behavior, and composition surfaces without concrete collision rules + +**Step 2: Update `SPEC-006` with the CMS-60 runtime-model delta** + +Add or update normative sections for: + +- `module` as the only MVP execution mode +- shell-owned concerns limited to bootstrap, verification, and fatal startup failures +- remote runtime as the full Studio app after `mount(...)` +- `basePath` on `StudioMountContext` +- remote-owned routing under the provided base path +- composition surfaces and deterministic validation rules + +**Step 3: Update `SPEC-002` to match the same architecture** + +Remove or rewrite the parts that still defer `iframe` vs `module`, and update the runtime data flow and validation bullets so they match the approved CMS-60 design. + +**Step 4: Update the package READMEs** + +Document: + +- the public shell contract in `@mdcms/studio` +- `basePath` as required loader input +- startup-only shell failure states +- composition-surface validation expectations in `@mdcms/shared` + +**Step 5: Run formatting check for the touched docs** + +Run: `bun run format:check` +Expected: PASS + +**Step 6: Commit** + +```bash +git add docs/specs/SPEC-002-system-architecture-and-extensibility.md docs/specs/SPEC-006-studio-runtime-and-ui.md packages/shared/README.md packages/studio/README.md +git commit -m "docs(studio): specify module runtime loader contract" +``` + +### Task 2: Tighten the shared Studio runtime contracts + +**Files:** + +- Modify: `packages/shared/src/lib/contracts/extensibility.ts` +- Modify: `packages/shared/src/lib/contracts/extensibility.test.ts` +- Modify: `packages/shared/src/index.ts` (only if exports need adjustment) +- Test: `packages/shared/src/lib/contracts/extensibility.test.ts` + +**Step 1: Write the failing contract tests** + +Add cases covering: + +- `StudioMountContext.basePath` required and non-empty +- bootstrap manifests reject non-`module` modes +- compatibility helpers still validate strict version bounds + +**Step 2: Run the targeted shared-contract test** + +Run: `bun test packages/shared/src/lib/contracts/extensibility.test.ts` +Expected: FAIL on missing `basePath` validation and permissive runtime-mode acceptance + +**Step 3: Implement the minimal contract changes** + +Update `extensibility.ts` so that: + +- `StudioExecutionMode` is `"module"` +- `StudioMountContext` includes `basePath: string` +- manifest validation only allows `mode: "module"` +- existing validators and helpers keep their current error-code behavior + +Use a concrete contract shape such as: + +```ts +export type StudioExecutionMode = "module"; + +export type StudioMountContext = { + apiBaseUrl: string; + basePath: string; + auth: { mode: "cookie" | "token"; token?: string }; + hostBridge: HostBridgeV1; +}; +``` + +**Step 4: Re-run the targeted shared-contract test** + +Run: `bun test packages/shared/src/lib/contracts/extensibility.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/contracts/extensibility.ts packages/shared/src/lib/contracts/extensibility.test.ts packages/shared/src/index.ts +git commit -m "refactor(shared): lock studio runtime to module mode" +``` + +### Task 3: Replace the shell-first embed with a loader host + +**Files:** + +- Modify: `packages/studio/src/lib/studio-component.tsx` +- Create: `packages/studio/src/lib/studio-loader.ts` +- Create: `packages/studio/src/lib/studio-loader.test.ts` +- Modify: `packages/studio/src/index.ts` +- Test: `packages/studio/src/lib/studio-loader.test.ts` + +**Step 1: Write the failing loader tests** + +Cover: + +- fetches `/api/v1/studio/bootstrap` +- validates manifest compatibility and runtime integrity before import +- imports the remote module from the manifest `entryUrl` +- passes `apiBaseUrl`, `basePath`, auth, and host bridge to `mount(...)` +- returns fatal startup state when bootstrap fetch, verification, import, or mount fails + +Keep the hard-to-mock logic in pure helper functions so the React component remains thin. + +**Step 2: Run the targeted loader test** + +Run: `bun test packages/studio/src/lib/studio-loader.test.ts` +Expected: FAIL because the loader host does not exist yet + +**Step 3: Implement the minimal loader** + +Add a small loader module that: + +- resolves `apiBaseUrl` from `config.serverUrl` +- fetches `/api/v1/studio/bootstrap` +- loads runtime bytes for integrity verification +- imports the remote module via `import(/* @vite-ignore */ entryUrl)` or equivalent dynamic ESM path +- validates the remote contract with `assertRemoteStudioModule(...)` +- calls `mount(container, ctx)` + +Update `Studio` so it becomes a client component that: + +- accepts `config`, `basePath`, and optional auth/host-bridge overrides for tests +- renders only fatal startup UI while the loader is unresolved +- delegates all normal application UI to the remote runtime after `mount(...)` + +**Step 4: Re-run the targeted loader test** + +Run: `bun test packages/studio/src/lib/studio-loader.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/studio/src/lib/studio-component.tsx packages/studio/src/lib/studio-loader.ts packages/studio/src/lib/studio-loader.test.ts packages/studio/src/index.ts +git commit -m "feat(studio): add remote runtime loader host" +``` + +### Task 4: Build the remote Studio app and runtime registry + +**Files:** + +- Modify: `packages/studio/src/lib/remote-module.ts` +- Create: `packages/studio/src/lib/remote-studio-app.tsx` +- Create: `packages/studio/src/lib/runtime-registry.ts` +- Create: `packages/studio/src/lib/runtime-registry.test.ts` +- Modify: `packages/studio/src/lib/bootstrap-verification.test.ts` (only if startup-path coverage needs extension) +- Test: `packages/studio/src/lib/runtime-registry.test.ts` + +**Step 1: Write the failing runtime-registry tests** + +Cover: + +- normalized route conflicts such as `/settings` vs `/settings/` +- param-shape conflicts such as duplicate normalized content-detail routes +- duplicate `fieldKinds`, `editorNodes`, `actionOverrides`, and `settingsPanels` +- `slotWidgets` missing explicit `priority` +- deterministic slot ordering by `priority` descending then `id` ascending +- unknown field kind falling back to a JSON editor descriptor and emitting a structured warning + +**Step 2: Run the targeted registry test** + +Run: `bun test packages/studio/src/lib/runtime-registry.test.ts` +Expected: FAIL because the registry implementation does not exist yet + +**Step 3: Implement the minimal remote app and registry** + +Create a runtime-owned app that: + +- mounts from `remote-module.ts` +- reads `ctx.basePath` +- owns browser history and route parsing inside the remote app +- builds and validates the composition registry before first render +- renders the default Studio surfaces through that registry +- uses a safe JSON editor fallback for unknown field kinds + +Keep the route and registry validation logic outside React where possible so tests stay deterministic and do not require a browser-heavy harness. + +**Step 4: Re-run the targeted registry test** + +Run: `bun test packages/studio/src/lib/runtime-registry.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/studio/src/lib/remote-module.ts packages/studio/src/lib/remote-studio-app.tsx packages/studio/src/lib/runtime-registry.ts packages/studio/src/lib/runtime-registry.test.ts packages/studio/src/lib/bootstrap-verification.test.ts +git commit -m "feat(studio): add remote studio runtime registry" +``` + +### Task 5: Remove shell-managed routing from the example embed and stale shell tests + +**Files:** + +- Modify: `apps/studio-example/app/admin/[[...path]]/page.tsx` +- Modify: `packages/studio/src/lib/studio.test.ts` +- Test: `packages/studio/src/lib/studio.test.ts` + +**Step 1: Write or update the failing embed tests** + +Refocus `studio.test.ts` around the new shell boundary: + +- fatal startup UI behavior only +- no shell-managed content/document route logic after remote mount +- `basePath` handoff into the loader path + +Drop assertions that are now remote-runtime responsibilities, such as shell-level route resolution and document-shell rendering. + +**Step 2: Run the targeted shell test** + +Run: `bun test packages/studio/src/lib/studio.test.ts` +Expected: FAIL until the old shell assumptions are removed + +**Step 3: Update the example embed page** + +Change the example so it: + +- stops preloading route-specific Studio state in the host page +- renders the shell with an explicit `basePath` +- leaves routing and document loading to the remote runtime + +The result should be closer to: + +```tsx +export default function AdminCatchAllPage() { + return ; +} +``` + +**Step 4: Re-run the targeted shell test** + +Run: `bun test packages/studio/src/lib/studio.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/studio-example/app/admin/[[...path]]/page.tsx packages/studio/src/lib/studio.test.ts +git commit -m "refactor(studio): remove host-managed studio routing" +``` + +### Task 6: Verify the full CMS-60 slice + +**Files:** + +- Modify: none unless verification exposes follow-up fixes +- Test: all touched package tests + +**Step 1: Run targeted Studio tests** + +Run: `bun test packages/studio/src/lib/studio-loader.test.ts packages/studio/src/lib/runtime-registry.test.ts packages/studio/src/lib/studio.test.ts packages/studio/src/lib/bootstrap-verification.test.ts` +Expected: PASS + +**Step 2: Run targeted shared-contract tests** + +Run: `bun test packages/shared/src/lib/contracts/extensibility.test.ts` +Expected: PASS + +**Step 3: Run formatting and workspace checks** + +Run: `bun run format:check && bun run check` +Expected: PASS + +**Step 4: Inspect git status** + +Run: `git status --short` +Expected: task-scoped tracked changes only; local-only paths such as `docs/plans/` remain unstaged + +**Step 5: Final commit if verification fixes were required** + +```bash +git add +git commit -m "test(studio): finalize cms-60 runtime loader verification" +``` diff --git a/.ai/plans/2026-03-23-cms-61-studio-runtime-reconciliation-design.md b/.ai/plans/2026-03-23-cms-61-studio-runtime-reconciliation-design.md new file mode 100644 index 00000000..30471dec --- /dev/null +++ b/.ai/plans/2026-03-23-cms-61-studio-runtime-reconciliation-design.md @@ -0,0 +1,82 @@ +# CMS-61 Studio Runtime Decision Reconciliation Design + +Date: 2026-03-23 +Task: CMS-61 + +## Goal + +Reconcile the stale Studio execution-mode decision record with the live module-only Studio runtime contract that is already implemented and specified elsewhere in the repository. + +## Canonical Inputs + +- `ROADMAP_TASKS.md` CMS-61 +- `docs/specs/README.md` +- `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- `docs/adrs/ADR-003-studio-delivery-approach-c.md` +- `packages/studio/README.md` + +## Spec Delta + +No owning-spec delta is required for this reconciliation slice. + +The authoritative product contract is already module-only in the owning specs: + +- `SPEC-002` says Studio runtime execution is `module`-only in MVP. +- `SPEC-006` defines `StudioExecutionMode` as `module` and states that `module` is the only supported Studio execution mode in MVP. + +The required delta is in the architectural rationale record: + +- `ADR-003` still says the execution-mode choice is open between `iframe` and `module`. +- `ADR-003` must be reconciled so the decision record matches the live spec and current implementation direction. + +## Scope + +### In Scope + +- Update `ADR-003` to state that MVP execution mode is `module`. +- Remove stale wording that leaves the execution mode undecided. +- Keep the ADR rationale aligned with host bridge and MDX preview requirements already reflected in the specs. + +### Out of Scope + +- Any new runtime implementation work +- Any `iframe` spike or proof-of-concept +- Changes to the owning specs beyond confirming they already contain the final contract +- New test coverage unless verification reveals a docs inconsistency that must be fixed + +## Design Decisions + +### 1. Treat This as ADR Reconciliation, Not Product Redesign + +`CMS-61` is being interpreted here as a documentation reconciliation slice only. The owning specs already carry the normative decision, so the ADR should record the same outcome instead of reopening the choice. + +### 2. Keep the Change Focused to `ADR-003` + +The smallest correct change is to update the stale ADR language in place: + +- replace the open decision wording with a final `module` decision +- explain that the host bridge and MDX preview requirements fit the in-process module model for MVP +- remove the statement that the final mode is still pending future spikes + +### 3. Use Existing Repo Evidence Rather Than Inventing New Spike Artifacts + +The repository already contains enough internal evidence for a reconciliation-only slice: + +- the owning specs are module-only +- the package README is module-only +- the runtime contracts and tests reject non-module manifests + +This slice should not manufacture a fake dual-mode evaluation artifact inside the codebase. + +## Verification + +Completion should be backed by: + +- confirming `ADR-003` no longer states the decision is open +- confirming the canonical docs set no longer disagrees about MVP execution mode +- `bun run format:check` + +## Notes + +- `docs/plans/` is local-only in this repository, so this design doc should remain untracked. diff --git a/.ai/plans/2026-03-23-cms-61-studio-runtime-reconciliation.md b/.ai/plans/2026-03-23-cms-61-studio-runtime-reconciliation.md new file mode 100644 index 00000000..182a3ce0 --- /dev/null +++ b/.ai/plans/2026-03-23-cms-61-studio-runtime-reconciliation.md @@ -0,0 +1,47 @@ +# CMS-61 Studio Runtime Reconciliation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Reconcile the stale Studio delivery ADR with the already-approved module-only Studio runtime contract. + +**Architecture:** This is a docs-only change. Update the ADR so its rationale and consequences match the module-only decision already owned by the live specs, then verify that no canonical docs still present the execution mode as undecided. + +**Tech Stack:** Markdown docs, ripgrep, Bun workspace verification commands + +--- + +### Task 1: Reconcile ADR-003 + +**Files:** + +- Modify: `docs/adrs/ADR-003-studio-delivery-approach-c.md` +- Verify against: `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- Verify against: `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- Verify against: `packages/studio/README.md` + +**Step 1: Update the stale ADR language** + +Change the security/delivery and consequence sections so they no longer describe `iframe` vs `module` as an open MVP choice. + +**Step 2: Align the rationale with the live contract** + +State that MVP uses `module` execution because it aligns with the capability-limited host bridge, MDX preview integration, and the existing thin-loader architecture. + +**Step 3: Run targeted consistency checks** + +Run: `rg -n "decision is open|stays open|iframe.*module|execution mode remains" docs/adrs docs/specs packages/studio` +Expected: no remaining stale open-decision wording in canonical docs for the MVP Studio execution mode + +**Step 4: Run format verification** + +Run: `bun run format:check` +Expected: PASS + +**Step 5: Review git status** + +Run: `git status --short` +Expected: only the intended tracked docs change is modified, and local-only files remain untracked + +## Notes + +- `docs/plans/` is local-only in this repository, so this plan should remain untracked. diff --git a/.ai/plans/2026-03-23-cms-62-studio-runtime-hardening-design.md b/.ai/plans/2026-03-23-cms-62-studio-runtime-hardening-design.md new file mode 100644 index 00000000..34ddc7f4 --- /dev/null +++ b/.ai/plans/2026-03-23-cms-62-studio-runtime-hardening-design.md @@ -0,0 +1,168 @@ +# CMS-62 Studio Runtime Hardening Design + +Date: 2026-03-23 +Task: CMS-62 + +## Goal + +Implement the selected `module` Studio runtime path as the only production mode and add deterministic startup recovery behavior, operator disable behavior, and regression coverage for runtime validation, authorization filtering, and MDX host-bridge preview behavior. + +## Canonical Inputs + +- `ROADMAP_TASKS.md` CMS-62 +- `docs/specs/README.md` +- `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- `docs/specs/SPEC-007-editor-mdx-and-collaboration.md` +- `apps/server/README.md` +- `packages/studio/README.md` + +## Spec Delta + +The execution mode itself does not change. The owning spec already defines MVP Studio runtime execution as `module` only. + +The required spec delta is the startup recovery contract: + +- `SPEC-006` must define server-owned publication state with `active`, optional `lastKnownGood`, and operator disable state. +- `SPEC-006` must define bootstrap success as a startup envelope, not a raw manifest-only payload. +- `SPEC-006` must define a deterministic recovery retry shape for client-detected runtime rejection: + - `rejectedBuildId` + - `rejectionReason` in `integrity | signature | compatibility` +- `SPEC-006` must define deterministic disabled/unavailable outcomes: + - `STUDIO_RUNTIME_DISABLED` + - `STUDIO_RUNTIME_UNAVAILABLE` +- `SPEC-002` should align its testing/architecture language to clarify that build selection remains server-owned and the shell only retries bootstrap once with rejection context. + +## Scope + +### In Scope + +- Keep `module` as the only production runtime mode. +- Add server-owned rollback to `lastKnownGood`. +- Add an operator kill-switch path for Studio startup. +- Keep `/api/v1/studio/bootstrap` as the single bootstrap endpoint. +- Add one bootstrap retry from the shell when runtime validation rejects a served build. +- Add regression coverage for integrity/signature/compatibility rejection, fallback selection, disabled/unavailable behavior, unauthorized action visibility, forced server rejection, and MDX host-bridge preview. + +### Out of Scope + +- Any production `iframe` runtime path. +- Browser-local caching of last-known-good builds. +- A new admin UI or public API for toggling the kill switch. +- A general deployment/promotion system beyond the minimum publication state required for bootstrap decisions. + +## Design Decisions + +### 1. Server Owns Publication Selection + +The server decides which Studio build is safe to serve. The shell never chooses between active and fallback builds on its own and never persists fallback state in the browser. + +Server publication state is modeled as: + +- `active` build +- optional `lastKnownGood` build +- operator `killSwitch` + +This preserves the existing architecture: the server publishes the Studio runtime, and the shell only validates and mounts the returned runtime. + +### 2. Keep a Single Bootstrap Endpoint + +`GET /api/v1/studio/bootstrap` remains the only startup endpoint. + +Initial startup request remains body-less. + +If the shell rejects a served build during integrity/signature/compatibility validation, it retries the same endpoint once with rejection context: + +- `rejectedBuildId=` +- `rejectionReason=integrity|signature|compatibility` + +The server uses that context to decide whether to serve `lastKnownGood` or return a deterministic disabled/unavailable response. + +### 3. Bootstrap Success Becomes a Startup Envelope + +Instead of returning only `{ data: StudioBootstrapManifest }`, bootstrap success returns a startup envelope: + +```ts +type StudioBootstrapReadyResponse = { + data: { + status: "ready"; + source: "active" | "lastKnownGood"; + manifest: StudioBootstrapManifest; + recovery?: { + rejectedBuildId: string; + rejectionReason: "integrity" | "signature" | "compatibility"; + }; + }; +}; +``` + +This keeps the public surface small while making fallback usage explicit and testable. + +### 4. Disabled and Unavailable Stay as Error Envelopes + +Bootstrap remains deterministic when Studio cannot be started: + +- `503 STUDIO_RUNTIME_DISABLED` + - operator kill switch is enabled +- `503 STUDIO_RUNTIME_UNAVAILABLE` + - no safe build can be served + +The shell renders deterministic startup error UI for those outcomes and does not retry further. + +### 5. The Shell Retries Once + +The shell startup flow becomes: + +1. Fetch bootstrap. +2. Fetch runtime asset. +3. Validate integrity/signature/compatibility. +4. Mount runtime if validation passes. +5. If validation fails and the failure reason is retryable, re-request bootstrap once with rejection context. +6. If the retry returns a ready payload, validate and mount the fallback build. +7. If the retry returns disabled/unavailable, or if the fallback build also fails validation, render deterministic startup failure UI and stop. + +There is no infinite retry loop. + +### 6. Operator Path Is Config-Driven in MVP + +CMS-62 does not add a new mutation API for Studio publication control. + +The kill switch is server configuration or environment driven. `lastKnownGood` is the previously promoted verified publication snapshot held by the server runtime/publication layer. + +This satisfies the acceptance criterion without widening the operator surface beyond MVP needs. + +### 7. Selected-Mode Regression Coverage Must Exercise Real Host-Bridge and Authorization Paths + +Regression coverage should prove more than contract parsing: + +- the `module` mode loader handles happy-path startup +- invalid runtime bytes/signature/compatibility trigger the one-shot bootstrap retry +- fallback builds are served from `lastKnownGood` +- disabled/unavailable outcomes are deterministic +- Studio-visible actions come only from the authorization-filtered catalog +- forced route invocation is still rejected by the server +- the remote runtime calls `hostBridge.renderMdxPreview(...)` on the document/editor path + +## Implementation Notes + +- Shared contracts should own the new bootstrap ready envelope types and validators. +- Server code should expose publication-state helpers without introducing circular package dependencies. +- The remote runtime can stay thin, but it should include one minimal document-route preview surface and one minimal action strip driven by the filtered action catalog so the tests cover real selected-mode behavior. + +## Verification + +The task is complete when the repository can demonstrate: + +- module-only runtime behavior remains the only production path +- bootstrap recovery is deterministic and server-owned +- the shell retries bootstrap once on validation rejection and stops cleanly afterward +- unauthorized actions are absent from Studio-visible action rendering and still rejected by the server when forced +- MDX preview host-bridge flow is exercised in selected mode +- `bun run format:check` +- `bun run check` +- targeted runtime regression tests in `shared`, `server`, and `studio` +- `bun run studio:embed:smoke` + +## Notes + +- `docs/plans/` is local-only in this repository and must remain untracked. diff --git a/.ai/plans/2026-03-23-cms-62-studio-runtime-hardening-plan.md b/.ai/plans/2026-03-23-cms-62-studio-runtime-hardening-plan.md new file mode 100644 index 00000000..21996a31 --- /dev/null +++ b/.ai/plans/2026-03-23-cms-62-studio-runtime-hardening-plan.md @@ -0,0 +1,436 @@ +# CMS-62 Studio Runtime Hardening Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement server-owned Studio runtime rollback/disable behavior for the selected `module` mode and add deterministic regression coverage for bootstrap recovery, authorization-filtered action visibility, and MDX host-bridge preview. + +**Architecture:** Keep `module` as the only production runtime mode. The server owns Studio publication state (`active`, optional `lastKnownGood`, kill switch) and returns one bootstrap outcome at a time; the shell validates the served runtime, retries bootstrap once with rejection context on retryable validation failures, and otherwise renders deterministic startup error UI. Selected-mode regression coverage should exercise the real loader, bootstrap route, filtered action catalog, and host bridge path rather than isolated contract stubs only. + +**Tech Stack:** Bun, TypeScript, React 19, Elysia, shared Zod runtime contracts, node:test, Nx + +--- + +> Local workflow note: `docs/plans/` is local-only and must remain untracked. Do not include this plan file in commits. + +### Task 1: Update the spec-owned runtime recovery contract + +**Files:** + +- Modify: `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- Modify: `docs/specs/SPEC-002-system-architecture-and-extensibility.md` +- Modify: `packages/studio/README.md` +- Modify: `apps/server/README.md` + +**Step 1: Patch the owning spec with the new bootstrap startup envelope** + +Add the server-owned publication state and bootstrap ready envelope to `SPEC-006`, including the retry query params and the disabled/unavailable error outcomes. + +```ts +export type StudioBootstrapRejectionReason = + | "integrity" + | "signature" + | "compatibility"; + +export type StudioBootstrapReadyResponse = { + data: { + status: "ready"; + source: "active" | "lastKnownGood"; + manifest: StudioBootstrapManifest; + recovery?: { + rejectedBuildId: string; + rejectionReason: StudioBootstrapRejectionReason; + }; + }; +}; +``` + +**Step 2: Align the supporting architecture and operator docs** + +Update `SPEC-002`, `packages/studio/README.md`, and `apps/server/README.md` so they all describe: + +- server-owned `active` vs `lastKnownGood` +- one-shot bootstrap retry with rejection context +- kill-switch via server config/env +- `module` remaining the only production mode + +**Step 3: Run a targeted terminology check** + +Run: + +```bash +rg -n "lastKnownGood|rejectedBuildId|rejectionReason|STUDIO_RUNTIME_DISABLED|STUDIO_RUNTIME_UNAVAILABLE" docs/specs/SPEC-006-studio-runtime-and-ui.md docs/specs/SPEC-002-system-architecture-and-extensibility.md packages/studio/README.md apps/server/README.md +``` + +Expected: each new runtime-recovery term appears in the updated docs. + +**Step 4: Run format verification** + +Run: + +```bash +bun run format:check +``` + +Expected: PASS + +**Step 5: Commit the spec/docs delta** + +```bash +git add docs/specs/SPEC-006-studio-runtime-and-ui.md docs/specs/SPEC-002-system-architecture-and-extensibility.md packages/studio/README.md apps/server/README.md +git commit -m "docs(studio): define runtime recovery contract" +``` + +### Task 2: Add shared bootstrap startup contracts and validators + +**Files:** + +- Modify: `packages/shared/src/lib/contracts/extensibility.ts` +- Modify: `packages/shared/src/lib/contracts/extensibility.test.ts` +- Modify: `packages/shared/src/index.ts` +- Modify: `packages/shared/README.md` + +**Step 1: Write the failing shared-contract tests** + +Add tests for: + +- accepting a ready bootstrap envelope with `source: "active"` +- accepting a fallback envelope with `source: "lastKnownGood"` and `recovery` +- rejecting invalid `rejectionReason` +- rejecting malformed `source` + +```ts +const ready = { + data: { + status: "ready", + source: "lastKnownGood", + manifest: validManifest, + recovery: { + rejectedBuildId: "bad-build", + rejectionReason: "integrity", + }, + }, +}; + +assertStudioBootstrapReadyResponse(ready); +``` + +**Step 2: Run the targeted shared tests to verify failure** + +Run: + +```bash +bun --cwd packages/shared test ./src/lib/contracts/extensibility.test.ts +``` + +Expected: FAIL because the new startup-envelope contract is not implemented yet. + +**Step 3: Implement the minimal shared types and runtime assertions** + +Add: + +- `StudioBootstrapRejectionReason` +- `StudioBootstrapReadyPayload` +- `StudioBootstrapReadyResponse` +- `assertStudioBootstrapReadyResponse(...)` + +Export them through `packages/shared/src/index.ts` and document the ready-envelope contract in `packages/shared/README.md`. + +**Step 4: Re-run the targeted shared tests** + +Run: + +```bash +bun --cwd packages/shared test ./src/lib/contracts/extensibility.test.ts +``` + +Expected: PASS + +**Step 5: Commit the shared-contract slice** + +```bash +git add packages/shared/src/lib/contracts/extensibility.ts packages/shared/src/lib/contracts/extensibility.test.ts packages/shared/src/index.ts packages/shared/README.md +git commit -m "feat(shared): add studio bootstrap ready contract" +``` + +### Task 3: Implement server publication state and bootstrap decision flow + +**Files:** + +- Modify: `apps/server/src/lib/env.ts` +- Modify: `apps/server/src/lib/env.test.ts` +- Modify: `apps/server/src/lib/studio-bootstrap.ts` +- Modify: `apps/server/src/lib/studio-bootstrap.test.ts` +- Modify: `apps/server/src/lib/server.ts` +- Modify: `apps/server/src/lib/health.test.ts` +- Modify: `apps/server/src/lib/runtime-with-modules.test.ts` + +**Step 1: Write the failing server tests** + +Cover: + +- kill-switch env parsing +- bootstrap returns ready envelope for `active` +- bootstrap returns ready envelope for `lastKnownGood` when retry query params reject the active build +- bootstrap returns `503 STUDIO_RUNTIME_DISABLED` when the kill switch is enabled +- bootstrap returns `503 STUDIO_RUNTIME_UNAVAILABLE` when no safe build exists + +Example bootstrap assertion: + +```ts +assert.deepEqual(body.data, { + status: "ready", + source: "lastKnownGood", + manifest: fallbackManifest, + recovery: { + rejectedBuildId: activeManifest.buildId, + rejectionReason: "integrity", + }, +}); +``` + +**Step 2: Run the targeted server tests to verify failure** + +Run: + +```bash +bun --cwd apps/server test ./src/lib/env.test.ts ./src/lib/studio-bootstrap.test.ts ./src/lib/health.test.ts ./src/lib/runtime-with-modules.test.ts +``` + +Expected: FAIL because the server still serves a raw manifest-only bootstrap payload and has no kill-switch/publication fallback logic. + +**Step 3: Implement the minimal server publication-state path** + +Add: + +- a parsed env/config flag for the operator kill switch +- a publication-state shape in `studio-bootstrap.ts` that can hold `active` and optional `lastKnownGood` +- bootstrap route logic in `server.ts` that: + - returns active ready payload on normal startup + - returns fallback ready payload when retry query params reject the active build and `lastKnownGood` exists + - returns deterministic `503` error envelopes when disabled or unavailable + +Do not add a new public mutation API for this task. + +**Step 4: Re-run the targeted server tests** + +Run: + +```bash +bun --cwd apps/server test ./src/lib/env.test.ts ./src/lib/studio-bootstrap.test.ts ./src/lib/health.test.ts ./src/lib/runtime-with-modules.test.ts +``` + +Expected: PASS + +**Step 5: Commit the server bootstrap slice** + +```bash +git add apps/server/src/lib/env.ts apps/server/src/lib/env.test.ts apps/server/src/lib/studio-bootstrap.ts apps/server/src/lib/studio-bootstrap.test.ts apps/server/src/lib/server.ts apps/server/src/lib/health.test.ts apps/server/src/lib/runtime-with-modules.test.ts +git commit -m "feat(server): add studio runtime recovery bootstrap" +``` + +### Task 4: Implement shell retry-on-rejection and deterministic disabled-state UI + +**Files:** + +- Modify: `packages/studio/src/lib/studio-loader.ts` +- Modify: `packages/studio/src/lib/studio-loader.test.ts` +- Modify: `packages/studio/src/lib/studio-component.tsx` +- Modify: `packages/studio/src/lib/studio.test.ts` + +**Step 1: Write the failing loader and shell tests** + +Add tests for: + +- parsing the new ready bootstrap envelope +- retrying bootstrap once with `rejectedBuildId` and `rejectionReason=integrity` +- surfacing `STUDIO_RUNTIME_DISABLED` +- surfacing `STUDIO_RUNTIME_UNAVAILABLE` +- stopping after one retry instead of looping + +Example retry expectation: + +```ts +assert.deepEqual(fetchLog, [ + "http://localhost:4000/api/v1/studio/bootstrap", + "http://localhost:4000/api/v1/studio/assets/active-build/runtime.mjs", + "http://localhost:4000/api/v1/studio/bootstrap?rejectedBuildId=active-build&rejectionReason=integrity", + "http://localhost:4000/api/v1/studio/assets/fallback-build/runtime.mjs", +]); +``` + +**Step 2: Run the targeted studio loader tests to verify failure** + +Run: + +```bash +bun --cwd packages/studio test ./src/lib/studio-loader.test.ts ./src/lib/studio.test.ts +``` + +Expected: FAIL because the loader still expects `{ data: StudioBootstrapManifest }` and has no retry/disabled logic. + +**Step 3: Implement the minimal loader retry path** + +Update the loader to: + +- parse `StudioBootstrapReadyResponse` +- classify retryable validation failures as `integrity`, `signature`, or `compatibility` +- re-request bootstrap once with rejection query params +- stop on non-retryable failures or after a failed fallback load + +**Step 4: Update deterministic startup error descriptions** + +Extend `describeStudioStartupError(...)` and the startup shell markup so disabled/unavailable states render explicit operator-facing copy instead of falling through to generic crash text. + +**Step 5: Re-run the targeted studio loader tests** + +Run: + +```bash +bun --cwd packages/studio test ./src/lib/studio-loader.test.ts ./src/lib/studio.test.ts +``` + +Expected: PASS + +**Step 6: Commit the shell recovery slice** + +```bash +git add packages/studio/src/lib/studio-loader.ts packages/studio/src/lib/studio-loader.test.ts packages/studio/src/lib/studio-component.tsx packages/studio/src/lib/studio.test.ts +git commit -m "feat(studio): retry bootstrap on runtime rejection" +``` + +### Task 5: Add selected-mode MDX preview and authorization-filtered action fixtures + +**Files:** + +- Modify: `packages/studio/src/lib/remote-studio-app.tsx` +- Modify: `packages/studio/src/lib/remote-studio-app.test.ts` +- Modify: `packages/studio/src/lib/action-catalog-adapter.ts` +- Modify: `apps/server/src/lib/health.test.ts` + +**Step 1: Write the failing remote-runtime tests** + +Add tests that prove: + +- document/editor route calls `context.hostBridge.renderMdxPreview(...)` +- preview cleanup runs on route change/unmount +- Studio action rendering uses only the filtered catalog returned by `/api/v1/actions` +- hidden actions do not appear in the rendered action strip + +Example host-bridge assertion: + +```ts +assert.deepEqual(previewCalls, [ + { + componentName: "HeroBanner", + props: { title: "Launch" }, + key: "preview:content.document", + }, +]); +``` + +**Step 2: Run the targeted remote-runtime tests to verify failure** + +Run: + +```bash +bun --cwd packages/studio test ./src/lib/remote-studio-app.test.ts +``` + +Expected: FAIL because the remote runtime does not yet exercise the host bridge or render action UI from the filtered action catalog. + +**Step 3: Implement the minimal remote-runtime behavior** + +Add: + +- a small document-route preview surface that calls `renderMdxPreview(...)` +- a small action strip driven by `createStudioActionCatalogAdapter(...)` +- deterministic loading/error fallbacks that never synthesize hidden actions locally + +Keep the implementation minimal and selected-mode only. + +**Step 4: Re-run the targeted remote-runtime tests** + +Run: + +```bash +bun --cwd packages/studio test ./src/lib/remote-studio-app.test.ts +``` + +Expected: PASS + +**Step 5: Re-run the existing forced-invocation server test** + +Run: + +```bash +bun --cwd apps/server test ./src/lib/health.test.ts +``` + +Expected: PASS, including the hidden-action forced-route rejection case. + +**Step 6: Commit the selected-mode fixture slice** + +```bash +git add packages/studio/src/lib/remote-studio-app.tsx packages/studio/src/lib/remote-studio-app.test.ts packages/studio/src/lib/action-catalog-adapter.ts apps/server/src/lib/health.test.ts +git commit -m "feat(studio): cover mdx preview and hidden actions" +``` + +### Task 6: Run full task verification and final hygiene checks + +**Files:** + +- Verify only + +**Step 1: Run the targeted package regression commands** + +Run: + +```bash +bun --cwd packages/shared test ./src/lib/contracts/extensibility.test.ts +bun --cwd apps/server test ./src/lib/env.test.ts ./src/lib/studio-bootstrap.test.ts ./src/lib/health.test.ts ./src/lib/runtime-with-modules.test.ts +bun --cwd packages/studio test ./src/lib/studio-loader.test.ts ./src/lib/studio.test.ts ./src/lib/remote-studio-app.test.ts +``` + +Expected: PASS + +**Step 2: Run the Studio embed smoke scenario** + +Run: + +```bash +bun run studio:embed:smoke +``` + +Expected: PASS + +**Step 3: Run formatting and workspace checks** + +Run: + +```bash +bun run format:check +bun run check +``` + +Expected: PASS + +**Step 4: Verify git hygiene** + +Run: + +```bash +git status --short +``` + +Expected: + +- only intended tracked files are modified or committed +- local-only paths remain unstaged +- `docs/plans/` remains untracked + +**Step 5: Create the final task-scoped commit** + +```bash +git add docs/specs/SPEC-006-studio-runtime-and-ui.md docs/specs/SPEC-002-system-architecture-and-extensibility.md apps/server/README.md packages/studio/README.md packages/shared/src/lib/contracts/extensibility.ts packages/shared/src/lib/contracts/extensibility.test.ts packages/shared/src/index.ts packages/shared/README.md apps/server/src/lib/env.ts apps/server/src/lib/env.test.ts apps/server/src/lib/studio-bootstrap.ts apps/server/src/lib/studio-bootstrap.test.ts apps/server/src/lib/server.ts apps/server/src/lib/health.test.ts apps/server/src/lib/runtime-with-modules.test.ts packages/studio/src/lib/studio-loader.ts packages/studio/src/lib/studio-loader.test.ts packages/studio/src/lib/studio-component.tsx packages/studio/src/lib/studio.test.ts packages/studio/src/lib/remote-studio-app.tsx packages/studio/src/lib/remote-studio-app.test.ts packages/studio/src/lib/action-catalog-adapter.ts +git commit -m "feat(studio): harden module runtime startup" +``` diff --git a/.ai/plans/2026-03-24-cms-68-local-mdx-component-catalog-design.md b/.ai/plans/2026-03-24-cms-68-local-mdx-component-catalog-design.md new file mode 100644 index 00000000..90ef1f4a --- /dev/null +++ b/.ai/plans/2026-03-24-cms-68-local-mdx-component-catalog-design.md @@ -0,0 +1,161 @@ +# CMS-68 Local MDX Component Catalog Design + +> Local-only planning note. This file is not canonical product documentation and should remain uncommitted. + +## Summary + +`CMS-68` should no longer be implemented as a backend schema-sync feature. +The embedded Studio runs inside the host app and must resolve the host app's +actual React MDX components locally. That makes component discovery, preview, +and custom props-editor resolution a host-local runtime concern, not a server +registry concern. + +## Problem + +The current specs drifted into a model where schema sync persists optional +`extractedComponents` metadata on the backend and implies that Studio reads +component registration metadata from the server. + +That conflicts with the approved embedding architecture: + +- Studio is embedded in the host app. +- MDX preview should use the host app's real React components. +- The backend stores MDX content and does not need to understand component + implementations. + +## Approved Direction + +### Source of truth + +`mdcms.config.ts` remains the single source of truth for MDX component +registration. + +### Backend scope + +The backend does not persist, validate, or expose a component catalog. + +- Remove `extractedComponents` from the schema-sync contract. +- Keep schema sync focused on content type registry state only. +- Treat MDX component usage as opaque content stored in markdown/MDX bodies. + +### Studio runtime scope + +The embedded Studio runtime reads component registrations from local config and +uses local runtime loaders for executable pieces. + +- Metadata for insertion/edit UI comes from `config.components`. +- Actual preview components are resolved locally in the host bundle. +- Custom props editors are resolved locally in the host bundle. + +## Public API Shape + +The public embed story should stay simple and avoid manual bridge plumbing or +codegen. + +Supported embedding pattern: + +```tsx +"use client"; + +import { Studio } from "@mdcms/studio"; +import config from "../../../mdcms.config"; + +export default function AdminPage() { + return ; +} +``` + +`Studio` should accept a config object that includes MDX component metadata plus +client-only loader callbacks on each component registration. + +Example authoring shape: + +```ts +components: [ + { + name: "Chart", + importPath: "@/components/mdx/Chart", + load: () => import("@/components/mdx/Chart").then((m) => m.Chart), + description: "Renders a data chart", + propHints: { + color: { widget: "color-picker" }, + }, + }, + { + name: "PricingTable", + importPath: "@/components/mdx/PricingTable", + load: () => + import("@/components/mdx/PricingTable").then((m) => m.PricingTable), + loadPropsEditor: () => + import("@/components/mdx/PricingTable.editor").then((m) => m.default), + description: "Pricing table", + }, +]; +``` + +## Internal Wiring + +The current host-bridge concept remains useful as an internal runtime boundary +between the shell and the dynamically loaded Studio bundle, but it should not +be user-authored boilerplate for MDX components. + +Implementation direction: + +- `@mdcms/studio` derives local MDX catalog metadata from `config.components`. +- `@mdcms/studio` builds any internal bridge/capability objects itself. +- The dynamically loaded Studio runtime receives: + - serializable MDX catalog metadata + - internal executable callbacks for preview/editor resolution + +## Consequences + +### Benefits + +- No backend component sync. +- No component registry persistence in the database. +- One source of truth for component registration. +- No extra binding boilerplate beyond the authored config. +- No code generation step. + +### Tradeoffs + +- The supported embed pattern for MDX component features becomes client-side, + because loader callbacks are not server-to-client serializable. +- `mdcms.config.ts` becomes responsible for both metadata and runtime loader + declarations for components. +- `createStudioEmbedConfig(...)` cannot remain the only documented path for + MDX-aware embedding if it strips runtime loaders away. + +## Required Spec Delta + +### SPEC-004 + +- Remove `extractedComponents` from registry model prose. +- Remove `extractedComponents?` from `PUT /api/v1/schema`. +- Remove any examples that imply backend persistence of MDX component metadata. + +### SPEC-006 + +- Update the embed contract to document client-side embedding for MDX component + features. +- Expand the shell/runtime contract so the remote runtime receives local MDX + catalog metadata and internal local resolvers from the shell package, not the + backend. + +### SPEC-007 + +- Replace "props are sent to the server" language with local embedded-Studio + extraction/runtime language. +- Define the local component-catalog contract owned by the embedded Studio + runtime. +- Clarify that preview and custom editor resolution are host-local concerns. + +## Acceptance Mapping + +- "Registered components are persisted and queryable by Studio" is no longer the + right architecture and should be reinterpreted as "registered components are + locally available to the embedded Studio runtime from `mdcms.config.ts`." +- "Foundational behavior is reusable by dependent tasks" maps to: + - stable local component registration contract + - stable extracted prop metadata shape + - stable preview/editor loader hooks for downstream MDX tasks diff --git a/.ai/plans/2026-03-24-cms-68-local-mdx-component-catalog.md b/.ai/plans/2026-03-24-cms-68-local-mdx-component-catalog.md new file mode 100644 index 00000000..55f2227c --- /dev/null +++ b/.ai/plans/2026-03-24-cms-68-local-mdx-component-catalog.md @@ -0,0 +1,418 @@ +# CMS-68 Local MDX Component Catalog Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the server-backed MDX component sync model with a host-local Studio component catalog sourced from `mdcms.config.ts`, while keeping the backend ignorant of component implementations. + +**Architecture:** Update the canonical specs first so they describe a local embedded-Studio component catalog instead of backend schema-sync persistence. Then extend the shared config contract to support client-only component loader callbacks, plumb that data through the Studio shell/runtime boundary, and delete the obsolete backend `extractedComponents` schema-sync contract and storage. + +**Tech Stack:** Bun, Nx, TypeScript, React, node:test, Zod, Drizzle, Markdown specs under `docs/specs/` + +--- + +### Task 1: Update Canonical Specs Before Code Changes + +**Files:** + +- Modify: `docs/specs/SPEC-004-schema-system-and-sync.md` +- Modify: `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- Modify: `docs/specs/SPEC-007-editor-mdx-and-collaboration.md` +- Reference: `docs/adrs/ADR-003-studio-delivery-approach-c.md` + +**Step 1: Edit the schema-sync spec to remove backend component catalog sync** + +- Remove `optional extractedComponents` from the registry model section. +- Remove `extractedComponents?` from the `PUT /api/v1/schema` contract table. +- Keep schema sync narrowly about content type registry state. + +**Step 2: Edit the Studio runtime spec to define local component catalog delivery** + +- Document that MDX-aware Studio embedding is client-side when loader callbacks + are used. +- Document that the shell/runtime boundary carries local MDX catalog metadata + and local executable resolvers from the host bundle. +- Keep the backend runtime publication model unchanged. + +**Step 3: Edit the MDX spec to move component extraction fully local** + +- Replace "sent to the server" language with "consumed by the embedded Studio + runtime". +- Define the local component-catalog contract and loader responsibilities. +- Clarify that preview and custom editor resolution happen locally in the host + app context. + +**Step 4: Review the three specs together for consistency** + +Run: `rg -n "extractedComponents|sent to the server|queryable by Studio|schema sync" docs/specs/SPEC-004-schema-system-and-sync.md docs/specs/SPEC-006-studio-runtime-and-ui.md docs/specs/SPEC-007-editor-mdx-and-collaboration.md` + +Expected: no remaining contradictory language about backend component-catalog sync. + +**Step 5: Commit** + +```bash +git add docs/specs/SPEC-004-schema-system-and-sync.md docs/specs/SPEC-006-studio-runtime-and-ui.md docs/specs/SPEC-007-editor-mdx-and-collaboration.md +git commit -m "docs: move mdx component catalog to studio runtime" +``` + +### Task 2: Extend Shared Config Contracts for Local Runtime Loaders + +**Files:** + +- Modify: `packages/shared/src/lib/contracts/config.ts` +- Modify: `packages/shared/src/lib/contracts/config.test.ts` +- Modify: `packages/shared/README.md` + +**Step 1: Write the failing tests for component loader authoring** + +Add tests in `packages/shared/src/lib/contracts/config.test.ts` that assert: + +- `defineConfig({... components: [...] })` accepts component registrations with + optional `load` and `loadPropsEditor` function fields. +- `parseMdcmsConfig(...)` continues to normalize serializable component metadata + and ignores runtime-only loader callbacks. + +Example test shape: + +```ts +test("parseMdcmsConfig ignores runtime-only component loader callbacks", () => { + const config = defineConfig({ + project: "marketing-site", + serverUrl: "http://localhost:4000", + components: [ + { + name: "Chart", + importPath: "@/components/mdx/Chart", + load: async () => null, + loadPropsEditor: async () => null, + }, + ], + }); + + const parsed = parseMdcmsConfig(config); + + assert.deepEqual(parsed.components, [ + { + name: "Chart", + importPath: "@/components/mdx/Chart", + }, + ]); +}); +``` + +**Step 2: Run the shared contract test to verify it fails** + +Run: `bun test packages/shared/src/lib/contracts/config.test.ts` + +Expected: FAIL because `MdcmsComponentRegistration` does not yet allow +`load` / `loadPropsEditor`. + +**Step 3: Write the minimal shared contract changes** + +In `packages/shared/src/lib/contracts/config.ts`: + +- Extend `MdcmsComponentRegistration` with: + - `load?: () => Promise` + - `loadPropsEditor?: () => Promise` +- Keep `ParsedMdcmsComponentRegistration` serializable and metadata-only. +- Add a short code comment explaining that runtime loader callbacks are + host-local Studio concerns and are intentionally stripped by the shared parser. + +**Step 4: Run the shared contract test to verify it passes** + +Run: `bun test packages/shared/src/lib/contracts/config.test.ts` + +Expected: PASS + +**Step 5: Update package docs and commit** + +- Document the new runtime-only component loader fields in + `packages/shared/README.md`. + +```bash +git add packages/shared/src/lib/contracts/config.ts packages/shared/src/lib/contracts/config.test.ts packages/shared/README.md +git commit -m "feat: add local mdx component loader fields" +``` + +### Task 3: Change the Studio Embed Contract to Accept MDX-Aware Local Config + +**Files:** + +- Modify: `packages/studio/src/lib/studio.ts` +- Modify: `packages/studio/src/lib/studio.test.ts` +- Modify: `packages/studio/README.md` +- Optional modify: `apps/studio-example/app/admin/[[...path]]/page.tsx` +- Optional create: `apps/studio-example/app/admin/admin-studio-client.tsx` + +**Step 1: Write the failing tests for MDX-aware Studio config** + +Add tests in `packages/studio/src/lib/studio.test.ts` that assert: + +- `Studio` runtime config accepts raw `SharedMdcmsConfig` with component loader + callbacks. +- `createStudioEmbedConfig(...)` behavior is explicitly documented: + - either it remains minimal metadata-only + - or it preserves MDX loader fields if you decide to broaden it +- MDX component registrations are visible to downstream Studio runtime code in a + serializable metadata form plus runtime-local loader access. + +Suggested minimal test shape: + +```ts +test("createStudioEmbedConfig preserves mdx component metadata needed by Studio", () => { + const config = createStudioEmbedConfig({ + project: "marketing-site", + environment: "staging", + serverUrl: "http://localhost:4000", + components: [ + { + name: "Chart", + importPath: "@/components/mdx/Chart", + load: async () => null, + }, + ], + }); + + assert.equal(config.components?.[0]?.name, "Chart"); +}); +``` + +If the final design is to let `Studio` accept the raw config directly, change +the assertion to cover the widened `Studio` config type instead. + +**Step 2: Run the Studio config test to verify it fails** + +Run: `bun test packages/studio/src/lib/studio.test.ts` + +Expected: FAIL because the Studio config types currently strip components down +to `{ project, environment, serverUrl }`. + +**Step 3: Implement the minimal Studio config contract** + +In `packages/studio/src/lib/studio.ts`: + +- Widen the accepted Studio config type so the shell can receive MDX component + registrations from `mdcms.config.ts`. +- Decide one of these two exact implementations and document it in code: + - `Studio` accepts raw shared config directly and `createStudioEmbedConfig` + becomes optional/minimal; or + - `createStudioEmbedConfig` preserves the MDX component registration data + needed at runtime. + +In `packages/studio/README.md`: + +- Replace the server-component-only example with the approved client-side embed + pattern for MDX-aware Studio usage. +- Explain that no backend component sync is required. + +**Step 4: Run the Studio config test to verify it passes** + +Run: `bun test packages/studio/src/lib/studio.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/studio/src/lib/studio.ts packages/studio/src/lib/studio.test.ts packages/studio/README.md apps/studio-example/app/admin/[[...path]]/page.tsx apps/studio-example/app/admin/admin-studio-client.tsx +git commit -m "feat: accept local mdx component config in studio" +``` + +### Task 4: Build the Internal Studio MDX Runtime Resolver + +**Files:** + +- Modify: `packages/shared/src/lib/contracts/extensibility.ts` +- Modify: `packages/shared/src/lib/contracts/extensibility.test.ts` +- Modify: `packages/studio/src/lib/studio-loader.ts` +- Modify: `packages/studio/src/lib/studio-loader.test.ts` +- Modify: `packages/studio/src/lib/studio-component.tsx` +- Modify: `packages/studio/src/lib/remote-studio-app.tsx` +- Test/inspect: `packages/studio/src/lib/remote-studio-app.test.ts` + +**Step 1: Write the failing tests for local MDX runtime delivery** + +Add tests that assert: + +- the shell builds an internal runtime capability object from `config.components` + without requiring user-authored bridge code +- the runtime mount context carries MDX catalog metadata needed for insertion UI +- preview resolution uses the local loader callbacks, not backend data + +Suggested loader test shape: + +```ts +test("loadStudioRuntime passes mdx catalog metadata to the mounted runtime", async () => { + const contexts: unknown[] = []; + + await loadStudioRuntime({ + config: { + project: "marketing-site", + environment: "staging", + serverUrl: "http://localhost:4000", + components: [ + { + name: "Chart", + importPath: "@/components/mdx/Chart", + load: async () => null, + }, + ], + }, + basePath: "/admin", + container: {}, + fetcher: async () => new Response(/* valid bootstrap/runtime fixture */), + loadRemoteModule: async () => ({ + mount: (_container, context) => { + contexts.push(context); + return () => {}; + }, + }), + }); + + assert.equal((contexts[0] as any).mdx.catalog[0].name, "Chart"); +}); +``` + +**Step 2: Run the loader/runtime tests to verify they fail** + +Run: `bun test packages/studio/src/lib/studio-loader.test.ts packages/studio/src/lib/remote-studio-app.test.ts` + +Expected: FAIL because no MDX catalog/runtime capability plumbing exists yet. + +**Step 3: Implement the minimal internal resolver** + +- Extend the shared shell/runtime contract in + `packages/shared/src/lib/contracts/extensibility.ts` with the MDX catalog + metadata needed by the mounted runtime. +- Keep the executable preview/editor resolver internal to `@mdcms/studio`. +- In `packages/studio/src/lib/studio-component.tsx` and + `packages/studio/src/lib/studio-loader.ts`, synthesize the runtime capability + object from `config.components`. +- In `packages/studio/src/lib/remote-studio-app.tsx`, consume local MDX catalog + metadata for insertion/edit UI instead of expecting backend data. + +**Step 4: Run the loader/runtime tests to verify they pass** + +Run: `bun test packages/studio/src/lib/studio-loader.test.ts packages/studio/src/lib/remote-studio-app.test.ts packages/shared/src/lib/contracts/extensibility.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/contracts/extensibility.ts packages/shared/src/lib/contracts/extensibility.test.ts packages/studio/src/lib/studio-loader.ts packages/studio/src/lib/studio-loader.test.ts packages/studio/src/lib/studio-component.tsx packages/studio/src/lib/remote-studio-app.tsx packages/studio/src/lib/remote-studio-app.test.ts +git commit -m "feat: resolve mdx components locally in studio runtime" +``` + +### Task 5: Remove Obsolete Backend Schema-Sync Component Plumbing + +**Files:** + +- Modify: `packages/shared/src/lib/contracts/schema.ts` +- Modify: `packages/shared/src/lib/contracts/schema.test.ts` +- Modify: `apps/server/src/lib/schema-api.ts` +- Modify: `apps/server/src/lib/schema-api.test.ts` +- Modify: `apps/server/src/lib/db/schema.ts` +- Modify: `apps/server/src/lib/db/schema.contract.test.ts` +- Modify: `apps/server/src/lib/content-api-test-support.ts` +- Modify: `apps/server/README.md` +- Modify: `apps/cli/README.md` + +**Step 1: Write the failing tests for removing `extractedComponents`** + +Update tests so they expect: + +- `SchemaRegistrySyncPayload` no longer accepts or documents + `extractedComponents` +- server schema-sync persistence no longer writes that field +- DB schema contract no longer includes `schema_syncs.extracted_components` + +Suggested schema contract test shape: + +```ts +test("assertSchemaRegistrySyncPayload rejects unknown extractedComponents field", () => { + expectInvalidInput( + () => + assertSchemaRegistrySyncPayload({ + rawConfigSnapshot: {}, + resolvedSchema: {}, + schemaHash: "hash", + extractedComponents: [], + } as never), + "payload.extractedComponents", + ); +}); +``` + +If the validator reports an unknown-field error differently, assert the real +path/message shape after updating the validator. + +**Step 2: Run the backend/schema tests to verify they fail** + +Run: `bun test packages/shared/src/lib/contracts/schema.test.ts apps/server/src/lib/schema-api.test.ts` + +Expected: FAIL because the current contracts and server schema still accept and +persist `extractedComponents`. + +**Step 3: Implement the minimal backend cleanup** + +- Remove `extractedComponents` from `SchemaRegistrySyncPayload`. +- Remove DB schema/storage for `schema_syncs.extractedComponents`. +- Remove persistence logic and test helpers that seed or assert this field. +- Update server and CLI README files so they no longer claim component metadata + travels through schema sync. + +**Step 4: Run the backend/schema tests to verify they pass** + +Run: `bun test packages/shared/src/lib/contracts/schema.test.ts apps/server/src/lib/schema-api.test.ts` + +Expected: PASS (database-backed cases may PASS/SKIP depending on local DB +availability, but no failures should remain) + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/contracts/schema.ts packages/shared/src/lib/contracts/schema.test.ts apps/server/src/lib/schema-api.ts apps/server/src/lib/schema-api.test.ts apps/server/src/lib/db/schema.ts apps/server/src/lib/db/schema.contract.test.ts apps/server/src/lib/content-api-test-support.ts apps/server/README.md apps/cli/README.md +git commit -m "refactor: remove backend mdx component sync state" +``` + +### Task 6: Final Verification and Example Flow Check + +**Files:** + +- Verify only; no required file edits + +**Step 1: Run focused package tests** + +Run: `bun test packages/shared/src/lib/contracts/config.test.ts packages/shared/src/lib/contracts/extensibility.test.ts packages/shared/src/lib/contracts/schema.test.ts packages/studio/src/lib/studio.test.ts packages/studio/src/lib/studio-loader.test.ts packages/studio/src/lib/remote-studio-app.test.ts apps/server/src/lib/schema-api.test.ts` + +Expected: PASS, with database-backed server cases allowed to SKIP when no local +Postgres test DB is available. + +**Step 2: Run repo formatting check** + +Run: `bun run format:check` + +Expected: PASS + +**Step 3: Run baseline repo validation** + +Run: `bun run check` + +Expected: PASS + +**Step 4: Inspect git status for local-only files** + +Run: `git status --short` + +Expected: + +- no staged or tracked changes for `docs/plans/` +- no staged or tracked changes for `ROADMAP_TASKS.md` +- only task-scoped product/code/doc files remain + +**Step 5: Commit the final task-scoped changes** + +```bash +git add docs/specs/SPEC-004-schema-system-and-sync.md docs/specs/SPEC-006-studio-runtime-and-ui.md docs/specs/SPEC-007-editor-mdx-and-collaboration.md packages/shared/src/lib/contracts/config.ts packages/shared/src/lib/contracts/config.test.ts packages/shared/src/lib/contracts/extensibility.ts packages/shared/src/lib/contracts/extensibility.test.ts packages/shared/src/lib/contracts/schema.ts packages/shared/src/lib/contracts/schema.test.ts packages/studio/src/lib/studio.ts packages/studio/src/lib/studio.test.ts packages/studio/src/lib/studio-component.tsx packages/studio/src/lib/studio-loader.ts packages/studio/src/lib/studio-loader.test.ts packages/studio/src/lib/remote-studio-app.tsx packages/studio/src/lib/remote-studio-app.test.ts apps/server/src/lib/schema-api.ts apps/server/src/lib/schema-api.test.ts apps/server/src/lib/db/schema.ts apps/server/src/lib/db/schema.contract.test.ts apps/server/src/lib/content-api-test-support.ts packages/shared/README.md packages/studio/README.md apps/server/README.md apps/cli/README.md apps/studio-example/app/admin/[[...path]]/page.tsx apps/studio-example/app/admin/admin-studio-client.tsx +git commit -m "feat: move mdx component catalog into studio runtime" +``` diff --git a/.ai/plans/2026-03-25-cms-69-typescript-prop-extraction-design.md b/.ai/plans/2026-03-25-cms-69-typescript-prop-extraction-design.md new file mode 100644 index 00000000..003a7c4a --- /dev/null +++ b/.ai/plans/2026-03-25-cms-69-typescript-prop-extraction-design.md @@ -0,0 +1,162 @@ +# CMS-69 TypeScript Prop Extraction Design + +> Local-only planning note. This file is not canonical product documentation and should remain uncommitted. + +## Summary + +`CMS-69` should establish a stable local prop-extraction contract for MDX +components without inventing a browser-time reflection system or a required +manual codegen command. + +The embedded Studio must receive plain serializable prop metadata, but the +source of truth remains the host app's TypeScript component definitions. That +means extraction belongs on a Node-side integration path in the consumer +workspace, not in the browser and not on the backend. + +## Problem + +The current codebase has the first half of the local MDX catalog model from +`CMS-68`: + +- `config.components` is the source of truth. +- Studio can pass through `extractedProps` if somebody adds them manually. +- The shared contract still treats `extractedProps` as `Record`. + +What is still missing for `CMS-69`: + +- a normative serializable extracted-prop shape +- deterministic filtering rules for unsupported props +- a reusable Node-side extractor that can inspect local TypeScript source +- a supported Studio integration path that prepares config before the client + shell renders + +Without that, downstream tasks (`CMS-70` through `CMS-74`) would build on an +unstable pseudo-contract. + +## Approved Direction + +### Extraction boundary + +Prop extraction happens on a Node-side integration path in the consumer +workspace. + +Supported integration points: + +- framework server components +- framework/build hooks +- dev-server hooks +- explicit local scripts + +Not supported: + +- browser-time TypeScript inspection +- backend-owned component-catalog sync +- requiring editors to type raw JSON for common props + +### Contract ownership + +`@mdcms/shared` owns the serializable extracted-prop contract and its runtime +validation rules. + +Planned ownership split: + +- `@mdcms/shared` + - exported `MdxExtractedProp` / `MdxExtractedProps` contract + - strict validation of extracted props inside Studio mount/catalog contracts + - a Node-only extractor subpath reusable by Studio and future CLI/tooling +- `@mdcms/studio/runtime` + - a Studio-facing prepare helper that consumes raw config plus workspace + context and returns config enriched with `extractedProps` +- `@mdcms/studio` + - continues to consume prepared serializable metadata only + +This keeps cross-cutting contract shape in shared while avoiding a future +dependency from CLI tooling onto the Studio package. + +### Supported normalized shapes + +The extracted metadata should stay intentionally small and fail closed: + +- `string` +- `number` +- `boolean` +- `date` +- string-literal `enum` +- `array` of `string` or `number` +- `json` only when the developer explicitly opts the prop into a JSON editor + and the TypeScript shape is JSON-serializable +- `rich-text` for `children` / `ReactNode` + +Requiredness is derived from declared TypeScript optionality only. The +extractor does not inspect runtime default expressions in component bodies. + +### Unsupported-by-default shapes + +Unsupported props are omitted from `extractedProps` and hidden from the +auto-generated Studio form: + +- functions and callbacks +- refs +- React elements/components other than `children` +- object/record/map/set/tuple/class-instance shapes without explicit `json` + opt-in +- mixed unions, intersections, unresolved generics +- arrays whose item type is not exactly `string` or `number` +- anything not JSON-serializable or not deterministically normalizable + +This keeps the contract safe and stable for `CMS-70` form mapping. + +### Integration model + +The preferred host-app flow is: + +1. import authored `mdcms.config.ts` on the Node side +2. call a prepare helper that resolves component source files and extracts prop + metadata +3. pass the prepared config into the client `` shell + +This means the existing direct-client-import story remains acceptable for +preview-only MDX registration, but auto-generated props editing depends on the +prepared config path. + +## Consequences + +### Benefits + +- No backend coupling for MDX component metadata. +- No required manual codegen command. +- Stable serializable contract for downstream form/editor tasks. +- One extractor implementation reusable by multiple local consumers. + +### Tradeoffs + +- The prepared-config path is more explicit than "just import config in a client + component." +- The extractor needs TypeScript compiler access and filesystem access on the + Node side. +- A new shared node-only export surface is needed so Studio and future CLI + flows can reuse the same extractor without depending on each other. + +## Spec Delta Applied + +The owning spec updates for this design are now in: + +- `docs/specs/SPEC-007-editor-mdx-and-collaboration.md` +- `docs/specs/SPEC-006-studio-runtime-and-ui.md` + +Those edits: + +- replace the old CLI-only extraction phrasing with a Node-side preparation + pipeline +- define the normalized `MdxExtractedProp` contract +- define fail-closed unsupported-prop filtering rules +- clarify that browser runtime never performs TypeScript inspection + +## Acceptance Mapping + +- "Extracted schema hides functions/refs and complex unsupported props" maps to + the fail-closed normalization rules plus fixture coverage. +- "Prop extraction output is stable across local MDX component-catalog + consumers" maps to shared contract types plus a reusable node-only extractor. +- "Foundational behavior is documented and reusable" maps to spec deltas, + shared runtime validation, and runtime/helper README updates. diff --git a/.ai/plans/2026-03-25-cms-69-typescript-prop-extraction.md b/.ai/plans/2026-03-25-cms-69-typescript-prop-extraction.md new file mode 100644 index 00000000..c150d4e8 --- /dev/null +++ b/.ai/plans/2026-03-25-cms-69-typescript-prop-extraction.md @@ -0,0 +1,383 @@ +# CMS-69 TypeScript Prop Extraction Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a deterministic Node-side MDX prop extraction pipeline that turns local TypeScript component props into stable serializable `extractedProps` metadata for Studio while filtering unsupported shapes by contract. + +**Architecture:** The canonical specs now define `extractedProps` as a shared serializable contract owned by `@mdcms/shared`. Implement that contract and validator in shared, add a node-only shared extractor subpath backed by the TypeScript compiler API, expose a Studio-facing prepare helper from `@mdcms/studio/runtime`, and update the Studio example/docs to pass prepared config into the client shell instead of assuming browser-time prop introspection. + +**Tech Stack:** Bun, Nx, TypeScript compiler API, React, node:test, Zod, Markdown specs/READMEs + +--- + +### Task 1: Lock the Shared Extracted-Prop Contract + +**Files:** + +- Modify: `packages/shared/src/lib/contracts/extensibility.ts` +- Modify: `packages/shared/src/lib/contracts/extensibility.test.ts` +- Modify: `packages/shared/README.md` + +**Step 1: Write the failing contract tests** + +Add tests in `packages/shared/src/lib/contracts/extensibility.test.ts` that +assert: + +- `assertStudioMountContext(...)` accepts MDX catalog entries whose + `extractedProps` values use only the allowed variants: + - `{ type: "string", required: false }` + - `{ type: "enum", required: true, values: ["bar", "line"] }` + - `{ type: "array", required: true, items: "number" }` + - `{ type: "json", required: false }` + - `{ type: "rich-text", required: false }` +- invalid extracted-prop payloads are rejected: + - unknown `type` + - `array.items = "boolean"` + - enum with empty `values` + - extra keys on prop descriptors + +Example accepted shape: + +```ts +assert.doesNotThrow(() => + assertStudioMountContext({ + apiBaseUrl: "http://localhost:4000", + basePath: "/admin", + auth: { mode: "cookie" }, + hostBridge: validHostBridge, + mdx: { + catalog: { + components: [ + { + name: "Chart", + importPath: "@/components/mdx/Chart", + extractedProps: { + title: { type: "string", required: false }, + type: { + type: "enum", + required: true, + values: ["bar", "line"], + }, + }, + }, + ], + }, + resolvePropsEditor: () => null, + }, + }), +); +``` + +**Step 2: Run the shared contract test to verify it fails** + +Run: `bun test packages/shared/src/lib/contracts/extensibility.test.ts` + +Expected: FAIL because `extractedProps` is still validated as +`Record`. + +**Step 3: Implement the shared contract** + +In `packages/shared/src/lib/contracts/extensibility.ts`: + +- export: + - `MdxExtractedProp` + - `MdxExtractedProps` +- replace the permissive `extractedProps: z.record(z.string(), z.unknown())` + with a strict schema for the approved variants +- make enum descriptors reject empty `values` +- keep the shape strict so unsupported keys fail validation + +In `packages/shared/README.md`: + +- document the extracted-prop variants and the purpose of the contract + +**Step 4: Run the shared contract test to verify it passes** + +Run: `bun test packages/shared/src/lib/contracts/extensibility.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/contracts/extensibility.ts packages/shared/src/lib/contracts/extensibility.test.ts packages/shared/README.md +git commit -m "feat(shared): define mdx extracted prop contract" +``` + +### Task 2: Build the Node-Only Shared TypeScript Extractor + +**Files:** + +- Create: `packages/shared/src/lib/mdx/extracted-props.ts` +- Create: `packages/shared/src/lib/mdx/extracted-props.test.ts` +- Create: `packages/shared/src/lib/mdx/index.ts` +- Modify: `packages/shared/package.json` +- Modify: `packages/shared/README.md` + +**Step 1: Write the failing extractor fixture tests** + +Add fixture-driven tests in `packages/shared/src/lib/mdx/extracted-props.test.ts` +that create temporary `.tsx` files and assert: + +- supported props extract correctly: + - `title?: string` + - `count: number` + - `published: boolean` + - `type: "bar" | "line"` + - `data: number[]` + - `tags?: string[]` + - `children?: ReactNode` +- unsupported props are omitted: + - `onClick?: () => void` + - `forwardedRef?: Ref` + - `options: Record` + - `pair: [number, number]` +- `json` hint opt-in re-enables a JSON-serializable object shape: + - `options: { theme: string; compact: boolean }` plus + `propHints.options = { widget: "json" }` => + `{ type: "json", required: true }` +- non-serializable `json` opt-in still fails closed: + - `handlerMap: Record void>` stays omitted even with `json` +- requiredness follows declared TypeScript only: + - `title?: string` => `required: false` + - `title: string | undefined` => `required: false` + - destructuring default values do not change the extracted `required` flag + +Example expected output: + +```ts +assert.deepEqual(result, { + title: { type: "string", required: false }, + type: { type: "enum", required: true, values: ["bar", "line"] }, + data: { type: "array", required: true, items: "number" }, +}); +``` + +**Step 2: Run the extractor test to verify it fails** + +Run: `bun test packages/shared/src/lib/mdx/extracted-props.test.ts` + +Expected: FAIL because the extractor module does not exist yet. + +**Step 3: Implement the extractor** + +In `packages/shared/src/lib/mdx/extracted-props.ts`: + +- use the TypeScript compiler API to: + - create/load a `Program` + - resolve the component source file from an absolute path + - find the exported component symbol + - resolve its props type from function params or a declared props interface +- expose a narrow API such as: + +```ts +export function extractMdxComponentProps(input: { + filePath: string; + componentName: string; + propHints?: Record; + tsconfigPath?: string; +}): MdxExtractedProps; +``` + +- normalize only the supported shapes +- omit everything else +- treat `json` opt-in as allowed only for JSON-serializable shapes +- add short comments only around the non-obvious TypeScript symbol/type walking + +In `packages/shared/src/lib/mdx/index.ts`: + +- re-export the extractor API + +In `packages/shared/package.json`: + +- add a node-oriented export such as `./mdx` +- add `typescript` as a package dependency if the extractor imports it at runtime + +In `packages/shared/README.md`: + +- document the node-only extractor subpath briefly + +**Step 4: Run the extractor and contract tests** + +Run: `bun test packages/shared/src/lib/mdx/extracted-props.test.ts packages/shared/src/lib/contracts/extensibility.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/mdx/extracted-props.ts packages/shared/src/lib/mdx/extracted-props.test.ts packages/shared/src/lib/mdx/index.ts packages/shared/package.json packages/shared/README.md +git commit -m "feat(shared): add mdx prop extraction helper" +``` + +### Task 3: Add a Studio Runtime Prepare Helper and Typed Consumption Path + +**Files:** + +- Modify: `packages/studio/src/lib/studio.ts` +- Modify: `packages/studio/src/lib/studio.test.ts` +- Modify: `packages/studio/src/lib/studio-loader.ts` +- Modify: `packages/studio/src/lib/studio-loader.test.ts` +- Modify: `packages/studio/README.md` + +**Step 1: Write the failing Studio tests** + +Add tests that assert: + +- `@mdcms/studio/runtime` exposes a prepare helper that enriches component + entries with typed `extractedProps` +- the prepare helper accepts authored config plus workspace context +- `loadStudioRuntime(...)` reads typed `extractedProps` without the current + `as { extractedProps?: unknown }` escape hatch + +Suggested test shape in `packages/studio/src/lib/studio.test.ts`: + +```ts +test("prepareStudioConfig enriches mdx component metadata from source files", async () => { + const config = await prepareStudioConfig( + { + project: "marketing-site", + environment: "staging", + serverUrl: "http://localhost:4000", + components: [ + { + name: "Chart", + importPath: "@/components/mdx/Chart", + }, + ], + }, + { + cwd: fixtureDir, + resolveImportPath: (value) => + value === "@/components/mdx/Chart" + ? join(fixtureDir, "Chart.tsx") + : value, + }, + ); + + assert.deepEqual(config.components?.[0]?.extractedProps, { + title: { type: "string", required: false }, + }); +}); +``` + +And in `packages/studio/src/lib/studio-loader.test.ts`: + +- remove the cast-based fake component shape and assert the loader accepts the + prepared typed config directly + +**Step 2: Run the Studio tests to verify they fail** + +Run: `bun test packages/studio/src/lib/studio.test.ts packages/studio/src/lib/studio-loader.test.ts` + +Expected: FAIL because there is no prepare helper and the config types do not +carry extracted props explicitly. + +**Step 3: Implement the Studio prepare helper and loader typing** + +In `packages/studio/src/lib/studio.ts`: + +- add a server-safe helper such as: + +```ts +export async function prepareStudioConfig( + config: SharedMdcmsConfig, + options: { + cwd: string; + resolveImportPath?: (value: string) => string; + tsconfigPath?: string; + }, +): Promise; +``` + +- internally call the shared node-only extractor for each component entry +- preserve authored loader callbacks and other config metadata +- require/validate `environment` before returning the prepared config + +In `packages/studio/src/lib/studio-loader.ts`: + +- replace the untyped `readExtractedProps(...)` cast with direct typed access +- keep the runtime consuming only serializable metadata plus local executable + resolvers + +In `packages/studio/README.md`: + +- update the MDX-aware embed example to show: + - server-side `prepareStudioConfig(...)` + - client-side `` +- keep the plain runtime-helper story accurate + +**Step 4: Run the Studio tests to verify they pass** + +Run: `bun test packages/studio/src/lib/studio.test.ts packages/studio/src/lib/studio-loader.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/studio/src/lib/studio.ts packages/studio/src/lib/studio.test.ts packages/studio/src/lib/studio-loader.ts packages/studio/src/lib/studio-loader.test.ts packages/studio/README.md +git commit -m "feat(studio): prepare mdx component props locally" +``` + +### Task 4: Wire the Example App and Run Verification + +**Files:** + +- Modify: `apps/studio-example/app/admin/[[...path]]/page.tsx` +- Optional create: `apps/studio-example/app/admin/admin-studio-client.tsx` +- Optional modify: `apps/studio-example/mdcms.config.ts` +- Reference: `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- Reference: `docs/specs/SPEC-007-editor-mdx-and-collaboration.md` + +**Step 1: Write the failing example integration assertion** + +Add or update a lightweight test/contract assertion that the example app can: + +- load raw authored config on the server side +- prepare it before passing it to the client `Studio` shell + +If there is no dedicated example test yet, add a type-level assertion in +`packages/studio/src/lib/studio.test.ts` or a focused smoke check in the example +route source. + +**Step 2: Run the relevant Studio/example tests to verify they fail** + +Run: `bun test packages/studio/src/lib/studio.test.ts` + +Expected: FAIL until the example/docs use the new helper path consistently. + +**Step 3: Update the example integration** + +In the example app: + +- keep the route itself server-side +- call `prepareStudioConfig(...)` +- pass the result into a small client wrapper that renders `` + +Keep the example minimal; it only needs to demonstrate the supported host-app +integration boundary. + +**Step 4: Run targeted verification** + +Run: + +```bash +bun test packages/shared/src/lib/contracts/extensibility.test.ts packages/shared/src/lib/mdx/extracted-props.test.ts +bun test packages/studio/src/lib/studio.test.ts packages/studio/src/lib/studio-loader.test.ts +bun run format:check +bun run check +``` + +Expected: + +- all targeted shared/studio tests PASS +- `bun run format:check` PASS +- `bun run check` PASS + +**Step 5: Commit** + +```bash +git add apps/studio-example/app/admin/[[...path]]/page.tsx apps/studio-example/app/admin/admin-studio-client.tsx apps/studio-example/mdcms.config.ts +git commit -m "docs(example): prepare studio config for mdx props" +``` diff --git a/.ai/plans/2026-03-25-cms-70-prop-type-form-control-mapping-design.md b/.ai/plans/2026-03-25-cms-70-prop-type-form-control-mapping-design.md new file mode 100644 index 00000000..b690555b --- /dev/null +++ b/.ai/plans/2026-03-25-cms-70-prop-type-form-control-mapping-design.md @@ -0,0 +1,124 @@ +# CMS-70 Prop Type to Form Control Mapping Design + +> Local-only planning note. This file is not canonical product documentation and should remain uncommitted. + +## Summary + +`CMS-70` should turn the extracted local MDX prop metadata from `CMS-69` into a +stable auto-form contract that downstream Studio/editor tasks can reuse without +re-deriving UI decisions ad hoc. + +The main contract wrinkle is the URL case. The approved direction is to model +URL as string formatting metadata, not as a new widget override. That keeps +type extraction, default form mapping, and later widget overrides clearly +separated. + +## Problem + +The codebase already has: + +- local MDX component catalog transport from host app to embedded Studio +- deterministic TypeScript prop extraction in `@mdcms/shared` +- fail-closed omission of unsupported props +- a basic Studio distinction between "auto form available" and "custom props + editor available" + +What is still missing for `CMS-70`: + +- a shared, explicit prop-to-control mapping contract +- a deterministic URL-input signal for string props +- a reusable mapping helper instead of Studio-only branching +- proof that Studio consumes the shared mapping contract rather than only + checking whether `extractedProps` exists + +Without that, `CMS-71` and `CMS-72` would have to infer control semantics from +raw extracted prop metadata in multiple places. + +## Approved Direction + +### Contract boundary + +`@mdcms/shared` owns both: + +- the extracted MDX prop contract +- the pure auto-form mapping helper derived from that contract + +`@mdcms/studio` consumes the mapping output but does not own the mapping rules. + +### URL handling + +URL is modeled as an optional format on string props: + +- extracted prop shape: `{ type: "string", required: boolean, format?: "url" }` +- declaration source: `propHints..format = "url"` +- extraction rule: preserve the format only when the normalized prop type is + `string` + +This is intentionally not a widget. Widget overrides remain the later `CMS-71` +concern. + +### Shared auto-form output + +Introduce a pure shared mapper that turns `MdxExtractedProps` into explicit +auto-form field metadata. Planned default control kinds: + +- `string` -> `text` +- `string` with `format: "url"` -> `url` +- `number` -> `number` +- `boolean` -> `boolean` +- `enum` -> `select` +- `array` with `items: "string"` -> `string-list` +- `array` with `items: "number"` -> `number-list` +- `date` -> `date` +- `rich-text` -> `rich-text` + +Function and ref props remain hidden by omission upstream in extraction, which +already matches the spec. + +### Studio integration scope + +`CMS-70` should stop short of building the full editable props UI. The Studio +side only needs a thin proof consumer that uses the shared mapper in the +existing hidden diagnostics/runtime test path. + +That keeps this task foundational and avoids bleeding into `CMS-72`. + +## Consequences + +### Benefits + +- One mapping source of truth for Studio and future local tooling. +- URL inputs become deterministic without expanding the widget system early. +- Downstream tasks can build form rendering on explicit field metadata instead + of reinterpreting extracted props. + +### Tradeoffs + +- The extracted prop contract grows slightly to include string formatting. +- Shared code now owns one more MDX helper surface that needs tests and README + coverage. +- The product spec must explicitly define the URL format hint before + implementation. + +## Spec Delta Required + +The owning spec update belongs in: + +- `docs/specs/SPEC-007-editor-mdx-and-collaboration.md` + +Required contract delta: + +- define URL intent as `propHints..format = "url"` +- define extracted string props as optionally carrying `format: "url"` +- define Studio mapping of `{ type: "string", format: "url" }` to a URL input + with validation +- keep widget overrides limited to the existing widget list + +## Acceptance Mapping + +- "Auto-controls implement all 11 prop-type-to-form-control mappings" maps to + the shared mapping helper plus tests covering each supported control outcome. +- "Foundational behavior is documented and reusable" maps to the spec delta, + shared contract typing, shared mapping helper exports, and README notes. +- "Any newly introduced public contract ... is documented at the point of use" + maps to contract comments/README updates in the shared package. diff --git a/.ai/plans/2026-03-25-cms-70-prop-type-form-control-mapping.md b/.ai/plans/2026-03-25-cms-70-prop-type-form-control-mapping.md new file mode 100644 index 00000000..2e3324eb --- /dev/null +++ b/.ai/plans/2026-03-25-cms-70-prop-type-form-control-mapping.md @@ -0,0 +1,408 @@ +# CMS-70 Prop Type to Form Control Mapping Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a shared, deterministic MDX prop-to-form-control mapping layer that converts extracted local component prop metadata into reusable auto-form field definitions, including URL-formatted string handling, while keeping widget overrides out of scope for this task. + +**Architecture:** Apply the missing product contract first in `SPEC-007`, then extend the shared extracted-prop contract so string props can carry `format: "url"`. Build a pure shared mapping helper in `@mdcms/shared/mdx` that turns extracted props into explicit auto-form fields for the CMS-70 mappings only, and have Studio consume that helper in its diagnostics/test surface instead of treating any non-empty `extractedProps` object as an auto-form signal. + +**Tech Stack:** Bun, Nx, TypeScript, node:test, Zod, React, Markdown specs/READMEs + +--- + +### Task 1: Apply the Owning Spec Delta + +**Files:** + +- Modify: `docs/specs/SPEC-007-editor-mdx-and-collaboration.md` + +**Step 1: Update the spec prose before code** + +Edit `docs/specs/SPEC-007-editor-mdx-and-collaboration.md` so it explicitly +defines: + +- `propHints..format = "url"` as the URL intent signal +- `MdxExtractedProp` string descriptors may carry optional `format: "url"` +- `{ type: "string", format: "url" }` maps to a URL input with validation +- this is not a widget and does not expand the widget list in the later + override section + +Suggested contract snippet: + +```ts +export type MdxExtractedProp = + | { type: "string"; required: boolean; format?: "url" } + | { type: "number"; required: boolean } + | { type: "boolean"; required: boolean } + | { type: "date"; required: boolean } + | { type: "enum"; required: boolean; values: string[] } + | { type: "array"; required: boolean; items: "string" | "number" } + | { type: "json"; required: boolean } + | { type: "rich-text"; required: boolean }; +``` + +**Step 2: Verify the spec wording matches the approved direction** + +Run: `rg -n "format: \"url\"|URL input with validation|not a widget" docs/specs/SPEC-007-editor-mdx-and-collaboration.md` + +Expected: the owning spec now contains all three concepts. + +**Step 3: Commit** + +```bash +git add docs/specs/SPEC-007-editor-mdx-and-collaboration.md +git commit -m "docs(specs): clarify mdx url format mapping" +``` + +### Task 2: Extend the Shared Extracted-Prop Contract + +**Files:** + +- Modify: `packages/shared/src/lib/contracts/extensibility.ts` +- Modify: `packages/shared/src/lib/contracts/extensibility.test.ts` +- Modify: `packages/shared/README.md` + +**Step 1: Write the failing contract tests** + +Add tests in `packages/shared/src/lib/contracts/extensibility.test.ts` that +assert: + +- a string descriptor with `format: "url"` is accepted +- non-string descriptors with `format: "url"` are rejected +- unsupported format values are rejected +- existing valid descriptors still pass unchanged + +Example accepted payload: + +```ts +extractedProps: { + website: { + type: "string", + required: false, + format: "url", + }, +} +``` + +Example rejected payloads: + +```ts +extractedProps: { + publishedAt: { + type: "date", + required: false, + format: "url", + }, +} +``` + +```ts +extractedProps: { + title: { + type: "string", + required: true, + format: "email", + }, +} +``` + +**Step 2: Run the contract test to verify it fails** + +Run: `bun test packages/shared/src/lib/contracts/extensibility.test.ts` + +Expected: FAIL because the validator does not permit `format` on string props +yet. + +**Step 3: Implement the contract change** + +In `packages/shared/src/lib/contracts/extensibility.ts`: + +- extend the `MdxExtractedProp` TypeScript union so only string props can carry + optional `format: "url"` +- update the Zod schema for extracted props to mirror that shape strictly +- keep all descriptor objects strict so extra keys still fail validation + +In `packages/shared/README.md`: + +- document the string `format: "url"` option and clarify that it drives default + auto-form behavior rather than the widget override system + +**Step 4: Run the contract test to verify it passes** + +Run: `bun test packages/shared/src/lib/contracts/extensibility.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/contracts/extensibility.ts packages/shared/src/lib/contracts/extensibility.test.ts packages/shared/README.md +git commit -m "feat(shared): add mdx string format metadata" +``` + +### Task 3: Preserve URL Format During Prop Extraction + +**Files:** + +- Modify: `packages/shared/src/lib/mdx/extracted-props.ts` +- Modify: `packages/shared/src/lib/mdx/extracted-props.test.ts` + +**Step 1: Write the failing extraction tests** + +Add tests in `packages/shared/src/lib/mdx/extracted-props.test.ts` that cover: + +- `string` prop plus `propHints.website = { format: "url" }` extracts as + `{ type: "string", required: ..., format: "url" }` +- the same hint on `number`, `date`, `enum`, `array`, or `rich-text` is ignored +- unsupported hint values do not leak into extracted output + +Example fixture: + +```tsx +export interface LinkCardProps { + title: string; + website?: string; +} + +export function LinkCard(_props: LinkCardProps) { + return null; +} +``` + +Example expected output: + +```ts +{ + title: { type: "string", required: true }, + website: { type: "string", required: false, format: "url" }, +} +``` + +**Step 2: Run the extractor test to verify it fails** + +Run: `bun test packages/shared/src/lib/mdx/extracted-props.test.ts` + +Expected: FAIL because extracted string props do not preserve `format` yet. + +**Step 3: Implement the extraction logic** + +In `packages/shared/src/lib/mdx/extracted-props.ts`: + +- add a narrow helper such as `getStringFormat(propHint)` that returns `"url"` + only when: + - the hint is an object + - `format === "url"` +- apply that helper only inside the `string` normalization branch +- ignore `format` hints for all non-string normalized prop shapes + +Keep the function fail-closed. Invalid or irrelevant hints should not throw and +should not alter the extracted descriptor. + +**Step 4: Run the extractor and contract tests** + +Run: `bun test packages/shared/src/lib/mdx/extracted-props.test.ts packages/shared/src/lib/contracts/extensibility.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/mdx/extracted-props.ts packages/shared/src/lib/mdx/extracted-props.test.ts +git commit -m "feat(shared): preserve mdx url string hints" +``` + +### Task 4: Add the Shared Auto-Form Mapping Helper + +**Files:** + +- Create: `packages/shared/src/lib/mdx/auto-form.ts` +- Create: `packages/shared/src/lib/mdx/auto-form.test.ts` +- Modify: `packages/shared/src/lib/mdx/index.ts` +- Modify: `packages/shared/README.md` + +**Step 1: Write the failing mapping tests** + +Create `packages/shared/src/lib/mdx/auto-form.test.ts` with coverage for all +CMS-70 mappings: + +- `string` -> `text` +- `string` + `format: "url"` -> `url` +- `number` -> `number` +- `boolean` -> `boolean` +- `enum` -> `select` +- `array:string` -> `string-list` +- `array:number` -> `number-list` +- `date` -> `date` +- `rich-text` -> `rich-text` + +Also assert: + +- field output is deterministic and preserves input property iteration order +- `json` extracted props are omitted for now +- empty input returns `[]` + +Suggested public shape: + +```ts +type MdxAutoFormField = + | { name: string; control: "text"; required: boolean } + | { name: string; control: "url"; required: boolean } + | { name: string; control: "number"; required: boolean } + | { name: string; control: "boolean"; required: boolean } + | { name: string; control: "select"; required: boolean; options: string[] } + | { name: string; control: "string-list"; required: boolean } + | { name: string; control: "number-list"; required: boolean } + | { name: string; control: "date"; required: boolean } + | { name: string; control: "rich-text"; required: boolean }; +``` + +**Step 2: Run the mapping test to verify it fails** + +Run: `bun test packages/shared/src/lib/mdx/auto-form.test.ts` + +Expected: FAIL because the helper module does not exist yet. + +**Step 3: Implement the mapping helper** + +In `packages/shared/src/lib/mdx/auto-form.ts`: + +- export the `MdxAutoFormField` type +- export a pure function such as: + +```ts +export function createMdxAutoFormFields( + extractedProps: MdxExtractedProps | undefined, +): MdxAutoFormField[]; +``` + +- switch on each extracted prop variant and return the mapped control +- skip `json` props for this task so CMS-71 can own the widget override path +- preserve deterministic order by iterating `Object.entries(extractedProps)` + +In `packages/shared/src/lib/mdx/index.ts`: + +- export the new helper/types + +In `packages/shared/README.md`: + +- add a short section describing `createMdxAutoFormFields(...)` and the default + controls it emits + +**Step 4: Run the shared MDX tests** + +Run: `bun test packages/shared/src/lib/mdx/extracted-props.test.ts packages/shared/src/lib/mdx/auto-form.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/shared/src/lib/mdx/auto-form.ts packages/shared/src/lib/mdx/auto-form.test.ts packages/shared/src/lib/mdx/index.ts packages/shared/README.md +git commit -m "feat(shared): add mdx auto form mapping" +``` + +### Task 5: Wire the Shared Mapper into Studio Diagnostics + +**Files:** + +- Modify: `packages/studio/src/lib/remote-studio-app.tsx` +- Modify: `packages/studio/src/lib/remote-studio-app.test.ts` + +**Step 1: Write the failing Studio test** + +Update `packages/studio/src/lib/remote-studio-app.test.ts` so the document-route +diagnostics assert explicit mapped control metadata rather than only the +presence of `extractedProps`. + +For example, expect rendered markers such as: + +```html +Auto form + + +``` + +Also add a component whose extracted props contain only `json` and assert it +does not render the auto-form marker yet. + +**Step 2: Run the Studio test to verify it fails** + +Run: `bun test packages/studio/src/lib/remote-studio-app.test.ts` + +Expected: FAIL because the runtime currently treats any non-empty +`extractedProps` object as an auto-form signal. + +**Step 3: Implement the Studio consumer** + +In `packages/studio/src/lib/remote-studio-app.tsx`: + +- import `createMdxAutoFormFields` from `@mdcms/shared/mdx` +- replace `hasGeneratedPropsEditor(...)` with a helper that builds mapped fields + and checks `fields.length > 0` +- expose the mapped control kinds on the hidden diagnostics surface so the test + can prove Studio is using the shared mapping contract + +Keep this diagnostic-only. Do not build the full props form UI in this task. + +**Step 4: Run the Studio test to verify it passes** + +Run: `bun test packages/studio/src/lib/remote-studio-app.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add packages/studio/src/lib/remote-studio-app.tsx packages/studio/src/lib/remote-studio-app.test.ts +git commit -m "feat(studio): consume mdx auto form mappings" +``` + +### Task 6: Run End-to-End Verification + +**Files:** + +- Modify: none + +**Step 1: Run focused package tests** + +Run: + +```bash +bun test packages/shared/src/lib/contracts/extensibility.test.ts +bun test packages/shared/src/lib/mdx/extracted-props.test.ts +bun test packages/shared/src/lib/mdx/auto-form.test.ts +bun test packages/studio/src/lib/remote-studio-app.test.ts +``` + +Expected: all PASS + +**Step 2: Run workspace formatting check** + +Run: `bun run format:check` + +Expected: PASS + +**Step 3: Run workspace baseline check** + +Run: `bun run check` + +Expected: PASS + +**Step 4: Inspect git status** + +Run: `git status --short` + +Expected: + +- only CMS-70 code/spec files are staged or modified +- local-only files remain unstaged and uncommitted: + - `AGENTS.md` + - `ROADMAP_TASKS.md` + - `docs/plans/` + +**Step 5: Final commit** + +```bash +git add docs/specs/SPEC-007-editor-mdx-and-collaboration.md packages/shared/src/lib/contracts/extensibility.ts packages/shared/src/lib/contracts/extensibility.test.ts packages/shared/src/lib/mdx/extracted-props.ts packages/shared/src/lib/mdx/extracted-props.test.ts packages/shared/src/lib/mdx/auto-form.ts packages/shared/src/lib/mdx/auto-form.test.ts packages/shared/src/lib/mdx/index.ts packages/shared/README.md packages/studio/src/lib/remote-studio-app.tsx packages/studio/src/lib/remote-studio-app.test.ts +git commit -m "feat(shared): map mdx props to auto form controls" +``` diff --git a/.ai/plans/2026-03-25-studio-ui-runtime-mock-integration-design.md b/.ai/plans/2026-03-25-studio-ui-runtime-mock-integration-design.md new file mode 100644 index 00000000..98a2830a --- /dev/null +++ b/.ai/plans/2026-03-25-studio-ui-runtime-mock-integration-design.md @@ -0,0 +1,179 @@ +# Studio UI Runtime Mock Integration Design + +Date: 2026-03-25 +Task: CMS-47 + CMS-48 follow-on Studio UI integration + +## Goal + +Replace the current remote Studio placeholder UI with the approved `./studio_ui` admin mock inside the real `@mdcms/studio` runtime so visiting `/admin` in the host app renders the new Studio shell and route set through the existing bootstrap/runtime-delivery path. + +## Canonical Inputs + +- `ROADMAP_TASKS.md` CMS-47, CMS-48, CMS-49, CMS-50 +- `docs/specs/README.md` +- `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- `packages/studio/README.md` +- `packages/studio/src/lib/build-runtime.ts` +- `packages/studio/src/lib/remote-module.ts` +- `packages/studio/src/lib/remote-studio-app.tsx` +- `packages/studio/src/lib/remote-studio-app.test.ts` +- `apps/studio-example/app/admin/[[...path]]/page.tsx` +- `apps/studio-example/README.md` +- `studio_ui/` + +## Spec Delta + +`SPEC-006` already owns the Studio embed shell and these runtime routes: + +- `/admin` +- `/admin/content` +- `/admin/content/:type` +- `/admin/content/:type/:documentId` +- `/admin/environments` +- `/admin/users` +- `/admin/settings` +- `/admin/trash` + +The approved mock adds runtime-owned admin pages that are not yet specified in +the owning spec: + +- `/admin/media` +- `/admin/schema` +- `/admin/workflows` +- `/admin/api` + +The required spec delta is narrow: + +- add those four routes to `SPEC-006` as Studio runtime-owned navigable + surfaces +- define this phase as shell-only/mock rendering for those pages +- keep live backend contracts and mutations for those pages deferred to their + future owning work +- explicitly keep mock auth pages out of scope because Studio auth remains + governed by `SPEC-005` and the host embed flow in `SPEC-006` + +## Scope + +### In Scope + +- Port the admin mock route set into `packages/studio` +- Keep the existing bootstrap, verification, and remote-module mount flow +- Replace Next-specific routing helpers with runtime-local navigation helpers +- Add a runtime-owned styling path so the remote bundle renders independently of + host-app CSS +- Keep mock data, stub actions, and local-only state where backend wiring is not + part of this slice +- Update tests so `/admin/*` routes are asserted through the real remote runtime + +### Out of Scope + +- Wiring mock pages to live backend contracts beyond what already exists +- Reworking the host-app embed architecture in `apps/studio-example` +- Porting `(auth)` pages from `studio_ui` +- Making `/admin/media`, `/admin/schema`, `/admin/workflows`, or `/admin/api` + functional beyond the approved shell-only placeholders +- Introducing new server endpoints or mutation contracts for the placeholder + pages + +## Design Decisions + +### 1. Keep the Real Runtime Boundary Intact + +The host app should keep mounting `@mdcms/studio` at `/admin/*` through the +existing catch-all route. The visible UI change should come from updating the +remote runtime bundle in `packages/studio`, not from bypassing the runtime and +rendering the mock directly in `apps/studio-example`. + +This preserves the architecture already defined in `SPEC-006`: + +- shell fetches bootstrap +- shell verifies runtime +- shell loads remote module +- remote runtime owns the Studio UI after `mount(...)` + +### 2. Port Only the Admin Runtime Surfaces + +The `studio_ui` project contains both admin and auth pages. Only the admin route +set should move into the remote runtime. Auth remains outside the Studio runtime +surface and should continue to be owned by the host/server auth stack. + +### 3. Replace Next Router APIs With Runtime-Local Navigation + +The mock currently depends on: + +- `next/link` +- `next/navigation` +- `next/font` + +The runtime port should replace those with: + +- a base-path-aware link component that uses `history.pushState` +- pathname/params helpers backed by the remote runtime router state +- plain CSS font stacks instead of Next font loaders + +This keeps the runtime bundle framework-agnostic once it is mounted. + +### 4. Add a Runtime-Owned CSS Asset Path + +The current remote runtime uses inline styles, while the mock uses Tailwind v4 +utility classes and a global token sheet. + +The port should add a dedicated runtime styling path: + +- keep the mock's token system and visual language +- compile the runtime stylesheet as a Studio asset during `build-runtime` +- derive the stylesheet URL from the remote module URL so the mounted runtime + can install its own styles without host-app participation + +This avoids coupling the Studio UI to host-app Tailwind configuration. + +### 5. Port Selectively, Not Blindly + +Only the components actually needed by the approved route set should move into +`packages/studio`: + +- shared admin layout +- sidebar/header/shell state +- mock data and view helpers +- route page components +- only the shadcn/Radix primitives used by those pages +- editor components needed for the document route + +This keeps the first integration pass focused while still delivering the whole +approved route set. + +### 6. Keep Backend Wiring Deferred But Shape the Runtime for It + +Mock data is acceptable in this phase, but the port should keep clean seams for +future backend work: + +- route-level page components should accept data via small local selectors or + adapters rather than reading globals everywhere +- extra placeholder pages should remain obvious shell surfaces, not pretend to + be wired features +- role visibility rules should already exist for admin-only surfaces so later + real auth/session wiring can slot into the same structure + +## Verification + +Completion should be backed by: + +- spec/doc updates for the extra Studio pages +- runtime build tests covering emitted CSS assets +- remote runtime tests covering: + - route matching for the expanded `/admin/*` set + - local navigation helpers + - route rendering titles for the ported pages + - role-aware nav visibility where applicable +- loader tests proving bootstrap/mount behavior remains unchanged +- host smoke verification so `http://127.0.0.1:4173/admin` renders the new UI +- `bun run format:check` +- `bun run check` + +## Notes + +- `docs/plans/` is local-only in this repository and must remain untracked. +- The current repository layout uses `packages/studio`, not `apps/studio`, so + implementation should follow the actual workspace boundaries. +- `ROADMAP_TASKS.md` and `SPEC-006` are aligned for the existing route set, but + the extra mock pages need the spec patch before implementation starts. diff --git a/.ai/plans/2026-03-25-studio-ui-runtime-mock-integration.md b/.ai/plans/2026-03-25-studio-ui-runtime-mock-integration.md new file mode 100644 index 00000000..352d808d --- /dev/null +++ b/.ai/plans/2026-03-25-studio-ui-runtime-mock-integration.md @@ -0,0 +1,440 @@ +# Studio UI Runtime Mock Integration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Port the approved `studio_ui` admin mock into the real `@mdcms/studio` remote runtime so `/admin/*` in the host app renders the new Studio UI through the existing bootstrap/runtime path. + +**Architecture:** Keep the current `@mdcms/studio` shell, bootstrap verification, and remote module loading intact. Update the owning spec first for the extra mock-only admin pages, then add a runtime-owned CSS asset path, base-path-aware navigation helpers, and a selective port of the admin shell/pages into `packages/studio` with mock data preserved for now. + +**Tech Stack:** Bun, TypeScript, React 19, Next host app, Tailwind v4 runtime stylesheet build, Radix UI primitives, TipTap, node:test, Nx + +--- + +> Local workflow note: `docs/plans/` is local-only and must remain untracked. Do not include this plan file in commits. + +### Task 1: Patch the owning spec and point-of-use docs for the expanded admin route set + +**Files:** + +- Modify: `docs/specs/SPEC-006-studio-runtime-and-ui.md` +- Modify: `packages/studio/README.md` +- Modify: `apps/studio-example/README.md` + +**Step 1: Write the spec delta for the extra mock-backed admin pages** + +Add `/admin/media`, `/admin/schema`, `/admin/workflows`, and `/admin/api` to +the Studio internal route list in `SPEC-006`, and describe them as +runtime-owned navigable surfaces that are allowed to render shell-only/mock +content in this phase. + +```md +- `/admin/media` - Media library shell surface +- `/admin/schema` - Schema explorer shell surface +- `/admin/workflows` - Workflow shell surface +- `/admin/api` - API playground shell surface +``` + +**Step 2: Align the runtime package and host-app docs** + +Update `packages/studio/README.md` and `apps/studio-example/README.md` so both +documents describe the expanded `/admin/*` route set and clarify that the +additional pages are present as UI surfaces before backend wiring lands. + +**Step 3: Run a targeted terminology check** + +Run: + +```bash +rg -n "/admin/media|/admin/schema|/admin/workflows|/admin/api|shell-only|mock content" docs/specs/SPEC-006-studio-runtime-and-ui.md packages/studio/README.md apps/studio-example/README.md +``` + +Expected: each new route appears in the updated docs. + +**Step 4: Run format verification** + +Run: + +```bash +bun run format:check +``` + +Expected: PASS + +**Step 5: Commit the spec/docs delta** + +```bash +git add docs/specs/SPEC-006-studio-runtime-and-ui.md packages/studio/README.md apps/studio-example/README.md +git commit -m "docs(studio): define expanded admin route surfaces" +``` + +### Task 2: Add a runtime-owned stylesheet asset path for the remote Studio bundle + +**Files:** + +- Modify: `packages/studio/package.json` +- Modify: `packages/studio/src/lib/build-runtime.ts` +- Modify: `packages/studio/src/lib/build-runtime.test.ts` +- Modify: `packages/studio/src/lib/remote-module.ts` +- Create: `packages/studio/src/lib/runtime-ui/styles.css` +- Create: `packages/studio/src/lib/runtime-ui/style-installer.ts` +- Create: `packages/studio/src/lib/runtime-ui/style-installer.test.ts` + +**Step 1: Write the failing runtime-style tests** + +Add tests that assert: + +- `buildStudioRuntimeArtifacts(...)` emits a CSS asset next to the JS entry +- the emitted CSS filename is derived from the same `buildId` +- `mount(...)` installs the stylesheet once and cleans up correctly on unmount + +```ts +assert.match(result.cssFile, /^studio-runtime\.[a-f0-9]{16}\.css$/); +assert.equal(installedLinks[0]?.href.endsWith(result.cssFile), true); +``` + +**Step 2: Run the targeted tests to verify failure** + +Run: + +```bash +bun --cwd packages/studio test ./src/lib/build-runtime.test.ts ./src/lib/runtime-ui/style-installer.test.ts +``` + +Expected: FAIL because the runtime build currently emits only the JS module. + +**Step 3: Implement the minimal CSS asset pipeline** + +Update `build-runtime.ts` so it also emits a compiled stylesheet asset, and add +a small installer helper that derives the CSS URL from `import.meta.url` when +the remote runtime mounts. + +```ts +const stylesheetUrl = new URL( + import.meta.url.replace(/\.mjs$/, ".css"), +).toString(); + +const removeStyles = installStudioRuntimeStyles(stylesheetUrl); +``` + +Keep the first pass simple: one global runtime stylesheet asset owned by +`@mdcms/studio`. + +**Step 4: Re-run the targeted tests** + +Run: + +```bash +bun --cwd packages/studio test ./src/lib/build-runtime.test.ts ./src/lib/runtime-ui/style-installer.test.ts +``` + +Expected: PASS + +**Step 5: Commit the runtime-style slice** + +```bash +git add packages/studio/package.json packages/studio/src/lib/build-runtime.ts packages/studio/src/lib/build-runtime.test.ts packages/studio/src/lib/remote-module.ts packages/studio/src/lib/runtime-ui/styles.css packages/studio/src/lib/runtime-ui/style-installer.ts packages/studio/src/lib/runtime-ui/style-installer.test.ts +git commit -m "feat(studio): add remote runtime stylesheet assets" +``` + +### Task 3: Add runtime-local navigation primitives and admin-shell state + +**Files:** + +- Modify: `packages/studio/src/lib/remote-studio-app.tsx` +- Modify: `packages/studio/src/lib/remote-studio-app.test.ts` +- Create: `packages/studio/src/lib/runtime-ui/mock-data.ts` +- Create: `packages/studio/src/lib/runtime-ui/utils.ts` +- Create: `packages/studio/src/lib/runtime-ui/navigation.tsx` +- Create: `packages/studio/src/lib/runtime-ui/runtime-link.tsx` +- Create: `packages/studio/src/lib/runtime-ui/layout/admin-layout.tsx` +- Create: `packages/studio/src/lib/runtime-ui/layout/app-sidebar.tsx` +- Create: `packages/studio/src/lib/runtime-ui/layout/page-header.tsx` +- Create: `packages/studio/src/lib/runtime-ui/coming-soon.tsx` + +**Step 1: Write the failing runtime-shell tests** + +Cover: + +- matching and rendering the expanded route set +- base-path-aware `navigate(...)` behavior +- active-nav highlighting under `/admin/*` +- role-aware nav visibility for admin-only pages + +```ts +assert.equal(matchStudioRoute("/api", routes)?.id, "api"); +assert.match(markup, /data-mdcms-nav-item="settings"/); +assert.doesNotMatch(viewerMarkup, /data-mdcms-nav-item="users"/); +``` + +**Step 2: Run the targeted remote-runtime tests to verify failure** + +Run: + +```bash +bun --cwd packages/studio test ./src/lib/remote-studio-app.test.ts +``` + +Expected: FAIL because the current remote runtime still renders the placeholder +registry demo. + +**Step 3: Implement the local navigation and shell foundations** + +Add a small runtime router state layer and use it to power a reusable admin +layout, sidebar, header, and shared mock-data selectors. + +```tsx +const { pathname, navigate } = useStudioNavigation(context.basePath); + +return ( + + {children} + +); +``` + +Keep `next/link`, `next/navigation`, and `next/font` out of the runtime port. + +**Step 4: Re-run the targeted tests** + +Run: + +```bash +bun --cwd packages/studio test ./src/lib/remote-studio-app.test.ts +``` + +Expected: PASS + +**Step 5: Commit the navigation/shell slice** + +```bash +git add packages/studio/src/lib/remote-studio-app.tsx packages/studio/src/lib/remote-studio-app.test.ts packages/studio/src/lib/runtime-ui/mock-data.ts packages/studio/src/lib/runtime-ui/utils.ts packages/studio/src/lib/runtime-ui/navigation.tsx packages/studio/src/lib/runtime-ui/runtime-link.tsx packages/studio/src/lib/runtime-ui/layout/admin-layout.tsx packages/studio/src/lib/runtime-ui/layout/app-sidebar.tsx packages/studio/src/lib/runtime-ui/layout/page-header.tsx packages/studio/src/lib/runtime-ui/coming-soon.tsx +git commit -m "feat(studio): add runtime admin shell navigation" +``` + +### Task 4: Port the shared UI primitives and editor surfaces used by the approved admin pages + +**Files:** + +- Modify: `packages/studio/package.json` +- Create: `packages/studio/src/lib/runtime-ui/editor/editor-sidebar.tsx` +- Create: `packages/studio/src/lib/runtime-ui/editor/tiptap-editor.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/avatar.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/badge.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/breadcrumb.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/button.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/card.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/checkbox.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/collapsible.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/dialog.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/dropdown-menu.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/input.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/label.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/pagination.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/select.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/separator.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/switch.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/table.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/tabs.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/textarea.tsx` +- Create: `packages/studio/src/lib/runtime-ui/ui/tooltip.tsx` + +**Step 1: Write the failing editor and primitive smoke tests** + +Add one small rendering test that mounts the document route and asserts the +ported editor/sidebar surfaces and core primitives render without throwing. + +```ts +assert.match(markup, /Document Editor/); +assert.match(markup, /data-mdcms-editor-sidebar/); +``` + +**Step 2: Run the targeted tests to verify failure** + +Run: + +```bash +bun --cwd packages/studio test ./src/lib/remote-studio-app.test.ts +``` + +Expected: FAIL because the route components and their primitive dependencies do +not exist yet. + +**Step 3: Port the minimal component set used by the admin pages** + +Copy only the primitives and editor surfaces referenced by the approved page +set. Trim mock-only dependencies that are not used by those pages, and prefer a +small local theme toggle over bringing `next-themes` into the runtime. + +```tsx +export function StudioButton(props: ButtonHTMLAttributes) { + return