3 Commits

Author SHA1 Message Date
Hartmut 29235e3208 rename(phase 1): cover .sh files missed by initial codemod
CI / Architecture Guardrails (pull_request) Successful in 2m21s
CI / Typecheck (pull_request) Successful in 2m50s
CI / Assistant Split Regression (pull_request) Successful in 4m35s
CI / Lint (pull_request) Successful in 4m46s
CI / Build (pull_request) Successful in 6m41s
CI / Unit Tests (pull_request) Successful in 7m59s
CI / E2E Tests (pull_request) Successful in 5m48s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m25s
CI / Release Images (pull_request) Has been skipped
The Phase 1 codemod only scanned .ts/.tsx/.js/.mjs/.cjs/.json, so two
shell scripts that reference workspace packages stayed pointing at the
old `@capakraken/*` names. The dev container's entrypoint then printed
"No projects matched the filters in /app" on every pnpm --filter call,
the app never bound to port 3100, and Fresh-Linux Docker Deploy red on
run 154.

- tooling/docker/app-dev-start.sh: pnpm --filter @capakraken/{db,web}
  → @nexus/{db,web} (5 occurrences); /tmp/capakraken-dev-home → /tmp/
  nexus-dev-home
- scripts/stop.sh: /tmp/capakraken-dev.pid → /tmp/nexus-dev.pid

Deferred to Phase 3 (these reference live infrastructure, not package
names): scripts/harden-postgres.sh DB_USER/DB_NAME defaults, restart.sh
volume names capakraken_node_modules/_next, scripts/start.sh
pg_isready -U capakraken -d capakraken.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 16:11:16 +02:00
Hartmut 2c2f4417c6 fix(ci): clear PR #61 lint error + bump fast-uri/next over high-sev advisories
CI / Architecture Guardrails (pull_request) Successful in 2m44s
CI / Assistant Split Regression (pull_request) Successful in 4m29s
CI / Lint (pull_request) Successful in 4m59s
CI / Typecheck (pull_request) Successful in 5m9s
CI / Unit Tests (pull_request) Successful in 6m24s
CI / Build (pull_request) Successful in 4m37s
CI / E2E Tests (pull_request) Successful in 5m35s
CI / Fresh-Linux Docker Deploy (pull_request) Failing after 7m29s
CI / Release Images (pull_request) Has been skipped
CI on PR #61 surfaced three issues. Two are real and fixed here; the
third was an act-runner flake (actions/setup-node container cleanup
race) that resolves on retrigger.

1. Lint error in apps/web/src/components/allocations/AllocationModal.tsx
   The `// eslint-disable-next-line @typescript-eslint/no-explicit-any`
   sat one line above the `as any` cast, so it suppressed nothing and
   eslint flagged it as an unused directive. Moved the comment to the
   line immediately above the cast.

2. pnpm audit --audit-level=high reported 9 high-severity findings,
   all transitive through two packages:
   - fast-uri <=3.1.1 (GHSA-q3j6-qgpj-74h6, host confusion via
     percent-encoded authority delimiters) — pinned to >=3.1.2 via
     pnpm.overrides since it's only reachable through @sentry/webpack-
     plugin > webpack > schema-utils > ajv > fast-uri
   - next 15.5.15 — bumped to ^15.5.16 (patched range starts here)

Quality gates green: typecheck (7/7), test:unit (7/7), lint (0 errors).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:28:52 +02:00
Hartmut 4a5edeef3e rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
  1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
  manifest, mobile header, MFA backup-codes header, tooltips, signin
  page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
  re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
  capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
  sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
  caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
  seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
  pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
  tooling/deploy/.env.production.example brand sweep

Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml

Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:10:44 +02:00
54 changed files with 246 additions and 610 deletions
-1
View File
@@ -1 +0,0 @@
{"sessionId":"aed37e34-4be8-4788-b03a-7145d9b4b2ce","pid":3544538,"procStart":"34480817","acquiredAt":1779373227101}
+2 -2
View File
@@ -32,7 +32,7 @@ POSTGRES_PASSWORD=
# host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app # host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app
# container this variable is overridden by docker-compose.yml (which routes # container this variable is overridden by docker-compose.yml (which routes
# to the postgres service name on the internal network). # to the postgres service name on the internal network).
DATABASE_URL=postgresql://nexus:nexus_dev@localhost:5433/nexus DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken
# ─── Redis ─────────────────────────────────────────────────────────────────── # ─── Redis ───────────────────────────────────────────────────────────────────
@@ -104,7 +104,7 @@ PGADMIN_PASSWORD=
# that any resolved path remains inside this directory; this prevents an # that any resolved path remains inside this directory; this prevents an
# admin (or compromised admin token) from pointing the parser at arbitrary # admin (or compromised admin token) from pointing the parser at arbitrary
# files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset. # files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset.
# DISPO_IMPORT_DIR=/var/lib/nexus/imports # DISPO_IMPORT_DIR=/var/lib/capakraken/imports
# ─── Testing (never enable in production) ──────────────────────────────────── # ─── Testing (never enable in production) ────────────────────────────────────
+26 -90
View File
@@ -43,14 +43,6 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -85,14 +77,6 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -123,14 +107,6 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -156,14 +132,6 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -191,11 +159,11 @@ jobs:
postgres: postgres:
image: postgres:16 image: postgres:16
env: env:
POSTGRES_DB: nexus_test POSTGRES_DB: capakraken_test
POSTGRES_USER: nexus POSTGRES_USER: capakraken
POSTGRES_PASSWORD: nexus_test POSTGRES_PASSWORD: capakraken_test
options: >- options: >-
--health-cmd="pg_isready -U nexus -d nexus_test" --health-cmd="pg_isready -U capakraken -d capakraken_test"
--health-interval=10s --health-interval=10s
--health-timeout=5s --health-timeout=5s
--health-retries=5 --health-retries=5
@@ -207,7 +175,7 @@ jobs:
--health-timeout=5s --health-timeout=5s
--health-retries=5 --health-retries=5
env: env:
DATABASE_URL: postgresql://nexus:nexus_test@postgres:5432/nexus_test DATABASE_URL: postgresql://capakraken:capakraken_test@postgres:5432/capakraken_test
REDIS_URL: redis://redis:6379 REDIS_URL: redis://redis:6379
# Force in-memory rate limiter to avoid cross-test state when Redis drops. # Force in-memory rate limiter to avoid cross-test state when Redis drops.
# Redis fallback downgrades to max/10 limits which rate-limits unit tests. # Redis fallback downgrades to max/10 limits which rate-limits unit tests.
@@ -228,14 +196,6 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -291,14 +251,6 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -339,11 +291,11 @@ jobs:
e2epg: e2epg:
image: postgres:16 image: postgres:16
env: env:
POSTGRES_DB: nexus_test POSTGRES_DB: capakraken_test
POSTGRES_USER: nexus POSTGRES_USER: capakraken
POSTGRES_PASSWORD: nexus_test POSTGRES_PASSWORD: capakraken_test
options: >- options: >-
--health-cmd="pg_isready -U nexus -d nexus_test" --health-cmd="pg_isready -U capakraken -d capakraken_test"
--health-interval=10s --health-interval=10s
--health-timeout=5s --health-timeout=5s
--health-retries=5 --health-retries=5
@@ -355,14 +307,14 @@ jobs:
--health-timeout=5s --health-timeout=5s
--health-retries=5 --health-retries=5
env: env:
DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
# Playwright test-server.mjs requires an explicit test DB URL. # Playwright test-server.mjs requires an explicit test DB URL.
PLAYWRIGHT_DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test PLAYWRIGHT_DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
# prisma-with-env.mjs refuses to run unless DATABASE_URL's db name matches # prisma-with-env.mjs refuses to run unless DATABASE_URL's db name matches
# the expected target; default is "nexus", CI uses nexus_test. # the expected target; default is "capakraken", CI uses capakraken_test.
NEXUS_EXPECTED_DB_NAME: nexus_test CAPAKRAKEN_EXPECTED_DB_NAME: capakraken_test
ALLOW_DESTRUCTIVE_DB_TOOLS: "true" ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
CONFIRM_DESTRUCTIVE_DB_NAME: nexus_test CONFIRM_DESTRUCTIVE_DB_NAME: capakraken_test
REDIS_URL: redis://e2eredis:6379 REDIS_URL: redis://e2eredis:6379
PORT: 3100 PORT: 3100
# test-server.mjs spawns `docker compose --profile test up postgres-test`; # test-server.mjs spawns `docker compose --profile test up postgres-test`;
@@ -395,14 +347,6 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
- name: Cache pnpm store
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -431,7 +375,7 @@ jobs:
- name: Push DB schema & seed - name: Push DB schema & seed
env: env:
PGPASSWORD: nexus_test PGPASSWORD: capakraken_test
run: | run: |
# Nuke any leftover schema state from a previous job that shared the # Nuke any leftover schema state from a previous job that shared the
# postgres service container (act_runner reuses service volumes). # postgres service container (act_runner reuses service volumes).
@@ -453,7 +397,7 @@ jobs:
IPS=$(getent hosts e2epg | awk '{print $1}') IPS=$(getent hosts e2epg | awk '{print $1}')
PG_IP="" PG_IP=""
for ip in $IPS; do for ip in $IPS; do
if PGPASSWORD=nexus_test psql -h "$ip" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then if PGPASSWORD=capakraken_test psql -h "$ip" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
PG_IP="$ip" PG_IP="$ip"
echo "Locked onto postgres at $PG_IP" echo "Locked onto postgres at $PG_IP"
break break
@@ -462,19 +406,19 @@ jobs:
fi fi
done done
if [ -z "$PG_IP" ]; then if [ -z "$PG_IP" ]; then
echo "ERROR: no resolved e2epg IP accepted nexus_test credentials" echo "ERROR: no resolved e2epg IP accepted capakraken_test credentials"
exit 1 exit 1
fi fi
PINNED_URL="postgresql://nexus:nexus_test@$PG_IP:5432/nexus_test" PINNED_URL="postgresql://capakraken:capakraken_test@$PG_IP:5432/capakraken_test"
echo "DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV" echo "DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV" echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
echo "--- DROP SCHEMA ---" echo "--- DROP SCHEMA ---"
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 \ psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 \
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO nexus; GRANT ALL ON SCHEMA public TO public;" -c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO capakraken; GRANT ALL ON SCHEMA public TO public;"
echo "--- prisma db push ---" echo "--- prisma db push ---"
DATABASE_URL="$PINNED_URL" pnpm --filter @nexus/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate DATABASE_URL="$PINNED_URL" pnpm --filter @nexus/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
echo "--- tables in public after push ---" echo "--- tables in public after push ---"
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -At \ psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -At \
-c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \ -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
| tee /tmp/tables.txt | tee /tmp/tables.txt
if ! grep -qx 'audit_logs' /tmp/tables.txt; then if ! grep -qx 'audit_logs' /tmp/tables.txt; then
@@ -524,8 +468,8 @@ jobs:
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
PGADMIN_PASSWORD=ci-pgadmin PGADMIN_PASSWORD=ci-pgadmin
# Must match the password baked into docker-compose.ci.yml's # Must match the password baked into docker-compose.ci.yml's
# DATABASE_URL override (nexus_dev). # DATABASE_URL override (capakraken_dev).
POSTGRES_PASSWORD=nexus_dev POSTGRES_PASSWORD=capakraken_dev
EOF EOF
- name: Tear down any stale stack & volumes - name: Tear down any stale stack & volumes
@@ -533,11 +477,7 @@ jobs:
# runs. A previous run's failed migration entry in _prisma_migrations # runs. A previous run's failed migration entry in _prisma_migrations
# causes P3009 on the next migrate deploy; wipe volumes for a truly # causes P3009 on the next migrate deploy; wipe volumes for a truly
# fresh deploy test every time. # fresh deploy test every time.
# Also tear down the legacy "capakraken" project (pre-Phase-3 rename) run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
# in case old containers are still holding host ports 5433/6380.
run: |
docker compose -p capakraken --profile full -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
docker compose --profile full -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
- name: Start infrastructure (postgres + redis) - name: Start infrastructure (postgres + redis)
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d postgres redis run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d postgres redis
@@ -545,14 +485,10 @@ jobs:
- name: Wait for postgres - name: Wait for postgres
run: | run: |
for i in $(seq 1 20); do for i in $(seq 1 20); do
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U nexus -d nexus && break docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U capakraken -d capakraken && break
sleep 3 sleep 3
done done
- name: Pre-pull Docker base image
run: docker pull node:20-bookworm-slim
continue-on-error: true
- name: Build and start app (full profile) - name: Build and start app (full profile)
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile full up -d --build app run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile full up -d --build app
@@ -569,7 +505,7 @@ jobs:
# the act_runner job can reach). No DNS, no guessing. # the act_runner job can reach). No DNS, no guessing.
run: | run: |
set -e set -e
for i in $(seq 1 60); do for i in $(seq 1 36); do
CID=$(docker compose -f docker-compose.yml -f docker-compose.ci.yml ps -q app || true) CID=$(docker compose -f docker-compose.yml -f docker-compose.ci.yml ps -q app || true)
if [ -n "$CID" ]; then if [ -n "$CID" ]; then
APP_IP=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{if eq $k "gitea_gitea"}}{{$v.IPAddress}}{{end}}{{end}}' "$CID") APP_IP=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{if eq $k "gitea_gitea"}}{{$v.IPAddress}}{{end}}{{end}}' "$CID")
+1 -1
View File
@@ -101,7 +101,7 @@ test.describe("Assistant approvals", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.addInitScript((conversationId) => { await page.addInitScript((conversationId) => {
window.sessionStorage.setItem("nexus-chat-conversation-id", conversationId); window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId);
}, CURRENT_CONVERSATION_ID); }, CURRENT_CONVERSATION_ID);
runDb(` runDb(`
+4 -4
View File
@@ -42,9 +42,9 @@ test.describe("Auth — login / logout", () => {
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 }); await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
// Error message visible // Error message visible
await expect(page.locator("text=/invalid|incorrect|wrong|credentials/i")).toBeVisible({ await expect(
timeout: 5000, page.locator("text=/invalid|incorrect|wrong|credentials/i"),
}); ).toBeVisible({ timeout: 5000 });
}); });
test("after logout, protected routes redirect to sign-in", async ({ page }) => { test("after logout, protected routes redirect to sign-in", async ({ page }) => {
@@ -75,7 +75,7 @@ test.describe("Session registry — no tRPC 401s after login", () => {
// At least one user row should be visible // At least one user row should be visible
await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({ await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
timeout: 10000, timeout: 10000,
}); });
await expect(page.locator("text=No users found")).toHaveCount(0); await expect(page.locator("text=No users found")).toHaveCount(0);
@@ -12,7 +12,7 @@
* - Creates a temporary test user via tRPC (admin session) for isolation. * - Creates a temporary test user via tRPC (admin session) for isolation.
* - Cleans up the test user in afterAll. * - Cleans up the test user in afterAll.
* - Uses an empty storageState to ensure no cross-user localStorage bleed. * - Uses an empty storageState to ensure no cross-user localStorage bleed.
* - localStorage key is user-scoped: "nexus_dashboard_v1_{userId}". * - localStorage key is user-scoped: "capakraken_dashboard_v1_{userId}".
*/ */
import { expect, test, type Browser, type Page } from "@playwright/test"; import { expect, test, type Browser, type Page } from "@playwright/test";
@@ -20,16 +20,9 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
// ─── tRPC helpers ───────────────────────────────────────────────────────────── // ─── tRPC helpers ─────────────────────────────────────────────────────────────
type TrpcResult = { type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
result?: { data?: unknown };
error?: { data?: { code?: string }; message?: string };
};
async function trpcMutation( async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
page: Page,
procedure: string,
input: unknown = null,
): Promise<TrpcResult> {
return page.evaluate( return page.evaluate(
async ({ procedure, input }) => { async ({ procedure, input }) => {
const res = await fetch(`/api/trpc/${procedure}?batch=1`, { const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
@@ -45,11 +38,7 @@ async function trpcMutation(
); );
} }
async function trpcQuery( async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
page: Page,
procedure: string,
input: unknown = null,
): Promise<TrpcResult> {
return page.evaluate( return page.evaluate(
async ({ procedure, input }) => { async ({ procedure, input }) => {
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } })); const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
@@ -139,9 +128,7 @@ test.describe("Dashboard — widget management", () => {
// Default layout should show at least the stat-cards widget // Default layout should show at least the stat-cards widget
// (from createDefaultDashboardLayout in useDashboardLayout) // (from createDefaultDashboardLayout in useDashboardLayout)
await expect( await expect(page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first()).toBeVisible({
page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first(),
).toBeVisible({
timeout: 8000, timeout: 8000,
}); });
}); });
@@ -151,21 +138,16 @@ test.describe("Dashboard — widget management", () => {
await navigateToDashboard(page); await navigateToDashboard(page);
// Open modal // Open modal
await page await page.getByRole("button", { name: /add widget/i }).first().click();
.getByRole("button", { name: /add widget/i })
.first()
.click();
// Verify modal is open // Verify modal is open
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({ await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({ timeout: 5000 });
timeout: 5000,
});
// Verify widget entries are visible in the modal // Verify widget entries are visible in the modal
// The catalog has 11 widgets; check for at least 5 visible buttons inside the modal // The catalog has 11 widgets; check for at least 5 visible buttons inside the modal
const widgetButtons = page const widgetButtons = page.locator(
.locator('[role="dialog"] button, .fixed button[type="button"]') '[role="dialog"] button, .fixed button[type="button"]',
.filter({ hasText: /./ }); ).filter({ hasText: /./ });
// Count items in the grid (the ×-close button is excluded by checking for icon content) // Count items in the grid (the ×-close button is excluded by checking for icon content)
const modalContent = page.locator(".fixed.inset-0 .grid"); const modalContent = page.locator(".fixed.inset-0 .grid");
@@ -184,16 +166,10 @@ test.describe("Dashboard — widget management", () => {
const initialCount = await page.locator(".react-grid-item").count(); const initialCount = await page.locator(".react-grid-item").count();
// Open modal and add "Resource Table" widget // Open modal and add "Resource Table" widget
await page await page.getByRole("button", { name: /add widget/i }).first().click();
.getByRole("button", { name: /add widget/i })
.first()
.click();
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 }); await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
await page await page.locator(".fixed.inset-0 button").filter({ hasText: /resource table/i }).click();
.locator(".fixed.inset-0 button")
.filter({ hasText: /resource table/i })
.click();
// Modal should close after adding // Modal should close after adding
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 }); await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
@@ -208,15 +184,9 @@ test.describe("Dashboard — widget management", () => {
await navigateToDashboard(page); await navigateToDashboard(page);
// Add a recognizable widget // Add a recognizable widget
await page await page.getByRole("button", { name: /add widget/i }).first().click();
.getByRole("button", { name: /add widget/i })
.first()
.click();
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 }); await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
await page await page.locator(".fixed.inset-0 button").filter({ hasText: /project overview/i }).click();
.locator(".fixed.inset-0 button")
.filter({ hasText: /project overview/i })
.click();
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 }); await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
const countAfterAdd = await page.locator(".react-grid-item").count(); const countAfterAdd = await page.locator(".react-grid-item").count();
@@ -244,23 +214,19 @@ test.describe("Dashboard — widget management", () => {
// Read the admin's localStorage key to verify it is user-scoped // Read the admin's localStorage key to verify it is user-scoped
const adminUserId = await adminPage.evaluate(async () => { const adminUserId = await adminPage.evaluate(async () => {
const res = await fetch( const res = await fetch("/api/trpc/user.me?batch=1&input=" + encodeURIComponent(JSON.stringify({ "0": { json: null } })), {
"/api/trpc/user.me?batch=1&input=" + credentials: "include",
encodeURIComponent(JSON.stringify({ "0": { json: null } })), });
{ const body = await res.json() as [{ result?: { data?: { json?: { id?: string } } } }];
credentials: "include",
},
);
const body = (await res.json()) as [{ result?: { data?: { json?: { id?: string } } } }];
return body[0]?.result?.data?.json?.id ?? null; return body[0]?.result?.data?.json?.id ?? null;
}); });
// Verify admin has a user-scoped storage key (not shared "nexus_dashboard_v1") // Verify admin has a user-scoped storage key (not shared "capakraken_dashboard_v1")
if (adminUserId) { if (adminUserId) {
const storageKey = await adminPage.evaluate((userId) => { const storageKey = await adminPage.evaluate((userId) => {
// Check both old (unscoped) and new (user-scoped) key formats // Check both old (unscoped) and new (user-scoped) key formats
const oldKey = "nexus_dashboard_v1"; const oldKey = "capakraken_dashboard_v1";
const newKey = `nexus_dashboard_v1_${userId}`; const newKey = `capakraken_dashboard_v1_${userId}`;
const oldValue = localStorage.getItem(oldKey); const oldValue = localStorage.getItem(oldKey);
const newValue = localStorage.getItem(newKey); const newValue = localStorage.getItem(newKey);
return { oldKey: oldValue !== null, newKey: newValue !== null }; return { oldKey: oldValue !== null, newKey: newValue !== null };
@@ -278,13 +244,8 @@ test.describe("Dashboard — widget management", () => {
// Inject the admin's storage key to simulate same browser // Inject the admin's storage key to simulate same browser
await newUserPage.evaluate( await newUserPage.evaluate(
({ key, value }) => { ({ key, value }) => { localStorage.setItem(key, value ?? ""); },
localStorage.setItem(key, value ?? ""); { key: `capakraken_dashboard_v1_${adminUserId}`, value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }) },
},
{
key: `nexus_dashboard_v1_${adminUserId}`,
value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }),
},
); );
// Log in as test user // Log in as test user
@@ -301,10 +262,7 @@ test.describe("Dashboard — widget management", () => {
const gridItems = await newUserPage.locator(".react-grid-item").count(); const gridItems = await newUserPage.locator(".react-grid-item").count();
// Either show default layout (≥1 widget) OR the properly-scoped empty state with Add Widget CTA // Either show default layout (≥1 widget) OR the properly-scoped empty state with Add Widget CTA
// The key check: the test user's Add Widget button should still work // The key check: the test user's Add Widget button should still work
await newUserPage await newUserPage.getByRole("button", { name: /add widget/i }).first().click();
.getByRole("button", { name: /add widget/i })
.first()
.click();
// Modal must show widgets to choose from // Modal must show widgets to choose from
const modalContent = newUserPage.locator(".fixed.inset-0 .grid"); const modalContent = newUserPage.locator(".fixed.inset-0 .grid");
+3 -3
View File
@@ -25,9 +25,9 @@ const RESET_TEST_USER = {
password: "Dev123456!", password: "Dev123456!",
}; };
const DB_CONTAINER = "nexus-postgres-1"; const DB_CONTAINER = "capakraken-postgres-1";
const DB_USER = "nexus"; const DB_USER = "capakraken";
const DB_NAME = "nexus"; const DB_NAME = "capakraken";
function psqlExec(sql: string): string { function psqlExec(sql: string): string {
return execSync( return execSync(
+12 -15
View File
@@ -26,7 +26,7 @@ export async function signOut(page: Page) {
await page.goto("/dashboard"); // land on any authenticated page for cookie context await page.goto("/dashboard"); // land on any authenticated page for cookie context
await page.evaluate(async () => { await page.evaluate(async () => {
const csrfRes = await fetch("/api/auth/csrf"); const csrfRes = await fetch("/api/auth/csrf");
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string }; const { csrfToken } = await csrfRes.json() as { csrfToken: string };
await fetch("/api/auth/signout", { await fetch("/api/auth/signout", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -62,9 +62,11 @@ function decodeMimeBody(body: string, encoding: string | undefined): string {
const enc = (encoding ?? "").toLowerCase().trim(); const enc = (encoding ?? "").toLowerCase().trim();
if (enc === "quoted-printable") { if (enc === "quoted-printable") {
return body return body
.replace(/=\r\n/g, "") // soft line break (CRLF) .replace(/=\r\n/g, "") // soft line break (CRLF)
.replace(/=\n/g, "") // soft line break (LF) .replace(/=\n/g, "") // soft line break (LF)
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16))); .replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) =>
String.fromCharCode(parseInt(hex, 16)),
);
} }
if (enc === "base64") { if (enc === "base64") {
return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8"); return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
@@ -88,10 +90,7 @@ export async function clearMailhog(): Promise<void> {
*/ */
export async function getLatestEmailTo( export async function getLatestEmailTo(
address: string, address: string,
{ { timeoutMs = 10_000, pollIntervalMs = 500 }: { timeoutMs?: number; pollIntervalMs?: number } = {},
timeoutMs = 10_000,
pollIntervalMs = 500,
}: { timeoutMs?: number; pollIntervalMs?: number } = {},
): Promise<{ subject: string; body: string; html: string }> { ): Promise<{ subject: string; body: string; html: string }> {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
@@ -145,9 +144,7 @@ export function extractUrlFromEmail(
pathPrefix: string, pathPrefix: string,
): string { ): string {
const text = email.html || email.body; const text = email.html || email.body;
const match = text.match( const match = text.match(new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`));
new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`),
);
if (!match?.[0]) { if (!match?.[0]) {
throw new Error(`No URL with prefix "${pathPrefix}" found in email`); throw new Error(`No URL with prefix "${pathPrefix}" found in email`);
} }
@@ -169,10 +166,10 @@ export async function resetPasswordViaApi(
// argon2id hashes use base64 chars only — safe inside a SQL single-quoted string // argon2id hashes use base64 chars only — safe inside a SQL single-quoted string
// Column name is camelCase (Prisma default) — must be double-quoted in SQL // Column name is camelCase (Prisma default) — must be double-quoted in SQL
const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`; const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`;
execSync(`docker exec -i nexus-postgres-1 psql -U nexus -d nexus`, { execSync(
input: sql, `docker exec -i capakraken-postgres-1 psql -U capakraken -d capakraken`,
encoding: "utf8", { input: sql, encoding: "utf8" },
}); );
} }
// ── tRPC helpers ─────────────────────────────────────────────────────────────── // ── tRPC helpers ───────────────────────────────────────────────────────────────
+3 -5
View File
@@ -27,7 +27,7 @@ test.describe("invite flow", () => {
}); });
test("admin invites a new user and invited user can sign in", async ({ page, browser }) => { test("admin invites a new user and invited user can sign in", async ({ page, browser }) => {
const testEmail = `invite-e2e-${Date.now()}@nexus.test`; const testEmail = `invite-e2e-${Date.now()}@capakraken.test`;
// Step 1: Navigate to admin users page // Step 1: Navigate to admin users page
await page.goto("/admin/users"); await page.goto("/admin/users");
@@ -36,7 +36,7 @@ test.describe("invite flow", () => {
// Step 2: Open invite modal // Step 2: Open invite modal
await page.click('button:has-text("Invite User")'); await page.click('button:has-text("Invite User")');
// Wait for the modal heading — AnimatedModal does not use role="dialog" // Wait for the modal heading — AnimatedModal does not use role="dialog"
await page.waitForSelector("text=Invite User", { state: "visible" }); await page.waitForSelector('text=Invite User', { state: "visible" });
// Step 3: Fill in invite form // Step 3: Fill in invite form
await page.fill('input[type="email"]', testEmail); await page.fill('input[type="email"]', testEmail);
@@ -45,9 +45,7 @@ test.describe("invite flow", () => {
await page.click('button:has-text("Send Invite")'); await page.click('button:has-text("Send Invite")');
// Step 5: Wait for success message (exact text from InviteUserModal.tsx) // Step 5: Wait for success message (exact text from InviteUserModal.tsx)
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({ await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({ timeout: 10_000 });
timeout: 10_000,
});
// Step 6: Read invite email from Mailhog // Step 6: Read invite email from Mailhog
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 }); const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 });
@@ -28,7 +28,7 @@ test.describe("RBAC — admin routes (admin session)", () => {
await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
// Seed users have planarchy.dev or nexus.dev email domains // Seed users have planarchy.dev or nexus.dev email domains
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({ await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
timeout: 10000, timeout: 10000,
}); });
}); });
+5 -5
View File
@@ -16,9 +16,9 @@ const webDistDirPath = resolve(webRoot, webDistDir);
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs"; const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110"; const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`; const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `nexus-e2e-${randomBytes(24).toString("hex")}`; const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`;
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true"; const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
const composeProjectName = `nexus-e2e-${process.pid}`; const composeProjectName = `capakraken-e2e-${process.pid}`;
const managedEnvKeys = [ const managedEnvKeys = [
"DATABASE_URL", "DATABASE_URL",
"REDIS_URL", "REDIS_URL",
@@ -29,7 +29,7 @@ const managedEnvKeys = [
"NODE_ENV", "NODE_ENV",
"PORT", "PORT",
]; ];
const e2eComposePrefix = "nexus-e2e-"; const e2eComposePrefix = "capakraken-e2e-";
function dockerComposeArgs(...args) { function dockerComposeArgs(...args) {
return ["compose", "-p", composeProjectName, ...args]; return ["compose", "-p", composeProjectName, ...args];
@@ -256,7 +256,7 @@ async function ensureE2eDatabaseContainer() {
try { try {
await runQuiet( await runQuiet(
"docker", "docker",
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "nexus", "-d", "nexus_test", "-q"), dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"),
workspaceRoot, workspaceRoot,
); );
return; return;
@@ -360,7 +360,7 @@ process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
if (selectedTestDbPort !== undefined) { if (selectedTestDbPort !== undefined) {
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort); process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
} }
process.env.NEXUS_EXPECTED_DB_NAME = playwrightDatabaseName; process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName;
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true"; process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName; process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
process.env.NODE_ENV = process.env.NODE_ENV ?? "development"; process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
+4 -4
View File
@@ -856,10 +856,10 @@ async function switchToResourceView(page: Page, readySelector?: string) {
async function ensureOpenDemandVisibilityEnabled(page: Page) { async function ensureOpenDemandVisibilityEnabled(page: Page) {
await page.evaluate(() => { await page.evaluate(() => {
const raw = window.localStorage.getItem("nexus_prefs"); const raw = window.localStorage.getItem("capakraken_prefs");
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {}; const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
window.localStorage.setItem( window.localStorage.setItem(
"nexus_prefs", "capakraken_prefs",
JSON.stringify({ JSON.stringify({
...parsed, ...parsed,
showDemandProjects: true, showDemandProjects: true,
@@ -874,9 +874,9 @@ test.describe("Timeline", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("nexus_theme", JSON.stringify({ mode: "dark" })); localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
localStorage.setItem( localStorage.setItem(
"nexus_prefs", "capakraken_prefs",
JSON.stringify({ JSON.stringify({
hideCompletedProjects: true, hideCompletedProjects: true,
timelineDisplayMode: "strip", timelineDisplayMode: "strip",
+1 -1
View File
@@ -24,6 +24,6 @@ export default defineConfig({
command: "node ./e2e/test-server.mjs", command: "node ./e2e/test-server.mjs",
url: e2eBaseUrl, url: e2eBaseUrl,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 300000, timeout: 180000,
}, },
}); });
@@ -82,7 +82,7 @@ describe("GET /api/cron/auth-anomaly-check — cron secret enforcement", () => {
const { GET } = await importRoute(); const { GET } = await importRoute();
const res = await GET(makeRequest()); const res = await GET(makeRequest());
expect(res.status).toBe(401); expect(res.status).toBe(401);
}, 15_000); // next/server cold-import can take >5s on the act runner });
it("proceeds when verifyCronSecret returns null (allowed)", async () => { it("proceeds when verifyCronSecret returns null (allowed)", async () => {
verifyCronSecretMock.mockReturnValue(null); verifyCronSecretMock.mockReturnValue(null);
+2 -2
View File
@@ -450,7 +450,7 @@ function SidebarContent({
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<div className="overflow-hidden"> <div className="overflow-hidden">
<h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50"> <h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50">
Nex<span className="text-brand-600">us</span> Capa<span className="text-brand-600">Kraken</span>
</h1> </h1>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400"> <p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Resource & Capacity Planning Resource & Capacity Planning
@@ -984,7 +984,7 @@ export function AppShell({
<HamburgerIcon /> <HamburgerIcon />
</button> </button>
<span className="ml-3 font-display text-sm font-semibold text-gray-900 dark:text-gray-50"> <span className="ml-3 font-display text-sm font-semibold text-gray-900 dark:text-gray-50">
Nex<span className="text-brand-600">us</span> Capa<span className="text-brand-600">Kraken</span>
</span> </span>
</div> </div>
<PageTransition>{children}</PageTransition> <PageTransition>{children}</PageTransition>
@@ -284,7 +284,7 @@ export function TimelineProvider({
const d = new Date(sp); const d = new Date(sp);
if (!isNaN(d.getTime())) return d; if (!isNaN(d.getTime())) return d;
} }
return addDays(today, -90); return addDays(today, -30);
}); });
const [viewDays, setViewDays] = useState(() => { const [viewDays, setViewDays] = useState(() => {
const sp = searchParams.get("days"); const sp = searchParams.get("days");
@@ -310,7 +310,7 @@ export function TimelineProvider({
const d = new Date(spStart); const d = new Date(spStart);
if (!isNaN(d.getTime())) return d; if (!isNaN(d.getTime())) return d;
} }
return addDays(today, -90); return addDays(today, -30);
}); });
const spDays = searchParams.get("days"); const spDays = searchParams.get("days");
@@ -2,7 +2,7 @@
import { clsx } from "clsx"; import { clsx } from "clsx";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js"; import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js"; import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js"; import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
@@ -685,70 +685,15 @@ function TimelineViewContent({
const scrollRafRef = useRef<number | null>(null); const scrollRafRef = useRef<number | null>(null);
const [scrollLeft, setScrollLeft] = useState(0); const [scrollLeft, setScrollLeft] = useState(0);
// Pixels to add to scrollLeft after a left-extension re-render (prevents jump).
const pendingLeftCompensationPx = useRef(0);
// Flag: scroll viewport to today after the next viewStart-driven re-layout.
const pendingScrollToTodayRef = useRef(false);
// Guard reset on every real unmount (including Strict Mode fake-unmount) so the
// scroll-to-today fires correctly on remount.
const hasScrolledToTodayOnLoad = useRef(false);
useLayoutEffect(() => {
return () => {
hasScrolledToTodayOnLoad.current = false;
};
}, []);
// Scroll to today the first time the canvas is in the DOM (isInitialLoading → false).
// totalCanvasWidth is non-zero before data loads, so it can't be used as the trigger.
useLayoutEffect(() => {
if (isInitialLoading) return;
if (hasScrolledToTodayOnLoad.current) return;
const el = scrollContainerRef.current;
if (!el) return;
el.scrollLeft = toLeft(today);
hasScrolledToTodayOnLoad.current = true;
}, [isInitialLoading, toLeft, today]);
// Apply scroll compensation synchronously after the canvas grows (left-extend or Today button).
useLayoutEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const px = pendingLeftCompensationPx.current;
if (px !== 0) {
el.scrollLeft += px;
pendingLeftCompensationPx.current = 0;
} else if (pendingScrollToTodayRef.current) {
el.scrollLeft = toLeft(today);
pendingScrollToTodayRef.current = false;
}
}, [viewStart, toLeft, today]);
// 5-year floor — no practical data exists further back; prevents runaway growth.
const minDate = useMemo(() => addDays(today, -(365 * 5)), [today]);
// ─── Navigation callbacks for TimelineToolbar ──────────────────────────── // ─── Navigation callbacks for TimelineToolbar ────────────────────────────
const handleNavigateBack = useCallback( const handleNavigateBack = useCallback(
() => () => setViewStart((v) => addDays(v, -28)),
setViewStart((v) => { [setViewStart],
const candidate = addDays(v, -28); );
return candidate < minDate ? minDate : candidate; const handleNavigateToday = useCallback(
}), () => setViewStart(addDays(today, -30)),
[setViewStart, minDate], [setViewStart, today],
); );
const handleNavigateToday = useCallback(() => {
const el = scrollContainerRef.current;
const todayMs = new Date(today).setHours(0, 0, 0, 0);
const vsMs = new Date(viewStart).setHours(0, 0, 0, 0);
const veMs = new Date(addDays(viewStart, viewDays)).setHours(0, 0, 0, 0);
if (todayMs >= vsMs && todayMs < veMs && el) {
// Today is in range — just scroll without touching state.
el.scrollLeft = toLeft(today);
} else {
// Today is out of range — reset the window and schedule a scroll.
pendingScrollToTodayRef.current = true;
setViewStart(addDays(today, -90));
}
}, [today, viewStart, viewDays, toLeft, setViewStart]);
const handleNavigateForward = useCallback( const handleNavigateForward = useCallback(
() => setViewStart((v) => addDays(v, 28)), () => setViewStart((v) => addDays(v, 28)),
[setViewStart], [setViewStart],
@@ -764,31 +709,10 @@ function TimelineViewContent({
const handleContainerScroll = useCallback(() => { const handleContainerScroll = useCallback(() => {
const el = scrollContainerRef.current; const el = scrollContainerRef.current;
if (!el) return; if (!el) return;
// Right-edge: extend future range
const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth; const distanceFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
if (distanceFromRight < CELL_WIDTH * 40) { if (distanceFromRight < CELL_WIDTH * 40) {
setViewDays((d) => d + 120); setViewDays((d) => d + 120);
} }
// Left-edge: prepend past range and compensate scroll position so viewport doesn't jump
if (el.scrollLeft < CELL_WIDTH * 40 && viewStart > minDate) {
const daysToPrepend = 120;
// Count the exact visible days (respecting showWeekends) being prepended
let prependedVisible = 0;
for (let i = 1; i <= daysToPrepend; i++) {
const d = addDays(viewStart, -i);
const dow = d.getDay();
if (filters.showWeekends || (dow !== 0 && dow !== 6)) prependedVisible++;
}
pendingLeftCompensationPx.current = prependedVisible * CELL_WIDTH;
setViewStart((v) => {
const candidate = addDays(v, -daysToPrepend);
return candidate < minDate ? minDate : candidate;
});
setViewDays((d) => d + daysToPrepend);
}
scrollLeftRef.current = el.scrollLeft; scrollLeftRef.current = el.scrollLeft;
if (scrollRafRef.current === null) { if (scrollRafRef.current === null) {
scrollRafRef.current = requestAnimationFrame(() => { scrollRafRef.current = requestAnimationFrame(() => {
@@ -796,7 +720,7 @@ function TimelineViewContent({
setScrollLeft(scrollLeftRef.current); setScrollLeft(scrollLeftRef.current);
}); });
} }
}, [CELL_WIDTH, setViewDays, viewStart, minDate, setViewStart, filters.showWeekends]); }, [CELL_WIDTH, setViewDays]);
// ─── Canvas mousemove — only forwards event when drag overlay is active ─── // ─── Canvas mousemove — only forwards event when drag overlay is active ───
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
+2 -2
View File
@@ -34,8 +34,8 @@ services:
# REDIS_URL to the unique compose container names so resolution is # REDIS_URL to the unique compose container names so resolution is
# unambiguous regardless of attached networks. # unambiguous regardless of attached networks.
environment: environment:
DATABASE_URL: postgresql://nexus:nexus_dev@nexus-postgres-1:5432/nexus DATABASE_URL: postgresql://capakraken:capakraken_dev@capakraken-postgres-1:5432/capakraken
REDIS_URL: redis://nexus-redis-1:6379 REDIS_URL: redis://capakraken-redis-1:6379
networks: networks:
gitea_gitea: gitea_gitea:
+12 -12
View File
@@ -1,4 +1,4 @@
name: nexus-prod name: capakraken-prod
services: services:
postgres: postgres:
@@ -7,8 +7,8 @@ services:
ports: ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432" - "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
environment: environment:
POSTGRES_DB: nexus POSTGRES_DB: capakraken
POSTGRES_USER: nexus POSTGRES_USER: capakraken
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
command: > command: >
postgres postgres
@@ -18,9 +18,9 @@ services:
-c log_line_prefix='%t [%p] %u@%d ' -c log_line_prefix='%t [%p] %u@%d '
-c log_min_duration_statement=1000 -c log_min_duration_statement=1000
volumes: volumes:
- nexus_prod_pgdata:/var/lib/postgresql/data - capakraken_prod_pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U nexus -d nexus"] test: ["CMD-SHELL", "pg_isready -U capakraken -d capakraken"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -34,7 +34,7 @@ services:
- "127.0.0.1:${REDIS_PORT:-6379}:6379" - "127.0.0.1:${REDIS_PORT:-6379}:6379"
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD} command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD}
volumes: volumes:
- nexus_prod_redis:/data - capakraken_prod_redis:/data
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "--no-auth-warning", "ping"] test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "--no-auth-warning", "ping"]
interval: 10s interval: 10s
@@ -49,7 +49,7 @@ services:
env_file: env_file:
- .env.production - .env.production
environment: environment:
DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/nexus DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis} RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
depends_on: depends_on:
@@ -67,7 +67,7 @@ services:
env_file: env_file:
- .env.production - .env.production
environment: environment:
DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/nexus DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis} RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-} NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
@@ -84,7 +84,7 @@ services:
start_period: 30s start_period: 30s
volumes: volumes:
nexus_prod_pgdata: capakraken_prod_pgdata:
name: nexus_prod_pgdata name: capakraken_prod_pgdata
nexus_prod_redis: capakraken_prod_redis:
name: nexus_prod_redis name: capakraken_prod_redis
+18 -18
View File
@@ -1,4 +1,4 @@
name: nexus name: capakraken
services: services:
postgres: postgres:
@@ -6,8 +6,8 @@ services:
ports: ports:
- "5433:5432" - "5433:5432"
environment: environment:
POSTGRES_DB: nexus POSTGRES_DB: capakraken
POSTGRES_USER: nexus POSTGRES_USER: capakraken
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env (any non-empty value for local dev)} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env (any non-empty value for local dev)}
command: > command: >
postgres postgres
@@ -17,9 +17,9 @@ services:
-c log_line_prefix='%t [%p] %u@%d ' -c log_line_prefix='%t [%p] %u@%d '
-c log_min_duration_statement=1000 -c log_min_duration_statement=1000
volumes: volumes:
- nexus_pgdata:/var/lib/postgresql/data - capakraken_pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U nexus -d nexus"] test: ["CMD-SHELL", "pg_isready -U capakraken -d capakraken"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
@@ -61,7 +61,7 @@ services:
# Always use the Docker-internal service name. The host-level DATABASE_URL # Always use the Docker-internal service name. The host-level DATABASE_URL
# (localhost:5433) must not bleed into the container where "localhost" is # (localhost:5433) must not bleed into the container where "localhost" is
# the container itself, not the host. # the container itself, not the host.
DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/nexus DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
REDIS_URL: redis://redis:6379 REDIS_URL: redis://redis:6379
NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set (e.g. https://your-domain.com)} NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set (e.g. https://your-domain.com)}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?set NEXTAUTH_SECRET} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?set NEXTAUTH_SECRET}
@@ -90,9 +90,9 @@ services:
volumes: volumes:
- .:/app - .:/app
# Named volumes mask the bind-mount for generated/installed artefacts. # Named volumes mask the bind-mount for generated/installed artefacts.
# Named (not anonymous) so they can be selectively removed: docker volume rm nexus_node_modules # Named (not anonymous) so they can be selectively removed: docker volume rm capakraken_node_modules
- nexus_node_modules:/app/node_modules - capakraken_node_modules:/app/node_modules
- nexus_next:/app/apps/web/.next - capakraken_next:/app/apps/web/.next
profiles: profiles:
- full - full
@@ -101,18 +101,18 @@ services:
ports: ports:
- "${POSTGRES_TEST_PORT:-5434}:5432" - "${POSTGRES_TEST_PORT:-5434}:5432"
environment: environment:
POSTGRES_DB: nexus_test POSTGRES_DB: capakraken_test
POSTGRES_USER: nexus POSTGRES_USER: capakraken
POSTGRES_PASSWORD: nexus_test POSTGRES_PASSWORD: capakraken_test
tmpfs: tmpfs:
- /var/lib/postgresql/data - /var/lib/postgresql/data
profiles: profiles:
- test - test
volumes: volumes:
nexus_pgdata: capakraken_pgdata:
name: nexus_pgdata name: capakraken_pgdata
nexus_node_modules: capakraken_node_modules:
name: nexus_node_modules name: capakraken_node_modules
nexus_next: capakraken_next:
name: nexus_next name: capakraken_next
+4 -4
View File
@@ -1,5 +1,5 @@
# Nexus nginx Security Hardening # CapaKraken nginx Security Hardening
# Apply to the server block for nexus.hartmut-noerenberg.com # Apply to the server block for capakraken.hartmut-noerenberg.com
# #
# References: # References:
# - EAPPS 3.3.1.3.04 (Server Header entfernen) # - EAPPS 3.3.1.3.04 (Server Header entfernen)
@@ -113,5 +113,5 @@ log_format security '$remote_addr - $remote_user [$time_local] '
'"$http_referer" "$http_user_agent" ' '"$http_referer" "$http_user_agent" '
'$request_time $upstream_response_time'; '$request_time $upstream_response_time';
access_log /var/log/nginx/nexus_access.log security; access_log /var/log/nginx/capakraken_access.log security;
error_log /var/log/nginx/nexus_error.log warn; error_log /var/log/nginx/capakraken_error.log warn;
+1 -1
View File
@@ -20,7 +20,7 @@
"check:imports": "node ./scripts/check-workspace-imports.mjs", "check:imports": "node ./scripts/check-workspace-imports.mjs",
"worktree:hygiene": "node ./scripts/worktree-hygiene.mjs", "worktree:hygiene": "node ./scripts/worktree-hygiene.mjs",
"clean:next": "node ./scripts/clean-next-artifacts.mjs", "clean:next": "node ./scripts/clean-next-artifacts.mjs",
"db:doctor": "node ./scripts/db-doctor.mjs nexus", "db:doctor": "node ./scripts/db-doctor.mjs capakraken",
"db:prisma": "node ./scripts/prisma-with-env.mjs", "db:prisma": "node ./scripts/prisma-with-env.mjs",
"db:push": "node ./scripts/prisma-with-env.mjs db push", "db:push": "node ./scripts/prisma-with-env.mjs db push",
"db:migrate": "node ./scripts/prisma-with-env.mjs migrate dev", "db:migrate": "node ./scripts/prisma-with-env.mjs migrate dev",
@@ -7,14 +7,12 @@ vi.mock("../lib/audit.js", () => ({
vi.mock("../router/assistant-approvals.js", () => ({ vi.mock("../router/assistant-approvals.js", () => ({
clearPendingAssistantApproval: vi.fn().mockResolvedValue(undefined), clearPendingAssistantApproval: vi.fn().mockResolvedValue(undefined),
consumePendingAssistantApproval: vi.fn(), consumePendingAssistantApproval: vi.fn(),
toApprovalPayload: vi.fn( toApprovalPayload: vi.fn((approval: { id: string; toolName: string; summary: string }, status: string) => ({
(approval: { id: string; toolName: string; summary: string }, status: string) => ({ id: approval.id,
id: approval.id, toolName: approval.toolName,
toolName: approval.toolName, summary: approval.summary,
summary: approval.summary, status,
status, })),
}),
),
})); }));
vi.mock("../router/assistant-confirmation.js", () => ({ vi.mock("../router/assistant-confirmation.js", () => ({
@@ -41,10 +39,16 @@ import {
clearPendingAssistantApproval, clearPendingAssistantApproval,
consumePendingAssistantApproval, consumePendingAssistantApproval,
} from "../router/assistant-approvals.js"; } from "../router/assistant-approvals.js";
import { canExecuteMutationTool, isCancellationReply } from "../router/assistant-confirmation.js"; import {
canExecuteMutationTool,
isCancellationReply,
} from "../router/assistant-confirmation.js";
import { buildAssistantInsight } from "../router/assistant-insights.js"; import { buildAssistantInsight } from "../router/assistant-insights.js";
import { handlePendingAssistantApproval } from "../router/assistant-chat-response.js"; import { handlePendingAssistantApproval } from "../router/assistant-chat-response.js";
import { readToolError, readToolSuccessMessage } from "../router/assistant-tool-results.js"; import {
readToolError,
readToolSuccessMessage,
} from "../router/assistant-tool-results.js";
import { executeTool } from "../router/assistant-tools.js"; import { executeTool } from "../router/assistant-tools.js";
function createPendingApproval() { function createPendingApproval() {
@@ -53,16 +57,14 @@ function createPendingApproval() {
userId: "user_1", userId: "user_1",
conversationId: "conv_1", conversationId: "conv_1",
toolName: "create_project", toolName: "create_project",
toolArguments: '{"name":"Apollo"}', toolArguments: "{\"name\":\"Apollo\"}",
summary: "create project (name=Apollo)", summary: "create project (name=Apollo)",
createdAt: Date.now(), createdAt: Date.now(),
expiresAt: Date.now() + 60_000, expiresAt: Date.now() + 60_000,
}; };
} }
function createHandleInput( function createHandleInput(overrides: Partial<Parameters<typeof handlePendingAssistantApproval>[0]> = {}) {
overrides: Partial<Parameters<typeof handlePendingAssistantApproval>[0]> = {},
) {
return { return {
db: {} as never, db: {} as never,
dbUserId: "user_1", dbUserId: "user_1",
@@ -79,10 +81,7 @@ function createHandleInput(
pendingApproval: createPendingApproval(), pendingApproval: createPendingApproval(),
lastUserMessage: { role: "user" as const, content: "ja" }, lastUserMessage: { role: "user" as const, content: "ja" },
messages: [ messages: [
{ { role: "assistant" as const, content: "__CAPAKRAKEN_CONFIRM__ create project (name=Apollo). Bitte bestätigen." },
role: "assistant" as const,
content: "__NEXUS_CONFIRM__ create project (name=Apollo). Bitte bestätigen.",
},
{ role: "user" as const, content: "ja" }, { role: "user" as const, content: "ja" },
], ],
collectedActions: [], collectedActions: [],
@@ -104,11 +103,9 @@ describe("assistant pending approval handling", () => {
it("cancels pending approvals when the user aborts", async () => { it("cancels pending approvals when the user aborts", async () => {
vi.mocked(isCancellationReply).mockReturnValue(true); vi.mocked(isCancellationReply).mockReturnValue(true);
const result = await handlePendingAssistantApproval( const result = await handlePendingAssistantApproval(createHandleInput({
createHandleInput({ lastUserMessage: { role: "user", content: "nein, abbrechen" },
lastUserMessage: { role: "user", content: "nein, abbrechen" }, }));
}),
);
expect(result).toMatchObject({ expect(result).toMatchObject({
response: { response: {
@@ -130,7 +127,7 @@ describe("assistant pending approval handling", () => {
summary: "create project (name=Apollo, status=DRAFT)", summary: "create project (name=Apollo, status=DRAFT)",
} as never); } as never);
vi.mocked(executeTool).mockResolvedValue({ vi.mocked(executeTool).mockResolvedValue({
content: '{"message":"Projekt Apollo angelegt"}', content: "{\"message\":\"Projekt Apollo angelegt\"}",
data: { message: "Projekt Apollo angelegt" }, data: { message: "Projekt Apollo angelegt" },
action: { type: "refresh" }, action: { type: "refresh" },
} as never); } as never);
@@ -151,35 +148,29 @@ describe("assistant pending approval handling", () => {
status: "approved", status: "approved",
}, },
actions: [{ type: "refresh" }], actions: [{ type: "refresh" }],
insights: [ insights: [{
{ kind: "holiday_region",
kind: "holiday_region", title: "Berlin",
title: "Berlin", }],
},
],
}, },
}); });
expect(executeTool).toHaveBeenCalledWith( expect(executeTool).toHaveBeenCalledWith(
"create_project", "create_project",
'{"name":"Apollo"}', "{\"name\":\"Apollo\"}",
expect.objectContaining({ userId: "user_1" }), expect.objectContaining({ userId: "user_1" }),
); );
expect(createAuditEntry).toHaveBeenCalledWith( expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({
expect.objectContaining({ entityName: "create_project",
entityName: "create_project", summary: "AI executed previously approved tool: create_project",
summary: "AI executed previously approved tool: create_project", }));
}),
);
}); });
it("does nothing when the user reply is not a valid confirmation", async () => { it("does nothing when the user reply is not a valid confirmation", async () => {
vi.mocked(canExecuteMutationTool).mockReturnValue(false); vi.mocked(canExecuteMutationTool).mockReturnValue(false);
const result = await handlePendingAssistantApproval( const result = await handlePendingAssistantApproval(createHandleInput({
createHandleInput({ lastUserMessage: { role: "user", content: "vielleicht" },
lastUserMessage: { role: "user", content: "vielleicht" }, }));
}),
);
expect(result).toBeNull(); expect(result).toBeNull();
expect(consumePendingAssistantApproval).not.toHaveBeenCalled(); expect(consumePendingAssistantApproval).not.toHaveBeenCalled();
@@ -105,10 +105,10 @@ describe("RBAC cache Redis pub/sub (#57)", () => {
// Simulate a peer instance publishing an invalidation: grab any // Simulate a peer instance publishing an invalidation: grab any
// subscriber on the channel and fire the event as if Redis delivered it. // subscriber on the channel and fire the event as if Redis delivered it.
const subs = channelSubscribers.get("nexus:rbac-invalidate"); const subs = channelSubscribers.get("capakraken:rbac-invalidate");
expect(subs).toBeDefined(); expect(subs).toBeDefined();
expect(subs!.size).toBeGreaterThanOrEqual(1); expect(subs!.size).toBeGreaterThanOrEqual(1);
for (const sub of subs!) sub.emit("message", "nexus:rbac-invalidate", "1"); for (const sub of subs!) sub.emit("message", "capakraken:rbac-invalidate", "1");
// Next load must hit the DB again. // Next load must hit the DB again.
await loadRoleDefaults(); await loadRoleDefaults();
@@ -126,6 +126,6 @@ describe("RBAC cache Redis pub/sub (#57)", () => {
const newPublishes = publishCalls.slice(countBefore); const newPublishes = publishCalls.slice(countBefore);
expect(newPublishes.length).toBe(1); expect(newPublishes.length).toBe(1);
expect(newPublishes[0]!.channel).toBe("nexus:rbac-invalidate"); expect(newPublishes[0]!.channel).toBe("capakraken:rbac-invalidate");
}); });
}); });
@@ -24,7 +24,7 @@ describe("assertWebhookUrlAllowed — SSRF guard", () => {
it("allows an HTTPS URL with a path and query string", async () => { it("allows an HTTPS URL with a path and query string", async () => {
await expect( await expect(
assertWebhookUrlAllowed("https://hooks.external.io/events?source=nexus"), assertWebhookUrlAllowed("https://hooks.external.io/events?source=capakraken"),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
+3 -3
View File
@@ -22,15 +22,15 @@ export function getAppBaseUrl(): string {
if (process.env["NODE_ENV"] === "production") { if (process.env["NODE_ENV"] === "production") {
throw new Error( throw new Error(
"NEXTAUTH_URL must be set in production — email links will contain localhost otherwise. " + "NEXTAUTH_URL must be set in production — email links will contain localhost otherwise. " +
"Set it to the public URL of this app (e.g. https://nexus.example.com).", "Set it to the public URL of this app (e.g. https://capakraken.example.com).",
); );
} }
if (!warned) { if (!warned) {
warned = true; warned = true;
console.warn( console.warn(
"[nexus] NEXTAUTH_URL is not set — falling back to http://localhost:3100 for email links. " + "[capakraken] NEXTAUTH_URL is not set — falling back to http://localhost:3100 for email links. " +
"Set NEXTAUTH_URL in your .env to suppress this warning.", "Set NEXTAUTH_URL in your .env to suppress this warning.",
); );
} }
+2 -2
View File
@@ -44,13 +44,13 @@ const redactConfig = { paths: REDACT_PATHS, censor: "[REDACTED]" };
export const logger = isProduction export const logger = isProduction
? pino({ ? pino({
level: LOG_LEVEL, level: LOG_LEVEL,
base: { service: "nexus-api" }, base: { service: "capakraken-api" },
redact: redactConfig, redact: redactConfig,
}) })
: pino( : pino(
{ {
level: LOG_LEVEL, level: LOG_LEVEL,
base: { service: "nexus-api" }, base: { service: "capakraken-api" },
redact: redactConfig, redact: redactConfig,
formatters: { formatters: {
level(label: string) { level(label: string) {
+1 -1
View File
@@ -31,7 +31,7 @@ type RateLimiterBackend = {
reset: () => Promise<void>; reset: () => Promise<void>;
}; };
const DEFAULT_REDIS_KEY_PREFIX = "nexus:ratelimit"; const DEFAULT_REDIS_KEY_PREFIX = "capakraken:ratelimit";
const DEFAULT_REDIS_BACKEND = process.env["RATE_LIMIT_BACKEND"] as RateLimitBackendMode | undefined; const DEFAULT_REDIS_BACKEND = process.env["RATE_LIMIT_BACKEND"] as RateLimitBackendMode | undefined;
const DEFAULT_REDIS_URL = process.env["REDIS_URL"]?.trim(); const DEFAULT_REDIS_URL = process.env["REDIS_URL"]?.trim();
const warnedRedisFailures = new Set<string>(); const warnedRedisFailures = new Set<string>();
+1 -1
View File
@@ -201,7 +201,7 @@ const REDIS_URL =
: (() => { : (() => {
throw new Error("REDIS_URL required in production"); throw new Error("REDIS_URL required in production");
})()); })());
const CHANNEL = "nexus:sse"; const CHANNEL = "capakraken:sse";
let publisher: Redis | null = null; let publisher: Redis | null = null;
let subscriber: Redis | null = null; let subscriber: Redis | null = null;
+1 -1
View File
@@ -42,7 +42,7 @@ const ROLE_DEFAULTS_TTL = 10_000;
// We publish a single invalidate message per change; every node subscribes and // We publish a single invalidate message per change; every node subscribes and
// clears its local cache on receipt. Failure to publish/subscribe is logged // clears its local cache on receipt. Failure to publish/subscribe is logged
// but never thrown — the TTL above is the fall-back. // but never thrown — the TTL above is the fall-back.
const RBAC_INVALIDATE_CHANNEL = "nexus:rbac-invalidate"; const RBAC_INVALIDATE_CHANNEL = "capakraken:rbac-invalidate";
let _rbacPublisher: Redis | null = null; let _rbacPublisher: Redis | null = null;
let _rbacSubscriber: Redis | null = null; let _rbacSubscriber: Redis | null = null;
+1 -1
View File
@@ -8,7 +8,7 @@
"./client": "./src/client.ts" "./client": "./src/client.ts"
}, },
"scripts": { "scripts": {
"db:doctor": "node ../../scripts/db-doctor.mjs nexus", "db:doctor": "node ../../scripts/db-doctor.mjs capakraken",
"db:push": "node ../../scripts/prisma-with-env.mjs db push --schema ./prisma/schema.prisma", "db:push": "node ../../scripts/prisma-with-env.mjs db push --schema ./prisma/schema.prisma",
"db:migrate": "node ../../scripts/prisma-with-env.mjs migrate dev --schema ./prisma/schema.prisma", "db:migrate": "node ../../scripts/prisma-with-env.mjs migrate dev --schema ./prisma/schema.prisma",
"db:migrate:deploy": "node ../../scripts/prisma-with-env.mjs migrate deploy --schema ./prisma/schema.prisma", "db:migrate:deploy": "node ../../scripts/prisma-with-env.mjs migrate deploy --schema ./prisma/schema.prisma",
+1 -1
View File
@@ -1,4 +1,4 @@
// Nexus — Prisma Schema // CapaKraken — Prisma Schema
// All monetary values stored as integer cents to avoid float precision issues. // All monetary values stored as integer cents to avoid float precision issues.
generator client { generator client {
+19 -19
View File
@@ -22,34 +22,34 @@ test.afterEach(() => {
process.env = { ...ORIGINAL_ENV }; process.env = { ...ORIGINAL_ENV };
}); });
test("assertDestructiveDbAllowed allows an explicitly confirmed disposable nexus test database", () => { test("assertDestructiveDbAllowed allows an explicitly confirmed disposable capakraken test database", () => {
setEnv({ setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_test", DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_test",
ALLOW_DESTRUCTIVE_DB_TOOLS: "true", ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
CONFIRM_DESTRUCTIVE_DB_NAME: "nexus_test", CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken_test",
}); });
const target = assertDestructiveDbAllowed({ const target = assertDestructiveDbAllowed({
commandName: "db:test", commandName: "db:test",
allowedDatabaseNames: ["nexus_test"], allowedDatabaseNames: ["capakraken_test"],
}); });
assert.equal(target.databaseName, "nexus_test"); assert.equal(target.databaseName, "capakraken_test");
assert.equal(target.hostname, "localhost"); assert.equal(target.hostname, "localhost");
}); });
test("assertDestructiveDbAllowed rejects protected live database names even if allowlisted", () => { test("assertDestructiveDbAllowed rejects protected live database names even if allowlisted", () => {
setEnv({ setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus", DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken",
ALLOW_DESTRUCTIVE_DB_TOOLS: "true", ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
CONFIRM_DESTRUCTIVE_DB_NAME: "nexus", CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken",
}); });
assert.throws( assert.throws(
() => () =>
assertDestructiveDbAllowed({ assertDestructiveDbAllowed({
commandName: "db:test", commandName: "db:test",
allowedDatabaseNames: ["nexus"], allowedDatabaseNames: ["capakraken"],
}), }),
/explicitly protected/u, /explicitly protected/u,
); );
@@ -57,7 +57,7 @@ test("assertDestructiveDbAllowed rejects protected live database names even if a
test("assertDestructiveDbAllowed rejects missing confirmation", () => { test("assertDestructiveDbAllowed rejects missing confirmation", () => {
setEnv({ setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_e2e", DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_e2e",
ALLOW_DESTRUCTIVE_DB_TOOLS: "true", ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
CONFIRM_DESTRUCTIVE_DB_NAME: "wrong_db", CONFIRM_DESTRUCTIVE_DB_NAME: "wrong_db",
}); });
@@ -66,24 +66,24 @@ test("assertDestructiveDbAllowed rejects missing confirmation", () => {
() => () =>
assertDestructiveDbAllowed({ assertDestructiveDbAllowed({
commandName: "db:test", commandName: "db:test",
allowedDatabaseNames: ["nexus_e2e"], allowedDatabaseNames: ["capakraken_e2e"],
}), }),
/CONFIRM_DESTRUCTIVE_DB_NAME=nexus_e2e/u, /CONFIRM_DESTRUCTIVE_DB_NAME=capakraken_e2e/u,
); );
}); });
test("assertDestructiveDbAllowed rejects missing destructive allow flag", () => { test("assertDestructiveDbAllowed rejects missing destructive allow flag", () => {
setEnv({ setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_ci", DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_ci",
ALLOW_DESTRUCTIVE_DB_TOOLS: undefined, ALLOW_DESTRUCTIVE_DB_TOOLS: undefined,
CONFIRM_DESTRUCTIVE_DB_NAME: "nexus_ci", CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken_ci",
}); });
assert.throws( assert.throws(
() => () =>
assertDestructiveDbAllowed({ assertDestructiveDbAllowed({
commandName: "db:test", commandName: "db:test",
allowedDatabaseNames: ["nexus_ci"], allowedDatabaseNames: ["capakraken_ci"],
}), }),
/ALLOW_DESTRUCTIVE_DB_TOOLS=true/u, /ALLOW_DESTRUCTIVE_DB_TOOLS=true/u,
); );
@@ -99,19 +99,19 @@ test("assertSafeSeedTarget rejects unexpected legacy disposable databases", () =
assert.throws(() => assertSafeSeedTarget("db:seed"), /not in the destructive-tool allowlist/u); assert.throws(() => assertSafeSeedTarget("db:seed"), /not in the destructive-tool allowlist/u);
}); });
test("assertNexusDbTarget accepts non-destructive nexus targets", () => { test("assertNexusDbTarget accepts non-destructive capakraken targets", () => {
setEnv({ setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_dev", DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_dev",
}); });
const target = assertNexusDbTarget("db:seed:holidays"); const target = assertNexusDbTarget("db:seed:holidays");
assert.equal(target.databaseName, "nexus_dev"); assert.equal(target.databaseName, "capakraken_dev");
}); });
test("assertNexusDbTarget rejects legacy non-nexus targets", () => { test("assertNexusDbTarget rejects legacy non-capakraken targets", () => {
setEnv({ setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/legacy_non_nexus", DATABASE_URL: "postgresql://tester:secret@localhost:5432/legacy_non_capakraken",
}); });
assert.throws(() => assertNexusDbTarget("db:seed:holidays"), /not a valid Nexus target/u); assert.throws(() => assertNexusDbTarget("db:seed:holidays"), /not a valid Nexus target/u);
+1 -1
View File
@@ -6,7 +6,7 @@ interface DestructiveGuardOptions {
requireConfirmation?: boolean; requireConfirmation?: boolean;
} }
const PROTECTED_DATABASE_NAMES = new Set(["nexus"]); const PROTECTED_DATABASE_NAMES = new Set(["capakraken"]);
export function parseDatabaseUrl(rawUrl: string) { export function parseDatabaseUrl(rawUrl: string) {
const parsed = new URL(rawUrl); const parsed = new URL(rawUrl);
+1 -1
View File
@@ -157,7 +157,7 @@ async function main() {
const options = parseArgs(process.argv.slice(2)); const options = parseArgs(process.argv.slice(2));
const target = assertDestructiveDbAllowed({ const target = assertDestructiveDbAllowed({
commandName: "db:reset:dispo", commandName: "db:reset:dispo",
allowedDatabaseNames: ["nexus_test", "nexus_e2e", "nexus_ci"], allowedDatabaseNames: ["capakraken_test", "capakraken_e2e", "capakraken_ci"],
}); });
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
+2 -2
View File
@@ -4,7 +4,7 @@ import {
parseDatabaseUrl, parseDatabaseUrl,
} from "./destructive-db-guard.js"; } from "./destructive-db-guard.js";
const TEST_DATABASE_NAMES = ["nexus_test", "nexus_e2e", "nexus_ci"]; const TEST_DATABASE_NAMES = ["capakraken_test", "capakraken_e2e", "capakraken_ci"];
export function assertSafeSeedTarget(commandName: string) { export function assertSafeSeedTarget(commandName: string) {
return assertDestructiveDbAllowed({ return assertDestructiveDbAllowed({
@@ -24,7 +24,7 @@ export function assertNexusDbTarget(commandName: string) {
const target = parseDatabaseUrl(rawUrl); const target = parseDatabaseUrl(rawUrl);
if (!target.databaseName.startsWith("nexus")) { if (!target.databaseName.startsWith("capakraken")) {
throw new Error( throw new Error(
`${commandName} aborted: database '${target.databaseName}' is not a valid Nexus target. Target=${formatTarget(target)}`, `${commandName} aborted: database '${target.databaseName}' is not a valid Nexus target. Target=${formatTarget(target)}`,
); );
+1 -1
View File
@@ -2372,7 +2372,7 @@ async function main() {
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" "); .join(" ");
const email = `${eid}@nexus.example`; const email = `${eid}@capakraken.example`;
const lcrCents = Math.round(lcr * 100); const lcrCents = Math.round(lcr * 100);
const ucrCents = Math.round(ucr * 100); const ucrCents = Math.round(ucr * 100);
const availability = computeAvailability(fraction, availDays); const availability = computeAvailability(fraction, availDays);
+2 -2
View File
@@ -23,7 +23,7 @@ function toDisplayName(eid) {
} }
function toEmail(eid) { function toEmail(eid) {
return `${eid}@nexus.example`; return `${eid}@capakraken.example`;
} }
function computeSkillLabel(chapter, typeOfWork) { function computeSkillLabel(chapter, typeOfWork) {
@@ -150,7 +150,7 @@ async function main() {
{ {
col: 15, // O col: 15, // O
header: "Email\n(generated)", header: "Email\n(generated)",
doc: "Generated email: firstname.lastname@nexus.example. Required unique field in Nexus. Replace with real email in production.", doc: "Generated email: firstname.lastname@capakraken.example. Required unique field in Nexus. Replace with real email in production.",
}, },
{ {
col: 16, // P col: 16, // P
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# restart.sh — Rebuild the Nexus app container from scratch. # restart.sh — Rebuild the CapaKraken app container from scratch.
# #
# When to use: # When to use:
# - After changing pnpm-lock.yaml (new/removed dependencies) # - After changing pnpm-lock.yaml (new/removed dependencies)
@@ -40,7 +40,7 @@ docker compose --profile "$PROFILE" stop app 2>/dev/null || true
if $CLEAN; then if $CLEAN; then
echo "==> Removing stale node_modules and .next volumes..." echo "==> Removing stale node_modules and .next volumes..."
docker volume rm nexus_node_modules nexus_next 2>/dev/null || true docker volume rm capakraken_node_modules capakraken_next 2>/dev/null || true
fi fi
echo "==> Rebuilding and starting ($( [[ -z "$SERVICES" ]] && echo "all services" || echo "$SERVICES" ))..." echo "==> Rebuilding and starting ($( [[ -z "$SERVICES" ]] && echo "all services" || echo "$SERVICES" ))..."
Binary file not shown.
+2 -2
View File
@@ -5,9 +5,9 @@
import { createRequire } from "module"; import { createRequire } from "module";
import { writeFileSync, mkdirSync } from "fs"; import { writeFileSync, mkdirSync } from "fs";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const XLSX = require("/home/hartmut/Documents/Copilot/nexus/node_modules/.pnpm/xlsx@0.18.5/node_modules/xlsx/xlsx.js"); const XLSX = require("/home/hartmut/Documents/Copilot/capakraken/node_modules/.pnpm/xlsx@0.18.5/node_modules/xlsx/xlsx.js");
const OUT_DIR = "/home/hartmut/Documents/Copilot/nexus/samples/skillmatrix_dummydata"; const OUT_DIR = "/home/hartmut/Documents/Copilot/capakraken/samples/skillmatrix_dummydata";
mkdirSync(OUT_DIR, { recursive: true }); mkdirSync(OUT_DIR, { recursive: true });
// ─── Skill Definitions ───────────────────────────────────────────────────── // ─── Skill Definitions ─────────────────────────────────────────────────────
+1 -1
View File
@@ -719,7 +719,7 @@ export const rules = [
], ],
forbidden: [ forbidden: [
{ {
pattern: /pnpm --filter @nexus\/db exec prisma generate/, pattern: /pnpm --filter @capakraken\/db exec prisma generate/,
message: "CI must not call prisma generate directly outside the workspace wrapper", message: "CI must not call prisma generate directly outside the workspace wrapper",
}, },
], ],
+2 -2
View File
@@ -4,7 +4,7 @@ export function formatDatabaseTarget(parsedUrl, databaseName) {
return `${parsedUrl.protocol}//${decodeURIComponent(parsedUrl.username)}@${parsedUrl.hostname}${parsedUrl.port ? `:${parsedUrl.port}` : ""}/${databaseName}`; return `${parsedUrl.protocol}//${decodeURIComponent(parsedUrl.username)}@${parsedUrl.hostname}${parsedUrl.port ? `:${parsedUrl.port}` : ""}/${databaseName}`;
} }
export function inspectDatabaseUrl(rawUrl, expectedDatabase = "nexus") { export function inspectDatabaseUrl(rawUrl, expectedDatabase = "capakraken") {
if (!rawUrl) { if (!rawUrl) {
throw new Error("DATABASE_URL is not configured."); throw new Error("DATABASE_URL is not configured.");
} }
@@ -82,5 +82,5 @@ export function shouldGuardPrismaCommand(args) {
} }
export function getExpectedDatabaseName() { export function getExpectedDatabaseName() {
return process.env.NEXUS_EXPECTED_DB_NAME?.trim() || "nexus"; return process.env.CAPAKRAKEN_EXPECTED_DB_NAME?.trim() || "capakraken";
} }
+8 -8
View File
@@ -6,21 +6,21 @@ import {
} from "./db-target-guard.mjs"; } from "./db-target-guard.mjs";
describe("db target guard", () => { describe("db target guard", () => {
it("accepts the expected nexus database target", () => { it("accepts the expected capakraken database target", () => {
const result = inspectDatabaseUrl( const result = inspectDatabaseUrl(
"postgresql://nexus:secret@localhost:5432/nexus", "postgresql://capakraken:secret@localhost:5432/capakraken",
"nexus", "capakraken",
); );
assert.equal(result.databaseName, "nexus"); assert.equal(result.databaseName, "capakraken");
assert.equal(result.expectedDatabase, "nexus"); assert.equal(result.expectedDatabase, "capakraken");
assert.equal(result.target, "postgresql://nexus@localhost:5432/nexus"); assert.equal(result.target, "postgresql://capakraken@localhost:5432/capakraken");
}); });
it("rejects a mismatched database target", () => { it("rejects a mismatched database target", () => {
assert.throws( assert.throws(
() => inspectDatabaseUrl("postgresql://nexus:secret@localhost:5432/planarchy", "nexus"), () => inspectDatabaseUrl("postgresql://capakraken:secret@localhost:5432/planarchy", "capakraken"),
/Unexpected database target 'planarchy'\. Expected 'nexus'\./, /Unexpected database target 'planarchy'\. Expected 'capakraken'\./,
); );
}); });
+5 -5
View File
@@ -10,8 +10,8 @@
* node scripts/export-dev-seed.mjs * node scripts/export-dev-seed.mjs
* *
* Requirements: * Requirements:
* - The nexus-postgres-1 Docker container must be running * - The capakraken-postgres-1 Docker container must be running
* - DATABASE_URL must point to a local nexus database * - DATABASE_URL must point to a local capakraken database
*/ */
import { execSync, spawnSync } from "node:child_process"; import { execSync, spawnSync } from "node:child_process";
@@ -48,7 +48,7 @@ if (!["localhost", "127.0.0.1", "::1"].includes(host)) {
// ── Docker container check ──────────────────────────────────────────────────── // ── Docker container check ────────────────────────────────────────────────────
const CONTAINER = "nexus-postgres-1"; const CONTAINER = "capakraken-postgres-1";
const containerCheck = spawnSync("docker", ["inspect", "--format={{.State.Running}}", CONTAINER], { const containerCheck = spawnSync("docker", ["inspect", "--format={{.State.Running}}", CONTAINER], {
encoding: "utf8", encoding: "utf8",
}); });
@@ -83,8 +83,8 @@ const excludeFlags = EXCLUDE_TABLES.flatMap((t) => ["--exclude-table-data", `pub
// ── Run pg_dump inside the Docker container ─────────────────────────────────── // ── Run pg_dump inside the Docker container ───────────────────────────────────
const DB_USER = decodeURIComponent(parsedUrl.username) || "nexus"; const DB_USER = decodeURIComponent(parsedUrl.username) || "capakraken";
const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "nexus"; const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "capakraken";
const DB_PORT = parsedUrl.port || "5432"; const DB_PORT = parsedUrl.port || "5432";
console.log(`🔍 Exporting ${DB_USER}@${host}:${DB_PORT}/${DB_NAME}`); console.log(`🔍 Exporting ${DB_USER}@${host}:${DB_PORT}/${DB_NAME}`);
+2 -2
View File
@@ -2,8 +2,8 @@
# Remove SUPERUSER from the application database user # Remove SUPERUSER from the application database user
# Run after initial setup: bash scripts/harden-postgres.sh # Run after initial setup: bash scripts/harden-postgres.sh
DB_USER="${DB_USER:-nexus}" DB_USER="${DB_USER:-capakraken}"
DB_NAME="${DB_NAME:-nexus}" DB_NAME="${DB_NAME:-capakraken}"
echo "Hardening PostgreSQL for $DB_USER..." echo "Hardening PostgreSQL for $DB_USER..."
+5 -5
View File
@@ -10,8 +10,8 @@
* node scripts/import-dev-seed.mjs * node scripts/import-dev-seed.mjs
* *
* Requirements: * Requirements:
* - The nexus-postgres-1 Docker container must be running * - The capakraken-postgres-1 Docker container must be running
* - DATABASE_URL must point to a local nexus database * - DATABASE_URL must point to a local capakraken database
* - dev-seed.sql must exist (run export-dev-seed.mjs first) * - dev-seed.sql must exist (run export-dev-seed.mjs first)
*/ */
@@ -46,13 +46,13 @@ if (!["localhost", "127.0.0.1", "::1"].includes(host)) {
process.exit(1); process.exit(1);
} }
const DB_USER = decodeURIComponent(parsedUrl.username) || "nexus"; const DB_USER = decodeURIComponent(parsedUrl.username) || "capakraken";
const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "nexus"; const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "capakraken";
const DB_PORT = parsedUrl.port || "5432"; const DB_PORT = parsedUrl.port || "5432";
// ── Docker container check ──────────────────────────────────────────────────── // ── Docker container check ────────────────────────────────────────────────────
const CONTAINER = "nexus-postgres-1"; const CONTAINER = "capakraken-postgres-1";
const containerCheck = spawnSync("docker", ["inspect", "--format={{.State.Running}}", CONTAINER], { const containerCheck = spawnSync("docker", ["inspect", "--format={{.State.Running}}", CONTAINER], {
encoding: "utf8", encoding: "utf8",
}); });
+1 -1
View File
@@ -27,7 +27,7 @@ if (shouldGuardPrismaCommand(prismaArgs)) {
} catch (error) { } catch (error) {
console.error(error instanceof Error ? error.message : String(error)); console.error(error instanceof Error ? error.message : String(error));
console.error("Refusing to run Prisma against an unexpected database target."); console.error("Refusing to run Prisma against an unexpected database target.");
console.error("Use the repo env files for Nexus, or set NEXUS_EXPECTED_DB_NAME explicitly if you intentionally target another database."); console.error("Use the repo env files for Nexus, or set CAPAKRAKEN_EXPECTED_DB_NAME explicitly if you intentionally target another database.");
process.exit(1); process.exit(1);
} }
} }
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
echo "Restarting Nexus..." echo "Restarting CapaKraken..."
echo "" echo ""
# Stop # Stop
+3 -3
View File
@@ -5,7 +5,7 @@ cd "$(dirname "$0")/.."
APP_PORT="${APP_PORT:-3100}" APP_PORT="${APP_PORT:-3100}"
APP_CONTAINER="${APP_CONTAINER:-$(docker compose --profile full ps -q app 2>/dev/null | head -1)}" APP_CONTAINER="${APP_CONTAINER:-$(docker compose --profile full ps -q app 2>/dev/null | head -1)}"
echo "Starting Nexus..." echo "Starting CapaKraken..."
# 1. Start Docker services # 1. Start Docker services
echo " Starting PostgreSQL + Redis..." echo " Starting PostgreSQL + Redis..."
@@ -15,7 +15,7 @@ sleep 2
# 2. Wait for PostgreSQL to be healthy # 2. Wait for PostgreSQL to be healthy
echo " Waiting for PostgreSQL..." echo " Waiting for PostgreSQL..."
for i in {1..30}; do for i in {1..30}; do
if docker compose exec -T postgres pg_isready -U nexus -d nexus -q 2>/dev/null; then if docker compose exec -T postgres pg_isready -U capakraken -d capakraken -q 2>/dev/null; then
break break
fi fi
sleep 1 sleep 1
@@ -34,7 +34,7 @@ echo " Waiting for server (up to 90s)..."
for i in {1..90}; do for i in {1..90}; do
if curl -sf "http://localhost:${APP_PORT}/api/health" > /dev/null 2>&1; then if curl -sf "http://localhost:${APP_PORT}/api/health" > /dev/null 2>&1; then
echo "" echo ""
echo "Nexus is running!" echo "CapaKraken is running!"
curl -s "http://localhost:${APP_PORT}/api/ready" | python3 -m json.tool 2>/dev/null || curl -s "http://localhost:${APP_PORT}/api/ready" curl -s "http://localhost:${APP_PORT}/api/ready" | python3 -m json.tool 2>/dev/null || curl -s "http://localhost:${APP_PORT}/api/ready"
echo "" echo ""
echo " URL: http://localhost:${APP_PORT}" echo " URL: http://localhost:${APP_PORT}"
+2 -2
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
echo "Stopping Nexus..." echo "Stopping CapaKraken..."
# 1. Stop any legacy local dev server # 1. Stop any legacy local dev server
if [ -f /tmp/nexus-dev.pid ]; then if [ -f /tmp/nexus-dev.pid ]; then
@@ -28,4 +28,4 @@ echo " Stopping app, PostgreSQL and Redis..."
docker compose --profile full stop app postgres redis 2>/dev/null || true docker compose --profile full stop app postgres redis 2>/dev/null || true
echo "" echo ""
echo "Nexus stopped." echo "CapaKraken stopped."
+1 -1
View File
@@ -9,7 +9,7 @@ import {
function createGitStub(statusOutput) { function createGitStub(statusOutput) {
return (args) => { return (args) => {
if (args[0] === "rev-parse" && args[1] === "--show-toplevel") { if (args[0] === "rev-parse" && args[1] === "--show-toplevel") {
return "/tmp/nexus\n"; return "/tmp/capakraken\n";
} }
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") { if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") {
return "main\n"; return "main\n";
+2 -2
View File
@@ -1,5 +1,5 @@
APP_IMAGE=ghcr.io/example/nexus-app:sha-abc123 APP_IMAGE=ghcr.io/example/capakraken-app:sha-abc123
MIGRATOR_IMAGE=ghcr.io/example/nexus-migrator:sha-abc123 MIGRATOR_IMAGE=ghcr.io/example/capakraken-migrator:sha-abc123
APP_HOST_PORT=3000 APP_HOST_PORT=3000
GHCR_USERNAME= GHCR_USERNAME=
GHCR_TOKEN= GHCR_TOKEN=
-167
View File
@@ -1,167 +0,0 @@
#!/usr/bin/env bash
#
# Phase 3 cutover: migrate the running stack from `capakraken` (DB, role,
# volumes, compose project) to `nexus`. Intended to be run inside a brief
# maintenance window — the app is stopped for the duration.
#
# Idempotency: each step checks the current state. Re-running after a
# partial run only does the missing pieces. The script never DROPS the
# legacy `capakraken` DB or role — that's a manual decision after a
# stability window.
#
# Usage:
# ./tooling/migrate/rename-to-nexus.sh dev # against docker-compose.yml
# ./tooling/migrate/rename-to-nexus.sh prod # against docker-compose.prod.yml
#
# Requires:
# - POSTGRES_PASSWORD set in env (or .env file picked up by docker compose)
# - docker compose CLI v2+
# - the `nexus` branch of code already checked out (compose files reference nexus_*)
set -euo pipefail
MODE="${1:-dev}"
case "$MODE" in
dev)
COMPOSE_FILE=docker-compose.yml
OLD_PROJECT=capakraken
NEW_PROJECT=nexus
VOLUMES=(pgdata node_modules next)
;;
prod)
COMPOSE_FILE=docker-compose.prod.yml
OLD_PROJECT=capakraken-prod
NEW_PROJECT=nexus-prod
VOLUMES=(prod_pgdata prod_redis)
;;
*)
echo "Usage: $0 [dev|prod]" >&2
exit 2
;;
esac
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "POSTGRES_PASSWORD must be set in the environment." >&2
exit 2
fi
DUMP_FILE="/tmp/capakraken-pre-rename-$(date +%Y%m%d-%H%M%S).sql"
echo "=== Phase 3 cutover ($MODE) ==="
echo "compose: $COMPOSE_FILE project: $OLD_PROJECT$NEW_PROJECT dump: $DUMP_FILE"
echo
#───────────────────────────────────────────────────────────────────────────────
# 1. Stop the app (DB stays up so we can dump it).
#───────────────────────────────────────────────────────────────────────────────
echo "[1/7] Stopping app container under old project name..."
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" stop app 2>/dev/null || true
#───────────────────────────────────────────────────────────────────────────────
# 2. Capture row counts for verification.
#───────────────────────────────────────────────────────────────────────────────
echo "[2/7] Capturing pre-rename row counts..."
PRE_COUNTS=$(docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
psql -U capakraken -d capakraken -t -c \
"SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY relname;")
echo "$PRE_COUNTS" | head -20
echo "..."
#───────────────────────────────────────────────────────────────────────────────
# 3. Dump existing DB.
#───────────────────────────────────────────────────────────────────────────────
echo "[3/7] pg_dump capakraken → $DUMP_FILE..."
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
pg_dump -U capakraken -d capakraken --clean --if-exists > "$DUMP_FILE"
echo "Dump size: $(du -h "$DUMP_FILE" | cut -f1)"
#───────────────────────────────────────────────────────────────────────────────
# 4. Create new role + DB inside the running postgres container.
#───────────────────────────────────────────────────────────────────────────────
echo "[4/7] Creating nexus role + database..."
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
psql -U capakraken -d postgres <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='nexus') THEN
CREATE ROLE nexus LOGIN PASSWORD '${POSTGRES_PASSWORD}';
END IF;
END
\$\$;
SELECT 'CREATE DATABASE nexus OWNER nexus'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname='nexus')
\gexec
SQL
#───────────────────────────────────────────────────────────────────────────────
# 5. Restore into new DB.
#───────────────────────────────────────────────────────────────────────────────
echo "[5/7] Restoring dump into nexus DB..."
# Replace OWNER directives so restored objects belong to `nexus`.
sed 's/OWNER TO capakraken/OWNER TO nexus/g' "$DUMP_FILE" | \
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
psql -U nexus -d nexus
#───────────────────────────────────────────────────────────────────────────────
# 6. Volume rename (offline copy).
#───────────────────────────────────────────────────────────────────────────────
echo "[6/7] Stopping old project and copying volumes..."
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" down --remove-orphans
for v in "${VOLUMES[@]}"; do
src="${OLD_PROJECT//-/_}_${v}"
dst="${NEW_PROJECT//-/_}_${v}"
# Skip dev-mode app overlays (node_modules / next) — they regenerate on startup.
if [ "$MODE" = "dev" ] && { [ "$v" = "node_modules" ] || [ "$v" = "next" ]; }; then
echo " Skipping $v (regenerated on next boot)"
continue
fi
if ! docker volume inspect "$src" >/dev/null 2>&1; then
echo " Source volume $src missing — skip"
continue
fi
if docker volume inspect "$dst" >/dev/null 2>&1; then
echo " Destination volume $dst already exists — skip"
continue
fi
echo " $src$dst"
docker volume create "$dst" >/dev/null
docker run --rm \
-v "${src}:/from:ro" \
-v "${dst}:/to" \
alpine sh -c "cd /from && cp -a . /to/"
done
#───────────────────────────────────────────────────────────────────────────────
# 7. Bring up under new compose project name.
#───────────────────────────────────────────────────────────────────────────────
echo "[7/7] Starting stack under new project name '$NEW_PROJECT'..."
PROFILE=""
[ "$MODE" = "dev" ] && PROFILE="--profile full"
# shellcheck disable=SC2086
docker compose -p "$NEW_PROJECT" -f "$COMPOSE_FILE" $PROFILE up -d
echo
echo "Waiting 15s for postgres to be ready..."
sleep 15
echo "=== Verification ==="
POST_COUNTS=$(docker compose -p "$NEW_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
psql -U nexus -d nexus -t -c \
"SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY relname;")
echo "Post-rename row counts (sample):"
echo "$POST_COUNTS" | head -20
if diff <(echo "$PRE_COUNTS") <(echo "$POST_COUNTS") >/dev/null; then
echo "✓ Row counts match — migration verified."
else
echo "⚠ Row counts differ — review diff:"
diff <(echo "$PRE_COUNTS") <(echo "$POST_COUNTS") | head
fi
echo
echo "Done. Old DB+role retained for rollback. Dump kept at $DUMP_FILE."
echo "After a stability window, drop with:"
echo " docker compose -p $NEW_PROJECT -f $COMPOSE_FILE exec postgres psql -U nexus -d postgres \\"
echo " -c 'DROP DATABASE capakraken; DROP ROLE capakraken;'"