diff --git a/CLAUDE.md b/.claude/CLAUDE.md similarity index 100% rename from CLAUDE.md rename to .claude/CLAUDE.md diff --git a/.claude/agents/project-architect.md b/.claude/agents/project-architect.md index df6e6d71..3d8caace 100644 --- a/.claude/agents/project-architect.md +++ b/.claude/agents/project-architect.md @@ -12,7 +12,7 @@ You are a software architect for the NeoBoard monorepo — an open-source dashbo Read these files for project rules and architecture: -- `CLAUDE.md` — Working rules, architecture boundaries, query safety, credentials +- `.claude/CLAUDE.md` — Working rules, architecture boundaries, query safety, credentials - `claude_code_docs/` — Detailed docs on testing, widget architecture, performance ## Tech Stack @@ -36,7 +36,7 @@ You may receive: 1. If given an issue number, fetch it: `gh issue view ` 2. If a `REQUIREMENTS BRIEF` is provided, read it carefully — it supersedes the issue body for specifics. -3. Read `CLAUDE.md` and relevant docs in `claude_code_docs/`. +3. Read `.claude/CLAUDE.md` and relevant docs in `claude_code_docs/`. 4. Search the codebase thoroughly to understand existing patterns related to the feature: - Find files that will need modification - Identify interfaces and types to extend diff --git a/.claude/hooks/check-boundaries.sh b/.claude/hooks/check-boundaries.sh index ac559348..afee5369 100755 --- a/.claude/hooks/check-boundaries.sh +++ b/.claude/hooks/check-boundaries.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Enforce package boundary rules from CLAUDE.md +# Enforce package boundary rules from .claude/CLAUDE.md # - component/ must NOT import from app/ or connection/ # - connection/ must NOT import React, app/, or component/ INPUT=$(cat) @@ -13,7 +13,7 @@ NEW_CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // .tool_input.conte # component/ must NOT import from app/ or connection/ if [[ "$FILE_PATH" == *"/component/src/"* ]]; then if echo "$NEW_CONTENT" | grep -qE "(from|import|require)[[:space:]]*['\"].*/(app|connection)/|(from|import|require)[[:space:]]*['\"]@/(app|connection)"; then - echo "BLOCKED: component/ cannot import from app/ or connection/. See CLAUDE.md architecture rules." >&2 + echo "BLOCKED: component/ cannot import from app/ or connection/. See .claude/CLAUDE.md architecture rules." >&2 exit 2 fi fi @@ -21,7 +21,7 @@ fi # connection/ must NOT import from app/, component/, or React if [[ "$FILE_PATH" == *"/connection/src/"* ]]; then if echo "$NEW_CONTENT" | grep -qE "(from|import|require)[[:space:]]*['\"]react(-dom)?['\"/]|(from|import|require)[[:space:]]*['\"].*/(app|component)/|(from|import|require)[[:space:]]*['\"]@/(app|component)"; then - echo "BLOCKED: connection/ cannot import React, app/, or component/. See CLAUDE.md architecture rules." >&2 + echo "BLOCKED: connection/ cannot import React, app/, or component/. See .claude/CLAUDE.md architecture rules." >&2 exit 2 fi fi diff --git a/.claude/hooks/check-migration-guard.sh b/.claude/hooks/check-migration-guard.sh index 1b9e88b5..f9daa40b 100755 --- a/.claude/hooks/check-migration-guard.sh +++ b/.claude/hooks/check-migration-guard.sh @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail # Hook: Prevent editing existing migration files (forward-only migrations) -# Rule: "Forward-only. Idempotent." — CLAUDE.md +# Rule: "Forward-only. Idempotent." — .claude/CLAUDE.md INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -er '.tool_input.file_path // .tool_input.filePath // empty') || { echo "BLOCKED: invalid hook payload (missing/invalid tool_input.file_path)" >&2 diff --git a/.claude/skills/next/SKILL.md b/.claude/skills/next/SKILL.md index 8139b540..5dfc1824 100644 --- a/.claude/skills/next/SKILL.md +++ b/.claude/skills/next/SKILL.md @@ -44,7 +44,7 @@ PR base = same `$BASE` detected above (release/X.Y when active, else dev). ## Step 3 — Run /drill -Before implementing, run `/drill ` to gather requirements, edge cases, and acceptance criteria. This is mandatory per CLAUDE.md. +Before implementing, run `/drill ` to gather requirements, edge cases, and acceptance criteria. This is mandatory per .claude/CLAUDE.md. ## Step 4 — Read the issue and relevant docs @@ -53,7 +53,7 @@ Identify which package(s) are affected: app/, component/, connection/. ## Step 5 — Implement -Follow all CLAUDE.md rules. Respect package boundaries. +Follow all .claude/CLAUDE.md rules. Respect package boundaries. If building UI, check existing components first (`find component/src -name '*.tsx'`). ## Step 6 — Test and lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 405aa057..89b7863c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ Chart-experience release: chart authoring, editing, rule-based styling and click ### Fixed -- Saving a dashboard on a window narrower than ~1500px permanently squashed its layout toward a single column. One layout is stored per page, but the grid was handed that layout with four different column counts (lg:12, md:10, sm:6, xs:4); below `lg` it clamped every item into the narrower count and the drag handler persisted the clamped result as *the* layout. Each save ratcheted it further, irreversibly. The grid now scales instead of reflowing (#1375) +- Connection reassignment was invisible to the optimistic lock: the update wrote `layoutJson` but never bumped `version`/`updatedAt`, so a browser with the editor open still held a matching version and its next save silently **reverted** the reassignment (#1376) +- The import's "N widgets imported without a connection" count included markdown and iframe widgets, which are exported with an empty `connectionId` by design — so a correctly mapped import containing three text widgets reported three unassigned widgets (#1377) +- Saving a dashboard on a window narrower than ~1500px permanently squashed its layout toward a single column. One layout is stored per page, but the grid was handed that layout with four different column counts (lg:12, md:10, sm:6, xs:4); below `lg` it clamped every item into the narrower count and the drag handler persisted the clamped result as _the_ layout. Each save ratcheted it further, irreversibly. The grid now scales instead of reflowing (#1375) - Every dialog animated in from the bottom-right. `zoom-in-95` alone leaves tailwindcss-animate's `from`-only keyframe at `translate3d(0,0,0)`, which overrides the `translate(-50%,-50%)` centring, so the box interpolated from a corner. Measured at **212.5px** off-centre at the first frame (584px for a full-size dialog); now invariant to float rounding. This is the third report of the same drift (#1373, after #1155 and `d723a127`) - Graph widgets re-ran their force layout and reshuffled on every scroll. Off-screen graphs were unmounted unconditionally to cap WebGL contexts, even with no context pressure — the Chart Reference graph page holds 8, half the browser ceiling. Unmounting is now gated on a live-graph budget (#1367) - ⌘E no longer remounts the whole dashboard. View and edit were separate routes, so toggling tore down and re-queried every widget and lost the scroll position; the UI is hoisted into a shared layout segment, so nothing unmounts and both URLs still work (#1370) @@ -26,6 +28,9 @@ Chart-experience release: chart authoring, editing, rule-based styling and click ### Added +- Bulk connector reassignment for a single dashboard, from the **Dashboard options** menu. Reassignment previously existed only as a connection-scoped operation that rewrote _every_ dashboard the caller could edit, reachable only from inside the delete-connection dialog (#1376) +- After an import that skipped a connection, the unassigned widgets can be fixed in one action instead of one widget editor at a time (#1377) + - A maximize toggle on the widget editor's query editor, doubling the editing width and giving 2.64× the height on open. Note the modal caps its body at `calc(90vh - 180px)`, so for an already-long query the gain is width plus moving the scroll into the editor rather than more height (#1374) ## [1.4.0] — 2026-07-29 — Audit, observability & correctness @@ -60,14 +65,14 @@ An internal documentation audit cross-checked the repo's own claims against its ### Fixed - Pie "Top N Slices" kept the first N rows in query order instead of the N largest. Unless you had also enabled the separate "Sort Slices by Value" toggle — which defaults to off — the chart silently collapsed the largest slice in your dataset into "Other" and rendered cleanly. This is a wrong answer a user would have believed (#1287) -- Neo4j integers above 2^53 failed `JSON.stringify` and returned an opaque `500 Query execution failed` for the *entire* query. A single snowflake ID, epoch-nanosecond value, checksum or large `id()` lost the whole result set, with no indication which column caused it — and the same query worked in Neo4j Browser (#1304) +- Neo4j integers above 2^53 failed `JSON.stringify` and returned an opaque `500 Query execution failed` for the _entire_ query. A single snowflake ID, epoch-nanosecond value, checksum or large `id()` lost the whole result set, with no indication which column caused it — and the same query worked in Neo4j Browser (#1304) - Neo4j temporal values are now formatted with the driver's own lossless `toString()` instead of three hand-rolled branches. `LocalTime` rendered nanoseconds as if they were milliseconds — a 10^6 error affecting roughly one in ten times — `DateTime` dropped its timezone offset and all sub-second precision, and the unpadded `h:m:s` output broke lexicographic sort in table widgets. All three failed silently, showing a plausible wrong value (#1306) - A single non-finite coordinate made Leaflet throw and replaced an entire map widget — 1,000 valid markers included — with "Chart failed to render". Non-finite markers are now dropped (#1288) - The markdown widget's inline-emphasis passes ran over already-generated HTML and spliced `` tags into `href`, `src` and `target` attributes; headings also carried no size class, so every heading rendered at 14px (#1290) - Clearing a numeric parameter input wrote `0` instead of leaving the field blank, because `Number("")` is `0`, not `NaN`, so the `isNaN(Number(raw))` guard never fired for the one input that reaches it (#1292) - Documentation accuracy pass. The docs site documented seven environment variables that exist nowhere in the code, invented CLI output, and made incorrect query-safety claims; `API_KEY_HMAC_SECRET` was missing from the Required Variables table, so an operator following the configuration page verbatim hit a boot failure (#1316). `CLAUDE.md` and `ARCHITECTURE.md` were corrected where they asserted behaviour the code does not have — a nonexistent `chart-registry` module, a nonexistent `--skip-migrations` flag, ORM-level tenant enforcement, and stale component counts (#1235, #1355) - Operator runbooks corrected. The documented restore recipes failed against a non-empty database (no `--clean`, and an auto-migrating app that created the schema before `pg_restore` ran) and never warned that a backup taken before a key rotation becomes permanently undecryptable; the real migration escape hatch is `MIGRATE_ON_START=0` and appeared in no operator doc; and the key-rotation procedure told operators to send a session cookie as an `Authorization: Bearer` header, which returns 401 (#1219, #1222, #1277) -- `bash install.sh` — the README quick start — failed on every fresh clone, because `npm install --prefix` means the *global* install location to npm and broke the postinstall `npm link` (#1309) +- `bash install.sh` — the README quick start — failed on every fresh clone, because `npm install --prefix` means the _global_ install location to npm and broke the postinstall `npm link` (#1309) - The published `@neoboard/cli` could not run outside the monorepo: project-root detection threw, and the compose files were not packaged (#1315) - `neoboard config set ports.app 4000` was honoured by doctor, the readiness probes, the generated `DATABASE_URL` and the banner URLs — but not by the actual port bindings, which stayed hardcoded (#1313) - A missing `API_KEY_HMAC_SECRET` now fails fast with the generate command instead of crash-looping, and it is listed in the compose files, the Dockerfile, and both "Required env vars" headers (#1221) @@ -80,7 +85,7 @@ An internal documentation audit cross-checked the repo's own claims against its - The connection-module cache key omitted both the password and any tenant discriminator, so two connection rows in different tenants naming the same host, username and database collapsed onto one cached pool. A caller who knew the URI and username but not the password received a working, already-authenticated pool belonging to whoever cached it first. The key now includes the password and tenant (#1300) - The login form's submit is gated on React hydration. Before hydration a click ran the browser's native form submit — a GET that put the email and plaintext password in the URL, and therefore in browser history and any access log (#1321) - Any authenticated user could permanently wedge a connector's query scheduler by submitting a whitespace-only query: `runQuery` returned without invoking a terminal callback, so the slot never freed. After `maxConcurrent` such requests every subsequent query on that connection failed with 408/503 until the process restarted. The shared SDK helper now always settles the caller (#1301) -- Write queries buffered the entire result set into the Node heap before the row limit was applied, so `MAX_ROWS+1` bounded only what was *displayed*. One Form submit against a large table could exhaust the heap shared by every tenant on the process. Both connectors now drain writes through a cursor (#1298, #1326) +- Write queries buffered the entire result set into the Node heap before the row limit was applied, so `MAX_ROWS+1` bounded only what was _displayed_. One Form submit against a large table could exhaust the heap shared by every tenant on the process. Both connectors now drain writes through a cursor (#1298, #1326) - Secrets are redacted at the pino logging boundary by default, at three install points covering the merged record, the message string and printf args, and the bare-Error path. Previously the always-on redaction covered five key names at one nesting level, and the URI-aware scrubber ran only under `LOG_ANONYMIZE=true`, which defaults to false. The audit found **no active credential leak** — all 33 `console.*` sites were reviewed and the connection package was already hardened — so this is defense in depth against a future call site, not a fix for a known exposure. Note that audit middleware still logs user-supplied query text verbatim; a secret embedded in a query literal reaches structured logs (#1227) - `neoboard doctor`'s credential probe reported "no stored credentials yet" on every local-mode install because its SQL never survived the shell, silently disabling the check entirely. A check that reports "nothing to verify" when it is broken is worse than no check (#1352) diff --git a/app/src/__tests__/test-environment-boundary.test.ts b/app/src/__tests__/test-environment-boundary.test.ts index 2e38b874..b33df707 100644 --- a/app/src/__tests__/test-environment-boundary.test.ts +++ b/app/src/__tests__/test-environment-boundary.test.ts @@ -24,7 +24,7 @@ * test is here so the next one fails fast and legibly instead. * * If this fails: rename your file to `.test.tsx` and drop the docblock. Do not - * add an allowlist entry — the `unit` project has no DOM by design (CLAUDE.md, + * add an allowlist entry — the `unit` project has no DOM by design (.claude/CLAUDE.md, * "Testing Boundaries"). */ import { describe, it, expect } from "vitest"; diff --git a/app/src/lib/__tests__/docs-accuracy.test.ts b/app/src/lib/__tests__/docs-accuracy.test.ts index 33c3e6b1..57e758e4 100644 --- a/app/src/lib/__tests__/docs-accuracy.test.ts +++ b/app/src/lib/__tests__/docs-accuracy.test.ts @@ -6,11 +6,11 @@ import { fileURLToPath } from "node:url"; /** * Guards the repo's own documentation against drift (#1235). * - * CLAUDE.md is loaded as ground truth by agent sessions, so a stale path or + * .claude/CLAUDE.md is loaded as ground truth by agent sessions, so a stale path or * count there becomes a wrong assumption in generated code. These tests fail * loudly instead. * - * Scope: the repo's OWN docs (CLAUDE.md, ARCHITECTURE.md, .claude/skills). + * Scope: the repo's OWN docs (.claude/CLAUDE.md, ARCHITECTURE.md, .claude/skills). * The published site under docs/src has its own guard — * scripts/__tests__/docs-accuracy.test.mjs — with the same name and a * different target. Add site checks there, repo checks here. @@ -53,7 +53,7 @@ const countFiles = (dir: string, ext = ".tsx") => readdirSync(resolve(REPO_ROOT, dir)).filter((f) => f.endsWith(ext)).length; describe("documentation accuracy", () => { - describe.each(["CLAUDE.md", "ARCHITECTURE.md"])("%s", (docName) => { + describe.each([".claude/CLAUDE.md", "ARCHITECTURE.md"])("%s", (docName) => { it("references only file paths that exist", () => { const missing = referencedPaths(readDoc(docName)).filter( (p) => !existsSync(resolve(REPO_ROOT, p)), @@ -92,17 +92,17 @@ describe("documentation accuracy", () => { }); }); - it("CLAUDE.md documents MIGRATE_ON_START, not a --skip-migrations flag", () => { + it(".claude/CLAUDE.md documents MIGRATE_ON_START, not a --skip-migrations flag", () => { // Naming the flag to debunk it is fine (readers search for it); asserting // it exists is not. The real escape hatch is MIGRATE_ON_START=0 (#1222). - const doc = readDoc("CLAUDE.md"); + const doc = readDoc(".claude/CLAUDE.md"); expect(doc).toContain("MIGRATE_ON_START"); // Assert the canonical debunk is present rather than blocklisting one // phrasing — "use `--skip-migrations`" would slip past a negative regex. expect(doc).toContain("there is no `--skip-migrations` CLI flag"); }); - it("CLAUDE.md points at the tenant guard by path, and that path exists", () => { + it(".claude/CLAUDE.md points at the tenant guard by path, and that path exists", () => { // The section used to say a forgotten tenant filter is "a leak that // nothing catches. Adding a guard is tracked in #1226." The guard shipped // (#1351), so that was false in a direction that changes behaviour: an @@ -111,7 +111,7 @@ describe("documentation accuracy", () => { // // Pinned by PATH rather than by phrasing, so a rewrite that drops the // pointer fails while a rewrite that keeps it is free to reword. - const doc = readDoc("CLAUDE.md"); + const doc = readDoc(".claude/CLAUDE.md"); const guardPath = "app/src/lib/db/__tests__/tenant-scope.test.ts"; expect(doc).toContain(guardPath); expect(existsSync(resolve(REPO_ROOT, guardPath))).toBe(true); @@ -131,7 +131,7 @@ describe("documentation accuracy", () => { }); it("the deploy skill does not send auditors looking for a flag that does not exist", () => { - // CLAUDE.md was corrected but the deploy skill still listed + // .claude/CLAUDE.md was corrected but the deploy skill still listed // "`--skip-migrations` flag missing or undocumented" as a gap to capture // — so the audit that produced #1222 was instructed to hunt a flag that // was never implemented. A prompt is documentation too (#1222). diff --git a/app/src/lib/db/__tests__/tenant-scope.test.ts b/app/src/lib/db/__tests__/tenant-scope.test.ts index e50dea8b..cfc0872b 100644 --- a/app/src/lib/db/__tests__/tenant-scope.test.ts +++ b/app/src/lib/db/__tests__/tenant-scope.test.ts @@ -4,7 +4,7 @@ * `tenant_id` is on every application table, but nothing enforces that a * query filters by it: `app/src/lib/db/index.ts` is a plain Drizzle client * with no middleware, and there is no Postgres RLS. The rule ("every query - * carries `eq(table.tenantId, session.tenantId)`") lives only in CLAUDE.md, + * carries `eq(table.tenantId, session.tenantId)`") lives only in .claude/CLAUDE.md, * so a handler that forgets it leaks across tenants and nothing fails. * * This test is that missing failure. It parses every file under `app/src`, diff --git a/package.json b/package.json index 58e3a042..7120836d 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "verify": "npm run typecheck && npm run lint && npm run test && npm run test:scripts", "typecheck": "npm -w component exec tsc -- --noEmit && npm -w app exec tsc -- --noEmit", "sonar:local": "node scripts/sonar-local.mjs", - "review:local": "coderabbit review --base release/1.5 --committed -c CLAUDE.md -c .coderabbit.yaml", + "review:local": "coderabbit review --base release/1.5 --committed -c .claude/CLAUDE.md -c .coderabbit.yaml", "test:scripts": "vitest run scripts/__tests__ --exclude '**/.claude/**' && node --test 'scripts/__tests__/*.node-test.mjs'" }, "lint-staged": {