Nexus Black Onboarding Last updated 14 September 2026

Technical guide · for engineers · apps/onboarding in IFS-NXB/nxb-ignition

How the Training portal works

Next.js 15 App Router, React 19, Tailwind 4, Clerk for sign-in, Prisma on Turso through the libSQL adapter, sharing one database and one Clerk application with Ignition. This guide covers the parts that are easy to break: the gates, the seed, the Portfolio Plan, the sign-up route, promotion and the two-worktree workflow. README.md is the run book; HANDOVER.md is the operators' record.

  1. 1. The three gates and where they live
  2. 2. The seed and the per-fixture rule
  3. 3. The Portfolio Plan: sources, credential, cache, fallback
  4. 4. The sign-up route's sole purpose
  5. 5. Promotion, with Ignition
  6. 6. Two Claude sessions, two worktrees
  7. 7. Testing a preview behind Vercel SSO
  8. 8. Layout, width and the guides

1. The three gates and where they live

GateWhereRule
Sessionsrc/middleware.ts (clerkMiddleware)Public: /sign-in(.*), /sign-up(.*), /docs/(.*), /api/health. A signed-out page request redirects to /sign-in with a return URL (307); a signed-out API request answers 401 JSON. A signed-out request carrying __clerk_ticket on any path is redirected to /sign-up with the same query, so an invitation whose redirect is the site root (every one sent before 10 September 2026) still ends in an account.
Rostersrc/lib/access.ts (getAccess), applied by src/app/(app)/layout.tsxThe Clerk user's primary email must end @ifs.com and match a Person.ifsEmail. rosterGate from @nxb/core then refuses Left rows and, wherever IGNITION_E2E_USER is absent (production always), rows with source: "e2e". Incoming rows are admitted here. The refusal is rendered in place by the layout (components/StaffOnly.tsx) rather than by redirect(): Clerk's post-sign-up navigation is a soft client navigation and a server redirect inside it left a blank page (10 September 2026). /staff-only renders the same component.
Start dateonboardingOpensOn in src/lib/access.ts, rendered by the layout as components/OpensOn.tsxAn Incoming row whose startDate (ISO, UTC string compare) is after today sees "Your onboarding opens on …" on every route; nothing else renders and markComplete throws. Active rows, missing or malformed dates, and dates today or earlier open the modules.

The order matters: session, then roster, then start date. getAccess is wrapped in React's cache, so a page and its layout share one lookup per request.

2. The seed and the per-fixture rule

