Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
4 changes: 2 additions & 2 deletions .claude/agents/project-architect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,7 +36,7 @@ You may receive:

1. If given an issue number, fetch it: `gh issue view <number>`
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
Expand Down
6 changes: 3 additions & 3 deletions .claude/hooks/check-boundaries.sh
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -13,15 +13,15 @@ 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

# 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
Expand Down
2 changes: 1 addition & 1 deletion .claude/hooks/check-migration-guard.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/next/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <number>` to gather requirements, edge cases, and acceptance criteria. This is mandatory per CLAUDE.md.
Before implementing, run `/drill <number>` to gather requirements, edge cases, and acceptance criteria. This is mandatory per .claude/CLAUDE.md.

## Step 4 — Read the issue and relevant docs

Expand All @@ -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
Expand Down
13 changes: 9 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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 `<em>` 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)
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the MAX_ROWS+1 wording.

Write cursors drain results to completion and retain only up to the row limit; this bounds memory, not write execution. The changelog should not claim that MAX_ROWS+1 bounds execution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 88, Update the changelog entry to state that write
cursors drain results to completion while retaining at most MAX_ROWS+1 rows,
clarifying that the limit bounds retained memory or displayed results rather
than write execution. Remove the claim that MAX_ROWS+1 bounded only what was
displayed if it conflicts with this behavior.

- 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)

Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/test-environment-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
16 changes: 8 additions & 8 deletions app/src/lib/__tests__/docs-accuracy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion app/src/lib/db/__tests__/tenant-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading