diff --git a/.changeset/wise-owls-guard.md b/.changeset/wise-owls-guard.md new file mode 100644 index 00000000..0f16dd54 --- /dev/null +++ b/.changeset/wise-owls-guard.md @@ -0,0 +1,17 @@ +--- +"@noormdev/cli": minor +"@noormdev/sdk": minor +--- + +## Access Roles + +Replace the per-config `protected: boolean` flag with per-channel access roles. + +**Breaking:** `Config.protected` is removed from the exported types and `access: ConfigAccess` is now required on `Config`/`ConfigSummary`. TypeScript consumers that read `.protected` or construct `Config` literals must move to `access`. Stored state auto-migrates on load (see below), so runtime configs are unaffected. (Released as `minor` under the `1.0.0-alpha` pre-release line, where `^` ranges pin within the alpha series; it is a breaking type change and will be treated as such at the 1.0 boundary.) + +* `feat(policy):` Configs now carry `access: { user, mcp }`, each `'viewer' | 'operator' | 'admin'` (or `mcp: false` to hide the config entirely). Roles are enforced by one policy matrix instead of scattered `protected` checks — `viewer` reads only, `operator` writes and confirms destructive operations, `admin` is frictionless. Enforcement runs at the core seam, so the SDK, TUI, and CLI all inherit it: `run`, `changes`, `transfer`, and the SQL terminal are gated, not just MCP. +* `feat(mcp):` Every MCP command is now gated on `access.mcp` before it runs, closing the gap where `change_run`/`change_ff`/`change_revert`/`run_file`/`run_build` reached the database unchecked. `access.mcp: false` makes a config invisible to agents — absent from `list_configs`, and `connect` fails with the same error an unknown config produces. `confirm`-tier permissions collapse to a hard deny on the MCP channel (no human to type a confirmation phrase); give a config `mcp: 'admin'` if an agent legitimately needs to run changes there. +* `feat(sql):` Raw SQL (`noorm sql`, MCP `sql`, the TUI SQL terminal) is now gated by what the statement actually does — classified `read`/`write`/`ddl` — instead of a blanket read-only check. Data-modifying CTEs (`WITH … AS (DELETE …) SELECT …`) and a denylist of side-effecting functions (`pg_terminate_backend`, `dblink_exec`, `query_to_xml`, … — bare or schema-qualified) classify as writes so a `viewer` cannot mutate through the read path. Multi-statement input takes the highest class present; unparseable input or `EXEC`/`CALL` classify as `ddl` (fail closed). The classifier guards against mutation, not disclosure — back hard confidentiality with database `GRANT`s. +* `feat(sdk):` `createContext({ channel })` — defaults to `'user'`; set `'mcp'` when embedding the SDK behind an MCP-like surface. New exported types `Channel`, `ConfigAccess`, `Role`. `ProtectedConfigError` is now raised from `checkPolicy` denials/unconfirmable actions rather than a bare `protected` check; match on `err.name`/`instanceof`, not the message string (the message text changed). +* **Behavior change:** on the `user` channel, `NOORM_YES=1` resolves an operator `confirm` to allow — including in the SDK guards. A migrated `protected: true` config becomes `operator`, so an SDK/CI caller with `NOORM_YES=1` set now *executes* `truncate`/`teardown`/`reset`/`dt.import`/`changes.*`/`run.*` where the previous `protected` hard-block threw unconditionally. Unset `NOORM_YES` (or use `viewer`/`mcp:false`) where the block must hold. +* `fix(config):` A legacy `protected: true` migrates automatically to `{ user: 'operator', mcp: 'viewer' }`, and `protected: false`/absent to `{ user: 'admin', mcp: 'admin' }`. The `protected` field is still accepted on `config import`/settings input for this release, then removed. Note: downgrading to a prior binary after this migration silently unprotects configs (older binaries have no access concept) — avoid rolling back once state is migrated. diff --git a/.claude/project/followups/INDEX.md b/.claude/project/followups/INDEX.md index 854cf5a6..2c8873bd 100644 --- a/.claude/project/followups/INDEX.md +++ b/.claude/project/followups/INDEX.md @@ -2,19 +2,23 @@ Auto-generated by `atomic followups render`. Do not edit. -Open: 2 • Stale: 0 • Last rendered: 2026-06-06 +Open: 7 • Stale: 0 • Last rendered: 2026-07-08 -## 📋 plans (0) +## 📋 plans (1) -(none) +- [configurable-sql-function-policy](configurable-sql-function-policy.md) — Configurable per-config SQL function allow/deny list + TUI editor → src/core/policy/classify.ts:79 (DESTRUCTIVE_FUNCTIONS) -## 🟡 risks (1) +## 🟡 risks (4) -- [config-module-scope-env-snapshot](config-module-scope-env-snapshot.md) — Move makeNestedConfig to call-time in config module (5d) +- [config-module-scope-env-snapshot](config-module-scope-env-snapshot.md) — Move makeNestedConfig to call-time in config module (37d) +- [downgrade-unprotects-configs](downgrade-unprotects-configs.md) — Downgrade after schemaVersion-2 migration silently unprotects all configs (0d) +- [policy-denial-observability](policy-denial-observability.md) — Policy denials leave no server-side trace; MCP server never inits logger (0d) +- [state-enc-atomic-write-lock](state-enc-atomic-write-lock.md) — state.enc: atomic write + inter-process lock + pre-migration backup (0d) -## 🔵 nits (1) +## 🔵 nits (2) -- [debug-process-test-no-assertions](debug-process-test-no-assertions.md) — debug-process.test.ts has no assertions (passes unconditionally) (5d) +- [debug-process-test-no-assertions](debug-process-test-no-assertions.md) — debug-process.test.ts has no assertions (passes unconditionally) (37d) +- [legacy-protected-removal-trigger](legacy-protected-removal-trigger.md) — Legacy 'protected' input path has no removal trigger; export still mints it (0d) ## ❓ questions (0) diff --git a/.claude/project/followups/configurable-sql-function-policy.md b/.claude/project/followups/configurable-sql-function-policy.md new file mode 100644 index 00000000..4cbe3393 --- /dev/null +++ b/.claude/project/followups/configurable-sql-function-policy.md @@ -0,0 +1,36 @@ +--- +id: configurable-sql-function-policy +title: Configurable per-config SQL function allow/deny list + TUI editor +created: "2026-07-08" +origin: | + user idea 2026-07-08, post-#40 +kind: plan +severity: question +review_by: "2026-09-06" +status: open +file: src/core/policy/classify.ts:79 (DESTRUCTIVE_FUNCTIONS) +--- + +Make the hardcoded DESTRUCTIVE_FUNCTIONS denylist user-extensible and DB-embedded. + +Model (proposed): per-config sqlFunctionPolicy { mode: 'denylist'|'allowlist' (mutually exclusive), functions: string[] (schema-qualified supported) }, persisted in encrypted state (new schemaVersion migration), so it travels with the project. + +THREE-BUCKET SEMANTICS (critical — the user lists govern ONLY the unknown/custom bucket, never the internals): + +1. Known-pure builtins (count, min, max, avg, sum, now, coalesce, lower, upper, …): ALWAYS `read`. NEVER subject to the user's lists. An allowlist must not cause `SELECT count(*)` to be denied — this is the explicit user requirement. +2. Known-destructive builtins (pg_terminate_backend, dblink_exec, query_to_xml family, …): the baseline denylist, always bump to >=write. +3. Unknown / user-custom functions (everything not in bucket 1 or 2): the ONLY bucket the config governs. + - denylist mode: user names the dangerous customs -> those bump to >=write; other unknowns stay read (fail-open guardrail). Extends today's behavior. + - allowlist mode: user names the safe customs -> those stay read; every OTHER unknown -> treated as write -> denied for viewer (fail-closed sandbox). Bucket 1 stays read regardless; also closes the disclosure gap (pg_read_file, if unlisted, -> denied for viewer). "allowlist" = "deny unrecognized customs not listed", NOT "deny everything not listed". + +Implies the classifier needs an explicit KNOWN_PURE builtin set (bucket 1) alongside the existing DESTRUCTIVE set (bucket 2), so allowlist mode can exempt internals. + +Per-role differentiation is FREE via the existing matrix — no per-role lists needed. A denylisted function reclassifies to write/ddl; the matrix already says admin CAN write and viewer CANNOT. So the user's example ("my sensitive admin-only proc the MCP viewer must not call") works with a SINGLE per-config list: admin keeps calling it, viewer is denied. v1 = one per-config list + reclassify + existing matrix. A finer viewer-vs-operator split on the same custom function would need per-role lists — defer unless asked. + +Layer: consumed by the CLASSIFIER (classifyStatements takes the config's function policy, or a post-classify refinement step that has the called-function set), then the EXISTING role matrix gates unchanged. One enforcement path; no parallel per-role permission engine. + +TUI: a screen that POPULATES the picker from live DB introspection (explore domain already has list_functions) — show actual functions/procedures as a checklist, toggle mode, check the sensitive ones. Encrypted, per-config. + +CAUTION (hard boundary): configurable FUNCTION lists only. Do NOT make the role->permission MATRIX user-editable — #40 deliberately fixed the three roles in-app ('User-defined or custom roles ... out of scope'). Keep that constraint. + +Size: comparable to one CP (state schema + migration, classifier change, TUI screen + introspection wiring, tests). Separate PR after #40 ships. This turns the hardcoded denylist into the default rather than the whole answer. diff --git a/.claude/project/followups/downgrade-unprotects-configs.md b/.claude/project/followups/downgrade-unprotects-configs.md new file mode 100644 index 00000000..71e5d995 --- /dev/null +++ b/.claude/project/followups/downgrade-unprotects-configs.md @@ -0,0 +1,14 @@ +--- +id: downgrade-unprotects-configs +title: Downgrade after schemaVersion-2 migration silently unprotects all configs +created: "2026-07-08" +origin: | + challenge-swarm #40 (ops F1, migration F1/F2) +kind: finding +severity: risk +review_by: "2026-09-06" +status: open +file: src/core/version/state/migrations/v2.ts:46 +--- + +DEFERRED by product owner (alpha). After state migrates to schemaVersion 2 (protected dropped), any prior binary reads protected as undefined -> fail-open, and re-persists without the marker. No shipped release has a version guard (schemaVersion system was dead until this branch). When leaving alpha: ship a forward version-guard in load() (refuse/warn on newer state) + state.enc.bak, and document the caveat. Not actionable retroactively for already-shipped binaries. diff --git a/.claude/project/followups/legacy-protected-removal-trigger.md b/.claude/project/followups/legacy-protected-removal-trigger.md new file mode 100644 index 00000000..2aa6df3d --- /dev/null +++ b/.claude/project/followups/legacy-protected-removal-trigger.md @@ -0,0 +1,14 @@ +--- +id: legacy-protected-removal-trigger +title: Legacy 'protected' input path has no removal trigger; export still mints it +created: "2026-07-08" +origin: | + challenge-swarm #40 (migration F5, tester F7, maintainer F6) +kind: finding +severity: nit +review_by: "2026-09-06" +status: open +file: src/core/config/schema.ts:44; src/tui/screens/config/ConfigExportScreen.tsx:142 +--- + +'Accepted for one version then removed' appears in 8 places but nothing operationalizes it — no version-keyed guard test, no tracked removal. Export actively writes protected:guarded(config) into every new export. Classic permanent-temporary: 3 accept-sites + 1 produce-site, 0 removal triggers. Add a test keyed to CURRENT_VERSIONS.state that fails when state version advances past 2 with the legacy input path still present. diff --git a/.claude/project/followups/policy-denial-observability.md b/.claude/project/followups/policy-denial-observability.md new file mode 100644 index 00000000..0c8fba0e --- /dev/null +++ b/.claude/project/followups/policy-denial-observability.md @@ -0,0 +1,14 @@ +--- +id: policy-denial-observability +title: Policy denials leave no server-side trace; MCP server never inits logger +created: "2026-07-08" +origin: | + challenge-swarm #40 (ops F3/F4) +kind: finding +severity: risk +review_by: "2026-09-06" +status: open +file: src/mcp/server.ts:106; src/mcp/index.ts:12 +--- + +Denied actions (MCP gate, checkPolicy, SDK guards) emit no event/log. The MCP server process never calls enableAutoLoggerInit (only ui.ts and sql/repl.ts do), so even an added denial event would not record. Ops cannot answer 'which agent actions were denied on prod this week'. mcp:false invisibility has no ops-facing counterpart either: hidden-vs-typo'd config is indistinguishable server-side. Add a stderr/log (never the MCP response) denial trace and wire the MCP logger. diff --git a/.claude/project/followups/state-enc-atomic-write-lock.md b/.claude/project/followups/state-enc-atomic-write-lock.md new file mode 100644 index 00000000..6aae8a75 --- /dev/null +++ b/.claude/project/followups/state-enc-atomic-write-lock.md @@ -0,0 +1,14 @@ +--- +id: state-enc-atomic-write-lock +title: 'state.enc: atomic write + inter-process lock + pre-migration backup' +created: "2026-07-08" +origin: | + challenge-swarm #40 (migration F3/F4, ops F2/F6) +kind: finding +severity: risk +review_by: "2026-09-06" +status: open +file: src/core/state/manager.ts:304 +--- + +persist() is a bare writeFileSync to the live path — no temp+rename, no lock, no .bak. needsMigration is effectively always-true (identity never emitted by migrateState) so every load persists. Concurrent MCP-server + CLI/TUI on one state.enc can corrupt the single encrypted store holding all configs+secrets; a crash mid-write loses everything with no backup. Pre-exists the access-roles branch. Fix: write-temp+rename (atomic on POSIX), a lockfile around load/persist, state.enc.bak before first migration. diff --git a/.gitignore b/.gitignore index 833808bd..4f333d17 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ skills/*/evals/ graphify-out/ .claude/.scratchpad/ .claude/project/.deterministic-signals.prev.md +.claude/.atomic-index/ diff --git a/docs/design/config-access-roles.md b/docs/design/config-access-roles.md new file mode 100644 index 00000000..51d0b75d --- /dev/null +++ b/docs/design/config-access-roles.md @@ -0,0 +1,116 @@ +# Per-config access roles + +Issue: https://github.com/noormdev/noorm/issues/40 + + +## Problem + + +Access control is a single `protected: boolean` per config. It cannot express intermediate access levels, cannot give different levels to different callers on the same config, and cannot hide a config from MCP entirely. Enforcement is also scattered: only `sql`/`run_sql` check `protected` on the MCP path — `change_run`, `change_ff`, `change_revert`, `run_file`, and `run_build` reach `ctx.noorm.*` with no gate, and `checkProtection` has no runtime callers at all. + + +## Model + + +Roles live on the config, not the actor. The actor is just a **channel** — who is asking: + +- `user` — CLI, TUI, and SDK (`createContext` defaults here) +- `mcp` — the MCP server + +Each config declares what each channel gets: + + access: { + user: 'viewer' | 'operator' | 'admin', + mcp: false | 'viewer' | 'operator' | 'admin', + } + +`mcp: false` means invisible: absent from `list_configs`, and `connect` fails with the byte-identical error an unknown config produces. Omission must not leak existence. `mcp: 'viewer'` is the softer posture — the agent sees schema and reads, touches nothing. + +The `access: {}` grouping (rather than flat top-level `user:` / `mcp:` keys) is deliberate: a top-level `user` key would sit lines away from `connection.user` (the database login) and read ambiguously. + + +## Role matrix + + +Hard-coded. Cells: allow (✓) / confirm / deny (✗). Not user-extensible. + +| permission | viewer | operator | admin | +|---|---|---|---| +| explore | ✓ | ✓ | ✓ | +| sql:read | ✓ | ✓ | ✓ | +| sql:write | ✗ | ✓ | ✓ | +| sql:ddl | ✗ | ✗ | ✓ | +| change:run / change:ff | ✗ | confirm | ✓ | +| change:revert | ✗ | confirm | ✓ | +| run:build / run:file / run:dir | ✗ | confirm | ✓ | +| db:create | ✗ | confirm | ✓ | +| db:reset | ✗ | confirm | ✓ | +| db:destroy | ✗ | ✗ | confirm | +| config:rm | ✗ | confirm | confirm | + +`confirm` is channel-resolved: + +- **user channel** — prompt for `yes-` (the phrase `protected` used to own). `NOORM_YES=1` still skips it, so CI is unaffected. +- **mcp channel** — collapses to deny, with a message directing to the CLI. There is no human on the other end of stdio; an agent typing its own confirmation phrase is theater. An agent that legitimately needs to run changes on a dev database gets `mcp: 'admin'` on that config — dev is disposable. + +The confirm-in-role design is what lets `protected` die without losing the prod guardrail: `operator` *is* "can do it, but types `yes-prod` first"; `admin` is frictionless. A human on an `operator` config cannot ad-hoc DDL prod — they go through a change file. That is a posture, not a limitation. + + +## SQL classification + + +Raw SQL (`sql`, `run_sql`) is gated by what the statements actually do. Generalize the existing `isReadOnlyStatement` (sql-parser-cst first, keyword fallback) into: + + classifyStatements(sql, dialect) → 'read' | 'write' | 'ddl' + +- Statement classes: SELECT/EXPLAIN/SHOW/DESCRIBE → `read`; INSERT/UPDATE/DELETE/MERGE → `write`; CREATE/ALTER/DROP/TRUNCATE/GRANT/REVOKE → `ddl`. +- Multi-statement input takes the highest class present. +- Unparseable input or unknown statement types classify as `ddl` — **fail closed**. +- `EXEC` / `CALL` classify as `ddl`. A stored procedure is opaque and can do anything; fail-closed wins. This is painful for proc-heavy MSSQL shops and is the first cell to revisit with real usage — but loosening later is safe, tightening later is a breaking change. + +The classifier applies **only** to the raw-SQL surface. Change files and run files are command-gated (`change:*`, `run:*`), not content-classified. The resulting posture is coherent with the product premise: ad-hoc DML is a role question, but DDL only travels through tracked, revertible change files. + + +## Enforcement + + +One function in core: + + checkPolicy(channel, config, permission) → { allowed, requiresConfirmation, confirmationPhrase?, blockedReason? } + +Same result shape as the old `ProtectionCheck`, so confirm-dialog plumbing carries over. + +- **MCP**: `RpcCommand` gains a required `permission` field. A single gate in `run_noorm_cmd` dispatch resolves (channel, config, command.permission) before any handler runs. Every current and future command is covered by construction — the class of bug where a new command forgets its check cannot exist. +- **CLI/TUI**: same `checkPolicy`, prompting on `confirm`. +- **SDK**: `createContext` stamps channel `user`. Side effect worth having: a `viewer` config is read-only even from application code — enforced above the driver, belt to the DB-grant suspenders. +- **Invisibility**: `list_configs` filters `mcp: false` configs for the mcp channel; `SessionManager.connect`/`getContext` reject them with the unknown-config error. + + +## Settings stages and display + + +Scoping surfaced two `protected` consumers beyond the config itself; both get explicit mappings rather than silent deletion: + +- **Stage defaults** (`settings.yml`) may set `protected: true`, and stage enforcement forbids overriding it to `false`. The stage keyword survives as authoring vocabulary, redefined as an **access ceiling**: a stage with `protected: true` clamps resolved access to at most `{ user: 'operator', mcp: 'viewer' }`. A config may be stricter (`viewer`, `mcp: false`), never looser. Adding full `access` blocks to stage defaults is a separate issue. +- **Rule matching and display** need a one-word notion of "this config is guarded." Defined as `guarded(config) := access.user !== 'admin'`. Settings rules' `match.protected` matches `guarded`, TUI yellow-border styling keys off `guarded`, and `config list`'s `[protected]` tag becomes the access levels. + +The SDK's existing destructive-op guards (`src/sdk/guards.ts`) stop consulting `protected` and consult `checkPolicy` on the `user` channel — one enforcement path, not two. + + +## Migration + + +`protected` dies as a concept. On state load, one version migration maps it: + +- `protected: true` → `access: { user: 'operator', mcp: 'viewer' }` +- `protected: false` → `access: { user: 'admin', mcp: 'admin' }` + +Both preserve today's observed behavior, with one intended tightening: protected configs newly deny MCP `change_run` et al. — that is the enforcement-gap fix riding along. The stored field is parsed and migrated for one version, then removed from types and code. + + +## Out of scope + + +- User-defined or custom roles — the set is fixed in the app. +- Identity/authn — channel is process context; no user accounts. +- Content-classifying change/run files — they are command-gated by design. diff --git a/docs/dev/README.md b/docs/dev/README.md index 6886f25d..d72e78a4 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -40,7 +40,7 @@ Configs define database connections. They merge from multiple sources with clear CLI flags > Environment > Stored config > Stage defaults > Defaults ``` -Protected configs require confirmation for dangerous operations. Stages enforce team-wide constraints. +Per-channel access roles (`viewer`/`operator`/`admin`) gate dangerous operations. Stages enforce team-wide constraints. [Read more about Configuration](./config.md) diff --git a/docs/dev/config-sharing.md b/docs/dev/config-sharing.md index adbe22b8..5b854b47 100644 --- a/docs/dev/config-sharing.md +++ b/docs/dev/config-sharing.md @@ -74,7 +74,7 @@ The exported data includes everything needed to recreate the config except crede name: 'production', type: 'remote', isTest: false, - protected: true, + access: { user: 'operator', mcp: 'viewer' }, connection: { dialect: 'postgres', host: 'db.example.com', diff --git a/docs/dev/config.md b/docs/dev/config.md index 0b754da5..ab8cc2a0 100644 --- a/docs/dev/config.md +++ b/docs/dev/config.md @@ -42,7 +42,7 @@ interface Config { name: string // Unique identifier: 'dev', 'staging', 'prod' type: 'local' | 'remote' // Connection type isTest: boolean // Test database flag - protected: boolean // Requires confirmation for dangerous ops + access: ConfigAccess // Per-channel access roles — see Access Roles below connection: { dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql' @@ -100,7 +100,7 @@ NOORM_{PATH}_{TO}_{VALUE} → { path: { to: { value: '' } } } |----------|-------------|-------| | `NOORM_NAME` | `name` | | | `NOORM_TYPE` | `type` | 'local' or 'remote' | -| `NOORM_PROTECTED` | `protected` | Use 'true'/'false' | +| `NOORM_PROTECTED` | `access` (legacy) | Use 'true'/'false'. Maps through the deprecated `protected` boolean into `access` — see [Access Roles](#access-roles) | | `NOORM_IDENTITY` | `identity` | | | `NOORM_isTest` | `isTest` | camelCase preserved | @@ -197,39 +197,42 @@ const full = parseConfig(partial) ``` -## Protected Configs +## Access Roles -Production databases need safeguards. Protected configs require confirmation for dangerous operations and block some entirely. +`Config.protected: boolean` was replaced by per-channel access roles. Roles live on the config, not the caller — the caller is a **channel**: `user` (CLI/TUI/SDK) or `mcp` (the MCP server). Each config declares a role per channel: ```typescript const config = { name: 'prod', - protected: true, + access: { + user: 'operator', // what a human at the CLI/TUI gets + mcp: 'viewer', // what a connected AI agent gets — can differ from user + }, // ... } ``` -Action classification: +Three roles, hard-coded, not user-extensible: -| Action | Protected Behavior | -|--------|-------------------| -| `change:run` | Requires confirmation | -| `change:revert` | Requires confirmation | -| `change:ff` | Requires confirmation | -| `change:next` | Requires confirmation | -| `run:build` | Requires confirmation | -| `run:file` | Requires confirmation | -| `run:dir` | Requires confirmation | -| `db:create` | Requires confirmation | -| `db:destroy` | **Blocked entirely** | -| `config:rm` | Requires confirmation | +| Permission | viewer | operator | admin | +|---|---|---|---| +| explore, `sql:read` | allow | allow | allow | +| `sql:write` | deny | allow | allow | +| `sql:ddl` | deny | deny | allow | +| `change:run`, `change:ff`, `change:revert` | deny | confirm | allow | +| `run:build`, `run:file`, `run:dir` | deny | confirm | allow | +| `db:create`, `db:reset` | deny | confirm | allow | +| `db:destroy` | deny | deny | confirm | +| `config:rm` | deny | confirm | confirm | -Check protection before executing: +`mcp: false` is not a role — it makes the config invisible on the MCP channel (absent from `list_configs`, `connect` fails with the byte-identical error an unknown config produces). + +Check policy before executing: ```typescript -import { checkProtection } from './core/config' +import { checkPolicy } from './core/policy' -const check = checkProtection(config, 'change:run') +const check = checkPolicy('user', config, 'change:run') if (!check.allowed) { console.error(check.blockedReason) @@ -246,13 +249,15 @@ if (check.requiresConfirmation) { // Proceed with action ``` -Skip confirmations in CI with `NOORM_YES=1`: +`confirm` resolves per channel: on `user` it prompts for `yes-` (skippable with `NOORM_YES=1`); on `mcp` it collapses straight to deny — there's no human on the other end of stdio to type a phrase. ```bash export NOORM_YES=1 -noorm change run # No prompt, even on protected config +noorm change run # No prompt, even on an operator-role config ``` +**Migration:** a legacy `protected: true` maps to `{ user: 'operator', mcp: 'viewer' }`; `protected: false` or absent maps to `{ user: 'admin', mcp: 'admin' }`. The `protected` field is accepted on input for one version, then dropped — see the state migration in `core/version/state/migrations/`. + ## Stages @@ -266,7 +271,7 @@ stages: locked: true # Cannot delete this config defaults: dialect: postgres - protected: true # Cannot be overridden to false + protected: true # Access ceiling: clamps resolved access to at most operator/viewer secrets: - key: DB_PASSWORD type: password @@ -327,7 +332,7 @@ Stage constraints that can't be violated: | Constraint | Behavior | |------------|----------| -| `protected: true` in defaults | Cannot set `protected: false` | +| `protected: true` in defaults | Clamps resolved `access` to at most `{ user: 'operator', mcp: 'viewer' }` — stricter survives, looser is clamped down (see [Access Roles](#access-roles)) | | `isTest: true` in defaults | Cannot set `isTest: false` | | `locked: true` | Config cannot be deleted | @@ -412,8 +417,8 @@ For listings, use `ConfigSummary` which omits sensitive connection details: ```typescript const summaries = state.listConfigs() // [ -// { name: 'dev', type: 'local', isTest: false, protected: false, isActive: true, dialect: 'postgres', database: 'dev_db' }, -// { name: 'prod', type: 'remote', isTest: false, protected: true, isActive: false, dialect: 'postgres', database: 'prod_db' }, +// { name: 'dev', type: 'local', isTest: false, access: { user: 'admin', mcp: 'admin' }, isActive: true, dialect: 'postgres', database: 'dev_db' }, +// { name: 'prod', type: 'remote', isTest: false, access: { user: 'operator', mcp: 'viewer' }, isActive: false, dialect: 'postgres', database: 'prod_db' }, // ] ``` @@ -424,7 +429,7 @@ interface ConfigSummary { name: string type: 'local' | 'remote' isTest: boolean - protected: boolean + access: ConfigAccess isActive: boolean dialect: Dialect database: string diff --git a/docs/dev/datamodel.md b/docs/dev/datamodel.md index ca43a268..02cfdd19 100644 --- a/docs/dev/datamodel.md +++ b/docs/dev/datamodel.md @@ -92,12 +92,24 @@ A database connection profile stored in encrypted state. | name | string | Yes | Unique identifier (e.g., `dev`, `staging`, `prod`) | | type | enum | Yes | `local` or `remote` | | isTest | boolean | Yes | Marks database as disposable for testing | -| protected | boolean | Yes | Requires confirmation for dangerous operations | +| access | ConfigAccess | Yes | Per-channel access roles (`{ user, mcp }`) — replaces the legacy `protected` boolean | | connection | ConnectionConfig | Yes | Database connection details | | paths | PathConfig | Yes | File system paths for schema and changes | | identity | string | No | Override identity for `executed_by` field | +### ConfigAccess + +Per-channel access grant. `Role` is `'viewer' | 'operator' | 'admin'`. + +| Field | Type | Description | +|-------|------|-------------| +| user | Role | Access for the CLI, TUI, and SDK | +| mcp | Role \| `false` | Access for the MCP server. `false` hides the config entirely on this channel | + +`checkPolicy(channel, config, permission)` resolves a `ConfigAccess` + `Permission` into `allow`/`confirm`/`deny`, channel-aware (`confirm` prompts on `user`, collapses to `deny` on `mcp`). See `docs/spec/config-access-roles.md` for the full permission matrix. + + ### ConnectionConfig Database connection parameters. @@ -143,7 +155,7 @@ Lightweight config view for listings. Omits sensitive connection details. | name | string | Config identifier | | type | enum | `local` or `remote` | | isTest | boolean | Test database flag | -| protected | boolean | Protection enabled | +| access | ConfigAccess | Per-channel access roles | | isActive | boolean | Currently selected config | @@ -192,7 +204,7 @@ stages: locked: true defaults: dialect: postgres - protected: true + protected: true # access ceiling: clamps resolved access to at most operator/viewer secrets: - key: DB_PASSWORD type: password @@ -200,7 +212,7 @@ stages: rules: - match: - protected: true + protected: true # matches guarded(config), i.e. access.user !== 'admin' exclude: - '**/*.seed.sql' @@ -268,7 +280,7 @@ Initial values when creating a config from a stage. | password | string? | Default password | | ssl | boolean? | Default SSL setting | | isTest | boolean? | Default test flag | -| protected | boolean? | Default protection (cannot be overridden if true) | +| protected | boolean? | `true` becomes an access **ceiling** at resolution: resolved `access` is clamped to at most `{ user: 'operator', mcp: 'viewer' }` — a stricter config-level `access` survives unchanged | ### StageSecret @@ -301,7 +313,7 @@ Conditions for rule evaluation. | Field | Type | Description | |-------|------|-------------| | name | string? | Match config by name | -| protected | boolean? | Match by protection status | +| protected | boolean? | Matches `guarded(config)` — `true` if `access.user !== 'admin'` — despite the field's legacy name, this reads current `access`, not a stored flag | | isTest | boolean? | Match by test flag | | type | enum? | Match by `local` or `remote` | @@ -444,7 +456,7 @@ The decrypted contents of a shared config. | connection | object | Host, port, database, ssl, pool (no user/password) | | paths | object | Schema and change directories | | isTest | boolean | Test database flag | -| protected | boolean | Protection status | +| access | ConfigAccess | Per-channel access roles | | secrets | Map | Config-scoped secrets | **Note:** `user` and `password` are intentionally omitted. Recipients provide their own credentials on import. diff --git a/docs/dev/sdk.md b/docs/dev/sdk.md index cbd603ba..6ce4c5c3 100644 --- a/docs/dev/sdk.md +++ b/docs/dev/sdk.md @@ -85,7 +85,7 @@ interface CreateContextOptions { config?: string // Config name (or use NOORM_CONFIG env var) projectRoot?: string // Project root path (see note below) requireTest?: boolean // Refuse if config.isTest !== true - allowProtected?: boolean // Allow destructive ops on protected configs + channel?: Channel // 'user' (default) or 'mcp' — which access role applies stage?: string // Stage name for stage defaults } ``` @@ -109,7 +109,7 @@ const ctx = await createContext({ - `requireTest: true` - Throws `RequireTestError` if the config doesn't have `isTest: true`. Use this in test suites to prevent accidentally running against production. -- `allowProtected: true` - Allows destructive operations (`truncate`, `teardown`, `reset`) on configs with `protected: true`. Use with caution. +- `channel: 'mcp'` - Enforces the config's `access.mcp` role instead of `access.user` for destructive operations (`truncate`, `teardown`, `reset`, `changes.revert`). Use this when embedding the SDK behind an MCP-like surface. Defaults to `'user'`. A denied or unconfirmable operation throws `ProtectedConfigError` — the SDK has no prompt, so a `confirm`-tier permission needs `NOORM_YES=1` or the CLI/TUI. See [Access Roles](./config.md#access-roles). ### Environment Variable Support @@ -968,7 +968,7 @@ try { await ctx.noorm.db.truncate() } catch (err) { if (err instanceof ProtectedConfigError) { - console.error('Cannot truncate protected database') + console.error('Denied by the config\'s access role, or needs confirmation the SDK can\'t give') } } diff --git a/docs/dev/settings.md b/docs/dev/settings.md index a0a5fce2..6ce65450 100644 --- a/docs/dev/settings.md +++ b/docs/dev/settings.md @@ -46,7 +46,7 @@ const paths = settings.getEffectiveBuildPaths({ name: 'dev', type: 'local', isTest: false, - protected: false, + access: { user: 'admin', mcp: 'admin' }, }) console.log('Effective include:', paths.include) console.log('Effective exclude:', paths.exclude) @@ -195,7 +195,7 @@ All conditions in a rule are AND'd together—every specified condition must be | Condition | Type | Description | |-----------|------|-------------| | `name` | string | Config name (exact match) | -| `protected` | boolean | Protected config flag | +| `protected` | boolean | Matches `guarded(config)` — `true` when `access.user !== 'admin'`, regardless of the field's legacy name | | `isTest` | boolean | Test database flag | | `type` | `'local'` \| `'remote'` | Connection type | @@ -203,7 +203,7 @@ All conditions in a rule are AND'd together—every specified condition must be import { ruleMatches } from './core/settings' const match = { isTest: true, type: 'local' } -const config = { name: 'test', type: 'local', isTest: true, protected: false } +const config = { name: 'test', type: 'local', isTest: true, access: { user: 'admin', mcp: 'admin' } } ruleMatches(match, config) // true - both conditions match ``` @@ -251,7 +251,7 @@ const { include, exclude } = settings.getEffectiveBuildPaths({ name: 'dev', type: 'local', isTest: false, - protected: false, + access: { user: 'admin', mcp: 'admin' }, }) // Build only these paths @@ -307,14 +307,14 @@ Defaults provide initial values when creating a config. Users can override most | Default | Behavior | |---------|----------| -| `protected: true` | Cannot be overridden to false | +| `protected: true` | Enforced as an access **ceiling** at config resolution (`resolveConfig`), not a value check here: resolved `access` is clamped to at most `{ user: 'operator', mcp: 'viewer' }` no matter what the config or the TUI sets | | `isTest: true` | Cannot be overridden to false | | `dialect` | Cannot be changed after creation | ```typescript -// Check if stage enforces protection +// Check if stage sets the protected default (the ceiling is applied later, at resolution) if (settings.stageEnforcesProtected('prod')) { - // Config cannot set protected: false + // This stage's configs get clamped to at most operator/viewer access } // Get stage defaults @@ -585,7 +585,7 @@ const result = settings.evaluateRules({ name: 'dev', type: 'local', isTest: true, - protected: false + access: { user: 'admin', mcp: 'admin' } }) // { matchedRules: [...], include: [...], exclude: [...] } ``` diff --git a/docs/dev/state.md b/docs/dev/state.md index 14639c9c..750b3f2d 100644 --- a/docs/dev/state.md +++ b/docs/dev/state.md @@ -77,7 +77,7 @@ await state.setConfig('dev', { name: 'dev', type: 'local', isTest: false, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect: 'postgres', host: 'localhost', @@ -97,7 +97,7 @@ const dev = state.getConfig('dev') // List all configs with summary info const configs = state.listConfigs() -// [{ name: 'dev', type: 'local', isTest: false, protected: false, isActive: true }] +// [{ name: 'dev', type: 'local', isTest: false, access: { user: 'admin', mcp: 'admin' }, isActive: true }] // Delete a config (also removes its secrets) await state.deleteConfig('dev') diff --git a/docs/dev/teardown.md b/docs/dev/teardown.md index 70c19ad4..1a5d066a 100644 --- a/docs/dev/teardown.md +++ b/docs/dev/teardown.md @@ -314,7 +314,7 @@ The CLI shows a preview of affected objects before execution and requires explic - `__noorm_locks__` - Active operation locks **Confirmation Required:** -- Protected configs require extra confirmation +- Teardown is gated by the config's access role: `viewer`/`operator` is denied or requires confirmation, `admin` runs unconfirmed - Production stages show additional warnings - Dry-run is recommended before actual execution @@ -345,7 +345,7 @@ These settings are applied automatically by the CLI and can be overridden per-op 4. **Post-script for seeds** - Use `postScript` to re-insert required seed data after teardown. -5. **Check protected status** - Teardown on protected configs should require explicit confirmation. +5. **Check the config's access role** - Teardown on a `viewer`/`operator`-role config is denied or requires explicit confirmation (`checkPolicy(channel, config, 'db:reset')`); only `admin` runs unconfirmed. ```typescript // Safe teardown pattern diff --git a/docs/getting-started/concepts.md b/docs/getting-started/concepts.md index f943a9bc..36b4b028 100644 --- a/docs/getting-started/concepts.md +++ b/docs/getting-started/concepts.md @@ -192,7 +192,7 @@ A **config** is a saved database connection. You can have multiple configs for d - `dev` - Your local database - `test` - Test database (gets wiped between runs) - `staging` - Staging server -- `prod` - Production (protected) +- `prod` - Production (guarded — see Access Roles below) ``` ┌─ Configs ─────────────────────────────────┐ @@ -215,15 +215,16 @@ noorm -c prod run build ``` -## Protected Configs +## Access Roles -Some configs are marked as **protected**. This prevents accidental destructive operations: +Every config declares an access role — `viewer`, `operator`, or `admin` — separately for two **channels**: `user` (you, at the CLI or TUI) and `mcp` (an AI agent connected over MCP). This is how you prevent accidental destructive operations: -- `db teardown` - Blocked on protected configs -- `db truncate` - Requires confirmation -- Accidental `DROP TABLE` - Still executes (be careful!) +- `viewer` - Read-only. `db teardown`, writes, and changes are all blocked. +- `operator` - Reads and writes are fine; `db teardown` and applying changes require typing a confirmation phrase. +- `admin` - No friction. Everything is allowed, nothing prompts. +- Accidental `DROP TABLE` via raw SQL - Still executes if the role allows SQL writes/DDL (be careful!) -Protection is a safety net, not a security boundary. It catches mistakes like running teardown against the wrong database. +Roles are a safety net, not a security boundary. They catch mistakes like running teardown against the wrong database — they won't stop a determined user or malicious script. ## Secrets @@ -320,7 +321,7 @@ stages: required: true ``` -Now when you create a config with stage `production`, it's automatically protected and requires an `AWS_SECRET_KEY` secret. +Now when you create a config with stage `production`, its resolved access is capped to at most `operator`/`viewer` (it can be stricter, never looser), and it requires an `AWS_SECRET_KEY` secret. ## SQL Files vs Changes diff --git a/docs/guide/automation/ci.md b/docs/guide/automation/ci.md index 6c2958de..7f1ba36e 100644 --- a/docs/guide/automation/ci.md +++ b/docs/guide/automation/ci.md @@ -90,7 +90,7 @@ jobs: ## Prod CI -When templates render vault-backed secrets or the target config is protected, the runner needs two things the test flow skipped: +When templates render vault-backed secrets or the target config's `access.user` role isn't `admin`, the runner needs two things the test flow skipped: 1. **An identity** — an X25519 keypair whose public key is enrolled in the target database, giving it a slot in the `encrypted_vault_key` column. 2. **A bootstrapped `.noorm/state/state.enc`** — the ephemeral state that every later command expects to find. diff --git a/docs/guide/automation/mcp.md b/docs/guide/automation/mcp.md index 936db6c6..2ce4c87c 100644 --- a/docs/guide/automation/mcp.md +++ b/docs/guide/automation/mcp.md @@ -90,11 +90,38 @@ Once connected, your agent can: - **Build schemas** — execute SQL files - **Inspect templates** — see available context before rendering -Config resolution, protection checks, and identity attribution work the same as the CLI. +Config resolution and identity attribution work the same as the CLI. Access control does not: every command is gated by the config's **`mcp` role** before its handler ever runs, and there is no confirmation flow — the agent gets an answer, not a prompt. -## Security +## Access Roles + +Every config declares a role per channel: `access: { user, mcp }`. The `mcp` role decides what an agent connected over this server can do to that config — independently of what a human gets in the CLI/TUI. + +| Role | What the agent can do | +|------|------------------------| +| `viewer` | Explore schema, run read-only SQL (`SELECT`, `EXPLAIN`, `SHOW`, `DESCRIBE`) | +| `operator` | Everything `viewer` can, plus SQL writes (`INSERT`/`UPDATE`/`DELETE`) — destructive commands (`change_run`, `run_build`, etc.) are still out of reach | +| `admin` | Full access: writes, DDL, changes, builds — frictionless, no confirmation | +| `false` | Invisible — the config does not exist on this channel | + +Raw SQL is classified by what the statement actually does (`sql:read` / `sql:write` / `sql:ddl`), not by which command the agent called — an agent with `mcp: viewer` gets a `SELECT` through but a same-shaped `INSERT` denied. + +There is no human on the other end of stdio, so the matrix's `confirm` cells never prompt on this channel — they resolve straight to **deny**, with a message pointing at the CLI. An agent typing its own confirmation phrase would be theater, not a safeguard. If an agent legitimately needs to run changes on a database, give that config `mcp: 'admin'` — reserve it for configs where that's an acceptable risk (a disposable dev database, say), not production. + +```yaml +# Shape of the config's `access` field — set via `noorm ui` → Config → Edit, +# or by editing the JSON before `config import` (see Config Sharing) +access: + user: admin # what you get in the CLI/TUI + mcp: viewer # what any connected agent gets +``` + + +## Invisible Configs -The MCP server uses the identity and config of the shell session that spawned it. It has no way to escalate privileges beyond what `noorm` itself can do. +Set `access.mcp: false` to hide a config from MCP entirely — it never appears in `list_configs`, and `connect`/`getContext` fail with the same error an unknown config name would produce. An agent enumerating configs cannot tell the difference between "doesn't exist" and "exists but is off-limits." + + +## Security -Protected configs still block destructive operations. Your agent must pass `--force` (via the payload) to override protection, same as a human caller. +The MCP server uses the identity and config of the shell session that spawned it. It has no way to escalate privileges beyond what `noorm` itself can do, and it cannot escalate past the `mcp` role a config was given — there is no `--force` override on this channel. diff --git a/docs/guide/automation/non-interactive.md b/docs/guide/automation/non-interactive.md index dff86087..5394a302 100644 --- a/docs/guide/automation/non-interactive.md +++ b/docs/guide/automation/non-interactive.md @@ -113,6 +113,7 @@ import ` with a pre-built JSON file. See destructive operations: - `noorm db drop`, `noorm db reset`, `noorm db teardown` still require `--yes` *or* `--force` per their own contracts. `NOORM_YES=1` does count toward those gates. +- Every destructive command is also gated by the config's access role (`viewer`/`operator`/`admin` — see [Access Roles](../../headless.md#access-roles)). `--yes`/`NOORM_YES=1` only skips an `operator` confirmation prompt; it cannot open a `deny` cell. A `viewer`-role config still refuses `db teardown` no matter how many flags you pass. - Vault and identity operations that need a private key still need the key — either on disk at `~/.noorm/identity.key` or in `$NOORM_IDENTITY_PRIVATE_KEY`. If a command refuses with a useful error in interactive mode, it refuses diff --git a/docs/guide/database/create.md b/docs/guide/database/create.md index eac8f224..5f4880c1 100644 --- a/docs/guide/database/create.md +++ b/docs/guide/database/create.md @@ -66,8 +66,8 @@ Three reasons, in order of importance: 1. **Configs already hold the database name.** The connection block in a noorm config carries `dialect`, `host`, `port`, `database`, `user`, `password`, and a handful of safety flags (`isTest`, - `protected`). Passing `--name foo` would let you bypass the - `protected` flag and create a database the active config doesn't + `access`). Passing `--name foo` would let you bypass the config's + `access` role and create a database the active config doesn't know about — a foot-gun. 2. **The config is also where stage defaults, secrets, and identity diff --git a/docs/guide/database/teardown.md b/docs/guide/database/teardown.md index 4d102310..cb79940b 100644 --- a/docs/guide/database/teardown.md +++ b/docs/guide/database/teardown.md @@ -52,8 +52,8 @@ Teardown removes all database objects. Clean slate for full rebuilds. noorm -y db teardown ``` -::: warning Protected Configs -`db teardown` is blocked on protected configs. Use `--force` to override, but consider whether you really want to drop everything from production. +::: warning Access Roles +`db teardown` is gated by the config's `db:reset` access role: `viewer` is denied outright, `operator` must confirm (`yes-`, or `NOORM_YES=1` in CI), `admin` runs unconfirmed — see [Access Roles](#access-roles) below. ::: ### Drop Order @@ -79,45 +79,46 @@ noorm internal tables are always preserved: After teardown, noorm can still track what was applied previously. Changes are marked as `stale`, meaning they'll re-run on the next [fast-forward](/guide/changes/forward-revert). See [History](/guide/changes/history) for how this affects your execution log. -## Protected Configs +## Access Roles -[Configs](/guide/environments/configs) marked as `protected: true` block destructive operations. This prevents accidentally wiping production data. You can also set protection at the [stage level](/guide/environments/stages). +[Configs](/guide/environments/configs) declare an `access.user` role — `viewer`, `operator`, or `admin` — that gates `db teardown` (and `truncate`, `reset`) via the `db:reset` permission. This prevents accidentally wiping production data. You can also cap it at the [stage level](/guide/environments/stages): a stage with `protected: true` clamps every linked config's resolved access to at most `operator`/`viewer`. ```yaml # .noorm/settings.yml stages: prod: defaults: - protected: true + protected: true # access ceiling: at most operator/viewer ``` -When you attempt teardown on a protected config: +When you attempt teardown on a `viewer`-role config: ``` -Cannot teardown on protected config "prod" +Cannot teardown on config "prod": "db:reset" is not allowed on config "prod" (role: viewer) ``` -### Overriding Protection +An `operator`-role config doesn't refuse teardown — it asks for confirmation instead (see below). -**CLI**: Use `--force` flag (with `--yes` to skip confirmation) +### Confirming on `operator` + +**CLI/TUI**: Type the confirmation phrase, or pass `--yes` (`NOORM_YES=1` in CI) to skip the prompt: ```bash -noorm -y --force db teardown --config prod +NOORM_YES=1 noorm db teardown --config prod ``` -**SDK**: Pass `allowProtected: true` +**SDK**: There is no prompt to answer. `ctx.noorm.db.teardown()` throws `ProtectedConfigError` on an `operator`-role config unless `NOORM_YES=1` is set in the environment — the same variable the CLI honors: ```typescript -const ctx = await createContext({ - config: 'prod', - allowProtected: true, -}) +const ctx = await createContext({ config: 'prod' }) -await ctx.teardown() // Now allowed +await ctx.noorm.db.teardown() // Throws ProtectedConfigError on operator; run via CLI/TUI or set NOORM_YES=1 ``` +A `viewer`-role config denies `db teardown` outright — no flag, environment variable, or SDK option overrides that. Give the config `admin` access if it genuinely needs frictionless teardown. + ::: danger -Overriding protection on production databases should be exceedingly rare. If you find yourself doing this regularly, reconsider your deployment workflow. +Running teardown against production should be exceedingly rare. If you find yourself doing this regularly, reconsider your deployment workflow. ::: @@ -347,7 +348,7 @@ SQLite uses DELETE instead of TRUNCATE because SQLite does not support TRUNCATE. **Use postScript for seeds.** Re-insert required data automatically after teardown. -**Check protected status.** The protected flag exists for a reason. Think twice before overriding. +**Check the config's access role.** A `viewer`/`operator` role exists for a reason. Think twice before switching a config to `admin` just to skip the confirmation. ```bash # Safe teardown pattern diff --git a/docs/guide/environments/configs.md b/docs/guide/environments/configs.md index ad518871..41cecc66 100644 --- a/docs/guide/environments/configs.md +++ b/docs/guide/environments/configs.md @@ -13,7 +13,7 @@ Real projects talk to multiple databases: - `dev` - Your local machine, fast iteration - `test` - Gets wiped between test runs - `staging` - Mirrors production, catches issues early -- `prod` - The real thing, protected from accidents +- `prod` - The real thing, locked down by access roles Without saved configs, you'd type connection details every time. With them, you switch environments in one keystroke. @@ -110,7 +110,8 @@ Every config has these fields: | `database` | Yes | Database name or file path | | `user` | No | Authentication username | | `password` | No | Authentication password (stored encrypted) | -| `protected` | No | Enables safety checks (default: false) | +| `access.user` | No | CLI/TUI/SDK role: `viewer`, `operator`, or `admin` (default: `admin`) | +| `access.mcp` | No | MCP role: `viewer`, `operator`, `admin`, or `false` to hide from MCP (default: `admin`) | | `isTest` | No | Marks as test database (default: false) | | `ssl` | No | SSL/TLS configuration | | `pool` | No | Connection pool settings | @@ -126,33 +127,39 @@ Every config has these fields: | SQLite | N/A | -## Protected Configs +## Access Roles -Production databases need safeguards. Mark a config as protected to prevent accidental damage by toggling the **Protected** flag in `noorm ui` → Config → Edit. +Production databases need safeguards. Every config carries an access role per **channel** — who's asking. `noorm ui` → Config → Edit sets `access.user` (the CLI/TUI/SDK) and `access.mcp` (an AI agent connected via MCP) independently, so a config can be wide open to you at the terminal while showing an agent only read access. -Protected configs change how dangerous operations behave: +| Role | Behavior | +|------|----------| +| `viewer` | Read-only: explore schema, run `SELECT`/`EXPLAIN` | +| `operator` | Reads plus writes; destructive operations (`change run/ff/revert`, `run build`, `db create`/`db reset`) require typing a confirmation phrase; `db drop` is denied | +| `admin` | Frictionless — everything allowed, nothing prompts | -| Operation | Protected Behavior | -|-----------|-------------------| -| `change run` | Requires confirmation | -| `change revert` | Requires confirmation | -| `change ff` | Requires confirmation | -| `db create` | Requires confirmation | -| `db teardown` | **Blocked entirely** | +| Operation | viewer | operator | admin | +|-----------|--------|----------|-------| +| `change run` / `change ff` / `change revert` | denied | confirm | allowed | +| `run build` | denied | confirm | allowed | +| `db create` | denied | confirm | allowed | +| `db teardown` | denied | confirm | allowed | +| `db drop` | denied | denied | confirm | -When you run a protected operation in the TUI, noorm asks you to type a confirmation phrase. This catches the "oops, wrong database" moment before damage happens. +When an `operator`-role operation needs confirmation in the TUI, noorm asks you to type a phrase (`yes-`). This catches the "oops, wrong database" moment before damage happens. **Skipping confirmations in CI:** -Automated pipelines can't type confirmations. Set `NOORM_YES=1` to skip them: +Automated pipelines can't type confirmations. Set `NOORM_YES=1` to skip them on the `user` channel: ```bash export NOORM_YES=1 noorm -c prod change run ``` -::: warning Protection is Not Security -Protected configs prevent accidents, not attacks. They won't stop a determined user or malicious script. Use proper database permissions for real security. +There is no equivalent skip on the MCP channel — an agent hitting a `confirm` cell always gets denied, redirected to the CLI. See [MCP](/guide/automation/mcp#access-roles) for the agent-facing side of this. + +::: warning Access Roles Are Not Security +Roles prevent accidents, not attacks. They won't stop a determined user or malicious script. Use proper database permissions for real security. ::: diff --git a/docs/guide/environments/stages.md b/docs/guide/environments/stages.md index b58b2ce4..05bdf48b 100644 --- a/docs/guide/environments/stages.md +++ b/docs/guide/environments/stages.md @@ -96,14 +96,14 @@ Some defaults are **enforced** and cannot be overridden in the TUI: | Default | Behavior | |---------|----------| -| `protected: true` | Cannot be set to false in the TUI | +| `protected: true` | Acts as an access ceiling: whatever `access` the config or the developer sets, the *resolved* access is clamped to at most `{ user: 'operator', mcp: 'viewer' }` | | `isTest: true` | Cannot be set to false in the TUI | | `dialect` | Cannot be changed after config creation | -This means if your `prod` stage sets `protected: true`, developers cannot create an unprotected production config through the normal workflow. +This means if your `prod` stage sets `protected: true`, developers cannot create a fully-open production config through the normal workflow — even picking `admin` in the TUI's access editor gets clamped back down at resolution time. ::: tip Manual Override -These enforced defaults can still be changed by manually editing the encrypted state file, but this requires deliberate action. The protection is against accidents, not determined circumvention. +The ceiling is enforced every time the config is resolved, so it survives even a manually edited state file — there's no stored value to hand-edit around. The protection is against accidents, not an attempt at real security. ::: diff --git a/docs/headless.md b/docs/headless.md index bfa2b68a..fbee29b4 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -167,6 +167,27 @@ NOORM_PATHS_CHANGES → paths.changes 4. Config defaults +## Access Roles + +Each config carries a per-channel access grant: `access: { user, mcp }`. The `user` role governs the CLI, TUI, and SDK; `mcp` governs the MCP server (see [MCP](./guide/automation/mcp.md)). Roles are fixed — `viewer`, `operator`, `admin` — and hard-coded to this matrix (cells: allow / confirm / deny): + +| Command class | viewer | operator | admin | +|---|---|---|---| +| explore, `sql` reads | allow | allow | allow | +| `sql` writes (INSERT/UPDATE/DELETE) | deny | allow | allow | +| `sql` DDL (CREATE/ALTER/DROP/...) | deny | deny | allow | +| `change run`/`ff`/`revert`, `run build`/`file`/`dir` | deny | confirm | allow | +| `db create`, `db reset` (truncate/teardown/reset) | deny | confirm | allow | +| `db drop` | deny | deny | confirm | +| `config rm` | deny | confirm | confirm | + +Raw SQL (`noorm sql`) is gated by what the statement actually does, not by a flag — a multi-statement input is classified by its highest class (a `SELECT` plus a `DROP` classifies as DDL). Unparseable or unrecognized statements classify as DDL, fail closed. + +`confirm` means: type the phrase `yes-` when prompted, or set `NOORM_YES=1` to skip the prompt in CI. There is no `--force` override for a denied permission — `--force` only skips file checksums (see [Common Flags](#common-flags)). + +**Migration note:** the old `Config.protected: boolean` maps automatically on first load — `protected: true` becomes `{ user: 'operator', mcp: 'viewer' }`, `protected: false` (or absent) becomes `{ user: 'admin', mcp: 'admin' }`. The legacy field is still accepted on `config import` for one version, then dropped. + + ## Commands @@ -936,7 +957,7 @@ noorm db drop -c dev --yes --json ``` ::: danger Destructive -Drops the entire database. Requires `--yes`. Cannot drop protected configs without `--force`. +Drops the entire database. Requires `--yes`. Gated by the config's `db:destroy` access: `viewer`/`operator` are denied outright — there is no flag that overrides this — and `admin` still requires confirmation. See [Access Roles](#access-roles) above. ::: @@ -998,11 +1019,10 @@ Drop all database objects. ```bash noorm -y db teardown -noorm --force db teardown # Override protection ``` -::: warning Protected Configs -`db teardown` is blocked on protected configs. Use `--force` to override. +::: warning Access Roles +`db teardown` is gated by the config's `db:reset` access: `viewer` is denied, `operator` must confirm (`yes-`, or `NOORM_YES=1` in CI), `admin` runs unconfirmed. There is no `--force` override — see [Access Roles](#access-roles) above. ::: diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index de65908d..5a219068 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -50,7 +50,7 @@ interface CreateContextOptions { config?: string; // Config name (or use NOORM_CONFIG env var) projectRoot?: string; // Defaults to process.cwd() requireTest?: boolean; // Refuse if config.isTest !== true - allowProtected?: boolean; // Allow destructive ops on protected configs + channel?: Channel; // 'user' (default) or 'mcp' — which access role applies stage?: string; // Stage name for stage defaults } @@ -67,9 +67,11 @@ const ctx = await createContext({ | `config` | `string` | Config name to use. Falls back to `NOORM_CONFIG` env var. | | `projectRoot` | `string` | Path to noorm project. Defaults to `process.cwd()`. | | `requireTest` | `boolean` | Throws `RequireTestError` if config doesn't have `isTest: true`. | -| `allowProtected` | `boolean` | Allows destructive operations on protected configs. | +| `channel` | `Channel` | Which caller channel this context represents for access-policy checks. Defaults to `'user'`. A destructive operation the config's role denies — or that resolves to "requires confirmation," which the SDK can't prompt for — throws `ProtectedConfigError`. | | `stage` | `string` | Stage name for inheriting stage defaults. | +> Access is per-config, not per-context: each config declares `access: { user, mcp }` (roles `viewer`/`operator`/`admin`, or `mcp: false` to hide the config from the `mcp` channel entirely). `channel` tells `createContext` which half of that grant to enforce — see [Access Roles](/guide/environments/configs#access-roles). + ## Top-Level Context Properties @@ -1306,7 +1308,7 @@ try { await ctx.noorm.db.truncate(); } catch (err) { if (err instanceof ProtectedConfigError) { - console.error('Cannot truncate protected database'); + console.error('Denied by the config\'s access role, or needs confirmation the SDK can\'t give'); } } @@ -1363,6 +1365,11 @@ import type { Identity, Dialect, + // Access policy + Channel, + ConfigAccess, + Role, + // Results BatchResult, FileResult, diff --git a/docs/spec/config-access-roles.md b/docs/spec/config-access-roles.md new file mode 100644 index 00000000..b7ac700d --- /dev/null +++ b/docs/spec/config-access-roles.md @@ -0,0 +1,153 @@ +# Spec: per-config access roles + +Design: `docs/design/config-access-roles.md` · Issue: noormdev/noorm#40 + +The body of this spec is current truth. Superseded decisions live only in the change log. + + +## Objective + + +Replace `Config.protected: boolean` with config-scoped, channel-keyed roles enforced by one central policy check. Add MCP invisibility (`mcp: false`). Gate raw SQL by statement classification. Close the existing gap where MCP `change_run`/`change_ff`/`change_revert`/`run_file`/`run_build` bypass protection entirely. + + +## Data model + + + type Role = 'viewer' | 'operator' | 'admin' + type Channel = 'user' | 'mcp' + + interface ConfigAccess { + user: Role; + mcp: Role | false; + } + +- `Config` gains `access?: ConfigAccess` — optional in the type until CP6 because CP4/CP5-owned files (`src/cli/ci/init.ts`, TUI config screens, app-context) construct `Config` literals without it; every config materialized through `parseConfig`/state load has it populated. **CP6 makes the field required** once those constructors are updated. Zod default when absent: `{ user: 'admin', mcp: 'admin' }`. +- `ConfigSummary` gains required `access: ConfigAccess`. +- Until CP6, `Config.protected` remains present and is **derived at load**: `protected = (access.user !== 'admin')`. It is never persisted (state `persist()` strips it; the zod schema never emits a stored value). No caller may write `protected` after CP2. CP6 deletes the field everywhere except the state migration parser. +- Enforcement code must not trust the optionality: on the `mcp` channel, a config with absent `access` is **denied** (fail closed) — in practice unreachable, since configs reach enforcement via parse/migration. + + +## Permissions and matrix + + + type Permission = + | 'explore' + | 'sql:read' | 'sql:write' | 'sql:ddl' + | 'change:run' | 'change:ff' | 'change:revert' + | 'run:build' | 'run:file' | 'run:dir' + | 'db:create' | 'db:reset' | 'db:destroy' + | 'config:rm' + +Matrix (cells: `allow` / `confirm` / `deny`), hard-coded in `src/core/policy/`: + +| permission | viewer | operator | admin | +|---|---|---|---| +| explore | allow | allow | allow | +| sql:read | allow | allow | allow | +| sql:write | deny | allow | allow | +| sql:ddl | deny | deny | allow | +| change:run, change:ff | deny | confirm | allow | +| change:revert | deny | confirm | allow | +| run:build, run:file, run:dir | deny | confirm | allow | +| db:create | deny | confirm | allow | +| db:reset | deny | confirm | allow | +| db:destroy | deny | deny | confirm | +| config:rm | deny | confirm | confirm | + + type PolicyTarget = { name: string; access: ConfigAccess } + // Config satisfies PolicyTarget structurally once CP2 adds `access`. + + checkPolicy(channel: Channel, target: PolicyTarget, permission: Permission): PolicyCheck + // PolicyCheck = { allowed, requiresConfirmation, confirmationPhrase?, blockedReason? } + // — same shape as the old ProtectionCheck. guarded(target) likewise takes PolicyTarget. + +Channel resolution of `confirm`: + +- `user`: `allowed: true, requiresConfirmation: true, confirmationPhrase: 'yes-'`. `NOORM_YES=1` (`shouldSkipConfirmations()`) resolves it to plain allow. +- `mcp`: `allowed: false`, blockedReason directs to the CLI. `NOORM_YES` has no effect on the mcp channel. + +`mcp: false` is not a role: policy is never consulted for an invisible config — visibility is enforced before policy (see Enforcement). + +Helper: `guarded(config): boolean = config.access.user !== 'admin'` — used by TUI styling, `config list` display, and settings rule matching. Display-only; never an enforcement input. + + +## SQL classification + + + classifyStatements(sql: string, dialect: Dialect): 'read' | 'write' | 'ddl' + +Lives in `src/core/policy/classify.ts` (moved and generalized from `src/rpc/protection.ts`; that file is deleted once callers are rewired). + +- CST first (`sql-parser-cst`), keyword fallback when the parser throws (existing strategy, including CTE handling). +- read: SELECT, EXPLAIN, SHOW, DESCRIBE/DESC. write: INSERT, UPDATE, DELETE, MERGE. ddl: everything else that parses (CREATE, ALTER, DROP, TRUNCATE, GRANT, REVOKE, SET, …). +- **Data-modifying CTEs**: a `WITH` whose any sub-statement is INSERT/UPDATE/DELETE/MERGE takes that sub-statement's class, even when the outer statement is `SELECT`. A `viewer`'s "touches nothing" guarantee requires this — `WITH t AS (DELETE … RETURNING …) SELECT * FROM t` is a write. Detect the DML node in the CST (and in the keyword fallback, a data-modifying keyword anywhere at CTE-definition depth), do not key only off the final keyword. +- **Side-effecting functions**: a SELECT that invokes a function on the `DESTRUCTIVE_FUNCTIONS` denylist (`src/core/policy/classify.ts`) classifies as at least `write`, whether the call is bare (`pg_terminate_backend(…)`) or schema-qualified (`pg_catalog.pg_terminate_backend(…)`). The list is a hardcoded, extensible constant seeded with known side-effecting builtins (`pg_terminate_backend`, `pg_cancel_backend`, `pg_reload_conf`, `pg_promote`, `lo_import`, `lo_export`, `lo_unlink`, `setval`, `nextval`, `dblink_exec`, `query_to_xml` and family, …). This is deliberately a denylist, not an allowlist: `SELECT f()` is statically undecidable, so pure helpers (`count`, `now`, `coalesce`, …) stay `read` and only known-dangerous calls are caught. Documented limitations: (a) a `viewer` calling an *unlisted* side-effecting function is not blocked; (b) `classifyStatements` guards against **mutation** (write/ddl), not **disclosure** — a read-only exfiltration function like `pg_read_file`/`pg_ls_dir` stays `read`. The role is a guardrail against casual/accidental writes, not an airtight sandbox; operators needing hard confidentiality or write isolation must back it with database-level `GRANT`s. +- Multi-statement input: highest class wins (read < write < ddl). +- Empty input: `read`. Unparseable input, unknown statement type, `EXEC`/`CALL`: `ddl` — fail closed. +- Applies to the ad-hoc raw-SQL surfaces: the `sql` RPC command and the TUI SQL terminal. Change/run files are command-gated (trusted, tracked artifacts), never content-classified. + + +## Enforcement + + +1. **RPC**: `RpcCommand` gains required `permission: Permission | 'open'`. `'open'` = no config-scoped gate (`list_configs`, `connect`, `disconnect`, `noorm_help` internals); every other command declares its permission. Assignments: explore/overview/list/detail → `explore`; sql → `sql:read` (handler escalates: run `classifyStatements` and re-check with the actual class before executing); change_history → `explore`; change_run → `change:run`; change_ff → `change:ff`; change_revert → `change:revert`; run_build → `run:build`; run_file → `run:file`. +2. **Gate location**: `run_noorm_cmd` dispatch in `src/mcp/server.ts`. For non-`'open'` commands: resolve target config (explicit `config` arg, else session active config), then `checkPolicy('mcp', config, cmd.permission)`; deny → `isError` result with `blockedReason`, handler never runs. +3. **Channel ownership**: `SessionManager` is constructed with a `Channel` (`'mcp'` in `mcp serve`). It exposes the channel to the gate. +4. **Invisibility** (`mcp: false`, mcp channel only): `list_configs` omits the config; `SessionManager.connect` and `getContext` throw the **byte-identical** error an unknown config name produces (assert equality in tests). Session info returned by `connect` reports the channel's effective role instead of `protected`. +5. **SDK**: `CreateContextOptions` gains `channel?: Channel` (default `'user'`). `src/sdk/guards.ts` destructive-op checks call `checkPolicy` instead of reading `protected`. Guard permission mapping: `db.truncate`/`db.teardown`/`db.reset`/`dt.importFile` → `db:reset` (data-destructive, admin frictionless — preserves pre-migration behavior for open configs, which is the migration section's promise); `changes.revert`/`changes.rewind` → `change:revert`; `changes.run`/`changes.ff` → `change:run`/`change:ff`; `run.file`/`run.dir`/`run.build` → `run:file`/`run:dir`/`run:build`; data transfer into a target → `db:reset`. In the SDK (no prompt available), `requiresConfirmation` blocks with a message naming `NOORM_YES=1` and the CLI/TUI; `checkPolicy` already resolves `NOORM_YES=1` to allow on the user channel. On deny, the thrown error carries the policy's `blockedReason` (role + permission), and `ProtectedConfigError.operation` names the public method the caller invoked. +5a. **Core-seam enforcement** (the "enforced above the driver" promise): the user-channel gate lives at the core boundary each surface funnels through — `runFile`/`runFiles` (`core/runner`), data transfer (`core/transfer`), the SQL terminal executor (ad-hoc SQL → `classifyStatements` then gate), and `changes.run`/`ff`/`revert` (`core/change`). The `Context` carries its `Channel`, so SDK, TUI, and CLI callers all inherit one enforcement path rather than each re-implementing checks per screen/namespace. Run/change files gate on their command permission (not content-classified); only the ad-hoc SQL terminal is classified. +6. **CLI/TUI**: inherit enforcement from the core seam (5a). `SmartConfirm` takes `requiresConfirmation` + `confirmationPhrase` instead of `protected: boolean`; `ProtectedConfirm` keeps the `yes-` phrase flow, driven by `confirmationPhrase` — sourced from one `confirmationPhraseFor(name)` helper in `src/core/policy/`, never re-templated per screen. The matrix is the enforcement **floor** — screens may keep stricter confirm UX than the cell requires (TUI teardown/truncate retain their unconditional phrase-confirm for all roles, and additionally deny when `checkConfigPolicy('user', config, 'db:reset')` says deny). +7. **Settings stages**: stage `protected: true` becomes an access **ceiling** at resolution (`src/core/config/resolver.ts`): resolved access is clamped to at most `{ user: 'operator', mcp: 'viewer' }`; stricter survives, looser is clamped. Settings rule `match.protected` matches `guarded(config)`. Stage/rule YAML vocabulary is unchanged in this issue. + + +## Migration + + +New state migration in `src/core/version/state/migrations/` (registered in `src/core/version/state/index.ts`, bump `CURRENT_VERSIONS.state`): + +- `protected: true` → `access: { user: 'operator', mcp: 'viewer' }` +- `protected: false` or absent → `access: { user: 'admin', mcp: 'admin' }` +- Stored `protected` field dropped from the persisted shape after migration. + +`ConfigSchema` (zod) accepts a legacy `protected` key on input for one version (feeding the same mapping) and never emits it. + + +## Checkpoints + + +Each checkpoint ends green: `bash tmp/run-test-groups.sh` (mirrors CI's four fresh-process `bun test --serial` groups — a single whole-suite `bun test` cross-contaminates and does not reflect CI; DB containers from repo-root `docker-compose.yml` must be up), `bun run typecheck`, `bun run typecheck:tests`, `bun run lint`. Commit per green checkpoint. + +| CP | Scope | Key files | Done when | +|---|---|---|---| +| 1 | Policy core, additive, unwired: types, matrix, `checkPolicy`, `guarded`, `classifyStatements` | new `src/core/policy/{types,matrix,check,classify,index}.ts`; new tests `tests/core/policy/` | Table-driven tests cover every matrix cell × both channels; classifier tests cover read/write/ddl, multi-statement max, CTE, EXEC/CALL→ddl, unparseable→ddl, empty→read. Existing suite untouched. | +| 2 | Config carries `access`: types, zod schema + default, state migration, resolver stage-ceiling clamp, `ConfigSummary.access`, `protected` becomes derived-at-load | `src/core/config/{types,schema,resolver}.ts`, `src/core/state/manager.ts`, `src/core/version/state/*`, `src/core/settings/rules.ts` (match via `guarded`) | Migration test: v-prev state with protected true/false loads with mapped access and no stored `protected`; resolver clamp tests (stricter survives, looser clamped); all existing tests green with derived `protected`. | +| 3 | MCP enforcement: `RpcCommand.permission`, dispatch gate, sql escalation via classifier, invisibility, session channel + role in session info | `src/rpc/{types,registry,session}.ts`, `src/rpc/commands/*.ts`, `src/mcp/server.ts`, `src/mcp/init.ts`; delete `src/rpc/protection.ts` after rewiring `query.ts` | Gate tests: viewer denies change_run/sql-write/ddl; operator denies change_run (confirm→deny) but allows sql write; admin allows; `mcp: false` absent from list_configs and connect error byte-identical to unknown config (string-equality assertion); sql on viewer allows SELECT, denies INSERT. | +| 4 | User-channel core: SDK guards via `checkPolicy`, `CreateContextOptions.channel`, CLI consumers (`ci/init` access defaults, `config/list` access display, `db/drop` policy check) | `src/sdk/{types,index,guards}.ts`, `src/cli/ci/init.ts`, `src/cli/config/list.ts`, `src/cli/db/drop.ts` | `tests/sdk/guards.test.ts` + `destructive-ops.test.ts` rewritten against access; CLI behavior covered by existing tests updated to fixtures with `access`. | +| 5 | TUI: `SmartConfirm`/`ProtectedConfirm` consume `PolicyCheck`; 12 screens swap `activeConfig.protected` for `checkPolicy`/`guarded`; ConfigAdd/Edit edit `access.user` + `access.mcp` (incl. `false`); Export/Import carry `access`; app-context stage defaults | `src/tui/components/dialogs/*.tsx`, `src/tui/screens/{change,config,db,run,lock,settings}/*.tsx`, `src/tui/app-context.tsx` | Typecheck green with zero `protected` reads in `src/tui/` except settings-stage vocabulary; existing TUI tests (if any) green. | +| 6 | Kill `protected`: delete field from `Config`/`ConfigSummary`/session info, delete `src/core/config/protection.ts` + its exports, sweep remaining reads, update all test fixtures | `src/core/config/{types,schema,index}.ts`, remaining consumers, `tests/**` fixtures | `rg -n '\.protected' src/ tests/` returns only settings stage/rule vocabulary and the state-migration parser; full suite + both typechecks green. | +| 7 | Docs + changeset: MCP guide access section, headless docs, config reference; minor changeset | `docs/guide/automation/mcp.md`, `docs/headless.md`, `docs/dev/headless.md`, config reference page, `.changeset/.md` | Docs describe access model (matrix, `mcp: false`, migration note); changeset present with `minor`. | +| 8 | **Post-swarm hardening** (challenge-swarm findings). Classifier: CTE-DML + destructive-function denylist. Core-seam user-channel enforcement (run/change/transfer/sql-terminal). Correctness: SDK deny carries `blockedReason`, `ProtectedConfigError.operation` labels, single-source `confirmationPhraseFor`, fix TUI `guarded` fail-open vs `checkConfigPolicy` fail-closed mismatch. Hygiene: permission-value test, delete dead `stageEnforcesProtected`, fix two false-confidence tests, honest changeset prose. | `src/core/policy/{classify,check}.ts`, `src/core/{runner,transfer,change,sql-terminal}/*`, `src/sdk/**`, `src/tui/**`, `tests/**`, `.changeset/*` | viewer denied on CTE-DML + denylisted funcs (probe + tests); run/transfer/sql-terminal gated on user channel; permission-value table test pins every command; dead code gone; false-confidence tests each have one failing behavior; all groups + both typechecks green. | + +**Deferred to follow-ups (out of scope for this PR — recorded in `.claude/project/followups/`):** downgrade version-guard + `state.enc.bak` (alpha, per product owner); `state.enc` atomic write + inter-process lock (pre-existing durability, broader than access-roles); policy-denial observability + MCP logger init (separate observability workstream); legacy `protected` input-path removal trigger keyed to a version; invisibility timing side-channel; single `ROLE_ORDER` constant. + + +## Conventions binding on builders + + +- Bun repo: run tests via `bash tmp/run-test-groups.sh` (CI-mirrored groups; see repo CLAUDE.md on cross-contamination). Typecheck both tsconfigs. +- Changeset frontmatter must reference `@noormdev/cli` and/or `@noormdev/sdk` — never `noorm` or `@noormdev/main` (breaks the Release workflow). +- Repo rules auto-load from `.claude/rules/` (typescript.md 4-block function structure + `attempt` tuples, tui-development.md, testing.md, documentation.md) — follow them. +- No `as` casts, no `any`; error tuples via `@logosdx/utils` `attempt`/`attemptSync` (existing idiom); native `#private` fields. +- TDD: failing test before implementation, per checkpoint. +- Discard scratch by moving it to `tmp/trash/`; never `rm`; do not chain shell commands. + + +## Change log + + +- 2026-07-07 — Initial spec from design doc + scoping report (issue #40). +- 2026-07-07 — CP1: `checkPolicy` takes a structural `PolicyTarget` (Config lacks `access` until CP2). Verification method corrected to CI-mirrored four-group runs. +- 2026-07-07 — CP2: `Config.access` optional until CP6 (CP4/CP5-owned constructors); fail-closed rule added for absent access on the mcp channel. Stage `protected: true` override-block in `checkConfigCompleteness` replaced by the ceiling clamp (the old "stored wins" behavior was the bug the clamp fixes). +- 2026-07-07 — CP4: added `db:reset` permission (viewer deny / operator confirm / admin allow) for data-destructive-but-not-drop operations; the initial CP4 mapping of truncate/teardown/reset/importFile to `db:destroy` violated the migration section's behavior-preservation promise for open configs. +- 2026-07-08 — CP8 (post challenge-swarm): classifier now catches CTE-wrapped DML and a destructive-function denylist (viewer write-bypass was confirmed critical). User-channel enforcement moved to the core seam so run/change/transfer/sql-terminal are gated for SDK+TUI+CLI (the earlier "same checkPolicy" claim was only partly delivered). Correctness + hygiene fixes per the swarm. Downgrade guard, state.enc durability/atomicity, and denial observability explicitly deferred (alpha; pre-existing; separate workstreams). diff --git a/docs/tui.md b/docs/tui.md index ff83cf72..0e816092 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -19,17 +19,17 @@ noorm - Database Schema & Change Manager Active Config: dev | Configs: 2 -┌─ Status ─────────────────────┐ ┌─ Quick Actions ──────────────┐ -│ │ │ │ -│ Connection: ● Connected │ │ [1] Run Build │ -│ Pending: 0 pending │ │ [2] Apply Changes (ff) │ -│ Lock: FREE │ │ [3] View Lock Status │ -│ │ │ │ -│ Stage Configs: │ │ │ -│ ✓ dev │ │ │ -│ prod [protected] │ │ │ -│ │ │ │ -└──────────────────────────────┘ └──────────────────────────────┘ +┌─ Status ────────────────────────────┐ ┌─ Quick Actions ──────────────┐ +│ │ │ │ +│ Connection: ● Connected │ │ [1] Run Build │ +│ Pending: 0 pending │ │ [2] Apply Changes (ff) │ +│ Lock: FREE │ │ [3] View Lock Status │ +│ │ │ │ +│ Stage Configs: │ │ │ +│ ✓ dev │ │ │ +│ prod [user:operator mcp:viewer] │ │ │ +│ │ │ │ +└─────────────────────────────────────┘ └──────────────────────────────┘ ┌─ Recent Activity ────────────────────────────────────────────┐ │ │ @@ -131,7 +131,7 @@ Home > Configurations │ │ │ > ○ dev postgres │ │ ● test postgres (active) [test] │ -│ ○ prod postgres [protected] │ +│ ○ prod postgres [user:operator mcp:off] │ │ │ └─────────────────────────────────────────────────────────────┘ @@ -143,7 +143,7 @@ Home > Configurations - `●` indicates active config - `○` indicates inactive config - `>` indicates cursor position -- `[protected]` tag shows protected configs +- `[user: mcp:]` tag shows access roles for any config that isn't fully open (`admin`/`admin`) — omitted entirely for wide-open configs - `[test]` tag shows test configs - Press `Enter` on a config to activate it diff --git a/docs/wiki/CLAUDE.md b/docs/wiki/CLAUDE.md new file mode 100644 index 00000000..13f4e3d0 --- /dev/null +++ b/docs/wiki/CLAUDE.md @@ -0,0 +1,22 @@ +--- +type: Steering +description: Authoritative steering for the signals/wiki inferrer when operating under docs/wiki/. +--- + + + +## Framework +# NestJS monorepo (not plain Express) + +## Domains +# - src/billing/ and src/payments/ are one domain ("payments") +# - src/internal-tools/ is scratch code — not a real domain + +## Build +# - Build: pnpm turbo build +# - Test: pnpm test:ci (not pnpm test — that runs watch mode) + +## Ignore for domains +# - vendor/ +# - generated/ diff --git a/docs/wiki/cli.md b/docs/wiki/cli.md index 80b4f990..e4b9196b 100644 --- a/docs/wiki/cli.md +++ b/docs/wiki/cli.md @@ -6,67 +6,71 @@ type: Domain ## What it does -Citty-based CLI with 17 top-level command groups. Each command group maps to a subdirectory under `src/cli/`. Commands emit events via the observer and delegate to core modules. Headless mode (`--yes`, `--json`) suppresses interactive prompts and formats output as JSON. +Citty-based CLI with 17 top-level command groups. Each command group maps to a subdirectory under [`src/cli/`](../../src/cli). Commands emit events via the observer and delegate to core modules. Headless mode (`--yes`, `--json`) suppresses interactive prompts and formats output as JSON. -Published as `@noormdev/cli` from `packages/cli/`. +Published as `@noormdev/cli` from [`packages/cli/`](../../packages/cli). ## Artifacts -- `packages/cli/package.json` — published package `@noormdev/cli`, version `1.0.0-alpha.35`; entry `noorm.js` -- `packages/cli/noorm.js` — thin wrapper that runs the compiled binary -- `packages/cli/scripts/postinstall.js` — postinstall script for binary extraction -- `packages/cli/CHANGELOG.md` — CLI release history -- `skills/noorm/SKILL.md` — Claude Code skill for noorm CLI usage -- `skills/noorm/references/cli.md` — comprehensive CLI command reference (1011L) -- `skills/noorm/references/config.md` — config management reference -- `skills/noorm/references/sdk.md` — SDK reference for skill use -- `skills/noorm/references/templates.md` — template reference for skill use +- [`packages/cli/package.json`](../../packages/cli/package.json) — published package `@noormdev/cli`, version `1.0.0-alpha.35`; entry `noorm.js` +- [`packages/cli/noorm.js`](../../packages/cli/noorm.js) — thin wrapper that runs the compiled binary +- [`packages/cli/scripts/postinstall.js`](../../packages/cli/scripts/postinstall.js) — postinstall script for binary extraction +- [`packages/cli/CHANGELOG.md`](../../packages/cli/CHANGELOG.md) — CLI release history +- [`skills/noorm/SKILL.md`](../../skills/noorm/SKILL.md) — Claude Code skill for noorm CLI usage +- [`skills/noorm/references/cli.md`](../../skills/noorm/references/cli.md) — comprehensive CLI command reference (1011L) +- [`skills/noorm/references/config.md`](../../skills/noorm/references/config.md) — config management reference +- [`skills/noorm/references/sdk.md`](../../skills/noorm/references/sdk.md) — SDK reference for skill use +- [`skills/noorm/references/templates.md`](../../skills/noorm/references/templates.md) — template reference for skill use ## CLI code -- `src/cli/index.ts` — citty entry point; registers all subcommands, help interceptor, `--cwd` global flag -- `src/cli/_utils.ts` — shared CLI utilities: headless detection, output formatting, flag parsing -- `src/cli/change/` — `change add|edit|ff|history|list|next|revert|rewind|rm|run` (13 files) -- `src/cli/ci/` — `ci init|secrets|identity/*` — CI automation commands -- `src/cli/config/` — `config add|cp|edit|export|import|list|rm|use|validate` (10 files) -- `src/cli/db/` — `db create|drop|explore*|reset|teardown|transfer|truncate` (16 files) -- `src/cli/dev/` — `dev test-helpers|test-workers` — internal diagnostics -- `src/cli/identity/` — `identity edit|export|init|list` -- `src/cli/lock/` — `lock acquire|force|release|status` -- `src/cli/mcp/` — `mcp init|serve` -- `src/cli/run/` — `run build|dir|exec|file|files|inspect|preview` (8 files) -- `src/cli/secret/` — `secret list|rm|set` -- `src/cli/settings/` — `settings build|edit|init|secret` (5 files) -- `src/cli/sql/` — `sql clear|history|query|repl` -- `src/cli/vault/` — `vault cp|init|list|propagate|rm|set` -- `src/cli/init.ts` — `noorm init` — project initialization wizard -- `src/cli/info.ts` — `noorm info` — display project + env info -- `src/cli/ui.ts` — `noorm ui` — launch TUI -- `src/cli/update.ts` — `noorm update` — self-update -- `src/cli/version.ts` — `noorm version` — print version info +- [`src/cli/index.ts`](../../src/cli/index.ts) — citty entry point; registers all subcommands, help interceptor, `--cwd` global flag +- [`src/cli/_utils.ts`](../../src/cli/_utils.ts) — shared CLI utilities: headless detection, output formatting, flag parsing +- [`src/cli/change/`](../../src/cli/change) — `change add|edit|ff|history|list|next|revert|rewind|rm|run` (13 files) +- [`src/cli/ci/`](../../src/cli/ci) — `ci init|secrets|identity/*` — CI automation commands +- [`src/cli/config/`](../../src/cli/config) — `config add|cp|edit|export|import|list|rm|use|validate` (10 files) +- [`src/cli/db/`](../../src/cli/db) — `db create|drop|explore*|reset|teardown|transfer|truncate` (16 files) +- [`src/cli/dev/`](../../src/cli/dev) — `dev test-helpers|test-workers` — internal diagnostics +- [`src/cli/identity/`](../../src/cli/identity) — `identity edit|export|init|list` +- [`src/cli/lock/`](../../src/cli/lock) — `lock acquire|force|release|status` +- [`src/cli/mcp/`](../../src/cli/mcp) — `mcp init|serve` +- [`src/cli/run/`](../../src/cli/run) — `run build|dir|exec|file|files|inspect|preview` (8 files) +- [`src/cli/secret/`](../../src/cli/secret) — `secret list|rm|set` +- [`src/cli/settings/`](../../src/cli/settings) — `settings build|edit|init|secret` (5 files) +- [`src/cli/sql/`](../../src/cli/sql) — `sql clear|history|query|repl` +- [`src/cli/vault/`](../../src/cli/vault) — `vault cp|init|list|propagate|rm|set` +- [`src/cli/init.ts`](../../src/cli/init.ts) — `noorm init` — project initialization wizard +- [`src/cli/info.ts`](../../src/cli/info.ts) — `noorm info` — display project + env info +- [`src/cli/ui.ts`](../../src/cli/ui.ts) — `noorm ui` — launch TUI +- [`src/cli/update.ts`](../../src/cli/update.ts) — `noorm update` — self-update +- [`src/cli/version.ts`](../../src/cli/version.ts) — `noorm version` — print version info ## Docs -- `docs/cli/` — 9 user-facing CLI reference pages -- `docs/dev/headless.md` — headless mode internals -- `docs/guide/automation/non-interactive.md` — non-interactive usage -- `docs/guide/automation/ci.md` — CI usage -- `docs/guide/automation/mcp.md` — MCP usage -- `docs/headless.md` — public headless reference (1592L) +- [`docs/cli/`](../cli) — 9 user-facing CLI reference pages +- [`docs/dev/headless.md`](../dev/headless.md) — headless mode internals +- [`docs/guide/automation/non-interactive.md`](../guide/automation/non-interactive.md) — non-interactive usage +- [`docs/guide/automation/ci.md`](../guide/automation/ci.md) — CI usage +- [`docs/guide/automation/mcp.md`](../guide/automation/mcp.md) — MCP usage +- [`docs/headless.md`](../headless.md) — public headless reference (1592L) ## Coupling -- Every CLI command imports from `src/core/` — any core API change may require CLI command updates. -- `src/cli/ui.ts` launches the TUI (`src/tui/app.tsx`) — TUI startup is a CLI concern. -- `src/cli/mcp/serve.ts` starts the MCP server from `src/mcp/server.ts` — MCP domain depends on CLI entry. +- Every CLI command imports from [`src/core/`](../../src/core) — any core API change may require CLI command updates. +- [`src/cli/ui.ts`](../../src/cli/ui.ts) launches the TUI ([`src/tui/app.tsx`](../../src/tui/app.tsx)) — TUI startup is a CLI concern. +- [`src/cli/mcp/serve.ts`](../../src/cli/mcp/serve.ts) starts the MCP server from [`src/mcp/server.ts`](../../src/mcp/server.ts) — MCP domain depends on CLI entry. - Headless mode output shape is consumed by CI pipelines and SDK integration tests. +- [`src/cli/db/drop.ts`](../../src/cli/db/drop.ts) and [`src/cli/sql/query.ts`](../../src/cli/sql/query.ts) call `checkConfigPolicy`/`executeRawSql` from [`src/core/policy/`](../../src/core/policy)/[`src/core/sql-terminal/executor.ts`](../../src/core/sql-terminal/executor.ts) — CLI destructive and raw-SQL commands gate through the same policy checks as MCP and TUI. +- [`src/cli/config/import.ts`](../../src/cli/config/import.ts) validates imported JSON via `parseConfig` ([`src/core/config/schema.ts`](../../src/core/config/schema.ts)) instead of a hand-rolled shape check — validation errors surface as `ConfigValidationError`. - `settings.paths.sql` and `settings.paths.changes` from `settings.yml` are the correct path sources (not per-config `paths` fields) — several run-related screens use `settings?.paths?.changes ?? 'changes'` pattern. ## Conventions worth knowing -- Commands attach `examples: string[]` to their `defineCommand` result; the help interceptor in `src/cli/index.ts` appends them after citty's auto-generated usage. +- Commands attach `examples: string[]` to their `defineCommand` result; the help interceptor in [`src/cli/index.ts`](../../src/cli/index.ts) appends them after citty's auto-generated usage. - `--cwd ` global flag (like `git -C`) must precede the subcommand. - `--yes` / `-y` flag suppresses all confirmation prompts (headless mode). - `--json` flag formats output as machine-readable JSON. - Build produces a standalone binary via `bun build --compile` — worker paths must use `resolveWorker()`. +- `config list` ([`src/cli/config/list.ts`](../../src/cli/config/list.ts)) prints an access tag (`user: mcp:`) instead of a `protected` flag, shown only when `guarded(config)` is true. +- `db drop` ([`src/cli/db/drop.ts`](../../src/cli/db/drop.ts)) no longer requires `--yes` unconditionally — it's only required when the resolved `db:destroy` policy check returns `requiresConfirmation`. - Workspace package `@noormdev/cli` publishes the pre-built binary; `postinstall.js` extracts it. diff --git a/docs/wiki/core-change.md b/docs/wiki/core-change.md index 7b37883c..c488df2c 100644 --- a/docs/wiki/core-change.md +++ b/docs/wiki/core-change.md @@ -12,30 +12,31 @@ Change directories hold a `manifest.json` and SQL files. Each change has a descr ## CLI code -- `src/core/change/scaffold.ts` — create/add/remove/rename/reorder change files on disk -- `src/core/change/parser.ts` — `parseChange`, `discoverChanges`, `resolveManifest`, `validateChange`, `parseSequence`, `parseDescription` -- `src/core/change/executor.ts` — `executeChange`, `revertChange`; applies SQL via the runner, records results -- `src/core/change/history.ts` — `ChangeHistory`; queries `__noorm_change__` and `__noorm_executions__` for per-change and per-file history -- `src/core/change/tracker.ts` — `ChangeTracker`; `canRevert` logic, orphaned-change detection -- `src/core/change/manager.ts` — `ChangeManager`; high-level facade: `list`, `run`, `revert`, `ff` (fast-forward) -- `src/core/change/validation.ts` — `validateChangeContent`; structural content checks -- `src/core/change/types.ts` — all change types, error classes (`ChangeValidationError`, `ChangeNotFoundError`, etc.) +- [`src/core/change/scaffold.ts`](../../src/core/change/scaffold.ts) — create/add/remove/rename/reorder change files on disk +- [`src/core/change/parser.ts`](../../src/core/change/parser.ts) — `parseChange`, `discoverChanges`, `resolveManifest`, `validateChange`, `parseSequence`, `parseDescription` +- [`src/core/change/executor.ts`](../../src/core/change/executor.ts) — `executeChange`, `revertChange`; applies SQL via the runner, records results. Each gates via `assertPolicy` (`core/policy`) against `ChangeContext.access`/`channel` before running (`change:run`/`change:revert` permissions) +- [`src/core/change/history.ts`](../../src/core/change/history.ts) — `ChangeHistory`; queries `__noorm_change__` and `__noorm_executions__` for per-change and per-file history +- [`src/core/change/tracker.ts`](../../src/core/change/tracker.ts) — `ChangeTracker`; `canRevert` logic, orphaned-change detection +- [`src/core/change/manager.ts`](../../src/core/change/manager.ts) — `ChangeManager`; high-level facade: `list`, `run`, `revert`, `ff` (fast-forward) +- [`src/core/change/validation.ts`](../../src/core/change/validation.ts) — `validateChangeContent`; structural content checks +- [`src/core/change/types.ts`](../../src/core/change/types.ts) — all change types, error classes (`ChangeValidationError`, `ChangeNotFoundError`, etc.) ## Docs -- `docs/dev/change.md` — developer reference for change internals -- `docs/guide/changes/overview.md` — user-facing: what changes are -- `docs/guide/changes/forward-revert.md` — forward and revert semantics -- `docs/guide/changes/history.md` — history querying -- `docs/cli/run.md` — run command docs (also covers change run) +- [`docs/dev/change.md`](../dev/change.md) — developer reference for change internals +- [`docs/guide/changes/overview.md`](../guide/changes/overview.md) — user-facing: what changes are +- [`docs/guide/changes/forward-revert.md`](../guide/changes/forward-revert.md) — forward and revert semantics +- [`docs/guide/changes/history.md`](../guide/changes/history.md) — history querying +- [`docs/cli/run.md`](../cli/run.md) — run command docs (also covers change run) ## Coupling - Calls `runner` (`runFile`) to execute SQL inside a change — changes in runner's `RunOptions` or file-execution semantics propagate here. -- Reads config via `src/core/config/` to resolve the active database connection — config schema changes affect `ChangeContext` construction. -- Emits events via `src/core/observer.ts` (`change:*` events) — the TUI subscribes via `useChangeProgress` hook. -- Writes to `__noorm_change__` and `__noorm_executions__` tables defined in `src/core/shared/tables.ts` — table renames propagate to executor and history queries. -- CLI commands in `src/cli/change/` call manager + scaffold functions — CLI argument shape changes here require CLI command updates. +- Reads config via [`src/core/config/`](../../src/core/config) to resolve the active database connection — config schema changes affect `ChangeContext` construction. +- Emits events via [`src/core/observer.ts`](../../src/core/observer.ts) (`change:*` events) — the TUI subscribes via `useChangeProgress` hook. +- Writes to `__noorm_change__` and `__noorm_executions__` tables defined in [`src/core/shared/tables.ts`](../../src/core/shared/tables.ts) — table renames propagate to executor and history queries. +- CLI commands in [`src/cli/change/`](../../src/cli/change) call manager + scaffold functions — CLI argument shape changes here require CLI command updates. +- `executeChange`/`revertChange` call `assertPolicy` from [`src/core/policy/`](../../src/core/policy) before executing — `ChangeContext` carries `access`/`channel` for the gate; policy-matrix changes affect which roles can run/revert changes. ## Conventions worth knowing diff --git a/docs/wiki/core-db.md b/docs/wiki/core-db.md index e7c8f447..397761a5 100644 --- a/docs/wiki/core-db.md +++ b/docs/wiki/core-db.md @@ -10,47 +10,50 @@ Database lifecycle operations: create/drop databases, schema exploration (tables ## CLI code -- `src/core/db/index.ts` — `checkDbStatus`, `createDb`, `destroyDb`, `getDialectOperations` -- `src/core/db/dual.ts` — `withDualConnection`; opens source + destination connections for transfer -- `src/core/db/dialects/` — dialect-specific create/drop implementations -- `src/core/explore/operations.ts` — `queryTables`, `queryViews`, `queryFunctions`, `queryIndexes`, `queryForeignKeys`, `queryProcedures`, `queryTypes` -- `src/core/explore/dialects/` — per-dialect SQL for introspection queries -- `src/core/explore/types.ts` — `TableInfo`, `ColumnInfo`, `ViewInfo`, `IndexInfo`, `ForeignKeyInfo`, etc. -- `src/core/teardown/operations.ts` — `truncateData`, `teardownSchema`, `previewTeardown` -- `src/core/teardown/dialects/` — dialect-specific truncate/drop implementations -- `src/core/teardown/types.ts` — `TruncateOptions`, `TeardownOptions`, `TeardownResult` -- `src/core/transfer/executor.ts` — `executeTransfer`; batch row copy with FK ordering -- `src/core/transfer/planner.ts` — `planTransfer`; dependency-sorted transfer plan -- `src/core/transfer/same-server.ts` — `sameServerTransfer`; direct SQL shortcut when source + dest are on same server -- `src/core/transfer/dialects/` — per-dialect identity-column and conflict-resolution strategies -- `src/core/transfer/types.ts` — `TransferOptions`, `TransferResult`, `TransferPlan` -- `src/core/connection/factory.ts` — `createConnection`, `testConnection`; Kysely instance factory -- `src/core/connection/manager.ts` — `ConnectionManager`; singleton connection lifecycle -- `src/core/connection/dialects/` — dialect drivers (pg, mysql2, tedious, better-sqlite3) +- [`src/core/db/index.ts`](../../src/core/db/index.ts) — `checkDbStatus`, `createDb`, `destroyDb`, `getDialectOperations` +- [`src/core/db/dual.ts`](../../src/core/db/dual.ts) — `withDualConnection`; opens source + destination connections for transfer +- [`src/core/db/dialects/`](../../src/core/db/dialects) — dialect-specific create/drop implementations +- [`src/core/explore/operations.ts`](../../src/core/explore/operations.ts) — `queryTables`, `queryViews`, `queryFunctions`, `queryIndexes`, `queryForeignKeys`, `queryProcedures`, `queryTypes` +- [`src/core/explore/dialects/`](../../src/core/explore/dialects) — per-dialect SQL for introspection queries +- [`src/core/explore/types.ts`](../../src/core/explore/types.ts) — `TableInfo`, `ColumnInfo`, `ViewInfo`, `IndexInfo`, `ForeignKeyInfo`, etc. +- [`src/core/teardown/operations.ts`](../../src/core/teardown/operations.ts) — `truncateData`, `teardownSchema`, `previewTeardown` +- [`src/core/teardown/dialects/`](../../src/core/teardown/dialects) — dialect-specific truncate/drop implementations +- [`src/core/teardown/types.ts`](../../src/core/teardown/types.ts) — `TruncateOptions`, `TeardownOptions`, `TeardownResult` +- [`src/core/transfer/index.ts`](../../src/core/transfer/index.ts) — `transferData`; gates via `assertPolicy` (`core/policy`, `db:reset` permission) against the destination config before opening any connection, then orchestrates plan + execute +- [`src/core/transfer/executor.ts`](../../src/core/transfer/executor.ts) — `executeTransfer`; batch row copy with FK ordering +- [`src/core/transfer/planner.ts`](../../src/core/transfer/planner.ts) — `planTransfer`; dependency-sorted transfer plan +- [`src/core/transfer/same-server.ts`](../../src/core/transfer/same-server.ts) — `sameServerTransfer`; direct SQL shortcut when source + dest are on same server +- [`src/core/transfer/dialects/`](../../src/core/transfer/dialects) — per-dialect identity-column and conflict-resolution strategies +- [`src/core/transfer/types.ts`](../../src/core/transfer/types.ts) — `TransferOptions`, `TransferResult`, `TransferPlan` +- [`src/core/connection/factory.ts`](../../src/core/connection/factory.ts) — `createConnection`, `testConnection`; Kysely instance factory +- [`src/core/connection/manager.ts`](../../src/core/connection/manager.ts) — `ConnectionManager`; singleton connection lifecycle +- [`src/core/connection/dialects/`](../../src/core/connection/dialects) — dialect drivers (pg, mysql2, tedious, better-sqlite3) ## Docs -- `docs/dev/explore.md` — explore internals -- `docs/dev/teardown.md` — teardown internals -- `docs/dev/transfer.md` — transfer internals -- `docs/guide/database/create.md` — create database guide -- `docs/guide/database/teardown.md` — teardown guide -- `docs/guide/database/transfer.md` — transfer guide -- `docs/guide/database/explore.md` — explore guide -- `docs/guide/database/terminal.md` — SQL terminal guide +- [`docs/dev/explore.md`](../dev/explore.md) — explore internals +- [`docs/dev/teardown.md`](../dev/teardown.md) — teardown internals +- [`docs/dev/transfer.md`](../dev/transfer.md) — transfer internals +- [`docs/guide/database/create.md`](../guide/database/create.md) — create database guide +- [`docs/guide/database/teardown.md`](../guide/database/teardown.md) — teardown guide +- [`docs/guide/database/transfer.md`](../guide/database/transfer.md) — transfer guide +- [`docs/guide/database/explore.md`](../guide/database/explore.md) — explore guide +- [`docs/guide/database/terminal.md`](../guide/database/terminal.md) — SQL terminal guide ## Coupling -- Transfer calls `withDualConnection` from `src/core/db/dual.ts` — dual-connection semantics shared with other DB ops. -- Teardown must skip `__noorm_*` tables (defined in `src/core/shared/tables.ts`) — `isNoormTable` guard in `teardown/operations.ts`. -- Connection manager (`src/core/connection/manager.ts`) is used by runner, change executor, SQL terminal, vault ops — reset-manager pattern coordinates with lifecycle domain. -- CLI commands in `src/cli/db/` surface all these ops — explore query shapes flow through to CLI output formatters. -- DT module (`src/core/dt/`) reads rows from transfer context — transfer and DT share the row-fetch pattern. +- Transfer calls `withDualConnection` from [`src/core/db/dual.ts`](../../src/core/db/dual.ts) — dual-connection semantics shared with other DB ops. +- Teardown must skip `__noorm_*` tables (defined in [`src/core/shared/tables.ts`](../../src/core/shared/tables.ts)) — `isNoormTable` guard in `teardown/operations.ts`. +- Connection manager ([`src/core/connection/manager.ts`](../../src/core/connection/manager.ts)) is used by runner, change executor, SQL terminal, vault ops — reset-manager pattern coordinates with lifecycle domain. +- CLI commands in [`src/cli/db/`](../../src/cli/db) surface all these ops — explore query shapes flow through to CLI output formatters. +- [`src/cli/db/drop.ts`](../../src/cli/db/drop.ts) calls `checkConfigPolicy` from [`src/core/policy/`](../../src/core/policy) (`db:destroy` permission) — `--yes` now satisfies the matrix's confirmation requirement rather than gating on a `protected` boolean. +- `transferData` ([`src/core/transfer/index.ts`](../../src/core/transfer/index.ts)) calls `assertPolicy` from [`src/core/policy/`](../../src/core/policy) — transfer and drop both route through the same policy domain as runner/change/sql-terminal. +- DT module ([`src/core/dt/`](../../src/core/dt)) reads rows from transfer context — transfer and DT share the row-fetch pattern. ## Conventions worth knowing - `testConnection(config, { testServerOnly: true })` connects to the dialect's system database without requiring the target DB — used in setup wizards. -- All dialects tested in integration: `tests/integration/explore/`, `tests/integration/teardown/`, `tests/integration/transfer/`. -- Transfer supports PostgreSQL, MySQL, MSSQL only (not SQLite) — `TRANSFER_SUPPORTED_DIALECTS` in `src/core/transfer/dialects/index.ts`. +- All dialects tested in integration: [`tests/integration/explore/`](../../tests/integration/explore), [`tests/integration/teardown/`](../../tests/integration/teardown), [`tests/integration/transfer/`](../../tests/integration/transfer). +- Transfer supports PostgreSQL, MySQL, MSSQL only (not SQLite) — `TRANSFER_SUPPORTED_DIALECTS` in [`src/core/transfer/dialects/index.ts`](../../src/core/transfer/dialects/index.ts). - Same-server transfer skips batch loop and uses direct `INSERT … SELECT` SQL. - Teardown skips noorm internal tables by name; `previewTeardown` returns a dry-run list without executing. diff --git a/docs/wiki/core-identity.md b/docs/wiki/core-identity.md index 1b86f75f..30928e3e 100644 --- a/docs/wiki/core-identity.md +++ b/docs/wiki/core-identity.md @@ -10,49 +10,50 @@ Two-tier identity system: (1) audit identity — name/email for execution proven ## CLI code -- `src/core/identity/crypto.ts` — keypair generation, `encryptForRecipient`, `decryptWithPrivateKey`, `deriveStateKey`, `encryptState`, `decryptState` -- `src/core/identity/factory.ts` — `loadExistingIdentity`; load keypair from disk -- `src/core/identity/resolver.ts` — `resolveIdentity`, `formatIdentity`, `identityToString`; audit identity resolution with caching -- `src/core/identity/storage.ts` — `saveKeyPair`, `loadPrivateKey`, `loadPublicKey`; disk persistence at `~/.noorm/` -- `src/core/identity/sync.ts` — `registerIdentity`; syncs identity record to `__noorm_identities__` table -- `src/core/identity/env.ts` — `loadIdentityFromEnv`; CI override via `NOORM_IDENTITY_*` env vars -- `src/core/identity/hash.ts` — identity hash derivation -- `src/core/identity/types.ts` — `Identity`, `CryptoIdentity`, `KnownUser`, `IdentityOptions` -- `src/core/vault/storage.ts` — vault CRUD (`initVault`, `getSecret`, `setSecret`, `removeSecret`, `listSecrets`) -- `src/core/vault/key.ts` — `generateVaultKey`, `encryptVaultKey`, `decryptVaultKey`, `encryptSecret`, `decryptSecret` -- `src/core/vault/copy.ts` — `copyVaultKey`; share vault access with another identity -- `src/core/vault/propagate.ts` — `propagateVault`; push vault data across configs -- `src/core/vault/resolve.ts` — `resolveVaultSecret`; read a secret at runtime for template context injection -- `src/core/vault/events.ts` — vault observer event types -- `src/core/logger/logger.ts` — `Logger`; structured logging with levels, rotation, redaction -- `src/core/logger/redact.ts` — pattern-based redaction of sensitive values -- `src/core/logger/formatter.ts` — log line formatting -- `src/core/logger/rotation.ts` — log file rotation -- `src/core/logger/queue.ts` — async write queue to prevent I/O blocking -- `src/core/logger/classifier.ts` — log level classification -- `src/core/sql-terminal/executor.ts` — `executeRawSql`; raw SQL execution without runner tracking -- `src/core/sql-terminal/history.ts` — `SqlHistoryManager`; persistent SQL REPL history +- [`src/core/identity/crypto.ts`](../../src/core/identity/crypto.ts) — keypair generation, `encryptForRecipient`, `decryptWithPrivateKey`, `deriveStateKey`, `encryptState`, `decryptState` +- [`src/core/identity/factory.ts`](../../src/core/identity/factory.ts) — `loadExistingIdentity`; load keypair from disk +- [`src/core/identity/resolver.ts`](../../src/core/identity/resolver.ts) — `resolveIdentity`, `formatIdentity`, `identityToString`; audit identity resolution with caching +- [`src/core/identity/storage.ts`](../../src/core/identity/storage.ts) — `saveKeyPair`, `loadPrivateKey`, `loadPublicKey`; disk persistence at `~/.noorm/` +- [`src/core/identity/sync.ts`](../../src/core/identity/sync.ts) — `registerIdentity`; syncs identity record to `__noorm_identities__` table +- [`src/core/identity/env.ts`](../../src/core/identity/env.ts) — `loadIdentityFromEnv`; CI override via `NOORM_IDENTITY_*` env vars +- [`src/core/identity/hash.ts`](../../src/core/identity/hash.ts) — identity hash derivation +- [`src/core/identity/types.ts`](../../src/core/identity/types.ts) — `Identity`, `CryptoIdentity`, `KnownUser`, `IdentityOptions` +- [`src/core/vault/storage.ts`](../../src/core/vault/storage.ts) — vault CRUD (`initVault`, `getSecret`, `setSecret`, `removeSecret`, `listSecrets`) +- [`src/core/vault/key.ts`](../../src/core/vault/key.ts) — `generateVaultKey`, `encryptVaultKey`, `decryptVaultKey`, `encryptSecret`, `decryptSecret` +- [`src/core/vault/copy.ts`](../../src/core/vault/copy.ts) — `copyVaultKey`; share vault access with another identity +- [`src/core/vault/propagate.ts`](../../src/core/vault/propagate.ts) — `propagateVault`; push vault data across configs +- [`src/core/vault/resolve.ts`](../../src/core/vault/resolve.ts) — `resolveVaultSecret`; read a secret at runtime for template context injection +- [`src/core/vault/events.ts`](../../src/core/vault/events.ts) — vault observer event types +- [`src/core/logger/logger.ts`](../../src/core/logger/logger.ts) — `Logger`; structured logging with levels, rotation, redaction +- [`src/core/logger/redact.ts`](../../src/core/logger/redact.ts) — pattern-based redaction of sensitive values +- [`src/core/logger/formatter.ts`](../../src/core/logger/formatter.ts) — log line formatting +- [`src/core/logger/rotation.ts`](../../src/core/logger/rotation.ts) — log file rotation +- [`src/core/logger/queue.ts`](../../src/core/logger/queue.ts) — async write queue to prevent I/O blocking +- [`src/core/logger/classifier.ts`](../../src/core/logger/classifier.ts) — log level classification +- [`src/core/sql-terminal/executor.ts`](../../src/core/sql-terminal/executor.ts) — `executeRawSql`; classifies the query (`classifyStatements` from `core/policy`) and gates it via `assertPolicy` against a `SqlPolicyGate` (access/channel/dialect) before delegating to `executeRawSqlUnchecked`, the ungated execution path reserved for tests +- [`src/core/sql-terminal/history.ts`](../../src/core/sql-terminal/history.ts) — `SqlHistoryManager`; persistent SQL REPL history ## Docs -- `docs/dev/identity.md` — cryptographic identity internals -- `docs/dev/vault.md` — vault internals -- `docs/dev/secrets.md` — secret management -- `docs/dev/logger.md` — logger internals -- `docs/dev/sql-terminal.md` — SQL terminal internals -- `docs/guide/environments/vault.md` — user guide: vault -- `docs/guide/environments/secrets.md` — user guide: secrets -- `docs/cli/identity.md` — identity CLI reference -- `docs/dev/headless.md` — headless/CI identity override docs +- [`docs/dev/identity.md`](../dev/identity.md) — cryptographic identity internals +- [`docs/dev/vault.md`](../dev/vault.md) — vault internals +- [`docs/dev/secrets.md`](../dev/secrets.md) — secret management +- [`docs/dev/logger.md`](../dev/logger.md) — logger internals +- [`docs/dev/sql-terminal.md`](../dev/sql-terminal.md) — SQL terminal internals +- [`docs/guide/environments/vault.md`](../guide/environments/vault.md) — user guide: vault +- [`docs/guide/environments/secrets.md`](../guide/environments/secrets.md) — user guide: secrets +- [`docs/cli/identity.md`](../cli/identity.md) — identity CLI reference +- [`docs/dev/headless.md`](../dev/headless.md) — headless/CI identity override docs ## Coupling -- Identity keypair is used by `src/core/state/manager.ts` for state encryption/decryption — identity must initialize before StateManager loads. +- Identity keypair is used by [`src/core/state/manager.ts`](../../src/core/state/manager.ts) for state encryption/decryption — identity must initialize before StateManager loads. - Vault uses the identity hash for per-user encryption key derivation — identity + vault are tightly coupled. -- Logger uses `src/core/observer.ts` events to capture log lines from all modules. +- Logger uses [`src/core/observer.ts`](../../src/core/observer.ts) events to capture log lines from all modules. - SQL terminal history writes to `~/.noorm/sql-history/` — path convention separate from project `.noorm/`. - CI environment loads identity from env vars (`NOORM_IDENTITY_NAME`, `NOORM_IDENTITY_EMAIL`, `NOORM_IDENTITY_KEY`) via `loadIdentityFromEnv` — CLI init reads from keychain by default. -- `__noorm_identities__` table (defined in `src/core/shared/tables.ts`) stores registered identities — `sync.ts` writes to it. +- `__noorm_identities__` table (defined in [`src/core/shared/tables.ts`](../../src/core/shared/tables.ts)) stores registered identities — `sync.ts` writes to it. +- `executeRawSql` imports `assertPolicy`/`classifyStatements` from [`src/core/policy/`](../../src/core/policy) — the read/write/ddl classification and the destructive-function denylist live in the policy domain, not here. ## Conventions worth knowing diff --git a/docs/wiki/core-policy.md b/docs/wiki/core-policy.md new file mode 100644 index 00000000..dbd6c972 --- /dev/null +++ b/docs/wiki/core-policy.md @@ -0,0 +1,47 @@ +--- +type: Domain +--- + +# core-policy + +## What it does + +Single access-control layer for every config-scoped action across every caller channel (CLI, TUI, SDK, MCP). Roles live on the config (`ConfigAccess`), not the actor — the actor is a `Channel` (`user` or `mcp`) — and a hard-coded permission × role matrix (`MATRIX`) resolves `allow`/`confirm`/`deny` per action. Replaces the removed `Config.protected: boolean` and the deleted `src/core/config/protection.ts` / `src/rpc/protection.ts` rule checkers. + +Also owns raw-SQL statement classification (`read`/`write`/`ddl`, with a destructive-function denylist) used to gate ad-hoc SQL, and the one-version `protected` boolean → `access` migration path (`resolveLegacyAccess`). + +## CLI code + +- [`src/core/policy/types.ts`](../../src/core/policy/types.ts) — `Role` (`viewer`/`operator`/`admin`), `Channel` (`user`/`mcp`), `ConfigAccess` (`{ user: Role; mcp: Role | false }`), `Permission`, `PolicyTarget`, `PolicyCell` (`allow`/`confirm`/`deny`), `PolicyCheck` +- [`src/core/policy/matrix.ts`](../../src/core/policy/matrix.ts) — `MATRIX`; the hard-coded `Permission × Role → PolicyCell` table (not user-extensible), mirroring [`docs/spec/config-access-roles.md`](../spec/config-access-roles.md) +- [`src/core/policy/check.ts`](../../src/core/policy/check.ts) — `checkPolicy`, `checkConfigPolicy`, `assertPolicy`, `guarded`, `confirmationPhraseFor`; the enforcement entrypoints every caller reaches for +- [`src/core/policy/classify.ts`](../../src/core/policy/classify.ts) — `classifyStatements`; SQL-parser-cst-based statement classifier with a keyword-based fallback, a CTE-DML upgrade rule, and `DESTRUCTIVE_FUNCTIONS` denylist (e.g. `pg_terminate_backend`, `lo_import`, `setval`) +- [`src/core/policy/legacy-access.ts`](../../src/core/policy/legacy-access.ts) — `resolveLegacyAccess`, `OPEN_ACCESS` (`{ user: 'admin', mcp: 'admin' }`), `GUARDED_ACCESS` (`{ user: 'operator', mcp: 'viewer' }`) +- [`src/core/policy/index.ts`](../../src/core/policy/index.ts) — barrel export for all of the above + +## Docs + +- [`docs/spec/config-access-roles.md`](../spec/config-access-roles.md) — implementation contract: data model, matrix, migration +- [`docs/design/config-access-roles.md`](../design/config-access-roles.md) — design rationale for the role model +- [`docs/dev/config.md`](../dev/config.md), [`docs/dev/config-sharing.md`](../dev/config-sharing.md) — updated to describe `access` in place of `protected` +- [`docs/guide/environments/configs.md`](../guide/environments/configs.md), [`docs/guide/environments/stages.md`](../guide/environments/stages.md) — user-facing access-role guidance +- [`skills/noorm/references/config.md`](../../skills/noorm/references/config.md) — skill reference for config access roles + +## Coupling + +- [`src/core/config/schema.ts`](../../src/core/config/schema.ts)/`types.ts` and [`src/core/state/manager.ts`](../../src/core/state/manager.ts) import `resolveLegacyAccess`/`ConfigAccess` — see the `core-state` domain for where `access` is resolved, defaulted, and backfilled. +- [`src/core/change/executor.ts`](../../src/core/change/executor.ts), [`src/core/runner/runner.ts`](../../src/core/runner/runner.ts), [`src/core/transfer/index.ts`](../../src/core/transfer/index.ts), and [`src/core/sql-terminal/executor.ts`](../../src/core/sql-terminal/executor.ts) all call `assertPolicy` at their core seam — see `core-change`, `core-runner`, `core-db`, and `core-identity` respectively. +- [`src/mcp/server.ts`](../../src/mcp/server.ts) and [`src/rpc/types.ts`](../../src/rpc/types.ts) gate every non-`'open'` `RpcCommand` via `checkConfigPolicy` — see `mcp-rpc`. +- [`src/sdk/guards.ts`](../../src/sdk/guards.ts) and [`src/sdk/index.ts`](../../src/sdk/index.ts) wrap `checkConfigPolicy` for the SDK's `channel`-aware guards — see `sdk`. +- [`src/tui/components/dialogs/SmartConfirm.tsx`](../../src/tui/components/dialogs/SmartConfirm.tsx)/`ProtectedConfirm.tsx` and every destructive-action TUI screen call `checkConfigPolicy` directly to build confirm-dialog props — see `tui`. +- [`src/core/settings/rules.ts`](../../src/core/settings/rules.ts)'s `protected` rule-match key checks `guarded(config)`, not a config field — see `core-state`. + +## Conventions worth knowing + +- `checkPolicy`'s `confirm` cell resolves differently per channel: `user` prompts for `yes-` (`confirmationPhraseFor`, skippable via `NOORM_YES`), `mcp` collapses `confirm` to `deny` — there's no human on the other end of MCP stdio to type a phrase. +- `mcp: false` (invisible config) is never a role and is not expected to reach `checkPolicy` — visibility is enforced upstream (`SessionManager.connect`, `list_configs`). +- `checkConfigPolicy` fails closed: a config with no `access` at all is denied on every channel. +- `classifyStatements` fails closed to `ddl` for anything it can't positively classify as `read` or `write` — an unrecognized statement could do anything. +- `DESTRUCTIVE_FUNCTIONS` is a denylist, not an allowlist, by design: `SELECT f()` is statically undecidable, so only known-dangerous builtins upgrade a `SELECT` to `write`. +- `guarded(target)` (`target.access.user !== 'admin'`) is display-only — used by TUI styling, `config list`, and settings rule matching — never an enforcement input. +- Tests: [`tests/core/policy/check.test.ts`](../../tests/core/policy/check.test.ts) (270L) covers `checkPolicy`/`checkConfigPolicy`/`assertPolicy`/`guarded`/`confirmationPhraseFor`; [`tests/core/policy/classify.test.ts`](../../tests/core/policy/classify.test.ts) (446L) covers read/write/ddl classification, multi-statement, CTE handling, CTE-DML, and the destructive-function denylist. diff --git a/docs/wiki/core-runner.md b/docs/wiki/core-runner.md index 8ddaea2c..ebf83239 100644 --- a/docs/wiki/core-runner.md +++ b/docs/wiki/core-runner.md @@ -8,36 +8,37 @@ type: Domain Executes SQL files against a database connection with checksum-based deduplication. Processes `.sql` and `.sql.tmpl` files. Template files are rendered via Eta before execution. Results are tracked in `__noorm_executions__`. Preview mode renders and returns SQL without executing. -The template engine (`src/core/template/`) is co-owned by the runner: runner calls `processFile`/`isTemplate` to render `.sql.tmpl` files before execution. +The template engine ([`src/core/template/`](../../src/core/template)) is co-owned by the runner: runner calls `processFile`/`isTemplate` to render `.sql.tmpl` files before execution. ## CLI code -- `src/core/runner/runner.ts` — `runBuild`, `runFile`, `runDir`, `preview`, `discoverFiles`; core execution loop -- `src/core/runner/tracker.ts` — `Tracker`; records execution results, computes `needsRun`, queries `__noorm_executions__` -- `src/core/runner/checksum.ts` — `computeChecksum`, `computeChecksumFromContent`, `computeCombinedChecksum`; SHA-based change detection -- `src/core/runner/mssql-batches.ts` — `executeSqlBody`; splits MSSQL `GO`-delimited batches before execution -- `src/core/runner/types.ts` — `RunOptions`, `RunContext`, `FileResult`, `BatchResult`, `SkipReason`, etc. -- `src/core/template/engine.ts` — Eta-based `renderTemplate`; called by runner for `.sql.tmpl` files -- `src/core/template/context.ts` — `buildContext`; assembles variables injected into templates -- `src/core/template/helpers.ts` — built-in SQL helpers (`sqlEscape`, `sqlQuote`, `generateUuid`, `isoNow`) -- `src/core/template/loaders/` — data loaders for JSON5, YAML, CSV, JS, SQL side-car files -- `src/core/template/types.ts` — `TemplateContext`, `Loader`, `LoaderRegistry` +- [`src/core/runner/runner.ts`](../../src/core/runner/runner.ts) — `runBuild`, `runFile`, `runDir`, `preview`, `discoverFiles`; core execution loop. Each exported entrypoint gates via `assertPolicy` (`core/policy`) against `RunContext.access`/`channel` (`run:build`/`run:file`/`run:dir` permissions) +- [`src/core/runner/tracker.ts`](../../src/core/runner/tracker.ts) — `Tracker`; records execution results, computes `needsRun`, queries `__noorm_executions__` +- [`src/core/runner/checksum.ts`](../../src/core/runner/checksum.ts) — `computeChecksum`, `computeChecksumFromContent`, `computeCombinedChecksum`; SHA-based change detection +- [`src/core/runner/mssql-batches.ts`](../../src/core/runner/mssql-batches.ts) — `executeSqlBody`; splits MSSQL `GO`-delimited batches before execution +- [`src/core/runner/types.ts`](../../src/core/runner/types.ts) — `RunOptions`, `RunContext`, `FileResult`, `BatchResult`, `SkipReason`, etc. +- [`src/core/template/engine.ts`](../../src/core/template/engine.ts) — Eta-based `renderTemplate`; called by runner for `.sql.tmpl` files +- [`src/core/template/context.ts`](../../src/core/template/context.ts) — `buildContext`; assembles variables injected into templates +- [`src/core/template/helpers.ts`](../../src/core/template/helpers.ts) — built-in SQL helpers (`sqlEscape`, `sqlQuote`, `generateUuid`, `isoNow`) +- [`src/core/template/loaders/`](../../src/core/template/loaders) — data loaders for JSON5, YAML, CSV, JS, SQL side-car files +- [`src/core/template/types.ts`](../../src/core/template/types.ts) — `TemplateContext`, `Loader`, `LoaderRegistry` ## Docs -- `docs/dev/runner.md` — runner internals reference -- `docs/dev/template.md` — template engine internals -- `docs/guide/sql-files/execution.md` — user guide: file execution -- `docs/guide/sql-files/templates.md` — user guide: template syntax -- `docs/guide/sql-files/organization.md` — user guide: file layout conventions +- [`docs/dev/runner.md`](../dev/runner.md) — runner internals reference +- [`docs/dev/template.md`](../dev/template.md) — template engine internals +- [`docs/guide/sql-files/execution.md`](../guide/sql-files/execution.md) — user guide: file execution +- [`docs/guide/sql-files/templates.md`](../guide/sql-files/templates.md) — user guide: template syntax +- [`docs/guide/sql-files/organization.md`](../guide/sql-files/organization.md) — user guide: file layout conventions ## Coupling -- Runner calls `src/core/template/` (`processFile`, `isTemplate`) — template API changes affect runner's file loop. -- Runner writes to `__noorm_executions__` table defined in `src/core/shared/tables.ts`. -- Change executor (`src/core/change/executor.ts`) calls `runFile` — runner `RunOptions` changes propagate to change execution. +- Runner calls [`src/core/template/`](../../src/core/template) (`processFile`, `isTemplate`) — template API changes affect runner's file loop. +- Runner writes to `__noorm_executions__` table defined in [`src/core/shared/tables.ts`](../../src/core/shared/tables.ts). +- Change executor ([`src/core/change/executor.ts`](../../src/core/change/executor.ts)) calls `runFile` — runner `RunOptions` changes propagate to change execution. - MSSQL batch splitting (`mssql-batches.ts`) is only invoked for MSSQL dialect; dialect info flows in via `RunContext`. -- CLI commands in `src/cli/run/` call `runBuild`, `runFile`, `runDir`, `preview` — CLI surface reflects `RunOptions` defaults. +- CLI commands in [`src/cli/run/`](../../src/cli/run) call `runBuild`, `runFile`, `runDir`, `preview` — CLI surface reflects `RunOptions` defaults. +- `runBuild`/`runFile`/`runDir` call `assertPolicy` from [`src/core/policy/`](../../src/core/policy) before executing — `RunContext` carries `access`/`channel` for the gate. ## Conventions worth knowing diff --git a/docs/wiki/core-state.md b/docs/wiki/core-state.md index 859872c0..3a81fa3a 100644 --- a/docs/wiki/core-state.md +++ b/docs/wiki/core-state.md @@ -10,57 +10,61 @@ Manages encrypted application state (configs, secrets, active config pointer), p Configs are stored encrypted in `.noorm/state/state.enc` using AES-256-GCM. Settings live in `.noorm/settings.yml` (plaintext YAML). Version migration runs at startup across all three layers. +Each config carries `access: ConfigAccess` (per-channel role pair, replacing the removed `protected: boolean`), resolved via `resolveLegacyAccess` from [`src/core/policy/`](../../src/core/policy). [`src/core/config/schema.ts`](../../src/core/config/schema.ts) maps a legacy `protected` boolean input to `access` at parse time; `StateManager.load()` backfills `access` on any config that reaches the current schema version without it. + ## CLI code -- `src/core/state/manager.ts` — `StateManager`; encrypt/decrypt state, CRUD for configs and secrets -- `src/core/state/encryption/` — AES-256-GCM encrypt/decrypt primitives -- `src/core/state/migrations.ts` — `migrateState`, `needsMigration` -- `src/core/settings/manager.ts` — `SettingsManager`; loads/saves `settings.yml`, validates against schema, stage merging -- `src/core/settings/schema.ts` — Zod schema for settings file -- `src/core/settings/rules.ts` — `ruleMatches`, `evaluateRule`, `evaluateRules`; config-based conditional overrides -- `src/core/settings/defaults.ts` — `DEFAULT_SETTINGS` -- `src/core/settings/events.ts` — settings-related observer event types -- `src/core/config/index.ts` — `makeNestedConfig`; builds config object from env at module scope (known contamination source — see CLAUDE.md) -- `src/core/config/resolver.ts` — `resolveConfig`, `SettingsProvider`; picks active config from state + settings -- `src/core/config/protection.ts` — `ConfigProtection`; hard-block rules for protected configs -- `src/core/config/schema.ts` — config schema validation -- `src/core/lifecycle/manager.ts` — `LifecycleManager`; shutdown phase orchestration, signal handlers -- `src/core/lifecycle/handlers.ts` — signal/exception handler registration -- `src/core/lifecycle/types.ts` — `ShutdownPhase`, `AppMode`, lifecycle state types -- `src/core/version/index.ts` — `VersionManager`, `checkSchemaVersion`, `migrateSchema`, `ensureSchemaVersion`, `bootstrapSchema` -- `src/core/version/schema/`, `src/core/version/state/`, `src/core/version/settings/` — per-layer migrations -- `src/core/project.ts` — `findProjectRoot`, `initProjectContext`, `isNoormProject`, `getGlobalNoormPath` -- `src/core/project-init.ts` — `initProjectContext` bootstrap: loads state, settings, lifecycle, runs version migrations -- `src/core/environment.ts` — env variable detection and normalization -- `src/core/observer.ts` — singleton `observer` (ObserverEngine from `@logosdx/observer`); event bus for all modules +- [`src/core/state/manager.ts`](../../src/core/state/manager.ts) — `StateManager`; encrypt/decrypt state, CRUD for configs and secrets. `load()` runs the schemaVersion-keyed migration (`migrateState`/`needsStateMigration` from `core/version/state/`, e.g. v2's `protected`→`access` mapping) ahead of the package-semver migration in `state/migrations.ts`, then backfills `access` on any config still missing it +- [`src/core/state/encryption/`](../../src/core/state/encryption) — AES-256-GCM encrypt/decrypt primitives +- [`src/core/state/migrations.ts`](../../src/core/state/migrations.ts) — `migrateState`, `needsMigration`; package-semver-keyed, distinct from the schemaVersion-keyed migrations in `core/version/state/` +- [`src/core/settings/manager.ts`](../../src/core/settings/manager.ts) — `SettingsManager`; loads/saves `settings.yml`, validates against schema, stage merging +- [`src/core/settings/schema.ts`](../../src/core/settings/schema.ts) — Zod schema for settings file +- [`src/core/settings/rules.ts`](../../src/core/settings/rules.ts) — `ruleMatches`, `evaluateRule`, `evaluateRules`; config-based conditional overrides. The rule's `protected` match key checks `guarded(config)` (`core/policy`), not a config field +- [`src/core/settings/defaults.ts`](../../src/core/settings/defaults.ts) — `DEFAULT_SETTINGS` +- [`src/core/settings/events.ts`](../../src/core/settings/events.ts) — settings-related observer event types +- [`src/core/config/index.ts`](../../src/core/config/index.ts) — `makeNestedConfig`; builds config object from env at module scope (known contamination source — see CLAUDE.md) +- [`src/core/config/resolver.ts`](../../src/core/config/resolver.ts) — `resolveConfig`, `SettingsProvider`; picks active config from state + settings. `applyStageCeiling` clamps a resolved config's `access` down to `{ user: 'operator', mcp: 'viewer' }` when the linked stage sets `protected: true` — replaces the old hard-violation check in `checkConfigCompleteness` +- [`src/core/config/schema.ts`](../../src/core/config/schema.ts) — config schema validation; `withResolvedAccess` maps a legacy `protected: boolean` input to `access: ConfigAccess` via `resolveLegacyAccess` (`core/policy`) +- [`src/core/lifecycle/manager.ts`](../../src/core/lifecycle/manager.ts) — `LifecycleManager`; shutdown phase orchestration, signal handlers +- [`src/core/lifecycle/handlers.ts`](../../src/core/lifecycle/handlers.ts) — signal/exception handler registration +- [`src/core/lifecycle/types.ts`](../../src/core/lifecycle/types.ts) — `ShutdownPhase`, `AppMode`, lifecycle state types +- [`src/core/version/index.ts`](../../src/core/version/index.ts) — `VersionManager`, `checkSchemaVersion`, `migrateSchema`, `ensureSchemaVersion`, `bootstrapSchema` +- [`src/core/version/schema/`](../../src/core/version/schema), [`src/core/version/state/`](../../src/core/version/state), [`src/core/version/settings/`](../../src/core/version/settings) — per-layer migrations (`version/state/migrations/v2.ts` maps the removed `protected` boolean to `access`) +- [`src/core/project.ts`](../../src/core/project.ts) — `findProjectRoot`, `initProjectContext`, `isNoormProject`, `getGlobalNoormPath` +- [`src/core/project-init.ts`](../../src/core/project-init.ts) — `initProjectContext` bootstrap: loads state, settings, lifecycle, runs version migrations +- [`src/core/environment.ts`](../../src/core/environment.ts) — env variable detection and normalization +- [`src/core/observer.ts`](../../src/core/observer.ts) — singleton `observer` (ObserverEngine from `@logosdx/observer`); event bus for all modules ## Docs -- `docs/dev/config.md` — config internals -- `docs/dev/config-sharing.md` — multi-user config sharing -- `docs/dev/settings.md` — settings file reference -- `docs/dev/state.md` — state file internals -- `docs/dev/version.md` — version migration internals -- `docs/dev/project-discovery.md` — project root detection -- `docs/dev/logger.md` — logger internals (uses observer) -- `docs/guide/environments/configs.md` — user guide: configs -- `docs/guide/environments/stages.md` — user guide: stages -- `docs/guide/environments/secrets.md` — user guide: secrets +- [`docs/dev/config.md`](../dev/config.md) — config internals +- [`docs/dev/config-sharing.md`](../dev/config-sharing.md) — multi-user config sharing +- [`docs/dev/settings.md`](../dev/settings.md) — settings file reference +- [`docs/dev/state.md`](../dev/state.md) — state file internals +- [`docs/dev/version.md`](../dev/version.md) — version migration internals +- [`docs/dev/project-discovery.md`](../dev/project-discovery.md) — project root detection +- [`docs/dev/logger.md`](../dev/logger.md) — logger internals (uses observer) +- [`docs/guide/environments/configs.md`](../guide/environments/configs.md) — user guide: configs +- [`docs/guide/environments/stages.md`](../guide/environments/stages.md) — user guide: stages +- [`docs/guide/environments/secrets.md`](../guide/environments/secrets.md) — user guide: secrets ## Coupling - `src/core/config/index.ts:34` calls `makeNestedConfig(process.env, …)` at module scope — snapshots env at first import. Same bug was fixed for `SettingsManager` in commit `ec9ccc2`. Not yet migrated to call-time. -- Observer (`src/core/observer.ts`) is imported by virtually every core module — it is the event bus; all `observer.emit()` calls couple to TUI hooks. +- Observer ([`src/core/observer.ts`](../../src/core/observer.ts)) is imported by virtually every core module — it is the event bus; all `observer.emit()` calls couple to TUI hooks. - VersionManager runs migrations at startup via `project-init.ts` — schema + state + settings must all be at CURRENT_VERSIONS before app proceeds. -- StateManager uses identity key from `src/core/identity/storage.ts` for encryption — identity domain must initialize before state loads. +- StateManager uses identity key from [`src/core/identity/storage.ts`](../../src/core/identity/storage.ts) for encryption — identity domain must initialize before state loads. - LifecycleManager coordinates connection teardown — `ConnectionManager.reset()` is called in lifecycle shutdown handlers. -- RPC session layer (`src/rpc/session.ts`) reads state via StateManager for active config lookup. +- RPC session layer ([`src/rpc/session.ts`](../../src/rpc/session.ts)) reads state via StateManager for active config lookup. +- [`src/core/config/schema.ts`](../../src/core/config/schema.ts), [`src/core/config/resolver.ts`](../../src/core/config/resolver.ts), [`src/core/state/manager.ts`](../../src/core/state/manager.ts), and [`src/core/settings/rules.ts`](../../src/core/settings/rules.ts) all import `ConfigAccess`/`resolveLegacyAccess`/`guarded` from [`src/core/policy/`](../../src/core/policy) — the `core-policy` domain owns the role matrix and channel checks; shape changes to `ConfigAccess` propagate to all four. ## Conventions worth knowing - State file path: `.noorm/state/state.enc` (configurable via `StateManagerOptions`). -- Settings file path: `.noorm/settings.yml`; `SETTINGS_FILE_PATH` constant exported from `src/core/settings/index.ts`. -- `CURRENT_VERSIONS` in `src/core/version/index.ts` is the version triple that must match after migration. +- Settings file path: `.noorm/settings.yml`; `SETTINGS_FILE_PATH` constant exported from [`src/core/settings/index.ts`](../../src/core/settings/index.ts). +- `CURRENT_VERSIONS` in [`src/core/version/index.ts`](../../src/core/version/index.ts) is the version triple that must match after migration. - `observer` is a module-scope singleton; `resetConnectionManager`/`resetSettingsManager`/`resetStateManager` are test-only reset points. - Stages in settings allow per-environment config overrides; `evaluateRules` applies them at runtime. - `initProjectContext` is the canonical startup sequence called by CLI entry and SDK `createContext`. +- `Config.access` defaults to `{ user: 'admin', mcp: 'admin' }` (`OPEN_ACCESS`) when absent; a legacy `protected: true` maps to `{ user: 'operator', mcp: 'viewer' }` (`GUARDED_ACCESS`) — both constants live in [`src/core/policy/legacy-access.ts`](../../src/core/policy/legacy-access.ts). +- `src/core/config/protection.ts` (hard-block rules for protected configs) was deleted — access enforcement now runs entirely through `core/policy`. diff --git a/docs/wiki/index.md b/docs/wiki/index.md index dfed3757..7abe9127 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -1,9 +1,10 @@ --- +reflects_rev: cf0d4c3b4b5d4ce5c54e12436ce3cfbefdb59191 type: Index --- repo - +f4d4ca36e98785afdb428172b90890d2f69ca66e 1 # Project signals @@ -12,28 +13,28 @@ type: Index - **Language:** TypeScript (80% LOC, 872 files), Bun runtime (>=1.2), Node >=22.13 - **SQL layer:** Kysely query builder + executor; dialect-aware across PostgreSQL, MySQL, MSSQL, SQLite -- **TUI:** Ink 6 + React 19 (`src/tui/`); Citty for CLI arg parsing (`src/cli/`) -- **Event bus:** `@logosdx/observer` (`ObserverEngine`); module-scope singleton in `src/core/observer.ts` +- **TUI:** Ink 6 + React 19 ([`src/tui/`](../../src/tui)); Citty for CLI arg parsing ([`src/cli/`](../../src/cli)) +- **Event bus:** `@logosdx/observer` (`ObserverEngine`); module-scope singleton in [`src/core/observer.ts`](../../src/core/observer.ts) - **Templating:** Eta 4 for `.sql.tmpl` files; data loaders for JSON5/YAML/CSV/JS side-cars - **Error handling:** `@logosdx/utils` `attempt`/`attemptSync` tuples — no try-catch in source -- **Encryption:** AES-256-GCM for state (`src/core/state/encryption/`), Ed25519-like keypairs for identity +- **Encryption:** AES-256-GCM for state ([`src/core/state/encryption/`](../../src/core/state/encryption)), Ed25519-like keypairs for identity - **MCP:** `@modelcontextprotocol/sdk` wrapping RPC registry over stdio ## Build / test / lint | Purpose | Command | Source | |---------|---------|--------| -| Build (tsc) | `bun run build` | `package.json` | -| Build packages | `bun run build:packages` | `scripts/build.mjs` (tsup) | -| Build binary | `bun run build:binary` | `scripts/build-binary.mjs` (bun compile) | -| Dev watch | `bun run dev` | `package.json` | -| Test (all, serial) | `bun run test` | `package.json` | +| Build (tsc) | `bun run build` | [`package.json`](../../package.json) | +| Build packages | `bun run build:packages` | [`scripts/build.mjs`](../../scripts/build.mjs) (tsup) | +| Build binary | `bun run build:binary` | [`scripts/build-binary.mjs`](../../scripts/build-binary.mjs) (bun compile) | +| Dev watch | `bun run dev` | [`package.json`](../../package.json) | +| Test (all, serial) | `bun run test` | [`package.json`](../../package.json) | | Test CI group 1 | `bun test --serial $(find tests/utils tests/core tests/sdk -name '*.test.ts' \| grep -v tests/core/transfer \| sort \| tr '\n' ' ')` | `.github/workflows/ci.yml:127` | | Test CI group 2 | `bun test --serial tests/core/transfer` | `.github/workflows/ci.yml:132` | | Test CI group 3 | `bun test --serial tests/cli` | `.github/workflows/ci.yml:137` | | Test CI group 4 | `bun test --serial tests/integration` | `.github/workflows/ci.yml:142` | -| Lint | `bun run lint` | ESLint, `eslint.config.js` | -| Typecheck | `bun run typecheck` | `tsconfig.json` | +| Lint | `bun run lint` | ESLint, [`eslint.config.js`](../../eslint.config.js) | +| Typecheck | `bun run typecheck` | [`tsconfig.json`](../../tsconfig.json) | CI gate: lint → typecheck → build → 4 test groups → 3 example jobs. Integration tests require live DB services (docker-compose or CI service containers). @@ -41,8 +42,8 @@ CI gate: lint → typecheck → build → 4 test groups → 3 example jobs. Inte | Language | LOC | Files | % | |----------|-----|-------|---| -| TypeScript | 199535 | 872 | 80% | -| Markdown | 42464 | 186 | 17% | +| TypeScript | 204246 | 886 | 80% | +| Markdown | 43000 | 195 | 17% | | YAML | 1114 | 16 | 1% | | JavaScript | 1005 | 22 | 1% | | HTML | 955 | 26 | 2% | @@ -53,35 +54,38 @@ CI gate: lint → typecheck → build → 4 test groups → 3 example jobs. Inte - **CI:** GitHub Actions (`ubuntu-24.04`), Bun 1.3.11 pinned; 4 test groups + 3 example jobs per push to master/main - **DB services (CI):** Postgres 17 on 15432, MySQL 8.0 on 13306, MSSQL 2022 on 11433 -- **DB services (local):** `docker-compose.yml` at repo root (same ports) -- **Publish:** Changesets-driven (`changeset publish`) via `.github/workflows/publish.yml`; packages: `@noormdev/cli` and `@noormdev/sdk` -- **Binary release:** `bun build --compile` → GitHub Releases via `.github/workflows/release-binary.yml` -- **Docs:** VitePress, deployed via `.github/workflows/docs.yml` +- **DB services (local):** [`docker-compose.yml`](../../docker-compose.yml) at repo root (same ports) +- **Publish:** Changesets-driven (`changeset publish`) via [`.github/workflows/publish.yml`](../../.github/workflows/publish.yml); packages: `@noormdev/cli` and `@noormdev/sdk` +- **Binary release:** `bun build --compile` → GitHub Releases via [`.github/workflows/release-binary.yml`](../../.github/workflows/release-binary.yml) +- **Docs:** VitePress, deployed via [`.github/workflows/docs.yml`](../../.github/workflows/docs.yml) ## Domains | Domain | Repo paths | One-liner | Detail | |--------|------------|-----------|--------| -| core-change | `src/core/change/`, `src/cli/change/`, `tests/core/change/` | Versioned DB changes: scaffold, parse, execute, history | .claude/project/signals/core-change.md | -| core-runner | `src/core/runner/`, `src/core/template/`, `src/cli/run/`, `tests/core/runner/`, `tests/core/template/` | SQL file execution with checksum dedup and Eta templating | .claude/project/signals/core-runner.md | -| core-db | `src/core/db/`, `src/core/connection/`, `src/core/explore/`, `src/core/teardown/`, `src/core/transfer/`, `src/cli/db/`, `tests/core/connection/`, `tests/core/explore/`, `tests/core/teardown/`, `tests/core/transfer/`, `tests/integration/` | DB lifecycle: create/drop, explore schema, teardown, cross-DB transfer | .claude/project/signals/core-db.md | -| core-state | `src/core/state/`, `src/core/settings/`, `src/core/config/`, `src/core/lifecycle/`, `src/core/version/`, `src/core/project.ts`, `src/core/project-init.ts`, `src/core/environment.ts`, `src/core/observer.ts`, `tests/core/state/`, `tests/core/settings/`, `tests/core/config/`, `tests/core/lifecycle/`, `tests/core/version/` | Encrypted state, settings.yml, config resolution, lifecycle, version migration | .claude/project/signals/core-state.md | -| core-identity | `src/core/identity/`, `src/core/vault/`, `src/core/logger/`, `src/core/sql-terminal/`, `src/cli/identity/`, `src/cli/secret/`, `src/cli/vault/`, `src/cli/sql/`, `tests/core/identity/`, `tests/core/vault/`, `tests/core/logger/`, `tests/core/sql-terminal/` | Identity keypairs, vault secrets, structured logger, SQL terminal history | .claude/project/signals/core-identity.md | -| sdk | `src/sdk/`, `src/core/dt/`, `packages/sdk/`, `tests/sdk/`, `tests/integration/sdk/` | Programmatic API (`createContext`) + DT binary serialization format | .claude/project/signals/sdk.md | -| cli | `src/cli/`, `packages/cli/`, `skills/noorm/`, `tests/cli/` | Citty CLI with 17 command groups, headless mode, binary distribution | .claude/project/signals/cli.md | -| tui | `src/tui/`, `src/hooks/`, `.claude/rules/tui-development.md`, `tests/cli/components/`, `tests/cli/hooks/`, `tests/cli/screens/` | Ink/React TUI with focus manager, keyboard routing, per-domain screens | .claude/project/signals/tui.md | -| mcp-rpc | `src/mcp/`, `src/rpc/`, `src/cli/mcp/`, `tests/core/mcp/`, `tests/core/rpc/` | MCP server over stdio wrapping flat RPC command registry | .claude/project/signals/mcp-rpc.md | -| worker-bridge | `src/core/worker-bridge/`, `src/workers/`, `tests/core/worker-bridge/`, `tests/workers/` | Hub-and-spoke worker threads for DT serialization and DB connection worker | .claude/project/signals/worker-bridge.md | -| infra | `.github/`, `scripts/`, `examples/`, `docs/`, `tsup.*.config.ts`, `docker-compose.yml`, `bunfig.toml` | CI, build pipeline, binary release, example projects, VitePress docs | .claude/project/signals/infra.md | +| core-change | [`src/core/change/`](../../src/core/change), [`src/cli/change/`](../../src/cli/change), [`tests/core/change/`](../../tests/core/change) | Versioned DB changes: scaffold, parse, execute, history | [`docs/wiki/core-change.md`](core-change.md) | +| core-runner | [`src/core/runner/`](../../src/core/runner), [`src/core/template/`](../../src/core/template), [`src/cli/run/`](../../src/cli/run), [`tests/core/runner/`](../../tests/core/runner), [`tests/core/template/`](../../tests/core/template) | SQL file execution with checksum dedup and Eta templating | [`docs/wiki/core-runner.md`](core-runner.md) | +| core-db | [`src/core/db/`](../../src/core/db), [`src/core/connection/`](../../src/core/connection), [`src/core/explore/`](../../src/core/explore), [`src/core/teardown/`](../../src/core/teardown), [`src/core/transfer/`](../../src/core/transfer), [`src/cli/db/`](../../src/cli/db), [`tests/core/connection/`](../../tests/core/connection), [`tests/core/explore/`](../../tests/core/explore), [`tests/core/teardown/`](../../tests/core/teardown), [`tests/core/transfer/`](../../tests/core/transfer), [`tests/integration/`](../../tests/integration) | DB lifecycle: create/drop, explore schema, teardown, cross-DB transfer | [`docs/wiki/core-db.md`](core-db.md) | +| core-state | [`src/core/state/`](../../src/core/state), [`src/core/settings/`](../../src/core/settings), [`src/core/config/`](../../src/core/config), [`src/core/lifecycle/`](../../src/core/lifecycle), [`src/core/version/`](../../src/core/version), [`src/core/project.ts`](../../src/core/project.ts), [`src/core/project-init.ts`](../../src/core/project-init.ts), [`src/core/environment.ts`](../../src/core/environment.ts), [`src/core/observer.ts`](../../src/core/observer.ts), [`tests/core/state/`](../../tests/core/state), [`tests/core/settings/`](../../tests/core/settings), [`tests/core/config/`](../../tests/core/config), [`tests/core/lifecycle/`](../../tests/core/lifecycle), [`tests/core/version/`](../../tests/core/version) | Encrypted state, settings.yml, config resolution, lifecycle, version migration | [`docs/wiki/core-state.md`](core-state.md) | +| core-identity | [`src/core/identity/`](../../src/core/identity), [`src/core/vault/`](../../src/core/vault), [`src/core/logger/`](../../src/core/logger), [`src/core/sql-terminal/`](../../src/core/sql-terminal), [`src/cli/identity/`](../../src/cli/identity), [`src/cli/secret/`](../../src/cli/secret), [`src/cli/vault/`](../../src/cli/vault), [`src/cli/sql/`](../../src/cli/sql), [`tests/core/identity/`](../../tests/core/identity), [`tests/core/vault/`](../../tests/core/vault), [`tests/core/logger/`](../../tests/core/logger), [`tests/core/sql-terminal/`](../../tests/core/sql-terminal) | Identity keypairs, vault secrets, structured logger, SQL terminal history | [`docs/wiki/core-identity.md`](core-identity.md) | +| core-policy | [`src/core/policy/`](../../src/core/policy), [`tests/core/policy/`](../../tests/core/policy) | Access-control policy: role×permission matrix, SQL statement classifier, legacy `protected`→`access` migration | [`docs/wiki/core-policy.md`](core-policy.md) | +| sdk | [`src/sdk/`](../../src/sdk), [`src/core/dt/`](../../src/core/dt), [`packages/sdk/`](../../packages/sdk), [`tests/sdk/`](../../tests/sdk), [`tests/integration/sdk/`](../../tests/integration/sdk) | Programmatic API (`createContext`) + DT binary serialization format | [`docs/wiki/sdk.md`](sdk.md) | +| cli | [`src/cli/`](../../src/cli), [`packages/cli/`](../../packages/cli), [`skills/noorm/`](../../skills/noorm), [`tests/cli/`](../../tests/cli) | Citty CLI with 17 command groups, headless mode, binary distribution | [`docs/wiki/cli.md`](cli.md) | +| tui | [`src/tui/`](../../src/tui), [`src/hooks/`](../../src/hooks), [`.claude/rules/tui-development.md`](../../.claude/rules/tui-development.md), [`tests/cli/components/`](../../tests/cli/components), [`tests/cli/hooks/`](../../tests/cli/hooks), [`tests/cli/screens/`](../../tests/cli/screens) | Ink/React TUI with focus manager, keyboard routing, per-domain screens | [`docs/wiki/tui.md`](tui.md) | +| mcp-rpc | [`src/mcp/`](../../src/mcp), [`src/rpc/`](../../src/rpc), [`src/cli/mcp/`](../../src/cli/mcp), [`tests/core/mcp/`](../../tests/core/mcp), [`tests/core/rpc/`](../../tests/core/rpc) | MCP server over stdio wrapping flat RPC command registry, permission-gated dispatch | [`docs/wiki/mcp-rpc.md`](mcp-rpc.md) | +| worker-bridge | [`src/core/worker-bridge/`](../../src/core/worker-bridge), [`src/workers/`](../../src/workers), [`tests/core/worker-bridge/`](../../tests/core/worker-bridge), [`tests/workers/`](../../tests/workers) | Hub-and-spoke worker threads for DT serialization and DB connection worker | [`docs/wiki/worker-bridge.md`](worker-bridge.md) | +| infra | [`.github/`](../../.github), [`scripts/`](../../scripts), [`examples/`](../../examples), [`docs/`](..), `tsup.*.config.ts`, [`docker-compose.yml`](../../docker-compose.yml), [`bunfig.toml`](../../bunfig.toml) | CI, build pipeline, binary release, example projects, VitePress docs | [`docs/wiki/infra.md`](infra.md) | ## Cross-cutting -**Test layout:** Tests mirror `src/` under `tests/`. `tests/utils/` holds shared DB helpers. `tests/fixtures/` has SQL fixtures per dialect. `tests/integration/` requires live databases. `tests/global-setup.ts` / `tests/global-teardown.ts` coordinate integration DB bootstrap. +**Test layout:** Tests mirror [`src/`](../../src) under [`tests/`](../../tests). [`tests/utils/`](../../tests/utils) holds shared DB helpers. [`tests/fixtures/`](../../tests/fixtures) has SQL fixtures per dialect. [`tests/integration/`](../../tests/integration) requires live databases. [`tests/global-setup.ts`](../../tests/global-setup.ts) / [`tests/global-teardown.ts`](../../tests/global-teardown.ts) coordinate integration DB bootstrap. **Known contamination:** `src/core/config/index.ts:34` calls `makeNestedConfig(process.env, …)` at module scope — snaps env at first import. This causes test cross-contamination when running the full suite in one process. Workaround: run test groups in separate `bun test --serial` invocations (same as CI). -**Convention pointers:** `.claude/rules/typescript.md` (4-block function structure, `attempt` over try-catch), `.claude/rules/tui-development.md` (focus system, Ink layout), `.claude/rules/testing.md` (test naming, coverage), `.claude/rules/documentation.md` (three-pillar structure). +**Convention pointers:** [`.claude/rules/typescript.md`](../../.claude/rules/typescript.md) (4-block function structure, `attempt` over try-catch), [`.claude/rules/tui-development.md`](../../.claude/rules/tui-development.md) (focus system, Ink layout), [`.claude/rules/testing.md`](../../.claude/rules/testing.md) (test naming, coverage), [`.claude/rules/documentation.md`](../../.claude/rules/documentation.md) (three-pillar structure). -**Domain partitioning basis:** Domains are functional vertical slices. `core-state` groups the startup/persistence concerns (state, settings, config, lifecycle, version) because they all initialize together in `project-init.ts`. `core-identity` groups crypto identity, vault, logger, and SQL terminal because they share the "user-facing sensitive data" concern. `core-db` groups connection, explore, transfer, and teardown because they all operate against a live database connection. `core-change` and `core-runner` are separate because changes are versioned operations while runner handles idempotent file execution — they share `runFile` but have distinct lifecycles. +**Domain partitioning basis:** Domains are functional vertical slices. `core-state` groups the startup/persistence concerns (state, settings, config, lifecycle, version) because they all initialize together in `project-init.ts`. `core-identity` groups crypto identity, vault, logger, and SQL terminal because they share the "user-facing sensitive data" concern. `core-db` groups connection, explore, transfer, and teardown because they all operate against a live database connection. `core-change` and `core-runner` are separate because changes are versioned operations while runner handles idempotent file execution — they share `runFile` but have distinct lifecycles. `core-policy` is a new cross-cutting domain ([`src/core/policy/`](../../src/core/policy)): domains that enforce a config-scoped action via `assertPolicy`/`checkConfigPolicy` (`core-change`, `core-runner`, `core-db`, `core-identity`, `sdk`, `cli`, `tui`, `mcp-rpc`) import from it directly, and `core-state` imports it too but only for `resolveLegacyAccess`/`guarded` — data resolution and display styling, not enforcement. The role×permission matrix and SQL classifier live nowhere else, so `core-policy` gets its own vertical slice rather than being folded into `core-state`. + +**Access-control policy (2026-07, config-access-roles feature):** `Config.protected: boolean` was replaced by `Config.access: ConfigAccess` (per-channel `user`/`mcp` roles), enforced through the new `core-policy` domain. `src/core/config/protection.ts` and `src/rpc/protection.ts` were both deleted — their rule-checking is absorbed into `core/policy`. The runner/change/transfer/sql-terminal executors gate at their core seam via `assertPolicy`, so SDK/CLI/TUI/MCP callers all inherit one enforcement path. `StateManager.load()` now also runs the schemaVersion-keyed migration (`core/version/state/`, v2 maps `protected`→`access`) ahead of the pre-existing package-semver migration — previously only the semver path ran here. **Deterministic substrate:** `.claude/project/deterministic-signals.md` (generated 2026-06-01T02:30:22Z, atomic 3.0.0) diff --git a/docs/wiki/infra.md b/docs/wiki/infra.md index 7ab969a3..5a742067 100644 --- a/docs/wiki/infra.md +++ b/docs/wiki/infra.md @@ -10,41 +10,41 @@ Build pipeline, CI, binary release, package publishing, and reference examples. ## Artifacts -- `examples/llm-memory-db-pg/` — PostgreSQL LLM memory DB example with SDK, CLI, and MCP coverage -- `examples/llm-memory-db-mssql/` — MSSQL equivalent with TVP patterns -- `examples/todo-db/` — reference CI target: soft-deletes, JSONB, TVFs, transactional SPs; used as CI stress test +- [`examples/llm-memory-db-pg/`](../../examples/llm-memory-db-pg) — PostgreSQL LLM memory DB example with SDK, CLI, and MCP coverage +- [`examples/llm-memory-db-mssql/`](../../examples/llm-memory-db-mssql) — MSSQL equivalent with TVP patterns +- [`examples/todo-db/`](../../examples/todo-db) — reference CI target: soft-deletes, JSONB, TVFs, transactional SPs; used as CI stress test ## CLI code -- `scripts/build.mjs` — builds both `@noormdev/cli` and `@noormdev/sdk` packages via tsup -- `scripts/build-binary.mjs` — `bun build --compile` to produce standalone binary -- `scripts/Dockerfile` — Docker image for binary builds -- `scripts/install.sh` — shell installer for binary distribution -- `scripts/ralph-wiggum.sh` — release automation helper -- `tsup.cli.config.ts` — tsup config for CLI package build -- `tsup.sdk.config.ts` — tsup config for SDK package build -- `tsconfig.json` — root TypeScript config -- `tsconfig.sdk-types.json` — SDK type extraction config -- `tsconfig.test.json` — test TypeScript config -- `bunfig.toml` — Bun runtime config -- `docker-compose.yml` — local dev databases: PostgreSQL (15432), MySQL (13306), MSSQL (11433) +- [`scripts/build.mjs`](../../scripts/build.mjs) — builds both `@noormdev/cli` and `@noormdev/sdk` packages via tsup +- [`scripts/build-binary.mjs`](../../scripts/build-binary.mjs) — `bun build --compile` to produce standalone binary +- [`scripts/Dockerfile`](../../scripts/Dockerfile) — Docker image for binary builds +- [`scripts/install.sh`](../../scripts/install.sh) — shell installer for binary distribution +- [`scripts/ralph-wiggum.sh`](../../scripts/ralph-wiggum.sh) — release automation helper +- [`tsup.cli.config.ts`](../../tsup.cli.config.ts) — tsup config for CLI package build +- [`tsup.sdk.config.ts`](../../tsup.sdk.config.ts) — tsup config for SDK package build +- [`tsconfig.json`](../../tsconfig.json) — root TypeScript config +- [`tsconfig.sdk-types.json`](../../tsconfig.sdk-types.json) — SDK type extraction config +- [`tsconfig.test.json`](../../tsconfig.test.json) — test TypeScript config +- [`bunfig.toml`](../../bunfig.toml) — Bun runtime config +- [`docker-compose.yml`](../../docker-compose.yml) — local dev databases: PostgreSQL (15432), MySQL (13306), MSSQL (11433) ## Docs -- `.github/workflows/ci.yml` — CI: lint → typecheck → build → 4 test groups → 3 example jobs (445L) -- `.github/workflows/publish.yml` — changesets-driven publish to npm -- `.github/workflows/release-binary.yml` — binary release to GitHub Releases -- `.github/workflows/docs.yml` — VitePress docs deployment -- `docs/getting-started/installation.md` — install instructions -- `docs/.vitepress/config.mts` — VitePress site config (192L) +- [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) — CI: lint → typecheck → build → 4 test groups → 3 example jobs (445L) +- [`.github/workflows/publish.yml`](../../.github/workflows/publish.yml) — changesets-driven publish to npm +- [`.github/workflows/release-binary.yml`](../../.github/workflows/release-binary.yml) — binary release to GitHub Releases +- [`.github/workflows/docs.yml`](../../.github/workflows/docs.yml) — VitePress docs deployment +- [`docs/getting-started/installation.md`](../getting-started/installation.md) — install instructions +- [`docs/.vitepress/config.mts`](../.vitepress/config.mts) — VitePress site config (192L) ## Coupling - Binary build (`build-binary.mjs`) must list all worker entry points explicitly — worker-bridge domain path conventions must be stable. - CI test split (4 groups) is a workaround for `mock.module` cross-contamination + runner image regression — see CLAUDE.md for the known contamination source. - Examples use `@noormdev/sdk` and CLI — they serve as integration smoke tests in CI. -- Changeset config (`.changeset/config.json`) references `@noormdev/cli` and `@noormdev/sdk` — only these two are publishable. -- `packages/cli/package.json` and `packages/sdk/package.json` carry the published versions and peer deps. +- Changeset config ([`.changeset/config.json`](../../.changeset/config.json)) references `@noormdev/cli` and `@noormdev/sdk` — only these two are publishable. +- [`packages/cli/package.json`](../../packages/cli/package.json) and [`packages/sdk/package.json`](../../packages/sdk/package.json) carry the published versions and peer deps. ## Conventions worth knowing @@ -54,5 +54,5 @@ Build pipeline, CI, binary release, package publishing, and reference examples. - Integration tests need live DB services — not runnable locally without `docker-compose up`. - Examples run as separate CI jobs (`example-todo-db`, `example-llm-memory-db-pg`, `example-llm-memory-db-mssql`). - `NOORM_TEST_PREBUILT=1` tells example test harness to skip local bootstrap and use CLI-generated DB state. -- Bun pinned to `1.3.11` in CI (see `.github/workflows/ci.yml`); local dev uses `>=1.2`. -- `@noormdev/main` (root `package.json`) is private and not published. +- Bun pinned to `1.3.11` in CI (see [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)); local dev uses `>=1.2`. +- `@noormdev/main` (root [`package.json`](../../package.json)) is private and not published. diff --git a/docs/wiki/mcp-rpc.md b/docs/wiki/mcp-rpc.md index 2a85c166..4f428d7a 100644 --- a/docs/wiki/mcp-rpc.md +++ b/docs/wiki/mcp-rpc.md @@ -8,37 +8,39 @@ type: Domain MCP (Model Context Protocol) server that exposes noorm operations to AI agents. The MCP server wraps an RPC registry — commands are registered by name, then dispatched by the MCP `run_noorm_cmd` tool. A second tool `noorm_help` lists available commands. Session management tracks per-config connection state across MCP calls. +Every `RpcCommand` declares a `permission: Permission | 'open'` (`core/policy` permissions, or `'open'` for commands that target no config). Dispatch in [`src/mcp/server.ts`](../../src/mcp/server.ts) checks non-`'open'` commands against the resolved session's config via `checkConfigPolicy` before the handler runs. + ## CLI code -- `src/mcp/server.ts` — `createMcpServer`; builds `McpServer` with `run_noorm_cmd` and `noorm_help` tools -- `src/mcp/init.ts` — `initMcpServer`; initializes RPC registry, registers all commands, wires session -- `src/mcp/index.ts` — barrel export -- `src/rpc/registry.ts` — `RpcRegistry`; flat `Map` with register/get/list -- `src/rpc/session.ts` — `SessionManager`; tracks active Kysely connections per config name -- `src/rpc/protection.ts` — `RpcProtection`; validates commands against protected-config rules -- `src/rpc/commands/changes.ts` — RPC commands: `list_changes`, `run_change`, `revert_change`, `ff_changes` -- `src/rpc/commands/config.ts` — RPC commands: `list_configs`, `get_active_config` -- `src/rpc/commands/explore.ts` — RPC commands: `list_tables`, `describe_table`, `list_views`, `list_functions` -- `src/rpc/commands/query.ts` — RPC commands: `sql`, `run_sql` -- `src/rpc/commands/run.ts` — RPC commands: `run_file`, `run_build` -- `src/rpc/commands/session.ts` — RPC commands: `connect`, `disconnect`, `overview` -- `src/rpc/commands/index.ts` — command group barrel -- `src/rpc/types.ts` — `RpcCommand`, `RpcCommandInfo`, `RpcSession` type definitions -- `src/cli/mcp/init.ts` — `mcp init` CLI command; writes `.mcp.json` config file -- `src/cli/mcp/serve.ts` — `mcp serve` CLI command; starts MCP server over stdio +- [`src/mcp/server.ts`](../../src/mcp/server.ts) — `createMcpServer`; builds `McpServer` with `run_noorm_cmd` and `noorm_help` tools. Dispatch gates every non-`'open'` command via `checkConfigPolicy` (`core/policy`) against the resolved session context before the handler runs +- [`src/mcp/init.ts`](../../src/mcp/init.ts) — `initMcpServer`; initializes RPC registry, registers all commands, wires session +- [`src/mcp/index.ts`](../../src/mcp/index.ts) — barrel export +- [`src/rpc/registry.ts`](../../src/rpc/registry.ts) — `RpcRegistry`; flat `Map` with register/get/list +- [`src/rpc/session.ts`](../../src/rpc/session.ts) — `SessionManager`; tracks active Kysely connections per config name. Carries the session's `channel` (`user`/`mcp`, default `'user'`) and enforces mcp-channel invisibility in `connect()` — a config with `access.mcp === false` (or no `access`) throws the same not-found error as an unknown config name +- [`src/rpc/commands/changes.ts`](../../src/rpc/commands/changes.ts) — RPC commands: `list_changes`, `run_change`, `revert_change`, `ff_changes` +- [`src/rpc/commands/config.ts`](../../src/rpc/commands/config.ts) — RPC commands: `list_configs` (`permission: 'open'`; filters out `access.mcp === false` configs for the mcp channel), `get_active_config` +- [`src/rpc/commands/explore.ts`](../../src/rpc/commands/explore.ts) — RPC commands: `list_tables`, `describe_table`, `list_views`, `list_functions` +- [`src/rpc/commands/query.ts`](../../src/rpc/commands/query.ts) — RPC commands: `sql` (dispatch-gates on `'sql:read'`; `executeRawSql` itself checks the classified statement class against the config's role), `run_sql` +- [`src/rpc/commands/run.ts`](../../src/rpc/commands/run.ts) — RPC commands: `run_file`, `run_build` +- [`src/rpc/commands/session.ts`](../../src/rpc/commands/session.ts) — RPC commands: `connect`, `disconnect` (both `permission: 'open'`), `overview` +- [`src/rpc/commands/index.ts`](../../src/rpc/commands/index.ts) — command group barrel +- [`src/rpc/types.ts`](../../src/rpc/types.ts) — `RpcCommand` (carries `permission: Permission | 'open'`), `RpcCommandInfo`, `RpcSession` (carries `readonly channel: Channel`) type definitions +- [`src/cli/mcp/init.ts`](../../src/cli/mcp/init.ts) — `mcp init` CLI command; writes `.mcp.json` config file +- [`src/cli/mcp/serve.ts`](../../src/cli/mcp/serve.ts) — `mcp serve` CLI command; starts MCP server over stdio ## Docs -- `docs/guide/automation/mcp.md` — MCP setup and usage guide -- `docs/dev/headless.md` — headless/MCP usage patterns +- [`docs/guide/automation/mcp.md`](../guide/automation/mcp.md) — MCP setup and usage guide +- [`docs/dev/headless.md`](../dev/headless.md) — headless/MCP usage patterns ## Coupling - MCP server wraps RPC registry — new RPC commands are automatically discoverable via `noorm_help`. - RPC commands delegate to core modules (same as CLI) — core API changes need RPC command updates in parallel with CLI changes. -- `RpcProtection` uses `src/core/config/protection.ts` rules — config protection domain changes affect which RPC commands are allowed. -- `SessionManager` holds live Kysely connections — connection lifecycle must coordinate with `src/core/connection/manager.ts`. -- `src/cli/mcp/serve.ts` is the CLI entry; `src/mcp/init.ts` is the wiring; `src/mcp/server.ts` is the MCP layer. +- MCP dispatch gates every non-`'open'` `RpcCommand` via `checkConfigPolicy` from [`src/core/policy/`](../../src/core/policy) — `src/rpc/protection.ts` and `src/core/config/protection.ts` (the old protected-config rule checkers) were both deleted; policy is now the sole enforcement point. +- Every `RpcCommand` declares a `permission: Permission | 'open'` ([`src/rpc/types.ts`](../../src/rpc/types.ts)) — new RPC commands must pick a `core/policy` `Permission` or `'open'`, or the dispatch gate in [`src/mcp/server.ts`](../../src/mcp/server.ts) has nothing to check. +- `SessionManager` holds live Kysely connections — connection lifecycle must coordinate with [`src/core/connection/manager.ts`](../../src/core/connection/manager.ts). +- [`src/cli/mcp/serve.ts`](../../src/cli/mcp/serve.ts) is the CLI entry; [`src/mcp/init.ts`](../../src/mcp/init.ts) is the wiring; [`src/mcp/server.ts`](../../src/mcp/server.ts) is the MCP layer. ## Conventions worth knowing @@ -47,4 +49,6 @@ MCP (Model Context Protocol) server that exposes noorm operations to AI agents. - `noorm_help` lists all registered commands with descriptions and parameter schemas. - `mcp init` writes `.mcp.json` with the `noorm mcp serve` invocation for Claude Desktop / IDE integration. - Zod schemas on each RPC command define the `payload` shape validated at dispatch time. -- Tests in `tests/core/mcp/` cover server init and command dispatch; `tests/core/rpc/` covers registry, protection, session. +- Tests in [`tests/core/mcp/`](../../tests/core/mcp) cover server init and command dispatch; [`tests/core/rpc/`](../../tests/core/rpc) covers registry, permissions, session. +- `connect()` on the mcp channel throws the identical `configNotFoundMessage` error (`core/config/resolver.ts`) for an unknown config and an invisible one (`access.mcp === false`) — an mcp caller cannot distinguish "doesn't exist" from "not permitted". +- `SessionInfo.protected: boolean` was replaced by `SessionInfo.role: Role` — the resolved role for the session's channel (`mcp` resolves `access.mcp`, `user` resolves `access.user`). diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index f2d86286..f4d4ca36 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -1,7 +1,3 @@ ---- -generated_at: 2026-06-01T02:30:22Z -atomic_version: 3.0.0 ---- # Deterministic signals ## Tree @@ -11,7 +7,7 @@ atomic_version: 3.0.0 │ └── opentui/ (2) │ ├── references/ (0 files, 8 dirs) │ └── SKILL.md (a62967f, 195L, 7253ch, 7427B) -├── .changeset/ (64) +├── .changeset/ (71) │ ├── README.md (bf33c79, 8L, 510ch, 510B) │ ├── binary-release-automation.md (b25adb3, 6L, 110ch, 110B) │ ├── bold-wolves-call.md (a1927b2, 10L, 442ch, 442B) @@ -37,6 +33,8 @@ atomic_version: 3.0.0 │ ├── fix-mssql-dialect-support-sdk.md (164eaa5, 10L, 442ch, 442B) │ ├── fix-mssql-dialect-support.md (dadf1da, 19L, 1407ch, 1409B) │ ├── fix-mssql-mysql-schema-migration.md (befbcf0, 11L, 504ch, 512B) +│ ├── fix-mssql-tarn-bundle-interop-cli.md (dd0bfcf, 11L, 474ch, 474B) +│ ├── fix-mssql-tarn-bundle-interop.md (fd9465e, 11L, 450ch, 450B) │ ├── fix-sdk-bundle-deps.md (69ad2cc, 15L, 738ch, 744B) │ ├── fix-shutdown-hang.md (a723a06, 8L, 367ch, 367B) │ ├── fix-teardown-schema-qualify-cli.md (ebd436d, 7L, 223ch, 223B) @@ -57,14 +55,18 @@ atomic_version: 3.0.0 │ ├── mssql-teardown-sdk.md (37fb6c1, 8L, 917ch, 929B) │ ├── noorm-ci-namespace.md (0e98b5d, 28L, 2748ch, 2762B) │ ├── noorm-init-sql-repl.md (19e9978, 8L, 373ch, 377B) -│ ├── pre.json (916cf1f, 74L, 1973ch, 1973B) +│ ├── pre.json (9860d10, 80L, 2219ch, 2219B) │ ├── quiet-pandas-sleep.md (a17d93c, 7L, 220ch, 220B) │ ├── rebuild-fix.md (174afad, 5L, 69ch, 69B) +│ ├── reset-ignores-preserve-tables-cli.md (5946a54, 13L, 594ch, 596B) +│ ├── reset-ignores-preserve-tables.md (e8a8f6d, 13L, 575ch, 577B) │ ├── rich-errors-templates-headless.md (fea62cb, 18L, 1337ch, 1345B) │ ├── runner-observability-cli.md (aea99ea, 9L, 562ch, 562B) │ ├── sdk-protected-config-hardblock.md (99d3624, 37L, 1477ch, 1489B) │ ├── sharp-foxes-run.md (4f88802, 5L, 224ch, 224B) │ ├── swift-clouds-drift.md (cbb537b, 7L, 175ch, 175B) +│ ├── teardown-mssql-check-constraint-udf-cli.md (e4a845b, 13L, 632ch, 632B) +│ ├── teardown-mssql-check-constraint-udf.md (445a6fa, 13L, 591ch, 591B) │ ├── tender-lions-enter.md (aae45fa, 32L, 1240ch, 1240B) │ ├── tiny-dogs-yawn.md (7c0d0cc, 5L, 127ch, 127B) │ ├── tty-yes-flag-cli.md (a93a4e3, 8L, 723ch, 723B) @@ -75,6 +77,7 @@ atomic_version: 3.0.0 │ ├── version-debug.md (268b067, 5L, 96ch, 96B) │ ├── warm-apples-march.md (e8178e6, 13L, 754ch, 754B) │ ├── warm-tables-stay.md (4440596, 9L, 327ch, 329B) +│ ├── wise-owls-guard.md (64ccc90, 17L, 3658ch, 3684B) │ └── worker-bridge.md (5aebf7c, 14L, 1107ch, 1109B) ├── .claude/ (2) │ ├── rules/ (4) @@ -117,15 +120,16 @@ atomic_version: 3.0.0 │ │ ├── settings-secret.md (c26ebf9, 23L, 675ch, 679B) │ │ ├── sql-repl.md (10dc595, 28L, 785ch, 789B) │ │ └── sql.md (18be39b, 70L, 2615ch, 2639B) -│ ├── design/ (1) -│ │ └── .gitkeep (e3b0c44, 0L, 0ch, 0B) +│ ├── design/ (2) +│ │ ├── .gitkeep (e3b0c44, 0L, 0ch, 0B) +│ │ └── config-access-roles.md (bd73baa, 116L, 6566ch, 6670B) │ ├── dev/ (26) -│ │ ├── README.md (2b333a0, 201L, 6527ch, 8033B) +│ │ ├── README.md (40f97b8, 201L, 6544ch, 8050B) │ │ ├── change.md (1ebd1fc, 509L, 15457ch, 15519B) │ │ ├── ci.md (2bd9b8a, 205L, 6796ch, 6804B) -│ │ ├── config-sharing.md (7d033e6, 269L, 8565ch, 8583B) -│ │ ├── config.md (97b3fc1, 432L, 11722ch, 11742B) -│ │ ├── datamodel.md (718afdb, 1028L, 28851ch, 28971B) +│ │ ├── config-sharing.md (61852f9, 269L, 8593ch, 8611B) +│ │ ├── config.md (8e00bcc, 437L, 12957ch, 12993B) +│ │ ├── datamodel.md (de196f7, 1040L, 29861ch, 29989B) │ │ ├── explore.md (6895cb3, 325L, 8889ch, 8959B) │ │ ├── headless.md (d1846fa, 756L, 17719ch, 17855B) │ │ ├── identity.md (4a4ea8e, 350L, 12135ch, 12159B) @@ -136,40 +140,40 @@ atomic_version: 3.0.0 │ │ ├── logger.md (b8754a5, 521L, 15363ch, 16867B) │ │ ├── project-discovery.md (ba6c27b, 128L, 4178ch, 4184B) │ │ ├── runner.md (ec9317d, 516L, 18416ch, 18438B) -│ │ ├── sdk.md (9ee441d, 1094L, 26206ch, 26248B) +│ │ ├── sdk.md (3efc2f5, 1094L, 26569ch, 26615B) │ │ ├── secrets.md (2312d48, 297L, 10122ch, 10192B) -│ │ ├── settings.md (abd87a4, 746L, 18091ch, 18101B) +│ │ ├── settings.md (b87e542, 746L, 18539ch, 18551B) │ │ ├── sql-terminal.md (381dcaa, 321L, 8991ch, 10355B) -│ │ ├── state.md (79f8e8e, 362L, 9285ch, 9315B) -│ │ ├── teardown.md (f40246e, 359L, 11762ch, 11794B) +│ │ ├── state.md (840d27d, 362L, 9331ch, 9361B) +│ │ ├── teardown.md (fc58de8, 359L, 11952ch, 11984B) │ │ ├── template.md (e6310f7, 460L, 11515ch, 11603B) │ │ ├── transfer.md (47b8c7b, 663L, 22886ch, 23134B) │ │ ├── vault.md (3095975, 521L, 16055ch, 17667B) │ │ └── version.md (c0f51c6, 644L, 17504ch, 17516B) │ ├── getting-started/ (4) │ │ ├── building-your-sdk.md (8389a71, 752L, 16897ch, 17383B) -│ │ ├── concepts.md (885c8ed, 346L, 10673ch, 11025B) +│ │ ├── concepts.md (470bf64, 347L, 11180ch, 11540B) │ │ ├── first-build.md (6ea690f, 332L, 8338ch, 8434B) │ │ └── installation.md (447f8bb, 114L, 2936ch, 2998B) │ ├── guide/ (6) │ │ ├── automation/ (3) -│ │ │ ├── ci.md (ee009ad, 345L, 11893ch, 11929B) -│ │ │ ├── mcp.md (bc7ea57, 100L, 2780ch, 3038B) -│ │ │ └── non-interactive.md (b320f7e, 120L, 4054ch, 4066B) +│ │ │ ├── ci.md (04e95d2, 345L, 11915ch, 11951B) +│ │ │ ├── mcp.md (9543edb, 127L, 4891ch, 5175B) +│ │ │ └── non-interactive.md (556d953, 121L, 4392ch, 4406B) │ │ ├── changes/ (3) │ │ │ ├── forward-revert.md (1754625, 285L, 8097ch, 8101B) │ │ │ ├── history.md (91ab04d, 320L, 9917ch, 9917B) │ │ │ └── overview.md (909a111, 348L, 13623ch, 13945B) │ │ ├── database/ (5) -│ │ │ ├── create.md (c765f14, 142L, 4944ch, 4968B) +│ │ │ ├── create.md (ec7375f, 142L, 4947ch, 4971B) │ │ │ ├── explore.md (929a4ee, 481L, 17845ch, 22315B) -│ │ │ ├── teardown.md (709966b, 363L, 9314ch, 9316B) +│ │ │ ├── teardown.md (4ed72b7, 364L, 10271ch, 10285B) │ │ │ ├── terminal.md (a88f37a, 225L, 7075ch, 9051B) │ │ │ └── transfer.md (a6608ba, 369L, 10184ch, 10210B) │ │ ├── environments/ (4) -│ │ │ ├── configs.md (fb44cf0, 340L, 10225ch, 10247B) +│ │ │ ├── configs.md (847237a, 347L, 11140ch, 11168B) │ │ │ ├── secrets.md (bb92c69, 200L, 6828ch, 6840B) -│ │ │ ├── stages.md (1484f7d, 258L, 7298ch, 7300B) +│ │ │ ├── stages.md (76e78ed, 258L, 7545ch, 7551B) │ │ │ └── vault.md (bd35325, 257L, 7681ch, 8229B) │ │ ├── sql-files/ (3) │ │ │ ├── execution.md (4a53b9a, 249L, 8166ch, 8308B) @@ -194,17 +198,18 @@ atomic_version: 3.0.0 │ │ │ └── logo.svg (8d46c28, 6L, 2529ch, 2529B) │ │ └── install.sh (0cc90a2, 116L, 2925ch, 2925B) │ ├── reference/ (1) -│ │ └── sdk.md (4dfc194, 1426L, 41209ch, 41368B) -│ ├── spec/ (1) -│ │ └── .gitkeep (e3b0c44, 0L, 0ch, 0B) +│ │ └── sdk.md (59c676d, 1433L, 41839ch, 42006B) +│ ├── spec/ (2) +│ │ ├── .gitkeep (e3b0c44, 0L, 0ch, 0B) +│ │ └── config-access-roles.md (c153076, 153L, 17852ch, 17952B) │ ├── superpowers/ (1) │ │ └── specs/ (1) │ │ └── 2026-04-19-cli-ci-identity-design.md (6c9cc80, 938L, 32762ch, 32918B) │ ├── bun.lockb (34225b2, 190L, 125711ch, 126677B) -│ ├── headless.md (9fd6327, 1592L, 36955ch, 36975B) +│ ├── headless.md (d10c758, 1612L, 38848ch, 38884B) │ ├── index.md (4d92e08, 178L, 6324ch, 6362B) │ ├── package.json (b4778f0, 24L, 657ch, 657B) -│ └── tui.md (c6668da, 407L, 13808ch, 17874B) +│ └── tui.md (99b066a, 407L, 13994ch, 18090B) ├── examples/ (3) │ ├── llm-memory-db-mssql/ (16) │ │ ├── .cursor/ (1) @@ -245,13 +250,13 @@ atomic_version: 3.0.0 │ │ │ └── sql/ (11 files, 0 dirs) │ │ ├── .gitignore (ccb61cd, 40L, 446ch, 446B) │ │ ├── .mcp.json (14f011f, 11L, 174ch, 174B) -│ │ ├── CHANGELOG.md (f40702f, 11L, 243ch, 243B) +│ │ ├── CHANGELOG.md (ad9092a, 20L, 413ch, 413B) │ │ ├── CLAUDE.md (1f39d31, 111L, 2676ch, 2676B) │ │ ├── README.md (3b60a6a, 121L, 6801ch, 6837B) │ │ ├── REPORT.md (4f3efcd, 161L, 12957ch, 13004B) │ │ ├── mcp-config.json (14f011f, 11L, 174ch, 174B) │ │ ├── mssql-problems.md (5083524, 328L, 23834ch, 23934B) -│ │ ├── package.json (a0b9242, 23L, 631ch, 631B) +│ │ ├── package.json (435022e, 23L, 631ch, 631B) │ │ └── tsconfig.json (7be3bae, 27L, 736ch, 736B) │ ├── llm-memory-db-pg/ (16) │ │ ├── .cursor/ (1) @@ -295,13 +300,13 @@ atomic_version: 3.0.0 │ │ │ └── mcp-discovery.test.ts (02d4035, 765L, 24559ch, 24603B) │ │ ├── .gitignore (a94396a, 36L, 397ch, 397B) │ │ ├── .mcp.json (14f011f, 11L, 174ch, 174B) -│ │ ├── CHANGELOG.md (4dd3e08, 11L, 240ch, 240B) +│ │ ├── CHANGELOG.md (96e019a, 20L, 410ch, 410B) │ │ ├── CLAUDE.md (1f39d31, 111L, 2676ch, 2676B) │ │ ├── README.md (18a81f1, 169L, 9519ch, 9823B) │ │ ├── REPORT-PHASE-1.md (59b7d41, 103L, 9610ch, 9670B) │ │ ├── REPORT.md (f22f51f, 140L, 17057ch, 17127B) │ │ ├── mcp-config.json (14f011f, 11L, 174ch, 174B) -│ │ ├── package.json (21f45f9, 23L, 670ch, 670B) +│ │ ├── package.json (e2a5af9, 23L, 670ch, 670B) │ │ └── tsconfig.json (4dc04b1, 30L, 735ch, 735B) │ └── todo-db/ (10) │ ├── .noorm/ (1) @@ -337,20 +342,20 @@ atomic_version: 3.0.0 │ │ ├── views/ (2 files, 0 dirs) │ │ └── preload.ts (0d97cd6, 25L, 658ch, 660B) │ ├── .gitignore (81531bd, 5L, 57ch, 57B) -│ ├── CHANGELOG.md (f223e05, 17L, 300ch, 300B) +│ ├── CHANGELOG.md (1baf6d6, 26L, 470ch, 470B) │ ├── bunfig.toml (e10e7cb, 5L, 89ch, 89B) -│ ├── package.json (174c9fc, 22L, 581ch, 581B) +│ ├── package.json (a96f8c6, 22L, 581ch, 581B) │ └── tsconfig.json (efeae48, 17L, 484ch, 484B) ├── packages/ (2) │ ├── cli/ (4) │ │ ├── scripts/ (1) │ │ │ └── postinstall.js (4ddeede, 155L, 4208ch, 4210B) -│ │ ├── CHANGELOG.md (6e5e36a, 649L, 34455ch, 34629B) +│ │ ├── CHANGELOG.md (490a0a0, 681L, 36171ch, 36347B) │ │ ├── noorm.js (e3d76e8, 41L, 1041ch, 1043B) -│ │ └── package.json (07ea87f, 31L, 540ch, 540B) +│ │ └── package.json (605e00c, 31L, 540ch, 540B) │ └── sdk/ (2) -│ ├── CHANGELOG.md (28d93a1, 495L, 22426ch, 22564B) -│ └── package.json (459a656, 60L, 1093ch, 1093B) +│ ├── CHANGELOG.md (84c9272, 527L, 24058ch, 24198B) +│ └── package.json (91878a9, 60L, 1093ch, 1093B) ├── scripts/ (5) │ ├── Dockerfile (5fe0d7a, 51L, 1595ch, 1595B) │ ├── build-binary.mjs (f598b0c, 37L, 1284ch, 1288B) @@ -360,9 +365,9 @@ atomic_version: 3.0.0 ├── skills/ (1) │ └── noorm/ (2) │ ├── references/ (4) -│ │ ├── cli.md (bd58f75, 1011L, 29192ch, 29234B) -│ │ ├── config.md (5dd1eb1, 267L, 8600ch, 9296B) -│ │ ├── sdk.md (632ebcb, 646L, 21501ch, 21584B) +│ │ ├── cli.md (19df9f6, 1010L, 29280ch, 29322B) +│ │ ├── config.md (7d36099, 272L, 9415ch, 10115B) +│ │ ├── sdk.md (d8c194a, 646L, 21778ch, 21863B) │ │ └── templates.md (20dbd54, 386L, 11061ch, 11161B) │ └── SKILL.md (d49c384, 65L, 3837ch, 3845B) ├── src/ (8) @@ -384,22 +389,22 @@ atomic_version: 3.0.0 │ │ ├── ci/ (4) │ │ │ ├── identity/ (3 files, 0 dirs) │ │ │ ├── index.ts (edb5806, 26L, 673ch, 679B) -│ │ │ ├── init.ts (e29ca75, 216L, 6703ch, 6711B) +│ │ │ ├── init.ts (015ad40, 217L, 6764ch, 6772B) │ │ │ └── secrets.ts (5b51d3a, 231L, 6176ch, 6180B) │ │ ├── config/ (10) │ │ │ ├── add.ts (cce6bfa, 27L, 732ch, 736B) │ │ │ ├── cp.ts (cc57e45, 81L, 2209ch, 2211B) │ │ │ ├── edit.ts (76f315f, 34L, 888ch, 892B) │ │ │ ├── export.ts (508008b, 81L, 2213ch, 2215B) -│ │ │ ├── import.ts (11eb6e3, 133L, 3468ch, 3470B) +│ │ │ ├── import.ts (01f8e59, 105L, 3068ch, 3070B) │ │ │ ├── index.ts (54b770a, 22L, 611ch, 613B) -│ │ │ ├── list.ts (d6aad68, 72L, 1905ch, 1909B) +│ │ │ ├── list.ts (b93a381, 74L, 2064ch, 2068B) │ │ │ ├── rm.ts (a5c6a97, 34L, 865ch, 869B) │ │ │ ├── use.ts (f307d0e, 79L, 2169ch, 2173B) │ │ │ └── validate.ts (d832331, 130L, 3623ch, 3625B) │ │ ├── db/ (16) │ │ │ ├── create.ts (9d3ae5b, 107L, 2993ch, 2995B) -│ │ │ ├── drop.ts (89c3bff, 95L, 2496ch, 2498B) +│ │ │ ├── drop.ts (7625328, 103L, 2880ch, 2882B) │ │ │ ├── explore-fks.ts (3a624f7, 79L, 1936ch, 1940B) │ │ │ ├── explore-functions.ts (cd47d2a, 132L, 3143ch, 3147B) │ │ │ ├── explore-indexes.ts (69140a5, 76L, 1762ch, 1764B) @@ -458,7 +463,7 @@ atomic_version: 3.0.0 │ │ │ ├── clear.ts (16b8b97, 89L, 2766ch, 2768B) │ │ │ ├── history.ts (7c2048c, 139L, 3970ch, 3980B) │ │ │ ├── index.ts (427fc50, 38L, 1298ch, 1300B) -│ │ │ ├── query.ts (612128e, 111L, 2835ch, 2837B) +│ │ │ ├── query.ts (84227f9, 115L, 2969ch, 2971B) │ │ │ └── repl.ts (4888c5d, 131L, 3817ch, 3819B) │ │ ├── vault/ (7) │ │ │ ├── cp.ts (2adf7aa, 195L, 5472ch, 5474B) @@ -475,23 +480,22 @@ atomic_version: 3.0.0 │ │ ├── ui.ts (c22c772, 65L, 1675ch, 1679B) │ │ ├── update.ts (f53462e, 110L, 2930ch, 2934B) │ │ └── version.ts (ca59073, 273L, 6880ch, 6890B) -│ ├── core/ (29) +│ ├── core/ (30) │ │ ├── change/ (9) -│ │ │ ├── executor.ts (57455e1, 1095L, 28953ch, 30419B) +│ │ │ ├── executor.ts (caa792c, 1118L, 29801ch, 31271B) │ │ │ ├── history.ts (f0f083c, 1081L, 29641ch, 31253B) │ │ │ ├── index.ts (edd765a, 128L, 3031ch, 4739B) │ │ │ ├── manager.ts (3f21ad7, 616L, 15844ch, 18156B) │ │ │ ├── parser.ts (917e642, 570L, 13337ch, 14801B) │ │ │ ├── scaffold.ts (0f2d03f, 649L, 15170ch, 17122B) │ │ │ ├── tracker.ts (da3742c, 245L, 7183ch, 7671B) -│ │ │ ├── types.ts (9f674de, 668L, 15169ch, 17853B) +│ │ │ ├── types.ts (ca31b61, 681L, 15684ch, 18370B) │ │ │ └── validation.ts (ca6f086, 90L, 2247ch, 2247B) -│ │ ├── config/ (5) -│ │ │ ├── index.ts (8bacaa8, 132L, 3477ch, 3477B) -│ │ │ ├── protection.ts (e825813, 152L, 3648ch, 3648B) -│ │ │ ├── resolver.ts (23c7786, 380L, 9351ch, 9351B) -│ │ │ ├── schema.ts (9d69aa4, 263L, 6611ch, 7099B) -│ │ │ └── types.ts (b7fdc63, 129L, 2867ch, 3111B) +│ │ ├── config/ (4) +│ │ │ ├── index.ts (1e9a284, 124L, 3364ch, 3364B) +│ │ │ ├── resolver.ts (351ba35, 450L, 12107ch, 12111B) +│ │ │ ├── schema.ts (e9af3c9, 308L, 8239ch, 8733B) +│ │ │ └── types.ts (9516dc0, 143L, 3478ch, 3726B) │ │ ├── connection/ (5) │ │ │ ├── dialects/ (7 files, 0 dirs) │ │ │ ├── factory.ts (7c5708c, 253L, 7370ch, 7370B) @@ -538,7 +542,7 @@ atomic_version: 3.0.0 │ │ │ ├── resolver.ts (67fae4f, 229L, 5068ch, 5068B) │ │ │ ├── storage.ts (29ef16f, 500L, 11805ch, 11805B) │ │ │ ├── sync.ts (cd659cf, 438L, 11504ch, 11504B) -│ │ │ └── types.ts (7093b29, 244L, 5823ch, 5823B) +│ │ │ └── types.ts (1bb32e8, 207L, 5085ch, 5085B) │ │ ├── lifecycle/ (4) │ │ │ ├── handlers.ts (9847a66, 228L, 5414ch, 5414B) │ │ │ ├── index.ts (2b435e9, 37L, 938ch, 938B) @@ -561,21 +565,28 @@ atomic_version: 3.0.0 │ │ │ ├── redact.ts (4314060, 389L, 9109ch, 10329B) │ │ │ ├── rotation.ts (3579fb5, 250L, 5628ch, 5628B) │ │ │ └── types.ts (5036c8b, 127L, 2773ch, 2773B) +│ │ ├── policy/ (6) +│ │ │ ├── check.ts (74b7467, 169L, 5317ch, 5331B) +│ │ │ ├── classify.ts (1d30fc5, 821L, 19394ch, 19426B) +│ │ │ ├── index.ts (dbf341d, 20L, 618ch, 618B) +│ │ │ ├── legacy-access.ts (f2b9b23, 35L, 1362ch, 1366B) +│ │ │ ├── matrix.ts (b5378bb, 27L, 1324ch, 1327B) +│ │ │ └── types.ts (fca41bb, 74L, 2275ch, 2281B) │ │ ├── runner/ (6) │ │ │ ├── checksum.ts (d21f46d, 99L, 2650ch, 2650B) │ │ │ ├── index.ts (5156823, 55L, 1020ch, 1020B) │ │ │ ├── mssql-batches.ts (6f7535b, 166L, 4825ch, 4835B) -│ │ │ ├── runner.ts (5d53e1f, 1413L, 36581ch, 38291B) +│ │ │ ├── runner.ts (9e3a619, 1440L, 37499ch, 39213B) │ │ │ ├── tracker.ts (3547b6f, 704L, 20648ch, 20876B) -│ │ │ └── types.ts (5378590, 389L, 9507ch, 11459B) +│ │ │ └── types.ts (99ca816, 403L, 10072ch, 12026B) │ │ ├── settings/ (7) │ │ │ ├── defaults.ts (3fef652, 117L, 2748ch, 2748B) │ │ │ ├── events.ts (f8588b1, 108L, 2257ch, 2257B) │ │ │ ├── index.ts (f096e30, 78L, 1597ch, 1597B) -│ │ │ ├── manager.ts (66221e2, 1048L, 23331ch, 26017B) -│ │ │ ├── rules.ts (16f8633, 277L, 6610ch, 6610B) -│ │ │ ├── schema.ts (6de2494, 338L, 9153ch, 11227B) -│ │ │ └── types.ts (79b9fb0, 306L, 6964ch, 6964B) +│ │ │ ├── manager.ts (6b5a0dd, 1034L, 22970ch, 25656B) +│ │ │ ├── rules.ts (1b5776e, 288L, 6904ch, 6904B) +│ │ │ ├── schema.ts (3447bca, 340L, 9392ch, 11470B) +│ │ │ └── types.ts (fda4f91, 310L, 7363ch, 7367B) │ │ ├── shared/ (5) │ │ │ ├── dialect-quoting.ts (8f028eb, 66L, 1860ch, 1866B) │ │ │ ├── errors.ts (9cb1445, 221L, 6038ch, 6528B) @@ -583,22 +594,22 @@ atomic_version: 3.0.0 │ │ │ ├── index.ts (d39b0e1, 62L, 1331ch, 1331B) │ │ │ └── tables.ts (06b23dd, 487L, 12754ch, 14716B) │ │ ├── sql-terminal/ (4) -│ │ │ ├── executor.ts (ad0e460, 101L, 2589ch, 2589B) +│ │ │ ├── executor.ts (9c75cbc, 164L, 4951ch, 4955B) │ │ │ ├── history.ts (0cdd43f, 407L, 9888ch, 9888B) │ │ │ ├── index.ts (fabf166, 9L, 185ch, 185B) │ │ │ └── types.ts (cb0a264, 123L, 2519ch, 2519B) │ │ ├── state/ (6) │ │ │ ├── encryption/ (2 files, 0 dirs) │ │ │ ├── index.ts (ad7758c, 70L, 1373ch, 1373B) -│ │ │ ├── manager.ts (488e8c2, 712L, 16908ch, 18372B) -│ │ │ ├── migrations.ts (eef668b, 81L, 2489ch, 2489B) -│ │ │ ├── types.ts (7d1b252, 82L, 2168ch, 2168B) +│ │ │ ├── manager.ts (22356f6, 778L, 19894ch, 21362B) +│ │ │ ├── migrations.ts (2d1f7fc, 88L, 2913ch, 2913B) +│ │ │ ├── types.ts (638ec9f, 92L, 2562ch, 2564B) │ │ │ └── version.ts (11a6a0b, 26L, 649ch, 649B) │ │ ├── teardown/ (4) │ │ │ ├── dialects/ (5 files, 0 dirs) │ │ │ ├── index.ts (74cbb84, 27L, 549ch, 549B) -│ │ │ ├── operations.ts (236951c, 549L, 15035ch, 15067B) -│ │ │ └── types.ts (237ef4d, 271L, 6923ch, 6925B) +│ │ │ ├── operations.ts (378e92c, 560L, 15529ch, 15561B) +│ │ │ └── types.ts (df8debf, 282L, 7405ch, 7407B) │ │ ├── template/ (7) │ │ │ ├── loaders/ (7 files, 0 dirs) │ │ │ ├── context.ts (a34f692, 245L, 6537ch, 6537B) @@ -611,10 +622,10 @@ atomic_version: 3.0.0 │ │ │ ├── dialects/ (5 files, 0 dirs) │ │ │ ├── events.ts (c8d31f3, 68L, 1652ch, 1652B) │ │ │ ├── executor.ts (66a317b, 1099L, 26390ch, 26392B) -│ │ │ ├── index.ts (4da78aa, 219L, 5734ch, 5734B) +│ │ │ ├── index.ts (a144bca, 233L, 6236ch, 6238B) │ │ │ ├── planner.ts (916e6d7, 660L, 17836ch, 17838B) │ │ │ ├── same-server.ts (365863e, 123L, 3024ch, 3024B) -│ │ │ └── types.ts (48073ce, 170L, 3917ch, 3917B) +│ │ │ └── types.ts (9fa32bd, 177L, 4138ch, 4138B) │ │ ├── update/ (7) │ │ │ ├── checker.ts (445cd79, 348L, 8642ch, 8642B) │ │ │ ├── global-settings.ts (ced5668, 317L, 7229ch, 7229B) @@ -637,7 +648,7 @@ atomic_version: 3.0.0 │ │ │ ├── settings/ (1 file, 1 dir) │ │ │ ├── state/ (1 file, 1 dir) │ │ │ ├── index.ts (8abf6c2, 302L, 7550ch, 8282B) -│ │ │ └── types.ts (0a0df3c, 253L, 6917ch, 8381B) +│ │ │ └── types.ts (5fcc885, 253L, 6917ch, 8381B) │ │ ├── worker-bridge/ (6) │ │ │ ├── bridge.ts (e4d45b1, 126L, 3245ch, 3247B) │ │ │ ├── index.ts (15b198a, 13L, 360ch, 360B) @@ -654,23 +665,22 @@ atomic_version: 3.0.0 │ ├── hooks/ (1) │ │ └── observer.ts (cc4883f, 76L, 1791ch, 1791B) │ ├── mcp/ (3) -│ │ ├── index.ts (931d3b5, 26L, 847ch, 849B) +│ │ ├── index.ts (d3d7f27, 26L, 852ch, 854B) │ │ ├── init.ts (84b9cbf, 85L, 1953ch, 1953B) -│ │ └── server.ts (3eea905, 195L, 6108ch, 6110B) -│ ├── rpc/ (6) +│ │ └── server.ts (621db73, 231L, 7413ch, 7417B) +│ ├── rpc/ (5) │ │ ├── commands/ (7) -│ │ │ ├── changes.ts (39633c7, 90L, 2508ch, 2508B) -│ │ │ ├── config.ts (b246033, 32L, 1048ch, 1048B) -│ │ │ ├── explore.ts (1341edb, 88L, 3158ch, 3158B) +│ │ │ ├── changes.ts (49d45ad, 94L, 2627ch, 2627B) +│ │ │ ├── config.ts (3ced138, 43L, 1379ch, 1379B) +│ │ │ ├── explore.ts (f24be95, 91L, 3239ch, 3239B) │ │ │ ├── index.ts (1d30237, 30L, 734ch, 734B) -│ │ │ ├── query.ts (298d906, 42L, 1404ch, 1406B) -│ │ │ ├── run.ts (a6ff11e, 55L, 1610ch, 1610B) -│ │ │ └── session.ts (33c99fd, 51L, 1571ch, 1571B) +│ │ │ ├── query.ts (d55e353, 40L, 1456ch, 1458B) +│ │ │ ├── run.ts (2e5fb40, 57L, 1667ch, 1667B) +│ │ │ └── session.ts (b747844, 53L, 1619ch, 1619B) │ │ ├── index.ts (32e718c, 21L, 630ch, 630B) -│ │ ├── protection.ts (9eb736e, 338L, 6887ch, 6897B) │ │ ├── registry.ts (02a1571, 109L, 2511ch, 2511B) -│ │ ├── session.ts (fad46fb, 160L, 3434ch, 3442B) -│ │ └── types.ts (37291b9, 70L, 1559ch, 1561B) +│ │ ├── session.ts (67b8b8c, 196L, 5142ch, 5156B) +│ │ └── types.ts (5ce4cf7, 78L, 2041ch, 2043B) │ ├── sdk/ (11) │ │ ├── impersonate/ (4) │ │ │ ├── dialect-strategy.ts (51babcc, 101L, 2765ch, 3745B) @@ -678,27 +688,27 @@ atomic_version: 3.0.0 │ │ │ ├── scope.ts (b139628, 103L, 3170ch, 3414B) │ │ │ └── types.ts (ace8416, 67L, 2106ch, 2594B) │ │ ├── namespaces/ (11) -│ │ │ ├── changes.ts (f9221bc, 479L, 12307ch, 13613B) -│ │ │ ├── db.ts (fd453fd, 365L, 9237ch, 10543B) -│ │ │ ├── dt.ts (e81b899, 106L, 2686ch, 3144B) +│ │ │ ├── changes.ts (be87721, 487L, 12794ch, 14100B) +│ │ │ ├── db.ts (726a447, 376L, 10050ch, 11358B) +│ │ │ ├── dt.ts (09c4dc4, 106L, 2723ch, 3181B) │ │ │ ├── index.ts (9ce85c2, 10L, 454ch, 454B) │ │ │ ├── lock.ts (dcd30d9, 154L, 3875ch, 4333B) -│ │ │ ├── run.ts (8cda085, 207L, 5429ch, 6525B) +│ │ │ ├── run.ts (585765d, 218L, 5973ch, 7069B) │ │ │ ├── secrets.ts (860ea8b, 42L, 967ch, 1213B) │ │ │ ├── templates.ts (da4c186, 53L, 1492ch, 1738B) -│ │ │ ├── transfer.ts (bd1c052, 63L, 1604ch, 1850B) +│ │ │ ├── transfer.ts (8dd6b7c, 72L, 2056ch, 2304B) │ │ │ ├── utils.ts (4f1abc0, 60L, 1491ch, 1737B) │ │ │ └── vault.ts (5e75287, 364L, 9201ch, 10299B) │ │ ├── stubs/ (1) │ │ │ └── ansis.ts (16243d6, 19L, 488ch, 490B) │ │ ├── context.ts (3e9d43b, 564L, 16926ch, 19032B) -│ │ ├── guards.ts (00b7989, 99L, 2252ch, 2742B) -│ │ ├── index.ts (00fbae2, 256L, 7480ch, 8214B) +│ │ ├── guards.ts (cb3eb1c, 128L, 3480ch, 3974B) +│ │ ├── index.ts (f1819b9, 265L, 8033ch, 8769B) │ │ ├── noorm-ops.ts (7b2144a, 186L, 4074ch, 4744B) │ │ ├── sql.ts (397c9c9, 728L, 19985ch, 21507B) │ │ ├── state.ts (4de0bfb, 29L, 1057ch, 1301B) │ │ ├── tvp.ts (05b7f0a, 168L, 4695ch, 5433B) -│ │ └── types.ts (b2fc815, 161L, 4101ch, 5077B) +│ │ └── types.ts (892f38a, 169L, 4431ch, 5407B) │ ├── tui/ (14) │ │ ├── components/ (10) │ │ │ ├── dialogs/ (6 files, 0 dirs) @@ -745,19 +755,19 @@ atomic_version: 3.0.0 │ │ │ ├── home.tsx (7f5fdf6, 635L, 21702ch, 21712B) │ │ │ └── not-found.tsx (135b44e, 72L, 1958ch, 1958B) │ │ ├── utils/ (12) -│ │ │ ├── change-context.ts (dbc7d53, 66L, 1991ch, 1991B) +│ │ │ ├── change-context.ts (88cb5c7, 70L, 2165ch, 2165B) │ │ │ ├── change-loader.ts (4ad58de, 252L, 6631ch, 6633B) │ │ │ ├── clipboard.ts (93a7f8c, 95L, 1991ch, 1991B) -│ │ │ ├── config-validation.ts (ff30891, 151L, 3627ch, 3629B) +│ │ │ ├── config-validation.ts (b1a9ceb, 221L, 5883ch, 5893B) │ │ │ ├── connection.ts (e2bc3b6, 79L, 1950ch, 1950B) │ │ │ ├── date.ts (ff07926, 20L, 391ch, 391B) │ │ │ ├── error.ts (97c198b, 33L, 736ch, 736B) │ │ │ ├── identity.ts (a44606a, 32L, 870ch, 870B) -│ │ │ ├── index.ts (d45254c, 25L, 883ch, 883B) +│ │ │ ├── index.ts (77c3309, 29L, 976ch, 976B) │ │ │ ├── paths.ts (ee0cf63, 53L, 1329ch, 1329B) -│ │ │ ├── run-context.ts (a8bf8c9, 63L, 1810ch, 1810B) +│ │ │ ├── run-context.ts (207450d, 66L, 1951ch, 1951B) │ │ │ └── string.ts (8c22fe4, 39L, 1182ch, 1182B) -│ │ ├── app-context.tsx (9ad6408, 1196L, 29554ch, 30788B) +│ │ ├── app-context.tsx (061c366, 1198L, 29675ch, 30909B) │ │ ├── app.tsx (e0768a4, 401L, 11536ch, 11592B) │ │ ├── focus.tsx (342fe30, 250L, 5134ch, 5134B) │ │ ├── keyboard.tsx (5d1144b, 401L, 9159ch, 9161B) @@ -770,7 +780,7 @@ atomic_version: 3.0.0 │ ├── compute.ts (c7b828a, 46L, 1233ch, 1233B) │ └── connection.ts (128339f, 241L, 5955ch, 5957B) ├── tests/ (11) -│ ├── cli/ (22) +│ ├── cli/ (25) │ │ ├── ci/ (4) │ │ │ ├── identity-enroll.test.ts (daea467, 75L, 2166ch, 2168B) │ │ │ ├── identity-new.test.ts (2462d0f, 88L, 2587ch, 2587B) @@ -778,14 +788,19 @@ atomic_version: 3.0.0 │ │ │ └── secrets.test.ts (53fa24b, 216L, 6180ch, 6180B) │ │ ├── components/ (7) │ │ │ ├── DismissableAlert.test.tsx (ad0c85f, 340L, 9391ch, 9391B) -│ │ │ ├── dialogs.test.tsx (47f67ce, 274L, 7546ch, 7550B) +│ │ │ ├── dialogs.test.tsx (67db743, 343L, 9979ch, 9983B) │ │ │ ├── form-navigation.test.tsx (7f6d5a4, 175L, 4812ch, 4816B) │ │ │ ├── forms.test.tsx (d67738d, 171L, 4982ch, 4990B) │ │ │ ├── layout.test.tsx (811eaec, 110L, 2675ch, 2683B) │ │ │ ├── lists.test.tsx (3807cc8, 220L, 6656ch, 6670B) │ │ │ └── status.test.tsx (345fc65, 191L, 5245ch, 5245B) +│ │ ├── config/ (2) +│ │ │ ├── import.test.ts (8d92923, 169L, 5938ch, 5948B) +│ │ │ └── list.test.ts (745c3f4, 136L, 4211ch, 4215B) +│ │ ├── db/ (1) +│ │ │ └── drop.test.ts (b86f15d, 178L, 5982ch, 5992B) │ │ ├── hooks/ (3) -│ │ │ ├── useObserver.test.tsx (58bdf47, 496L, 12728ch, 12730B) +│ │ │ ├── useObserver.test.tsx (aa57ff1, 530L, 13868ch, 13872B) │ │ │ ├── useTransferProgress.test.tsx (5267f2e, 322L, 10736ch, 10744B) │ │ │ └── useUpdateChecker.test.tsx (3073b2c, 283L, 8301ch, 8301B) │ │ ├── run/ (9) @@ -805,6 +820,7 @@ atomic_version: 3.0.0 │ │ ├── change-edit.test.ts (8125b19, 103L, 2985ch, 2985B) │ │ ├── change-prompts.test.ts (9be33db, 107L, 2822ch, 2822B) │ │ ├── citty-help.test.ts (a81fe18, 61L, 1570ch, 1570B) +│ │ ├── config-validation.test.ts (7abf580, 33L, 1092ch, 1092B) │ │ ├── debug-pid.test.tsx (d07de2e, 11L, 188ch, 188B) │ │ ├── env-bootstrap.test.ts (048bf9c, 75L, 2314ch, 2314B) │ │ ├── focus.test.tsx (77608ce, 662L, 15713ch, 15713B) @@ -817,22 +833,21 @@ atomic_version: 3.0.0 │ │ ├── sql-repl.test.ts (37b9c5a, 32L, 864ch, 864B) │ │ ├── types.test.ts (c9a86fb, 136L, 4786ch, 4786B) │ │ └── yes-flag.test.ts (768cd0f, 476L, 12490ch, 12492B) -│ ├── core/ (25) +│ ├── core/ (26) │ │ ├── change/ (5) │ │ │ ├── fixtures/ (0 files, 3 dirs) -│ │ │ ├── executor.test.ts (e0369c0, 342L, 12372ch, 12376B) +│ │ │ ├── executor.test.ts (0364934, 388L, 14082ch, 14086B) │ │ │ ├── parser.test.ts (3ce9b9e, 394L, 12279ch, 12279B) │ │ │ ├── scaffold.test.ts (c8b3e10, 364L, 10559ch, 10559B) │ │ │ └── types.test.ts (6dac816, 150L, 4669ch, 4669B) -│ │ ├── config/ (5) +│ │ ├── config/ (4) │ │ │ ├── debug-process.test.ts (48b3b6d, 12L, 300ch, 300B) │ │ │ ├── env.test.ts (7c8ba98, 406L, 10934ch, 10934B) -│ │ │ ├── protection.test.ts (788faec, 232L, 6612ch, 6612B) -│ │ │ ├── resolver.test.ts (b24941d, 744L, 21240ch, 21240B) -│ │ │ └── schema.test.ts (5e937d9, 326L, 8795ch, 8795B) +│ │ │ ├── resolver.test.ts (f440ae4, 830L, 24768ch, 24770B) +│ │ │ └── schema.test.ts (fa74209, 402L, 11508ch, 11508B) │ │ ├── connection/ (2) │ │ │ ├── factory.test.ts (d4ef387, 140L, 3916ch, 3916B) -│ │ │ └── manager.test.ts (6d87552, 192L, 5424ch, 5424B) +│ │ │ └── manager.test.ts (7620812, 192L, 5447ch, 5447B) │ │ ├── dt/ (13) │ │ │ ├── crypto.test.ts (396a0f4, 162L, 4548ch, 4548B) │ │ │ ├── deserialize.test.ts (f86c304, 309L, 10375ch, 10375B) @@ -852,7 +867,7 @@ atomic_version: 3.0.0 │ │ │ └── operations.test.ts (64e9a5f, 589L, 16355ch, 16371B) │ │ ├── identity/ (7) │ │ │ ├── crypto.test.ts (8801b11, 311L, 8701ch, 8701B) -│ │ │ ├── env.test.ts (2e40161, 136L, 4113ch, 4113B) +│ │ │ ├── env.test.ts (a4f0c52, 146L, 4559ch, 4561B) │ │ │ ├── factory.test.ts (61019f8, 348L, 10115ch, 10115B) │ │ │ ├── hash.test.ts (4ccae4e, 226L, 5558ch, 5584B) │ │ │ ├── overrides.test.ts (1a4a1c5, 100L, 2374ch, 2374B) @@ -877,36 +892,41 @@ atomic_version: 3.0.0 │ │ │ └── rotation.test.ts (2aad64b, 360L, 9943ch, 9943B) │ │ ├── mcp/ (2) │ │ │ ├── init.test.ts (9cb2913, 70L, 2197ch, 2197B) -│ │ │ └── server.test.ts (98bb227, 400L, 11152ch, 11552B) -│ │ ├── rpc/ (5) -│ │ │ ├── commands.test.ts (e512c37, 456L, 14464ch, 14466B) -│ │ │ ├── protection.test.ts (a9c8015, 188L, 4790ch, 4790B) +│ │ │ └── server.test.ts (3f621da, 558L, 15851ch, 16255B) +│ │ ├── policy/ (2) +│ │ │ ├── check.test.ts (19551f1, 270L, 8111ch, 8111B) +│ │ │ └── classify.test.ts (4006bc7, 446L, 13890ch, 13902B) +│ │ ├── rpc/ (7) +│ │ │ ├── commands.test.ts (a10d74f, 480L, 16121ch, 16125B) +│ │ │ ├── list-configs.test.ts (5ba9331, 115L, 3592ch, 3598B) +│ │ │ ├── permissions.test.ts (e69d7c7, 57L, 1719ch, 1721B) │ │ │ ├── registry-integration.test.ts (ce3964c, 230L, 6153ch, 6159B) -│ │ │ ├── registry.test.ts (6cbc997, 105L, 2893ch, 2893B) -│ │ │ └── session.test.ts (83a9eea, 50L, 1007ch, 1007B) +│ │ │ ├── registry.test.ts (f674271, 109L, 3041ch, 3041B) +│ │ │ ├── session-not-found.test.ts (f1df070, 76L, 2649ch, 2651B) +│ │ │ └── session.test.ts (2fe1518, 194L, 5712ch, 5712B) │ │ ├── runner/ (4) │ │ │ ├── fixtures/ (6 files, 0 dirs) │ │ │ ├── checksum.test.ts (16c5853, 151L, 4092ch, 4092B) -│ │ │ ├── mssql-batches.test.ts (4d6ccef, 283L, 8017ch, 8023B) -│ │ │ └── runner.test.ts (4ba2300, 151L, 4724ch, 4724B) +│ │ │ ├── mssql-batches.test.ts (3587bb5, 285L, 8091ch, 8097B) +│ │ │ └── runner.test.ts (fea8194, 201L, 6199ch, 6201B) │ │ ├── settings/ (5) │ │ │ ├── env-override.test.ts (a3300c6, 119L, 3276ch, 3276B) -│ │ │ ├── manager.test.ts (4f1c37b, 1144L, 25776ch, 25776B) -│ │ │ ├── rules.test.ts (de167f9, 416L, 13184ch, 13184B) +│ │ │ ├── manager.test.ts (3b80659, 1122L, 25257ch, 25257B) +│ │ │ ├── rules.test.ts (cf7cfe6, 416L, 13281ch, 13281B) │ │ │ ├── schema.test.ts (5e7baac, 517L, 13184ch, 13184B) │ │ │ └── setTeardown.test.ts (c2d7f2b, 84L, 2603ch, 2603B) │ │ ├── shared/ (2) │ │ │ ├── errors.test.ts (e78da77, 279L, 7975ch, 9571B) │ │ │ └── tables.test.ts (b53c90b, 110L, 3272ch, 3272B) │ │ ├── sql-terminal/ (2) -│ │ │ ├── executor.test.ts (00d1c88, 396L, 13321ch, 13321B) +│ │ │ ├── executor.test.ts (674c979, 480L, 16869ch, 16875B) │ │ │ └── history.test.ts (0db8ae6, 1013L, 34309ch, 34309B) │ │ ├── state/ (2) │ │ │ ├── encryption/ (1 file, 0 dirs) -│ │ │ └── manager.test.ts (47119ac, 664L, 20958ch, 22666B) +│ │ │ └── manager.test.ts (6b17f57, 894L, 29892ch, 31846B) │ │ ├── teardown/ (2) │ │ │ ├── dialects/ (4 files, 0 dirs) -│ │ │ └── operations.test.ts (9e8b400, 609L, 20381ch, 21875B) +│ │ │ └── operations.test.ts (dfc1c9e, 699L, 23788ch, 25528B) │ │ ├── template/ (6) │ │ │ ├── fixtures/ (0 files, 4 dirs) │ │ │ ├── engine.test.ts (f3f1e18, 388L, 11697ch, 11697B) @@ -914,11 +934,12 @@ atomic_version: 3.0.0 │ │ │ ├── loaders.test.ts (115cc74, 257L, 6369ch, 6369B) │ │ │ ├── security.test.ts (82855d4, 237L, 7609ch, 7609B) │ │ │ └── utils.test.ts (d54a7a5, 165L, 3442ch, 3442B) -│ │ ├── transfer/ (5) +│ │ ├── transfer/ (6) │ │ │ ├── dialects/ (4 files, 0 dirs) │ │ │ ├── events.test.ts (ab6d02e, 364L, 10614ch, 10614B) │ │ │ ├── executor.test.ts (602b084, 420L, 13400ch, 13400B) │ │ │ ├── planner.test.ts (a81c2b6, 341L, 10240ch, 10240B) +│ │ │ ├── policy-gate.test.ts (d7abf8f, 60L, 2425ch, 2429B) │ │ │ └── same-server.test.ts (cf2bf8d, 369L, 10550ch, 10550B) │ │ ├── update/ (3) │ │ │ ├── checker.test.ts (4db209f, 191L, 5313ch, 5313B) @@ -930,8 +951,8 @@ atomic_version: 3.0.0 │ │ │ ├── manager.test.ts (8ff56f8, 319L, 11020ch, 11020B) │ │ │ ├── schema.test.ts (9fc95bd, 658L, 20341ch, 20341B) │ │ │ ├── settings.test.ts (4681701, 294L, 7728ch, 7728B) -│ │ │ ├── state.test.ts (17e78f0, 333L, 8663ch, 8663B) -│ │ │ └── types.test.ts (9892c26, 125L, 3021ch, 3021B) +│ │ │ ├── state.test.ts (28be6b2, 474L, 13484ch, 13484B) +│ │ │ └── types.test.ts (5f0f71f, 125L, 3021ch, 3021B) │ │ ├── worker-bridge/ (3) │ │ │ ├── bridge.test.ts (a6225e4, 63L, 1725ch, 1725B) │ │ │ ├── order-buffer.test.ts (182cbed, 85L, 1988ch, 1992B) @@ -969,19 +990,20 @@ atomic_version: 3.0.0 │ │ │ ├── mssql.test.ts (36695d6, 114L, 3625ch, 3629B) │ │ │ └── postgres.test.ts (1638202, 113L, 3593ch, 3595B) │ │ ├── runner/ (1) -│ │ │ └── mssql-batches.test.ts (1f41720, 264L, 7895ch, 7897B) -│ │ ├── sdk/ (2) -│ │ │ ├── tvf.test.ts (5d4f3a2, 279L, 7496ch, 8228B) +│ │ │ └── mssql-batches.test.ts (395ef9c, 268L, 8059ch, 8061B) +│ │ ├── sdk/ (3) +│ │ │ ├── db-reset.test.ts (a77c170, 117L, 3630ch, 3634B) +│ │ │ ├── tvf.test.ts (730359b, 279L, 7502ch, 8234B) │ │ │ └── tvp.test.ts (61c50ec, 599L, 17433ch, 19149B) │ │ ├── sql-terminal/ (4) -│ │ │ ├── mssql.test.ts (9352a97, 776L, 22700ch, 22700B) -│ │ │ ├── mysql.test.ts (b493743, 658L, 18368ch, 18368B) -│ │ │ ├── postgres.test.ts (a7137f8, 942L, 28694ch, 28694B) -│ │ │ └── sqlite.test.ts (f1cd0ad, 768L, 22000ch, 22000B) +│ │ │ ├── mssql.test.ts (1c242a3, 776L, 23168ch, 23168B) +│ │ │ ├── mysql.test.ts (19a7a01, 658L, 18773ch, 18773B) +│ │ │ ├── postgres.test.ts (a32d0a8, 942L, 29279ch, 29279B) +│ │ │ └── sqlite.test.ts (4ea272e, 768L, 22486ch, 22486B) │ │ ├── teardown/ (5) -│ │ │ ├── mssql.test.ts (63e885e, 598L, 21330ch, 21582B) +│ │ │ ├── mssql.test.ts (baa46aa, 641L, 22999ch, 23253B) │ │ │ ├── mysql.test.ts (730f424, 400L, 12896ch, 12896B) -│ │ │ ├── postgres.test.ts (629207f, 403L, 14664ch, 14664B) +│ │ │ ├── postgres.test.ts (399902f, 453L, 16737ch, 16981B) │ │ │ ├── sdk-preserve.test.ts (8d2711e, 177L, 5673ch, 6601B) │ │ │ └── sqlite.test.ts (04aa3c8, 396L, 13541ch, 13541B) │ │ ├── transfer/ (3) @@ -993,19 +1015,19 @@ atomic_version: 3.0.0 │ ├── sdk/ (9) │ │ ├── impersonate/ (3) │ │ │ ├── dialect-strategy.test.ts (d630381, 146L, 3515ch, 4491B) -│ │ │ ├── impersonate.test.ts (da5bf5f, 297L, 7604ch, 8580B) +│ │ │ ├── impersonate.test.ts (339dd61, 297L, 7627ch, 8603B) │ │ │ └── scope.test.ts (b9d359a, 122L, 3310ch, 3798B) -│ │ ├── bundle-smoke.test.ts (5a63bf9, 319L, 8866ch, 10820B) -│ │ ├── context.test.ts (4804a5c, 597L, 18663ch, 19407B) -│ │ ├── db-namespace.test.ts (4994ef5, 259L, 7784ch, 8704B) -│ │ ├── destructive-ops.test.ts (36308b1, 196L, 5828ch, 7386B) -│ │ ├── guards.test.ts (585e35c, 178L, 4768ch, 4768B) +│ │ ├── bundle-smoke.test.ts (66dd1dd, 319L, 8912ch, 10866B) +│ │ ├── context.test.ts (341f429, 597L, 18688ch, 19432B) +│ │ ├── db-namespace.test.ts (e7fb04c, 259L, 7807ch, 8727B) +│ │ ├── destructive-ops.test.ts (4f030f6, 471L, 15487ch, 17903B) +│ │ ├── guards.test.ts (01e310e, 318L, 9872ch, 9874B) │ │ ├── lifecycle.test.ts (371af51, 126L, 3617ch, 4353B) -│ │ ├── noorm-ops.test.ts (0564bc8, 321L, 8629ch, 9801B) +│ │ ├── noorm-ops.test.ts (bea2339, 321L, 8652ch, 9824B) │ │ └── sql.test.ts (959c16c, 1035L, 32675ch, 34387B) │ ├── utils/ (3) │ │ ├── db-splitter.test.ts (4db513c, 280L, 8506ch, 8506B) -│ │ ├── db.ts (0dfa608, 851L, 23959ch, 23969B) +│ │ ├── db.ts (a3d4ce9, 851L, 23982ch, 23992B) │ │ └── mssql-batches.test.ts (551221c, 270L, 7309ch, 7313B) │ ├── workers/ (2) │ │ ├── compute.test.ts (66855f3, 72L, 2003ch, 2003B) @@ -1014,11 +1036,11 @@ atomic_version: 3.0.0 │ ├── global-teardown.ts (1cce0b3, 39L, 936ch, 939B) │ ├── preload.ts (86e4ebc, 100L, 2645ch, 2649B) │ └── sample.env (fd7767a, 26L, 664ch, 664B) -├── .gitignore (48047e2, 48L, 493ch, 493B) +├── .gitignore (e2ac795, 49L, 516ch, 516B) ├── .npmrc (60376c8, 1L, 36ch, 36B) ├── .prettierignore (e3b0c44, 0L, 0ch, 0B) ├── .signalsignore (b0287a5, 17L, 662ch, 674B) -├── CLAUDE.md (f460842, 201L, 8300ch, 8506B) +├── CLAUDE.md (c380a2c, 201L, 8292ch, 8498B) ├── CNAME (f3bed50, 1L, 9ch, 9B) ├── README.md (6bbc797, 84L, 2482ch, 2502B) ├── TODO.md (6a63836, 375L, 21659ch, 21929B) @@ -1038,22 +1060,22 @@ atomic_version: 3.0.0 ## Manifests - docs/package.json: name=@noormdev/docs, scripts=[build, dev, preview] -- examples/llm-memory-db-mssql/package.json: name=@noormdev/example-llm-memory-db-mssql, version=0.0.1-alpha.1, scripts=[test, test:watch, typecheck] -- examples/llm-memory-db-pg/package.json: name=@noormdev/example-llm-memory-db-pg, version=0.0.1-alpha.1, scripts=[test, test:watch, typecheck] -- examples/todo-db/package.json: name=@noormdev/example-todo-db, version=0.0.1-alpha.1, scripts=[test, test:watch, typecheck] +- examples/llm-memory-db-mssql/package.json: name=@noormdev/example-llm-memory-db-mssql, version=0.0.1-alpha.2, scripts=[test, test:watch, typecheck] +- examples/llm-memory-db-pg/package.json: name=@noormdev/example-llm-memory-db-pg, version=0.0.1-alpha.2, scripts=[test, test:watch, typecheck] +- examples/todo-db/package.json: name=@noormdev/example-todo-db, version=0.0.1-alpha.2, scripts=[test, test:watch, typecheck] - package.json: name=@noormdev/main, version=0.0.1, scripts=[build, build:binary, build:packages, changeset, clean, dev, lint, lint:fix, prepublishOnly, release, start, start:init, test, test:coverage, test:watch, typecheck, typecheck:tests, version] -- packages/cli/package.json: name=@noormdev/cli, version=1.0.0-alpha.35, scripts=[postinstall] -- packages/sdk/package.json: name=@noormdev/sdk, version=1.0.0-alpha.35 +- packages/cli/package.json: name=@noormdev/cli, version=1.0.0-alpha.36, scripts=[postinstall] +- packages/sdk/package.json: name=@noormdev/sdk, version=1.0.0-alpha.36 ## Languages -- TypeScript: 199535 LOC (80%), 872 files (75%) -- Markdown: 42464 LOC (17%), 186 files (16%) +- TypeScript: 204246 LOC (80%), 886 files (75%) +- Markdown: 43000 LOC (17%), 195 files (16%) - YAML: 1114 LOC (0%), 16 files (1%) - JavaScript: 1005 LOC (0%), 22 files (1%) - HTML: 955 LOC (0%), 26 files (2%) - CSS: 913 LOC (0%), 3 files (0%) - Shell: 726 LOC (0%), 4 files (0%) -- JSON: 549 LOC (0%), 23 files (1%) +- JSON: 555 LOC (0%), 23 files (1%) - Vue: 181 LOC (0%), 3 files (0%) - TOML: 10 LOC (0%), 2 files (0%) diff --git a/docs/wiki/sdk.md b/docs/wiki/sdk.md index d1acfca9..f2505b4e 100644 --- a/docs/wiki/sdk.md +++ b/docs/wiki/sdk.md @@ -6,57 +6,58 @@ type: Domain ## What it does -Programmatic API for noorm-managed databases. `createContext` returns a `Context` object with a Kysely instance plus namespaced noorm operations (run, changes, db, dt, lock, vault, transfer, templates, secrets, utils). Published as `@noormdev/sdk` from `packages/sdk/`. +Programmatic API for noorm-managed databases. `createContext` returns a `Context` object with a Kysely instance plus namespaced noorm operations (run, changes, db, dt, lock, vault, transfer, templates, secrets, utils). Published as `@noormdev/sdk` from [`packages/sdk/`](../../packages/sdk). Also includes the DT (Data Transfer format) module for typed binary serialization of database rows — separate from the `transfer` domain. DT produces `.dt` files with a universal type system. ## Artifacts -- `packages/sdk/package.json` — published package `@noormdev/sdk`, version `1.0.0-alpha.35` -- `packages/sdk/CHANGELOG.md` — SDK release history +- [`packages/sdk/package.json`](../../packages/sdk/package.json) — published package `@noormdev/sdk`, version `1.0.0-alpha.35` +- [`packages/sdk/CHANGELOG.md`](../../packages/sdk/CHANGELOG.md) — SDK release history ## CLI code -- `src/sdk/index.ts` — `createContext` factory; resolves config, initializes state, returns `Context` -- `src/sdk/context.ts` — `Context` class; holds Kysely instance, all namespaced ops, connect/disconnect -- `src/sdk/namespaces/run.ts` — `RunNamespace`; wraps `runFile`, `runDir`, `runBuild`, `preview` -- `src/sdk/namespaces/changes.ts` — `ChangesNamespace`; wraps `ChangeManager` for ff/run/revert/list -- `src/sdk/namespaces/db.ts` — `DbNamespace`; explore, create, drop, teardown, truncate, reset -- `src/sdk/namespaces/dt.ts` — `DtNamespace`; export/import `.dt` files -- `src/sdk/namespaces/lock.ts` — `LockNamespace`; acquire/release/force-release/status -- `src/sdk/namespaces/vault.ts` — `VaultNamespace`; init, get/set/remove secrets, propagate, copy key -- `src/sdk/namespaces/transfer.ts` — `TransferNamespace`; wraps `transferData` -- `src/sdk/namespaces/templates.ts` — `TemplatesNamespace`; render, process file/files -- `src/sdk/namespaces/secrets.ts` — `SecretsNamespace`; stage-level secret resolution -- `src/sdk/namespaces/utils.ts` — `UtilsNamespace`; Kysely sql tag, connection ping -- `src/sdk/impersonate/scope.ts` — `ImpersonateScope`; run operations as a different identity -- `src/sdk/impersonate/dialect-strategy.ts` — per-dialect identity-column handling for impersonation -- `src/sdk/sql.ts` — `createSqlHelper`; typed SQL tag builder wrapping Kysely's `sql` -- `src/sdk/tvp.ts` — `createTvp`, `TvpBuilder`; MSSQL table-valued parameter construction -- `src/sdk/noorm-ops.ts` — `NoormOps`; assembled namespace object attached to `ctx.noorm` -- `src/sdk/guards.ts` — `checkRequireTest`; prevents SDK use in production without explicit opt-in -- `src/sdk/types.ts` — `CreateContextOptions`, `ContextConfig`, SDK-level types -- `src/core/dt/index.ts` — DT module: `exportTable`, `importTable`, serialize/deserialize, versioning, crypto -- `src/core/dt/dialects/` — per-dialect type mapping for DT -- `src/core/dt/type-map.ts` — `SimpleType` vs `EncodedType` classification; `text` type uses gz64 compression -- `src/core/dt/schema.ts` — DT file schema validation +- [`src/sdk/index.ts`](../../src/sdk/index.ts) — `createContext` factory; resolves config, initializes state, returns `Context` +- [`src/sdk/context.ts`](../../src/sdk/context.ts) — `Context` class; holds Kysely instance, all namespaced ops, connect/disconnect +- [`src/sdk/namespaces/run.ts`](../../src/sdk/namespaces/run.ts) — `RunNamespace`; wraps `runFile`, `runDir`, `runBuild`, `preview` +- [`src/sdk/namespaces/changes.ts`](../../src/sdk/namespaces/changes.ts) — `ChangesNamespace`; wraps `ChangeManager` for ff/run/revert/list +- [`src/sdk/namespaces/db.ts`](../../src/sdk/namespaces/db.ts) — `DbNamespace`; explore, create, drop, teardown, truncate, reset +- [`src/sdk/namespaces/dt.ts`](../../src/sdk/namespaces/dt.ts) — `DtNamespace`; export/import `.dt` files +- [`src/sdk/namespaces/lock.ts`](../../src/sdk/namespaces/lock.ts) — `LockNamespace`; acquire/release/force-release/status +- [`src/sdk/namespaces/vault.ts`](../../src/sdk/namespaces/vault.ts) — `VaultNamespace`; init, get/set/remove secrets, propagate, copy key +- [`src/sdk/namespaces/transfer.ts`](../../src/sdk/namespaces/transfer.ts) — `TransferNamespace`; wraps `transferData` +- [`src/sdk/namespaces/templates.ts`](../../src/sdk/namespaces/templates.ts) — `TemplatesNamespace`; render, process file/files +- [`src/sdk/namespaces/secrets.ts`](../../src/sdk/namespaces/secrets.ts) — `SecretsNamespace`; stage-level secret resolution +- [`src/sdk/namespaces/utils.ts`](../../src/sdk/namespaces/utils.ts) — `UtilsNamespace`; Kysely sql tag, connection ping +- [`src/sdk/impersonate/scope.ts`](../../src/sdk/impersonate/scope.ts) — `ImpersonateScope`; run operations as a different identity +- [`src/sdk/impersonate/dialect-strategy.ts`](../../src/sdk/impersonate/dialect-strategy.ts) — per-dialect identity-column handling for impersonation +- [`src/sdk/sql.ts`](../../src/sdk/sql.ts) — `createSqlHelper`; typed SQL tag builder wrapping Kysely's `sql` +- [`src/sdk/tvp.ts`](../../src/sdk/tvp.ts) — `createTvp`, `TvpBuilder`; MSSQL table-valued parameter construction +- [`src/sdk/noorm-ops.ts`](../../src/sdk/noorm-ops.ts) — `NoormOps`; assembled namespace object attached to `ctx.noorm` +- [`src/sdk/guards.ts`](../../src/sdk/guards.ts) — `checkRequireTest`; prevents SDK use in production without explicit opt-in. `checkProtectedConfig` now runs `checkConfigPolicy` (`core/policy`) and throws `ProtectedConfigError` on denial or on a `confirm` cell (the SDK has no interactive prompt) +- [`src/sdk/types.ts`](../../src/sdk/types.ts) — `CreateContextOptions` (carries `channel?: Channel`, default `'user'`), `ContextConfig`, SDK-level types +- [`src/core/dt/index.ts`](../../src/core/dt/index.ts) — DT module: `exportTable`, `importTable`, serialize/deserialize, versioning, crypto +- [`src/core/dt/dialects/`](../../src/core/dt/dialects) — per-dialect type mapping for DT +- [`src/core/dt/type-map.ts`](../../src/core/dt/type-map.ts) — `SimpleType` vs `EncodedType` classification; `text` type uses gz64 compression +- [`src/core/dt/schema.ts`](../../src/core/dt/schema.ts) — DT file schema validation ## Docs -- `docs/dev/sdk.md` — SDK internals reference (1094L) -- `docs/reference/sdk.md` — public SDK API reference (1426L) -- `docs/dev/transfer.md` — DT transfer internals -- `docs/getting-started/building-your-sdk.md` — getting-started guide for SDK users -- `skills/noorm/references/sdk.md` — skill reference for SDK usage patterns +- [`docs/dev/sdk.md`](../dev/sdk.md) — SDK internals reference (1094L) +- [`docs/reference/sdk.md`](../reference/sdk.md) — public SDK API reference (1426L) +- [`docs/dev/transfer.md`](../dev/transfer.md) — DT transfer internals +- [`docs/getting-started/building-your-sdk.md`](../getting-started/building-your-sdk.md) — getting-started guide for SDK users +- [`skills/noorm/references/sdk.md`](../../skills/noorm/references/sdk.md) — skill reference for SDK usage patterns ## Coupling -- `createContext` calls `initProjectContext` from `src/core/project-init.ts` — same startup sequence as CLI. +- `createContext` calls `initProjectContext` from [`src/core/project-init.ts`](../../src/core/project-init.ts) — same startup sequence as CLI. +- `createContext` defaults `options.channel` to `'user'` and re-exports `Channel`/`ConfigAccess`/`Role` from [`src/core/policy/`](../../src/core/policy) — every guard in [`src/sdk/guards.ts`](../../src/sdk/guards.ts) reads `state.options.channel` for its `checkConfigPolicy` call. - All namespaces delegate to core modules — any core API change propagates to the namespace wrappers. -- DT module (`src/core/dt/`) is co-owned: SDK exposes it via `ctx.noorm.dt`, but it is also used standalone by `src/cli/db/transfer.ts`. -- Impersonation (`src/sdk/impersonate/`) requires the identity domain keypair loaded. -- TVP (`src/sdk/tvp.ts`) is MSSQL-only — dialect guard at construction time. -- Published package build defined in `tsup.sdk.config.ts`; types extracted via `@microsoft/api-extractor` + `dts-bundle-generator`. +- DT module ([`src/core/dt/`](../../src/core/dt)) is co-owned: SDK exposes it via `ctx.noorm.dt`, but it is also used standalone by [`src/cli/db/transfer.ts`](../../src/cli/db/transfer.ts). +- Impersonation ([`src/sdk/impersonate/`](../../src/sdk/impersonate)) requires the identity domain keypair loaded. +- TVP ([`src/sdk/tvp.ts`](../../src/sdk/tvp.ts)) is MSSQL-only — dialect guard at construction time. +- Published package build defined in [`tsup.sdk.config.ts`](../../tsup.sdk.config.ts); types extracted via `@microsoft/api-extractor` + `dts-bundle-generator`. ## Conventions worth knowing @@ -65,4 +66,5 @@ Also includes the DT (Data Transfer format) module for typed binary serializatio - `ctx.noorm` is the noorm namespace (changes, run, db, dt, lock, vault, transfer, templates, secrets, utils). - DT `text` type uses gz64 compression for large TEXT columns; `string` is for short VARCHAR/CHAR. - `checkRequireTest` throws `RequireTestError` if `options.requireTest === true` but `config.isTest === false` — prevents accidental production use of test helpers. -- Integration tests in `tests/sdk/` and `tests/integration/sdk/` cover TVF and TVP patterns. +- `ProtectedConfigError` on a `confirm`-cell permission names `NOORM_YES=1` (scripted opt-in) or the CLI/TUI (interactive confirm) as the way through — the SDK itself never prompts. +- Integration tests in [`tests/sdk/`](../../tests/sdk) and [`tests/integration/sdk/`](../../tests/integration/sdk) cover TVF and TVP patterns. diff --git a/docs/wiki/tui.md b/docs/wiki/tui.md index a2d9db05..744ea6ee 100644 --- a/docs/wiki/tui.md +++ b/docs/wiki/tui.md @@ -10,50 +10,53 @@ Ink/React-based terminal UI launched by `noorm ui`. Full-screen interactive inte ## CLI code -- `src/tui/app.tsx` — root component; mounts `AppContext`, `ObserverContext`, focus/keyboard providers -- `src/tui/app-context.tsx` — `AppContext` (1196L); global state: active config, settings, lock status, update check, screen routing -- `src/tui/screens.tsx` — `ScreenRegistry`; maps screen IDs to components (664L) -- `src/tui/screens/home.tsx` — home screen; keyboard shortcuts for all domains (635L) -- `src/tui/router.tsx` — `Router`; screen stack push/pop navigation -- `src/tui/focus.tsx` — `FocusManager`; focus stack for nested interactive components -- `src/tui/keyboard.tsx` — `KeyboardManager`; global key event routing with priority stacking (401L) -- `src/tui/observer-context.ts` — `ObserverContext`; provides observer singleton to React tree -- `src/tui/shutdown.tsx` — graceful shutdown sequence with progress display -- `src/tui/types.ts` — TUI type contracts (Screen, ScreenProps, etc.) (470L) -- `src/tui/components/` — shared UI components: dialogs, feedback, forms, layout, lists, overlays, secrets, status, terminal -- `src/tui/hooks/` — 14 hooks: `useObserver`, `useConnection`, `useChangeProgress`, `useRunProgress`, `useTransferProgress`, `useLockStatus`, `useVaultConnection`, `useVaultSecretKeys`, `useSettingsOperation`, `useUpdateChecker`, `useSecretSource`, `useAsyncEffect`, `useLoadGuard` -- `src/tui/providers/ConnectionProvider.tsx` — `ConnectionProvider`; DB connection lifecycle for TUI screens -- `src/tui/utils/` — 12 utilities: path resolution, connection helpers, config validation, clipboard, change-loader -- `src/tui/screens/change/` — 12 change-related screens -- `src/tui/screens/config/` — 11 config screens -- `src/tui/screens/db/` — 11 DB screens + 1 subdir -- `src/tui/screens/debug/` — 4 debug screens -- `src/tui/screens/identity/` — 6 identity screens -- `src/tui/screens/init/` — 4 init wizard screens -- `src/tui/screens/lock/` — 6 lock screens -- `src/tui/screens/run/` — 7 run screens -- `src/tui/screens/secret/` — 4 secret screens -- `src/tui/screens/settings/` — 17 settings screens -- `src/tui/screens/vault/` — 5 vault screens -- `src/hooks/observer.ts` — `useObserver` hook (non-tui hooks barrel) -- `src/tui/screens/UpdateScreen.tsx` — update available prompt -- `src/tui/screens/MoreScreen.tsx` — extended help screen +- [`src/tui/app.tsx`](../../src/tui/app.tsx) — root component; mounts `AppContext`, `ObserverContext`, focus/keyboard providers +- [`src/tui/app-context.tsx`](../../src/tui/app-context.tsx) — `AppContext` (1196L); global state: active config, settings, lock status, update check, screen routing +- [`src/tui/screens.tsx`](../../src/tui/screens.tsx) — `ScreenRegistry`; maps screen IDs to components (664L) +- [`src/tui/screens/home.tsx`](../../src/tui/screens/home.tsx) — home screen; keyboard shortcuts for all domains (635L) +- [`src/tui/router.tsx`](../../src/tui/router.tsx) — `Router`; screen stack push/pop navigation +- [`src/tui/focus.tsx`](../../src/tui/focus.tsx) — `FocusManager`; focus stack for nested interactive components +- [`src/tui/keyboard.tsx`](../../src/tui/keyboard.tsx) — `KeyboardManager`; global key event routing with priority stacking (401L) +- [`src/tui/observer-context.ts`](../../src/tui/observer-context.ts) — `ObserverContext`; provides observer singleton to React tree +- [`src/tui/shutdown.tsx`](../../src/tui/shutdown.tsx) — graceful shutdown sequence with progress display +- [`src/tui/types.ts`](../../src/tui/types.ts) — TUI type contracts (Screen, ScreenProps, etc.) (470L) +- [`src/tui/components/`](../../src/tui/components) — shared UI components: dialogs, feedback, forms, layout, lists, overlays, secrets, status, terminal +- [`src/tui/hooks/`](../../src/tui/hooks) — 14 hooks: `useObserver`, `useConnection`, `useChangeProgress`, `useRunProgress`, `useTransferProgress`, `useLockStatus`, `useVaultConnection`, `useVaultSecretKeys`, `useSettingsOperation`, `useUpdateChecker`, `useSecretSource`, `useAsyncEffect`, `useLoadGuard` +- [`src/tui/providers/ConnectionProvider.tsx`](../../src/tui/providers/ConnectionProvider.tsx) — `ConnectionProvider`; DB connection lifecycle for TUI screens +- [`src/tui/utils/`](../../src/tui/utils) — 12 utilities: path resolution, connection helpers, config validation, clipboard, change-loader +- [`src/tui/screens/change/`](../../src/tui/screens/change) — 12 change-related screens +- [`src/tui/screens/config/`](../../src/tui/screens/config) — 11 config screens +- [`src/tui/screens/db/`](../../src/tui/screens/db) — 11 DB screens + 1 subdir +- [`src/tui/screens/debug/`](../../src/tui/screens/debug) — 4 debug screens +- [`src/tui/screens/identity/`](../../src/tui/screens/identity) — 6 identity screens +- [`src/tui/screens/init/`](../../src/tui/screens/init) — 4 init wizard screens +- [`src/tui/screens/lock/`](../../src/tui/screens/lock) — 6 lock screens +- [`src/tui/screens/run/`](../../src/tui/screens/run) — 7 run screens +- [`src/tui/screens/secret/`](../../src/tui/screens/secret) — 4 secret screens +- [`src/tui/screens/settings/`](../../src/tui/screens/settings) — 17 settings screens +- [`src/tui/screens/vault/`](../../src/tui/screens/vault) — 5 vault screens +- [`src/hooks/observer.ts`](../../src/hooks/observer.ts) — `useObserver` hook (non-tui hooks barrel) +- [`src/tui/screens/UpdateScreen.tsx`](../../src/tui/screens/UpdateScreen.tsx) — update available prompt +- [`src/tui/screens/MoreScreen.tsx`](../../src/tui/screens/MoreScreen.tsx) — extended help screen ## Docs -- `docs/tui.md` — TUI user guide (407L) -- `docs/dev/ink-cheatsheet.md` — Ink layout reference for developers (1427L) -- `docs/dev/ink-testing-library-cheatsheet.md` — testing cheatsheet (737L) -- `.claude/rules/tui-development.md` — TUI development rules (focus system, UI patterns, layout) -- `.claude/skills/noorm-design/` — design system assets and colors +- [`docs/tui.md`](../tui.md) — TUI user guide (407L) +- [`docs/dev/ink-cheatsheet.md`](../dev/ink-cheatsheet.md) — Ink layout reference for developers (1427L) +- [`docs/dev/ink-testing-library-cheatsheet.md`](../dev/ink-testing-library-cheatsheet.md) — testing cheatsheet (737L) +- [`.claude/rules/tui-development.md`](../../.claude/rules/tui-development.md) — TUI development rules (focus system, UI patterns, layout) +- [`.claude/skills/noorm-design/`](../../.claude/skills/noorm-design) — design system assets and colors ## Coupling -- All TUI screens consume observer events from `src/core/observer.ts` — observer event shape changes break TUI hooks. -- `useConnection` and `ConnectionProvider` use `src/core/connection/manager.ts` — connection manager resets affect TUI session. -- `src/tui/utils/paths.ts` uses `settings.paths.sql`/`settings.paths.changes` from SettingsManager — not per-config paths (see project CLAUDE.md). -- Lifecycle shutdown (`src/core/lifecycle/`) drives `src/tui/shutdown.tsx` — shutdown phase changes affect TUI teardown. -- TUI is launched by `src/cli/ui.ts` — CLI dependency. +- All TUI screens consume observer events from [`src/core/observer.ts`](../../src/core/observer.ts) — observer event shape changes break TUI hooks. +- `useConnection` and `ConnectionProvider` use [`src/core/connection/manager.ts`](../../src/core/connection/manager.ts) — connection manager resets affect TUI session. +- [`src/tui/utils/paths.ts`](../../src/tui/utils/paths.ts) uses `settings.paths.sql`/`settings.paths.changes` from SettingsManager — not per-config paths (see project CLAUDE.md). +- Lifecycle shutdown ([`src/core/lifecycle/`](../../src/core/lifecycle)) drives [`src/tui/shutdown.tsx`](../../src/tui/shutdown.tsx) — shutdown phase changes affect TUI teardown. +- TUI is launched by [`src/cli/ui.ts`](../../src/cli/ui.ts) — CLI dependency. +- `SmartConfirm`/`ProtectedConfirm` ([`src/tui/components/dialogs/`](../../src/tui/components/dialogs)) take `requiresConfirmation`/`confirmationPhrase` from a `PolicyCheck` (`checkConfigPolicy`, `core/policy`) instead of a config's `protected` flag — every destructive-action screen (change run/revert/ff, db create/destroy/teardown/truncate, config rm) calls `checkConfigPolicy` directly to build these props. +- [`src/tui/utils/config-validation.ts`](../../src/tui/utils/config-validation.ts) builds `ConfigAccess` from the Add/Edit config forms' `userRole`/`mcpRole` select fields (`buildAccessFromValues`) — `ConfigAddScreen`/`ConfigEditScreen` replaced the old single `protected` checkbox with these two role selects. +- [`src/tui/app-context.tsx`](../../src/tui/app-context.tsx) derives placeholder stage configs' `access` from `stage.defaults.protected` via `GUARDED_ACCESS`/`OPEN_ACCESS` (`core/policy`). ## Conventions worth knowing @@ -64,3 +67,4 @@ Ink/React-based terminal UI launched by `noorm ui`. Full-screen interactive inte - Home screen hotkeys: `c`=config, `g`=changes, `r`=run, `d`=db, `l`=lock, `s`=settings, `k`=secrets, `i`=identity, `q`=quit. - Sub-screen hotkeys: `a`=add, `e`=edit, `d`=delete, `x`=export, `i`=import, `u`=use/activate, `v`=validate, `k`=secrets. - `Shift+L` toggles log viewer overlay globally. +- `guarded()` (re-exported for the TUI as `isConfigGuarded` in [`src/tui/utils/config-validation.ts`](../../src/tui/utils/config-validation.ts)) is display-only styling, never an enforcement input — `checkConfigPolicy` is the only gate. diff --git a/docs/wiki/worker-bridge.md b/docs/wiki/worker-bridge.md index 5ad0eef7..abdc045b 100644 --- a/docs/wiki/worker-bridge.md +++ b/docs/wiki/worker-bridge.md @@ -10,25 +10,25 @@ Hub-and-spoke worker thread infrastructure. `WorkerBridge` (an `ObserverRelay` s ## CLI code -- `src/core/worker-bridge/bridge.ts` — `WorkerBridge`; ObserverRelay subclass, message correlation, error propagation -- `src/core/worker-bridge/pool.ts` — `WorkerPool`; round-robin N-worker dispatch -- `src/core/worker-bridge/order-buffer.ts` — `OrderBuffer`; index-ordered response reassembly -- `src/core/worker-bridge/paths.ts` — `resolveWorker`; path resolution for dev/compiled contexts -- `src/core/worker-bridge/types.ts` — `WireMessage`, `Correlated`, event contract types -- `src/workers/connection.ts` — persistent DB worker entry point; owns Kysely instance, handles all DB ops -- `src/workers/compute.ts` — stateless compute worker entry point; serialize/deserialize for DT pipeline +- [`src/core/worker-bridge/bridge.ts`](../../src/core/worker-bridge/bridge.ts) — `WorkerBridge`; ObserverRelay subclass, message correlation, error propagation +- [`src/core/worker-bridge/pool.ts`](../../src/core/worker-bridge/pool.ts) — `WorkerPool`; round-robin N-worker dispatch +- [`src/core/worker-bridge/order-buffer.ts`](../../src/core/worker-bridge/order-buffer.ts) — `OrderBuffer`; index-ordered response reassembly +- [`src/core/worker-bridge/paths.ts`](../../src/core/worker-bridge/paths.ts) — `resolveWorker`; path resolution for dev/compiled contexts +- [`src/core/worker-bridge/types.ts`](../../src/core/worker-bridge/types.ts) — `WireMessage`, `Correlated`, event contract types +- [`src/workers/connection.ts`](../../src/workers/connection.ts) — persistent DB worker entry point; owns Kysely instance, handles all DB ops +- [`src/workers/compute.ts`](../../src/workers/compute.ts) — stateless compute worker entry point; serialize/deserialize for DT pipeline ## Docs -- `docs/dev/README.md` — worker bridge architecture overview (section in monorepo dev guide) +- [`docs/dev/README.md`](../dev/README.md) — worker bridge architecture overview (section in monorepo dev guide) ## Coupling - `resolveWorker` is called wherever a worker is spawned — never hardcode worker paths. - `WorkerBridge` extends `ObserverRelay` from `@logosdx/observer` — observer domain is a dependency. -- DT module (`src/core/dt/`) spawns compute workers via `WorkerPool` — DT changes may require worker message-type updates. -- Connection worker (`src/workers/connection.ts`) holds the Kysely instance used by runner and change executor in worker contexts — worker restart resets all in-flight operations. -- Bun `--compile` binary path resolution: `src/workers/compute.ts` → `workers/compute.js` in `$bunfs` — the `IS_COMPILED` guard in `paths.ts` handles this. +- DT module ([`src/core/dt/`](../../src/core/dt)) spawns compute workers via `WorkerPool` — DT changes may require worker message-type updates. +- Connection worker ([`src/workers/connection.ts`](../../src/workers/connection.ts)) holds the Kysely instance used by runner and change executor in worker contexts — worker restart resets all in-flight operations. +- Bun `--compile` binary path resolution: [`src/workers/compute.ts`](../../src/workers/compute.ts) → `workers/compute.js` in `$bunfs` — the `IS_COMPILED` guard in `paths.ts` handles this. ## Conventions worth knowing diff --git a/skills/noorm/references/cli.md b/skills/noorm/references/cli.md index 01044530..2041316a 100644 --- a/skills/noorm/references/cli.md +++ b/skills/noorm/references/cli.md @@ -591,11 +591,10 @@ noorm -y db truncate ### db teardown -Drop all database objects. **Blocked on protected configs** unless `--force` is used. +Drop all database objects. Gated by the config's `db:reset` access role: `viewer` denied, `operator` must confirm (`--yes`/`NOORM_YES=1`), `admin` runs unconfirmed. No flag overrides a `viewer` denial. ```bash noorm -y db teardown -noorm --force db teardown # Override protection ``` ### db transfer @@ -788,7 +787,7 @@ noorm help change ff Two patterns to choose between: - **Test CI** — ephemeral database (spun up in the CI job), no vault needed. The minimum viable flow: set `NOORM_CONNECTION_*`, then `run build` + `change ff`. Use when your templates and changes do not reference vault-backed secrets. -- **Prod CI** — real database, vault-backed secrets. The runner needs an enrolled identity (via `ci identity enroll`, run once by a developer) and bootstraps state with `ci init`. Use when templates render secrets or the config is protected. +- **Prod CI** — real database, vault-backed secrets. The runner needs an enrolled identity (via `ci identity enroll`, run once by a developer) and bootstraps state with `ci init`. Use when templates render secrets or the config's `access.user` role isn't `admin`. ### Test CI (GitHub Actions) diff --git a/skills/noorm/references/config.md b/skills/noorm/references/config.md index 5ad66ba2..337263b4 100644 --- a/skills/noorm/references/config.md +++ b/skills/noorm/references/config.md @@ -77,7 +77,7 @@ stages: locked: true # Configs from this stage cannot be deleted defaults: dialect: postgres - protected: true # Cannot be overridden to false + protected: true # Access ceiling: clamps resolved access to at most operator/viewer isTest: false secrets: - key: DB_PASSWORD @@ -136,7 +136,7 @@ secrets: |---|---|---| | `description` | `string?` | Human-readable rule description | | `match.name` | `string?` | Exact config name match | -| `match.protected` | `boolean?` | Match protected configs | +| `match.protected` | `boolean?` | Matches `guarded(config)` (`access.user !== 'admin'`) — legacy field name, current semantics | | `match.isTest` | `boolean?` | Match test configs | | `match.type` | `'local' \| 'remote'?` | Match connection type | | `include` | `string[]?` | Folders to add when matched | @@ -158,7 +158,7 @@ All conditions within a rule are AND'd — every specified field must match. | `defaults.password` | `string?` | Password | | `defaults.ssl` | `boolean?` | Enable SSL | | `defaults.isTest` | `boolean?` | Test flag (if `true`, cannot be overridden to `false`) | -| `defaults.protected` | `boolean?` | Protection flag (if `true`, cannot be overridden to `false`) | +| `defaults.protected` | `boolean?` | If `true`, acts as an access ceiling: resolved config `access` is clamped to at most `{ user: 'operator', mcp: 'viewer' }` | | `secrets[]` | array | Secrets required for this stage | ### `secrets[]` (stage or universal) @@ -208,7 +208,10 @@ interface Config { name: string; // Unique identifier type: 'local' | 'remote'; // Connection type (default: 'local') isTest: boolean; // Test database flag (default: false) - protected: boolean; // Blocks destructive ops (default: false) + access: { // Per-channel access roles (replaces the legacy `protected` boolean) + user: 'viewer' | 'operator' | 'admin'; // CLI/TUI/SDK role (default: 'admin') + mcp: 'viewer' | 'operator' | 'admin' | false; // MCP role; `false` hides the config from MCP (default: 'admin') + }; connection: { dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql'; host?: string; // Required for non-SQLite @@ -234,7 +237,9 @@ interface Config { ## Stage Constraints -When a stage sets `defaults.protected: true` or `defaults.isTest: true`, configs created from that stage **cannot override those values to `false`**. This enforces safety invariants — a production stage stays protected, a test stage stays flagged as test. +`defaults.isTest: true` cannot be overridden to `false` for configs created from that stage. `defaults.protected: true` works differently: it is an access **ceiling** applied at config resolution — a config linked to that stage gets its resolved `access` clamped to at most `{ user: 'operator', mcp: 'viewer' }`, no matter what `access` the config itself declares. This enforces safety invariants — a production stage stays guarded, a test stage stays flagged as test. + +See `docs/spec/config-access-roles.md` for the full per-channel access model (`viewer`/`operator`/`admin` roles, the permission matrix, and MCP's `access.mcp: false` invisibility). ## Minimal Settings Example diff --git a/skills/noorm/references/sdk.md b/skills/noorm/references/sdk.md index 35141b2b..1cef8d0b 100644 --- a/skills/noorm/references/sdk.md +++ b/skills/noorm/references/sdk.md @@ -45,7 +45,7 @@ const ctx = await createContext(options); | `config` | `string` | — | Config name from stored state. Falls back to `NOORM_CONFIG` env, then env-only mode | | `projectRoot` | `string` | `process.cwd()` | Project root directory | | `requireTest` | `boolean` | `false` | Refuse if `config.isTest` is not `true`. Throws `RequireTestError` | -| `allowProtected` | `boolean` | `false` | Allow destructive ops on protected configs. Otherwise throws `ProtectedConfigError` | +| `channel` | `Channel` | `'user'` | Which caller channel this context represents for access-policy checks (`'user'` or `'mcp'`). Destructive ops throw `ProtectedConfigError` when the config's role for this channel denies or can't confirm the action | | `stage` | `string` | — | Stage name for stage defaults from `settings.yml` | ### Env-Only Mode (CI/CD) @@ -332,7 +332,7 @@ const tables = await ctx.noorm.db.listTables(); const detail = await ctx.noorm.db.describeTable('users'); const overview = await ctx.noorm.db.overview(); // Counts of all object types -// Destructive (guarded by config.protected — set allowProtected to override) +// Destructive (guarded by config.access[channel] via checkPolicy — throws ProtectedConfigError on deny/unconfirmable) await ctx.noorm.db.truncate(); // Wipe data, keep schema await ctx.noorm.db.teardown(); // Drop all objects await ctx.noorm.db.reset(); // teardown() + build() — full schema rebuild @@ -589,7 +589,7 @@ it('should apply changes safely', async () => { | Guard | Error Type | Purpose | |---|---|---| | `requireTest: true` | `RequireTestError` | Blocks non-test configs | -| `config.protected` | `ProtectedConfigError` | Blocks destructive ops unless `allowProtected: true` | +| `config.access[channel]` | `ProtectedConfigError` | Blocks destructive ops the role denies outright, or that resolve to "requires confirmation" (the SDK has no prompt — set `NOORM_YES=1`, or run via the CLI/TUI) | ### Error Types for Assertions diff --git a/src/cli/ci/init.ts b/src/cli/ci/init.ts index 4c67b2ca..b1936fef 100644 --- a/src/cli/ci/init.ts +++ b/src/cli/ci/init.ts @@ -21,6 +21,7 @@ import { setKeyOverride, setIdentityOverride } from '../../core/identity/storage import { getEnvConfig } from '../../core/config/index.js'; import type { Config } from '../../core/config/types.js'; import type { ConnectionConfig } from '../../core/connection/types.js'; +import { OPEN_ACCESS } from '../../core/policy/index.js'; import { initState } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; @@ -150,7 +151,7 @@ const initCommand = defineCommand({ name: configName, type: 'remote', isTest: true, - protected: false, + access: OPEN_ACCESS, connection, }; diff --git a/src/cli/config/import.ts b/src/cli/config/import.ts index 81ef5294..a94e2071 100644 --- a/src/cli/config/import.ts +++ b/src/cli/config/import.ts @@ -10,42 +10,10 @@ import { readFile } from 'node:fs/promises'; import { attempt, attemptSync } from '@logosdx/utils'; import { defineCommand } from 'citty'; -import type { Config } from '../../core/config/types.js'; +import { ConfigValidationError, parseConfig } from '../../core/config/schema.js'; import { initState, getStateManager } from '../../core/state/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; -/** - * Parse and validate a raw JSON value as a Config. - * - * Returns the typed Config when the required fields are present, - * or null when validation fails. Does not throw. - */ -function parseConfig(value: unknown): Config | null { - - if (!value || typeof value !== 'object') { - - return null; - - } - - const obj = value as Record; - - if (!obj['name'] || typeof obj['name'] !== 'string') { - - return null; - - } - - if (!obj['connection'] || typeof obj['connection'] !== 'object') { - - return null; - - } - - return obj as unknown as Config; - -} - const importCommand = defineCommand({ meta: { name: 'import', @@ -78,11 +46,15 @@ const importCommand = defineCommand({ } - const config = parseConfig(jsonValue); + const [config, configErr] = attemptSync(() => parseConfig(jsonValue)); + + if (configErr) { - if (!config) { + const message = configErr instanceof ConfigValidationError + ? configErr.message + : 'Config JSON is missing required fields: name, connection'; - outputError(args, 'Config JSON is missing required fields: name, connection'); + outputError(args, message); process.exit(1); } diff --git a/src/cli/config/list.ts b/src/cli/config/list.ts index d704064e..1dc2abed 100644 --- a/src/cli/config/list.ts +++ b/src/cli/config/list.ts @@ -8,6 +8,7 @@ import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; +import { guarded } from '../../core/policy/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; const listCommand = defineCommand({ @@ -44,9 +45,10 @@ const listCommand = defineCommand({ const lines = configs.map((c) => { const active = c.isActive ? ' (active)' : ''; + const accessTag = guarded(c) ? `user:${c.access.user} mcp:${c.access.mcp === false ? 'off' : c.access.mcp}` : null; const flags = [ c.isTest ? 'test' : null, - c.protected ? 'protected' : null, + accessTag, ].filter(Boolean).join(', '); const suffix = flags ? ` [${flags}]` : ''; diff --git a/src/cli/db/drop.ts b/src/cli/db/drop.ts index 9fc500ce..733182c0 100644 --- a/src/cli/db/drop.ts +++ b/src/cli/db/drop.ts @@ -2,13 +2,16 @@ * noorm db drop — drop the entire database. * * Destructive operation that drops the database associated with the - * active (or specified) configuration. Requires --yes flag for safety. + * active (or specified) configuration. Gated by the config's `db:destroy` + * access: viewer/operator are denied outright; admin requires --yes to + * satisfy the matrix's confirmation requirement. */ import { attempt } from '@logosdx/utils'; import { defineCommand } from 'citty'; import { initState, getStateManager } from '../../core/state/index.js'; import { destroyDb } from '../../core/db/index.js'; +import { checkConfigPolicy } from '../../core/policy/index.js'; import { outputResult, outputError, sharedArgs } from '../_utils.js'; const dropCommand = defineCommand({ @@ -23,13 +26,6 @@ const dropCommand = defineCommand({ }, async run({ args }) { - if (!args.yes) { - - outputError(args, 'This is a destructive operation. Pass --yes to confirm.'); - process.exit(1); - - } - const projectRoot = process.cwd(); const [, initErr] = await attempt(() => initState(projectRoot)); @@ -60,9 +56,21 @@ const dropCommand = defineCommand({ } - if (config.protected) { + const check = checkConfigPolicy('user', config, 'db:destroy'); + + if (!check.allowed) { + + outputError(args, check.blockedReason ?? `Config "${configName}" cannot be dropped.`); + process.exit(1); + + } + + if (check.requiresConfirmation && !args.yes) { - outputError(args, `Config "${configName}" is protected. Cannot drop protected databases.`); + outputError( + args, + `This is a destructive operation requiring confirmation (${check.confirmationPhrase}). Pass --yes to confirm.`, + ); process.exit(1); } diff --git a/src/cli/sql/query.ts b/src/cli/sql/query.ts index f67dbf01..071dc412 100644 --- a/src/cli/sql/query.ts +++ b/src/cli/sql/query.ts @@ -49,7 +49,11 @@ const sqlCommand = defineCommand({ const [result, error] = await withContext({ args, - fn: async (ctx) => executeRawSql(ctx.kysely as unknown as Kysely, query!, args.config ?? 'default'), + fn: async (ctx) => executeRawSql(ctx.kysely as unknown as Kysely, query!, ctx.noorm.config.name, { + access: ctx.noorm.config.access, + channel: 'user', + dialect: ctx.dialect, + }), }); if (error) process.exit(1); diff --git a/src/core/change/executor.ts b/src/core/change/executor.ts index 292ea1fb..646d35ce 100644 --- a/src/core/change/executor.ts +++ b/src/core/change/executor.ts @@ -29,6 +29,8 @@ import { attempt, attemptSync } from '@logosdx/utils'; import { observer } from '../observer.js'; import { formatIdentity } from '../identity/resolver.js'; import { processFile, isTemplate } from '../template/index.js'; +import { assertPolicy } from '../policy/index.js'; +import type { Permission } from '../policy/index.js'; import { computeChecksum, computeCombinedChecksum } from '../runner/checksum.js'; import { getSqlErrorMessage } from '../shared/index.js'; import { getLockManager } from '../lock/index.js'; @@ -59,6 +61,23 @@ const DEFAULT_OPTIONS: Required> & { output: strin /** Default SQL template - files with only this content are considered empty */ const SQL_TEMPLATE = '-- TODO: Add SQL statements here'; +/** + * Gate a change entrypoint against the config's access policy. + * + * The single enforcement seam for `executeChange`/`revertChange`: every + * caller (SDK, TUI, CLI) funnels through one of these functions, so gating + * here — rather than per-caller — closes the surface uniformly. Change + * files are command-gated, not content-classified. + * + * @throws Error carrying the policy's blockedReason when the channel's + * role denies the permission. + */ +function assertChangePolicy(context: ChangeContext, permission: Permission): void { + + assertPolicy(context.channel, { name: context.configName, access: context.access }, permission); + +} + // ───────────────────────────────────────────────────────────── // Execute Change (Change Direction) // ───────────────────────────────────────────────────────────── @@ -89,6 +108,8 @@ export async function executeChange( options: ChangeOptions = {}, ): Promise { + assertChangePolicy(context, 'change:run'); + const start = performance.now(); const opts = { ...DEFAULT_OPTIONS, ...options }; @@ -252,6 +273,8 @@ export async function revertChange( options: ChangeOptions = {}, ): Promise { + assertChangePolicy(context, 'change:revert'); + const start = performance.now(); const opts = { ...DEFAULT_OPTIONS, ...options }; diff --git a/src/core/change/types.ts b/src/core/change/types.ts index 9d92c751..a38f660e 100644 --- a/src/core/change/types.ts +++ b/src/core/change/types.ts @@ -17,6 +17,7 @@ import type { ExecutionStatus, } from '../shared/index.js'; import type { Identity } from '../identity/index.js'; +import type { Channel, ConfigAccess } from '../policy/index.js'; // ───────────────────────────────────────────────────────────── // File Types @@ -299,6 +300,8 @@ export const DEFAULT_BATCH_OPTIONS: Required> * projectRoot: '/project', * changesDir: '/project/changes', * sqlDir: '/project/sql', + * access: { user: 'admin', mcp: 'admin' }, + * channel: 'user', * } * ``` */ @@ -321,6 +324,16 @@ export interface ChangeContext { /** Schema directory for resolving .txt references */ sqlDir: string; + /** + * Config's per-channel access roles. `executeChange`/`revertChange` + * gate on this once, at the core seam, so SDK/TUI/CLI callers inherit + * one enforcement path instead of re-implementing the check per caller. + */ + access: ConfigAccess; + + /** Caller channel for the policy gate — `user` for CLI/TUI/SDK, `mcp` for MCP. */ + channel: Channel; + /** Config object for template context */ config?: Record; diff --git a/src/core/config/index.ts b/src/core/config/index.ts index 704b552d..4aebf28f 100644 --- a/src/core/config/index.ts +++ b/src/core/config/index.ts @@ -89,7 +89,7 @@ export function getEnvConfig(): ConfigInput { * Config module - configuration management for noorm. * * Handles config loading, validation, merging from multiple sources, - * and protected config handling. + * and per-channel access roles (`docs/spec/config-access-roles.md`). */ // Types @@ -122,11 +122,3 @@ export { type CompletenessCheckOptions, } from './resolver.js'; -// Protection -export { - checkProtection, - validateConfirmation, - type ProtectedAction, - type ProtectionCheck, -} from './protection.js'; - diff --git a/src/core/config/protection.ts b/src/core/config/protection.ts deleted file mode 100644 index bef53595..00000000 --- a/src/core/config/protection.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Protected config handling. - * - * Protected configs require confirmation for destructive operations. - * Some operations (like db:destroy) are completely blocked. - */ -import { shouldSkipConfirmations } from '../environment.js'; -import type { Config } from './types.js'; - -/** - * Actions that can be performed on configs. - */ -export type ProtectedAction = - | 'change:run' - | 'change:revert' - | 'change:ff' - | 'change:next' - | 'run:build' - | 'run:file' - | 'run:dir' - | 'db:create' - | 'db:destroy' - | 'config:rm'; - -/** - * Actions that are completely blocked on protected configs. - */ -const BLOCKED_ACTIONS: ProtectedAction[] = ['db:destroy']; - -/** - * Actions that require confirmation on protected configs. - */ -const CONFIRM_ACTIONS: ProtectedAction[] = [ - 'change:run', - 'change:revert', - 'change:ff', - 'change:next', - 'run:build', - 'run:file', - 'run:dir', - 'db:create', - 'config:rm', -]; - -/** - * Result of checking protection for an action. - */ -export interface ProtectionCheck { - /** Whether the action is allowed to proceed */ - allowed: boolean; - - /** Whether user confirmation is needed before proceeding */ - requiresConfirmation: boolean; - - /** The phrase user must type to confirm (e.g., "yes-production") */ - confirmationPhrase?: string; - - /** Reason the action is blocked (if not allowed) */ - blockedReason?: string; -} - -/** - * Check if an action is allowed on a config. - * - * For protected configs: - * - Some actions are completely blocked (e.g., db:destroy) - * - Some actions require user confirmation - * - Confirmation can be skipped with NOORM_YES=1 - * - * @example - * ```typescript - * const check = checkProtection(config, 'change:run') - * - * if (!check.allowed) { - * console.error(check.blockedReason) - * process.exit(1) - * } - * - * if (check.requiresConfirmation) { - * const input = await prompt(`Type "${check.confirmationPhrase}" to confirm:`) - * if (input !== check.confirmationPhrase) { - * console.error('Confirmation failed') - * process.exit(1) - * } - * } - * - * // Proceed with action... - * ``` - */ -export function checkProtection(config: Config, action: ProtectedAction): ProtectionCheck { - - // Non-protected configs allow everything - if (!config.protected) { - - return { allowed: true, requiresConfirmation: false }; - - } - - // Blocked actions - if (BLOCKED_ACTIONS.includes(action)) { - - return { - allowed: false, - requiresConfirmation: false, - blockedReason: - `"${action}" is not allowed on protected config "${config.name}". ` + - 'Connect to the database directly to perform this action.', - }; - - } - - // Actions requiring confirmation - if (CONFIRM_ACTIONS.includes(action)) { - - // Skip confirmation if NOORM_YES is set (for scripted CI) - if (shouldSkipConfirmations()) { - - return { allowed: true, requiresConfirmation: false }; - - } - - return { - allowed: true, - requiresConfirmation: true, - confirmationPhrase: `yes-${config.name}`, - }; - - } - - // Unknown action - allow by default - return { allowed: true, requiresConfirmation: false }; - -} - -/** - * Validate a confirmation phrase. - * - * The expected phrase is "yes-{configName}". - * - * @example - * ```typescript - * const valid = validateConfirmation(config, userInput) - * if (!valid) { - * console.error('Invalid confirmation') - * } - * ``` - */ -export function validateConfirmation(config: Config, input: string): boolean { - - return input === `yes-${config.name}`; - -} diff --git a/src/core/config/resolver.ts b/src/core/config/resolver.ts index 0f1e8565..4d020408 100644 --- a/src/core/config/resolver.ts +++ b/src/core/config/resolver.ts @@ -12,9 +12,10 @@ import { merge, clone } from '@logosdx/utils'; import type { Config, ConfigInput, CompletenessCheck } from './types.js'; import { getEnvConfigName } from '../environment.js'; -import { parseConfig } from './schema.js'; +import { parseConfig, type ConfigSchemaType } from './schema.js'; import { getEnvConfig } from './index.js'; import type { SettingsManager, StageDefaults } from '../settings/index.js'; +import type { ConfigAccess, Role } from '../policy/index.js'; /** * Interface for state manager dependency. @@ -62,11 +63,19 @@ export class SettingsProvider { /** * Default config values. + * + * `access` is deliberately absent here: it must be decided from what the + * merged input (stored/env/flags) actually supplied, not injected ahead of + * it. Merging a default `access` in first would make it "already present" + * by the time `parseConfig` runs, short-circuiting the legacy `protected` + * fallback in `withResolvedAccess` and silently opening up any config that + * only sets legacy `protected: true`. `parseConfig` still defaults absent + * `access` to admin/admin (via `resolveLegacyAccess`) — just after the + * merge, from the real inputs, not before it. */ const DEFAULTS: ConfigInput = { type: 'local', isTest: false, - protected: false, connection: { host: 'localhost', pool: { min: 0, max: 10 }, @@ -76,6 +85,60 @@ const DEFAULTS: ConfigInput = { }, }; +/** + * Ceiling a `protected: true` stage caps a config's access to. A config can + * still be stricter than this (e.g. `mcp: false`); it just can't be looser. + */ +const PROTECTED_STAGE_CEILING: ConfigAccess = { user: 'operator', mcp: 'viewer' }; + +/** + * Strictness rank for comparing roles: `false` (invisible) is stricter than + * `viewer`, which is stricter than `operator`, which is stricter than `admin`. + */ +function roleRank(role: Role | false): number { + + if (role === false) return 0; + if (role === 'viewer') return 1; + if (role === 'operator') return 2; + + return 3; + +} + +/** + * Clamps access down to a ceiling, per channel. A value stricter than the + * ceiling survives unchanged; a looser value is pulled down to it. Never + * loosens a value that was already stricter than the ceiling. + */ +function clampToCeiling(access: ConfigAccess, ceiling: ConfigAccess): ConfigAccess { + + return { + user: roleRank(access.user) <= roleRank(ceiling.user) ? access.user : ceiling.user, + mcp: roleRank(access.mcp) <= roleRank(ceiling.mcp) ? access.mcp : ceiling.mcp, + }; + +} + +/** + * Applies the stage `protected: true` access ceiling to a resolved config. + * + * A `protected: true` stage caps how open the config's access can be. + * Stricter configs (e.g. an already-viewer config) are left alone. + */ +function applyStageCeiling( + config: ConfigSchemaType, + stageDefaults: StageDefaults | null, +): ConfigSchemaType { + + if (stageDefaults?.protected !== true) return config; + + return { + ...config, + access: clampToCeiling(config.access, PROTECTED_STAGE_CEILING), + }; + +} + /** * Options for resolving a config. */ @@ -93,6 +156,18 @@ export interface ResolveOptions { settings?: SettingsProvider; } +/** + * Message for a config name that has no stored config. + * + * Single source of truth: `SessionManager`'s mcp-channel invisibility deny + * (`src/rpc/session.ts`) reuses this so a hidden config's connect error is + * byte-identical to an unknown config's, rather than hand-copying the string. + * + * @example + * configNotFoundMessage('prod') // 'Config "prod" not found' + */ +export const configNotFoundMessage = (name: string): string => `Config "${name}" not found`; + /** * Resolve the active config from all sources. * @@ -156,7 +231,7 @@ export function resolveConfig(state: StateProvider, options: ResolveOptions = {} const stored = state.getConfig(configName); if (!stored) { - throw new Error(`Config "${configName}" not found`); + throw new Error(configNotFoundMessage(configName)); } @@ -178,8 +253,8 @@ export function resolveConfig(state: StateProvider, options: ResolveOptions = {} merged = merge(merged, envConfig) as ConfigInput; merged = merge(merged, options.flags ?? {}); - // 5. Validate and return with defaults applied - return parseConfig(merged); + // 5. Validate, apply defaults, and clamp to the stage's access ceiling + return applyStageCeiling(parseConfig(merged), stageDefaults); } @@ -240,7 +315,7 @@ function resolveFromEnvOnly( } - return parseConfig(merged); + return applyStageCeiling(parseConfig(merged), stageDefaults); } @@ -320,14 +395,9 @@ export function checkConfigCompleteness( // Check stage constraint violations const defaults = stage.defaults ?? {} as ConfigInput; - // protected: true cannot be overridden to false - if (defaults.protected === true && config.protected === false) { - - result.violations.push( - `Stage "${stageName ?? config.name}" requires protected=true, but config has protected=false`, - ); - - } + // protected: true is enforced as an access ceiling at resolution + // (resolveConfig/applyStageCeiling), so a resolved config can no longer + // violate it here — it's clamped before this function ever sees it. // isTest: true cannot be overridden to false if (defaults.isTest === true && config.isTest === false) { diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index 5aad5927..63cfd62e 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -6,11 +6,46 @@ */ import { z } from 'zod'; +import { resolveLegacyAccess } from '../policy/index.js'; +import type { ConfigAccess } from '../policy/index.js'; + /** * Valid database dialects. */ export const DialectSchema = z.enum(['postgres', 'mysql', 'sqlite', 'mssql']); +/** + * Per-channel access roles. Mirrors `ConfigAccess` from `core/policy`. + */ +const RoleSchema = z.enum(['viewer', 'operator', 'admin']); + +const AccessSchema = z.object({ + user: RoleSchema, + mcp: z.union([RoleSchema, z.literal(false)]), +}); + +/** + * Resolves the final `access` from raw input that may carry either the new + * `access` field, the legacy `protected` boolean, or neither. + * + * `access` wins when present. Otherwise the legacy boolean maps to a role + * pair for one version (`docs/spec/config-access-roles.md#migration`). + * `access` is the only stored source of truth — the legacy boolean is never + * echoed back in the output. + */ +function withResolvedAccess( + data: T, +): Omit & { access: ConfigAccess } { + + const { protected: legacyProtected, access, ...rest } = data; + + return { + ...rest, + access: resolveLegacyAccess(access, legacyProtected), + }; + +} + /** * Config name pattern - alphanumeric with hyphens and underscores. */ @@ -76,17 +111,25 @@ export const ConnectionSchema = z }); /** - * Full config schema. + * Full config object schema, before access/protected resolution. */ -export const ConfigSchema = z.object({ +const ConfigObjectSchema = z.object({ name: ConfigNameSchema, type: z.enum(['local', 'remote']).default('local'), isTest: z.boolean().default(false), - protected: z.boolean().default(false), + /** Legacy input path — mapped to `access` by `withResolvedAccess`. */ + protected: z.boolean().optional(), + access: AccessSchema.optional(), connection: ConnectionSchema, identity: z.string().optional(), }); +/** + * Full config schema. Resolves `access` (defaulting/mapping legacy + * `protected`) and derives `protected` from it. + */ +export const ConfigSchema = ConfigObjectSchema.transform(withResolvedAccess); + /** * Partial connection schema (all fields optional). */ @@ -114,6 +157,8 @@ export const ConfigInputSchema = z.object({ .optional(), type: z.enum(['local', 'remote']).optional(), isTest: z.boolean().optional(), + access: AccessSchema.optional(), + /** Legacy input path — mapped to `access` by `ConfigSchema`. */ protected: z.boolean().optional(), connection: PartialConnectionSchema.optional(), identity: z.string().optional(), @@ -124,9 +169,9 @@ export const ConfigInputSchema = z.object({ * * Allows missing name (will be generated as '__env__'). */ -export const EnvConfigSchema = ConfigSchema.extend({ - name: ConfigNameSchema.optional(), -}); +export const EnvConfigSchema = ConfigObjectSchema + .extend({ name: ConfigNameSchema.optional() }) + .transform(withResolvedAccess); // ───────────────────────────────────────────────────────────── // Type Exports @@ -239,7 +284,7 @@ export function validateConfigInput(input: unknown): asserts input is ConfigInpu * const config = parseConfig(minimal) * // config.type === 'local' (default) * // config.isTest === false (default) - * // config.protected === false (default) + * // config.access === { user: 'admin', mcp: 'admin' } (default) * ``` */ export function parseConfig(config: unknown): ConfigSchemaType { diff --git a/src/core/config/types.ts b/src/core/config/types.ts index d0f2cde2..0de684ff 100644 --- a/src/core/config/types.ts +++ b/src/core/config/types.ts @@ -3,10 +3,12 @@ * * Configs define how noorm connects to databases and where to find * SQL/change files. They support multiple environments, - * environment variable overrides, and protected configs for production safety. + * environment variable overrides, and per-channel access roles for + * production safety (`docs/spec/config-access-roles.md`). */ import type { ConnectionConfig, Dialect } from '../connection/types.js'; import type { LogLevel } from '../logger/types.js'; +import type { ConfigAccess } from '../policy/index.js'; /** * Full configuration object. @@ -17,7 +19,7 @@ import type { LogLevel } from '../logger/types.js'; * name: 'dev', * type: 'local', * isTest: false, - * protected: false, + * access: { user: 'admin', mcp: 'admin' }, * connection: { * dialect: 'postgres', * host: 'localhost', @@ -33,7 +35,13 @@ export interface Config { name: string; type: 'local' | 'remote'; isTest: boolean; - protected: boolean; + + /** + * Per-channel access roles — the source of truth for policy checks + * (`checkPolicy`/`guarded` from `core/policy`). Every config materialized + * through `parseConfig`/state load has it populated. + */ + access: ConfigAccess; connection: ConnectionConfig; @@ -48,6 +56,12 @@ export interface ConfigInput { name?: string; type?: 'local' | 'remote'; isTest?: boolean; + access?: ConfigAccess; + /** + * Legacy input path — a config produced before per-channel access + * roles. Accepted for one version at parse time and mapped to `access` + * via `resolveLegacyAccess` (`ConfigSchema`); never stored. + */ protected?: boolean; connection?: Partial; identity?: string; @@ -63,7 +77,7 @@ export interface ConfigSummary { name: string; type: 'local' | 'remote'; isTest: boolean; - protected: boolean; + access: ConfigAccess; isActive: boolean; dialect: Dialect; database: string; diff --git a/src/core/identity/types.ts b/src/core/identity/types.ts index 21522161..8c9af8cf 100644 --- a/src/core/identity/types.ts +++ b/src/core/identity/types.ts @@ -205,40 +205,3 @@ export interface SharedConfigPayload { /** Encrypted config data (hex) */ ciphertext: string; } - -/** - * Decrypted config data from sharing. - * - * NOTE: user/password are NOT included - recipient provides their own. - */ -export interface ExportedConfig { - /** Config name */ - name: string; - - /** Database dialect */ - dialect: string; - - /** Connection details (excluding user/password) */ - connection: { - host?: string; - port?: number; - database: string; - ssl?: boolean; - pool?: { min?: number; max?: number }; - }; - - /** File paths */ - paths: { - sql: string; - changes: string; - }; - - /** Test database flag */ - isTest: boolean; - - /** Protection flag */ - protected: boolean; - - /** Config-scoped secrets */ - secrets: Record; -} diff --git a/src/core/policy/check.ts b/src/core/policy/check.ts new file mode 100644 index 00000000..c3a266b6 --- /dev/null +++ b/src/core/policy/check.ts @@ -0,0 +1,169 @@ +import { shouldSkipConfirmations } from '../environment.js'; +import { MATRIX } from './matrix.js'; +import type { Channel, ConfigAccess, Permission, PolicyCheck, PolicyTarget } from './types.js'; + +/** + * The type-to-confirm phrase for a config — the single source every + * `confirm`-cell resolution and TUI confirmation dialog derives from, so the + * format can't drift between `checkPolicy` and its callers. + * + * @example + * confirmationPhraseFor('prod'); // 'yes-prod' + */ +export function confirmationPhraseFor(name: string): string { + + return `yes-${name}`; + +} + +/** + * Resolve whether a channel may exercise a permission against a config. + * + * The matrix cell is channel-agnostic (`allow`/`confirm`/`deny`); only + * `confirm` resolves differently per channel: the `user` channel prompts for + * `yes-` (skippable via `NOORM_YES`), while `mcp` collapses confirm + * to deny — there's no human on the other end of stdio to type a phrase, and + * an agent confirming its own destructive action is theater. + * + * `mcp: false` (invisible config) is never a role and is not expected to + * reach this function — visibility is enforced upstream. If it does, this + * fails closed rather than crashing. + * + * @example + * const check = checkPolicy('user', config, 'db:destroy'); + * if (!check.allowed) throw new Error(check.blockedReason); + * if (check.requiresConfirmation) await promptFor(check.confirmationPhrase); + */ +export function checkPolicy(channel: Channel, target: PolicyTarget, permission: Permission): PolicyCheck { + + const role = target.access[channel]; + + if (role === false) { + + return { + allowed: false, + requiresConfirmation: false, + blockedReason: `Config "${target.name}" is not accessible on the ${channel} channel.`, + }; + + } + + const cell = MATRIX[permission][role]; + + if (cell === 'allow') { + + return { allowed: true, requiresConfirmation: false }; + + } + + if (cell === 'deny') { + + return { + allowed: false, + requiresConfirmation: false, + blockedReason: `"${permission}" is not allowed on config "${target.name}" (role: ${role}).`, + }; + + } + + if (channel === 'mcp') { + + return { + allowed: false, + requiresConfirmation: false, + blockedReason: `"${permission}" on config "${target.name}" requires confirmation — use the CLI.`, + }; + + } + + if (shouldSkipConfirmations()) { + + return { allowed: true, requiresConfirmation: false }; + + } + + return { + allowed: true, + requiresConfirmation: true, + confirmationPhrase: confirmationPhraseFor(target.name), + }; + +} + +/** + * Runs `checkPolicy` against a config that may not carry `access` — the + * single fail-closed wrapper every caller reaches for when the value in + * hand isn't a validated `Config` (e.g. raw JSON, a test double). `Config` + * itself requires `access` (docs/spec/config-access-roles.md#data-model). + * Absent access denies on both channels with the same message, rather than + * each caller hand-rolling its own "no access configuration" branch. In + * practice this never triggers for a real `Config`: every one reaching + * enforcement came through `parseConfig`/state load, which always + * populates `access`. + * + * @example + * const check = checkConfigPolicy('mcp', ctx.noorm.config, 'sql:write'); + * if (!check.allowed) throw new RpcError(check.blockedReason ?? 'denied'); + */ +export function checkConfigPolicy( + channel: Channel, + config: { name: string; access?: ConfigAccess }, + permission: Permission, +): PolicyCheck { + + if (!config.access) { + + return { + allowed: false, + requiresConfirmation: false, + blockedReason: `Config "${config.name}" has no access configuration.`, + }; + + } + + return checkPolicy(channel, { name: config.name, access: config.access }, permission); + +} + +/** + * Runs `checkConfigPolicy` and throws when denied — the single fail-closed + * gate every command entrypoint (`runBuild`/`runFile`/`runDir`, `executeChange`/ + * `revertChange`, `executeRawSql`) reaches for instead of hand-rolling its own + * check-then-throw with a duplicated fallback message. Callers that need a + * tuple instead of a throw (e.g. `transferData`) wrap this in `attemptSync`. + * + * @throws Error carrying the policy's blockedReason when the channel's role + * denies the permission. + * + * @example + * assertPolicy(context.channel, { name: context.configName, access: context.access }, 'run:build'); + */ +export function assertPolicy( + channel: Channel, + target: { name: string; access?: ConfigAccess }, + permission: Permission, +): void { + + const check = checkConfigPolicy(channel, target, permission); + + if (!check.allowed) { + + throw new Error(check.blockedReason ?? `"${permission}" is not allowed on config "${target.name}".`); + + } + +} + +/** + * Display-only shorthand for "this config isn't wide open" — used by TUI + * styling, `config list`, and settings rule matching. Never an enforcement + * input; `checkPolicy` is the only gate. + * + * @example + * guarded({ name: 'prod', access: { user: 'operator', mcp: 'viewer' } }); // true + */ +export function guarded(target: PolicyTarget): boolean { + + return target.access.user !== 'admin'; + +} diff --git a/src/core/policy/classify.ts b/src/core/policy/classify.ts new file mode 100644 index 00000000..cbad4d67 --- /dev/null +++ b/src/core/policy/classify.ts @@ -0,0 +1,821 @@ +import { cstVisitor, parse, type Program } from 'sql-parser-cst'; +import { attemptSync } from '@logosdx/utils'; + +import type { Dialect } from '../connection/types.js'; + +/** + * Statement classification. Multi-statement input takes the highest class + * present (`read < write < ddl`). + */ +export type SqlClass = 'read' | 'write' | 'ddl'; + +/** + * Dialect mapping from noorm to sql-parser-cst. + */ +const DIALECT_MAP: Record = { + sqlite: 'sqlite', + postgres: 'postgresql', + mysql: 'mysql', + mssql: 'postgresql', // best-effort — fall back to keyword if parser chokes +}; + +/** + * CST statement types that are read-only. + */ +const READ_STMT_TYPES: Record = { + select_stmt: true, + explain_stmt: true, + show_stmt: true, + describe_stmt: true, +}; + +/** + * CST statement types that write data without changing schema. + */ +const WRITE_STMT_TYPES: Record = { + insert_stmt: true, + update_stmt: true, + delete_stmt: true, + merge_stmt: true, +}; + +/** + * Keywords that indicate a read-only statement (uppercase). + */ +const READ_KEYWORDS: Record = { + SELECT: true, + EXPLAIN: true, + SHOW: true, + DESCRIBE: true, + DESC: true, +}; + +/** + * Keywords that indicate a data-writing statement (uppercase). + */ +const WRITE_KEYWORDS: Record = { + INSERT: true, + UPDATE: true, + DELETE: true, + MERGE: true, +}; + +/** + * Ordering used to resolve the highest class across a multi-statement input. + */ +const CLASS_RANK: Record = { read: 0, write: 1, ddl: 2 }; + +/** + * Side-effecting builtins that must not be treated as read-only just because + * they're invoked from a SELECT. Guardrail against a `viewer` config running + * `SELECT pg_terminate_backend(...)` through the read-allowed `sql` path. + * + * Deliberately a denylist, not an allowlist: `SELECT f()` is statically + * undecidable, so pure helpers (`count`, `now`, `coalesce`, ...) stay `read` + * and only known-dangerous calls are caught. An unlisted side-effecting + * function on a viewer config is a documented limitation — this guards + * against casual/accidental writes, not a determined adversary. + */ +export const DESTRUCTIVE_FUNCTIONS: ReadonlySet = new Set([ + 'pg_terminate_backend', + 'pg_cancel_backend', + 'pg_reload_conf', + 'pg_rotate_logfile', + 'pg_promote', + 'pg_switch_wal', + 'pg_create_restore_point', + 'pg_drop_replication_slot', + 'lo_import', + 'lo_export', + 'lo_unlink', + 'setval', + 'nextval', + 'dblink_exec', + 'query_to_xml', + 'query_to_xmlschema', + 'query_to_xml_and_xmlschema', + 'cursor_to_xml', + 'cursor_to_xmlschema', +]); + +/** + * Word-boundary match for a denylisted function call, used by the keyword + * fallback. Built once from `DESTRUCTIVE_FUNCTIONS` rather than per call. + */ +const DESTRUCTIVE_FUNCTION_PATTERN = new RegExp(`\\b(?:${[...DESTRUCTIVE_FUNCTIONS].join('|')})\\s*\\(`, 'i'); + +/** + * Classify raw SQL as `read`, `write`, or `ddl`. + * + * Strategy: try sql-parser-cst first, fall back to keyword-based + * if the parser throws (e.g., MSSQL-specific syntax). Anything not + * recognized as read or write — CREATE/ALTER/DROP/TRUNCATE/GRANT/REVOKE/SET, + * EXEC/CALL, or input the parser can't make sense of — classifies as `ddl`: + * fail closed, since an unrecognized statement could do anything. + * + * @example + * classifyStatements('SELECT * FROM users', 'postgres'); // 'read' + * classifyStatements("INSERT INTO users (name) VALUES ('x')", 'postgres'); // 'write' + * classifyStatements('DROP TABLE users', 'postgres'); // 'ddl' + */ +export function classifyStatements(sql: string, dialect: Dialect): SqlClass { + + const trimmed = sql.trim(); + + if (trimmed === '') return 'read'; + + // Try CST parser + const [result, err] = attemptSync(() => + parse(trimmed, { + dialect: DIALECT_MAP[dialect], + includeComments: false, + includeSpaces: false, + includeNewlines: false, + }), + ); + + if (!err && result) { + + return classifyCst(result); + + } + + // Parser failed — fall back to keyword-based + return classifyKeyword(trimmed); + +} + +/** + * Classify via CST. Highest class among all parsed statements wins. + * + * A data-modifying CTE definition (`WITH t AS (DELETE ... ) SELECT ...`) or + * a denylisted function call anywhere in the tree upgrades the result to at + * least `write`, even when the outer/final statement is a plain SELECT. + */ +function classifyCst(program: Program): SqlClass { + + let highest: SqlClass = 'read'; + + for (const stmt of program.statements) { + + highest = maxClass(highest, classifyStmtType(stmt)); + + } + + if (containsWriteSignal(program)) { + + highest = maxClass(highest, 'write'); + + } + + return highest; + +} + +/** + * True when the tree contains a data-modifying statement nested inside + * another statement (only possible via a CTE definition body — see + * `CommonTableExpr.expr` in sql-parser-cst's types) or a call to a + * `DESTRUCTIVE_FUNCTIONS` builtin anywhere. Either signals at least `write` + * regardless of what the outer/final statement looks like. + */ +function containsWriteSignal(program: Program): boolean { + + let found = false; + + const markWrite = () => { + + found = true; + + }; + + const visit = cstVisitor({ + insert_stmt: markWrite, + update_stmt: markWrite, + delete_stmt: markWrite, + merge_stmt: markWrite, + func_call: (node) => { + + // A schema-qualified call (`pg_catalog.pg_terminate_backend(...)`) parses to a + // member_expr name; its `property` is the called identifier regardless of + // qualification depth (`db.pg_catalog.fn` nests further qualifiers under + // `object`, so `property` is always the rightmost/actual function name). + const funcName = node.name.type === 'identifier' ? node.name : node.name.property; + + if (funcName.type === 'identifier' && DESTRUCTIVE_FUNCTIONS.has(funcName.name.toLowerCase())) { + + found = true; + + } + + }, + }); + + visit(program); + + return found; + +} + +/** + * Map a single CST statement to a class. `empty` covers comment-only + * or whitespace-only input that still parses successfully. + * + * A `select_stmt` carrying an INTO clause (`into_table_clause`, + * `into_outfile_clause`, `into_variables_clause`, `into_dumpfile_clause`) + * redirects the result set into a new table or a file instead of returning + * rows — that's schema-creation or exfiltration, not a read. Fail closed + * to `ddl` for any INTO variant rather than special-casing which ones + * actually touch schema. + */ +function classifyStmtType(stmt: { type: string; clauses?: Array<{ type: string }> }): SqlClass { + + if (stmt.type === 'select_stmt' && stmt.clauses?.some((clause) => clause.type.startsWith('into_'))) { + + return 'ddl'; + + } + + if (stmt.type === 'empty' || READ_STMT_TYPES[stmt.type]) return 'read'; + if (WRITE_STMT_TYPES[stmt.type]) return 'write'; + + return 'ddl'; + +} + +/** + * Classify via keyword analysis. + * + * Strips comments, splits on semicolons, checks the leading keyword of each + * statement, takes the highest class present. + */ +function classifyKeyword(sql: string): SqlClass { + + const stripped = stripComments(sql); + const statements = splitStatements(stripped); + + if (statements.length === 0) return 'read'; + + let highest: SqlClass = 'read'; + + for (const stmt of statements) { + + highest = maxClass(highest, classifyKeywordStatement(stmt)); + + } + + if (hasDestructiveFunctionCall(stripped)) { + + highest = maxClass(highest, 'write'); + + } + + return highest; + +} + +/** + * Classify a single statement (no comments, no semicolons) by its leading + * keyword. Handles CTEs via paren-depth tracking for the WITH keyword. + * + * A leading SELECT carrying a top-level INTO (MSSQL `INTO #temp`, MySQL + * `INTO OUTFILE`/`INTO @var`) is checked before the generic READ_KEYWORDS + * lookup — the CST parser can reject dialect-specific INTO targets (e.g. + * `#temp`), so this fallback must catch them too. Fail closed to `ddl`. + */ +function classifyKeywordStatement(stmt: string): SqlClass { + + const upper = stmt.toUpperCase(); + const firstWord = upper.match(/^(\w+)/)?.[1]; + + if (!firstWord) return 'read'; + + if (firstWord === 'SELECT') { + + return hasTopLevelInto(upper) ? 'ddl' : 'read'; + + } + + if (READ_KEYWORDS[firstWord]) return 'read'; + + // Handle WITH ... (CTE) + if (firstWord === 'WITH') { + + return classifyCte(upper); + + } + + if (WRITE_KEYWORDS[firstWord]) return 'write'; + + return 'ddl'; + +} + +/** + * Classify a CTE (WITH ...): the highest of (a) the final statement's own + * class and (b) any data-modifying CTE definition's class. A `WITH t AS + * (DELETE ...) SELECT ...` must classify as `write` even though the final + * statement is a SELECT — keying only off the final keyword would miss it. + */ +function classifyCte(upper: string): SqlClass { + + return maxClass(classifyCteDefinitions(upper), classifyCteFinalStatement(upper)); + +} + +/** + * Classify a CTE by the keyword of its final statement. + * + * Finds the keyword right after the CTE definition list ends, using the + * same `AS (...)` boundary as `classifyCteDefinitions` — not just the + * last top-level closing paren, which a subquery inside the final + * statement itself (`... WHERE id IN (SELECT ...)`) would also close at + * depth 0, misidentifying the CTE boundary. + */ +function classifyCteFinalStatement(upper: string): SqlClass { + + const cteEnd = findCteDefinitionsEnd(upper); + + if (cteEnd === -1) return 'ddl'; + + const afterCte = upper.slice(cteEnd).trim(); + + // Skip optional comma (recursive CTEs) + const finalStmt = afterCte.replace(/^,/, '').trim(); + const finalKeyword = finalStmt.match(/^(\w+)/)?.[1]; + + if (finalKeyword === 'SELECT') return hasTopLevelInto(finalStmt) ? 'ddl' : 'read'; + if (finalKeyword && READ_KEYWORDS[finalKeyword]) return 'read'; + if (finalKeyword && WRITE_KEYWORDS[finalKeyword]) return 'write'; + + return 'ddl'; + +} + +/** + * Index just past the closing paren of the last `name AS (...)` CTE + * definition — the boundary between the definition list and the final + * statement. Walks the same comma-separated, depth-0 `AS (...)` structure + * as `classifyCteDefinitions` so a paren inside the final statement's own + * subquery is never mistaken for this boundary. + */ +function findCteDefinitionsEnd(upper: string): number { + + let depth = 0; + let i = 0; + let end = -1; + + while (i < upper.length) { + + if (upper[i] === '(') { + + if (depth === 0 && isPrecededByAsKeyword(upper, i)) { + + const bodyEnd = findMatchingParen(upper, i); + + end = bodyEnd + 1; + i = bodyEnd + 1; + continue; + + } + + depth++; + i++; + continue; + + } + + if (upper[i] === ')') { + + depth--; + i++; + continue; + + } + + i++; + + } + + return end; + +} + +/** + * Scan each top-level CTE definition body (`name AS (...)`) for a leading + * data-modifying or DDL keyword. Mirrors the CST path's inspection of the + * parsed CTE's inner statement type, for dialects where the parser throws + * and classification falls back to keywords. + */ +function classifyCteDefinitions(upper: string): SqlClass { + + let highest: SqlClass = 'read'; + let depth = 0; + let i = 0; + + while (i < upper.length) { + + if (upper[i] === '(') { + + if (depth === 0 && isPrecededByAsKeyword(upper, i)) { + + const bodyEnd = findMatchingParen(upper, i); + const body = upper.slice(i + 1, bodyEnd).trim(); + + highest = maxClass(highest, classifyCteBodyKeyword(body)); + + i = bodyEnd + 1; + continue; + + } + + depth++; + i++; + continue; + + } + + if (upper[i] === ')') { + + depth--; + i++; + continue; + + } + + i++; + + } + + return highest; + +} + +/** + * True when the `(` at `openIdx` is immediately preceded (ignoring + * whitespace) by a standalone `AS` keyword — the shape of a CTE + * definition's `name AS (...)` body, as opposed to a column-list paren + * (`name(a, b)`) or an unrelated nested paren. + */ +function isPrecededByAsKeyword(text: string, openIdx: number): boolean { + + let j = openIdx - 1; + + while (j >= 0) { + + const ch = text[j]; + + if (ch === undefined || !/\s/.test(ch)) break; + + j--; + + } + + if (j < 1 || text[j] !== 'S' || text[j - 1] !== 'A') return false; + + const before = text[j - 2]; + + return before === undefined || !/\w/.test(before); + +} + +/** + * Index of the `)` matching the `(` at `openIdx`, tracking nested depth. + * Falls back to the end of the string for malformed/unterminated input. + */ +function findMatchingParen(text: string, openIdx: number): number { + + let depth = 0; + + for (let k = openIdx; k < text.length; k++) { + + if (text[k] === '(') depth++; + if (text[k] === ')') { + + depth--; + + if (depth === 0) return k; + + } + + } + + return text.length - 1; + +} + +/** + * Classify a single CTE definition body by its leading keyword. Recurses + * for a CTE-within-a-CTE (`t AS (WITH inner AS (...) SELECT ...)`). + */ +function classifyCteBodyKeyword(body: string): SqlClass { + + const firstWord = body.match(/^(\w+)/)?.[1]; + + if (!firstWord) return 'read'; + if (firstWord === 'WITH') return classifyCte(body); + if (READ_KEYWORDS[firstWord]) return 'read'; + if (WRITE_KEYWORDS[firstWord]) return 'write'; + + return 'ddl'; + +} + +/** + * True when a top-level ` INTO ` keyword appears outside string literals + * and outside parentheses. Mirrors the string/paren-depth tracking used + * elsewhere in this file (`stripComments`, `classifyCte`) so a quoted + * value like `'INTO table'` or an INTO nested inside a subquery's parens + * doesn't trip the check — only a real SELECT ... INTO target does. + */ +function hasTopLevelInto(sql: string): boolean { + + let depth = 0; + let inString = false; + let i = 0; + + while (i < sql.length) { + + if (sql[i] === "'" && !inString) { + + inString = true; + i++; + + } + else if (sql[i] === "'" && inString) { + + if (sql[i + 1] === "'") { + + i += 2; + + } + else { + + inString = false; + i++; + + } + + } + else if (inString) { + + i++; + + } + else if (sql[i] === '(') { + + depth++; + i++; + + } + else if (sql[i] === ')') { + + depth--; + i++; + + } + else if ( + depth === 0 && + sql.slice(i, i + 4) === 'INTO' && + !/\w/.test(sql[i - 1] ?? ' ') && + !/\w/.test(sql[i + 4] ?? ' ') + ) { + + return true; + + } + else { + + i++; + + } + + } + + return false; + +} + +/** + * True when the SQL invokes a `DESTRUCTIVE_FUNCTIONS` builtin outside a + * string literal. Used by the keyword fallback, where there's no CST to + * walk for `func_call` nodes. + */ +function hasDestructiveFunctionCall(sql: string): boolean { + + return DESTRUCTIVE_FUNCTION_PATTERN.test(blankStringLiterals(sql)); + +} + +/** + * Replace the contents of single-quoted string literals with spaces, + * preserving overall length. Mirrors the quote-tracking used elsewhere in + * this file (`stripComments`, `hasTopLevelInto`) so a denylisted function + * name embedded in a quoted value can't spoof a match. + */ +function blankStringLiterals(sql: string): string { + + let result = ''; + let i = 0; + + while (i < sql.length) { + + if (sql[i] !== "'") { + + result += sql[i++]; + continue; + + } + + result += ' '; + i++; + + while (i < sql.length) { + + if (sql[i] === "'" && sql[i + 1] === "'") { + + result += ' '; + i += 2; + continue; + + } + + if (sql[i] === "'") { + + result += ' '; + i++; + break; + + } + + result += ' '; + i++; + + } + + } + + return result; + +} + +/** + * Resolve the higher-impact of two classes (`read < write < ddl`). + */ +function maxClass(a: SqlClass, b: SqlClass): SqlClass { + + return CLASS_RANK[b] > CLASS_RANK[a] ? b : a; + +} + +/** + * Split SQL on semicolons while respecting string literals. + * + * Naive split(';') mishandles semicolons inside quoted strings. + * This walks the string tracking quote state to split correctly. + */ +function splitStatements(sql: string): string[] { + + const statements: string[] = []; + let current = ''; + let inString = false; + let i = 0; + + while (i < sql.length) { + + if (sql[i] === "'" && !inString) { + + inString = true; + current += sql[i++]; + + } + else if (sql[i] === "'" && inString) { + + if (sql[i + 1] === "'") { + + current += "''"; + i += 2; + + } + else { + + inString = false; + current += sql[i++]; + + } + + } + else if (sql[i] === ';' && !inString) { + + const trimmed = current.trim(); + + if (trimmed.length > 0) { + + statements.push(trimmed); + + } + + current = ''; + i++; + + } + else { + + current += sql[i++]; + + } + + } + + const trimmed = current.trim(); + + if (trimmed.length > 0) { + + statements.push(trimmed); + + } + + return statements; + +} + +/** + * Strip SQL comments while respecting string literals. + * + * Uses a state machine to avoid stripping comment markers that + * appear inside single-quoted strings. This prevents crafted inputs + * from hiding destructive SQL behind comment markers embedded in + * string literals. + */ +function stripComments(sql: string): string { + + let result = ''; + let i = 0; + + while (i < sql.length) { + + // Single-quoted string — copy verbatim (handles '' escapes) + if (sql[i] === "'") { + + result += sql[i++]; + + while (i < sql.length) { + + if (sql[i] === "'" && sql[i + 1] === "'") { + + result += "''"; + i += 2; + + } + else if (sql[i] === "'") { + + result += sql[i++]; + break; + + } + else { + + result += sql[i++]; + + } + + } + + } + // Block comment — skip + else if (sql[i] === '/' && sql[i + 1] === '*') { + + i += 2; + + while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) { + + i++; + + } + + i += 2; // skip closing */ + + } + // Line comment — skip to end of line + else if (sql[i] === '-' && sql[i + 1] === '-') { + + i += 2; + + while (i < sql.length && sql[i] !== '\n') { + + i++; + + } + + } + else { + + result += sql[i++]; + + } + + } + + return result; + +} diff --git a/src/core/policy/index.ts b/src/core/policy/index.ts new file mode 100644 index 00000000..a921fca8 --- /dev/null +++ b/src/core/policy/index.ts @@ -0,0 +1,20 @@ +/** + * Access policy module exports. + * + * One central policy check for every channel (CLI/TUI/SDK, MCP) and every + * config-scoped action. + */ +export { assertPolicy, checkConfigPolicy, checkPolicy, confirmationPhraseFor, guarded } from './check.js'; +export { classifyStatements } from './classify.js'; +export type { SqlClass } from './classify.js'; +export { GUARDED_ACCESS, OPEN_ACCESS, resolveLegacyAccess } from './legacy-access.js'; +export { MATRIX } from './matrix.js'; +export type { + Channel, + ConfigAccess, + Permission, + PolicyCell, + PolicyCheck, + PolicyTarget, + Role, +} from './types.js'; diff --git a/src/core/policy/legacy-access.ts b/src/core/policy/legacy-access.ts new file mode 100644 index 00000000..c34bec7a --- /dev/null +++ b/src/core/policy/legacy-access.ts @@ -0,0 +1,35 @@ +/** + * Legacy `protected` boolean -> access role mapping. + * + * `Config.protected: boolean` was replaced by per-channel `access` roles + * (see docs/spec/config-access-roles.md#migration). Every place that still + * accepts the legacy boolean as input — zod schema parsing, state + * persistence, and the v2 state migration — resolves it through this one + * helper so the mapping exists exactly once. + */ +import type { ConfigAccess } from './types.js'; + +/** Access for a config with no explicit role and no legacy `protected` flag. */ +export const OPEN_ACCESS: ConfigAccess = { user: 'admin', mcp: 'admin' }; + +/** Access a legacy `protected: true` boolean maps to. */ +export const GUARDED_ACCESS: ConfigAccess = { user: 'operator', mcp: 'viewer' }; + +/** + * Resolves the access a config should have from its raw inputs. + * + * Explicit `access` always wins. Otherwise, a legacy `protected: true` + * maps to the guarded role pair; `false` or absent maps to fully open. + * + * @example + * resolveLegacyAccess(undefined, true); // { user: 'operator', mcp: 'viewer' } + * resolveLegacyAccess(undefined, undefined); // { user: 'admin', mcp: 'admin' } + */ +export function resolveLegacyAccess( + access: ConfigAccess | undefined, + legacyProtected: boolean | undefined, +): ConfigAccess { + + return access ?? (legacyProtected === true ? GUARDED_ACCESS : OPEN_ACCESS); + +} diff --git a/src/core/policy/matrix.ts b/src/core/policy/matrix.ts new file mode 100644 index 00000000..4e6d0761 --- /dev/null +++ b/src/core/policy/matrix.ts @@ -0,0 +1,27 @@ +import type { Permission, PolicyCell, Role } from './types.js'; + +/** + * The hard-coded permission × role matrix. Not user-extensible — see + * `docs/spec/config-access-roles.md` for the source of truth this mirrors. + */ +export const MATRIX: Record> = { + 'explore': { viewer: 'allow', operator: 'allow', admin: 'allow' }, + + 'sql:read': { viewer: 'allow', operator: 'allow', admin: 'allow' }, + 'sql:write': { viewer: 'deny', operator: 'allow', admin: 'allow' }, + 'sql:ddl': { viewer: 'deny', operator: 'deny', admin: 'allow' }, + + 'change:run': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'change:ff': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'change:revert': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + + 'run:build': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'run:file': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'run:dir': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + + 'db:create': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'db:reset': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'db:destroy': { viewer: 'deny', operator: 'deny', admin: 'confirm' }, + + 'config:rm': { viewer: 'deny', operator: 'confirm', admin: 'confirm' }, +}; diff --git a/src/core/policy/types.ts b/src/core/policy/types.ts new file mode 100644 index 00000000..331b9618 --- /dev/null +++ b/src/core/policy/types.ts @@ -0,0 +1,74 @@ +/** + * Access policy data model. + * + * Roles live on the config, not the actor — the actor is a channel (who is + * asking), and each config declares what each channel is allowed to do. + */ + +/** + * A config-scoped access level. Ordered loosest to strictest in practice: + * `viewer` reads only, `operator` writes and confirms destructive ops, + * `admin` is frictionless. + */ +export type Role = 'viewer' | 'operator' | 'admin'; + +/** + * Who is asking — CLI/TUI/SDK callers are `user`, the MCP server is `mcp`. + * Not an identity system; there are no user accounts. + */ +export type Channel = 'user' | 'mcp'; + +/** + * Per-channel access for a config. `mcp: false` means the config is + * invisible on the MCP channel — not a role, and never consulted by + * `checkPolicy` (visibility is enforced upstream of policy). + */ +export interface ConfigAccess { + user: Role; + mcp: Role | false; +} + +/** + * Actions gated by the policy matrix. `sql:read`/`sql:write`/`sql:ddl` are + * assigned to raw SQL after classification, not chosen by the caller. + */ +export type Permission = + | 'explore' + | 'sql:read' | 'sql:write' | 'sql:ddl' + | 'change:run' | 'change:ff' | 'change:revert' + | 'run:build' | 'run:file' | 'run:dir' + | 'db:create' | 'db:reset' | 'db:destroy' + | 'config:rm'; + +/** + * The minimum shape `checkPolicy` and `guarded` need from a config. + * `Config` satisfies this structurally once it gains `access`. + */ +export interface PolicyTarget { + name: string; + access: ConfigAccess; +} + +/** + * Raw matrix cell before channel resolution: `allow` always allows, + * `deny` always blocks, `confirm` resolves per-channel in `checkPolicy`. + */ +export type PolicyCell = 'allow' | 'confirm' | 'deny'; + +/** + * Result of a policy check. Same shape as the old `ProtectionCheck` so + * confirm-dialog plumbing (SmartConfirm/ProtectedConfirm) carries over. + */ +export interface PolicyCheck { + /** Whether the action is allowed to proceed. */ + allowed: boolean; + + /** Whether the user must confirm before proceeding. */ + requiresConfirmation: boolean; + + /** The phrase the user must type to confirm (e.g. "yes-production"). */ + confirmationPhrase?: string; + + /** Reason the action is blocked, when `allowed` is false. */ + blockedReason?: string; +} diff --git a/src/core/runner/runner.ts b/src/core/runner/runner.ts index ec2805da..d643d643 100644 --- a/src/core/runner/runner.ts +++ b/src/core/runner/runner.ts @@ -33,6 +33,8 @@ import { attempt, attemptSync } from '@logosdx/utils'; import { observer } from '../observer.js'; import { formatIdentity } from '../identity/resolver.js'; import { processFile, isTemplate } from '../template/index.js'; +import { assertPolicy } from '../policy/index.js'; +import type { Permission } from '../policy/index.js'; import { computeChecksum, computeChecksumFromContent, computeCombinedChecksum } from './checksum.js'; import { executeSqlBody } from './mssql-batches.js'; import { Tracker } from './tracker.js'; @@ -62,6 +64,23 @@ const FILE_HEADER_TEMPLATE = `-- =============================================== `; +/** + * Gate a run entrypoint against the config's access policy. + * + * The single enforcement seam for `runBuild`/`runFile`/`runDir`/`runFiles`: + * every caller (SDK, TUI, CLI) funnels through one of these functions, so + * gating here — rather than per-caller — closes the surface uniformly. + * Run files are command-gated, not content-classified. + * + * @throws Error carrying the policy's blockedReason when the channel's + * role denies the permission. + */ +function assertRunPolicy(context: RunContext, permission: Permission): void { + + assertPolicy(context.channel, { name: context.configName, access: context.access }, permission); + +} + // ───────────────────────────────────────────────────────────── // Build Mode // ───────────────────────────────────────────────────────────── @@ -93,6 +112,8 @@ export async function runBuild( preFilteredFiles?: string[], ): Promise { + assertRunPolicy(context, 'run:build'); + const start = performance.now(); const opts = { ...DEFAULT_RUN_OPTIONS_INTERNAL, ...options }; @@ -175,6 +196,8 @@ export async function runFile( options: RunOptions = {}, ): Promise { + assertRunPolicy(context, 'run:file'); + const opts = { ...DEFAULT_RUN_OPTIONS_INTERNAL, ...options }; observer.emit('run:file', { @@ -252,6 +275,8 @@ export async function runDir( options: RunOptions = {}, ): Promise { + assertRunPolicy(context, 'run:dir'); + const start = performance.now(); const opts = { ...DEFAULT_RUN_OPTIONS_INTERNAL, ...options }; @@ -300,6 +325,8 @@ export async function runFiles( options: RunOptions = {}, ): Promise { + assertRunPolicy(context, 'run:dir'); + const opts = { ...DEFAULT_RUN_OPTIONS_INTERNAL, ...options }; observer.emit('run:files', { diff --git a/src/core/runner/types.ts b/src/core/runner/types.ts index 66d6a6e4..f47711fd 100644 --- a/src/core/runner/types.ts +++ b/src/core/runner/types.ts @@ -10,6 +10,7 @@ import type { Kysely } from 'kysely'; import type { NoormDatabase, ExecutionStatus } from '../shared/index.js'; import type { Identity } from '../identity/index.js'; +import type { Channel, ConfigAccess } from '../policy/index.js'; // ───────────────────────────────────────────────────────────── // Run Options @@ -76,6 +77,8 @@ export const DEFAULT_RUN_OPTIONS: Required> & { outpu * configName: 'dev', * identity: { name: 'Alice', email: 'alice@example.com' }, * projectRoot: '/project', + * access: { user: 'admin', mcp: 'admin' }, + * channel: 'user', * } * ``` */ @@ -92,6 +95,17 @@ export interface RunContext { /** Project root for template resolution */ projectRoot: string; + /** + * Config's per-channel access roles. Every exported run entrypoint + * (`runBuild`/`runFile`/`runDir`/`runFiles`) gates on this once, at the + * core seam, so SDK/TUI/CLI callers inherit one enforcement path + * instead of re-implementing the check per caller. + */ + access: ConfigAccess; + + /** Caller channel for the policy gate — `user` for CLI/TUI/SDK, `mcp` for MCP. */ + channel: Channel; + /** Database dialect for schema-aware operations. Default: 'postgres' */ dialect?: 'postgres' | 'mysql' | 'sqlite' | 'mssql'; diff --git a/src/core/settings/manager.ts b/src/core/settings/manager.ts index 161a6749..0340c144 100644 --- a/src/core/settings/manager.ts +++ b/src/core/settings/manager.ts @@ -536,20 +536,6 @@ export class SettingsManager { } - /** - * Check if a stage enforces protected: true. - * - * When a stage has protected: true in defaults, configs - * linked to that stage cannot override it to false. - */ - stageEnforcesProtected(stageName: string): boolean { - - const defaults = this.getStageDefaults(stageName); - - return defaults.protected === true; - - } - /** * Check if a stage enforces isTest: true. * diff --git a/src/core/settings/rules.ts b/src/core/settings/rules.ts index 4977c380..fae66e36 100644 --- a/src/core/settings/rules.ts +++ b/src/core/settings/rules.ts @@ -14,6 +14,7 @@ import type { RulesEvaluationResult, ConfigForRuleMatch, } from './types.js'; +import { guarded } from '../policy/index.js'; /** * Check if a single condition matches. @@ -40,6 +41,16 @@ function _matchesCondition( } +/** + * Whether a config counts as "guarded" for rule matching, per the policy + * module's `access`-based definition. + */ +function isConfigGuarded(config: ConfigForRuleMatch): boolean { + + return guarded(config); + +} + /** * Check if all conditions in a rule match the config. * @@ -48,7 +59,7 @@ function _matchesCondition( * @example * ```typescript * const rule = { match: { isTest: true, type: 'local' } } - * const config = { name: 'dev', isTest: true, type: 'local', protected: false } + * const config = { name: 'dev', isTest: true, type: 'local', access: { user: 'admin', mcp: 'admin' } } * * ruleMatches(rule.match, config) // true * ``` @@ -62,7 +73,7 @@ export function ruleMatches(match: RuleMatch, config: ConfigForRuleMatch): boole } - if (match.protected !== undefined && config.protected !== match.protected) { + if (match.protected !== undefined && isConfigGuarded(config) !== match.protected) { return false; diff --git a/src/core/settings/schema.ts b/src/core/settings/schema.ts index a7b3e489..91f55e49 100644 --- a/src/core/settings/schema.ts +++ b/src/core/settings/schema.ts @@ -72,6 +72,7 @@ const StageDefaultsSchema = z.object({ password: z.string().optional(), ssl: z.boolean().optional(), isTest: z.boolean().optional(), + // Settings-stage vocabulary — distinct from the removed `Config.protected`; see docs/spec/config-access-roles.md. protected: z.boolean().optional(), }); @@ -96,6 +97,7 @@ const StageSchema = z.object({ const RuleMatchSchema = z .object({ name: z.string().optional(), + // Settings-stage vocabulary — distinct from the removed `Config.protected`; matched against `guarded(config)`. protected: z.boolean().optional(), isTest: z.boolean().optional(), type: ConnectionTypeSchema.optional(), diff --git a/src/core/settings/types.ts b/src/core/settings/types.ts index bd1c584a..374acc7f 100644 --- a/src/core/settings/types.ts +++ b/src/core/settings/types.ts @@ -5,6 +5,7 @@ * Unlike encrypted configs (credentials), settings are version controlled * and shared across the team via .noorm/settings.yml */ +import type { ConfigAccess } from '../policy/index.js'; /** * Secret type hints for CLI input handling. @@ -46,7 +47,7 @@ export interface StageSecret { * Stage defaults that can be overridden when creating a config. * * These provide initial values. Some are enforceable constraints: - * - protected: true - Cannot be overridden to false + * - protected: true - Clamped as an access ceiling at resolution, not an override-block (`resolver.ts`) * - isTest: true - Cannot be overridden to false * - dialect - Cannot be changed after creation */ @@ -59,6 +60,7 @@ export interface StageDefaults { password?: string; ssl?: boolean; isTest?: boolean; + /** Settings-stage vocabulary — distinct from the removed `Config.protected`; see docs/spec/config-access-roles.md. */ protected?: boolean; } @@ -106,7 +108,7 @@ export interface RuleMatch { /** Config name (exact match) */ name?: string; - /** Protected config flag */ + /** Settings-stage vocabulary — distinct from the removed `Config.protected`; matched against `guarded(config)`. */ protected?: boolean; /** Test database flag */ @@ -302,5 +304,7 @@ export interface ConfigForRuleMatch { name: string; type: 'local' | 'remote'; isTest: boolean; - protected: boolean; + + /** `ruleMatches` matches `match.protected` against `guarded(config)`. */ + access: ConfigAccess; } diff --git a/src/core/sql-terminal/executor.ts b/src/core/sql-terminal/executor.ts index 0cbc1140..f8ce37a9 100644 --- a/src/core/sql-terminal/executor.ts +++ b/src/core/sql-terminal/executor.ts @@ -8,33 +8,48 @@ import { attempt } from '@logosdx/utils'; import type { Kysely } from 'kysely'; import { observer } from '../observer.js'; +import { assertPolicy, classifyStatements } from '../policy/index.js'; +import type { Channel, ConfigAccess, Permission, SqlClass } from '../policy/index.js'; +import type { Dialect } from '../connection/types.js'; import type { SqlExecutionResult } from './types.js'; +/** Maps a classified statement to the permission it's gated by. */ +const CLASS_PERMISSION: Record = { + read: 'sql:read', + write: 'sql:write', + ddl: 'sql:ddl', +}; + /** - * Execute raw SQL and return structured results. + * Policy inputs for the ad-hoc SQL gate. Every production ad-hoc SQL surface + * (RPC `sql` command, CLI `sql`, TUI SQL terminal) passes this: it is the + * single seam where read/write/ddl classification is checked against the + * config's access role for the calling channel. + */ +export interface SqlPolicyGate { + access: ConfigAccess; + channel: Channel; + dialect: Dialect; +} + +/** + * Execute raw SQL and return structured results, with no policy gate. * * Uses Kysely's `sql.raw()` to execute arbitrary SQL. * Emits observer events before and after execution. * + * Execution mechanics only — access control lives in `executeRawSql`. + * Reserved for tests that exercise the Kysely plumbing (row shaping, error + * handling, timing) rather than access control, and for `executeRawSql` + * itself once it has gated. Not for production call sites: every ad-hoc + * SQL surface (RPC, CLI, TUI) must go through `executeRawSql`. + * * @param db - Kysely database instance * @param query - Raw SQL query to execute * @param configName - Config name for event context * @returns Execution result with columns, rows, and metadata - * - * @example - * ```typescript - * const result = await executeRawSql(db, 'SELECT * FROM users LIMIT 10', 'production') - * - * if (result.success) { - * console.log(result.columns) // ['id', 'name', 'email'] - * console.log(result.rows) // [{id: 1, name: 'Alice', ...}, ...] - * } - * else { - * console.error(result.errorMessage) - * } - * ``` */ -export async function executeRawSql( +export async function executeRawSqlUnchecked( db: Kysely, query: string, configName: string, @@ -99,3 +114,51 @@ export async function executeRawSql( }; } + +/** + * Execute raw SQL and return structured results. + * + * Classifies `query` (read/write/ddl) and checks it against `gate.access` + * for `gate.channel` before executing — `gate` is mandatory so the + * production-facing symbol can never run raw SQL ungated. Delegates to + * `executeRawSqlUnchecked` once the check passes. + * + * @param db - Kysely database instance + * @param query - Raw SQL query to execute + * @param configName - Config name for event context + * @param gate - Policy inputs the query is classified and checked against + * @returns Execution result with columns, rows, and metadata + * + * @throws Error carrying the policy's blockedReason when `gate` denies. + * + * @example + * ```typescript + * const result = await executeRawSql(db, 'SELECT * FROM users LIMIT 10', 'production', { + * access: config.access, + * channel: 'user', + * dialect: 'postgres', + * }) + * + * if (result.success) { + * console.log(result.columns) // ['id', 'name', 'email'] + * console.log(result.rows) // [{id: 1, name: 'Alice', ...}, ...] + * } + * else { + * console.error(result.errorMessage) + * } + * ``` + */ +export async function executeRawSql( + db: Kysely, + query: string, + configName: string, + gate: SqlPolicyGate, +): Promise { + + const statementClass = classifyStatements(query, gate.dialect); + + assertPolicy(gate.channel, { name: configName, access: gate.access }, CLASS_PERMISSION[statementClass]); + + return executeRawSqlUnchecked(db, query, configName); + +} diff --git a/src/core/state/manager.ts b/src/core/state/manager.ts index 60d1408b..4ed1ee65 100644 --- a/src/core/state/manager.ts +++ b/src/core/state/manager.ts @@ -12,6 +12,11 @@ import { attemptSync, attempt } from '@logosdx/utils'; import type { Config } from '../config/types.js'; import type { KnownUser } from '../identity/types.js'; import { loadPrivateKey } from '../identity/storage.js'; +import { resolveLegacyAccess } from '../policy/index.js'; +import { + migrateState as migrateSchemaVersion, + needsStateMigration, +} from '../version/state/index.js'; import { encrypt, decrypt } from './encryption/index.js'; import type { State, ConfigSummary, EncryptedPayload } from './types.js'; import { createEmptyState } from './types.js'; @@ -22,6 +27,16 @@ import { observer } from '../observer.js'; const DEFAULT_STATE_DIR = '.noorm/state'; const DEFAULT_STATE_FILE = 'state.enc'; +/** + * Narrows freshly-parsed JSON to a plain record so the schema-version + * migration (which expects `Record`) can run on it. + */ +function isRecord(value: unknown): value is Record { + + return typeof value === 'object' && value !== null; + +} + /** * Options for StateManager constructor. */ @@ -164,13 +179,63 @@ export class StateManager { } + // Two independent migration systems run here, in this order: + // schema-version migrations (core/version/state, keyed on the numeric + // `schemaVersion` field — the canonical version-migration domain, + // e.g. the v2 `protected` -> `access` mapping) run first, on the raw + // record. The package-semver migration below only knows about + // State's own six top-level fields and would silently drop anything + // schema-version-owned (including `schemaVersion` itself) if it ran + // first. + const stateRecord = isRecord(parsedState) ? parsedState : {}; + const schemaMigratedState = migrateSchemaVersion(stateRecord); + // Apply migrations if needed (migrateState emits state:migrated if version changed) - const wasMigrated = needsMigration(parsedState, currentVersion); - this.state = migrateState(parsedState, currentVersion); + const needsVersionMigration = + needsMigration(schemaMigratedState, currentVersion) || + needsStateMigration(stateRecord); + this.state = migrateState(schemaMigratedState, currentVersion); + + // Raw-data-boundary invariant: migrations above guarantee every + // config carries `access`, but a hand-edited or corrupted state + // file can still reach this point without it. This is the single + // place that backfills it, so no downstream consumer + // (setConfig/listConfigs/guarded) needs its own fallback. Tracking + // whether it actually mutated anything (below) feeds the persist + // decision, so a config backfilled at an already-current schema + // version still lands on disk instead of being re-healed forever. + // + // `config` is typed `Config`, but the object underneath is untyped + // JSON that reached the current schema version without ever passing + // through `parseConfig` (e.g. a legacy-shaped config saved directly + // via `setConfig`, bypassing the zod path). It can still carry a + // stray legacy `protected` key the type doesn't declare, so read it + // defensively rather than assuming `undefined` — fail-safe, + // consistent with the fail-closed default: `protected: true` maps + // to operator/viewer, never the admin/admin fallback. + let backfilledAccess = false; + + for (const config of Object.values(this.state.configs)) { + + if (!config.access) { + + backfilledAccess = true; + + } + + const rawConfig: unknown = config; + const legacyProtected = isRecord(rawConfig) && typeof rawConfig['protected'] === 'boolean' + ? rawConfig['protected'] + : undefined; + + config.access = resolveLegacyAccess(config.access, legacyProtected); + + } + this.loaded = true; - // Persist if migrations were applied - if (wasMigrated) { + // Persist if migrations were applied or the backfill above mutated a config + if (needsVersionMigration || backfilledAccess) { this.persist(); @@ -289,7 +354,8 @@ export class StateManager { const state = this.getState(); const isNew = !state.configs[name]; - state.configs[name] = config; + + state.configs[name] = { ...config }; this.persist(); observer.emit(isNew ? 'config:created' : 'config:updated', { @@ -330,7 +396,7 @@ export class StateManager { name, type: config.type, isTest: config.isTest, - protected: config.protected, + access: config.access, isActive: state.activeConfig === name, dialect: config.connection.dialect, database: config.connection.database, diff --git a/src/core/state/migrations.ts b/src/core/state/migrations.ts index 0a5783f0..cf73ae5c 100644 --- a/src/core/state/migrations.ts +++ b/src/core/state/migrations.ts @@ -35,10 +35,17 @@ export function migrateState(state: unknown, currentVersion: string): State { const obj = state as Record; const previousVersion = obj['version'] as string | undefined; + // schemaVersion is owned by the schema-version migration (core/version/state), + // which runs before this function and stamps it onto the input record; + // carry it through rather than dropping it as an unrecognized field. + // Falls back to 0 (unversioned) for callers that skip that stage. + const schemaVersion = typeof obj['schemaVersion'] === 'number' ? obj['schemaVersion'] : 0; + // Build migrated state with defaults for missing fields // Note: identity is now stored globally in ~/.noorm/, not in project state const migrated: State = { version: currentVersion, + schemaVersion, knownUsers: (obj['knownUsers'] as Record) ?? {}, activeConfig: (obj['activeConfig'] as string | null) ?? null, configs: (obj['configs'] as Record as State['configs']) ?? {}, diff --git a/src/core/state/types.ts b/src/core/state/types.ts index c26ad0d5..87dc5972 100644 --- a/src/core/state/types.ts +++ b/src/core/state/types.ts @@ -8,6 +8,7 @@ */ import type { Config, ConfigSummary } from '../config/types.js'; import type { KnownUser } from '../identity/types.js'; +import { CURRENT_VERSIONS } from '../version/types.js'; // Re-export ConfigSummary from config/types to avoid duplication export type { ConfigSummary }; @@ -22,6 +23,14 @@ export interface State { /** Package version that last saved this state */ version: string; + /** + * State schema version (independent of `version` above) — tracks the + * schema-version migrations registered in `core/version/state`, e.g. + * the v2 `protected` -> `access` mapping + * (docs/spec/config-access-roles.md#migration). + */ + schemaVersion: number; + /** Known users discovered from database syncs (identityHash -> KnownUser) */ knownUsers: Record; @@ -72,6 +81,7 @@ export function createEmptyState(version: string): State { return { version, + schemaVersion: CURRENT_VERSIONS.state, knownUsers: {}, activeConfig: null, configs: {}, diff --git a/src/core/transfer/index.ts b/src/core/transfer/index.ts index e0a576f5..27d049d8 100644 --- a/src/core/transfer/index.ts +++ b/src/core/transfer/index.ts @@ -23,7 +23,10 @@ * } * ``` */ +import { attemptSync } from '@logosdx/utils'; + import { withDualConnection } from '../db/dual.js'; +import { assertPolicy } from '../policy/index.js'; import type { Config } from '../config/types.js'; import type { TransferOptions, TransferResult, TransferPlan } from './types.js'; @@ -68,6 +71,17 @@ export async function transferData( options: TransferOptions = {}, ): Promise<[TransferResult | null, Error | null]> { + // Transfer writes into the destination — that's the destructive act, + // so the gate targets destConfig, not sourceConfig. Checked before any + // connection opens; dry runs are gated too (the matrix has no carve-out). + const [, policyErr] = attemptSync(() => assertPolicy(options.channel ?? 'user', destConfig, 'db:reset')); + + if (policyErr) { + + return [null, policyErr]; + + } + // Validate dialects are supported const srcDialect = sourceConfig.connection.dialect; const dstDialect = destConfig.connection.dialect; diff --git a/src/core/transfer/types.ts b/src/core/transfer/types.ts index 9ef541c3..96924910 100644 --- a/src/core/transfer/types.ts +++ b/src/core/transfer/types.ts @@ -6,6 +6,7 @@ */ import type { DtColumn } from '../dt/types.js'; import type { Dialect } from '../connection/types.js'; +import type { Channel } from '../policy/index.js'; /** * Strategy for handling primary key conflicts during transfer. @@ -59,6 +60,12 @@ export interface TransferOptions { /** Passphrase for .dtzx export encryption. */ passphrase?: string; + /** + * Caller channel for the `db:reset` policy gate against the + * destination config (the write target). Default: `'user'`. + */ + channel?: Channel; + } /** diff --git a/src/core/version/state/index.ts b/src/core/version/state/index.ts index 03c4d7a9..784f5575 100644 --- a/src/core/version/state/index.ts +++ b/src/core/version/state/index.ts @@ -22,12 +22,13 @@ import { // Import migrations import { v1 } from './migrations/v1.js'; +import { v2 } from './migrations/v2.js'; /** * All state migrations in order. * Add new migrations here as they're created. */ -const MIGRATIONS: StateMigration[] = [v1]; +const MIGRATIONS: StateMigration[] = [v1, v2]; /** * Get state version from state object. diff --git a/src/core/version/state/migrations/v2.ts b/src/core/version/state/migrations/v2.ts new file mode 100644 index 00000000..bcb18325 --- /dev/null +++ b/src/core/version/state/migrations/v2.ts @@ -0,0 +1,94 @@ +/** + * State Migration v2 - Per-config access roles. + * + * Replaces each config's boolean `protected` flag with channel-scoped + * access roles. For state schema documentation, see + * docs/spec/config-access-roles.md#migration. + */ +import { resolveLegacyAccess } from '../../../policy/index.js'; +import type { StateMigration } from '../../types.js'; + +function isRecord(value: unknown): value is Record { + + return typeof value === 'object' && value !== null; + +} + +/** + * Migration v2: per-config access roles. + * + * - `protected: true` -> `{ user: 'operator', mcp: 'viewer' }` + * - `protected: false` or absent -> `{ user: 'admin', mcp: 'admin' }` + * + * The stored `protected` field is dropped — `access` becomes the sole + * source of truth for a config's roles. + */ +export const v2: StateMigration = { + version: 2, + description: 'Map per-config protected boolean to access roles', + + up(state: Record): Record { + + const rawConfigs = state['configs']; + const configs = isRecord(rawConfigs) ? rawConfigs : {}; + + const migratedConfigs: Record = {}; + + for (const [name, rawConfig] of Object.entries(configs)) { + + if (!isRecord(rawConfig)) { + + migratedConfigs[name] = rawConfig; + continue; + + } + + const { protected: legacyProtected, access, ...rest } = rawConfig; + + migratedConfigs[name] = { + ...rest, + access: access ?? resolveLegacyAccess(undefined, legacyProtected === true), + }; + + } + + return { + ...state, + configs: migratedConfigs, + }; + + }, + + down(state: Record): Record { + + const rawConfigs = state['configs']; + const configs = isRecord(rawConfigs) ? rawConfigs : {}; + + const revertedConfigs: Record = {}; + + for (const [name, rawConfig] of Object.entries(configs)) { + + if (!isRecord(rawConfig)) { + + revertedConfigs[name] = rawConfig; + continue; + + } + + const { access, ...rest } = rawConfig; + const user = isRecord(access) ? access['user'] : undefined; + + revertedConfigs[name] = { + ...rest, + protected: user !== undefined && user !== 'admin', + }; + + } + + return { + ...state, + configs: revertedConfigs, + }; + + }, +}; diff --git a/src/core/version/types.ts b/src/core/version/types.ts index 0190087a..76ef2efe 100644 --- a/src/core/version/types.ts +++ b/src/core/version/types.ts @@ -27,7 +27,7 @@ export const CURRENT_VERSIONS = Object.freeze({ schema: 2, /** State file (state.enc) schema version */ - state: 1, + state: 2, /** Settings file (settings.yml) schema version */ settings: 1, diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 6941e804..7769280d 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -12,7 +12,7 @@ import { createMcpServer } from './server.js'; export async function startServer(): Promise { const registry = createRegistry(); - const session = new SessionManager(); + const session = new SessionManager('mcp'); const server = createMcpServer(registry, session); const transport = new StdioServerTransport(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6c1672ea..a93095b9 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,9 +1,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { attempt } from '@logosdx/utils'; +import { attempt, attemptSync } from '@logosdx/utils'; import type { RpcRegistry } from '../rpc/registry.js'; import type { SessionManager } from '../rpc/session.js'; +import { checkConfigPolicy } from '../core/policy/index.js'; import type { RpcSession } from '../rpc/types.js'; /** @@ -81,6 +82,41 @@ export function createMcpServer(registry: RpcRegistry, session: SessionManager): } + // Gate config-scoped commands before the handler ever runs. `'open'` + // commands (list_configs, connect, disconnect) target no config and + // skip this — target resolution itself requires an active connection. + if (cmd.permission !== 'open') { + + const [target, targetErr] = attemptSync(() => session.getContext(config)); + + if (targetErr) { + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ error: targetErr.message }), + }], + isError: true, + }; + + } + + const check = checkConfigPolicy(session.channel, target.noorm.config, cmd.permission); + + if (!check.allowed) { + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ error: check.blockedReason ?? `"${cmd.permission}" is not allowed.` }), + }], + isError: true, + }; + + } + + } + // For non-session commands, scope the session to the requested config // so handlers calling session.getContext() internally get the right connection. const sessionForHandler = !isSessionCmd && config diff --git a/src/rpc/commands/changes.ts b/src/rpc/commands/changes.ts index 3eece7f3..46864867 100644 --- a/src/rpc/commands/changes.ts +++ b/src/rpc/commands/changes.ts @@ -21,6 +21,7 @@ const changeHistoryCommand: RpcCommand = { { description: 'get last 5', input: { limit: 5 } }, ], inputSchema: historySchema, + permission: 'explore', handler: async (input, session) => { const { limit } = input; @@ -38,6 +39,7 @@ const changeRunCommand: RpcCommand = { { description: 'apply a change', input: { name: '2026-01-15-add-users-table' } }, ], inputSchema: changeNameSchema, + permission: 'change:run', handler: async (input, session) => { const { name } = input; @@ -55,6 +57,7 @@ const changeFfCommand: RpcCommand> = { { description: 'apply all pending', input: {} }, ], inputSchema: z.object({}), + permission: 'change:ff', handler: async (_input, session) => { const ctx = session.getContext(); @@ -71,6 +74,7 @@ const changeRevertCommand: RpcCommand = { { description: 'revert a change', input: { name: '2026-01-15-add-users-table' } }, ], inputSchema: changeNameSchema, + permission: 'change:revert', handler: async (input, session) => { const { name } = input; diff --git a/src/rpc/commands/config.ts b/src/rpc/commands/config.ts index 4e9cd648..40dfa2c2 100644 --- a/src/rpc/commands/config.ts +++ b/src/rpc/commands/config.ts @@ -8,12 +8,13 @@ import { RpcError } from '../types.js'; const listConfigsCommand: RpcCommand, ConfigSummary[]> = { name: 'list_configs', - description: 'List all database configurations with dialect, database name, and protection status. Does not require a database connection.', + description: 'List all database configurations with dialect, database name, and access role. Does not require a database connection.', examples: [ { description: 'list all configs', input: {} }, ], inputSchema: z.object({}), - handler: async (): Promise => { + permission: 'open', + handler: async (_input, session): Promise => { const [manager, err] = await attempt(() => initState()); @@ -23,7 +24,17 @@ const listConfigsCommand: RpcCommand, ConfigSummary[]> = { } - return manager.listConfigs(); + const summaries = manager.listConfigs(); + + // Invisibility: a config with access.mcp === false does not exist + // as far as the mcp channel is concerned. + if (session.channel === 'mcp') { + + return summaries.filter((summary) => summary.access.mcp !== false); + + } + + return summaries; }, }; diff --git a/src/rpc/commands/explore.ts b/src/rpc/commands/explore.ts index 9d99e0b8..c969eae4 100644 --- a/src/rpc/commands/explore.ts +++ b/src/rpc/commands/explore.ts @@ -33,6 +33,7 @@ const overviewCommand: RpcCommand> = { { description: 'get overview', input: {} }, ], inputSchema: z.object({}), + permission: 'explore', handler: async (_input, session) => { const ctx = session.getContext(); @@ -51,6 +52,7 @@ const listCommand: RpcCommand = { { description: 'list foreign keys', input: { category: 'foreignKeys' } }, ], inputSchema: listSchema, + permission: 'explore', handler: async (input, session) => { const { category } = input; @@ -70,6 +72,7 @@ const detailCommand: RpcCommand = { { description: 'view procedure detail', input: { category: 'procedures', name: 'usp_GetUser' } }, ], inputSchema: detailSchema, + permission: 'explore', handler: async (input, session) => { const { category, name, schema } = input; diff --git a/src/rpc/commands/query.ts b/src/rpc/commands/query.ts index aee9fb9c..c2857496 100644 --- a/src/rpc/commands/query.ts +++ b/src/rpc/commands/query.ts @@ -2,8 +2,6 @@ import { z } from 'zod'; import { executeRawSql } from '../../core/sql-terminal/executor.js'; import type { RpcCommand } from '../types.js'; -import { RpcError } from '../types.js'; -import { isReadOnlyStatement } from '../protection.js'; const sqlSchema = z.object({ query: z.string().describe('The SQL query to execute'), @@ -13,27 +11,27 @@ type SqlInput = z.infer; const sqlCommand: RpcCommand = { name: 'sql', - description: 'Execute a raw SQL query. On protected configs, only SELECT, EXPLAIN, SHOW, and DESCRIBE statements are allowed.', + description: 'Execute a raw SQL query. The statement is classified (read/write/ddl) and checked against the config\'s access role for the calling channel.', examples: [ { description: 'simple select', input: { query: 'SELECT * FROM users LIMIT 10' } }, { description: 'count rows', input: { query: 'SELECT COUNT(*) AS total FROM orders' } }, ], inputSchema: sqlSchema, + // Dispatch gates on 'sql:read' (always allowed); the actual class is + // checked by executeRawSql itself once the query text is known — the + // single sql-gate seam shared with the CLI and TUI SQL terminal. + permission: 'sql:read', handler: async (input, session) => { const { query } = input; const ctx = session.getContext(); const config = ctx.noorm.config; - if (config.protected && !isReadOnlyStatement(query, ctx.dialect)) { - - throw new RpcError( - `Config "${config.name}" is protected — only SELECT, EXPLAIN, SHOW, and DESCRIBE are allowed`, - ); - - } - - return executeRawSql(ctx.kysely, query, config.name); + return executeRawSql(ctx.kysely, query, config.name, { + access: config.access, + channel: session.channel, + dialect: ctx.dialect, + }); }, }; diff --git a/src/rpc/commands/run.ts b/src/rpc/commands/run.ts index 7f25046f..27ba5ffb 100644 --- a/src/rpc/commands/run.ts +++ b/src/rpc/commands/run.ts @@ -21,6 +21,7 @@ const runBuildCommand: RpcCommand = { { description: 'force rebuild all', input: { force: true } }, ], inputSchema: buildSchema, + permission: 'run:build', handler: async (input, session) => { const { force } = input; @@ -38,6 +39,7 @@ const runFileCommand: RpcCommand = { { description: 'run a file', input: { path: 'sql/procedures/usp_get_user.sql' } }, ], inputSchema: runFileSchema, + permission: 'run:file', handler: async (input, session) => { const { path } = input; diff --git a/src/rpc/commands/session.ts b/src/rpc/commands/session.ts index c83168f4..2103abf9 100644 --- a/src/rpc/commands/session.ts +++ b/src/rpc/commands/session.ts @@ -21,6 +21,7 @@ const connectCommand: RpcCommand = { { description: 'connect to specific config', input: { config: 'dev' } }, ], inputSchema: connectSchema, + permission: 'open', handler: async (input, session) => { return session.connect(input.config); @@ -36,6 +37,7 @@ const disconnectCommand: RpcCommand = { { description: 'disconnect specific config', input: { config: 'dev' } }, ], inputSchema: disconnectSchema, + permission: 'open', handler: async (input, session) => { await session.disconnect(input.config); diff --git a/src/rpc/protection.ts b/src/rpc/protection.ts deleted file mode 100644 index d0afdd27..00000000 --- a/src/rpc/protection.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { parse } from 'sql-parser-cst'; -import { attemptSync } from '@logosdx/utils'; - -import type { Dialect } from '../core/connection/types.js'; - -/** - * Dialect mapping from noorm to sql-parser-cst. - */ -const DIALECT_MAP: Record = { - sqlite: 'sqlite', - postgres: 'postgresql', - mysql: 'mysql', - mssql: 'postgresql', // best-effort — fall back to keyword if parser chokes -}; - -/** - * Statement types that are read-only. - */ -const READ_ONLY_STMT_TYPES: Record = { - select_stmt: true, - explain_stmt: true, - show_stmt: true, - describe_stmt: true, -}; - -/** - * Keywords that indicate a read-only statement (uppercase). - */ -const READ_ONLY_KEYWORDS: Record = { - SELECT: true, - EXPLAIN: true, - SHOW: true, - DESCRIBE: true, - DESC: true, -}; - -/** - * Check if a SQL string contains only read-only statements. - * - * Strategy: try sql-parser-cst first, fall back to keyword-based - * if the parser throws (e.g., MSSQL-specific syntax). - * - * @example - * isReadOnlyStatement('SELECT * FROM users', 'postgres'); // true - * isReadOnlyStatement('DROP TABLE users', 'postgres'); // false - */ -export function isReadOnlyStatement(sql: string, dialect: Dialect): boolean { - - const trimmed = sql.trim(); - - if (trimmed === '') return true; - - // Try CST parser - const [result, err] = attemptSync(() => - parse(trimmed, { - dialect: DIALECT_MAP[dialect], - includeComments: false, - includeSpaces: false, - includeNewlines: false, - }), - ); - - if (!err && result) { - - return isReadOnlyCst(result); - - } - - // Parser failed — fall back to keyword-based - return isReadOnlyKeyword(trimmed); - -} - -/** - * Check read-only via CST. - * - * Iterates all parsed statements and verifies each maps to an allowed type. - */ -function isReadOnlyCst(program: { statements: Array<{ type: string }> }): boolean { - - for (const stmt of program.statements) { - - if (!READ_ONLY_STMT_TYPES[stmt.type]) { - - return false; - - } - - } - - return true; - -} - -/** - * Check read-only via keyword analysis. - * - * Strips comments, splits on semicolons, checks leading keyword. - */ -function isReadOnlyKeyword(sql: string): boolean { - - const stripped = stripComments(sql); - const statements = splitStatements(stripped); - - if (statements.length === 0) return true; - - for (const stmt of statements) { - - if (!isStatementReadOnly(stmt)) { - - return false; - - } - - } - - return true; - -} - -/** - * Check if a single statement (no comments, no semicolons) is read-only. - * - * Handles CTEs via paren-depth tracking for the WITH keyword. - */ -function isStatementReadOnly(stmt: string): boolean { - - const upper = stmt.toUpperCase(); - const firstWord = upper.match(/^(\w+)/)?.[1]; - - if (!firstWord) return true; - - if (READ_ONLY_KEYWORDS[firstWord]) return true; - - // Handle WITH ... SELECT (CTE) - if (firstWord === 'WITH') { - - return isCteReadOnly(upper); - - } - - return false; - -} - -/** - * Check if a CTE (WITH ...) ends with a SELECT. - * - * Finds the last top-level keyword after all CTE definitions by tracking - * parenthesis depth to skip past nested subqueries. - */ -function isCteReadOnly(upper: string): boolean { - - // Find the final statement after the CTE definitions. - // CTEs are: WITH name AS (...), name AS (...) - // We need to find the keyword after the last closing paren at depth 0. - let depth = 0; - let lastCloseIdx = -1; - - for (let i = 0; i < upper.length; i++) { - - if (upper[i] === '(') depth++; - if (upper[i] === ')') { - - depth--; - - if (depth === 0) { - - lastCloseIdx = i; - - } - - } - - } - - if (lastCloseIdx === -1) return false; - - const afterCte = upper.slice(lastCloseIdx + 1).trim(); - - // Skip optional comma (recursive CTEs) - const finalKeyword = afterCte.replace(/^,/, '').trim().match(/^(\w+)/)?.[1]; - - return finalKeyword === 'SELECT'; - -} - -/** - * Split SQL on semicolons while respecting string literals. - * - * Naive split(';') mishandles semicolons inside quoted strings. - * This walks the string tracking quote state to split correctly. - */ -function splitStatements(sql: string): string[] { - - const statements: string[] = []; - let current = ''; - let inString = false; - let i = 0; - - while (i < sql.length) { - - if (sql[i] === "'" && !inString) { - - inString = true; - current += sql[i++]; - - } - else if (sql[i] === "'" && inString) { - - if (sql[i + 1] === "'") { - - current += "''"; - i += 2; - - } - else { - - inString = false; - current += sql[i++]; - - } - - } - else if (sql[i] === ';' && !inString) { - - const trimmed = current.trim(); - - if (trimmed.length > 0) { - - statements.push(trimmed); - - } - - current = ''; - i++; - - } - else { - - current += sql[i++]; - - } - - } - - const trimmed = current.trim(); - - if (trimmed.length > 0) { - - statements.push(trimmed); - - } - - return statements; - -} - -/** - * Strip SQL comments while respecting string literals. - * - * Uses a state machine to avoid stripping comment markers that - * appear inside single-quoted strings. This prevents crafted inputs - * from hiding destructive SQL behind comment markers embedded in - * string literals. - */ -function stripComments(sql: string): string { - - let result = ''; - let i = 0; - - while (i < sql.length) { - - // Single-quoted string — copy verbatim (handles '' escapes) - if (sql[i] === "'") { - - result += sql[i++]; - - while (i < sql.length) { - - if (sql[i] === "'" && sql[i + 1] === "'") { - - result += "''"; - i += 2; - - } - else if (sql[i] === "'") { - - result += sql[i++]; - break; - - } - else { - - result += sql[i++]; - - } - - } - - } - // Block comment — skip - else if (sql[i] === '/' && sql[i + 1] === '*') { - - i += 2; - - while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) { - - i++; - - } - - i += 2; // skip closing */ - - } - // Line comment — skip to end of line - else if (sql[i] === '-' && sql[i + 1] === '-') { - - i += 2; - - while (i < sql.length && sql[i] !== '\n') { - - i++; - - } - - } - else { - - result += sql[i++]; - - } - - } - - return result; - -} diff --git a/src/rpc/session.ts b/src/rpc/session.ts index d94021b5..a922ff63 100644 --- a/src/rpc/session.ts +++ b/src/rpc/session.ts @@ -1,6 +1,8 @@ import { attempt } from '@logosdx/utils'; import { createContext, type Context } from '../sdk/index.js'; +import { configNotFoundMessage } from '../core/config/resolver.js'; +import type { Channel, Role } from '../core/policy/index.js'; import { RpcError, type RpcSession } from './types.js'; /** @@ -10,7 +12,7 @@ export interface SessionInfo { name: string; dialect: string; database: string; - protected: boolean; + role: Role; } /** @@ -24,11 +26,31 @@ export class SessionManager implements RpcSession { #contexts = new Map(); + /** + * The channel this session was opened on. Drives policy checks + * (`checkConfigPolicy`) and mcp-channel invisibility in `connect`/ + * `getContext`. Defaults to `'user'` so pre-existing `new SessionManager()` + * call sites keep working; `mcp serve` passes `'mcp'` explicitly. + */ + readonly channel: Channel; + + constructor(channel: Channel = 'user') { + + this.channel = channel; + + } + /** * Connect to a database configuration. * * Creates a Context, connects, and stores it. * If config is omitted, resolves the active config from state. + * + * On the `mcp` channel, a config with `access.mcp === false` (or no + * `access` at all — fail closed per docs/spec/config-access-roles.md) + * is invisible: this throws the byte-identical error an unknown config + * name produces, rather than surfacing that the config exists but is + * off-limits. */ async connect(config?: string): Promise { @@ -40,6 +62,15 @@ export class SessionManager implements RpcSession { } + const resolvedName = ctx.noorm.config.name; + const rawAccess = ctx.noorm.config.access; + + if (this.channel === 'mcp' && (!rawAccess || rawAccess.mcp === false)) { + + throw new RpcError('Failed to create context', configNotFoundMessage(resolvedName)); + + } + const [, connectErr] = await attempt(() => ctx.connect()); if (connectErr) { @@ -48,15 +79,15 @@ export class SessionManager implements RpcSession { } - const resolvedName = ctx.noorm.config.name; - this.#contexts.set(resolvedName, ctx); return { name: resolvedName, dialect: ctx.dialect, database: ctx.noorm.config.connection.database, - protected: ctx.noorm.config.protected, + // access.mcp === false is unreachable here — the invisibility + // guard above already denies before a context is ever stored. + role: this.channel === 'mcp' ? (rawAccess.mcp === false ? 'viewer' : rawAccess.mcp) : rawAccess.user, }; } @@ -88,7 +119,12 @@ export class SessionManager implements RpcSession { /** * Get the active context for a config. * - * Throws if not connected. + * Throws if not connected. Does not re-check `access` — `connect()` is + * the sole writer into `#contexts` and already gates on it, and a config + * edit lands in a separate CLI process; it can't mutate this process's + * already-held `Context.config`, so it only takes effect on the next + * `connect()`. Re-checking a snapshot on every call would add cost + * without closing any real gap. */ getContext(config?: string): Context { diff --git a/src/rpc/types.ts b/src/rpc/types.ts index 15ad64b4..77a4f79b 100644 --- a/src/rpc/types.ts +++ b/src/rpc/types.ts @@ -1,11 +1,15 @@ import type { z } from 'zod'; import type { Context } from '../sdk/context.js'; +import type { Channel, Permission, Role } from '../core/policy/index.js'; /** * A registered RPC command. * * Commands are transport-agnostic — they define what operations are available, - * validate input via Zod, and execute against SDK/core functions. + * validate input via Zod, and execute against SDK/core functions. `permission` + * gates the command at dispatch (`src/mcp/server.ts`); `'open'` means the + * command targets no config and skips the gate (`list_configs`, `connect`, + * `disconnect`). */ export interface RpcCommand { @@ -13,6 +17,7 @@ export interface RpcCommand { description: string; examples: RpcExample[]; inputSchema: z.ZodType; + permission: Permission | 'open'; handler: (input: TInput, session: RpcSession) => Promise; } @@ -20,13 +25,16 @@ export interface RpcCommand { /** * Session interface for RPC command handlers. * - * Provides access to database connections. + * Provides access to database connections and the channel (`user`/`mcp`) + * the session was opened on, so handlers can run channel-aware policy + * checks (e.g. the `sql` command's statement-class escalation). * Implemented by SessionManager in session.ts. */ export interface RpcSession { + readonly channel: Channel; getContext(config?: string): Context; - connect(config?: string): Promise<{ name: string; dialect: string; database: string; protected: boolean }>; + connect(config?: string): Promise<{ name: string; dialect: string; database: string; role: Role }>; disconnect(config?: string): Promise; disconnectAll(): Promise; hasConnection(config: string): boolean; diff --git a/src/sdk/guards.ts b/src/sdk/guards.ts index bce8f9e5..74145355 100644 --- a/src/sdk/guards.ts +++ b/src/sdk/guards.ts @@ -4,6 +4,8 @@ * Guards protect against accidental destructive operations * on production or protected databases. */ +import { checkConfigPolicy } from '../core/policy/index.js'; +import type { Permission } from '../core/policy/index.js'; import type { Config } from '../core/config/types.js'; import type { CreateContextOptions } from './types.js'; @@ -35,11 +37,13 @@ export class RequireTestError extends Error { } /** - * Error thrown when attempting destructive operations on protected configs. + * Error thrown when the config's access policy blocks a destructive + * operation — either the role denies it outright, or the role requires + * confirmation the SDK cannot provide interactively. * * @example * ```typescript - * // config.protected is true — all destructive ops are blocked + * // config.access.user is 'operator' — db:reset requires confirmation the SDK can't give * await ctx.noorm.db.truncate() // Throws ProtectedConfigError * ``` */ @@ -50,9 +54,14 @@ export class ProtectedConfigError extends Error { constructor( public readonly configName: string, public readonly operation: string, + reason?: string, ) { - super(`Cannot ${operation} on protected config "${configName}"`); + super( + reason + ? `Cannot ${operation} on config "${configName}": ${reason}` + : `Cannot ${operation} on protected config "${configName}"`, + ); } @@ -81,18 +90,38 @@ export function checkRequireTest( } /** - * Check if operation is allowed on protected config. + * Check if a destructive operation is allowed on a config, given the + * channel the context was created on. * - * @throws ProtectedConfigError if config is protected + * The SDK has no interactive prompt: a permission that resolves to + * "requires confirmation" (matrix `confirm` cells on the `user` channel) + * blocks just like an outright denial, naming `NOORM_YES=1` as the + * scripted opt-in and the CLI/TUI as the interactive route. + * + * @throws ProtectedConfigError if the policy denies or requires confirmation */ export function checkProtectedConfig( config: Config, + options: Pick, + permission: Permission, operation: string, ): void { - if (config.protected) { + const check = checkConfigPolicy(options.channel ?? 'user', config, permission); + + if (!check.allowed) { + + throw new ProtectedConfigError(config.name, operation, check.blockedReason); + + } + + if (check.requiresConfirmation) { - throw new ProtectedConfigError(config.name, operation); + throw new ProtectedConfigError( + config.name, + operation, + 'requires confirmation — set NOORM_YES=1 for scripted use, or run this via the noorm CLI/TUI to confirm interactively', + ); } diff --git a/src/sdk/index.ts b/src/sdk/index.ts index b7164acf..36e1458d 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -141,7 +141,11 @@ export async function createContext(config, settings, identity, options, projectRoot); + // Default the channel so guards.ts can always read it off state.options, + // without every call site (or ContextState fixture) special-casing undefined. + const resolvedOptions: CreateContextOptions = { ...options, channel: options.channel ?? 'user' }; + + return new Context(config, settings, identity, resolvedOptions, projectRoot); } @@ -176,6 +180,11 @@ export type { ExtractReturn, } from './types.js'; +// Access policy types — `Config.access` (re-exported below) is typed +// `ConfigAccess`, whose fields are typed `Role`; re-exporting all three +// keeps the public surface closed under its own references. +export type { Channel, ConfigAccess, Role } from '../core/policy/index.js'; + // TVP (Table-Valued Parameters) export { tvp } from './tvp.js'; export type { TvpValue } from './tvp.js'; diff --git a/src/sdk/namespaces/changes.ts b/src/sdk/namespaces/changes.ts index 5aa65734..d9c3b126 100644 --- a/src/sdk/namespaces/changes.ts +++ b/src/sdk/namespaces/changes.ts @@ -233,6 +233,8 @@ export class ChangesNamespace { */ async apply(name: string, options?: ChangeOptions): Promise { + checkProtectedConfig(this.#state.config, this.#state.options, 'change:run', 'changes.apply'); + return this.#getManager().run(name, options); } @@ -254,7 +256,7 @@ export class ChangesNamespace { */ async revert(name: string, options?: ChangeOptions): Promise { - checkProtectedConfig(this.#state.config, 'changes.revert'); + checkProtectedConfig(this.#state.config, this.#state.options, 'change:revert', 'changes.revert'); return this.#getManager().revert(name, options); @@ -277,6 +279,8 @@ export class ChangesNamespace { */ async ff(options?: BatchChangeOptions): Promise { + checkProtectedConfig(this.#state.config, this.#state.options, 'change:ff', 'changes.ff'); + return this.#getManager().ff(options); } @@ -297,6 +301,8 @@ export class ChangesNamespace { */ async next(count: number = 1, options?: BatchChangeOptions): Promise { + checkProtectedConfig(this.#state.config, this.#state.options, 'change:run', 'changes.next'); + return this.#getManager().next(count, options); } @@ -381,7 +387,7 @@ export class ChangesNamespace { */ async rewind(target: number | string): Promise { - checkProtectedConfig(this.#state.config, 'changes.revert'); + checkProtectedConfig(this.#state.config, this.#state.options, 'change:revert', 'changes.rewind'); return this.#getManager().rewind(target); @@ -457,6 +463,8 @@ export class ChangesNamespace { projectRoot: this.#state.projectRoot, changesDir: this.#changesDir, sqlDir: this.#sqlDir, + access: this.#state.config.access, + channel: this.#state.options.channel ?? 'user', config: this.#state.config as unknown as Record, secrets: state.getAllSecrets(this.#state.config.name), globalSecrets: state.getAllGlobalSecrets(), diff --git a/src/sdk/namespaces/db.ts b/src/sdk/namespaces/db.ts index c704b6fa..df82d7a1 100644 --- a/src/sdk/namespaces/db.ts +++ b/src/sdk/namespaces/db.ts @@ -2,7 +2,8 @@ * Db namespace — database exploration and schema operations. * * Mirrors [d] db in the TUI. All operations require a connection. - * Destructive operations are unconditionally blocked on protected configs. + * Destructive operations are gated by the config's `db:reset` access + * (see `checkProtectedConfig` in ../guards.ts). */ import type { Kysely } from 'kysely'; @@ -274,7 +275,7 @@ export class DbNamespace { */ async truncate(options?: TruncateOptions): Promise { - checkProtectedConfig(this.#state.config, 'truncate'); + checkProtectedConfig(this.#state.config, this.#state.options, 'db:reset', 'truncate'); const preserve = options?.preserve ?? this.#state.settings.teardown?.preserveTables; @@ -296,7 +297,7 @@ export class DbNamespace { */ async teardown(): Promise { - checkProtectedConfig(this.#state.config, 'teardown'); + checkProtectedConfig(this.#state.config, this.#state.options, 'db:reset', 'teardown'); return teardownSchema(this.#kysely, this.#dialect, { configName: this.#state.config.name, @@ -317,7 +318,7 @@ export class DbNamespace { */ async reset(): Promise { - checkProtectedConfig(this.#state.config, 'reset'); + checkProtectedConfig(this.#state.config, this.#state.options, 'db:reset', 'reset'); // Full teardown — deliberately does NOT honor preserveTables. // reset() rebuilds the entire schema from sql/, so any table left diff --git a/src/sdk/namespaces/dt.ts b/src/sdk/namespaces/dt.ts index 1ce745a8..8148bdbc 100644 --- a/src/sdk/namespaces/dt.ts +++ b/src/sdk/namespaces/dt.ts @@ -67,7 +67,7 @@ export class DtNamespace { options?: ImportOptions, ): Promise<[{ rowsImported: number; rowsSkipped: number } | null, Error | null]> { - checkProtectedConfig(this.#state.config, 'dt.import'); + checkProtectedConfig(this.#state.config, this.#state.options, 'db:reset', 'dt.importFile'); return importDtFile({ filepath, diff --git a/src/sdk/namespaces/run.ts b/src/sdk/namespaces/run.ts index 23a693be..7e7ad989 100644 --- a/src/sdk/namespaces/run.ts +++ b/src/sdk/namespaces/run.ts @@ -24,6 +24,7 @@ import { discoverFiles as coreDiscoverFiles, } from '../../core/runner/index.js'; import { getStateManager } from '../../core/state/index.js'; +import { checkProtectedConfig } from '../guards.js'; import type { ContextState } from '../state.js'; import type { BuildOptions } from '../types.js'; @@ -102,6 +103,8 @@ export class RunNamespace { */ async file(filepath: string, options?: RunOptions): Promise { + checkProtectedConfig(this.#state.config, this.#state.options, 'run:file', 'run.file'); + const context = this.#createRunContext(); const absolutePath = this.#resolvePath(filepath); @@ -119,6 +122,8 @@ export class RunNamespace { */ async files(filepaths: string[], options?: RunOptions): Promise { + checkProtectedConfig(this.#state.config, this.#state.options, 'run:dir', 'run.files'); + const context = this.#createRunContext(); const absolutePaths = filepaths.map((fp) => this.#resolvePath(fp)); @@ -136,6 +141,8 @@ export class RunNamespace { */ async dir(dirpath: string, options?: RunOptions): Promise { + checkProtectedConfig(this.#state.config, this.#state.options, 'run:dir', 'run.dir'); + const context = this.#createRunContext(); const absolutePath = this.#resolvePath(dirpath); @@ -153,6 +160,8 @@ export class RunNamespace { */ async build(options?: BuildOptions): Promise { + checkProtectedConfig(this.#state.config, this.#state.options, 'run:build', 'run.build'); + const context = this.#createRunContext(); const sqlPath = path.join( this.#state.projectRoot, @@ -197,6 +206,8 @@ export class RunNamespace { identity: this.#state.identity, projectRoot: this.#state.projectRoot, dialect: this.#state.config.connection.dialect, + access: this.#state.config.access, + channel: this.#state.options.channel ?? 'user', config: this.#state.config as unknown as Record, secrets: state.getAllSecrets(this.#state.config.name), globalSecrets: state.getAllGlobalSecrets(), diff --git a/src/sdk/namespaces/transfer.ts b/src/sdk/namespaces/transfer.ts index a3191cb2..125dcd11 100644 --- a/src/sdk/namespaces/transfer.ts +++ b/src/sdk/namespaces/transfer.ts @@ -6,6 +6,7 @@ import type { Config } from '../../core/config/types.js'; import type { TransferOptions, TransferResult, TransferPlan } from '../../core/transfer/types.js'; import { transferData, getTransferPlan } from '../../core/transfer/index.js'; +import { checkProtectedConfig } from '../guards.js'; import type { ContextState } from '../state.js'; @@ -39,7 +40,15 @@ export class TransferNamespace { options?: TransferOptions, ): Promise<[TransferResult | null, Error | null]> { - return transferData(this.#state.config, destConfig, options); + // Gated against destConfig (the write target), not the source — the + // SDK has no interactive prompt, so a `db:reset` confirm cell blocks + // outright here, same as db.truncate()/dt.importFile(). + checkProtectedConfig(destConfig, this.#state.options, 'db:reset', 'transfer.to'); + + return transferData(this.#state.config, destConfig, { + ...options, + channel: this.#state.options.channel ?? 'user', + }); } diff --git a/src/sdk/types.ts b/src/sdk/types.ts index 8f318c22..b0dc2388 100644 --- a/src/sdk/types.ts +++ b/src/sdk/types.ts @@ -3,6 +3,7 @@ * * All interfaces and types for the noorm programmatic SDK. */ +import type { Channel } from '../core/policy/index.js'; // ───────────────────────────────────────────────────────────── @@ -53,6 +54,13 @@ export interface CreateContextOptions { /** Stage name for stage defaults (from settings.yml) */ stage?: string; + /** + * Which caller channel this context represents for access-policy checks + * (`checkPolicy` in `src/sdk/guards.ts`). SDK/CLI/TUI callers are `user`; + * pass `'mcp'` when the context backs an MCP session. Default: `'user'`. + */ + channel?: Channel; + } // ───────────────────────────────────────────────────────────── diff --git a/src/tui/app-context.tsx b/src/tui/app-context.tsx index 47e441f8..edc41973 100644 --- a/src/tui/app-context.tsx +++ b/src/tui/app-context.tsx @@ -52,6 +52,7 @@ import type { StateManager } from '../core/state/manager.js'; import type { SettingsManager } from '../core/settings/manager.js'; import type { CryptoIdentity } from '../core/identity/types.js'; import { loadExistingIdentity } from '../core/identity/index.js'; +import { GUARDED_ACCESS, OPEN_ACCESS } from '../core/policy/index.js'; // ───────────────────────────────────────────────────────────── // Project Name Detection @@ -410,13 +411,14 @@ export function AppContextProvider({ if (!stage.defaults?.dialect) continue; // Create placeholder config from stage defaults + const access = stage.defaults.protected ? GUARDED_ACCESS : OPEN_ACCESS; const config: Config = { name: stageName, type: stage.defaults.host && stage.defaults.host !== 'localhost' ? 'remote' : 'local', isTest: stage.defaults.isTest ?? false, - protected: stage.defaults.protected ?? false, + access, connection: { dialect: stage.defaults.dialect, host: stage.defaults.host ?? 'localhost', diff --git a/src/tui/components/dialogs/ProtectedConfirm.tsx b/src/tui/components/dialogs/ProtectedConfirm.tsx index caae8cc9..aeb3d63f 100644 --- a/src/tui/components/dialogs/ProtectedConfirm.tsx +++ b/src/tui/components/dialogs/ProtectedConfirm.tsx @@ -8,6 +8,7 @@ * ```tsx * deleteConfig('production')} * onCancel={() => setShowConfirm(false)} @@ -30,6 +31,13 @@ export interface ProtectedConfirmProps { /** Name of the protected configuration */ configName: string; + /** + * Phrase the user must type to confirm — sourced from `checkPolicy`'s + * `confirmationPhrase` (or hand-built by callers with no matrix + * permission, e.g. lock force-release). Never recomputed here. + */ + confirmPhrase: string; + /** Action being performed (for display) */ action: string; @@ -54,6 +62,7 @@ export interface ProtectedConfirmProps { */ export function ProtectedConfirm({ configName, + confirmPhrase, action, onConfirm, onCancel, @@ -68,7 +77,6 @@ export function ProtectedConfirm({ }); const isFocused = hasExternalFocus ? externalFocused : internalFocus.isFocused; - const confirmPhrase = `yes-${configName}`; const [input, setInput] = useState(''); const [error, setError] = useState(null); diff --git a/src/tui/components/dialogs/SmartConfirm.tsx b/src/tui/components/dialogs/SmartConfirm.tsx index 38adc8d6..a4dc7e65 100644 --- a/src/tui/components/dialogs/SmartConfirm.tsx +++ b/src/tui/components/dialogs/SmartConfirm.tsx @@ -1,12 +1,17 @@ /** - * SmartConfirm — renders ProtectedConfirm or Confirm based on protection status. + * SmartConfirm — renders ProtectedConfirm or Confirm based on a policy check. * * Eliminates the repeated if/else branch pattern across screens that - * conditionally show type-to-confirm vs simple yes/no confirmation. + * conditionally show type-to-confirm vs simple yes/no confirmation. Takes + * `requiresConfirmation`/`confirmationPhrase` as flat props (mirroring + * `PolicyCheck`'s shape) rather than a single `check` object, matching this + * component's existing flat prop style. * * @example + * const check = checkConfigPolicy('user', activeConfig, 'change:run'); * + {check.blockedReason} + + ); + + } + // Loading if (step === 'loading') { @@ -252,11 +266,12 @@ export function ChangeFFScreen({ params: _params }: ScreenProps): ReactElement { ); return ( - + {confirmContent} + {check.blockedReason} + + ); + + } + // Loading if (step === 'loading') { @@ -306,11 +320,12 @@ export function ChangeNextScreen({ params }: ScreenProps): ReactElement { ); return ( - + {confirmContent} + {check.blockedReason} + + ); + + } + // Loading if (step === 'loading') { @@ -245,11 +261,12 @@ export function ChangeRevertScreen({ params }: ScreenProps): ReactElement { ); return ( - + {confirmContent} + {check.blockedReason} + + ); + + } + // Loading if (step === 'loading') { @@ -374,11 +388,12 @@ export function ChangeRewindScreen({ params }: ScreenProps): ReactElement { ); return ( - + {confirmContent} + {check.blockedReason} + + ); + + } + // Loading if (step === 'loading') { @@ -241,11 +257,12 @@ export function ChangeRunScreen({ params }: ScreenProps): ReactElement { ); return ( - + {confirmContent} (null); + // Default access for a brand-new config: the matched stage's `protected` + // flag (guarded when true) if the caller navigated with a known stage + // name, otherwise fully open. + const matchedStage = params.name ? settings?.stages?.[params.name] : undefined; + const defaultAccess = matchedStage?.defaults?.protected ? GUARDED_ACCESS : OPEN_ACCESS; + // Form fields for config creation const fields: FormField[] = [ { @@ -102,10 +118,18 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { placeholder: '(optional)', }, { - key: 'protected', - label: 'Protected (requires confirmation for destructive ops)', - type: 'checkbox', - defaultValue: false, + key: 'userRole', + label: 'User Role (CLI/TUI access)', + type: 'select', + options: USER_ROLE_OPTIONS, + defaultValue: defaultAccess.user, + }, + { + key: 'mcpRole', + label: 'MCP Role (agent access)', + type: 'select', + options: MCP_ROLE_OPTIONS, + defaultValue: defaultAccess.mcp === false ? 'off' : defaultAccess.mcp, }, { key: 'isTest', @@ -150,11 +174,12 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { // Build full config const configName = String(values['name']); + const access = buildAccessFromValues(values); const config: Config = { name: configName, type: 'local', isTest: Boolean(values['isTest']), - protected: Boolean(values['protected']), + access, connection: connectionConfig, }; diff --git a/src/tui/screens/config/ConfigEditScreen.tsx b/src/tui/screens/config/ConfigEditScreen.tsx index eb6223b3..427accfb 100644 --- a/src/tui/screens/config/ConfigEditScreen.tsx +++ b/src/tui/screens/config/ConfigEditScreen.tsx @@ -23,7 +23,15 @@ import { useRouter } from '../../router.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Form, useToast, MissingParamPanel, NotFoundPanel } from '../../components/index.js'; import { testConnection } from '../../../core/connection/factory.js'; -import { getErrorMessage, validateConfigName, validatePort, buildConnectionConfig } from '../../utils/index.js'; +import { + getErrorMessage, + validateConfigName, + validatePort, + buildConnectionConfig, + buildAccessFromValues, + USER_ROLE_OPTIONS, + MCP_ROLE_OPTIONS, +} from '../../utils/index.js'; /** * ConfigEditScreen component. @@ -54,6 +62,8 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { if (!config) return []; + const access = config.access; + return [ { key: 'name', @@ -104,10 +114,18 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { placeholder: '(unchanged if empty)', }, { - key: 'protected', - label: 'Protected', - type: 'checkbox', - defaultValue: config.protected, + key: 'userRole', + label: 'User Role (CLI/TUI access)', + type: 'select', + options: USER_ROLE_OPTIONS, + defaultValue: access.user, + }, + { + key: 'mcpRole', + label: 'MCP Role (agent access)', + type: 'select', + options: MCP_ROLE_OPTIONS, + defaultValue: access.mcp === false ? 'off' : access.mcp, }, { key: 'isTest', @@ -157,11 +175,12 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { // Build updated config const newName = String(values['name']); + const access = buildAccessFromValues(values); const updatedConfig = { ...config, name: newName, isTest: Boolean(values['isTest']), - protected: Boolean(values['protected']), + access, connection: connectionConfig, }; diff --git a/src/tui/screens/config/ConfigExportScreen.tsx b/src/tui/screens/config/ConfigExportScreen.tsx index 6eb1b0a3..151a5507 100644 --- a/src/tui/screens/config/ConfigExportScreen.tsx +++ b/src/tui/screens/config/ConfigExportScreen.tsx @@ -32,6 +32,7 @@ import { } from '../../components/index.js'; import { encryptForRecipient } from '../../../core/identity/crypto.js'; import type { KnownUser } from '../../../core/identity/types.js'; +import { guarded } from '../../../core/policy/index.js'; import { getErrorMessage } from '../../utils/index.js'; /** @@ -128,13 +129,17 @@ export function ConfigExportScreen({ params }: ScreenProps): ReactElement { const [_, err] = await attempt(async () => { - // Build export data (omit user/password) + // Build export data (omit user/password). `access` is the + // source of truth; `protected` rides along so an + // as-yet-unupgraded importer on the recipient's end still + // gets a safe (if coarser) access decision. const exportData = { config: { name: config.name, type: config.type, isTest: config.isTest, - protected: config.protected, + access: config.access, + protected: guarded(config), connection: { dialect: config.connection.dialect, host: config.connection.host, diff --git a/src/tui/screens/config/ConfigImportScreen.tsx b/src/tui/screens/config/ConfigImportScreen.tsx index 2c8e7fb3..2f5b4b05 100644 --- a/src/tui/screens/config/ConfigImportScreen.tsx +++ b/src/tui/screens/config/ConfigImportScreen.tsx @@ -33,6 +33,7 @@ import { import { decryptWithPrivateKey } from '../../../core/identity/crypto.js'; import { loadPrivateKey } from '../../../core/identity/storage.js'; import type { SharedConfigPayload } from '../../../core/identity/types.js'; +import { resolveLegacyAccess } from '../../../core/policy/index.js'; import { getErrorMessage } from '../../utils/index.js'; /** @@ -48,9 +49,12 @@ type ImportStep = /** * Imported config data structure. + * + * `config.protected` is the legacy field a not-yet-upgraded exporter may + * still send; `resolveLegacyAccess` maps it to `access` on import. */ interface ImportedData { - config: Partial; + config: Partial & { protected?: boolean }; secrets: Record; } @@ -203,13 +207,14 @@ export function ConfigImportScreen({ params }: ScreenProps): ReactElement { const [_, err] = await attempt(async () => { const configName = String(values['name'] || importedData.config.name); + const access = resolveLegacyAccess(importedData.config.access, importedData.config.protected); // Build full config with credentials const config: Config = { name: configName, type: importedData.config.type ?? 'local', isTest: importedData.config.isTest ?? false, - protected: importedData.config.protected ?? false, + access, connection: { dialect: importedData.config.connection?.dialect ?? 'postgres', host: importedData.config.connection?.host, diff --git a/src/tui/screens/config/ConfigListScreen.tsx b/src/tui/screens/config/ConfigListScreen.tsx index 524755b8..1028e1f7 100644 --- a/src/tui/screens/config/ConfigListScreen.tsx +++ b/src/tui/screens/config/ConfigListScreen.tsx @@ -1,7 +1,7 @@ /** * ConfigListScreen - displays all database configurations. * - * Shows a list of configs with their status (active, dialect, protected). + * Shows a list of configs with their status (active, dialect, access roles). * Keyboard shortcuts provide quick access to actions: * - Enter: Set as active config * - a: Add new config @@ -27,6 +27,8 @@ import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, SelectList, type SelectListItem } from '../../components/index.js'; import { syncIdentityWithConfig } from '../../../core/identity/index.js'; +import { guarded } from '../../../core/policy/index.js'; +import type { ConfigAccess } from '../../../core/policy/index.js'; /** * Config list item value. @@ -35,10 +37,25 @@ interface ConfigListValue { name: string; dialect: string; isActive: boolean; - protected: boolean; + access: ConfigAccess; isTest: boolean; } +/** + * Formats access as `user: mcp:` — same format as + * `noorm config list` (`src/cli/config/list.ts`) — omitted entirely for + * fully open (admin/admin) configs. + */ +function formatAccessTag(config: { name: string; access: ConfigAccess }): string | null { + + if (!guarded(config)) return null; + + const { access } = config; + + return `user:${access.user} mcp:${access.mcp === false ? 'off' : access.mcp}`; + +} + /** * ConfigListScreen component. * @@ -56,19 +73,25 @@ export function ConfigListScreen({ params: _params }: ScreenProps): ReactElement ); // Convert configs to list items - const items: SelectListItem[] = configs.map((config) => ({ - key: config.name, - label: config.name, - value: { - name: config.name, - dialect: config.dialect, - isActive: config.isActive, - protected: config.protected, - isTest: config.isTest, - }, - description: `${config.dialect}${config.isActive ? ' (active)' : ''}${config.protected ? ' [protected]' : ''}${config.isTest ? ' [test]' : ''}`, - icon: config.isActive ? '●' : '○', - })); + const items: SelectListItem[] = configs.map((config) => { + + const accessTag = formatAccessTag(config); + + return { + key: config.name, + label: config.name, + value: { + name: config.name, + dialect: config.dialect, + isActive: config.isActive, + access: config.access, + isTest: config.isTest, + }, + description: `${config.dialect}${config.isActive ? ' (active)' : ''}${accessTag ? ` [${accessTag}]` : ''}${config.isTest ? ' [test]' : ''}`, + icon: config.isActive ? '●' : '○', + }; + + }); // Handle config selection (Enter) - set as active const handleSelect = useCallback( diff --git a/src/tui/screens/config/ConfigRemoveScreen.tsx b/src/tui/screens/config/ConfigRemoveScreen.tsx index bdd61529..c75b64cb 100644 --- a/src/tui/screens/config/ConfigRemoveScreen.tsx +++ b/src/tui/screens/config/ConfigRemoveScreen.tsx @@ -22,6 +22,7 @@ import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Confirm, ProtectedConfirm, Spinner, useToast, MissingParamPanel, NotFoundPanel } from '../../components/index.js'; +import { checkConfigPolicy, confirmationPhraseFor } from '../../../core/policy/index.js'; import { getErrorMessage } from '../../utils/index.js'; /** @@ -50,6 +51,9 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { // Check if this is the active config const isActive = configName === activeConfigName; + // Policy check for the config:rm permission + const check = config ? checkConfigPolicy('user', config, 'config:rm') : null; + // Handle confirm const handleConfirm = useCallback(async () => { @@ -104,8 +108,8 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { if (!isFocused) return; - // Handle escape for error states (no config, not found, active config) - if (!configName || !config || isActive) { + // Handle escape for error states (no config, not found, active config, denied) + if (!configName || !config || isActive || (check && !check.allowed)) { if (key.escape || key.return) { @@ -153,6 +157,23 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { } + // Denied by policy (viewer role) + if (check && !check.allowed) { + + return ( + + + {check.blockedReason} + + + + [Enter/Esc] Back + + + ); + + } + // Deleting if (deleting) { @@ -164,12 +185,14 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { } - // Confirmation step - use ProtectedConfirm for protected configs - if (config.protected) { + // Confirmation step - type-to-confirm when config:rm requires it + // (operator/admin both confirm per the matrix, unless NOORM_YES is set) + if (check?.requiresConfirmation) { return ( ('loading'); @@ -60,6 +62,15 @@ export function DbCreateScreen({ params: _params }: ScreenProps): ReactElement { } + // Block viewer-role configs before connecting + if (check && !check.allowed) { + + setPhase('blocked'); + + return; + + } + const result = await checkDbStatus(activeConfig.connection); if (isCancelled()) return; @@ -147,6 +158,16 @@ export function DbCreateScreen({ params: _params }: ScreenProps): ReactElement { if (!isFocused) return; + if (phase === 'blocked') { + + if (key.escape || key.return) { + + back(); + + } + + } + if (phase === 'done' || phase === 'error') { if (key.return || key.escape) { @@ -179,6 +200,23 @@ export function DbCreateScreen({ params: _params }: ScreenProps): ReactElement { } + // Blocked - denied by policy + if (phase === 'blocked') { + + return ( + + + {check?.blockedReason} + + + + [Enter/Esc] Back + + + ); + + } + // Loading phase if (phase === 'loading') { @@ -202,7 +240,8 @@ export function DbCreateScreen({ params: _params }: ScreenProps): ReactElement { return ( ('loading'); @@ -63,8 +65,8 @@ export function DbDestroyScreen({ params: _params }: ScreenProps): ReactElement } - // Block protected configs - if (activeConfig.protected) { + // Block configs the policy denies db:destroy on (viewer/operator) + if (check && !check.allowed) { setPhase('blocked'); @@ -195,21 +197,13 @@ export function DbDestroyScreen({ params: _params }: ScreenProps): ReactElement } - // Blocked - protected config + // Blocked - denied by policy if (phase === 'blocked') { return ( - - - Cannot destroy protected configuration{' '} - {activeConfigName} - - - Protected configs cannot be dropped to prevent accidental data loss. - - + {check?.blockedReason} @@ -274,6 +268,7 @@ export function DbDestroyScreen({ params: _params }: ScreenProps): ReactElement ('loading'); const [error, setError] = useState(null); @@ -81,6 +83,15 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement } + // Block viewer-role configs before connecting + if (check && !check.allowed) { + + setPhase('blocked'); + + return; + + } + if (connError) { setError(connError); @@ -197,6 +208,16 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement if (!isFocused) return; + if (phase === 'blocked') { + + if (key.escape || key.return) { + + back(); + + } + + } + if (phase === 'preview') { if (key.escape) { @@ -316,6 +337,23 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement } + // Blocked - denied by policy + if (phase === 'blocked') { + + return ( + + + {check?.blockedReason} + + + + [Enter/Esc] Back + + + ); + + } + // Loading if (phase === 'loading') { @@ -506,6 +544,7 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement setPhase('preview')} diff --git a/src/tui/screens/db/DbTransferScreen.tsx b/src/tui/screens/db/DbTransferScreen.tsx index d5d6d932..b07324e7 100644 --- a/src/tui/screens/db/DbTransferScreen.tsx +++ b/src/tui/screens/db/DbTransferScreen.tsx @@ -507,6 +507,7 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement tables, onConflict: conflictStrategy, truncateFirst, + channel: 'user', }; const [_result, err] = await transferData(activeConfig, destConfig, options); diff --git a/src/tui/screens/db/DbTruncateScreen.tsx b/src/tui/screens/db/DbTruncateScreen.tsx index 89acccb7..f36e762a 100644 --- a/src/tui/screens/db/DbTruncateScreen.tsx +++ b/src/tui/screens/db/DbTruncateScreen.tsx @@ -29,8 +29,9 @@ import { useAppContext, useSettings } from '../../app-context.js'; import { useToast, Panel, Spinner, ProtectedConfirm } from '../../components/index.js'; import { useConnection, useAsyncEffect } from '../../hooks/index.js'; import { getErrorMessage } from '../../utils/index.js'; +import { checkConfigPolicy, confirmationPhraseFor } from '../../../core/policy/index.js'; -type Phase = 'loading' | 'preview' | 'confirm' | 'running' | 'done' | 'error'; +type Phase = 'loading' | 'blocked' | 'preview' | 'confirm' | 'running' | 'done' | 'error'; /** * DbTruncateScreen component. @@ -42,6 +43,7 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement const { activeConfig, activeConfigName } = useAppContext(); const { settings } = useSettings(); const { showToast } = useToast(); + const check = activeConfig ? checkConfigPolicy('user', activeConfig, 'db:reset') : null; const [phase, setPhase] = useState('loading'); const [error, setError] = useState(null); @@ -69,6 +71,15 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement } + // Block viewer-role configs before connecting + if (check && !check.allowed) { + + setPhase('blocked'); + + return; + + } + if (connError) { setError(connError); @@ -152,6 +163,16 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement if (!isFocused) return; + if (phase === 'blocked') { + + if (key.escape || key.return) { + + back(); + + } + + } + if (phase === 'preview') { if (key.escape) { @@ -197,6 +218,23 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement } + // Blocked - denied by policy + if (phase === 'blocked') { + + return ( + + + {check?.blockedReason} + + + + [Enter/Esc] Back + + + ); + + } + // Loading if (phase === 'loading') { @@ -299,6 +337,7 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement setPhase('preview')} diff --git a/src/tui/screens/db/SqlTerminalScreen.tsx b/src/tui/screens/db/SqlTerminalScreen.tsx index 27ba1425..2ae16861 100644 --- a/src/tui/screens/db/SqlTerminalScreen.tsx +++ b/src/tui/screens/db/SqlTerminalScreen.tsx @@ -204,12 +204,22 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { // Execute query const handleExecute = useCallback(async (sql: string) => { - if (!dbRef.current || !historyManagerRef.current || !activeConfigName) return; + if (!dbRef.current || !historyManagerRef.current || !activeConfigName || !activeConfig) return; setIsExecuting(true); setResult(null); - const execResult = await executeRawSql(dbRef.current, sql, activeConfigName); + const [rawResult, gateErr] = await attempt(() => + executeRawSql(dbRef.current!, sql, activeConfigName, { + access: activeConfig.access, + channel: 'user', + dialect: activeConfig.connection.dialect, + }), + ); + + const execResult: SqlExecutionResult = gateErr + ? { success: false, errorMessage: gateErr.message, durationMs: 0 } + : rawResult!; // Save to history await historyManagerRef.current.addEntry(sql, execResult); @@ -247,7 +257,7 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { } - }, [activeConfigName, showToast]); + }, [activeConfigName, activeConfig, showToast]); // Navigate history const handleHistoryNavigate = useCallback((direction: 'up' | 'down') => { diff --git a/src/tui/screens/lock/LockForceScreen.tsx b/src/tui/screens/lock/LockForceScreen.tsx index eda9ab6a..83457256 100644 --- a/src/tui/screens/lock/LockForceScreen.tsx +++ b/src/tui/screens/lock/LockForceScreen.tsx @@ -22,10 +22,12 @@ import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; -import { Panel, Spinner, ProtectedConfirm, useToast } from '../../components/index.js'; +import { Panel, Spinner, SmartConfirm, useToast } from '../../components/index.js'; import { useAsyncEffect } from '../../hooks/index.js'; import { createConnection, testConnection } from '../../../core/connection/index.js'; import { getLockManager } from '../../../core/lock/index.js'; +import { confirmationPhraseFor } from '../../../core/policy/index.js'; +import { isConfigGuarded } from '../../utils/index.js'; /** * Screen phase state. @@ -43,6 +45,9 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement const { isFocused } = useFocusScope('LockForce'); const { activeConfig, activeConfigName } = useAppContext(); const { showToast } = useToast(); + // Locks have no matrix permission — guarded() drives confirm UX only, + // never enforcement (docs/spec/config-access-roles.md#permissions-and-matrix). + const isGuarded = activeConfig ? isConfigGuarded(activeConfig) : false; const [phase, setPhase] = useState('loading'); const [error, setError] = useState(null); @@ -274,9 +279,12 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement - , + projectRoot, activeConfig, stateManager, dialect: conn.dialect, }); @@ -223,7 +225,7 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { } // Retry on 'r' in error or complete phase - if (input === 'r' && (phase === 'error' || phase === 'complete')) { + if (input === 'r' && (phase === 'error' || phase === 'complete') && check?.allowed) { executeBuild(); @@ -251,6 +253,23 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { } + // Denied by policy + if (check && !check.allowed) { + + return ( + + + {check.blockedReason} + + + + [Esc] Back + + + ); + + } + // Loading phase if (phase === 'loading') { @@ -330,7 +349,8 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { , + projectRoot, activeConfig, stateManager, dialect: sharedDialect ?? undefined, }); @@ -315,7 +315,7 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { const context = buildRunContext({ db, configName: activeConfigName, identity, - projectRoot, activeConfig: activeConfig as unknown as Record, + projectRoot, activeConfig, stateManager, dialect: conn.dialect, }); diff --git a/src/tui/screens/run/RunExecScreen.tsx b/src/tui/screens/run/RunExecScreen.tsx index 1517a1b1..de8d9d39 100644 --- a/src/tui/screens/run/RunExecScreen.tsx +++ b/src/tui/screens/run/RunExecScreen.tsx @@ -153,7 +153,7 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { const context = buildRunContext({ db, configName: activeConfigName, identity, - projectRoot, activeConfig: activeConfig as unknown as Record, + projectRoot, activeConfig, stateManager, dialect: conn.dialect, }); diff --git a/src/tui/screens/run/RunFileScreen.tsx b/src/tui/screens/run/RunFileScreen.tsx index 9cd45dbb..0676f2ff 100644 --- a/src/tui/screens/run/RunFileScreen.tsx +++ b/src/tui/screens/run/RunFileScreen.tsx @@ -155,7 +155,7 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { const context = buildRunContext({ db: sharedDb, configName: activeConfigName, identity, - projectRoot, activeConfig: activeConfig as unknown as Record, + projectRoot, activeConfig, stateManager, dialect: sharedDialect ?? undefined, }); @@ -256,7 +256,7 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { const context = buildRunContext({ db, configName: activeConfigName, identity, - projectRoot, activeConfig: activeConfig as unknown as Record, + projectRoot, activeConfig, stateManager, dialect: conn.dialect, }); diff --git a/src/tui/screens/run/RunListScreen.tsx b/src/tui/screens/run/RunListScreen.tsx index a5f37944..4cf64567 100644 --- a/src/tui/screens/run/RunListScreen.tsx +++ b/src/tui/screens/run/RunListScreen.tsx @@ -69,7 +69,7 @@ export function RunListScreen({ params: _params }: ScreenProps): ReactElement { // Create config for rule matching const configForMatch = { name: activeConfigName ?? '', - protected: activeConfig.protected ?? false, + access: activeConfig.access, isTest: activeConfig.isTest ?? false, type: activeConfig.type, }; diff --git a/src/tui/utils/change-context.ts b/src/tui/utils/change-context.ts index 9be7f72d..153251da 100644 --- a/src/tui/utils/change-context.ts +++ b/src/tui/utils/change-context.ts @@ -7,7 +7,7 @@ * @example * ```typescript * const manager = createChangeManager({ - * db, configName, projectRoot, settings, cryptoIdentity, + * db, configName, projectRoot, settings, cryptoIdentity, activeConfig, * }); * await manager.ff(); * ``` @@ -17,6 +17,7 @@ import type { Kysely } from 'kysely'; import type { NoormDatabase } from '../../core/shared/index.js'; import type { Settings } from '../../core/settings/types.js'; import type { CryptoIdentity } from '../../core/identity/types.js'; +import type { Config } from '../../core/config/types.js'; import type { ChangeContext } from '../../core/change/types.js'; import { ChangeManager } from '../../core/change/manager.js'; import { resolveChangesDir, resolveSqlDir } from './paths.js'; @@ -31,6 +32,7 @@ export interface CreateChangeManagerOptions { projectRoot: string; settings: Settings | null; cryptoIdentity: CryptoIdentity | null | undefined; + activeConfig: Config; } /** @@ -50,7 +52,7 @@ export interface CreateChangeManagerOptions { */ export function createChangeManager(options: CreateChangeManagerOptions): ChangeManager { - const { db, configName, projectRoot, settings, cryptoIdentity } = options; + const { db, configName, projectRoot, settings, cryptoIdentity, activeConfig } = options; const context: ChangeContext = { db, @@ -59,6 +61,8 @@ export function createChangeManager(options: CreateChangeManagerOptions): Change projectRoot, changesDir: resolveChangesDir(projectRoot, settings), sqlDir: resolveSqlDir(projectRoot, settings), + access: activeConfig.access, + channel: 'user', }; return new ChangeManager(context); diff --git a/src/tui/utils/config-validation.ts b/src/tui/utils/config-validation.ts index 98260ea6..e86cd75f 100644 --- a/src/tui/utils/config-validation.ts +++ b/src/tui/utils/config-validation.ts @@ -10,8 +10,11 @@ * const config = buildConnectionConfig(values, dialect); * ``` */ -import type { FormValues } from '../components/index.js'; +import type { FormValues, SelectOption } from '../components/index.js'; import type { ConnectionConfig, Dialect } from '../../core/connection/types.js'; +import type { Config } from '../../core/config/types.js'; +import type { ConfigAccess, Role } from '../../core/policy/index.js'; +import { guarded } from '../../core/policy/index.js'; /** @@ -149,3 +152,70 @@ export function buildConnectionConfig( }; } + +/** + * Select options for the `userRole` field — the `user` channel has no + * `false`/off state, unlike `mcp`. + */ +export const USER_ROLE_OPTIONS: SelectOption[] = [ + { label: 'Viewer', value: 'viewer' }, + { label: 'Operator', value: 'operator' }, + { label: 'Admin', value: 'admin' }, +]; + +/** + * Select options for the `mcpRole` field — `off` maps to `access.mcp: false` + * (invisible to the MCP channel), the one state the `user` channel lacks. + */ +export const MCP_ROLE_OPTIONS: SelectOption[] = [ + { label: 'Off (hidden from MCP)', value: 'off' }, + { label: 'Viewer', value: 'viewer' }, + { label: 'Operator', value: 'operator' }, + { label: 'Admin', value: 'admin' }, +]; + +function isRole(value: string): value is Role { + + return value === 'viewer' || value === 'operator' || value === 'admin'; + +} + +/** + * Builds a `ConfigAccess` from the `userRole`/`mcpRole` select fields shared + * by ConfigAdd/ConfigEdit. The select only ever offers valid options, so an + * unrecognized/missing value should never happen in practice — the fallback + * is defense-in-depth and fails closed (`viewer`/`false`) rather than + * granting `admin` on a value it can't recognize. + * + * @example + * ```typescript + * buildAccessFromValues({ userRole: 'operator', mcpRole: 'off' }); + * // { user: 'operator', mcp: false } + * ``` + */ +export function buildAccessFromValues(values: FormValues): ConfigAccess { + + const userRoleValue = String(values['userRole'] ?? 'viewer'); + const mcpRoleValue = String(values['mcpRole'] ?? 'off'); + + return { + user: isRole(userRoleValue) ? userRoleValue : 'viewer', + mcp: mcpRoleValue === 'off' ? false : (isRole(mcpRoleValue) ? mcpRoleValue : false), + }; + +} + +/** + * `guarded()` narrowed for the TUI's `Config`. Display-only (styling cues), + * same role as `guarded()` itself — never an enforcement input. + * + * @example + * ```tsx + * borderColor={isConfigGuarded(activeConfig) ? 'yellow' : undefined} + * ``` + */ +export function isConfigGuarded(config: Config): boolean { + + return guarded(config); + +} diff --git a/src/tui/utils/index.ts b/src/tui/utils/index.ts index 86113782..0c4e3f3d 100644 --- a/src/tui/utils/index.ts +++ b/src/tui/utils/index.ts @@ -19,7 +19,11 @@ export { validateConfigName, validatePort, buildConnectionConfig, + buildAccessFromValues, + isConfigGuarded, DEFAULT_PORTS, + USER_ROLE_OPTIONS, + MCP_ROLE_OPTIONS, type ConnectionDefaults, } from './config-validation.js'; export { getErrorMessage } from './error.js'; diff --git a/src/tui/utils/run-context.ts b/src/tui/utils/run-context.ts index 7ccedba8..3c5f5f5d 100644 --- a/src/tui/utils/run-context.ts +++ b/src/tui/utils/run-context.ts @@ -16,6 +16,7 @@ import type { RunContext } from '../../core/runner/types.js'; import type { Dialect } from '../../core/connection/types.js'; import type { NoormDatabase } from '../../core/shared/index.js'; import type { Identity } from '../../core/identity/types.js'; +import type { Config } from '../../core/config/types.js'; import type { StateManager } from '../../core/state/index.js'; /** @@ -26,7 +27,7 @@ export interface BuildRunContextOptions { configName: string; identity: Identity; projectRoot: string; - activeConfig: Record; + activeConfig: Config; stateManager: StateManager; dialect?: Dialect; } @@ -55,7 +56,9 @@ export function buildRunContext(options: BuildRunContextOptions): RunContext { identity, projectRoot, dialect, - config: activeConfig, + access: activeConfig.access, + channel: 'user', + config: activeConfig as unknown as Record, secrets: stateManager.getAllSecrets(configName), globalSecrets: stateManager.getAllGlobalSecrets(), }; diff --git a/tests/cli/components/dialogs.test.tsx b/tests/cli/components/dialogs.test.tsx index 35e3e801..ecd82264 100644 --- a/tests/cli/components/dialogs.test.tsx +++ b/tests/cli/components/dialogs.test.tsx @@ -11,6 +11,7 @@ import { FocusProvider } from '../../../src/tui/focus.js'; import { Confirm, ProtectedConfirm, + SmartConfirm, FilePicker, } from '../../../src/tui/components/dialogs/index.js'; @@ -84,6 +85,7 @@ describe('cli: components/dialogs', () => { {}} onCancel={() => {}} @@ -101,6 +103,7 @@ describe('cli: components/dialogs', () => { {}} onCancel={() => {}} @@ -118,6 +121,7 @@ describe('cli: components/dialogs', () => { {}} onCancel={() => {}} @@ -129,12 +133,32 @@ describe('cli: components/dialogs', () => { }); + it('should use the confirmPhrase prop rather than recomputing it from configName', () => { + + const { lastFrame } = render( + + {}} + onCancel={() => {}} + /> + , + ); + + expect(lastFrame()).toContain('totally-different-phrase'); + expect(lastFrame()).not.toContain('yes-prod'); + + }); + it('should render protected configuration title', () => { const { lastFrame } = render( {}} onCancel={() => {}} @@ -148,6 +172,51 @@ describe('cli: components/dialogs', () => { }); + describe('SmartConfirm', () => { + + it('should render ProtectedConfirm with the policy-supplied phrase when requiresConfirmation is true', () => { + + const { lastFrame } = render( + + {}} + onCancel={() => {}} + /> + , + ); + + expect(lastFrame()).toContain('Protected Configuration'); + expect(lastFrame()).toContain('yes-production'); + + }); + + it('should render a plain Confirm when requiresConfirmation is false', () => { + + const { lastFrame } = render( + + {}} + onCancel={() => {}} + /> + , + ); + + expect(lastFrame()).toContain('Apply this change?'); + expect(lastFrame()).not.toContain('Protected Configuration'); + + }); + + }); + describe('FilePicker', () => { it('should render file list', () => { diff --git a/tests/cli/config-validation.test.ts b/tests/cli/config-validation.test.ts new file mode 100644 index 00000000..0ab92ad5 --- /dev/null +++ b/tests/cli/config-validation.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'bun:test'; + +import { buildAccessFromValues } from '../../src/tui/utils/config-validation.js'; + +describe('config-validation: buildAccessFromValues', () => { + + it('maps valid userRole/mcpRole values to the matching ConfigAccess', () => { + + expect(buildAccessFromValues({ userRole: 'operator', mcpRole: 'off' })) + .toEqual({ user: 'operator', mcp: false }); + + expect(buildAccessFromValues({ userRole: 'admin', mcpRole: 'admin' })) + .toEqual({ user: 'admin', mcp: 'admin' }); + + expect(buildAccessFromValues({ userRole: 'viewer', mcpRole: 'viewer' })) + .toEqual({ user: 'viewer', mcp: 'viewer' }); + + }); + + it('fails closed to viewer/false when fields are missing', () => { + + expect(buildAccessFromValues({})).toEqual({ user: 'viewer', mcp: false }); + + }); + + it('fails closed to viewer/false on unrecognized/garbage values', () => { + + expect(buildAccessFromValues({ userRole: 'superuser', mcpRole: 'root' })) + .toEqual({ user: 'viewer', mcp: false }); + + }); + +}); diff --git a/tests/cli/config/import.test.ts b/tests/cli/config/import.test.ts new file mode 100644 index 00000000..2acc1e5e --- /dev/null +++ b/tests/cli/config/import.test.ts @@ -0,0 +1,169 @@ +/** + * cli: noorm config import — legacy `protected` fail-open regression. + * + * `config import` calls `process.exit`, so — like every other citty command + * test in this suite — it's driven as a subprocess against the compiled CLI + * rather than invoked in-process (tests/cli/db/drop.test.ts's pattern). + * Identity comes from `NOORM_IDENTITY_*` env vars (env-bootstrap.test.ts's + * pattern) so no `~/.noorm/identity.key` is ever touched; the persisted + * `access` is read back directly via `StateManager` rather than parsing + * `config list` text output. + * + * Regression: `parseConfig()` inside `import.ts` used to cast the raw JSON + * straight to `Config` (`obj as unknown as Config`), bypassing `ConfigSchema` + * entirely. A legacy export with `protected: true` and no `access` landed in + * state access-less; the load-boundary backfill then hardcoded + * `resolveLegacyAccess(config.access, undefined)`, resolving to the + * admin/admin open default instead of the documented operator/viewer + * fail-closed mapping. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import { decrypt } from '../../../src/core/state/encryption/index.js'; +import type { EncryptedPayload } from '../../../src/core/state/types.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm config import — legacy protected mapping', () => { + + let tmpDir: string; + let fakeHome: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-config-import-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-config-import-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a raw config JSON file, the shape `config export` (or a legacy exporter) would produce. */ + function writeConfigFile(fileName: string, config: Record): string { + + const filePath = join(tmpDir, fileName); + writeFileSync(filePath, JSON.stringify(config)); + + return filePath; + + } + + function runImport(path: string, args: string[] = []) { + + return spawnSync('node', [CLI, 'config', 'import', path, ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: identityEnv, + }); + + } + + /** + * Reads the `access` a config was persisted with immediately after + * import, decrypting state.enc directly rather than going through a + * fresh `StateManager.load()` — a subsequent load's boundary backfill + * would itself resolve a missing `access`, masking a regression in + * `import.ts`'s own parse path. + */ + function readPersistedAccess(name: string): unknown { + + const statePath = new StateManager(tmpDir, { privateKey }).getStatePath(); + const raw = readFileSync(statePath, 'utf8'); + const decrypted = JSON.parse( + decrypt(JSON.parse(raw) as EncryptedPayload, privateKey), + ) as { configs: Record }; + + return decrypted.configs[name]?.access; + + } + + it('maps a legacy protected:true config with no access to guarded roles, not the fail-open admin/admin default', () => { + + const path = writeConfigFile('legacy.json', { + name: 'legacy-guarded', + protected: true, + connection: { dialect: 'sqlite', database: ':memory:' }, + }); + + const result = runImport(path); + + expect(result.status).toBe(0); + + const access = readPersistedAccess('legacy-guarded'); + expect(access).toEqual({ user: 'operator', mcp: 'viewer' }); + + }); + + it('round-trips a config that already carries access unchanged', () => { + + const path = writeConfigFile('modern.json', { + name: 'modern-viewer', + access: { user: 'viewer', mcp: false }, + connection: { dialect: 'sqlite', database: ':memory:' }, + }); + + const result = runImport(path); + + expect(result.status).toBe(0); + + const access = readPersistedAccess('modern-viewer'); + expect(access).toEqual({ user: 'viewer', mcp: false }); + + }); + + it('rejects config JSON missing required fields instead of silently importing it', () => { + + const path = writeConfigFile('broken.json', { protected: true }); + + const result = runImport(path); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain('Error'); + + }); + +}); diff --git a/tests/cli/config/list.test.ts b/tests/cli/config/list.test.ts new file mode 100644 index 00000000..21ae4549 --- /dev/null +++ b/tests/cli/config/list.test.ts @@ -0,0 +1,136 @@ +/** + * cli: noorm config list — access tag display. + * + * `config list` reads state and exits (`process.exit`), so it's driven as + * a subprocess against the compiled CLI (same reasoning as + * tests/cli/db/drop.test.ts). The config fixture is written directly via + * `StateManager` since `config add`/`edit` are TUI-only and can't set an + * exact `access` role from the CLI. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'tagged'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_CONFIG/NOORM_YES leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm config list — access tag display', () => { + + let tmpDir: string; + let fakeHome: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-config-list-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-config-list-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one config at the given access role, bypassing the TUI-only `config add`/`edit` commands. */ + async function seedConfig(access: ConfigAccess): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: false, + access, + connection: { dialect: 'sqlite', database: join(tmpDir, 'target.db') }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + + } + + function runList(): ReturnType { + + return spawnSync('node', [CLI, 'config', 'list'], { + cwd: tmpDir, + encoding: 'utf-8', + env: identityEnv, + }); + + } + + it('renders "user: mcp:" for a guarded config', async () => { + + await seedConfig({ user: 'operator', mcp: 'viewer' }); + + const result = runList(); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('user:operator mcp:viewer'); + + }); + + it('renders "mcp:off" when access.mcp is false', async () => { + + await seedConfig({ user: 'viewer', mcp: false }); + + const result = runList(); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('user:viewer mcp:off'); + + }); + + it('renders no access tag for an admin/admin config', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + const result = runList(); + + expect(result.status).toBe(0); + expect(result.stdout).not.toContain('user:'); + + }); + +}); diff --git a/tests/cli/db/drop.test.ts b/tests/cli/db/drop.test.ts new file mode 100644 index 00000000..96f83be5 --- /dev/null +++ b/tests/cli/db/drop.test.ts @@ -0,0 +1,178 @@ +/** + * cli: noorm db drop — access-role policy gate. + * + * `db drop` is destructive and calls `process.exit`, so — like every other + * citty command test in this suite — it's driven as a subprocess against + * the compiled CLI rather than invoked in-process (an in-process call would + * kill the test runner on the first `process.exit`). Identity comes from + * `NOORM_IDENTITY_*` env vars (env-bootstrap.test.ts's pattern) so no + * `~/.noorm/identity.key` is ever touched, and the config fixture is + * written directly via `StateManager` (tests/core/rpc/list-configs.test.ts's + * pattern) since `config add`/`edit` are TUI-only and can't set an exact + * `access` role from the CLI. The target "database" is a real SQLite file — + * dropping it is a real file deletion, not a stub, so `--yes` exercises the + * actual drop path end-to-end. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'dropme'; + +/** Strips inherited NOORM_* env vars before applying explicit overrides, so no ambient NOORM_YES/NOORM_CONFIG leaks into a subprocess run. */ +function cleanEnvWithOverrides(overrides: Record): Record { + + const env: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + + if (value !== undefined && !key.startsWith('NOORM_')) env[key] = value; + + } + + for (const [key, value] of Object.entries(overrides)) { + + if (value !== undefined) env[key] = value; + + } + + return env; + +} + +describe('cli: noorm db drop — access policy gate', () => { + + let tmpDir: string; + let fakeHome: string; + let dbPath: string; + let privateKey: string; + let identityEnv: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-db-drop-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-db-drop-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + dbPath = join(tmpDir, 'target.db'); + writeFileSync(dbPath, ''); + + privateKey = generateKeyPair().privateKey; + identityEnv = cleanEnvWithOverrides({ + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }); + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + /** Writes a real encrypted state.enc with one active config at the given access role, bypassing the TUI-only `config add`/`edit` commands. */ + async function seedConfig(access: ConfigAccess): Promise { + + const config: Config = { + name: CONFIG_NAME, + type: 'local', + isTest: true, + access, + connection: { dialect: 'sqlite', database: dbPath }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(CONFIG_NAME, config); + await manager.setActiveConfig(CONFIG_NAME); + + } + + function runDrop(args: string[] = [], envOverrides: Record = {}) { + + return spawnSync('node', [CLI, 'db', 'drop', ...args], { + cwd: tmpDir, + encoding: 'utf-8', + env: { ...identityEnv, ...envOverrides }, + }); + + } + + it('denies a viewer with the policy blockedReason and leaves the database intact', async () => { + + await seedConfig({ user: 'viewer', mcp: 'admin' }); + + const result = runDrop(); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain( + `"db:destroy" is not allowed on config "${CONFIG_NAME}" (role: viewer).`, + ); + expect(existsSync(dbPath)).toBe(true); + + }); + + it('denies an operator with the policy blockedReason and leaves the database intact', async () => { + + await seedConfig({ user: 'operator', mcp: 'admin' }); + + const result = runDrop(); + + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain( + `"db:destroy" is not allowed on config "${CONFIG_NAME}" (role: operator).`, + ); + expect(existsSync(dbPath)).toBe(true); + + }); + + it('blocks an admin without --yes or NOORM_YES, naming the confirmation phrase', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + const result = runDrop(); + + expect(result.status).toBe(1); + const out = result.stdout + result.stderr; + expect(out).toContain(`yes-${CONFIG_NAME}`); + expect(out).toContain('Pass --yes to confirm'); + expect(existsSync(dbPath)).toBe(true); + + }); + + it('proceeds to the real drop when an admin passes --yes', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + const result = runDrop(['--yes']); + + expect(result.status).toBe(0); + expect(existsSync(dbPath)).toBe(false); + + }); + + it('proceeds to the real drop when NOORM_YES=1 is set without --yes', async () => { + + await seedConfig({ user: 'admin', mcp: 'admin' }); + + const result = runDrop([], { NOORM_YES: '1' }); + + expect(result.status).toBe(0); + expect(existsSync(dbPath)).toBe(false); + + }); + +}); diff --git a/tests/core/change/executor.test.ts b/tests/core/change/executor.test.ts index 8fa52cd2..f17a3ccd 100644 --- a/tests/core/change/executor.test.ts +++ b/tests/core/change/executor.test.ts @@ -11,7 +11,7 @@ import { join } from 'node:path'; import { Kysely, SqliteDialect } from 'kysely'; import { BunSqliteDatabase } from '../../../src/core/connection/dialects/sqlite-bun.js'; -import { executeChange } from '../../../src/core/change/executor.js'; +import { executeChange, revertChange } from '../../../src/core/change/executor.js'; import { ChangeHistory } from '../../../src/core/change/history.js'; import { v1 } from '../../../src/core/version/schema/migrations/v1.js'; import { resetLockManager } from '../../../src/core/lock/index.js'; @@ -78,6 +78,8 @@ describe('change: executor', () => { projectRoot: tempDir, changesDir, sqlDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', dialect: 'sqlite', }; @@ -339,4 +341,48 @@ describe('change: executor', () => { }); + describe('policy gate', () => { + + // The gate is the first thing executeChange/revertChange do, so a + // viewer denial fires before any file I/O, lock acquisition, or + // history/tracker DB access is attempted. + + it('should deny executeChange for a viewer role', async () => { + + const change = await createTestChange('test-gate-run', [ + { name: '001.sql', content: 'CREATE TABLE gate_test (id INTEGER)' }, + ]); + + const context: ChangeContext = { ...buildContext(), access: { user: 'viewer', mcp: false } }; + + await expect(executeChange(context, change)).rejects.toThrow(/change:run/); + + }); + + it('should deny revertChange for a viewer role', async () => { + + const change = await createTestChange('test-gate-revert', [ + { name: '001.sql', content: 'CREATE TABLE gate_test2 (id INTEGER)' }, + ]); + + const context: ChangeContext = { ...buildContext(), access: { user: 'viewer', mcp: false } }; + + await expect(revertChange(context, change)).rejects.toThrow(/change:revert/); + + }); + + it('should carry the config name in the denial reason', async () => { + + const change = await createTestChange('test-gate-name', [ + { name: '001.sql', content: 'CREATE TABLE gate_test3 (id INTEGER)' }, + ]); + + const context: ChangeContext = { ...buildContext(), access: { user: 'viewer', mcp: false } }; + + await expect(executeChange(context, change)).rejects.toThrow(/"test"/); + + }); + + }); + }); diff --git a/tests/core/config/protection.test.ts b/tests/core/config/protection.test.ts deleted file mode 100644 index bba7d1c4..00000000 --- a/tests/core/config/protection.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Protected config handling tests. - */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { - checkProtection, - validateConfirmation, - type ProtectedAction, -} from '../../../src/core/config/index.js'; -import type { Config } from '../../../src/core/config/index.js'; - -/** - * Create a test config. - */ -function createConfig(overrides: Partial = {}): Config { - - return { - name: 'test', - type: 'local', - isTest: false, - protected: false, - connection: { - dialect: 'sqlite', - database: ':memory:', - }, - ...overrides, - }; - -} - -describe('config: protection', () => { - - const envBackup: Record = {}; - - beforeEach(() => { - - envBackup['NOORM_YES'] = process.env['NOORM_YES']; - delete process.env['NOORM_YES']; - - }); - - afterEach(() => { - - if (envBackup['NOORM_YES'] === undefined) { - - delete process.env['NOORM_YES']; - - } - else { - - process.env['NOORM_YES'] = envBackup['NOORM_YES']; - - } - - }); - - describe('checkProtection', () => { - - it('should allow all actions on non-protected config', () => { - - const config = createConfig({ protected: false }); - const actions: ProtectedAction[] = [ - 'change:run', - 'change:revert', - 'change:ff', - 'change:next', - 'run:build', - 'run:file', - 'run:dir', - 'db:create', - 'db:destroy', - 'config:rm', - ]; - - for (const action of actions) { - - const check = checkProtection(config, action); - - expect(check.allowed).toBe(true); - expect(check.requiresConfirmation).toBe(false); - - } - - }); - - it('should block db:destroy on protected config', () => { - - const config = createConfig({ name: 'production', protected: true }); - - const check = checkProtection(config, 'db:destroy'); - - expect(check.allowed).toBe(false); - expect(check.requiresConfirmation).toBe(false); - expect(check.blockedReason).toContain('db:destroy'); - expect(check.blockedReason).toContain('production'); - expect(check.blockedReason).toContain('not allowed'); - - }); - - it('should require confirmation for change actions on protected config', () => { - - const config = createConfig({ name: 'staging', protected: true }); - const actions: ProtectedAction[] = [ - 'change:run', - 'change:revert', - 'change:ff', - 'change:next', - ]; - - for (const action of actions) { - - const check = checkProtection(config, action); - - expect(check.allowed).toBe(true); - expect(check.requiresConfirmation).toBe(true); - expect(check.confirmationPhrase).toBe('yes-staging'); - - } - - }); - - it('should require confirmation for run actions on protected config', () => { - - const config = createConfig({ name: 'prod', protected: true }); - const actions: ProtectedAction[] = ['run:build', 'run:file', 'run:dir']; - - for (const action of actions) { - - const check = checkProtection(config, action); - - expect(check.allowed).toBe(true); - expect(check.requiresConfirmation).toBe(true); - expect(check.confirmationPhrase).toBe('yes-prod'); - - } - - }); - - it('should require confirmation for db:create on protected config', () => { - - const config = createConfig({ name: 'main', protected: true }); - - const check = checkProtection(config, 'db:create'); - - expect(check.allowed).toBe(true); - expect(check.requiresConfirmation).toBe(true); - expect(check.confirmationPhrase).toBe('yes-main'); - - }); - - it('should require confirmation for config:rm on protected config', () => { - - const config = createConfig({ name: 'primary', protected: true }); - - const check = checkProtection(config, 'config:rm'); - - expect(check.allowed).toBe(true); - expect(check.requiresConfirmation).toBe(true); - expect(check.confirmationPhrase).toBe('yes-primary'); - - }); - - it('should skip confirmation when NOORM_YES is set', () => { - - process.env['NOORM_YES'] = '1'; - const config = createConfig({ name: 'prod', protected: true }); - - const check = checkProtection(config, 'change:run'); - - expect(check.allowed).toBe(true); - expect(check.requiresConfirmation).toBe(false); - - }); - - it('should still block db:destroy even with NOORM_YES', () => { - - process.env['NOORM_YES'] = '1'; - const config = createConfig({ name: 'prod', protected: true }); - - const check = checkProtection(config, 'db:destroy'); - - expect(check.allowed).toBe(false); - - }); - - it('should handle unknown actions as allowed', () => { - - const config = createConfig({ protected: true }); - - // Cast to bypass type check for unknown action - const check = checkProtection(config, 'unknown:action' as ProtectedAction); - - expect(check.allowed).toBe(true); - expect(check.requiresConfirmation).toBe(false); - - }); - - }); - - describe('validateConfirmation', () => { - - it('should accept correct confirmation phrase', () => { - - const config = createConfig({ name: 'production' }); - - expect(validateConfirmation(config, 'yes-production')).toBe(true); - - }); - - it('should reject incorrect confirmation phrase', () => { - - const config = createConfig({ name: 'production' }); - - expect(validateConfirmation(config, 'yes')).toBe(false); - expect(validateConfirmation(config, 'yes-staging')).toBe(false); - expect(validateConfirmation(config, 'production')).toBe(false); - expect(validateConfirmation(config, '')).toBe(false); - - }); - - it('should be case-sensitive', () => { - - const config = createConfig({ name: 'Production' }); - - expect(validateConfirmation(config, 'yes-Production')).toBe(true); - expect(validateConfirmation(config, 'yes-production')).toBe(false); - - }); - - }); - -}); diff --git a/tests/core/config/resolver.test.ts b/tests/core/config/resolver.test.ts index 95507e27..639f7958 100644 --- a/tests/core/config/resolver.test.ts +++ b/tests/core/config/resolver.test.ts @@ -11,6 +11,7 @@ import { } from '../../../src/core/config/index.js'; import { SettingsProvider } from '../../../src/core/config/resolver.js'; import type { Config, Stage } from '../../../src/core/config/index.js'; +import { guarded } from '../../../src/core/policy/index.js'; /** * Create a mock state provider for testing. @@ -78,7 +79,7 @@ function createConfig(overrides: Partial = {}): Config { name: 'test', type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', @@ -373,7 +374,7 @@ describe('config: resolver', () => { const config = resolveConfig(state); expect(config!.type).toBe('local'); - expect(config!.protected).toBe(false); + expect(config!.access).toEqual({ user: 'admin', mcp: 'admin' }); }); @@ -440,7 +441,12 @@ describe('config: resolver', () => { // Stored should override stage defaults const config = resolveConfig(state, { settings }); - expect(config!.protected).toBe(false); // stored has protected=false + // Stage `protected: true` is an access ceiling, not a value the + // stored config can override to something looser: stored has no + // explicit access (defaults to admin/admin), which is looser + // than the operator/viewer ceiling, so it gets clamped down. + expect(guarded(config!)).toBe(true); + expect(config!.access).toEqual({ user: 'operator', mcp: 'viewer' }); expect(config!.connection.host).toBe('localhost'); // stored overrides stage }); @@ -451,7 +457,6 @@ describe('config: resolver', () => { name: 'prod', type: 'local', isTest: false, - protected: false, connection: { dialect: 'postgres', database: 'myapp', @@ -500,8 +505,10 @@ describe('config: resolver', () => { const config = resolveConfig(state, { settings }); - // Since stored has protected=false, it overrides stage default - expect(config!.protected).toBe(false); + // Stage `protected: true` clamps the stored config's (looser, + // default admin/admin) access down to the ceiling. + expect(guarded(config!)).toBe(true); + expect(config!.access).toEqual({ user: 'operator', mcp: 'viewer' }); }); @@ -538,6 +545,82 @@ describe('config: resolver', () => { }); + // Legacy `protected`-only (no `access`) stored configs are no longer + // representable through the typed `StateProvider`/`Config` surface — + // `access` is required on `Config`, so the state migration (v2) and + // `parseConfig`'s legacy mapping (tests/core/config/schema.test.ts) are + // the only remaining places raw `protected` boolean input is resolved. + + describe('stage access ceiling', () => { + + function resolveWithStageProtected( + access: { user: 'viewer' | 'operator' | 'admin'; mcp: 'viewer' | 'operator' | 'admin' | false }, + stageProtected: boolean, + ) { + + const stored = createConfig({ name: 'prod', access }); + const state = createMockState({ + configs: { prod: stored }, + activeConfig: 'prod', + }); + const settings = createMockSettings({ + prod: { + defaults: { + protected: stageProtected, + log: { level: 'info' }, + }, + }, + }); + + return resolveConfig(state, { settings }); + + } + + it('should not clamp when the stage is not protected', () => { + + const config = resolveWithStageProtected({ user: 'admin', mcp: 'admin' }, false); + + expect(config!.access).toEqual({ user: 'admin', mcp: 'admin' }); + + }); + + it('should clamp a looser user+mcp access down to the ceiling', () => { + + const config = resolveWithStageProtected({ user: 'admin', mcp: 'admin' }, true); + + expect(config!.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(guarded(config!)).toBe(true); + + }); + + it('should leave access exactly at the ceiling unchanged', () => { + + const config = resolveWithStageProtected({ user: 'operator', mcp: 'viewer' }, true); + + expect(config!.access).toEqual({ user: 'operator', mcp: 'viewer' }); + + }); + + it('should let a stricter access survive the ceiling untouched', () => { + + const config = resolveWithStageProtected({ user: 'viewer', mcp: false }, true); + + expect(config!.access).toEqual({ user: 'viewer', mcp: false }); + expect(guarded(config!)).toBe(true); + + }); + + it('should clamp each channel independently', () => { + + // user is looser than the ceiling, mcp is already stricter + const config = resolveWithStageProtected({ user: 'admin', mcp: false }, true); + + expect(config!.access).toEqual({ user: 'operator', mcp: false }); + + }); + + }); + describe('checkConfigCompleteness', () => { it('should return complete when no stage', () => { @@ -599,11 +682,15 @@ describe('config: resolver', () => { }); - it('should detect protected constraint violation', () => { + it('should not report a protected violation (enforced as an access ceiling at resolution instead)', () => { + // A config with open access reaching this check is only + // possible outside resolveConfig, which clamps access to the + // stage ceiling before returning. checkConfigCompleteness no + // longer duplicates that enforcement as a violation. const config = createConfig({ name: 'prod', - protected: false, + access: { user: 'admin', mcp: 'admin' }, }); const state = createMockState(); @@ -618,9 +705,8 @@ describe('config: resolver', () => { const result = checkConfigCompleteness(config, state, settings); - expect(result.complete).toBe(false); - expect(result.violations).toHaveLength(1); - expect(result.violations[0]).toContain('protected=true'); + expect(result.complete).toBe(true); + expect(result.violations).toEqual([]); }); @@ -653,7 +739,7 @@ describe('config: resolver', () => { const config = createConfig({ name: 'prod', - protected: true, + access: { user: 'operator', mcp: 'viewer' }, }); const state = createMockState({ secrets: { prod: ['DB_PASSWORD', 'API_KEY'] }, diff --git a/tests/core/config/schema.test.ts b/tests/core/config/schema.test.ts index 0a8b118f..458e1823 100644 --- a/tests/core/config/schema.test.ts +++ b/tests/core/config/schema.test.ts @@ -10,6 +10,7 @@ import { ConfigValidationError, } from '../../../src/core/config/index.js'; import type { Config } from '../../../src/core/config/index.js'; +import { guarded } from '../../../src/core/policy/index.js'; /** * Create a valid test config. @@ -20,7 +21,7 @@ function createValidConfig(overrides: Partial = {}): Config { name: 'test', type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', @@ -293,7 +294,7 @@ describe('config: schema validation', () => { expect(result.type).toBe('local'); expect(result.isTest).toBe(false); - expect(result.protected).toBe(false); + expect(result.access).toEqual({ user: 'admin', mcp: 'admin' }); }); @@ -302,14 +303,14 @@ describe('config: schema validation', () => { const config = createValidConfig({ type: 'remote', isTest: true, - protected: true, + access: { user: 'operator', mcp: 'viewer' }, }); const result = parseConfig(config); expect(result.type).toBe('remote'); expect(result.isTest).toBe(true); - expect(result.protected).toBe(true); + expect(result.access).toEqual({ user: 'operator', mcp: 'viewer' }); }); @@ -321,6 +322,81 @@ describe('config: schema validation', () => { }); + describe('access roles', () => { + + /** Strips the default `access` so the legacy `protected` fallback is reachable. */ + function withoutAccess(config: Config): Omit { + + const { access: _access, ...rest } = config; + + return rest; + + } + + it('should default access to admin/admin when neither access nor protected is given', () => { + + const result = parseConfig(withoutAccess(createValidConfig())); + + expect(result.access).toEqual({ user: 'admin', mcp: 'admin' }); + expect(guarded(result)).toBe(false); + + }); + + it('should map legacy protected: true to guarded access', () => { + + const config = { ...withoutAccess(createValidConfig()), protected: true }; + + const result = parseConfig(config); + + expect(result.access).toEqual({ user: 'operator', mcp: 'viewer' }); + expect(guarded(result)).toBe(true); + + }); + + it('should map legacy protected: false to open access', () => { + + const config = { ...withoutAccess(createValidConfig()), protected: false }; + + const result = parseConfig(config); + + expect(result.access).toEqual({ user: 'admin', mcp: 'admin' }); + expect(guarded(result)).toBe(false); + + }); + + it('should prefer an explicit access over the legacy protected flag', () => { + + const config = { + ...createValidConfig(), + protected: true, + access: { user: 'viewer' as const, mcp: false as const }, + }; + + const result = parseConfig(config); + + expect(result.access).toEqual({ user: 'viewer', mcp: false }); + + }); + + it('should never let the raw input protected value override an explicit access', () => { + + // access says open (admin/admin) while the legacy protected + // flag says guarded; the resolved access must follow the + // explicit access, never the raw input's protected flag. + const config = { + ...createValidConfig(), + protected: true, + access: { user: 'admin' as const, mcp: 'admin' as const }, + }; + + const result = parseConfig(config); + + expect(guarded(result)).toBe(false); + + }); + + }); + }); }); diff --git a/tests/core/connection/manager.test.ts b/tests/core/connection/manager.test.ts index 22af7567..9007602e 100644 --- a/tests/core/connection/manager.test.ts +++ b/tests/core/connection/manager.test.ts @@ -29,7 +29,7 @@ function createTestConfig(name: string): Config { name, type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', diff --git a/tests/core/mcp/server.test.ts b/tests/core/mcp/server.test.ts index a889c505..0b6dd52a 100644 --- a/tests/core/mcp/server.test.ts +++ b/tests/core/mcp/server.test.ts @@ -11,7 +11,8 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { createMcpServer } from '../../../src/mcp/server.js'; import { RpcRegistry } from '../../../src/rpc/registry.js'; -import type { RpcSession } from '../../../src/rpc/types.js'; +import type { RpcCommand, RpcSession } from '../../../src/rpc/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; import type { Context } from '../../../src/sdk/context.js'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -88,6 +89,26 @@ async function callText( const mockContext = {} as Context; +/** + * Builds a fake Context carrying a config with the given access — used to + * drive the dispatch gate's `checkConfigPolicy` call. `access: undefined` + * simulates a config that reached enforcement without `access` populated + * (fail-closed case). + */ +function mockContextWithAccess(access: ConfigAccess | undefined, name = 'test'): Context { + + return { + noorm: { + config: { + name, + access, + connection: { database: 'testdb' }, + }, + }, + } as unknown as Context; + +} + function buildMockSession(overrides: Partial = {}): RpcSession & { getContextCalls: string[]; } { @@ -95,6 +116,7 @@ function buildMockSession(overrides: Partial = {}): RpcSession & { const getContextCalls: string[] = []; return { + channel: 'mcp', getContextCalls, getContext(config?: string) { @@ -107,7 +129,7 @@ function buildMockSession(overrides: Partial = {}): RpcSession & { name: 'test', dialect: 'postgres', database: 'testdb', - protected: false, + role: 'admin', }), disconnect: async () => {}, disconnectAll: async () => {}, @@ -127,6 +149,7 @@ function buildRegistry() { description: 'A test command', examples: [{ description: 'basic', input: { value: 'hello' } }], inputSchema: z.object({ value: z.string().describe('Test value') }), + permission: 'open', handler: async (input) => ({ echo: (input as { value: string }).value }), }); @@ -135,6 +158,7 @@ function buildRegistry() { description: 'A command that always throws', examples: [], inputSchema: z.object({}), + permission: 'open', handler: async () => { throw new Error('boom'); @@ -150,6 +174,7 @@ function buildRegistry() { inputSchema: z.object({ config: z.string().optional().describe('Config name'), }), + permission: 'open', handler: async (_input, session) => session.connect(), }); @@ -263,6 +288,7 @@ describe('mcp: server dispatch (run_noorm_cmd)', () => { description: 'Calls getContext', examples: [], inputSchema: z.object({}), + permission: 'open', handler: async (_input, session) => { session.getContext(); @@ -298,7 +324,7 @@ describe('mcp: server dispatch (run_noorm_cmd)', () => { name: config ?? 'active', dialect: 'postgres', database: 'testdb', - protected: false, + role: 'admin', }; }, @@ -311,6 +337,7 @@ describe('mcp: server dispatch (run_noorm_cmd)', () => { description: 'Connect', examples: [], inputSchema: z.object({ config: z.string().optional() }), + permission: 'open', handler: async (input, session) => session.connect((input as { config?: string }).config), }); @@ -332,6 +359,137 @@ describe('mcp: server dispatch (run_noorm_cmd)', () => { }); +describe('mcp: server dispatch — policy gate (CP3)', () => { + + /** + * Registers a single gated command and wires a session whose + * `getContext` resolves to a config with the given access, so the + * dispatch gate's `checkConfigPolicy` call is exercised end-to-end. + */ + async function setup( + permission: RpcCommand['permission'], + access: ConfigAccess | undefined, + ) { + + const called = { value: false }; + const registry = new RpcRegistry(); + + registry.register({ + name: 'gated_cmd', + description: 'A permission-gated command', + examples: [], + inputSchema: z.object({}), + permission, + handler: async () => { + + called.value = true; + + return { ok: true }; + + }, + }); + + const session = buildMockSession({ + getContext: () => mockContextWithAccess(access), + }); + + const { client, cleanup } = await createTestPair(registry, session); + + return { client, cleanup, called }; + + } + + it('should deny and never invoke the handler when the resolved role denies the permission (viewer)', async () => { + + const { client, cleanup, called } = await setup('change:run', { user: 'admin', mcp: 'viewer' }); + + const { isError, parsed } = await callJson(client, 'run_noorm_cmd', { command: 'gated_cmd', payload: {} }); + + await cleanup(); + + const body = parsed as { error: string }; + + expect(isError).toBe(true); + expect(body.error).toContain('change:run'); + expect(called.value).toBe(false); + + }); + + it('should collapse an operator confirm cell to deny on the mcp channel', async () => { + + const { client, cleanup, called } = await setup('change:run', { user: 'admin', mcp: 'operator' }); + + const { isError, parsed } = await callJson(client, 'run_noorm_cmd', { command: 'gated_cmd', payload: {} }); + + await cleanup(); + + const body = parsed as { error: string }; + + expect(isError).toBe(true); + expect(body.error.toLowerCase()).toContain('cli'); + expect(called.value).toBe(false); + + }); + + it('should allow and invoke the handler when the resolved role allows the permission (admin)', async () => { + + const { client, cleanup, called } = await setup('change:run', { user: 'admin', mcp: 'admin' }); + + const { isError } = await callJson(client, 'run_noorm_cmd', { command: 'gated_cmd', payload: {} }); + + await cleanup(); + + expect(isError).toBeFalsy(); + expect(called.value).toBe(true); + + }); + + it('should fail closed and deny when the resolved config has no access at all', async () => { + + const { client, cleanup, called } = await setup('explore', undefined); + + const { isError } = await callJson(client, 'run_noorm_cmd', { command: 'gated_cmd', payload: {} }); + + await cleanup(); + + expect(isError).toBe(true); + expect(called.value).toBe(false); + + }); + + it("should skip the gate entirely for 'open' commands, even when getContext would throw", async () => { + + const registry = new RpcRegistry(); + + registry.register({ + name: 'open_cmd', + description: 'An open command', + examples: [], + inputSchema: z.object({}), + permission: 'open', + handler: async () => ({ ok: true }), + }); + + const session = buildMockSession({ + getContext: () => { + + throw new Error('should not be called for an open command'); + + }, + }); + + const { client, cleanup } = await createTestPair(registry, session); + + const { isError } = await callJson(client, 'run_noorm_cmd', { command: 'open_cmd', payload: {} }); + + await cleanup(); + + expect(isError).toBeFalsy(); + + }); + +}); + describe('mcp: server dispatch (noorm_help)', () => { let registry: RpcRegistry; diff --git a/tests/core/policy/check.test.ts b/tests/core/policy/check.test.ts new file mode 100644 index 00000000..96a8f6d3 --- /dev/null +++ b/tests/core/policy/check.test.ts @@ -0,0 +1,270 @@ +/** + * Access policy: checkPolicy + guarded. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { assertPolicy, checkConfigPolicy, checkPolicy, confirmationPhraseFor, guarded } from '../../../src/core/policy/index.js'; +import type { Channel, Permission, PolicyCell, PolicyTarget, Role } from '../../../src/core/policy/index.js'; + +/** + * The matrix from `docs/spec/config-access-roles.md`, authored independently + * of `src/core/policy/matrix.ts` so these tests catch a wrong cell, not just + * a self-consistent one. + */ +const EXPECTED_MATRIX: Record> = { + 'explore': { viewer: 'allow', operator: 'allow', admin: 'allow' }, + + 'sql:read': { viewer: 'allow', operator: 'allow', admin: 'allow' }, + 'sql:write': { viewer: 'deny', operator: 'allow', admin: 'allow' }, + 'sql:ddl': { viewer: 'deny', operator: 'deny', admin: 'allow' }, + + 'change:run': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'change:ff': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'change:revert': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + + 'run:build': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'run:file': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'run:dir': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + + 'db:create': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'db:reset': { viewer: 'deny', operator: 'confirm', admin: 'allow' }, + 'db:destroy': { viewer: 'deny', operator: 'deny', admin: 'confirm' }, + + 'config:rm': { viewer: 'deny', operator: 'confirm', admin: 'confirm' }, +}; + +const PERMISSIONS: Permission[] = [ + 'explore', + 'sql:read', 'sql:write', 'sql:ddl', + 'change:run', 'change:ff', 'change:revert', + 'run:build', 'run:file', 'run:dir', + 'db:create', 'db:reset', 'db:destroy', + 'config:rm', +]; +const ROLES: Role[] = ['viewer', 'operator', 'admin']; +const CHANNELS: Channel[] = ['user', 'mcp']; + +/** + * Build a target where both channels carry the given role, so the channel + * under test is what actually drives `checkPolicy`'s decision. + */ +function targetFor(role: Role, name = 'acme'): PolicyTarget { + + return { name, access: { user: role, mcp: role } }; + +} + +describe('policy: checkPolicy', () => { + + const envBackup: Record = {}; + + beforeEach(() => { + + envBackup['NOORM_YES'] = process.env['NOORM_YES']; + delete process.env['NOORM_YES']; + + }); + + afterEach(() => { + + if (envBackup['NOORM_YES'] === undefined) { + + delete process.env['NOORM_YES']; + + } + else { + + process.env['NOORM_YES'] = envBackup['NOORM_YES']; + + } + + }); + + for (const permission of PERMISSIONS) { + + for (const role of ROLES) { + + for (const channel of CHANNELS) { + + const cell = EXPECTED_MATRIX[permission][role]; + + it(`${permission} / ${role} / ${channel} -> ${cell}`, () => { + + const target = targetFor(role); + const check = checkPolicy(channel, target, permission); + + if (cell === 'allow') { + + expect(check.allowed).toBe(true); + expect(check.requiresConfirmation).toBe(false); + + return; + + } + + if (cell === 'deny') { + + expect(check.allowed).toBe(false); + expect(check.requiresConfirmation).toBe(false); + expect(check.blockedReason).toBeDefined(); + + return; + + } + + // cell === 'confirm', channel-resolved + if (channel === 'user') { + + expect(check.allowed).toBe(true); + expect(check.requiresConfirmation).toBe(true); + expect(check.confirmationPhrase).toBe(`yes-${target.name}`); + + } + else { + + expect(check.allowed).toBe(false); + expect(check.requiresConfirmation).toBe(false); + expect(check.blockedReason).toBeDefined(); + expect(check.blockedReason?.toLowerCase()).toContain('cli'); + + } + + }); + + } + + } + + } + + it('should skip user-channel confirmation when NOORM_YES=1', () => { + + process.env['NOORM_YES'] = '1'; + + const check = checkPolicy('user', targetFor('operator', 'prod'), 'change:run'); + + expect(check.allowed).toBe(true); + expect(check.requiresConfirmation).toBe(false); + expect(check.confirmationPhrase).toBeUndefined(); + + }); + + it('should not let NOORM_YES affect the mcp channel', () => { + + process.env['NOORM_YES'] = '1'; + + const check = checkPolicy('mcp', targetFor('operator', 'prod'), 'change:run'); + + expect(check.allowed).toBe(false); + expect(check.blockedReason).toBeDefined(); + + }); + + it('should deny with a blockedReason when access.mcp is false', () => { + + const target: PolicyTarget = { name: 'invisible', access: { user: 'admin', mcp: false } }; + + const check = checkPolicy('mcp', target, 'explore'); + + expect(check.allowed).toBe(false); + expect(check.requiresConfirmation).toBe(false); + expect(check.blockedReason).toBeDefined(); + + }); + +}); + +describe('policy: checkConfigPolicy', () => { + + it('should deny on the user channel with the shared message when access is absent', () => { + + const check = checkConfigPolicy('user', { name: 'legacy' }, 'explore'); + + expect(check.allowed).toBe(false); + expect(check.requiresConfirmation).toBe(false); + expect(check.blockedReason).toBe('Config "legacy" has no access configuration.'); + + }); + + it('should deny on the mcp channel with the same shared message when access is absent', () => { + + const check = checkConfigPolicy('mcp', { name: 'legacy' }, 'explore'); + + expect(check.allowed).toBe(false); + expect(check.requiresConfirmation).toBe(false); + expect(check.blockedReason).toBe('Config "legacy" has no access configuration.'); + + }); + + it('should delegate to checkPolicy when access is present', () => { + + const check = checkConfigPolicy('user', targetFor('admin'), 'db:destroy'); + + expect(check.allowed).toBe(true); + expect(check.requiresConfirmation).toBe(true); + expect(check.confirmationPhrase).toBe('yes-acme'); + + }); + +}); + +describe('policy: assertPolicy', () => { + + it('should throw with the blockedReason when a viewer is denied', () => { + + expect(() => assertPolicy('user', targetFor('viewer'), 'db:destroy')).toThrow( + '"db:destroy" is not allowed on config "acme" (role: viewer).', + ); + + }); + + it('should not throw when an admin is allowed', () => { + + expect(() => assertPolicy('user', targetFor('admin'), 'sql:read')).not.toThrow(); + + }); + + it('should deny with the shared fallback message when access is absent', () => { + + expect(() => assertPolicy('user', { name: 'legacy' }, 'explore')).toThrow( + 'Config "legacy" has no access configuration.', + ); + + }); + +}); + +describe('policy: confirmationPhraseFor', () => { + + it('should produce the yes- phrase checkPolicy resolves for a confirm cell', () => { + + expect(confirmationPhraseFor('prod')).toBe('yes-prod'); + + const check = checkPolicy('user', targetFor('operator', 'prod'), 'change:run'); + + expect(check.confirmationPhrase).toBe(confirmationPhraseFor('prod')); + + }); + +}); + +describe('policy: guarded', () => { + + it('should be false for admin', () => { + + expect(guarded(targetFor('admin'))).toBe(false); + + }); + + it('should be true for operator', () => { + + expect(guarded(targetFor('operator'))).toBe(true); + + }); + + it('should be true for viewer', () => { + + expect(guarded(targetFor('viewer'))).toBe(true); + + }); + +}); diff --git a/tests/core/policy/classify.test.ts b/tests/core/policy/classify.test.ts new file mode 100644 index 00000000..679928c8 --- /dev/null +++ b/tests/core/policy/classify.test.ts @@ -0,0 +1,446 @@ +/** + * Access policy: classifyStatements. + */ +import { describe, it, expect } from 'bun:test'; +import { classifyStatements } from '../../../src/core/policy/index.js'; + +describe('policy: classifyStatements', () => { + + describe('read', () => { + + it('should classify SELECT as read', () => { + + expect(classifyStatements('SELECT * FROM users', 'postgres')).toBe('read'); + + }); + + it('should classify lowercase select as read', () => { + + expect(classifyStatements('select 1', 'postgres')).toBe('read'); + + }); + + it('should classify EXPLAIN as read', () => { + + expect(classifyStatements('EXPLAIN SELECT * FROM users', 'postgres')).toBe('read'); + + }); + + it('should classify SHOW as read on mysql', () => { + + expect(classifyStatements('SHOW TABLES', 'mysql')).toBe('read'); + + }); + + it('should classify DESCRIBE as read on mssql (keyword fallback)', () => { + + expect(classifyStatements('DESCRIBE users', 'mssql')).toBe('read'); + + }); + + it('should classify DESC as read on mssql (keyword fallback)', () => { + + expect(classifyStatements('DESC users', 'mssql')).toBe('read'); + + }); + + it('should classify empty input as read', () => { + + expect(classifyStatements('', 'postgres')).toBe('read'); + + }); + + it('should classify whitespace-only input as read', () => { + + expect(classifyStatements(' \n ', 'postgres')).toBe('read'); + + }); + + it('should classify comment-only input as read', () => { + + expect(classifyStatements('-- just a comment', 'postgres')).toBe('read'); + + }); + + it('should classify all-SELECT multi-statement input as read', () => { + + expect(classifyStatements('SELECT 1; SELECT 2', 'postgres')).toBe('read'); + + }); + + it("should classify SELECT with 'INTO' inside a string literal as read (no false positive)", () => { + + expect(classifyStatements("SELECT * FROM users WHERE name = 'INTO table'", 'postgres')).toBe('read'); + + }); + + it('should classify a leading line comment followed by SELECT as read', () => { + + expect(classifyStatements('-- this is a comment\nSELECT 1', 'postgres')).toBe('read'); + + }); + + it('should classify a leading block comment followed by SELECT as read', () => { + + expect(classifyStatements('/* comment */ SELECT 1', 'postgres')).toBe('read'); + + }); + + it('should classify SELECT with a semicolon inside a string literal as read (no false split)', () => { + + expect(classifyStatements("SELECT 'a;b' FROM t", 'mssql')).toBe('read'); + + }); + + }); + + describe('write', () => { + + it('should classify INSERT as write', () => { + + expect(classifyStatements("INSERT INTO users (name) VALUES ('alice')", 'postgres')).toBe('write'); + + }); + + it('should classify UPDATE as write', () => { + + expect(classifyStatements("UPDATE users SET name = 'bob'", 'postgres')).toBe('write'); + + }); + + it('should classify DELETE as write', () => { + + expect(classifyStatements('DELETE FROM users', 'postgres')).toBe('write'); + + }); + + it('should classify MERGE as write', () => { + + expect(classifyStatements( + 'MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.x = s.x', + 'postgres', + )).toBe('write'); + + }); + + it('should classify INSERT as write on mssql (keyword fallback)', () => { + + expect(classifyStatements("INSERT INTO users (name) VALUES ('alice')", 'mssql')).toBe('write'); + + }); + + }); + + describe('ddl', () => { + + it('should classify DROP as ddl', () => { + + expect(classifyStatements('DROP TABLE users', 'postgres')).toBe('ddl'); + + }); + + it('should classify CREATE as ddl', () => { + + expect(classifyStatements('CREATE TABLE users (id INT)', 'postgres')).toBe('ddl'); + + }); + + it('should classify ALTER as ddl', () => { + + expect(classifyStatements('ALTER TABLE users ADD COLUMN email TEXT', 'postgres')).toBe('ddl'); + + }); + + it('should classify TRUNCATE as ddl', () => { + + expect(classifyStatements('TRUNCATE TABLE users', 'postgres')).toBe('ddl'); + + }); + + it('should classify GRANT as ddl', () => { + + expect(classifyStatements('GRANT SELECT ON t TO role1', 'postgres')).toBe('ddl'); + + }); + + it('should classify REVOKE as ddl', () => { + + expect(classifyStatements('REVOKE SELECT ON t FROM role1', 'postgres')).toBe('ddl'); + + }); + + it('should classify SET as ddl', () => { + + expect(classifyStatements('SET search_path TO foo', 'postgres')).toBe('ddl'); + + }); + + it('should classify CALL as ddl (postgres, parses via CST)', () => { + + expect(classifyStatements('CALL my_proc(1, 2)', 'postgres')).toBe('ddl'); + + }); + + it('should classify EXEC as ddl on mssql (keyword fallback)', () => { + + expect(classifyStatements('EXEC sp_who2', 'mssql')).toBe('ddl'); + + }); + + it('should classify EXECUTE as ddl on mssql (keyword fallback)', () => { + + expect(classifyStatements('EXECUTE sp_help', 'mssql')).toBe('ddl'); + + }); + + it('should classify unparseable input as ddl (fail closed)', () => { + + expect(classifyStatements('this is not sql at all !!', 'postgres')).toBe('ddl'); + + }); + + it('should classify DROP hidden behind comment markers in string literals as ddl', () => { + + expect(classifyStatements( + "SELECT TOP 1 'safe /* ' FROM t; DROP TABLE users -- */'", + 'mssql', + )).toBe('ddl'); + + }); + + it('should classify SELECT ... INTO new_table as ddl (postgres, CST path)', () => { + + expect(classifyStatements('SELECT * INTO new_table FROM users', 'postgres')).toBe('ddl'); + + }); + + it('should classify SELECT ... INTO #tmp as ddl on mssql (keyword fallback)', () => { + + expect(classifyStatements('SELECT * INTO #tmp FROM users', 'mssql')).toBe('ddl'); + + }); + + it('should classify SELECT ... INTO OUTFILE as ddl on mysql (CST path)', () => { + + expect(classifyStatements("SELECT * FROM users INTO OUTFILE '/tmp/x'", 'mysql')).toBe('ddl'); + + }); + + }); + + describe('multi-statement — highest class wins', () => { + + it('should classify SELECT + DROP as ddl', () => { + + expect(classifyStatements('SELECT 1; DROP TABLE users', 'postgres')).toBe('ddl'); + + }); + + it('should classify SELECT + INSERT as write', () => { + + expect(classifyStatements('SELECT 1; INSERT INTO t VALUES (1)', 'postgres')).toBe('write'); + + }); + + it('should classify SELECT + INSERT + DROP as ddl', () => { + + expect(classifyStatements('SELECT 1; INSERT INTO t VALUES (1); DROP TABLE t', 'postgres')).toBe('ddl'); + + }); + + }); + + describe('CTE handling', () => { + + it('should classify WITH ... SELECT as read (CST, postgres)', () => { + + expect(classifyStatements('WITH cte AS (SELECT 1) SELECT * FROM cte', 'postgres')).toBe('read'); + + }); + + it('should classify WITH ... INSERT as write (CST, postgres)', () => { + + expect(classifyStatements( + 'WITH cte AS (SELECT 1) INSERT INTO t SELECT * FROM cte', + 'postgres', + )).toBe('write'); + + }); + + it('should classify WITH ... SELECT as read via keyword fallback (mssql-only syntax)', () => { + + expect(classifyStatements( + 'WITH cte AS (SELECT TOP 1 * FROM t) SELECT * FROM cte', + 'mssql', + )).toBe('read'); + + }); + + it('should classify WITH ... DELETE as write via keyword fallback (mssql-only syntax)', () => { + + expect(classifyStatements( + 'WITH cte AS (SELECT TOP 1 * FROM t) DELETE FROM cte', + 'mssql', + )).toBe('write'); + + }); + + }); + + describe('CTE-DML — data-modifying CTE definitions bypass the read gate', () => { + + it('should classify WITH t AS (DELETE ... RETURNING ...) SELECT as write (CST, postgres)', () => { + + expect(classifyStatements( + 'WITH t AS (DELETE FROM users WHERE id=1 RETURNING id) SELECT * FROM t', + 'postgres', + )).toBe('write'); + + }); + + it('should classify WITH t AS (INSERT ... RETURNING ...) SELECT as write (CST, postgres)', () => { + + expect(classifyStatements( + "WITH t AS (INSERT INTO users(name) VALUES ('x') RETURNING id) SELECT * FROM t", + 'postgres', + )).toBe('write'); + + }); + + it('should classify WITH t AS (UPDATE ... RETURNING ...) SELECT as write (CST, postgres)', () => { + + expect(classifyStatements( + "WITH t AS (UPDATE users SET name='y' WHERE id=1 RETURNING id) SELECT * FROM t", + 'postgres', + )).toBe('write'); + + }); + + it('should classify WITH t AS (DELETE ...) SELECT as write via keyword fallback (mssql TOP forces fallback)', () => { + + expect(classifyStatements( + 'WITH t AS (DELETE TOP (1) FROM users) SELECT * FROM t', + 'mssql', + )).toBe('write'); + + }); + + it('should still classify a pure WITH t AS (SELECT ...) SELECT as read (CST, postgres — no false positive)', () => { + + expect(classifyStatements('WITH t AS (SELECT 1) SELECT * FROM t', 'postgres')).toBe('read'); + + }); + + it('should still classify a pure WITH t AS (SELECT ...) SELECT as read via keyword fallback (mssql — no false positive)', () => { + + expect(classifyStatements( + 'WITH t AS (SELECT TOP 1 * FROM t) SELECT * FROM t', + 'mssql', + )).toBe('read'); + + }); + + }); + + describe('destructive function denylist', () => { + + it('should classify SELECT pg_terminate_backend(...) as write (CST, postgres)', () => { + + expect(classifyStatements('SELECT pg_terminate_backend(123)', 'postgres')).toBe('write'); + + }); + + it('should classify SELECT pg_terminate_backend(...) as write via keyword fallback (mssql TOP forces fallback)', () => { + + expect(classifyStatements('SELECT TOP 1 pg_terminate_backend(123)', 'mssql')).toBe('write'); + + }); + + it('should stay write when a denylisted function is called inside an already-write statement', () => { + + expect(classifyStatements( + 'UPDATE t SET x = pg_terminate_backend(1) WHERE id = 1', + 'postgres', + )).toBe('write'); + + }); + + it('should classify SELECT count(*) as read (pure function, not on the denylist)', () => { + + expect(classifyStatements('SELECT count(*) FROM t', 'postgres')).toBe('read'); + + }); + + it('should classify SELECT now() as read (pure function, not on the denylist)', () => { + + expect(classifyStatements('SELECT now()', 'postgres')).toBe('read'); + + }); + + it('should classify SELECT delete_user(1) as read — documented limitation: unlisted side-effecting functions are not caught', () => { + + expect(classifyStatements('SELECT delete_user(1)', 'postgres')).toBe('read'); + + }); + + it('should classify a schema-qualified denylisted function call as write (member_expr name)', () => { + + expect(classifyStatements('SELECT pg_catalog.pg_terminate_backend(1)', 'postgres')).toBe('write'); + + }); + + it('should classify a quoted-schema-qualified denylisted function call as write (member_expr name)', () => { + + expect(classifyStatements('SELECT "pg_catalog".pg_terminate_backend(1)', 'postgres')).toBe('write'); + + }); + + it('should classify a doubly-qualified denylisted function call as write (nested member_expr, rightmost property wins)', () => { + + expect(classifyStatements('SELECT db.pg_catalog.pg_terminate_backend(1)', 'postgres')).toBe('write'); + + }); + + it('should classify a schema-qualified pure function call as read (no false positive from qualification alone)', () => { + + expect(classifyStatements('SELECT pg_catalog.count(x) FROM t', 'postgres')).toBe('read'); + + }); + + it('should classify a denylisted name used as a column qualifier (not a function call) as read', () => { + + expect(classifyStatements('SELECT pg_terminate_backend.x FROM t', 'postgres')).toBe('read'); + + }); + + it('should classify SELECT query_to_xml(...) as write (arbitrary-SQL-executing XML function)', () => { + + expect(classifyStatements("SELECT query_to_xml('DELETE FROM users', false, false, '')", 'postgres')).toBe('write'); + + }); + + }); + + describe('CTE final statement — own subquery parens must not be mistaken for the CTE boundary', () => { + + it('should classify a CTE with a SELECT final statement containing a subquery as read via keyword fallback (mssql)', () => { + + expect(classifyStatements( + 'WITH t AS (SELECT TOP 1 * FROM x) SELECT * FROM users WHERE id IN (SELECT id FROM t)', + 'mssql', + )).toBe('read'); + + }); + + it('should classify a CTE with a DELETE final statement containing a subquery as write via keyword fallback (mssql), not over-denied to ddl', () => { + + expect(classifyStatements( + 'WITH t AS (SELECT TOP 1 * FROM x) DELETE FROM users WHERE id IN (SELECT id FROM t)', + 'mssql', + )).toBe('write'); + + }); + + }); + +}); diff --git a/tests/core/rpc/commands.test.ts b/tests/core/rpc/commands.test.ts index bc532c22..639a7a95 100644 --- a/tests/core/rpc/commands.test.ts +++ b/tests/core/rpc/commands.test.ts @@ -8,6 +8,7 @@ import { changesCommands } from '../../../src/rpc/commands/changes.js'; import { runCommands } from '../../../src/rpc/commands/run.js'; import { RpcError } from '../../../src/rpc/types.js'; import type { RpcSession } from '../../../src/rpc/types.js'; +import type { ConfigAccess, Channel } from '../../../src/core/policy/index.js'; import type { Context } from '../../../src/sdk/context.js'; // === Mock factories === @@ -16,14 +17,17 @@ interface MockContextOptions { configName?: string; dialect?: string; database?: string; - protected?: boolean; + access?: ConfigAccess; + channel?: Channel; } /** * Creates a mock RpcSession with a fake context for unit testing handlers. * - * Provides full mock noorm operations so handlers can reach the protection + * Provides full mock noorm operations so handlers can reach the policy * check and session delegation logic without needing a real database. + * Defaults to the `mcp` channel since that's what CP3 gates; individual + * tests override `channel` to prove the same handler logic is channel-generic. */ function createMockSession(options: MockContextOptions = {}): RpcSession { @@ -33,7 +37,7 @@ function createMockSession(options: MockContextOptions = {}): RpcSession { noorm: { config: { name: options.configName ?? 'test', - protected: options.protected ?? false, + access: options.access ?? { user: 'admin', mcp: 'admin' }, connection: { database: options.database ?? 'testdb' }, }, changes: { @@ -50,12 +54,13 @@ function createMockSession(options: MockContextOptions = {}): RpcSession { } as unknown as Context; return { + channel: options.channel ?? 'mcp', getContext: () => mockContext, connect: async (config?: string) => ({ name: config ?? 'test', dialect: 'postgres', database: 'testdb', - protected: false, + role: 'admin', }), disconnect: async () => {}, disconnectAll: async () => {}, @@ -75,12 +80,13 @@ function createMockSession(options: MockContextOptions = {}): RpcSession { function createDisconnectedSession(): RpcSession { return { + channel: 'mcp', getContext: () => { throw new RpcError('Not connected — call connect first'); }, - connect: async () => ({ name: 'test', dialect: 'postgres', database: 'testdb', protected: false }), + connect: async () => ({ name: 'test', dialect: 'postgres', database: 'testdb', role: 'admin' }), disconnect: async () => {}, disconnectAll: async () => {}, hasConnection: () => false, @@ -89,83 +95,92 @@ function createDisconnectedSession(): RpcSession { } -// === Group 1: SQL Protection === +// === Group 1: SQL access-role escalation === describe('rpc commands: sql', () => { const cmd = queryCommands[0]!; - it('should block INSERT on protected config', async () => { + it('should deny INSERT (sql:write) for a viewer role', async () => { - const session = createMockSession({ protected: true }); + const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' } }); const input = cmd.inputSchema.parse({ query: 'INSERT INTO t VALUES (1)' }); - await expect(cmd.handler(input, session)).rejects.toThrow(/protected/i); + await expect(cmd.handler(input, session)).rejects.toThrow(/sql:write/i); }); - it('should block DROP on protected config', async () => { + it('should deny DROP (sql:ddl) for a viewer role', async () => { - const session = createMockSession({ protected: true }); + const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' } }); const input = cmd.inputSchema.parse({ query: 'DROP TABLE users' }); - await expect(cmd.handler(input, session)).rejects.toThrow(/protected/i); + await expect(cmd.handler(input, session)).rejects.toThrow(/sql:ddl/i); }); - it('should block UPDATE on protected config', async () => { + it('should deny UPDATE (sql:write) for a viewer role', async () => { - const session = createMockSession({ protected: true }); + const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' } }); const input = cmd.inputSchema.parse({ query: 'UPDATE users SET name = \'bob\'' }); - await expect(cmd.handler(input, session)).rejects.toThrow(/protected/i); + await expect(cmd.handler(input, session)).rejects.toThrow(/sql:write/i); }); - it('should block DELETE on protected config', async () => { + it('should deny DELETE (sql:write) for a viewer role', async () => { - const session = createMockSession({ protected: true }); + const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' } }); const input = cmd.inputSchema.parse({ query: 'DELETE FROM users' }); - await expect(cmd.handler(input, session)).rejects.toThrow(/protected/i); + await expect(cmd.handler(input, session)).rejects.toThrow(/sql:write/i); }); - it('should allow SELECT on protected config (no protection error)', async () => { + it('should allow SELECT for a viewer role (policy passes, execution reached)', async () => { - const session = createMockSession({ protected: true }); + const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' } }); const input = cmd.inputSchema.parse({ query: 'SELECT * FROM users LIMIT 10' }); - // Protection check passes; executeRawSql will fail without a real DB - const [, err] = await attempt(() => cmd.handler(input, session)); + // The policy gate is the only thing in this call chain that rejects + // for a denied permission — executeRawSqlUnchecked catches its own + // DB errors into a `{ success: false }` result instead of rejecting. + // So an unconditional resolve is proof the policy check allowed this + // query; a denied permission would reject instead. + await expect(cmd.handler(input, session)).resolves.toBeDefined(); + + }); - if (err) { + it('should allow INSERT (sql:write) for an operator role (policy passes, execution reached)', async () => { - expect(err.message).not.toMatch(/protected/i); + const session = createMockSession({ access: { user: 'admin', mcp: 'operator' } }); + const input = cmd.inputSchema.parse({ query: 'INSERT INTO t VALUES (1)' }); - } + await expect(cmd.handler(input, session)).resolves.toBeDefined(); }); - it('should allow INSERT on unprotected config (no protection error)', async () => { + it('should deny DROP (sql:ddl) for an operator role', async () => { - const session = createMockSession({ protected: false }); - const input = cmd.inputSchema.parse({ query: 'INSERT INTO t VALUES (1)' }); + const session = createMockSession({ access: { user: 'admin', mcp: 'operator' } }); + const input = cmd.inputSchema.parse({ query: 'DROP TABLE users' }); - // Protection check is skipped; executeRawSql will fail without a real DB - const [, err] = await attempt(() => cmd.handler(input, session)); + await expect(cmd.handler(input, session)).rejects.toThrow(/sql:ddl/i); + + }); - if (err) { + it('should allow DROP (sql:ddl) for an admin role (policy passes, execution reached)', async () => { - expect(err.message).not.toMatch(/protected/i); + const session = createMockSession({ access: { user: 'admin', mcp: 'admin' } }); + const input = cmd.inputSchema.parse({ query: 'DROP TABLE users' }); - } + await expect(cmd.handler(input, session)).resolves.toBeDefined(); }); - it('should include config name in protection error', async () => { + it('should include config name in the denial reason', async () => { - const session = createMockSession({ protected: true, configName: 'prod' }); + const session = createMockSession({ access: { user: 'admin', mcp: 'viewer' }, configName: 'prod' }); const input = cmd.inputSchema.parse({ query: 'DROP TABLE secrets' }); const [, err] = await attempt(() => cmd.handler(input, session)); @@ -184,6 +199,15 @@ describe('rpc commands: sql', () => { }); + it('should apply the same escalation on the user channel', async () => { + + const session = createMockSession({ access: { user: 'viewer', mcp: 'admin' }, channel: 'user' }); + const input = cmd.inputSchema.parse({ query: 'INSERT INTO t VALUES (1)' }); + + await expect(cmd.handler(input, session)).rejects.toThrow(/sql:write/i); + + }); + }); // === Group 2: Session Commands === @@ -202,7 +226,7 @@ describe('rpc commands: session', () => { connectCalled = true; receivedConfig = config; - return { name: config ?? 'test', dialect: 'postgres', database: 'testdb', protected: false }; + return { name: config ?? 'test', dialect: 'postgres', database: 'testdb', role: 'admin' }; }, }; @@ -226,7 +250,7 @@ describe('rpc commands: session', () => { receivedConfig = config; - return { name: 'test', dialect: 'postgres', database: 'testdb', protected: false }; + return { name: 'test', dialect: 'postgres', database: 'testdb', role: 'admin' }; }, }; diff --git a/tests/core/rpc/list-configs.test.ts b/tests/core/rpc/list-configs.test.ts new file mode 100644 index 00000000..221f0af3 --- /dev/null +++ b/tests/core/rpc/list-configs.test.ts @@ -0,0 +1,115 @@ +/** + * rpc commands: list_configs mcp-channel invisibility. + * + * Uses a real StateManager (via the module singleton `list_configs` reads + * through `initState()`) rather than mocking state, so the test proves the + * actual filtering behavior end-to-end. `setKeyOverride` supplies the + * encryption key in-memory — the same mechanism CI identity bootstrap uses — + * so persistence never touches the real `~/.noorm/identity.key`. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { initState, resetStateManager } from '../../../src/core/state/index.js'; +import { generateKeyPair, setKeyOverride, clearKeyOverride } from '../../../src/core/identity/index.js'; +import { configCommands } from '../../../src/rpc/commands/config.js'; +import type { Config, ConfigSummary } from '../../../src/core/config/types.js'; +import type { RpcSession } from '../../../src/rpc/types.js'; +import type { Channel } from '../../../src/core/policy/index.js'; + +/** `configCommands` is typed `RpcCommand[]` (generics erased) — narrow the handler's `unknown` result without casting. */ +function isConfigSummaryArray(value: unknown): value is ConfigSummary[] { + + return Array.isArray(value); + +} + +function testConfig(name: string, overrides: Partial = {}): Config { + + return { + name, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: ':memory:' }, + ...overrides, + }; + +} + +function sessionFor(channel: Channel): RpcSession { + + return { + channel, + getContext: () => { + + throw new Error('not used by list_configs'); + + }, + connect: async () => ({ name: 'x', dialect: 'sqlite', database: ':memory:', role: 'admin' }), + disconnect: async () => {}, + disconnectAll: async () => {}, + hasConnection: () => false, + listConnections: () => [], + }; + +} + +describe('rpc commands: list_configs', () => { + + const cmd = configCommands.find((c) => c.name === 'list_configs')!; + + let tempDir: string; + + beforeEach(async () => { + + resetStateManager(); + tempDir = mkdtempSync(join(tmpdir(), 'noorm-list-configs-')); + + const { privateKey } = await generateKeyPair(); + setKeyOverride(privateKey); + + const manager = await initState(tempDir); + + await manager.setConfig('visible', testConfig('visible')); + await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', mcp: false } })); + + }); + + afterEach(() => { + + clearKeyOverride(); + resetStateManager(); + rmSync(tempDir, { recursive: true, force: true }); + + }); + + it('should omit configs with access.mcp === false on the mcp channel', async () => { + + const result = await cmd.handler({}, sessionFor('mcp')); + + if (!isConfigSummaryArray(result)) throw new Error('expected an array of ConfigSummary'); + + const names = result.map((c) => c.name); + + expect(names).toContain('visible'); + expect(names).not.toContain('hidden'); + + }); + + it('should include all configs on the user channel', async () => { + + const result = await cmd.handler({}, sessionFor('user')); + + if (!isConfigSummaryArray(result)) throw new Error('expected an array of ConfigSummary'); + + const names = result.map((c) => c.name); + + expect(names).toContain('visible'); + expect(names).toContain('hidden'); + + }); + +}); diff --git a/tests/core/rpc/permissions.test.ts b/tests/core/rpc/permissions.test.ts new file mode 100644 index 00000000..567a7d3c --- /dev/null +++ b/tests/core/rpc/permissions.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'bun:test'; + +import { RpcRegistry } from '../../../src/rpc/registry.js'; +import { registerAllCommands } from '../../../src/rpc/commands/index.js'; +import type { Permission } from '../../../src/core/policy/index.js'; + +/** + * Pins the security-relevant `permission` gate for every RPC command. The + * dispatch check in `src/mcp/server.ts` is only as strong as the value each + * command registers — a typo or an accidental flip to `'open'` would skip + * the policy gate entirely and go undetected by any test that mocks the + * command list instead of the real registry. + */ +const EXPECTED_PERMISSIONS: Record = { + change_run: 'change:run', + change_ff: 'change:ff', + change_revert: 'change:revert', + run_build: 'run:build', + run_file: 'run:file', + sql: 'sql:read', + list: 'explore', + detail: 'explore', + overview: 'explore', + change_history: 'explore', + list_configs: 'open', + connect: 'open', + disconnect: 'open', +}; + +describe('rpc: command permissions', () => { + + const registry = new RpcRegistry(); + registerAllCommands(registry); + + for (const [name, permission] of Object.entries(EXPECTED_PERMISSIONS)) { + + it(`${name} should gate on '${permission}'`, () => { + + const cmd = registry.get(name); + + expect(cmd).toBeDefined(); + expect(cmd!.permission).toBe(permission); + + }); + + } + + it('should not register any command outside the pinned table', () => { + + const registered = registry.list().map((cmd) => cmd.name).sort(); + const expected = Object.keys(EXPECTED_PERMISSIONS).sort(); + + expect(registered).toEqual(expected); + + }); + +}); diff --git a/tests/core/rpc/protection.test.ts b/tests/core/rpc/protection.test.ts deleted file mode 100644 index 2ad8047f..00000000 --- a/tests/core/rpc/protection.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { isReadOnlyStatement } from '../../../src/rpc/protection.js'; - -describe('rpc: protection', () => { - - describe('isReadOnlyStatement', () => { - - // === Allowed statements === - - it('should allow SELECT', () => { - - expect(isReadOnlyStatement('SELECT * FROM users', 'postgres')).toBe(true); - - }); - - it('should allow select (lowercase)', () => { - - expect(isReadOnlyStatement('select 1', 'postgres')).toBe(true); - - }); - - it('should allow EXPLAIN', () => { - - expect(isReadOnlyStatement('EXPLAIN SELECT * FROM users', 'postgres')).toBe(true); - - }); - - it('should allow SHOW', () => { - - expect(isReadOnlyStatement('SHOW TABLES', 'mysql')).toBe(true); - - }); - - it('should allow DESCRIBE', () => { - - expect(isReadOnlyStatement('DESCRIBE users', 'mysql')).toBe(true); - - }); - - it('should allow DESC', () => { - - expect(isReadOnlyStatement('DESC users', 'mysql')).toBe(true); - - }); - - it('should allow WITH ... SELECT (CTE)', () => { - - expect(isReadOnlyStatement('WITH cte AS (SELECT 1) SELECT * FROM cte', 'postgres')).toBe(true); - - }); - - // === Blocked statements === - - it('should block INSERT', () => { - - expect(isReadOnlyStatement('INSERT INTO users (name) VALUES (\'alice\')', 'postgres')).toBe(false); - - }); - - it('should block UPDATE', () => { - - expect(isReadOnlyStatement('UPDATE users SET name = \'bob\'', 'postgres')).toBe(false); - - }); - - it('should block DELETE', () => { - - expect(isReadOnlyStatement('DELETE FROM users', 'postgres')).toBe(false); - - }); - - it('should block DROP', () => { - - expect(isReadOnlyStatement('DROP TABLE users', 'postgres')).toBe(false); - - }); - - it('should block CREATE', () => { - - expect(isReadOnlyStatement('CREATE TABLE users (id INT)', 'postgres')).toBe(false); - - }); - - it('should block ALTER', () => { - - expect(isReadOnlyStatement('ALTER TABLE users ADD COLUMN email TEXT', 'postgres')).toBe(false); - - }); - - it('should block TRUNCATE', () => { - - expect(isReadOnlyStatement('TRUNCATE TABLE users', 'postgres')).toBe(false); - - }); - - // === Edge cases === - - it('should handle SQL with leading comments', () => { - - expect(isReadOnlyStatement('-- this is a comment\nSELECT 1', 'postgres')).toBe(true); - - }); - - it('should handle SQL with block comments', () => { - - expect(isReadOnlyStatement('/* comment */ SELECT 1', 'postgres')).toBe(true); - - }); - - it('should handle comment hiding a dangerous statement', () => { - - expect(isReadOnlyStatement('-- SELECT 1\nDROP TABLE users', 'postgres')).toBe(false); - - }); - - it('should block multi-statement with mixed intent', () => { - - expect(isReadOnlyStatement('SELECT 1; DROP TABLE users', 'postgres')).toBe(false); - - }); - - it('should allow multi-statement all SELECT', () => { - - expect(isReadOnlyStatement('SELECT 1; SELECT 2', 'postgres')).toBe(true); - - }); - - it('should block WITH ... INSERT', () => { - - expect(isReadOnlyStatement('WITH cte AS (SELECT 1) INSERT INTO t SELECT * FROM cte', 'postgres')).toBe(false); - - }); - - it('should handle empty string', () => { - - expect(isReadOnlyStatement('', 'postgres')).toBe(true); - - }); - - it('should handle whitespace only', () => { - - expect(isReadOnlyStatement(' \n ', 'postgres')).toBe(true); - - }); - - // === String literal edge cases === - - it('should block DROP hidden by comment markers in string literals', () => { - - expect(isReadOnlyStatement( - "SELECT TOP 1 'safe /* ' FROM t; DROP TABLE users -- */'", - 'mssql', - )).toBe(false); - - }); - - it('should allow SELECT with semicolon inside string literal', () => { - - expect(isReadOnlyStatement( - "SELECT 'a;b' FROM t", - 'mssql', - )).toBe(true); - - }); - - // === MSSQL fallback === - - it('should block EXEC on mssql (keyword fallback)', () => { - - expect(isReadOnlyStatement('EXEC sp_who2', 'mssql')).toBe(false); - - }); - - it('should block EXECUTE on mssql', () => { - - expect(isReadOnlyStatement('EXECUTE sp_help', 'mssql')).toBe(false); - - }); - - it('should allow SELECT on mssql', () => { - - expect(isReadOnlyStatement('SELECT TOP 10 * FROM users', 'mssql')).toBe(true); - - }); - - }); - -}); diff --git a/tests/core/rpc/registry.test.ts b/tests/core/rpc/registry.test.ts index 0d992bcd..373e337c 100644 --- a/tests/core/rpc/registry.test.ts +++ b/tests/core/rpc/registry.test.ts @@ -22,6 +22,7 @@ describe('rpc: registry', () => { description: 'A test command', examples: [{ description: 'basic usage', input: { foo: 'bar' } }], inputSchema: z.object({ foo: z.string() }), + permission: 'open', handler: async () => ({ result: true }), }; @@ -50,6 +51,7 @@ describe('rpc: registry', () => { description: 'First command', examples: [], inputSchema: z.object({}), + permission: 'open', handler: async () => ({}), }); @@ -58,6 +60,7 @@ describe('rpc: registry', () => { description: 'Second command', examples: [], inputSchema: z.object({}), + permission: 'open', handler: async () => ({}), }); @@ -83,6 +86,7 @@ describe('rpc: registry', () => { inputSchema: z.object({ query: z.string().describe('The SQL query to execute'), }), + permission: 'sql:read', handler: async () => ({}), }); diff --git a/tests/core/rpc/session-not-found.test.ts b/tests/core/rpc/session-not-found.test.ts new file mode 100644 index 00000000..68084c24 --- /dev/null +++ b/tests/core/rpc/session-not-found.test.ts @@ -0,0 +1,76 @@ +/** + * rpc session: real unknown-config vs mcp-invisibility error parity. + * + * Runs `SessionManager.connect()` against a real `createContext` / + * `StateManager` (no sdk mock — `session.test.ts` mocks `createContext` to + * drive its other cases without a live project + identity key, which means + * none of those tests ever exercise the resolver's actual unknown-config + * throw). `setKeyOverride` supplies the encryption key in-memory, same as + * `list-configs.test.ts`, so persistence never touches `~/.noorm/identity.key`. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { attempt } from '@logosdx/utils'; + +import { SessionManager } from '../../../src/rpc/session.js'; +import type { Config } from '../../../src/core/config/types.js'; +import { initState, resetStateManager } from '../../../src/core/state/index.js'; +import { resetSettingsManager } from '../../../src/core/settings/index.js'; +import { generateKeyPair, setKeyOverride, clearKeyOverride } from '../../../src/core/identity/index.js'; + +function testConfig(name: string, overrides: Partial = {}): Config { + + return { + name, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: ':memory:' }, + ...overrides, + }; + +} + +describe('rpc: session manager (real createContext)', () => { + + let tempDir: string; + + beforeEach(async () => { + + resetStateManager(); + resetSettingsManager(); + tempDir = mkdtempSync(join(tmpdir(), 'noorm-session-not-found-')); + + const { privateKey } = await generateKeyPair(); + setKeyOverride(privateKey); + + const manager = await initState(tempDir); + await manager.setConfig('hidden', testConfig('hidden', { access: { user: 'admin', mcp: false } })); + + }); + + afterEach(() => { + + clearKeyOverride(); + resetStateManager(); + resetSettingsManager(); + rmSync(tempDir, { recursive: true, force: true }); + + }); + + it('should throw the byte-identical error for a real hidden config and a real unknown config', async () => { + + const mcpSession = new SessionManager('mcp'); + + const [, unknownErr] = await attempt(() => mcpSession.connect('ghost')); + const [, hiddenErr] = await attempt(() => mcpSession.connect('hidden')); + + expect(unknownErr).toBeDefined(); + expect(hiddenErr).toBeDefined(); + expect(hiddenErr!.message).toBe(unknownErr!.message.replace('ghost', 'hidden')); + + }); + +}); diff --git a/tests/core/rpc/session.test.ts b/tests/core/rpc/session.test.ts index 6cbc52b2..9bbc2d0b 100644 --- a/tests/core/rpc/session.test.ts +++ b/tests/core/rpc/session.test.ts @@ -1,5 +1,54 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach, afterAll, mock } from 'bun:test'; +import { attempt } from '@logosdx/utils'; + import { SessionManager } from '../../../src/rpc/session.js'; +import { configNotFoundMessage } from '../../../src/core/config/resolver.js'; +import type { Config } from '../../../src/core/config/types.js'; + +// The real `createContext` resolves configs through on-disk state, which +// session.connect() would otherwise need a live project + identity key for. +// Mocking it lets these tests drive the mcp-channel invisibility and +// fail-closed rules directly, with a plain in-memory config registry. +const actualSdk = await import('../../../src/sdk/index.js'); + +/** Configs the mocked `createContext` resolves by name. */ +const configs = new Map(); + +mock.module('../../../src/sdk/index.js', () => ({ + ...actualSdk, + createContext: async ({ config }: { config?: string } = {}) => { + + const name = config ?? 'default'; + const found = configs.get(name); + + if (!found) { + + throw new Error(configNotFoundMessage(name)); + + } + + return { + dialect: found.connection.dialect, + noorm: { config: found }, + connect: async () => {}, + disconnect: async () => {}, + }; + + }, +})); + +function testConfig(name: string, overrides: Partial = {}): Config { + + return { + name, + type: 'local', + isTest: true, + access: { user: 'admin', mcp: 'admin' }, + connection: { dialect: 'sqlite', database: ':memory:' }, + ...overrides, + }; + +} describe('rpc: session manager', () => { @@ -8,6 +57,19 @@ describe('rpc: session manager', () => { beforeEach(() => { session = new SessionManager(); + configs.clear(); + + }); + + afterEach(() => { + + configs.clear(); + + }); + + afterAll(() => { + + mock.module('../../../src/sdk/index.js', () => actualSdk); }); @@ -47,4 +109,86 @@ describe('rpc: session manager', () => { }); + describe('channel', () => { + + it('should default to "user"', () => { + + expect(new SessionManager().channel).toBe('user'); + + }); + + it('should expose the channel passed to the constructor', () => { + + expect(new SessionManager('mcp').channel).toBe('mcp'); + + }); + + }); + + describe('connect: mcp invisibility', () => { + + it('should throw the byte-identical error for a hidden config (access.mcp: false) and an unknown config', async () => { + + const mcpSession = new SessionManager('mcp'); + + const [, unknownErr] = await attempt(() => mcpSession.connect('does-not-exist')); + + configs.set('secret', testConfig('secret', { access: { user: 'admin', mcp: false } })); + + const [, hiddenErr] = await attempt(() => mcpSession.connect('secret')); + + expect(unknownErr).toBeDefined(); + expect(hiddenErr).toBeDefined(); + expect(unknownErr!.message).toBe('Failed to create context: Config "does-not-exist" not found'); + expect(hiddenErr!.message).toBe(unknownErr!.message.replace('does-not-exist', 'secret')); + + }); + + it('should allow a visible config through on the mcp channel and report its mcp role', async () => { + + const mcpSession = new SessionManager('mcp'); + + configs.set('reporting', testConfig('reporting', { access: { user: 'admin', mcp: 'viewer' } })); + + const info = await mcpSession.connect('reporting'); + + expect(info.role).toBe('viewer'); + + }); + + it('should not apply mcp invisibility on the user channel', async () => { + + configs.set('secret', testConfig('secret', { access: { user: 'operator', mcp: false } })); + + const info = await session.connect('secret'); + + expect(info.role).toBe('operator'); + + }); + + it('should deny an mcp-channel config that reaches connect with no `access` at all, identically to an unknown config', async () => { + + const mcpSession = new SessionManager('mcp'); + + const [, unknownErr] = await attempt(() => mcpSession.connect('does-not-exist')); + + // `Config.access` is required at compile time; this hand-built + // double simulates a runtime boundary that bypasses it (a + // non-TS SDK caller, or state that slipped past the load-time + // normalization) to prove the `!rawAccess` fail-closed check in + // session.ts actually denies rather than throwing or opening up. + const noAccessConfig = testConfig('no-access'); + Reflect.deleteProperty(noAccessConfig, 'access'); + configs.set('no-access', noAccessConfig); + + const [, noAccessErr] = await attempt(() => mcpSession.connect('no-access')); + + expect(unknownErr).toBeDefined(); + expect(noAccessErr).toBeDefined(); + expect(noAccessErr!.message).toBe(unknownErr!.message.replace('does-not-exist', 'no-access')); + + }); + + }); + }); diff --git a/tests/core/runner/mssql-batches.test.ts b/tests/core/runner/mssql-batches.test.ts index ac280d1e..049b5e83 100644 --- a/tests/core/runner/mssql-batches.test.ts +++ b/tests/core/runner/mssql-batches.test.ts @@ -130,6 +130,8 @@ function makeContext(dialect: RunContext['dialect']): TestSetup { configName: 'test', identity: { name: 'Test', email: 't@x.com', source: 'config' }, projectRoot: '/tmp', + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', dialect, }; diff --git a/tests/core/runner/runner.test.ts b/tests/core/runner/runner.test.ts index 426865ec..195125d7 100644 --- a/tests/core/runner/runner.test.ts +++ b/tests/core/runner/runner.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, afterAll } from 'bun:test'; import path from 'node:path'; import { rm } from 'node:fs/promises'; -import { preview } from '../../../src/core/runner/runner.js'; +import { preview, runBuild, runFile, runDir, runFiles } from '../../../src/core/runner/runner.js'; import type { RunContext } from '../../../src/core/runner/types.js'; const FIXTURES_DIR = path.join(import.meta.dirname, 'fixtures'); @@ -19,6 +19,8 @@ const mockContext: RunContext = { configName: 'test', identity: { name: 'Test User', email: 'test@example.com', source: 'config' }, projectRoot: FIXTURES_DIR, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', config: { table: 'users' }, secrets: { API_KEY: 'secret123' }, }; @@ -135,6 +137,54 @@ describe('runner: preview', () => { }); +describe('runner: policy gate', () => { + + // Viewer denies run:build/run:file/run:dir outright (matrix cell: + // deny) — the gate is the first thing each entrypoint does, so this + // proves it fires before any file I/O or DB access is attempted. + const viewerContext: RunContext = { + ...mockContext, + access: { user: 'viewer', mcp: false }, + }; + + it('should deny runBuild for a viewer role', async () => { + + await expect(runBuild(viewerContext, FIXTURES_DIR)).rejects.toThrow(/run:build/); + + }); + + it('should deny runFile for a viewer role', async () => { + + const filepath = path.join(FIXTURES_DIR, 'raw.sql'); + + await expect(runFile(viewerContext, filepath)).rejects.toThrow(/run:file/); + + }); + + it('should deny runDir for a viewer role', async () => { + + await expect(runDir(viewerContext, FIXTURES_DIR)).rejects.toThrow(/run:dir/); + + }); + + it('should deny runFiles for a viewer role', async () => { + + const filepath = path.join(FIXTURES_DIR, 'raw.sql'); + + await expect(runFiles(viewerContext, [filepath])).rejects.toThrow(/run:dir/); + + }); + + it('should carry the config name in the denial reason', async () => { + + const filepath = path.join(FIXTURES_DIR, 'raw.sql'); + + await expect(runFile(viewerContext, filepath)).rejects.toThrow(/"test"/); + + }); + +}); + describe('runner: file detection', () => { it('should identify SQL files', async () => { diff --git a/tests/core/settings/manager.test.ts b/tests/core/settings/manager.test.ts index c2c81ea3..206bd3a5 100644 --- a/tests/core/settings/manager.test.ts +++ b/tests/core/settings/manager.test.ts @@ -688,28 +688,6 @@ stages: }); - it('should check if stage enforces protected', async () => { - - const { manager, cleanup } = createTestContext(); - - try { - - await manager.load(); - await manager.setStage('prod', { defaults: { protected: true } }); - await manager.setStage('dev', { defaults: { protected: false } }); - - expect(manager.stageEnforcesProtected('prod')).toBe(true); - expect(manager.stageEnforcesProtected('dev')).toBe(false); - - } - finally { - - cleanup(); - - } - - }); - it('should check if stage enforces isTest', async () => { const { manager, cleanup } = createTestContext(); @@ -1044,7 +1022,7 @@ stages: name: 'test', type: 'local' as const, isTest: true, - protected: false, + access: { user: 'admin' as const, mcp: 'admin' as const }, }; const result = manager.evaluateRules(testConfig); @@ -1082,7 +1060,7 @@ stages: name: 'test', type: 'local' as const, isTest: true, - protected: false, + access: { user: 'admin' as const, mcp: 'admin' as const }, }; const result = manager.getEffectiveBuildPaths(testConfig); diff --git a/tests/core/settings/rules.test.ts b/tests/core/settings/rules.test.ts index aa2a428f..70cc2e9d 100644 --- a/tests/core/settings/rules.test.ts +++ b/tests/core/settings/rules.test.ts @@ -17,28 +17,28 @@ describe('settings: rule evaluation', () => { name: 'dev', type: 'local', isTest: false, - protected: false, + access: { user: 'admin', mcp: 'admin' }, }; const testConfig: ConfigForRuleMatch = { name: 'test', type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, }; const prodConfig: ConfigForRuleMatch = { name: 'prod', type: 'remote', isTest: false, - protected: true, + access: { user: 'operator', mcp: 'viewer' }, }; const stagingConfig: ConfigForRuleMatch = { name: 'staging', type: 'remote', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, }; describe('ruleMatches', () => { diff --git a/tests/core/sql-terminal/executor.test.ts b/tests/core/sql-terminal/executor.test.ts index 227eb09e..ff9b93a7 100644 --- a/tests/core/sql-terminal/executor.test.ts +++ b/tests/core/sql-terminal/executor.test.ts @@ -1,13 +1,14 @@ /** * SQL Terminal Executor tests. * - * Tests executeRawSql with mocked database interactions. + * Tests executeRawSqlUnchecked (execution mechanics) and executeRawSql + * (mandatory policy gate) with mocked database interactions. * Verifies observer events, result structure, and error handling. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'bun:test'; import type { Kysely, RawBuilder } from 'kysely'; -import { executeRawSql } from '../../../src/core/sql-terminal/executor.js'; +import { executeRawSql, executeRawSqlUnchecked } from '../../../src/core/sql-terminal/executor.js'; import { observer } from '../../../src/core/observer.js'; /** @@ -26,7 +27,7 @@ interface ExecuteAfterEventData { describe('sql-terminal: executor', () => { - describe('executeRawSql', () => { + describe('executeRawSqlUnchecked / executeRawSql', () => { let mockDb: Kysely; let mockExecute: ReturnType; @@ -84,7 +85,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - await executeRawSql(mockDb, 'SELECT * FROM users', 'production'); + await executeRawSqlUnchecked(mockDb, 'SELECT * FROM users', 'production'); const beforeEvent = events.find((e) => e.event === 'before'); expect(beforeEvent).toBeDefined(); @@ -110,7 +111,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - await executeRawSql(mockDb, 'SELECT * FROM users', 'production'); + await executeRawSqlUnchecked(mockDb, 'SELECT * FROM users', 'production'); const afterEvent = events.find((e) => e.event === 'after'); expect(afterEvent).toBeDefined(); @@ -135,7 +136,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - await executeRawSql(mockDb, 'SELECT * FROM users', 'production'); + await executeRawSqlUnchecked(mockDb, 'SELECT * FROM users', 'production'); const afterEvent = events.find((e) => e.event === 'after'); expect(afterEvent).toBeDefined(); @@ -164,7 +165,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( mockDb, 'SELECT id, name, email FROM users', 'test', @@ -192,7 +193,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( mockDb, 'INSERT INTO users (name) VALUES (\'Alice\'), (\'Bob\'), (\'Charlie\')', 'test', @@ -218,7 +219,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( mockDb, 'UPDATE users SET active = true WHERE id > 10', 'test', @@ -242,7 +243,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( mockDb, 'DELETE FROM users WHERE id < 5', 'test', @@ -264,7 +265,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( mockDb, 'SELECT * FORM users', 'test', @@ -287,7 +288,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - const result = await executeRawSql(mockDb, 'SELECT 1', 'test'); + const result = await executeRawSqlUnchecked(mockDb, 'SELECT 1', 'test'); expect(result.success).toBe(false); expect(result.errorMessage).toBe('String error'); @@ -307,7 +308,7 @@ describe('sql-terminal: executor', () => { } as unknown as RawBuilder); const query = 'SELECT * FROM users WHERE id = 42'; - await executeRawSql(mockDb, query, 'test'); + await executeRawSqlUnchecked(mockDb, query, 'test'); expect(rawSpy).toHaveBeenCalledWith(query); expect(mockExecute).toHaveBeenCalledWith(mockDb); @@ -326,7 +327,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( mockDb, 'SELECT * FROM users WHERE id = -1', 'test', @@ -359,7 +360,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - const result = await executeRawSql(mockDb, 'SELECT 1', 'test'); + const result = await executeRawSqlUnchecked(mockDb, 'SELECT 1', 'test'); expect(result.success).toBe(true); // setTimeout(15) may fire slightly early - allow 10ms minimum @@ -387,7 +388,7 @@ describe('sql-terminal: executor', () => { execute: mockExecute, } as unknown as RawBuilder); - const result = await executeRawSql(mockDb, 'SELECT * FROM complex_table', 'test'); + const result = await executeRawSqlUnchecked(mockDb, 'SELECT * FROM complex_table', 'test'); expect(result.success).toBe(true); expect(result.columns).toEqual(['id', 'metadata', 'createdAt', 'count', 'isActive']); @@ -397,6 +398,83 @@ describe('sql-terminal: executor', () => { }); + describe('policy gate', () => { + + const VIEWER_GATE = { + access: { user: 'admin' as const, mcp: 'viewer' as const }, + channel: 'mcp' as const, + dialect: 'postgres' as const, + }; + const ADMIN_GATE = { + access: { user: 'admin' as const, mcp: 'admin' as const }, + channel: 'user' as const, + dialect: 'postgres' as const, + }; + + it('should execute a SELECT for a viewer role (sql:read allows)', async () => { + + mockExecute.mockResolvedValue({ rows: [{ id: 1 }], numAffectedRows: undefined }); + + const { sql } = await import('kysely'); + vi.spyOn(sql, 'raw').mockReturnValue({ + execute: mockExecute, + } as unknown as RawBuilder); + + const result = await executeRawSql(mockDb, 'SELECT * FROM users', 'prod', VIEWER_GATE); + + expect(result.success).toBe(true); + + }); + + it('should deny an INSERT for a viewer role without touching the database', async () => { + + const { sql } = await import('kysely'); + const rawSpy = vi.spyOn(sql, 'raw'); + + await expect( + executeRawSql(mockDb, 'INSERT INTO users VALUES (1)', 'prod', VIEWER_GATE), + ).rejects.toThrow(/sql:write/); + + expect(rawSpy).not.toHaveBeenCalled(); + + }); + + it('should allow an INSERT for an admin role', async () => { + + mockExecute.mockResolvedValue({ rows: [], numAffectedRows: BigInt(1) }); + + const { sql } = await import('kysely'); + vi.spyOn(sql, 'raw').mockReturnValue({ + execute: mockExecute, + } as unknown as RawBuilder); + + const result = await executeRawSql(mockDb, 'INSERT INTO users VALUES (1)', 'prod', ADMIN_GATE); + + expect(result.success).toBe(true); + + }); + + it('requires a gate — the public symbol cannot execute raw SQL ungated', async () => { + + mockExecute.mockResolvedValue({ rows: [], numAffectedRows: BigInt(1) }); + + const { sql } = await import('kysely'); + const rawSpy = vi.spyOn(sql, 'raw').mockReturnValue({ + execute: mockExecute, + } as unknown as RawBuilder); + + // @ts-expect-error `gate` is mandatory on executeRawSql — this line + // only compiles because the error is suppressed, proving the type + // system rejects an ungated call. Ungated execution mechanics are + // executeRawSqlUnchecked's job, tested above. + await expect(executeRawSql(mockDb, 'INSERT INTO users VALUES (1)', 'prod')).rejects.toThrow(); + + expect(rawSpy).not.toHaveBeenCalled(); + + }); + + }); + }); }); diff --git a/tests/core/state/manager.test.ts b/tests/core/state/manager.test.ts index 63b644d0..c9643085 100644 --- a/tests/core/state/manager.test.ts +++ b/tests/core/state/manager.test.ts @@ -5,12 +5,16 @@ * polluting the project directory. */ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtempSync, rmSync, existsSync } from 'fs'; -import { join } from 'path'; -import { StateManager, resetStateManager } from '../../../src/core/state/index.js'; +import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { StateManager, resetStateManager, getPackageVersion } from '../../../src/core/state/index.js'; import type { Config } from '../../../src/core/config/types.js'; import type { KnownUser } from '../../../src/core/identity/types.js'; import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { encrypt, decrypt } from '../../../src/core/state/encryption/index.js'; +import type { EncryptedPayload } from '../../../src/core/state/types.js'; +import { guarded } from '../../../src/core/policy/index.js'; +import { CURRENT_VERSIONS } from '../../../src/core/version/types.js'; /** * Create a valid test config. @@ -21,7 +25,7 @@ function createTestConfig(name: string, overrides: Partial = {}): Config name, type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect: 'sqlite', database: ':memory:', @@ -112,6 +116,196 @@ describe('state: manager', () => { }); + // ───────────────────────────────────────────────────────────── + // Migration (legacy `protected` -> `access`, on real load()) + // ───────────────────────────────────────────────────────────── + + describe('load: legacy protected -> access migration', () => { + + /** + * Writes a state.enc file shaped like state predating per-config + * access roles: no `schemaVersion` field (that field didn't exist + * yet) and configs carrying the legacy `protected` boolean instead + * of `access`. + */ + function writeLegacyState( + statePath: string, + privateKey: string, + configs: Record, + ): void { + + const legacyState = { + version: '1.0.0', + knownUsers: {}, + activeConfig: null, + configs, + secrets: {}, + globalSecrets: {}, + }; + + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync( + statePath, + JSON.stringify(encrypt(JSON.stringify(legacyState), privateKey), null, 2), + ); + + } + + it('should migrate a legacy protected:true config to guarded access', async () => { + + const statePath = state.getStatePath(); + writeLegacyState(statePath, testPrivateKey, { + prod: { + name: 'prod', + type: 'local', + isTest: false, + protected: true, + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }); + + await state.load(); + + expect(state.getConfig('prod')?.access).toEqual({ user: 'operator', mcp: 'viewer' }); + + const raw = readFileSync(statePath, 'utf8'); + const decrypted = JSON.parse( + decrypt(JSON.parse(raw) as EncryptedPayload, testPrivateKey), + ) as { schemaVersion: number; configs: Record> }; + + expect(decrypted.schemaVersion).toBe(CURRENT_VERSIONS.state); + expect(decrypted.configs['prod']).not.toHaveProperty('protected'); + + }); + + it('should migrate a legacy protected:false config to open access', async () => { + + const statePath = state.getStatePath(); + writeLegacyState(statePath, testPrivateKey, { + dev: { + name: 'dev', + type: 'local', + isTest: false, + protected: false, + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }); + + await state.load(); + + expect(state.getConfig('dev')?.access).toEqual({ user: 'admin', mcp: 'admin' }); + + const raw = readFileSync(statePath, 'utf8'); + const decrypted = JSON.parse( + decrypt(JSON.parse(raw) as EncryptedPayload, testPrivateKey), + ) as { schemaVersion: number; configs: Record> }; + + expect(decrypted.schemaVersion).toBe(CURRENT_VERSIONS.state); + expect(decrypted.configs['dev']).not.toHaveProperty('protected'); + + }); + + it('should persist a backfilled access to disk even when no version migration ran', async () => { + + const statePath = state.getStatePath(); + const currentVersion = getPackageVersion(); + + const currentState = { + version: currentVersion, + schemaVersion: CURRENT_VERSIONS.state, + identity: {}, + knownUsers: {}, + activeConfig: null, + configs: { + corrupt: { + name: 'corrupt', + type: 'local', + isTest: false, + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }, + secrets: {}, + globalSecrets: {}, + }; + + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync( + statePath, + JSON.stringify(encrypt(JSON.stringify(currentState), testPrivateKey), null, 2), + ); + + await state.load(); + + expect(state.getConfig('corrupt')?.access).toEqual({ user: 'admin', mcp: 'admin' }); + + const raw = readFileSync(statePath, 'utf8'); + const decrypted = JSON.parse( + decrypt(JSON.parse(raw) as EncryptedPayload, testPrivateKey), + ) as { configs: Record> }; + + expect(decrypted.configs['corrupt']?.['access']).toEqual({ + user: 'admin', + mcp: 'admin', + }); + + }); + + it('should backfill a legacy protected:true config at current schemaVersion to guarded access', async () => { + + // Reproduces a config that reached the current schemaVersion carrying + // the legacy `protected` boolean without `access` — e.g. saved + // directly via setConfig, bypassing ConfigSchema (the CLI `config + // import` bug this guards against). The schema-version migration + // is a no-op at current version, so this raw shape survives + // unchanged into the backfill loop; it must resolve per the + // documented fail-closed mapping (protected:true -> operator/viewer), + // not the admin/admin open-access fallback. + const statePath = state.getStatePath(); + const currentVersion = getPackageVersion(); + + const currentState = { + version: currentVersion, + schemaVersion: CURRENT_VERSIONS.state, + identity: {}, + knownUsers: {}, + activeConfig: null, + configs: { + guarded: { + name: 'guarded', + type: 'local', + isTest: false, + protected: true, + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }, + secrets: {}, + globalSecrets: {}, + }; + + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync( + statePath, + JSON.stringify(encrypt(JSON.stringify(currentState), testPrivateKey), null, 2), + ); + + await state.load(); + + expect(state.getConfig('guarded')?.access).toEqual({ user: 'operator', mcp: 'viewer' }); + + const raw = readFileSync(statePath, 'utf8'); + const decrypted = JSON.parse( + decrypt(JSON.parse(raw) as EncryptedPayload, testPrivateKey), + ) as { configs: Record> }; + + expect(decrypted.configs['guarded']?.['access']).toEqual({ + user: 'operator', + mcp: 'viewer', + }); + + }); + + }); + // ───────────────────────────────────────────────────────────── // Config Operations // ───────────────────────────────────────────────────────────── @@ -144,10 +338,10 @@ describe('state: manager', () => { it('should update existing config', async () => { await state.setConfig('dev', createTestConfig('dev')); - await state.setConfig('dev', createTestConfig('dev', { protected: true })); + await state.setConfig('dev', createTestConfig('dev', { access: { user: 'operator', mcp: 'viewer' } })); const config = state.getConfig('dev'); - expect(config?.protected).toBe(true); + expect(guarded(config!)).toBe(true); }); @@ -166,12 +360,48 @@ describe('state: manager', () => { const initialCount = state.listConfigs().length; await state.setConfig('dev', createTestConfig('dev')); - await state.setConfig('prod', createTestConfig('prod', { protected: true })); + await state.setConfig('prod', createTestConfig('prod', { access: { user: 'operator', mcp: 'viewer' } })); const list = state.listConfigs(); expect(list).toHaveLength(initialCount + 2); expect(list.find((c) => c.name === 'dev')).toBeDefined(); - expect(list.find((c) => c.name === 'prod')?.protected).toBe(true); + expect(guarded(list.find((c) => c.name === 'prod')!)).toBe(true); + + }); + + it('should include access in config summaries', async () => { + + await state.setConfig('dev', createTestConfig('dev')); + await state.setConfig('prod', createTestConfig('prod', { access: { user: 'operator', mcp: 'viewer' } })); + + const list = state.listConfigs(); + + expect(list.find((c) => c.name === 'dev')?.access).toEqual({ + user: 'admin', + mcp: 'admin', + }); + expect(list.find((c) => c.name === 'prod')?.access).toEqual({ + user: 'operator', + mcp: 'viewer', + }); + + }); + + it('should not persist a stored protected field on disk', async () => { + + await state.setConfig('dev', createTestConfig('dev', { access: { user: 'operator', mcp: 'viewer' } })); + + const raw = readFileSync(state.getStatePath(), 'utf8'); + const payload = JSON.parse(raw) as EncryptedPayload; + const decrypted = JSON.parse(decrypt(payload, testPrivateKey)) as { + configs: Record>; + }; + + expect(decrypted.configs['dev']).not.toHaveProperty('protected'); + expect(decrypted.configs['dev']?.['access']).toEqual({ + user: 'operator', + mcp: 'viewer', + }); }); diff --git a/tests/core/transfer/policy-gate.test.ts b/tests/core/transfer/policy-gate.test.ts new file mode 100644 index 00000000..59211c48 --- /dev/null +++ b/tests/core/transfer/policy-gate.test.ts @@ -0,0 +1,60 @@ +/** + * transferData policy-gate unit tests. + * + * Proves transferData gates on the DESTINATION config's access role (not + * the source) before any connection is attempted — no container required + * since a deny returns before withDualConnection ever opens a socket. + */ +import { describe, it, expect } from 'bun:test'; + +import { transferData } from '../../../src/core/transfer/index.js'; +import { makeTestConfig } from '../../utils/db.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const VIEWER: ConfigAccess = { user: 'viewer', mcp: false }; +const ADMIN: ConfigAccess = { user: 'admin', mcp: 'admin' }; + +describe('transfer: policy gate', () => { + + it('should deny transferData when the destination role denies db:reset', async () => { + + const source = { ...makeTestConfig('source', { dialect: 'postgres', database: 'x' }), access: ADMIN }; + const dest = { ...makeTestConfig('dest', { dialect: 'postgres', database: 'y' }), access: VIEWER }; + + const [result, err] = await transferData(source, dest, { channel: 'user' }); + + expect(result).toBeNull(); + expect(err?.message).toMatch(/db:reset/); + expect(err?.message).toContain('dest'); + + }); + + it('should gate on the destination, not the source', async () => { + + // Source is viewer (would deny if the gate looked at it), dest is + // admin — the gate must pass, so the failure (if any) surfaces from + // dialect validation, not the policy layer. + const source = { ...makeTestConfig('source', { dialect: 'postgres', database: 'x' }), access: VIEWER }; + const dest = { ...makeTestConfig('dest', { dialect: 'sqlite', database: 'y' }), access: ADMIN }; + + const [result, err] = await transferData(source, dest, { channel: 'user' }); + + expect(result).toBeNull(); + expect(err?.message).not.toMatch(/db:reset.*not allowed/); + expect(err?.message).toMatch(/not supported/i); + + }); + + it('should default channel to user when omitted', async () => { + + const source = { ...makeTestConfig('source', { dialect: 'postgres', database: 'x' }), access: ADMIN }; + const dest = { ...makeTestConfig('dest', { dialect: 'postgres', database: 'y' }), access: VIEWER }; + + const [result, err] = await transferData(source, dest, {}); + + expect(result).toBeNull(); + expect(err?.message).toMatch(/db:reset/); + + }); + +}); diff --git a/tests/core/version/state.test.ts b/tests/core/version/state.test.ts index a83a79da..a28fe7e5 100644 --- a/tests/core/version/state.test.ts +++ b/tests/core/version/state.test.ts @@ -166,7 +166,9 @@ describe('version: state', () => { expect(migrated['identity']).toEqual({ name: 'test' }); expect(migrated['activeConfig']).toBe('dev'); - expect(migrated['configs']).toEqual({ dev: {} }); + // v2 backfills access roles onto every config (see the "v2: + // per-config access roles" tests below for the mapping itself). + expect(migrated['configs']).toEqual({ dev: { access: { user: 'admin', mcp: 'admin' } } }); }); @@ -254,6 +256,145 @@ describe('version: state', () => { }); + describe('v2: per-config access roles', () => { + + it('should map protected: true to guarded access and drop protected', () => { + + const state = { + schemaVersion: 1, + configs: { + prod: { + name: 'prod', + protected: true, + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }, + }; + + const migrated = migrateState(state); + + expect(migrated['configs']).toEqual({ + prod: { + name: 'prod', + connection: { dialect: 'sqlite', database: ':memory:' }, + access: { user: 'operator', mcp: 'viewer' }, + }, + }); + + }); + + it('should map protected: false to open access and drop protected', () => { + + const state = { + schemaVersion: 1, + configs: { + dev: { + name: 'dev', + protected: false, + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }, + }; + + const migrated = migrateState(state); + + expect(migrated['configs']).toEqual({ + dev: { + name: 'dev', + connection: { dialect: 'sqlite', database: ':memory:' }, + access: { user: 'admin', mcp: 'admin' }, + }, + }); + + }); + + it('should map absent protected to open access', () => { + + const state = { + schemaVersion: 1, + configs: { + dev: { + name: 'dev', + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }, + }; + + const migrated = migrateState(state); + + expect(migrated['configs']).toEqual({ + dev: { + name: 'dev', + connection: { dialect: 'sqlite', database: ':memory:' }, + access: { user: 'admin', mcp: 'admin' }, + }, + }); + + }); + + it('should leave an already-migrated access untouched', () => { + + const state = { + schemaVersion: 1, + configs: { + staging: { + name: 'staging', + access: { user: 'viewer', mcp: false }, + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }, + }; + + const migrated = migrateState(state); + + expect(migrated['configs']).toEqual({ + staging: { + name: 'staging', + connection: { dialect: 'sqlite', database: ':memory:' }, + access: { user: 'viewer', mcp: false }, + }, + }); + + }); + + it('should keep access when both access and legacy protected are present (access wins)', () => { + + const state = { + schemaVersion: 1, + configs: { + prod: { + name: 'prod', + access: { user: 'admin', mcp: 'admin' }, + protected: true, + connection: { dialect: 'sqlite', database: ':memory:' }, + }, + }, + }; + + const migrated = migrateState(state); + + expect(migrated['configs']).toEqual({ + prod: { + name: 'prod', + connection: { dialect: 'sqlite', database: ':memory:' }, + access: { user: 'admin', mcp: 'admin' }, + }, + }); + + }); + + it('should bump schemaVersion to current', () => { + + const state = { schemaVersion: 1, configs: {} }; + + const migrated = migrateState(state); + + expect(migrated['schemaVersion']).toBe(CURRENT_VERSIONS.state); + + }); + + }); + }); describe('createEmptyVersionedState', () => { diff --git a/tests/core/version/types.test.ts b/tests/core/version/types.test.ts index 2e50215f..3fa65031 100644 --- a/tests/core/version/types.test.ts +++ b/tests/core/version/types.test.ts @@ -21,7 +21,7 @@ describe('version: types', () => { it('should have state version', () => { - expect(CURRENT_VERSIONS.state).toBe(1); + expect(CURRENT_VERSIONS.state).toBe(2); }); diff --git a/tests/integration/runner/mssql-batches.test.ts b/tests/integration/runner/mssql-batches.test.ts index 7121bc0b..0ac7b83c 100644 --- a/tests/integration/runner/mssql-batches.test.ts +++ b/tests/integration/runner/mssql-batches.test.ts @@ -188,6 +188,8 @@ describe('integration: mssql runner GO batch splitter', () => { configName: 'integration-test', identity: { name: 'test', email: 'test@example.com', source: 'config' }, projectRoot: tempDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', dialect: 'mssql', }; @@ -239,6 +241,8 @@ describe('integration: mssql runner GO batch splitter', () => { configName: 'integration-test', identity: { name: 'test', email: 'test@example.com', source: 'config' }, projectRoot: tempDir, + access: { user: 'admin', mcp: 'admin' }, + channel: 'user', dialect: 'mssql', }; diff --git a/tests/integration/sdk/db-reset.test.ts b/tests/integration/sdk/db-reset.test.ts index 7909d5c3..db6c7b82 100644 --- a/tests/integration/sdk/db-reset.test.ts +++ b/tests/integration/sdk/db-reset.test.ts @@ -48,7 +48,7 @@ describe('integration: sdk DbNamespace reset vs preserveTables', () => { name: 'test', type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect: 'postgres', database: 'noorm_test' }, }, settings: { teardown: { preserveTables: ['ev_keep'] } }, diff --git a/tests/integration/sql-terminal/mssql.test.ts b/tests/integration/sql-terminal/mssql.test.ts index af82ee52..9ceef09b 100644 --- a/tests/integration/sql-terminal/mssql.test.ts +++ b/tests/integration/sql-terminal/mssql.test.ts @@ -1,13 +1,13 @@ /** * Integration tests for MSSQL SQL terminal operations. * - * Tests executeRawSql against a real MSSQL instance. + * Tests executeRawSqlUnchecked against a real MSSQL instance. * Requires docker-compose.test.yml to be running. */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import type { Kysely } from 'kysely'; -import { executeRawSql } from '../../../src/core/sql-terminal/executor.js'; +import { executeRawSqlUnchecked } from '../../../src/core/sql-terminal/executor.js'; import { createTestConnection, deployTestSchema, @@ -61,7 +61,7 @@ describe('integration: mssql sql-terminal', () => { it('should return correct columns and rows for simple SELECT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT id, email, username FROM users', 'test', @@ -78,7 +78,7 @@ describe('integration: mssql sql-terminal', () => { it('should return all columns when using SELECT *', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM users', 'test', @@ -97,7 +97,7 @@ describe('integration: mssql sql-terminal', () => { it('should return correct data for filtered queries', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE email = 'user1@test.com'", 'test', @@ -114,7 +114,7 @@ describe('integration: mssql sql-terminal', () => { it('should return empty result for no matches', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE email = 'nonexistent@test.com'", 'test', @@ -128,7 +128,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle aggregate functions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT COUNT(*) as user_count FROM users', 'test', @@ -145,7 +145,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle JOINs', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT u.username, tl.title FROM users u @@ -162,7 +162,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle ORDER BY', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT username FROM users ORDER BY username ASC', 'test', @@ -178,7 +178,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle LIMIT (TOP in MSSQL)', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT TOP 2 * FROM users', 'test', @@ -191,7 +191,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle subqueries', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT * FROM users WHERE id IN (SELECT user_id FROM todo_lists)`, @@ -210,7 +210,7 @@ describe('integration: mssql sql-terminal', () => { it('should return rowsAffected for INSERT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash, display_name) VALUES (NEWID(), 'newuser@test.com', 'newuser', 'hash123', 'New User')`, @@ -224,7 +224,7 @@ describe('integration: mssql sql-terminal', () => { it('should return rowsAffected for multi-row INSERT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash, display_name) VALUES @@ -240,7 +240,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle INSERT with default values', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (email, username, password_hash) VALUES ('default@test.com', 'defaultuser', 'hash123')`, @@ -258,7 +258,7 @@ describe('integration: mssql sql-terminal', () => { it('should return rowsAffected for UPDATE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'Updated Name' WHERE username = 'user1'", 'test', @@ -271,7 +271,7 @@ describe('integration: mssql sql-terminal', () => { it('should return rowsAffected for multi-row UPDATE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'Updated' WHERE username IN ('user1', 'user2')", 'test', @@ -284,7 +284,7 @@ describe('integration: mssql sql-terminal', () => { it('should return 0 rowsAffected when no rows match', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'Updated' WHERE username = 'nonexistent'", 'test', @@ -301,7 +301,7 @@ describe('integration: mssql sql-terminal', () => { it('should return rowsAffected for DELETE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "DELETE FROM users WHERE username = 'user3'", 'test', @@ -315,7 +315,7 @@ describe('integration: mssql sql-terminal', () => { it('should return rowsAffected for multi-row DELETE', async () => { // First, insert extra users to delete - await executeRawSql( + await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash) VALUES (NEWID(), 'delete1@test.com', 'todelete1', 'hash'), @@ -323,7 +323,7 @@ describe('integration: mssql sql-terminal', () => { 'test', ); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "DELETE FROM users WHERE username LIKE 'todelete%'", 'test', @@ -336,7 +336,7 @@ describe('integration: mssql sql-terminal', () => { it('should return 0 rowsAffected when no rows match', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "DELETE FROM users WHERE username = 'nonexistent'", 'test', @@ -353,7 +353,7 @@ describe('integration: mssql sql-terminal', () => { it('should execute CREATE TABLE successfully', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `CREATE TABLE test_ddl_create ( id INT PRIMARY KEY, @@ -365,7 +365,7 @@ describe('integration: mssql sql-terminal', () => { expect(result.success).toBe(true); // Verify table was created - const verify = await executeRawSql( + const verify = await executeRawSqlUnchecked( db, `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'test_ddl_create'`, @@ -374,20 +374,20 @@ describe('integration: mssql sql-terminal', () => { expect(verify.rows).toHaveLength(1); // Cleanup - await executeRawSql(db, 'DROP TABLE test_ddl_create', 'test'); + await executeRawSqlUnchecked(db, 'DROP TABLE test_ddl_create', 'test'); }); it('should execute ALTER TABLE successfully', async () => { // Create test table - await executeRawSql( + await executeRawSqlUnchecked( db, 'CREATE TABLE test_ddl_alter (id INT PRIMARY KEY)', 'test', ); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'ALTER TABLE test_ddl_alter ADD new_column VARCHAR(50)', 'test', @@ -396,7 +396,7 @@ describe('integration: mssql sql-terminal', () => { expect(result.success).toBe(true); // Verify column was added - const verify = await executeRawSql( + const verify = await executeRawSqlUnchecked( db, `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'test_ddl_alter' AND COLUMN_NAME = 'new_column'`, @@ -405,20 +405,20 @@ describe('integration: mssql sql-terminal', () => { expect(verify.rows).toHaveLength(1); // Cleanup - await executeRawSql(db, 'DROP TABLE test_ddl_alter', 'test'); + await executeRawSqlUnchecked(db, 'DROP TABLE test_ddl_alter', 'test'); }); it('should execute DROP TABLE successfully', async () => { // Create test table - await executeRawSql( + await executeRawSqlUnchecked( db, 'CREATE TABLE test_ddl_drop (id INT PRIMARY KEY)', 'test', ); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'DROP TABLE test_ddl_drop', 'test', @@ -427,7 +427,7 @@ describe('integration: mssql sql-terminal', () => { expect(result.success).toBe(true); // Verify table was dropped - const verify = await executeRawSql( + const verify = await executeRawSqlUnchecked( db, `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'test_ddl_drop'`, @@ -439,7 +439,7 @@ describe('integration: mssql sql-terminal', () => { it('should execute CREATE INDEX successfully', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'CREATE INDEX idx_test_users_display ON users(display_name)', 'test', @@ -448,7 +448,7 @@ describe('integration: mssql sql-terminal', () => { expect(result.success).toBe(true); // Cleanup - await executeRawSql( + await executeRawSqlUnchecked( db, 'DROP INDEX idx_test_users_display ON users', 'test', @@ -462,7 +462,7 @@ describe('integration: mssql sql-terminal', () => { it('should return error for syntax errors', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELEC * FORM users', 'test', @@ -477,7 +477,7 @@ describe('integration: mssql sql-terminal', () => { it('should return error for non-existent table', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM nonexistent_table', 'test', @@ -491,7 +491,7 @@ describe('integration: mssql sql-terminal', () => { it('should return error for non-existent column', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT nonexistent_column FROM users', 'test', @@ -504,7 +504,7 @@ describe('integration: mssql sql-terminal', () => { it('should return error for constraint violation', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (email, username, password_hash) VALUES ('user1@test.com', 'user1', 'hash')`, @@ -519,7 +519,7 @@ describe('integration: mssql sql-terminal', () => { it('should return error for foreign key violation', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO todo_lists (id, user_id, title, position) VALUES (NEWID(), '99999999-9999-9999-9999-999999999999', 'Test', 0)`, @@ -534,7 +534,7 @@ describe('integration: mssql sql-terminal', () => { it('should return error for invalid data type', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET created_at = 'not-a-date' WHERE username = 'user1'", 'test', @@ -551,7 +551,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle queries with parameters using variables', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `DECLARE @email VARCHAR(255) = 'user1@test.com'; SELECT * FROM users WHERE email = @email`, @@ -565,7 +565,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle CASE expressions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT username, CASE WHEN display_name IS NOT NULL THEN display_name @@ -583,7 +583,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle CTEs (Common Table Expressions)', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `WITH UserCounts AS ( SELECT user_id, COUNT(*) as list_count @@ -604,7 +604,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle UNION queries', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT 'user' as type, username as name FROM users UNION ALL @@ -622,7 +622,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle date functions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT username, DATEDIFF(day, created_at, GETDATE()) as days_since_created FROM users`, @@ -636,7 +636,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle string functions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT UPPER(username) as upper_name, LEN(email) as email_length FROM users`, @@ -655,7 +655,7 @@ describe('integration: mssql sql-terminal', () => { it('should handle NULL comparisons', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM users WHERE deleted_at IS NULL', 'test', @@ -670,14 +670,14 @@ describe('integration: mssql sql-terminal', () => { it('should handle GROUP BY with HAVING', async () => { // First, add more todo lists for testing - await executeRawSql( + await executeRawSqlUnchecked( db, `INSERT INTO todo_lists (id, user_id, title, position) SELECT NEWID(), id, 'Extra List', 10 FROM users WHERE username = 'user1'`, 'test', ); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT user_id, COUNT(*) as list_count FROM todo_lists @@ -698,7 +698,7 @@ describe('integration: mssql sql-terminal', () => { it('should report accurate duration for fast queries', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT 1 as test', 'test', @@ -713,7 +713,7 @@ describe('integration: mssql sql-terminal', () => { it('should report accurate duration for slow queries', async () => { // WAITFOR DELAY in MSSQL - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "WAITFOR DELAY '00:00:00.1'; SELECT 1 as test", 'test', @@ -732,7 +732,7 @@ describe('integration: mssql sql-terminal', () => { it('should execute recursive CTE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `WITH numbers AS ( SELECT 1 AS n @@ -757,7 +757,7 @@ describe('integration: mssql sql-terminal', () => { it('should return estimated plan', async () => { // MSSQL uses SET SHOWPLAN_TEXT ON or sys.dm_exec_query_plan - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM users WHERE id = NEWID() OPTION (RECOMPILE)', 'test', diff --git a/tests/integration/sql-terminal/mysql.test.ts b/tests/integration/sql-terminal/mysql.test.ts index 42aee3f5..31306977 100644 --- a/tests/integration/sql-terminal/mysql.test.ts +++ b/tests/integration/sql-terminal/mysql.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import type { Kysely } from 'kysely'; -import { executeRawSql } from '../../../src/core/sql-terminal/executor.js'; +import { executeRawSqlUnchecked } from '../../../src/core/sql-terminal/executor.js'; import { createTestConnection, deployTestSchema, @@ -60,7 +60,7 @@ describe('integration: mysql sql-terminal', () => { it('should return correct columns for simple SELECT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT id, email, username FROM users', configName, @@ -73,7 +73,7 @@ describe('integration: mysql sql-terminal', () => { it('should return correct row count', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM users', configName, @@ -86,7 +86,7 @@ describe('integration: mysql sql-terminal', () => { it('should return correct row data', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT email, username FROM users ORDER BY username', configName, @@ -105,7 +105,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle SELECT with WHERE clause', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE email = 'user1@test.com'", configName, @@ -119,7 +119,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle SELECT with JOIN', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT u.username, tl.title FROM users u @@ -136,7 +136,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle SELECT with LIMIT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM users LIMIT 2', configName, @@ -149,7 +149,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle SELECT with aggregate functions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT COUNT(*) as total, MAX(priority) as max_priority FROM todo_items', configName, @@ -164,7 +164,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle SELECT from views', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM v_active_users', configName, @@ -177,7 +177,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle empty result set', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE email = 'nonexistent@test.com'", configName, @@ -191,7 +191,7 @@ describe('integration: mysql sql-terminal', () => { it('should include duration in result', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM users', configName, @@ -209,7 +209,7 @@ describe('integration: mysql sql-terminal', () => { it('should return rowsAffected for single INSERT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash, display_name) VALUES (UUID(), 'new@test.com', 'newuser', 'hash123', 'New User')`, @@ -223,7 +223,7 @@ describe('integration: mysql sql-terminal', () => { it('should return rowsAffected for multiple INSERT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash, display_name) VALUES (UUID(), 'batch1@test.com', 'batch1', 'hash1', 'Batch 1'), @@ -238,7 +238,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle INSERT with all columns', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users ( id, email, username, password_hash, display_name, avatar_url @@ -259,7 +259,7 @@ describe('integration: mysql sql-terminal', () => { it('should return rowsAffected for UPDATE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'Updated Name' WHERE email = 'user1@test.com'", configName, @@ -272,7 +272,7 @@ describe('integration: mysql sql-terminal', () => { it('should return rowsAffected for UPDATE affecting multiple rows', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'Bulk Update'", configName, @@ -285,7 +285,7 @@ describe('integration: mysql sql-terminal', () => { it('should return 0 rowsAffected when no rows match', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'No Match' WHERE email = 'nonexistent@test.com'", configName, @@ -302,7 +302,7 @@ describe('integration: mysql sql-terminal', () => { it('should return rowsAffected for DELETE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "DELETE FROM todo_items WHERE title = 'Buy groceries'", configName, @@ -315,7 +315,7 @@ describe('integration: mysql sql-terminal', () => { it('should return rowsAffected for DELETE affecting multiple rows', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'DELETE FROM todo_items', configName, @@ -328,7 +328,7 @@ describe('integration: mysql sql-terminal', () => { it('should return 0 rowsAffected when no rows match', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "DELETE FROM users WHERE email = 'nonexistent@test.com'", configName, @@ -345,7 +345,7 @@ describe('integration: mysql sql-terminal', () => { it('should execute CREATE TABLE successfully', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `CREATE TABLE test_ddl ( id INT PRIMARY KEY AUTO_INCREMENT, @@ -357,20 +357,20 @@ describe('integration: mysql sql-terminal', () => { expect(result.success).toBe(true); // Cleanup - await executeRawSql(db, 'DROP TABLE test_ddl', configName); + await executeRawSqlUnchecked(db, 'DROP TABLE test_ddl', configName); }); it('should execute DROP TABLE successfully', async () => { // Create table first - await executeRawSql( + await executeRawSqlUnchecked( db, 'CREATE TABLE test_drop (id INT PRIMARY KEY)', configName, ); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'DROP TABLE test_drop', configName, @@ -383,13 +383,13 @@ describe('integration: mysql sql-terminal', () => { it('should execute ALTER TABLE successfully', async () => { // Create table first - await executeRawSql( + await executeRawSqlUnchecked( db, 'CREATE TABLE test_alter (id INT PRIMARY KEY)', configName, ); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'ALTER TABLE test_alter ADD COLUMN name VARCHAR(100)', configName, @@ -398,20 +398,20 @@ describe('integration: mysql sql-terminal', () => { expect(result.success).toBe(true); // Cleanup - await executeRawSql(db, 'DROP TABLE test_alter', configName); + await executeRawSqlUnchecked(db, 'DROP TABLE test_alter', configName); }); it('should execute CREATE INDEX successfully', async () => { // Create table first - await executeRawSql( + await executeRawSqlUnchecked( db, 'CREATE TABLE test_index (id INT PRIMARY KEY, name VARCHAR(100))', configName, ); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'CREATE INDEX idx_test_name ON test_index (name)', configName, @@ -420,7 +420,7 @@ describe('integration: mysql sql-terminal', () => { expect(result.success).toBe(true); // Cleanup - await executeRawSql(db, 'DROP TABLE test_index', configName); + await executeRawSqlUnchecked(db, 'DROP TABLE test_index', configName); }); @@ -430,7 +430,7 @@ describe('integration: mysql sql-terminal', () => { it('should return error for syntax error', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELEC * FROM users', configName, @@ -444,7 +444,7 @@ describe('integration: mysql sql-terminal', () => { it('should return error for non-existent table', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM nonexistent_table', configName, @@ -458,7 +458,7 @@ describe('integration: mysql sql-terminal', () => { it('should return error for non-existent column', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT nonexistent_column FROM users', configName, @@ -471,7 +471,7 @@ describe('integration: mysql sql-terminal', () => { it('should return error for constraint violation', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash) VALUES (UUID(), 'user1@test.com', 'duplicate', 'hash')`, @@ -486,7 +486,7 @@ describe('integration: mysql sql-terminal', () => { it('should return error for foreign key violation', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO todo_lists (id, user_id, title) VALUES (UUID(), 'nonexistent-user-id-123', 'Test List')`, @@ -500,7 +500,7 @@ describe('integration: mysql sql-terminal', () => { it('should include duration even on error', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'INVALID SQL QUERY', configName, @@ -518,7 +518,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle SHOW statements', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SHOW TABLES', configName, @@ -531,7 +531,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle DESCRIBE statement', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'DESCRIBE users', configName, @@ -544,7 +544,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle CALL procedure', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "CALL get_user_by_email('user1@test.com')", configName, @@ -556,7 +556,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle MySQL date functions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT NOW() as `current_time`, CURDATE() as `current_date`', configName, @@ -570,7 +570,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle MySQL string functions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT CONCAT(username, ' <', email, '>') as formatted FROM users LIMIT 1", configName, @@ -584,7 +584,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle backtick-quoted identifiers', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT `id`, `email` FROM `users` LIMIT 1', configName, @@ -597,7 +597,7 @@ describe('integration: mysql sql-terminal', () => { it('should handle IF expression', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT IF(is_completed, \'Done\', \'Pending\') as status FROM todo_items', configName, @@ -616,7 +616,7 @@ describe('integration: mysql sql-terminal', () => { it('should execute recursive CTE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `WITH RECURSIVE numbers AS ( SELECT 1 AS n @@ -640,7 +640,7 @@ describe('integration: mysql sql-terminal', () => { it('should return execution plan', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'EXPLAIN SELECT * FROM users', configName, diff --git a/tests/integration/sql-terminal/postgres.test.ts b/tests/integration/sql-terminal/postgres.test.ts index d5224c6c..c9338200 100644 --- a/tests/integration/sql-terminal/postgres.test.ts +++ b/tests/integration/sql-terminal/postgres.test.ts @@ -1,13 +1,13 @@ /** * Integration tests for PostgreSQL sql-terminal operations. * - * Tests executeRawSql against a real PostgreSQL database. + * Tests executeRawSqlUnchecked against a real PostgreSQL database. * Requires docker-compose.test.yml containers to be running. */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; import type { Kysely } from 'kysely'; -import { executeRawSql } from '../../../src/core/sql-terminal/executor.js'; +import { executeRawSqlUnchecked } from '../../../src/core/sql-terminal/executor.js'; import { observer } from '../../../src/core/observer.js'; import { createTestConnection, @@ -60,7 +60,7 @@ describe('integration: postgres sql-terminal', () => { it('should return columns and rows for simple SELECT', async () => { - const result = await executeRawSql(db, 'SELECT id, email, username FROM users', 'test'); + const result = await executeRawSqlUnchecked(db, 'SELECT id, email, username FROM users', 'test'); expect(result.success).toBe(true); expect(result.columns).toEqual(['id', 'email', 'username']); @@ -76,7 +76,7 @@ describe('integration: postgres sql-terminal', () => { it('should return correct column count for SELECT *', async () => { - const result = await executeRawSql(db, 'SELECT * FROM users', 'test'); + const result = await executeRawSqlUnchecked(db, 'SELECT * FROM users', 'test'); expect(result.success).toBe(true); expect(result.columns).toHaveLength(9); // All columns from users table @@ -88,7 +88,7 @@ describe('integration: postgres sql-terminal', () => { it('should return empty array for SELECT with no results', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE email = 'nonexistent@test.com'", 'test', @@ -102,7 +102,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle SELECT with WHERE clause', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE email = 'user1@test.com'", 'test', @@ -117,7 +117,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle SELECT with JOIN', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT u.username, tl.title FROM users u @@ -133,7 +133,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle SELECT with aggregate functions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT COUNT(*) as user_count FROM users', 'test', @@ -148,7 +148,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle SELECT with GROUP BY', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT u.username, COUNT(tl.id) as list_count FROM users u @@ -168,7 +168,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle SELECT with ORDER BY and LIMIT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT username FROM users ORDER BY username LIMIT 2', 'test', @@ -183,7 +183,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle SELECT from views', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM v_active_users', 'test', @@ -198,7 +198,7 @@ describe('integration: postgres sql-terminal', () => { it('should measure execution duration', async () => { - const result = await executeRawSql(db, 'SELECT * FROM users', 'test'); + const result = await executeRawSqlUnchecked(db, 'SELECT * FROM users', 'test'); expect(result.success).toBe(true); expect(result.durationMs).toBeGreaterThanOrEqual(0); @@ -212,7 +212,7 @@ describe('integration: postgres sql-terminal', () => { it('should return rowsAffected for INSERT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (email, username, password_hash, display_name) VALUES ('new@test.com', 'newuser', 'hash123', 'New User')`, @@ -223,7 +223,7 @@ describe('integration: postgres sql-terminal', () => { expect(result.rowsAffected).toBe(1); // Verify insert worked - const verify = await executeRawSql( + const verify = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE email = 'new@test.com'", 'test', @@ -234,7 +234,7 @@ describe('integration: postgres sql-terminal', () => { it('should return rowsAffected for multi-row INSERT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (email, username, password_hash, display_name) VALUES @@ -251,7 +251,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle INSERT with RETURNING clause', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (email, username, password_hash, display_name) VALUES ('returning@test.com', 'returnuser', 'hash123', 'Return User') @@ -273,7 +273,7 @@ describe('integration: postgres sql-terminal', () => { it('should return rowsAffected for UPDATE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'Updated Name' WHERE username = 'user1'", 'test', @@ -283,7 +283,7 @@ describe('integration: postgres sql-terminal', () => { expect(result.rowsAffected).toBe(1); // Verify update worked - const verify = await executeRawSql( + const verify = await executeRawSqlUnchecked( db, "SELECT display_name FROM users WHERE username = 'user1'", 'test', @@ -294,7 +294,7 @@ describe('integration: postgres sql-terminal', () => { it('should return rowsAffected for multi-row UPDATE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET avatar_url = 'https://example.com/avatar.png'", 'test', @@ -307,7 +307,7 @@ describe('integration: postgres sql-terminal', () => { it('should return 0 rowsAffected for UPDATE with no matches', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'Ghost' WHERE username = 'nonexistent'", 'test', @@ -320,7 +320,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle UPDATE with RETURNING clause', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `UPDATE users SET display_name = 'Returned Update' @@ -342,7 +342,7 @@ describe('integration: postgres sql-terminal', () => { it('should return rowsAffected for DELETE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "DELETE FROM todo_items WHERE title = 'Complete report'", 'test', @@ -355,7 +355,7 @@ describe('integration: postgres sql-terminal', () => { it('should return rowsAffected for multi-row DELETE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'DELETE FROM todo_items', 'test', @@ -368,7 +368,7 @@ describe('integration: postgres sql-terminal', () => { it('should return 0 rowsAffected for DELETE with no matches', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "DELETE FROM users WHERE email = 'nonexistent@test.com'", 'test', @@ -381,7 +381,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle DELETE with RETURNING clause', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `DELETE FROM todo_items WHERE title = 'Complete report' @@ -402,7 +402,7 @@ describe('integration: postgres sql-terminal', () => { it('should execute CREATE TABLE successfully', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `CREATE TABLE test_ddl_table ( id SERIAL PRIMARY KEY, @@ -414,7 +414,7 @@ describe('integration: postgres sql-terminal', () => { expect(result.success).toBe(true); // Verify table exists - const verify = await executeRawSql( + const verify = await executeRawSqlUnchecked( db, `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'test_ddl_table'`, @@ -423,20 +423,20 @@ describe('integration: postgres sql-terminal', () => { expect(verify.rows).toHaveLength(1); // Clean up - await executeRawSql(db, 'DROP TABLE test_ddl_table', 'test'); + await executeRawSqlUnchecked(db, 'DROP TABLE test_ddl_table', 'test'); }); it('should execute ALTER TABLE successfully', async () => { // Create temp table - await executeRawSql( + await executeRawSqlUnchecked( db, 'CREATE TABLE test_alter_table (id SERIAL PRIMARY KEY)', 'test', ); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'ALTER TABLE test_alter_table ADD COLUMN description TEXT', 'test', @@ -445,7 +445,7 @@ describe('integration: postgres sql-terminal', () => { expect(result.success).toBe(true); // Verify column exists - const verify = await executeRawSql( + const verify = await executeRawSqlUnchecked( db, `SELECT column_name FROM information_schema.columns WHERE table_name = 'test_alter_table' AND column_name = 'description'`, @@ -454,20 +454,20 @@ describe('integration: postgres sql-terminal', () => { expect(verify.rows).toHaveLength(1); // Clean up - await executeRawSql(db, 'DROP TABLE test_alter_table', 'test'); + await executeRawSqlUnchecked(db, 'DROP TABLE test_alter_table', 'test'); }); it('should execute DROP TABLE successfully', async () => { // Create temp table - await executeRawSql( + await executeRawSqlUnchecked( db, 'CREATE TABLE test_drop_table (id SERIAL PRIMARY KEY)', 'test', ); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'DROP TABLE test_drop_table', 'test', @@ -476,7 +476,7 @@ describe('integration: postgres sql-terminal', () => { expect(result.success).toBe(true); // Verify table is gone - const verify = await executeRawSql( + const verify = await executeRawSqlUnchecked( db, `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'test_drop_table'`, @@ -488,7 +488,7 @@ describe('integration: postgres sql-terminal', () => { it('should execute CREATE INDEX successfully', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'CREATE INDEX test_idx_email ON users (email)', 'test', @@ -497,7 +497,7 @@ describe('integration: postgres sql-terminal', () => { expect(result.success).toBe(true); // Clean up - await executeRawSql(db, 'DROP INDEX test_idx_email', 'test'); + await executeRawSqlUnchecked(db, 'DROP INDEX test_idx_email', 'test'); }); @@ -507,7 +507,7 @@ describe('integration: postgres sql-terminal', () => { it('should return error for syntax errors', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELCT * FROM users', // Typo: SELCT 'test', @@ -521,7 +521,7 @@ describe('integration: postgres sql-terminal', () => { it('should return error for non-existent table', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM nonexistent_table_xyz', 'test', @@ -535,7 +535,7 @@ describe('integration: postgres sql-terminal', () => { it('should return error for invalid column reference', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT nonexistent_column FROM users', 'test', @@ -549,7 +549,7 @@ describe('integration: postgres sql-terminal', () => { it('should return error for constraint violations', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (email, username, password_hash) VALUES ('user1@test.com', 'duplicate', 'hash')`, // Duplicate email @@ -567,7 +567,7 @@ describe('integration: postgres sql-terminal', () => { it('should return error for foreign key violations', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO todo_lists (user_id, title) VALUES ('99999999-9999-9999-9999-999999999999', 'Orphan List')`, @@ -582,7 +582,7 @@ describe('integration: postgres sql-terminal', () => { it('should include duration even on error', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'INVALID SQL QUERY', 'test', @@ -615,7 +615,7 @@ describe('integration: postgres sql-terminal', () => { try { - await executeRawSql(db, 'SELECT * FROM users', 'test-config'); + await executeRawSqlUnchecked(db, 'SELECT * FROM users', 'test-config'); expect(events.length).toBe(2); @@ -657,7 +657,7 @@ describe('integration: postgres sql-terminal', () => { try { - await executeRawSql(db, 'INVALID QUERY', 'test-config'); + await executeRawSqlUnchecked(db, 'INVALID QUERY', 'test-config'); const afterEvent = events.find((e) => e.event === 'after'); expect(afterEvent).toBeDefined(); @@ -680,7 +680,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle UUID columns correctly', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT id FROM users LIMIT 1', 'test', @@ -696,7 +696,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle TIMESTAMPTZ columns correctly', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT created_at FROM users LIMIT 1', 'test', @@ -711,7 +711,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle BOOLEAN columns correctly', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT is_completed FROM todo_items', 'test', @@ -725,7 +725,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle NULL values correctly', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT deleted_at FROM users LIMIT 1', 'test', @@ -738,7 +738,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle SMALLINT columns correctly', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT priority FROM todo_items LIMIT 1', 'test', @@ -751,7 +751,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle TEXT columns correctly', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT description FROM todo_items WHERE description IS NOT NULL LIMIT 1', 'test', @@ -768,7 +768,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle CTEs (Common Table Expressions)', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `WITH user_list_counts AS ( SELECT user_id, COUNT(*) as list_count @@ -790,7 +790,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle subqueries', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT username FROM users @@ -805,7 +805,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle window functions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT username, @@ -822,7 +822,7 @@ describe('integration: postgres sql-terminal', () => { it('should handle CASE expressions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT title, @@ -852,7 +852,7 @@ describe('integration: postgres sql-terminal', () => { it('should execute recursive CTE for hierarchy traversal', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `WITH RECURSIVE numbers AS ( SELECT 1 AS n @@ -876,7 +876,7 @@ describe('integration: postgres sql-terminal', () => { it('should return execution plan', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'EXPLAIN SELECT * FROM users', 'test', @@ -893,16 +893,16 @@ describe('integration: postgres sql-terminal', () => { it('should handle explicit BEGIN/COMMIT', async () => { - await executeRawSql(db, 'BEGIN', 'test'); - await executeRawSql( + await executeRawSqlUnchecked(db, 'BEGIN', 'test'); + await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash) VALUES (gen_random_uuid(), 'tx@test.com', 'txuser', 'hash')`, 'test', ); - await executeRawSql(db, 'COMMIT', 'test'); + await executeRawSqlUnchecked(db, 'COMMIT', 'test'); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE email = 'tx@test.com'", 'test', @@ -915,16 +915,16 @@ describe('integration: postgres sql-terminal', () => { it('should handle ROLLBACK', async () => { - await executeRawSql(db, 'BEGIN', 'test'); - await executeRawSql( + await executeRawSqlUnchecked(db, 'BEGIN', 'test'); + await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash) VALUES (gen_random_uuid(), 'rollback@test.com', 'rbuser', 'hash')`, 'test', ); - await executeRawSql(db, 'ROLLBACK', 'test'); + await executeRawSqlUnchecked(db, 'ROLLBACK', 'test'); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE email = 'rollback@test.com'", 'test', diff --git a/tests/integration/sql-terminal/sqlite.test.ts b/tests/integration/sql-terminal/sqlite.test.ts index 4b7bc640..e5c471dc 100644 --- a/tests/integration/sql-terminal/sqlite.test.ts +++ b/tests/integration/sql-terminal/sqlite.test.ts @@ -13,7 +13,7 @@ import { seedTestData, resetTestData, } from '../../utils/db.js'; -import { executeRawSql } from '../../../src/core/sql-terminal/executor.js'; +import { executeRawSqlUnchecked } from '../../../src/core/sql-terminal/executor.js'; describe('integration: sqlite sql-terminal', () => { @@ -49,7 +49,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute simple SELECT and return columns and rows', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT id, email, username FROM users', 'test-config', @@ -63,7 +63,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return correct row data', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT email, username FROM users WHERE email = 'user1@test.com'", 'test-config', @@ -80,7 +80,7 @@ describe('integration: sqlite sql-terminal', () => { it('should handle SELECT with WHERE clause returning multiple rows', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT id, title FROM todo_items WHERE priority >= 1', 'test-config', @@ -93,7 +93,7 @@ describe('integration: sqlite sql-terminal', () => { it('should handle SELECT with JOIN', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT u.username, tl.title FROM users u @@ -109,7 +109,7 @@ describe('integration: sqlite sql-terminal', () => { it('should handle SELECT with aggregate functions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT COUNT(*) as total, MAX(priority) as max_priority FROM todo_items', 'test-config', @@ -124,7 +124,7 @@ describe('integration: sqlite sql-terminal', () => { it('should handle SELECT returning empty result set', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE email = 'nonexistent@test.com'", 'test-config', @@ -138,7 +138,7 @@ describe('integration: sqlite sql-terminal', () => { it('should handle SELECT from view', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM v_active_users', 'test-config', @@ -153,7 +153,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return execution duration', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM users', 'test-config', @@ -170,7 +170,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute INSERT and return rowsAffected', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash, display_name) VALUES ('99999999-9999-9999-9999-999999999999', 'new@test.com', 'newuser', 'hash', 'New User')`, @@ -184,14 +184,14 @@ describe('integration: sqlite sql-terminal', () => { it('should persist inserted data', async () => { - await executeRawSql( + await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test@insert.com', 'inserttest', 'hash')`, 'test-config', ); - const check = await executeRawSql( + const check = await executeRawSqlUnchecked( db, "SELECT email FROM users WHERE username = 'inserttest'", 'test-config', @@ -207,14 +207,14 @@ describe('integration: sqlite sql-terminal', () => { // SQLite doesn't support multi-value INSERT in all versions // So we do multiple inserts to test rowsAffected - const result1 = await executeRawSql( + const result1 = await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'a@test.com', 'usera', 'hash')`, 'test-config', ); - const result2 = await executeRawSql( + const result2 = await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'b@test.com', 'userb', 'hash')`, @@ -232,7 +232,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute UPDATE and return rowsAffected', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'Updated Name' WHERE username = 'user1'", 'test-config', @@ -245,7 +245,7 @@ describe('integration: sqlite sql-terminal', () => { it('should update multiple rows', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET avatar_url = 'https://example.com/avatar.png'", 'test-config', @@ -258,7 +258,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return rowsAffected = 0 when no rows match', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'Ghost' WHERE username = 'nonexistent'", 'test-config', @@ -271,13 +271,13 @@ describe('integration: sqlite sql-terminal', () => { it('should persist updated data', async () => { - await executeRawSql( + await executeRawSqlUnchecked( db, "UPDATE users SET display_name = 'Alice Smith' WHERE username = 'user1'", 'test-config', ); - const check = await executeRawSql( + const check = await executeRawSqlUnchecked( db, "SELECT display_name FROM users WHERE username = 'user1'", 'test-config', @@ -293,7 +293,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute DELETE and return rowsAffected', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "DELETE FROM todo_items WHERE title = 'Buy groceries'", 'test-config', @@ -306,7 +306,7 @@ describe('integration: sqlite sql-terminal', () => { it('should delete multiple rows', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'DELETE FROM todo_items WHERE is_completed = 0', 'test-config', @@ -319,7 +319,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return rowsAffected = 0 when no rows match', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, "DELETE FROM users WHERE email = 'nonexistent@ghost.com'", 'test-config', @@ -332,13 +332,13 @@ describe('integration: sqlite sql-terminal', () => { it('should persist deletion', async () => { - await executeRawSql( + await executeRawSqlUnchecked( db, "DELETE FROM users WHERE username = 'user3'", 'test-config', ); - const check = await executeRawSql( + const check = await executeRawSqlUnchecked( db, 'SELECT COUNT(*) as count FROM users', 'test-config', @@ -354,7 +354,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute CREATE TABLE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `CREATE TABLE test_table ( id INTEGER PRIMARY KEY, @@ -366,7 +366,7 @@ describe('integration: sqlite sql-terminal', () => { expect(result.success).toBe(true); // Verify table exists - const check = await executeRawSql( + const check = await executeRawSqlUnchecked( db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'test_table'", 'test-config', @@ -379,13 +379,13 @@ describe('integration: sqlite sql-terminal', () => { it('should execute DROP TABLE', async () => { // First create a table - await executeRawSql( + await executeRawSqlUnchecked( db, 'CREATE TABLE temp_table (id INTEGER)', 'test-config', ); - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'DROP TABLE temp_table', 'test-config', @@ -394,7 +394,7 @@ describe('integration: sqlite sql-terminal', () => { expect(result.success).toBe(true); // Verify table is gone - const check = await executeRawSql( + const check = await executeRawSqlUnchecked( db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'temp_table'", 'test-config', @@ -406,7 +406,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute CREATE INDEX', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'CREATE INDEX idx_test_display_name ON users(display_name)', 'test-config', @@ -415,7 +415,7 @@ describe('integration: sqlite sql-terminal', () => { expect(result.success).toBe(true); // Verify index exists - const check = await executeRawSql( + const check = await executeRawSqlUnchecked( db, "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_test_display_name'", 'test-config', @@ -427,7 +427,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute ALTER TABLE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'ALTER TABLE users ADD COLUMN bio TEXT', 'test-config', @@ -436,7 +436,7 @@ describe('integration: sqlite sql-terminal', () => { expect(result.success).toBe(true); // Verify column exists - const check = await executeRawSql( + const check = await executeRawSqlUnchecked( db, 'PRAGMA table_info(users)', 'test-config', @@ -453,7 +453,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute PRAGMA queries', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'PRAGMA table_info(users)', 'test-config', @@ -468,7 +468,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute PRAGMA foreign_keys', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'PRAGMA foreign_keys', 'test-config', @@ -484,7 +484,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return error for syntax error', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELEC * FORM users', 'test-config', @@ -498,7 +498,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return error for non-existent table', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM nonexistent_table', 'test-config', @@ -511,7 +511,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return error for non-existent column', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT nonexistent_column FROM users', 'test-config', @@ -524,7 +524,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return error for constraint violation', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash) VALUES ('11111111-1111-1111-1111-111111111111', 'duplicate@test.com', 'user1', 'hash')`, @@ -543,7 +543,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return error for invalid SQL type', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'NOT_A_VALID_SQL_COMMAND', 'test-config', @@ -556,7 +556,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return duration even on error', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'INVALID SQL', 'test-config', @@ -569,7 +569,7 @@ describe('integration: sqlite sql-terminal', () => { it('should not return columns or rows on error', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT * FROM nonexistent', 'test-config', @@ -587,7 +587,7 @@ describe('integration: sqlite sql-terminal', () => { it('should handle subqueries', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT username FROM users WHERE id IN (SELECT user_id FROM todo_lists)`, @@ -601,7 +601,7 @@ describe('integration: sqlite sql-terminal', () => { it('should handle GROUP BY and HAVING', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT list_id, COUNT(*) as item_count FROM todo_items @@ -617,7 +617,7 @@ describe('integration: sqlite sql-terminal', () => { it('should handle ORDER BY with LIMIT', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'SELECT username FROM users ORDER BY username ASC LIMIT 2', 'test-config', @@ -632,7 +632,7 @@ describe('integration: sqlite sql-terminal', () => { it('should handle CASE expressions', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT title, CASE priority @@ -652,7 +652,7 @@ describe('integration: sqlite sql-terminal', () => { it('should handle COALESCE and IFNULL', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `SELECT username, COALESCE(display_name, 'Anonymous') as name FROM users`, @@ -675,10 +675,10 @@ describe('integration: sqlite sql-terminal', () => { it('should handle BEGIN/COMMIT', async () => { - const beginResult = await executeRawSql(db, 'BEGIN', 'test-config'); + const beginResult = await executeRawSqlUnchecked(db, 'BEGIN', 'test-config'); expect(beginResult.success).toBe(true); - const insertResult = await executeRawSql( + const insertResult = await executeRawSqlUnchecked( db, `INSERT INTO users (id, email, username, password_hash) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'tx@test.com', 'txuser', 'hash')`, @@ -686,11 +686,11 @@ describe('integration: sqlite sql-terminal', () => { ); expect(insertResult.success).toBe(true); - const commitResult = await executeRawSql(db, 'COMMIT', 'test-config'); + const commitResult = await executeRawSqlUnchecked(db, 'COMMIT', 'test-config'); expect(commitResult.success).toBe(true); // Verify data is committed - const check = await executeRawSql( + const check = await executeRawSqlUnchecked( db, "SELECT * FROM users WHERE username = 'txuser'", 'test-config', @@ -707,7 +707,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute recursive CTE', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, `WITH RECURSIVE numbers(n) AS ( SELECT 1 @@ -731,7 +731,7 @@ describe('integration: sqlite sql-terminal', () => { it('should return query plan', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'EXPLAIN QUERY PLAN SELECT * FROM users', 'test-config', @@ -748,7 +748,7 @@ describe('integration: sqlite sql-terminal', () => { it('should execute PRAGMA commands', async () => { - const result = await executeRawSql( + const result = await executeRawSqlUnchecked( db, 'PRAGMA table_info(users)', 'test-config', diff --git a/tests/sdk/bundle-smoke.test.ts b/tests/sdk/bundle-smoke.test.ts index 888632b5..8583a2c6 100644 --- a/tests/sdk/bundle-smoke.test.ts +++ b/tests/sdk/bundle-smoke.test.ts @@ -188,7 +188,7 @@ describe.skipIf(!bundleExists)('sdk bundle: Context instantiation', () => { name: 'smoke-test', type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect: 'postgres', database: 'smokedb' }, }, {}, @@ -301,7 +301,7 @@ describe.skipIf(!bundleExists)('sdk bundle: dialect chunks', () => { name: `${dialect}-test`, type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect, database: `${dialect}db` }, }, {}, diff --git a/tests/sdk/context.test.ts b/tests/sdk/context.test.ts index a3958bb1..872e127e 100644 --- a/tests/sdk/context.test.ts +++ b/tests/sdk/context.test.ts @@ -31,7 +31,7 @@ function createMockConfig(dialect: Config['connection']['dialect'] = 'postgres') name: 'test', type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect, database: 'testdb' }, }; diff --git a/tests/sdk/db-namespace.test.ts b/tests/sdk/db-namespace.test.ts index f4609d4d..4ef795ca 100644 --- a/tests/sdk/db-namespace.test.ts +++ b/tests/sdk/db-namespace.test.ts @@ -84,7 +84,7 @@ function createMockConfig(): Config { name: 'test', type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect: 'postgres', database: 'testdb' }, }; diff --git a/tests/sdk/destructive-ops.test.ts b/tests/sdk/destructive-ops.test.ts index 7fd58398..3368044b 100644 --- a/tests/sdk/destructive-ops.test.ts +++ b/tests/sdk/destructive-ops.test.ts @@ -1,44 +1,60 @@ /** * SDK destructive-ops guard tests. * - * Proves that config.protected = true unconditionally blocks every - * destructive operation across DbNamespace, DtNamespace, and - * ChangesNamespace, and that the same operations are NOT blocked on - * an unprotected config (they may fail for other reasons, but not with - * ProtectedConfigError). Read-only ops are never blocked regardless of - * the protected flag. + * Proves that role-denied configs (viewer/operator) unconditionally block + * every destructive operation across DbNamespace, DtNamespace, + * ChangesNamespace, RunNamespace, and TransferNamespace via checkPolicy, + * and that admin-role configs proceed frictionlessly — change:revert + * (`change:revert`), change:run/change:ff (`change:run`/`change:ff`), + * run.file/run.build (`run:file`/`run:build`), transfer.to (`db:reset` + * against the *destination* config), and + * truncate/teardown/reset/importFile (`db:reset`) are all `allow` cells for + * admin, matching the legacy protected:false behavior for open configs. + * Read-only ops are never blocked regardless of access. */ import { describe, it, expect } from 'bun:test'; import { DbNamespace } from '../../src/sdk/namespaces/db.js'; import { DtNamespace } from '../../src/sdk/namespaces/dt.js'; import { ChangesNamespace } from '../../src/sdk/namespaces/changes.js'; +import { RunNamespace } from '../../src/sdk/namespaces/run.js'; +import { TransferNamespace } from '../../src/sdk/namespaces/transfer.js'; import { ProtectedConfigError } from '../../src/sdk/guards.js'; import type { ContextState } from '../../src/sdk/state.js'; import type { Config } from '../../src/core/config/types.js'; +import type { ConfigAccess } from '../../src/core/policy/index.js'; // ───────────────────────────────────────────────────────────── // Fixtures // ───────────────────────────────────────────────────────────── -function makeConfig(isProtected: boolean): Config { +/** Mirrors the legacy `protected: true` migration mapping. */ +const OPERATOR_ACCESS: ConfigAccess = { user: 'operator', mcp: 'viewer' }; + +/** Denies everything but explore/sql:read on the user channel. */ +const VIEWER_ACCESS: ConfigAccess = { user: 'viewer', mcp: false }; + +/** Mirrors the legacy `protected: false` migration mapping. */ +const ADMIN_ACCESS: ConfigAccess = { user: 'admin', mcp: 'admin' }; + +function makeConfig(access: ConfigAccess): Config { return { - name: isProtected ? 'prod' : 'dev', + name: access.user === 'admin' ? 'dev' : 'prod', type: 'local', isTest: false, - protected: isProtected, + access, connection: { dialect: 'postgres', database: 'testdb' }, }; } -function makeState(isProtected: boolean): ContextState { +function makeState(access: ConfigAccess): ContextState { return { connection: null, - config: makeConfig(isProtected), + config: makeConfig(access), settings: {}, identity: { name: 'tester', @@ -55,17 +71,18 @@ function makeState(isProtected: boolean): ContextState { // Tests // ───────────────────────────────────────────────────────────── -describe('sdk: protected config guard', () => { +describe('sdk: access-guarded destructive ops', () => { // ───────────────────────────────────────────────────── - // DbNamespace — protected blocks all destructive ops + // DbNamespace — operator role can't satisfy the db:reset + // confirm cell (SDK has no interactive prompt) // ───────────────────────────────────────────────────── - describe('DbNamespace on protected config', () => { + describe('DbNamespace on operator-role config', () => { it('should throw ProtectedConfigError for truncate()', async () => { - const db = new DbNamespace(makeState(true)); + const db = new DbNamespace(makeState(OPERATOR_ACCESS)); await expect(db.truncate()).rejects.toThrow(ProtectedConfigError); @@ -73,7 +90,7 @@ describe('sdk: protected config guard', () => { it('should throw ProtectedConfigError for teardown()', async () => { - const db = new DbNamespace(makeState(true)); + const db = new DbNamespace(makeState(OPERATOR_ACCESS)); await expect(db.teardown()).rejects.toThrow(ProtectedConfigError); @@ -81,7 +98,7 @@ describe('sdk: protected config guard', () => { it('should throw ProtectedConfigError for reset()', async () => { - const db = new DbNamespace(makeState(true)); + const db = new DbNamespace(makeState(OPERATOR_ACCESS)); await expect(db.reset()).rejects.toThrow(ProtectedConfigError); @@ -90,30 +107,48 @@ describe('sdk: protected config guard', () => { }); // ───────────────────────────────────────────────────── - // DtNamespace — protected blocks importFile + // DtNamespace — operator role can't satisfy the db:reset + // confirm cell (SDK has no interactive prompt) // ───────────────────────────────────────────────────── - describe('DtNamespace on protected config', () => { + describe('DtNamespace on operator-role config', () => { it('should throw ProtectedConfigError for importFile()', async () => { - const dt = new DtNamespace(makeState(true)); + const dt = new DtNamespace(makeState(OPERATOR_ACCESS)); await expect(dt.importFile('./fake.dtz')).rejects.toThrow(ProtectedConfigError); }); + it('should name the "dt.importFile" method the consumer called, not an internal alias', async () => { + + const dt = new DtNamespace(makeState(OPERATOR_ACCESS)); + + expect.assertions(1); + + const err = await dt.importFile('./fake.dtz').catch((e: unknown) => e); + + if (err instanceof ProtectedConfigError) { + + expect(err.operation).toBe('dt.importFile'); + + } + + }); + }); // ───────────────────────────────────────────────────── - // ChangesNamespace — protected blocks revert and rewind + // ChangesNamespace — operator role requires confirmation, + // which the SDK cannot satisfy without NOORM_YES // ───────────────────────────────────────────────────── - describe('ChangesNamespace on protected config', () => { + describe('ChangesNamespace on operator-role config', () => { it('should throw ProtectedConfigError for revert()', async () => { - const changes = new ChangesNamespace(makeState(true)); + const changes = new ChangesNamespace(makeState(OPERATOR_ACCESS)); await expect(changes.revert('any-change')).rejects.toThrow(ProtectedConfigError); @@ -121,24 +156,60 @@ describe('sdk: protected config guard', () => { it('should throw ProtectedConfigError for rewind()', async () => { - const changes = new ChangesNamespace(makeState(true)); + const changes = new ChangesNamespace(makeState(OPERATOR_ACCESS)); await expect(changes.rewind('any-change')).rejects.toThrow(ProtectedConfigError); }); + it('should name the "changes.rewind" method the consumer called, not "changes.revert"', async () => { + + const changes = new ChangesNamespace(makeState(OPERATOR_ACCESS)); + + expect.assertions(1); + + const err = await changes.rewind('any-change').catch((e: unknown) => e); + + if (err instanceof ProtectedConfigError) { + + expect(err.operation).toBe('changes.rewind'); + + } + + }); + }); // ───────────────────────────────────────────────────── - // Unprotected config — guard does NOT block + // Admin role — change:revert is a frictionless `allow` cell, + // matching the legacy protected:false behavior exactly // ───────────────────────────────────────────────────── - describe('DbNamespace on unprotected config', () => { + describe('ChangesNamespace on admin-role config', () => { - it('should not throw ProtectedConfigError for truncate()', async () => { + it('should not throw ProtectedConfigError for revert()', async () => { + + const changes = new ChangesNamespace(makeState(ADMIN_ACCESS)); + + const err = await changes.revert('any-change').catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); - const db = new DbNamespace(makeState(false)); + // ───────────────────────────────────────────────────── + // Admin role — db:reset is an `allow` cell for admin, so + // truncate/teardown/reset/importFile proceed frictionlessly, + // same as the legacy protected:false behavior + // ───────────────────────────────────────────────────── + + describe('DbNamespace on admin-role config', () => { + it('should not throw ProtectedConfigError for truncate()', async () => { + + const db = new DbNamespace(makeState(ADMIN_ACCESS)); const err = await db.truncate().catch((e: unknown) => e); expect(err).not.toBeInstanceOf(ProtectedConfigError); @@ -147,12 +218,11 @@ describe('sdk: protected config guard', () => { }); - describe('DtNamespace on unprotected config', () => { + describe('DtNamespace on admin-role config', () => { it('should not throw ProtectedConfigError for importFile()', async () => { - const dt = new DtNamespace(makeState(false)); - + const dt = new DtNamespace(makeState(ADMIN_ACCESS)); const err = await dt.importFile('./fake.dtz').catch((e: unknown) => e); expect(err).not.toBeInstanceOf(ProtectedConfigError); @@ -161,13 +231,17 @@ describe('sdk: protected config guard', () => { }); - describe('ChangesNamespace on unprotected config', () => { + // ───────────────────────────────────────────────────── + // Read-only ops — never blocked regardless of access + // ───────────────────────────────────────────────────── - it('should not throw ProtectedConfigError for revert()', async () => { + describe('DtNamespace read-only ops on operator-role config', () => { - const changes = new ChangesNamespace(makeState(false)); + it('should not throw ProtectedConfigError for exportTable()', async () => { - const err = await changes.revert('any-change').catch((e: unknown) => e); + const dt = new DtNamespace(makeState(OPERATOR_ACCESS)); + + const err = await dt.exportTable('users', './fake.dtz').catch((e: unknown) => e); expect(err).not.toBeInstanceOf(ProtectedConfigError); @@ -176,16 +250,217 @@ describe('sdk: protected config guard', () => { }); // ───────────────────────────────────────────────────── - // Read-only ops — never blocked by protected flag + // RunNamespace — viewer denies run:file/run:build outright; + // operator requires confirmation the SDK cannot give // ───────────────────────────────────────────────────── - describe('DtNamespace read-only ops on protected config', () => { + describe('RunNamespace on viewer-role config', () => { - it('should not throw ProtectedConfigError for exportTable()', async () => { + it('should throw ProtectedConfigError for file()', async () => { - const dt = new DtNamespace(makeState(true)); + const run = new RunNamespace(makeState(VIEWER_ACCESS)); - const err = await dt.exportTable('users', './fake.dtz').catch((e: unknown) => e); + await expect(run.file('sql/seed.sql')).rejects.toThrow(ProtectedConfigError); + + }); + + it('should throw ProtectedConfigError for build()', async () => { + + const run = new RunNamespace(makeState(VIEWER_ACCESS)); + + await expect(run.build()).rejects.toThrow(ProtectedConfigError); + + }); + + }); + + describe('RunNamespace on operator-role config', () => { + + it('should throw ProtectedConfigError for file() when NOORM_YES is unset', async () => { + + const run = new RunNamespace(makeState(OPERATOR_ACCESS)); + + await expect(run.file('sql/seed.sql')).rejects.toThrow(ProtectedConfigError); + + }); + + it('should throw ProtectedConfigError for build() when NOORM_YES is unset', async () => { + + const run = new RunNamespace(makeState(OPERATOR_ACCESS)); + + await expect(run.build()).rejects.toThrow(ProtectedConfigError); + + }); + + it('should not throw ProtectedConfigError for file() once NOORM_YES=1 is set', async () => { + + process.env['NOORM_YES'] = '1'; + + const run = new RunNamespace(makeState(OPERATOR_ACCESS)); + const err = await run.file('sql/seed.sql').catch((e: unknown) => e); + + delete process.env['NOORM_YES']; + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + + describe('RunNamespace on admin-role config', () => { + + it('should not throw ProtectedConfigError for file()', async () => { + + const run = new RunNamespace(makeState(ADMIN_ACCESS)); + const err = await run.file('sql/seed.sql').catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + it('should not throw ProtectedConfigError for build()', async () => { + + const run = new RunNamespace(makeState(ADMIN_ACCESS)); + const err = await run.build().catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + + // ───────────────────────────────────────────────────── + // ChangesNamespace — viewer denies change:run/change:ff outright; + // operator requires confirmation the SDK cannot give + // ───────────────────────────────────────────────────── + + describe('ChangesNamespace apply/ff on viewer-role config', () => { + + it('should throw ProtectedConfigError for apply()', async () => { + + const changes = new ChangesNamespace(makeState(VIEWER_ACCESS)); + + await expect(changes.apply('any-change')).rejects.toThrow(ProtectedConfigError); + + }); + + it('should throw ProtectedConfigError for ff()', async () => { + + const changes = new ChangesNamespace(makeState(VIEWER_ACCESS)); + + await expect(changes.ff()).rejects.toThrow(ProtectedConfigError); + + }); + + }); + + describe('ChangesNamespace apply/ff on operator-role config', () => { + + it('should throw ProtectedConfigError for apply() when NOORM_YES is unset', async () => { + + const changes = new ChangesNamespace(makeState(OPERATOR_ACCESS)); + + await expect(changes.apply('any-change')).rejects.toThrow(ProtectedConfigError); + + }); + + it('should throw ProtectedConfigError for ff() when NOORM_YES is unset', async () => { + + const changes = new ChangesNamespace(makeState(OPERATOR_ACCESS)); + + await expect(changes.ff()).rejects.toThrow(ProtectedConfigError); + + }); + + it('should not throw ProtectedConfigError for apply() once NOORM_YES=1 is set', async () => { + + process.env['NOORM_YES'] = '1'; + + const changes = new ChangesNamespace(makeState(OPERATOR_ACCESS)); + const err = await changes.apply('any-change').catch((e: unknown) => e); + + delete process.env['NOORM_YES']; + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + + describe('ChangesNamespace apply/ff on admin-role config', () => { + + it('should not throw ProtectedConfigError for apply()', async () => { + + const changes = new ChangesNamespace(makeState(ADMIN_ACCESS)); + const err = await changes.apply('any-change').catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + it('should not throw ProtectedConfigError for ff()', async () => { + + const changes = new ChangesNamespace(makeState(ADMIN_ACCESS)); + const err = await changes.ff().catch((e: unknown) => e); + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + + // ───────────────────────────────────────────────────── + // TransferNamespace — gated on the DESTINATION config's role, since + // the destructive act (writing rows) lands there, not on the source + // ───────────────────────────────────────────────────── + + describe('TransferNamespace transfer.to on a viewer-role destination', () => { + + it('should throw ProtectedConfigError even when the source config is admin', async () => { + + const transfer = new TransferNamespace(makeState(ADMIN_ACCESS)); + const destConfig = makeConfig(VIEWER_ACCESS); + + await expect(transfer.to(destConfig)).rejects.toThrow(ProtectedConfigError); + + }); + + }); + + describe('TransferNamespace transfer.to on an operator-role destination', () => { + + it('should throw ProtectedConfigError when NOORM_YES is unset', async () => { + + const transfer = new TransferNamespace(makeState(ADMIN_ACCESS)); + const destConfig = makeConfig(OPERATOR_ACCESS); + + await expect(transfer.to(destConfig)).rejects.toThrow(ProtectedConfigError); + + }); + + it('should not throw ProtectedConfigError once NOORM_YES=1 is set', async () => { + + process.env['NOORM_YES'] = '1'; + + const transfer = new TransferNamespace(makeState(ADMIN_ACCESS)); + const destConfig = makeConfig(OPERATOR_ACCESS); + const err = await transfer.to(destConfig).catch((e: unknown) => e); + + delete process.env['NOORM_YES']; + + expect(err).not.toBeInstanceOf(ProtectedConfigError); + + }); + + }); + + describe('TransferNamespace transfer.to on an admin-role destination', () => { + + it('should not throw ProtectedConfigError', async () => { + + const transfer = new TransferNamespace(makeState(ADMIN_ACCESS)); + const destConfig = makeConfig(ADMIN_ACCESS); + const err = await transfer.to(destConfig).catch((e: unknown) => e); expect(err).not.toBeInstanceOf(ProtectedConfigError); diff --git a/tests/sdk/guards.test.ts b/tests/sdk/guards.test.ts index 5d839658..b0d9d234 100644 --- a/tests/sdk/guards.test.ts +++ b/tests/sdk/guards.test.ts @@ -7,15 +7,20 @@ import { ProtectedConfigError, } from '../../src/sdk/guards.js'; import type { Config } from '../../src/core/config/types.js'; +import type { ConfigAccess } from '../../src/core/policy/index.js'; import type { CreateContextOptions } from '../../src/sdk/types.js'; -function makeConfig(overrides: Partial = {}): Config { +const ADMIN_ACCESS: ConfigAccess = { user: 'admin', mcp: 'admin' }; +const OPERATOR_ACCESS: ConfigAccess = { user: 'operator', mcp: 'viewer' }; +const VIEWER_ACCESS: ConfigAccess = { user: 'viewer', mcp: false }; + +function makeConfig(access: ConfigAccess, overrides: Partial = {}): Config { return { name: 'test', type: 'local', isTest: true, - protected: false, + access, connection: { dialect: 'postgres', database: 'testdb' }, ...overrides, }; @@ -26,7 +31,7 @@ describe('checkRequireTest', () => { it('does not throw when requireTest is false and isTest is false', () => { - const config = makeConfig({ isTest: false }); + const config = makeConfig(ADMIN_ACCESS, { isTest: false }); const options: CreateContextOptions = { requireTest: false }; expect(() => checkRequireTest(config, options)).not.toThrow(); @@ -35,7 +40,7 @@ describe('checkRequireTest', () => { it('does not throw when requireTest is true and isTest is true', () => { - const config = makeConfig({ isTest: true }); + const config = makeConfig(ADMIN_ACCESS, { isTest: true }); const options: CreateContextOptions = { requireTest: true }; expect(() => checkRequireTest(config, options)).not.toThrow(); @@ -44,7 +49,7 @@ describe('checkRequireTest', () => { it('throws RequireTestError when requireTest is true and isTest is false', () => { - const config = makeConfig({ isTest: false, name: 'prod' }); + const config = makeConfig(ADMIN_ACCESS, { isTest: false, name: 'prod' }); const options: CreateContextOptions = { requireTest: true }; expect(() => checkRequireTest(config, options)).toThrow(RequireTestError); @@ -53,7 +58,7 @@ describe('checkRequireTest', () => { it('carries config name in RequireTestError', () => { - const config = makeConfig({ isTest: false, name: 'prod' }); + const config = makeConfig(ADMIN_ACCESS, { isTest: false, name: 'prod' }); const options: CreateContextOptions = { requireTest: true }; expect.assertions(2); @@ -71,7 +76,7 @@ describe('checkRequireTest', () => { it('does not throw when requireTest is omitted from options', () => { - const config = makeConfig({ isTest: false }); + const config = makeConfig(ADMIN_ACCESS, { isTest: false }); const options: CreateContextOptions = {}; expect(() => checkRequireTest(config, options)).not.toThrow(); @@ -82,32 +87,61 @@ describe('checkRequireTest', () => { describe('checkProtectedConfig', () => { - it('does not throw on non-protected config', () => { + it('does not throw when the permission is an allow cell for the role (change:revert, admin)', () => { + + const config = makeConfig(ADMIN_ACCESS); + + expect(() => checkProtectedConfig(config, {}, 'change:revert', 'changes.revert')).not.toThrow(); + + }); + + it('does not throw when the permission is an allow cell for the role (db:reset, admin)', () => { + + const config = makeConfig(ADMIN_ACCESS); + + expect(() => checkProtectedConfig(config, {}, 'db:reset', 'truncate')).not.toThrow(); + + }); + + it('throws ProtectedConfigError when the role denies the permission (db:reset, viewer)', () => { + + const config = makeConfig(VIEWER_ACCESS, { name: 'prod' }); + + expect(() => checkProtectedConfig(config, {}, 'db:reset', 'truncate')).toThrow(ProtectedConfigError); + + }); + + it('surfaces the policy blockedReason (role + permission) on a deny cell', () => { + + const config = makeConfig(VIEWER_ACCESS, { name: 'prod' }); + + expect.assertions(1); + + const [, err] = attemptSync(() => checkProtectedConfig(config, {}, 'db:reset', 'truncate')); + + if (err instanceof ProtectedConfigError) { - const config = makeConfig({ protected: false }); - const operation = 'truncate'; + expect(err.message).toContain('"db:reset" is not allowed on config "prod" (role: viewer).'); - expect(() => checkProtectedConfig(config, operation)).not.toThrow(); + } }); - it('throws ProtectedConfigError when protected is true', () => { + it('throws ProtectedConfigError when the role requires confirmation the SDK cannot give (db:reset, operator)', () => { - const config = makeConfig({ protected: true }); - const operation = 'truncate'; + const config = makeConfig(OPERATOR_ACCESS, { name: 'prod' }); - expect(() => checkProtectedConfig(config, operation)).toThrow(ProtectedConfigError); + expect(() => checkProtectedConfig(config, {}, 'db:reset', 'truncate')).toThrow(ProtectedConfigError); }); it('carries configName in ProtectedConfigError', () => { - const config = makeConfig({ protected: true, name: 'prod' }); - const operation = 'truncate'; + const config = makeConfig(OPERATOR_ACCESS, { name: 'prod' }); expect.assertions(1); - const [, err] = attemptSync(() => checkProtectedConfig(config, operation)); + const [, err] = attemptSync(() => checkProtectedConfig(config, {}, 'db:reset', 'truncate')); if (err instanceof ProtectedConfigError) { @@ -119,12 +153,11 @@ describe('checkProtectedConfig', () => { it('carries operation in ProtectedConfigError', () => { - const config = makeConfig({ protected: true, name: 'prod' }); - const operation = 'truncate'; + const config = makeConfig(OPERATOR_ACCESS, { name: 'prod' }); expect.assertions(2); - const [, err] = attemptSync(() => checkProtectedConfig(config, operation)); + const [, err] = attemptSync(() => checkProtectedConfig(config, {}, 'db:reset', 'truncate')); if (err instanceof ProtectedConfigError) { @@ -135,43 +168,150 @@ describe('checkProtectedConfig', () => { }); - it('blocks truncate operation', () => { + it('blocks truncate-class operations (db:reset) for operator role', () => { + + const config = makeConfig(OPERATOR_ACCESS); + + expect(() => checkProtectedConfig(config, {}, 'db:reset', 'truncate')).toThrow(ProtectedConfigError); + + }); + + it('blocks teardown-class operations (db:reset) for operator role', () => { + + const config = makeConfig(OPERATOR_ACCESS); + + expect(() => checkProtectedConfig(config, {}, 'db:reset', 'teardown')).toThrow(ProtectedConfigError); + + }); + + it('blocks reset-class operations (db:reset) for operator role', () => { + + const config = makeConfig(OPERATOR_ACCESS); + + expect(() => checkProtectedConfig(config, {}, 'db:reset', 'reset')).toThrow(ProtectedConfigError); + + }); + + it('blocks dt.import-class operations (db:reset) for operator role', () => { + + const config = makeConfig(OPERATOR_ACCESS); + + expect(() => checkProtectedConfig(config, {}, 'db:reset', 'dt.import')).toThrow(ProtectedConfigError); + + }); + + it('allows truncate/teardown/reset/dt.import-class operations (db:reset) for admin role without confirmation', () => { + + const config = makeConfig(ADMIN_ACCESS, { name: 'prod' }); + + for (const operation of ['truncate', 'teardown', 'reset', 'dt.import']) { + + expect(() => checkProtectedConfig(config, {}, 'db:reset', operation)).not.toThrow(); + + } + + }); + + it('blocks changes.revert-class operations (change:revert) for viewer role', () => { + + const config = makeConfig(VIEWER_ACCESS); + + expect(() => checkProtectedConfig(config, {}, 'change:revert', 'changes.revert')).toThrow(ProtectedConfigError); + + }); + + it('blocks a confirm cell for admin role (db:destroy, reserved for dropping a database) when NOORM_YES is unset', () => { + + const config = makeConfig(ADMIN_ACCESS, { name: 'prod' }); + + expect(() => checkProtectedConfig(config, {}, 'db:destroy', 'drop')).toThrow(ProtectedConfigError); + + }); + + it('names the confirmation route in the message when confirmation is required', () => { + + const config = makeConfig(ADMIN_ACCESS, { name: 'prod' }); + + expect.assertions(1); + + const [, err] = attemptSync(() => checkProtectedConfig(config, {}, 'db:destroy', 'drop')); + + if (err instanceof ProtectedConfigError) { + + expect(err.message).toContain('NOORM_YES'); + + } + + }); + + it('allows a confirm cell for admin role (db:destroy) once NOORM_YES=1 is set', () => { + + process.env['NOORM_YES'] = '1'; - const config = makeConfig({ protected: true }); + const config = makeConfig(ADMIN_ACCESS); - expect(() => checkProtectedConfig(config, 'truncate')).toThrow(ProtectedConfigError); + const [, err] = attemptSync(() => checkProtectedConfig(config, {}, 'db:destroy', 'drop')); + + delete process.env['NOORM_YES']; + + expect(err).toBeNull(); }); - it('blocks teardown operation', () => { + it('blocks a confirm cell for operator role (change:revert) when NOORM_YES is unset', () => { - const config = makeConfig({ protected: true }); + const config = makeConfig(OPERATOR_ACCESS); - expect(() => checkProtectedConfig(config, 'teardown')).toThrow(ProtectedConfigError); + expect(() => checkProtectedConfig(config, {}, 'change:revert', 'changes.revert')).toThrow(ProtectedConfigError); }); - it('blocks reset operation', () => { + it('allows a confirm cell for operator role (change:revert) once NOORM_YES=1 is set', () => { + + process.env['NOORM_YES'] = '1'; + + const config = makeConfig(OPERATOR_ACCESS); + + const [, err] = attemptSync(() => checkProtectedConfig(config, {}, 'change:revert', 'changes.revert')); - const config = makeConfig({ protected: true }); + delete process.env['NOORM_YES']; - expect(() => checkProtectedConfig(config, 'reset')).toThrow(ProtectedConfigError); + expect(err).toBeNull(); }); - it('blocks dt.import operation', () => { + it('defaults the channel to user when options.channel is omitted', () => { - const config = makeConfig({ protected: true }); + // change:revert is a confirm cell for operator — allowed on `user` + // once NOORM_YES skips the prompt, but OPERATOR_ACCESS.mcp is + // 'viewer', a deny cell, so the `mcp` channel is always blocked. + // (VIEWER_ACCESS.mcp === false would deny on both channels + // regardless of the default, proving nothing about which one ran.) + // If the default silently fell through to `mcp` this would throw, + // so this pins the CreateContextOptions.channel default to `user`. + process.env['NOORM_YES'] = '1'; - expect(() => checkProtectedConfig(config, 'dt.import')).toThrow(ProtectedConfigError); + const config = makeConfig(OPERATOR_ACCESS); + + const [, err] = attemptSync(() => checkProtectedConfig(config, {}, 'change:revert', 'changes.revert')); + + delete process.env['NOORM_YES']; + + expect(err).toBeNull(); }); - it('blocks changes.revert operation', () => { + it('respects an explicit mcp channel, where NOORM_YES has no effect on confirm cells', () => { + + process.env['NOORM_YES'] = '1'; + + const config = makeConfig(ADMIN_ACCESS); - const config = makeConfig({ protected: true }); + expect( + () => checkProtectedConfig(config, { channel: 'mcp' }, 'db:destroy', 'drop'), + ).toThrow(ProtectedConfigError); - expect(() => checkProtectedConfig(config, 'changes.revert')).toThrow(ProtectedConfigError); + delete process.env['NOORM_YES']; }); diff --git a/tests/sdk/impersonate/impersonate.test.ts b/tests/sdk/impersonate/impersonate.test.ts index 04b009cc..b1f7fe6b 100644 --- a/tests/sdk/impersonate/impersonate.test.ts +++ b/tests/sdk/impersonate/impersonate.test.ts @@ -30,7 +30,7 @@ function createMockConfig(dialect: Config['connection']['dialect'] = 'postgres') name: 'test', type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect, database: 'testdb' }, }; diff --git a/tests/sdk/noorm-ops.test.ts b/tests/sdk/noorm-ops.test.ts index fb199edf..02da226c 100644 --- a/tests/sdk/noorm-ops.test.ts +++ b/tests/sdk/noorm-ops.test.ts @@ -33,7 +33,7 @@ function createMockConfig(dialect: Config['connection']['dialect'] = 'postgres') name: 'test', type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection: { dialect, database: 'testdb' }, }; diff --git a/tests/utils/db.ts b/tests/utils/db.ts index 760b60bd..32639d65 100644 --- a/tests/utils/db.ts +++ b/tests/utils/db.ts @@ -844,7 +844,7 @@ export function makeTestConfig(name: string, connection: ConnectionConfig): Conf name, type: 'local', isTest: true, - protected: false, + access: { user: 'admin', mcp: 'admin' }, connection, };