Content. Module text is data/onboarding/*.md|html, seeded into ContentAsset (versioned by slug) by pnpm --filter @nxb/db seed -- --skip-roster. IGNITION_TARGET=turso-dev targets the preview database, IGNITION_TARGET=turso production; without it the local file. The Ignition build runs the seed (scripts/vercel-prepare-db.mjs); the onboarding build does not, so new module text reaches production with the next Ignition build or a manual seed. The seeder writes only slugs that exist as files, so the plan cache slugs below are never touched by it.

Test identities. pnpm --filter @nxb/db seed:e2e upserts three roster rows with source: "e2e": the operator (Active), the joiner (Incoming) and the invitee (Incoming). It refuses the production database unless SEED_E2E_ALLOW_PRODUCTION=yes.

The per-fixture rule. The two Incoming fixtures must have a start date ahead of the run, or the start-date gate tests are wrong for the right reason (this happened in production on 10 September: a row seeded "tomorrow" on the 8th). So the date is never stored as a constant: packages/db/src/e2e-joiner.ts computes the UTC day after now, and apps/ignition/e2e/global-setup.ts re-dates both rows before every suite run through seed:e2e -- --joiner-only, which writes startDate and updatedBy on those rows and nothing else, refusing any row that is not an e2e fixture or not Incoming. The setup maps the target to a database (local file; a preview host to turso-dev) and refuses production outright. The reporter records what it did as "Joiner fixture:" in docs/testing/last-run.md. E2E_JOINER_START_DATE overrides the date for both the fixture and the spec.

3. The Portfolio Plan: sources, credential, cache, fallback

Code in src/lib/gantt/; the page is module kind gantt in src/lib/modules.ts; the drawing is components/Gantt.tsx (server-rendered, no client script, <details> drill-down).

Sources, all on IFS-NXB/product-base, branch tracking, read at one commit

The credential

GITHUB_TRACKING_TOKEN: fine-grained token "nxb-onboarding gantt (read-only)", resource owner IFS-NXB, repository product-base only, permission Contents read-only; created by Steven Buckle on 11 September 2026, expires 12 September 2027 (renew in the last week of August 2027). Held only as a Vercel environment variable on nxb-onboarding (Production and Preview) and in an engineer's .env.local; never logged, never stored, sent to api.github.com only. The token is tied to the account that created it; an IFS-NXB owner should hold a copy or create their own. github.ts turns 401, 403 and 404 into plain-English reasons that the caption shows.

The 24-hour cache

store.ts: the plan is ContentAsset gantt-live (one version per successful build, body JSON); the last attempt is gantt-live-status (one row, rewritten). planForPage rebuilds on page load when the plan is older than 24 hours or absent, unless the last attempt failed less than 10 minutes ago; the build has a 45-second timeout and the page a 60-second maxDuration. A failed attempt never touches the plan. Admins get Refresh now (refreshGantt server action).

The caption is the honesty rule

caption.ts: a plan under 24 hours old reads "Plan built <time> UTC from IFS-NXB/product-base@tracking <sha7> · sources last read <date>. Workstream detail; ticket-level drill-down to follow." Anything else is a red role="alert" box: "The plan could not be rebuilt at <time>. Showing the plan built <time>, sources last read <date>." with the failure; with no plan at all it names the 15 July export and its 10 July data date. Every caption carries data-data-date. Stale data is never presented as current; the Playwright C test asserts the caption is one of these two shapes.

The POST fallback

POST /api/onboarding/gantt (ignitionAdmin, signed in): PMBot's gantt.html export as text/html, a plan JSON, or a multipart file; stored as an upload by that person, so the caption reads "from an export uploaded by …". GET returns the current plan JSON for admins. Use it when GitHub cannot be reached for a while.

4. The sign-up route's sole purpose

src/app/sign-up/[[...sign-up]]/page.tsx exists solely to consume an invitation ticket. The Clerk application is Restricted (Invite-only; Manan Shah, 11 September 2026). Without a ticket Clerk's <SignUp> renders its restricted-access card; the wording is replaced through ClerkProvider localization in src/app/layout.tsx (signUp.restrictedAccess) so it says access is by invitation and names who to ask, never "contact support". Do not remove the route or hide the component to "fix" that card: with a ticket the same page consumes it, the address is pre-verified, and forceRedirectUrl lands the person on /module/welcome instead of Clerk's developer page (the Account Portal's default, which nobody here can change without dashboard access). Ignition's /sign-up has the same purpose. The sign-in page has no Sign up affordance (signIn.start.actionText/actionLink blanked; no signUpUrl).

5. Promotion, with Ignition

Neither Vercel project is Git-connected: pushing main deploys nothing. A promotion is a Vercel CLI deploy from a clean detached checkout of the named commit (git worktree add --detach ../nxb-ignition-release <sha>), onboarding first, then Ignition, because Ignition's Part 3 points invitations at the onboarding /sign-up route and the onboarding middleware carries the ticket:

vercel link --yes --project nxb-onboarding --scope ifs-nxb && vercel deploy --prod --yes --scope ifs-nxb
vercel link --yes --project nxb-ignition   --scope ifs-nxb && vercel deploy --prod --yes --scope ifs-nxb

Then: health, sign-in and a signed-out / on all four hosts (ignition.nexusblack.ai, nxb-ignition.vercel.app, onboarding.nexusblack.ai, nxb-onboarding.vercel.app); pnpm test:e2e e2e/smoke.spec.ts --base-url https://ignition.nexusblack.ai; and a signed-out look at both /sign-up pages. Production refuses the test identities by design, so the signed-in suite runs locally or against a preview only. Nothing is promoted without Steven Buckle's (later Kevin van Heerden's) word, and never over a newer production: check that the commit you are about to deploy is a descendant of what the aliases serve.

6. Two Claude sessions, two worktrees

The Ignition session works in nxb-ignition, the Training session in nxb-ignition-training, a git worktree on a local branch training that is only a pointer: commits reach main with git pull --rebase origin main then git push origin HEAD:main. Each worktree has its own .env.local (ports IGNITION_PORT/ONBOARDING_PORT: 3000/3001 and 3100/3101), its own packages/db/dev.db and its own dev servers. Two lessons from 11 September 2026:

Both sessions write docs/testing/last-run.md, so rebases conflict there; keep the record of the run you just made. Stage only your own files, by name.

7. Testing a preview behind Vercel SSO

Onboarding previews sit behind Vercel Authentication (ssoProtection: all_except_custom_domains) and the project has no automation bypass secret, so the suite cannot sign in there from a terminal. Three routes:

Against a remote Ignition target without E2E_ONBOARDING_URL the onboarding and invitation specs skip themselves with a reason rather than failing.

8. Layout, width and the guides

src/app/(app)/layout.tsx: no maximum width; 32px side margins from md up (24px on mobile), a 220px module list, a 32px gap, and the content column takes the rest (md:grid-cols-[220px_minmax(0,1fr)]). Only running text is capped (--measure: 72ch on .prose children and .measure); tables, the plan and the Process diagram fill the column. The Playwright B test measures 1280, 1600, 1920, 2560 and 375 wide. Process diagrams render client-side from Mermaid 11.15.0 (cdnjs) via components/MermaidDiagrams.tsx, with a CSS reset so the portal's typography never reaches Mermaid's labels.

These guides are docs/src/onboarding/*.html, built by scripts/build-docs.mjs onboarding into apps/onboarding/public/docs/ (gitignored) as the first step of the onboarding build; 14 September 2026 is the only token. The Help control is components/HelpMenu.tsx; /docs/(.*) is public in the middleware so the sign-in page can link the joiner's guide.