Imported from kalevski/toolcase (
examples/public/next-fullstack-app/SKILL.md). Install upstream withnpx skills add kalevski/toolcase --skill next-fullstack-app. Copyright stays with the author.
name: next-fullstack-app
description: Use when scaffolding or extending a self-hosted full-stack Next.js App Router application with its own backend — a single-process app that owns auth, a SQLite system-of-record, REST API routes, and a tc-* web-components dashboard UI. This IS Next.js (the next package, App Router, RSC, route handlers, next build/next dev) — for a static prerendered marketing site use next-static-app instead, for a backend-only Node service use node-service. Defines the layered server (config → data/repositories → domain → infrastructure → services → web), the node:sqlite singleton + reentrant tx + append-only migration runner, GitHub-OAuth + HMAC-cookie auth with a rank-ordered role model, the guard→parse→validate→service→audit→json route contract with typed service errors (httpErrorFor), thin server-component pages delegating to 'use client' components (server page-guards or a client AuthGate + MeProvider), the lib/tc.ts web-components interop + the shared client UI kit (DataTable/FormModal/fields/states/SubTabBar), the AES-256-GCM secret-at-rest cipher keyring, prefixed random IDs, typed external-API client factories, globalThis background tickers, instrumentation boot hooks, the root-vitest test layout, and the monorepo Docker standalone build — plus optional proven patterns: a second machine-facing HTTP listener, multi-realm (multi-daemon) tenancy, and cron-scheduled host jobs. Apply when adding an API route, a page, a repository/table + migration, a service, an env var, a background ticker, a scheduled job, an encrypted secret, an auth/role check, or scaffolding a new full-stack app workspace.
next-fullstack-app — Architecture Reference
Opinionated blueprint for self-hosted, single-process, full-stack Next.js applications: an internal tool or control panel that owns its own auth, its own database, and a real-time dashboard UI — all in one deployable. App Router does double duty as the web framework (pages + RSC) and the HTTP backend (route handlers). State persists in a single SQLite file via Node's built-in node:sqlite. No external DB server, no separate API process, no DI container.
This is Next.js. If you need a statically-prerendered marketing/content site (no server runtime, no auth, no DB) use next-static-app. If you need a headless backend with no UI use node-service. This skill is for the case where one Next.js process is the whole product.
The reference implementations are quaykeeper (a static-hosting control plane) and taskforge (a Claude Code task runner). They share the same skeleton — lib/tc.ts is identical, server/data/db.ts and server/config.ts are ported with only names changed. That shared skeleton is what this skill encodes; deviation from it is a smell, not a style choice. quaykeeper is the larger reference: it layers the optional patterns (multi-realm tenancy, a machine-facing agent listener, cron jobs, the cipher keyring) onto the same skeleton, and where the two apps diverge (route guards, audit seam, page guards) this doc shows both variants and says when to pick which.
Stack baseline:
- Next.js 16 (App Router), React 19, TypeScript
strict.output: 'standalone',reactStrictMode: true,transpilePackages: ['@toolcase/base'],eslint.ignoreDuringBuilds: true. node:sqlite(DatabaseSync) — synchronous, WAL journal,foreign_keys = ON,busy_timeout = 5000. Single connection cached onglobalThis(survives dev hot-reload). Prepared-statement cache (Map<string, StatementSync>). Append-onlyMIGRATIONS[]runner. Reentranttx()(a depth counter lets nestedtx()calls join the open transaction).server-onlyimport at the top of every server module — a build-time tripwire that fails the bundle if backend code is ever pulled into a client component.@/*path alias → repo root.runtime = 'nodejs'+dynamic = 'force-dynamic'on every route/page that touches the DB or session.- GitHub OAuth code flow → signed
httpOnlysession cookie (<base64url payload>.<base64url HMAC-SHA256 sig>). Rank-ordered role model (ROLE_RANK) — three tiers (admin | standard | guest) or four (owner | maintainer | standard | guest). The access token is used during callback and never enters the session; if a later flow needs it, persist it only cipher-encrypted at rest. - Prefixed env vars — every app-owned var carries the app's namespace (
QUAYKEEPER_*,TASKFORGE_*); only shared infra knobs stay bare (PORT,DB_PATH,WORKSPACE_DIR,HOSTNAME). @toolcase/web-componentsfor the UI — framework-freetc-*custom elements rendered into light DOM, driven from React vialib/tc.ts.@toolcase/basefor helpers/data structures.- Node ≥ 22.5 (required for
node:sqlite; flag-free on Node 24). Docker builds onnode:22-slim/node:24-slim, Next standalone output, monorepo-root build context. - Stateful singletons (DB handle, schedulers, execution engines) cached on
globalThisrather than injected — they must survive Next.js dev hot-reload.
Optional @toolcase Libraries
@toolcase/web-components— primary UI primitives as framework-freetc-*Web Components (tc-dashboard-layout,tc-side-nav,tc-brand,tc-user-panel,tc-table,tc-chart, form inputs, overlays).register()once, client-side only, via a dynamic import inside a clientProviderscomponent — a static import evaluatesclass … extends HTMLElementduring SSR whereHTMLElementis undefined. Seeweb-componentsskill for the catalog andlib/tc.tsbelow for the React interop.@toolcase/base— pure helpers + data structures (browser + Node). Listed intranspilePackagesso Next resolves its named exports cleanly in the SSR build.@toolcase/logging— isomorphic logger when you need structured logs to a sink. Otherwiseconsole+ a smallserver-log.tsring buffer (see infrastructure) is enough.@toolcase/node— reach for it when a feature wants the heavier backend helpers (Redis KV, OAuth2/OIDC, image transforms, the engine-agnostic repository contract). The baseline app deliberately does NOT use it — rawnode:sqlite+ hand-written repositories keep the dependency surface tiny. Add it per-feature, not by default.
The architecture is library-independent — you can swap the UI primitives — but pick one primitives library per app and drive every cosmetic through its theming contract.
Workspace Layout
<app>/
├── package.json # next dev -p <port> / build / start / lint / typecheck
├── next.config.mjs # output: 'standalone', transpilePackages, (experimental.instrumentationHook)
├── tsconfig.json # strict, "@/*": ["./*"], jsx: react-jsx, moduleResolution: bundler
├── instrumentation.ts # boot hook — start background tickers once per server start
├── middleware.ts # thin edge guard — session-cookie PRESENCE only (optional)
├── Dockerfile # multi-stage monorepo build → node:22/24-slim standalone
├── run-docker.sh # local run: --user $(id -u), bind-mount the workspace volume
├── .env.example # every env var with a placeholder (committed; .env.local gitignored)
├── test/ # unit + integration tests — run by the ROOT vitest config (no local config/script)
├── scripts/ # dev/codegen scripts (e.g. regenerate types from an external API's OpenAPI schema)
├── app/ # App Router — pages + RSC + route handlers
│ ├── layout.tsx # imports tc style.css + react JSX typings + globals.css; <Providers>
│ ├── globals.css
│ ├── providers.tsx # 'use client' — dynamic register() web-components, gate UI behind it
│ ├── page.tsx # dashboard home (thin server component → client component)
│ ├── not-found.tsx
│ ├── login/ no-access/ <feature>/page.tsx
│ └── api/<area>/route.ts # guard → parse → validate → service → audit → json
├── components/ # 'use client' UI — AppShell, modals, per-feature <Feature>Client.tsx
├── lib/ # client glue — tc.ts (web-components interop), fetcher, contexts, toast, modal, icons
└── server/ # 'server-only' backend — the layered core
├── config.ts # validated env, fail-fast, NEXT_PHASE build deferral
├── data/
│ ├── db.ts # node:sqlite singleton + migrations + prep/getRow/allRows/exec/tx
│ └── repositories/ # one file per table area — Raw (snake_case) + map() → camelCase domain type
├── domain/ # PURE logic + types.ts. No I/O. Unit-tested from test/.
├── infrastructure/ # adapters to the outside world — github, slack, git, daemon clients, cipher, ids, server-log
├── services/ # orchestration — compose repos + domain + infra; auth.ts, roles.ts
└── web/ # http.ts (json/error/guard/audit), page-guards.ts, sse.ts
Directionality is the whole point. Dependencies point inward and down only:
app/api app/page → server/web → server/services → server/infrastructure
↓ ↓
server/domain ← server/data/repositories
↓ ↓
└──── server/domain/types.ts ────┘ (pure, also client-importable)
domain/imports nothing fromservices/,infrastructure/,data/, orapp/. It is pure functions + types — the only layer with unit tests.data/repositories/importdomain/types(for row shapes) anddata/db(forprep/getRow/allRows/tx). Nothing else.services/are the sole callers of repositories. A route never imports a repository directly — it calls a service.infrastructure/wraps external systems (HTTP APIs, child processes, the filesystem) behind typed functions + typed errors. Services call infra; infra never calls services.web/+app/are the HTTP edge. They call services, format responses, and own nothing else.
Data Layer — server/data/db.ts
The single owner of the node:sqlite handle. Copy it verbatim per app and change only the global name (__<app>Db), the DB-path resolution, and the MIGRATIONS[] array.
import 'server-only'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import type { DatabaseSync, StatementSync } from 'node:sqlite' // type-only — erased at compile time
import { config } from '@/server/config'
interface DbWrap { db: DatabaseSync; stmts: Map<string, StatementSync> }
declare global { var __appDb: DbWrap | undefined }
// node:sqlite is a runtime built-in (Node ≥ 22.5). Load it via getBuiltinModule so
// importing this file on an older runtime doesn't crash at import — it fails fast,
// with an actionable message, only when the DB is first touched.
function loadDatabaseSync(): typeof DatabaseSync {
const getBuiltin = (process as any).getBuiltinModule
const mod = typeof getBuiltin === 'function' ? getBuiltin('node:sqlite') : undefined
if (mod?.DatabaseSync) return mod.DatabaseSync
throw new Error(`[app] node:sqlite unavailable on Node ${process.version}. Requires Node >= 22.5.`)
}
function open(): DbWrap {
const DB = loadDatabaseSync()
mkdirSync(path.dirname(config.dbPath), { recursive: true })
const db = new DB(config.dbPath)
db.exec('PRAGMA journal_mode = WAL;') // concurrent readers + one writer, durable
db.exec('PRAGMA foreign_keys = ON;')
db.exec('PRAGMA busy_timeout = 5000;')
migrate(db)
return { db, stmts: new Map() }
}
function wrap(): DbWrap { return (globalThis.__appDb ??= open()) }
The migration runner — ordered, append-only. Each entry's index + 1 is its version. Never reorder or rewrite an applied migration; only append:
const MIGRATIONS: string[] = [
`CREATE TABLE app_user (
github_id INTEGER PRIMARY KEY, login TEXT NOT NULL, name TEXT NOT NULL,
avatar_url TEXT, role TEXT NOT NULL, added_at TEXT NOT NULL
);
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT NOT NULL,
github_id INTEGER, login TEXT, action TEXT NOT NULL, detail TEXT
);`,
// v2 — additive change. Use ALTER TABLE ... ADD COLUMN; nullable or DEFAULT so existing rows survive.
`ALTER TABLE app_user ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0;`,
]
function migrate(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL);`)
const applied = new Set((db.prepare('SELECT version FROM schema_migrations').all() as any[]).map((r) => r.version))
const insert = db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
// FKs toggle OUTSIDE any transaction (the pragma is a no-op inside one). Off for the whole
// run so table-REBUILD migrations (CREATE new → copy → DROP old → RENAME) don't trip references.
db.exec('PRAGMA foreign_keys = OFF;')
try {
for (let i = 0; i < MIGRATIONS.length; i++) {
const version = i + 1
if (applied.has(version)) continue
db.exec('BEGIN')
try { db.exec(MIGRATIONS[i]); insert.run(version, new Date().toISOString()); db.exec('COMMIT') }
catch (err) { db.exec('ROLLBACK'); throw err }
}
} finally { db.exec('PRAGMA foreign_keys = ON;') }
}
The only surface repositories use:
export function prep(sql: string): StatementSync { // prepare once, cache by SQL text
const w = wrap(); let s = w.stmts.get(sql)
if (!s) { s = w.db.prepare(sql); w.stmts.set(sql, s) }
return s
}
export function getRow<T>(sql: string, ...p: any[]): T | undefined { return prep(sql).get(...p) as T | undefined }
export function allRows<T>(sql: string, ...p: any[]): T[] { return prep(sql).all(...p) as T[] }
export function exec(sql: string): void { wrap().db.exec(sql) }
let txDepth = 0
export function tx<T>(fn: () => T): T { // reentrant — DatabaseSync has no nested
const { db } = wrap() // transactions, so an inner tx() joins the
if (txDepth > 0) return fn() // outer one; only the outermost owns BEGIN/COMMIT
txDepth++; db.exec('BEGIN')
try { const r = fn(); db.exec('COMMIT'); return r }
catch (e) { db.exec('ROLLBACK'); throw e }
finally { txDepth-- }
}
export function initDb(): void { wrap() } // eager open (called from instrumentation)
Rules:
- All schema is migrations. Adding a table or column = a new
MIGRATIONS[]entry, never an edit to an existing one. Additive changes (ADD COLUMNnullable or withDEFAULT) so a running instance upgrades without data loss. - Non-additive changes are rebuild migrations. SQLite can't alter constraints/PKs in place — one migration entry does
CREATE TABLE <x>_new … ; INSERT INTO <x>_new SELECT … FROM <x>; DROP TABLE <x>; ALTER TABLE <x>_new RENAME TO <x>;(the FK-off window above makes this safe). getRow<T>/allRows<T>centralize the cast fromnode:sqlite'sRecord<string, SQLOutputValue>to the caller's row shape. Repos stay free ofas unknown asnoise.- Always parameterize (
?placeholders + bound args). Never interpolate values into SQL. - Timestamps are ISO-8601 strings (
new Date().toISOString()) inTEXTcolumns. SQLite has no native date type; string ISO sorts lexically.
Repositories — server/data/repositories/<area>-repo.ts
One file per table (or tight cluster). A Raw interface mirrors the snake_case DB columns; a map() converts to the camelCase domain type. Named function exports, no class:
import 'server-only'
import { prep, getRow, allRows } from '@/server/data/db'
import type { Site } from '@/server/domain/types'
interface Raw { id: string; owner_id: number; hostname: string; status: string; created_at: string }
function map(r: Raw): Site {
return { id: r.id, ownerId: r.owner_id, hostname: r.hostname, status: r.status as Site['status'], createdAt: r.created_at }
}
export function create(s: Site): void {
prep(`INSERT INTO site (id, owner_id, hostname, status, created_at) VALUES (?, ?, ?, ?, ?)`)
.run(s.id, s.ownerId, s.hostname, s.status, s.createdAt)
}
export function byId(id: string): Site | undefined {
const r = getRow<Raw>(`SELECT * FROM site WHERE id = ?`, id); return r ? map(r) : undefined
}
export function listByOwner(ownerId: number): Site[] {
return allRows<Raw>(`SELECT * FROM site WHERE owner_id = ? ORDER BY created_at DESC`, ownerId).map(map)
}
- The
Raw → map()boundary is mandatory: SQL speaks snake_case, the rest of the app speaks camelCase. The mapping lives here and nowhere else. - Repositories contain only SQL + mapping. No business rules, no validation, no orchestration — those live in
domain/andservices/. - An optional
repositories/index.tsdocuments the layer + theserver-onlyconvention; import repos directly (import * as siteRepo from '@/server/data/repositories/site-repo').
Domain Layer — server/domain/
Pure, synchronous, I/O-free logic + shared types. This is the only layer with unit tests (which live in test/ — see Testing below).
types.ts— every shared type + constant (Role,ROLE_RANK,SessionPayload,MeResponse, entity shapes, enums). It must not import anything server-only (nofs, nochild_process, nonode:*) — it is imported from both server and client code. This is the contract between the two halves.- Other files (
quota.ts,hostname.ts,cron.ts,deploy-machine.ts,resource-state.ts,config-resolution.ts, …) hold pure decisions: validation, state-machine transitions, diffing, derived values. Given inputs, return outputs — no DB, no network, no clock-reading beyond a passed-innow. - If a function is hard to test because it does I/O, it belongs in a service, not the domain.
- A domain file that is also imported client-side (e.g. a validator shared with a form editor) must use only isomorphic APIs —
TextEncoderfor byte-length caps, neverBuffer.
// server/domain/types.ts
export type Role = 'owner' | 'maintainer' | 'standard' | 'guest' // or 3-tier: 'admin' | 'standard' | 'guest'
export const ROLE_RANK: Record<Role, number> = { guest: 0, standard: 1, maintainer: 2, owner: 3 }
// 'guest' is a runtime fallback for an unresolvable role — never stored, never assignable.
export interface SessionPayload { sub: number; login: string; role: Role; iat: number; exp: number }
export interface MeResponse { githubId: number; login: string; name: string; avatarUrl?: string; role: Role }
Testing — root vitest, <app>/test/
The app has no vitest dependency, config, or test script — the repo-root vitest.config.ts discovers **/*.test.ts across all workspaces and supplies two aliases that make server modules loadable under plain Node:
resolve: { alias: [
{ find: /^@\//, replacement: fileURLToPath(new URL('./<app>/', import.meta.url)) }, // the app's @/* paths
// `server-only` throws outside a Next bundler; its own no-op `empty.js` lets server modules unit-test under Node.
{ find: /^server-only$/, replacement: fileURLToPath(new URL('./node_modules/server-only/empty.js', import.meta.url)) },
]},
test: { include: ['**/*.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', '**/lib/**'], environment: 'node' },
- Unit tests (
test/<thing>.test.ts) exercisedomain/pure functions directly. - Integration tests (
test/<flow>.integration.test.ts) boot the real stack: set every required env var before importing anything (configreads env at import time), point the DB path at a temp file, then dynamic-import()and run real migrations against realnode:sqlite:
process.env.APP_DB_PATH = path.join(mkdtempSync(path.join(tmpdir(), 'app-test-')), 'test.db')
process.env.APP_AUTH_SECRET = 'x'.repeat(32) // …every required var, minLen-compliant
const db = await import('@/server/data/db'); db.initDb() // real migrations, real SQLite
const { runJob } = await import('@/server/services/jobs')
Infrastructure Layer — server/infrastructure/
Adapters to the outside world, one file per external system. Each wraps its I/O behind typed functions and throws typed errors the service/route layer can branch on:
github.ts— GitHub REST/GraphQL calls (fetch,cache: 'no-store').git.ts,agent.ts— child-process spawns with timeouts;GitError { stderr }etc.slack.ts,notify.ts— outbound webhooks.server-log.ts— an in-memory ring buffer for the health/log surface.fs-workspace.ts— guarded filesystem ops;UnsafePathErrorfor path-traversal attempts.cipher.ts— the AES-256-GCM secret-at-rest keyring (below).ids.ts— prefixed random IDs (below).
Rules: infra never imports services (no upward calls); it never touches the DB (that's repositories); it owns timeouts, retries, and the typed-error vocabulary for its system. A hung external call must not be able to wedge a request forever — give every spawn/fetch a deadline.
cipher.ts — secret-at-rest keyring (AES-256-GCM)
Anything secret that must round-trip (third-party admin tokens, DB passwords, a user's OAuth token) is stored encrypted, never plaintext. Ciphertext format: v<keyId>.<b64url iv>.<b64url ciphertext>.<b64url tag> — 12-byte random IV, and keyId = first 8 hex of sha256(secret) stamps which ring key sealed it:
function keyring(): string[] {
// Dedicated key + previous key (rotation window) when set; else derive one from AUTH_SECRET.
if (config.secretKey) return [config.secretKey, config.secretKeyPrev].filter(Boolean) as string[]
const derived = crypto.hkdfSync('sha256', Buffer.from(config.authSecret, 'utf8'),
Buffer.alloc(0), Buffer.from('<app>-secrets-v1', 'utf8'), 32)
return [Buffer.from(derived).toString('hex')]
}
// encrypt() always seals with ring[0]; decrypt() selects the ring key matching the v<keyId> stamp.
// AES key = sha256(ring entry) → always 32 bytes regardless of the secret string's length.
Rotation: set <APP>_SECRET_KEY to the new key and <APP>_SECRET_KEY_PREV to the old — existing ciphertext still decrypts, new writes re-seal with the new key. DTOs never expose the value; mask to hasToken: boolean.
ids.ts — prefixed random IDs
export const ID = { site: 'site', job: 'job', run: 'run', secret: 'sec', instance: 'inst' } as const
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
export function newId(prefix: string): string {
const bytes = crypto.randomBytes(11); let out = ''
for (const b of bytes) out += ALPHABET[b % ALPHABET.length]
return `${prefix}_${out}` // self-describing: 'job_x8k2m…' reads in logs, audit rows, URLs
}
One scheme per app — register every entity's prefix on the ID object; don't grow a second unprefixed generator alongside it.
Typed external-API client — factory over a connection
When the app drives a downstream daemon/REST API, wrap it in a factory that closes over the connection, so the same typed surface targets whichever tenant/realm the caller resolved:
export class DaemonError extends Error {
constructor(msg: string, public status?: number, public detail?: string) { super(msg) }
}
export function daemonClient(conn: { baseUrl: string; token?: string }) {
const authHeaders = () => (conn.token ? { Authorization: `Bearer ${conn.token}` } : {})
async function adminFetch(method: string, apiPath: string, init: RequestInit = {}): Promise<Response> {
try {
return await fetch(new URL(apiPath, conn.baseUrl), { ...init, method, cache: 'no-store',
headers: { ...authHeaders(), ...(init.headers ?? {}) }, signal: AbortSignal.timeout(15_000) })
} catch (err) {
const timedOut = err instanceof DOMException && err.name === 'TimeoutError'
throw new DaemonError(timedOut ? 'daemon timed out' : 'daemon unreachable', timedOut ? 504 : 502)
}
}
async function adminOk(method: string, apiPath: string, init: RequestInit = {}): Promise<Response> {
const res = await adminFetch(method, apiPath, init)
if (!res.ok) throw new DaemonError(`${method} ${apiPath} failed (${res.status})`, res.status,
(await res.text().catch(() => '')).trim() || undefined) // forward the daemon's body to operators
return res
}
return { status: () => adminOk('GET', '/status').then((r) => r.json()) /* …one method per endpoint… */ }
}
export type DaemonClient = ReturnType<typeof daemonClient>
- Timeout → 504, transport error → 502, non-2xx → typed error carrying the daemon's
detail. Long-running ops (cert issuance, builds) get their own larger timeout constant. DELETEtreats the target's 404 as idempotent success.- If the daemon serves an OpenAPI schema, add
scripts/generate-<daemon>-types.mjs(fetchGET /schema→npx openapi-typescript→ a checked-in.d.ts) behind atypegen:<daemon>npm script.
Machine credentials — store the hash, never the key
Non-cookie callers (agents, CI, sidecars) authenticate with a minted fetch key. Mint once, show once, store only the hash:
const rawKey = crypto.randomBytes(32).toString('base64url') // returned to the operator ONCE
repo.setKeyHash(id, crypto.createHash('sha256').update(rawKey).digest('hex'))
// Verify: hash the presented key, constant-time compare.
const a = Buffer.from(sha256Hex(presented)), b = Buffer.from(row.keyHash)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { ok: false, status: 401 }
Services Layer — server/services/
Orchestration + policy. A service is the sole caller of the repositories beneath it; it composes domain/ decisions with data/ persistence and infrastructure/ I/O. Import directly: import * as sites from '@/server/services/sites'. An optional services/index.ts is a doc-only layer marker — import 'server-only' + export {} + a comment block naming each service and its responsibility; never a barrel.
Every service shares one error contract: typed error(s) carrying { code, status } plus an httpErrorFor(err) mapper, so a route's catch is one line:
export class SiteError extends Error {
constructor(public code: string, public status: number, msg?: string) { super(msg ?? code) }
}
export function httpErrorFor(err: unknown): { status: number; code: string } {
if (err instanceof SiteError) return { status: err.status, code: err.code }
if (err instanceof DaemonError) return { status: err.status === 504 ? 504 : 502, code: 'daemon_unreachable' }
return { status: 500, code: 'internal' }
}
Audit seam — pick ONE per app: in the route after the service call (taskforge style, via web/http.ts's audit()), or inside the service next to the mutation it describes (quaykeeper style, a local helper over auditRepo.append). Either way it is best-effort and never blocks the mutation.
Two services exist in every app:
auth.ts — GitHub OAuth + HMAC session cookie
import 'server-only'
import crypto from 'node:crypto'
import { cookies } from 'next/headers'
import { config } from '@/server/config'
import { ROLE_RANK, type Role, type SessionPayload } from '@/server/domain/types'
export const SESSION_COOKIE = 'app_session'
export const STATE_COOKIE = 'app_oauth_state'
const b64url = (i: Buffer | string) => Buffer.from(i).toString('base64url')
const hmac = (d: string) => crypto.createHmac('sha256', config.authSecret).update(d).digest('base64url')
function signToken(obj: unknown): string { const p = b64url(JSON.stringify(obj)); return `${p}.${hmac(p)}` }
function verifyToken<T>(token: string | undefined): T | null {
if (!token) return null
const dot = token.lastIndexOf('.'); if (dot <= 0) return null
const payload = token.slice(0, dot), sig = token.slice(dot + 1)
const a = Buffer.from(sig), b = Buffer.from(hmac(payload))
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null // constant-time compare
try { return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as T } catch { return null }
}
export async function getSession(): Promise<SessionPayload | null> {
const p = verifyToken<SessionPayload>((await cookies()).get(SESSION_COOKIE)?.value)
if (!p || typeof p.exp !== 'number' || p.exp * 1000 < Date.now()) return null // verify expiry too
return p
}
// The per-request authorization guard. Re-reads the role from the AUTHORITATIVE source
// (DB app_user.role or a roles.json file) so promotions/demotions take effect without re-login.
export type AuthzResult = { ok: true; session: SessionPayload; role: Role } | { ok: false; status: 401 | 403 }
export async function authorize(minRole: Role): Promise<AuthzResult> {
const session = await getSession()
if (!session) return { ok: false, status: 401 }
const role = (await getRole(session.sub)) ?? 'guest'
if (ROLE_RANK[role] < ROLE_RANK[minRole]) return { ok: false, status: 403 }
return { ok: true, session, role }
}
Cookie + OAuth contract:
- Token =
<base64url(JSON payload)>.<base64url(HMAC-SHA256(payload, AUTH_SECRET))>. Verify withcrypto.timingSafeEqual. Checkexpon every read. httpOnly,sameSite: 'lax',path: '/',secure: truein production,maxAge = sessionTtl. A separate short-lived (maxAge: 600) HMAC state cookie guards the OAuth callback against CSRF — and the state token is single-use: a consumed-nonce set onglobalThis(self-sweeping) rejects replays within the TTL.- OAuth code flow:
buildAuthorizeUrl(state)→ GitHub →/api/auth/github/callbackexchanges the code (exchangeCodeForToken), fetches the profile (fetchGithubProfile), runs the optional allow-list / org-membership gate (checkAllowlist), resolves the role, sets the session cookie. The GitHub access token never enters the session payload. If a later flow genuinely needs it (private-repo access), seal it with the cipher keyring into its own table (user_github_token) and decrypt server-side per use — never plaintext, never client-visible. - First-login bootstrap — the first user ever becomes the top role, decided inside
tx()so two concurrent first logins can't both win:
const role: Role = tx(() => {
const r: Role = userRepo.ownerCount() === 0 ? 'owner' : 'standard'
userRepo.upsert({ ...profile, role: r }); return r
})
- Role is re-read per request from the authoritative source, not trusted from the cookie payload (which is an advisory hint). The authoritative source is either the
app_user.roleDB column (quaykeeper style — fold role resolution intoauth.ts) or aroles.jsonfile read by a separateservices/roles.ts(taskforge style). Either way: change a role, it applies on the next request without re-login.
roles.ts (optional) — authoritative role source
When roles live in a file rather than the DB, this service reads/writes roles.json and exposes getRole(githubId) + an owner-bootstrap rule (first login becomes owner/admin). When roles live in the DB, fold getRole into auth.ts and drop this file.
Other services are app-specific (sites.ts, deploy.ts, quota.ts, projects.ts, scheduler.ts, execution-manager.ts, …). Each: guards nothing (the route already authorized), validates via domain/, persists via repositories, drives I/O via infrastructure/, and exposes a small typed surface + a domain-error → HTTP mapping helper where useful.
Web Layer — server/web/
The thin seam between route handlers and services. Three files:
http.ts — route helpers
import 'server-only'
import { NextResponse } from 'next/server'
import { authorize, type AuthzResult } from '@/server/services/auth'
import * as auditRepo from '@/server/data/repositories/audit-repo'
import type { Role } from '@/server/domain/types'
export function json(data: unknown, status = 200) { return NextResponse.json(data, { status }) }
export function error(message: string, status: number) { return NextResponse.json({ error: message }, { status }) }
// Guard a route by minimum role. Returns the authorized result, or a ready-to-return 401/403.
export async function guard(minRole: Role): Promise<{ res: NextResponse } | Extract<AuthzResult, { ok: true }>> {
const r = await authorize(minRole)
if (!r.ok) return { res: error(r.status === 401 ? 'unauthorized' : 'forbidden', r.status) }
return r
}
// Record who did what — best-effort; an audit failure never blocks the mutation it describes.
export function audit(auth: Extract<AuthzResult, { ok: true }>, action: string, detail?: string | null): void {
try { auditRepo.append({ githubId: auth.session.sub, login: auth.session.login, action, detail }) } catch {}
}
page-guards.ts — server-component guards
import 'server-only'
import { redirect } from 'next/navigation'
import { getSession } from '@/server/services/auth'
import { getUser } from '@/server/services/roles'
import { ROLE_RANK, type MeResponse, type Role } from '@/server/domain/types'
export async function requireSession(): Promise<MeResponse> {
const session = await getSession()
if (!session) redirect('/login')
const user = await getUser(session.sub)
return { githubId: session.sub, login: user?.login ?? session.login, name: user?.name ?? session.login, avatarUrl: user?.avatarUrl, role: user?.role ?? 'guest' }
}
export async function requireRole(minRole: Role): Promise<MeResponse> {
const me = await requireSession()
if (ROLE_RANK[me.role] < ROLE_RANK[minRole]) redirect(me.role === 'guest' ? '/no-access' : '/')
return me
}
sse.ts — Server-Sent Events (only if you stream live state)
A ReadableStream that (1) sends a snapshot, (2) replays a ring buffer tagged replay: true so late subscribers rebuild scrollback without re-firing toasts, (3) subscribes to a live event emitter, (4) sends a : ping heartbeat every ~25 s, and (5) tears down on cancel(). Response headers: Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, X-Accel-Buffering: no.
Inline variant (quaykeeper): no
server/web/directory — every route inlines the same three steps and stays ~15 lines:const authz = await authorize('standard') if (!authz.ok) return NextResponse.json({ error: 'unauthorized' }, { status: authz.status }) let body: unknown try { body = await req.json() } catch { return NextResponse.json({ error: 'invalid_json' }, { status: 400 }) } try { return NextResponse.json(await svc.createSite(actor, body)) } catch (err) { const { status, code } = svc.httpErrorFor(err); return NextResponse.json({ error: code }, { status }) }Audit happens inside the service in this variant. taskforge instead factors
guard/json/error/auditintoweb/http.ts. Both are valid — pick one and keep every route identical to its siblings.
API Routes — app/api/<area>/route.ts
Every route handler follows the same contract: declare runtime → guard → parse → validate → call service → audit → respond, with domain errors mapped to HTTP status.
import { guard, json, error, audit } from '@/server/web/http'
import { getProjectSummaries } from '@/server/services/projects'
import { createProject, ProjectExistsError } from '@/server/services/provision'
import { GitError } from '@/server/infrastructure/git'
export const runtime = 'nodejs' // node:sqlite + node:crypto need the Node runtime, not Edge
export const dynamic = 'force-dynamic' // never statically cache an authed, DB-backed response
export const maxDuration = 300 // only when an operation legitimately runs long (clone, build)
export async function GET() {
const auth = await guard('standard')
if ('res' in auth) return auth.res
return json(await getProjectSummaries())
}
export async function POST(req: Request) {
const auth = await guard('standard')
if ('res' in auth) return auth.res
const body = (await req.json().catch(() => ({}))) as { name?: string; gitUrl?: string }
const name = (body.name ?? '').trim()
if (!name) return error('name required', 400)
try {
await createProject({ name, gitUrl: body.gitUrl })
audit(auth, 'project.create', name)
return json({ name }, 201)
} catch (e) {
if (e instanceof ProjectExistsError) return error('project already exists', 409)
if (e instanceof GitError) return error(`clone failed: ${e.message}`, 422)
throw e // unknown → let Next return 500
}
}
Rules:
export const runtime = 'nodejs'andexport const dynamic = 'force-dynamic'on every route that touches the DB or session. The default Edge runtime has nonode:sqlite/node:crypto; the default caching would serve one user's authed response to another.- Guard first, before reading the body.
const auth = await guard(minRole); if ('res' in auth) return auth.res. (Direct-authorizevariant:const authz = await authorize(minRole); if (!authz.ok) return error(...).) - Parse the body defensively:
await req.json().catch(() => ({})), then validate each field →error(msg, 400). Never trust shape. - Push all real work into a service. The route is glue: it authorizes, validates, delegates, audits, and shapes the response.
- Map known domain/infra errors to status codes (
409conflict,422unprocessable,404not found,400bad input); re-throw the unknown so Next returns 500. At scale, replace per-routeinstanceofchains with the service'shttpErrorFor(err)mapper — onecatchline per route. audit(auth, 'verb.noun', detail)after every mutation. Audit is best-effort and never blocks the mutation.- Dynamic segments:
{ params }: { params: Promise<{ id: string }> }—paramsis a Promise in Next 15+;awaitit.
Pages — app/<feature>/page.tsx
Pages are thin server components. They declare runtime, optionally guard + fetch, then delegate all rendering to a 'use client' component. The UI is client-only because tc-* elements register in the browser.
import { OverviewClient } from '@/components/project/OverviewClient'
import type { Metadata } from 'next'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function generateMetadata(ctx: { params: Promise<{ project: string }> }): Promise<Metadata> {
return { title: (await ctx.params).project }
}
export default function RepoOverviewPage() {
return <OverviewClient />
}
For a guarded, server-data page, call requireRole()/requireSession() (which redirect() on failure) and pass the result down:
export default async function UsersPage() {
const me = await requireRole('admin') // redirects guests/standard before any render
return <AppShell me={me}><UsersClient /></AppShell>
}
root layout.tsximports the web-components stylesheet + JSX typings + globals, setsmetadata(title template), and wraps children in<Providers>.- Per-page
generateMetadatais async andawaitsparams.
Two page-guard models — pick one
- Server page-guards (taskforge): every page RSC calls
requireSession()/requireRole(), whichredirect()before the client tree mounts. Best when pages render server data. - Client AuthGate (quaykeeper): authenticated pages are unguarded RSC shells rendering
<AuthGate><FeatureClient/></AuthGate>.AuthGatefetchesGET /api/meonce via the typed fetcher, shows loading / error+retry, bouncesisAuthErrorto/login, then mounts the whole authenticated tree:<MeProvider me={me}><ToastProvider><AppShell>…. Every descendant readsuseMe()from context instead of re-fetching. Per-area hooks additionallyrouter.replace('/')for under-ranked roles.
Either way the security boundary is authorize() in every /api route — page redirects and client gates are UX only, and the code should say so in a comment. One server-side guard stays mandatory in the AuthGate model: /login must bounce already-authed users using the same gate /api/me uses (if ((await authorize('standard')).ok) redirect('/')), otherwise an unprovisioned guest session ping-pongs between /login and /.
Frontend — app/providers.tsx, lib/tc.ts, components/
Providers — register web-components client-side only
'use client'
import { useEffect, useState, type ReactNode } from 'react'
export function Providers({ children }: { children: ReactNode }) {
const [registered, setRegistered] = useState(false)
useEffect(() => {
let active = true
void import('@toolcase/web-components').then((m) => { if (active) { m.register(); setRegistered(true) } })
return () => { active = false }
}, [])
if (!registered) return null // gate children until elements are defined — see note
return <>{children}</>
}
Why dynamic + gated: a static import '@toolcase/web-components' evaluates class … extends HTMLElement during SSR/prerender where HTMLElement is undefined. Importing inside useEffect defers it to the browser. Gating children behind registered removes a race: React sets JS properties on tc-* elements at attach time (via lib/tc.ts); if the element isn't upgraded yet, those land as own data properties that later shadow the class's prototype setters — the component renders empty on a hard refresh. Return null until registered (matches the server's null, so no hydration mismatch).
lib/tc.ts — React ↔ web-components interop (copy verbatim)
React 18/19 through JSX can't set object/array values as DOM properties (it stringifies to attributes) and can't subscribe to CustomEvents. tc-* needs both. useTc(props, handlers) returns a callback ref you attach to the element:
const ref = useTcProps<HTMLElement>({ sections, onItemClick }) // object props applied as element properties
const ref2 = useTcEvents<HTMLElement>({ 'tc-change': (e) => setValue(detailValue(e)) }) // CustomEvent listeners
return <tc-side-nav ref={ref} />
- A callback ref (not
useRef) is essential: it fires on attach (with the node) and detach (withnull), so it works for elements that mount after first render (conditional JSX) and pairs correctly under StrictMode's dev remount. useMemoarray/object prop values at the call site so identity changes only when data does (the hook re-applies props when their values change).- Helpers:
escapeHtml(v)(fortc-tablerendercells, which return HTML strings — escape user data),targetValue(e),targetChecked(e),detailValue(e)(readsdetail.valueoff atc-change).
components/ — the shared UI kit
'use client' everywhere. Naming: AppShell.tsx (the chrome), <Feature>Client.tsx per page, per-area subdirs. A small kit of generic wrappers keeps feature code declarative — copy these into any new app:
DataTable.tsx— wrapstc-table(columns + rows set as element properties; relocation-safe). Row actions render as HTML strings inside a columnrender(row)(escape user data withescapeHtml); clicks route through ONE delegated host listener —closest('[data-action]')→onAction(action, el.dataset, event)— bound for bothclickandchangeso<select>s in cells work too. The same delegation idiom drivestc-advanced-tablewhen rows are an injected escaped-HTML string.FormModal.tsx— wrapstc-modal(title/busy/submitLabel/onSubmit/onClose, optionalsecondary). Remount per open, keyed on the edit target (key={editing?.id ?? 'new'}) — tc-modal captures children at connect. A hidden submit button makes Enter submit;busysetsstatic-backdrop; every close path (X/Esc/backdrop) lands inonCloseviatc-hidden.ConfirmDialog.tsx— declarativeopenprop; never self-closes — the caller flips state ontc-confirm/tc-cancel. For destructive deletes, aTypeToConfirmModal(type the resource name to arm the button) built on FormModal.Toast.tsx— context provider +useToast().show(message, { variant, title }); returns a no-op outside the provider; renders a fixed stack oftc-toast(autohide), each removing itself from state ontc-hidden.fields.tsx—TextField/TextAreaField/SelectField/CheckField/SwitchFieldover the tc inputs; controlledvalue/onValue(orchecked/onChecked); sharedstate/error/helpvalidation props.SelectFieldkeys on the joined option values so a changed option set forces a clean remount (tc-select snapshots its<option>children at connect).states.tsx—LoadingState(skeleton silhouettes byshape),ErrorState(banner + in-place Retry; pair withdescribeApiError),EmptyState(icon + title + CTA). Every data view renders exactly one of these or its content — never a bare "Loading…".SubTabBar.tsx—tc-tab-barover route hrefs; active tab = longest href prefix ofpathname(so/admin/usersbeats/admin); click =router.push.CommandPalette.tsx—tc-command-palettetoggled by a windowkeydown(⌘K/Ctrl-K); items = a role-gated navigation list mirroring the side nav;tc-selectreadsdetail.itemand pushes the route.lib/action-icons.tsx— oneACTION_ICONSmap (semantic name → lucide-static SVG string, size-stripped so CSS owns sizing) with two render forms:iconBtnHtml({ icon, label, data, danger })(escaped HTML string for injected table cells, carriesdata-*for delegation) and<IconBtn/>(JSX). Add icons to the map — never inline a raw SVG.lib/fetcher.ts— a typedapiFetch<T>(url, opts)with anAbortControllertimeout (default 10 s), a smallApiErrorKindunion (unauthorized | forbidden | notfound | server | timeout | network | parse), anApiErrorclass,describeApiError()for user copy, andisAuthError()to decide a/loginbounce. One fetch stack per app — quaykeeper drifted into a second result-objectcallApihelper alongside the throwingapiFetch; wrap one over the other, don't fork.lib/me-context.tsx—MeProvider({ me })+useMe()(throws outside the provider). AuthGate fetches/api/meonce; AppShell, CommandPalette, and every gate hook read from context.
AppShell — the chrome
Composes tc-dashboard-layout via named slots (brand, sidebar-menu, sidebar-panel) + a skip-link + <CommandPalette/>. Nav is data: sections: SideNavSection[] built in useMemo from ROLE_RANK[me.role] — base section always, higher-rank sections push()ed by rank, admin section owner-only. Active item derives per-item from usePathname(); clicks stay client-side (event.preventDefault(); router.push(item.href)). Sign-out = POST /api/auth/logout → router.push('/login'), wired to the user-panel's trailing icon. Wrap the brand slot content in a plain <div slot="brand"> — the layout relocates it once at connect, so its contents must be stable.
Per-area shared.tsx + the gate hook
Each feature area gets a shared.tsx holding (1) a role-gate data hook and (2) a page frame. Write the hook once, parameterized by minRole — don't fork a copy per role:
export function useAreaData<T>(fetcher: () => Promise<T | null>, minRole: Role = 'maintainer') {
const me = useMe(); const router = useRouter()
const [state, setState] = useState<{ phase: 'loading' | 'forbidden' | 'error' | 'ready'; data?: T }>({ phase: 'loading' })
useEffect(() => {
if (ROLE_RANK[me.role] < ROLE_RANK[minRole]) { setState({ phase: 'forbidden' }); router.replace('/'); return }
let cancelled = false
void fetcher().then((data) => { if (!cancelled) setState(data ? { phase: 'ready', data } : { phase: 'error' }) })
return () => { cancelled = true }
}, [me.role, router]) // fetcher deliberately omitted — callers pass a useCallback-stable fetcher
return state
}
The page frame (<AreaPage title=… state=…>) renders tc-rich-page-header + the phase switch (LoadingState/ErrorState/children). A feature client then reduces to: stable useCallback fetcher → gate hook → frame → table + modals, with reload() + toast.show() after every mutation. This gate is a UX nicety, not the security boundary — the API routes authorize independently.
Detail pages with tab sub-routes
No nested layout.tsx. Each tab is its own page.tsx — an RSC that awaits params and renders the SAME client shell with a literal tab prop:
export default async function DbServerUsersPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <AuthGate><DbServerDetail serverId={id} tab="users" /></AuthGate>
}
The shell fetches the parent resource, renders header + back link + <SubTabBar tabs={…}/>, then switches {tab === 'users' && <UsersTab server={server}/>}. The bare [id]/page.tsx just redirect()s to the default tab. Tab navigation remounts the shell (re-fetches the parent) — acceptable for a control panel; each tab owns its own live reads.
Branding / theming — lib/branding-context.tsx
BrandingProvider fetches the public GET /api/settings once (initial state = DEFAULT_SETTINGS from pure domain/settings.ts, so no flash), exposes useBranding() + refresh() (the admin settings page calls refresh() after save for a live re-skin). Theme applies as data-tc-theme / data-tc-variant attributes on document.documentElement; the default theme removes the attribute (it's the bare :root voice). Source of truth is server settings, not localStorage. Provider order matters: BrandingProvider wraps Providers (the register gate) in layout.tsx, so theme attributes exist before elements upgrade; MeProvider/ToastProvider live inside AuthGate.
Config — server/config.ts
One validated, fail-fast config object. Reads process.env once at first import. Copy the helper set verbatim:
import 'server-only'
function required(name: string): string {
const v = process.env[name]
if (!v || !v.trim()) {
if (process.env.NEXT_PHASE === 'phase-production-build') return '' // env absent during build tracing
throw new Error(`[app] Missing required env var: ${name}. See .env.example.`)
}
return v
}
function requiredSecret(name: string, minLen: number): string {
const v = required(name)
if (v && v.length < minLen) throw new Error(`[app] ${name} must be >= ${minLen} chars. e.g. \`openssl rand -hex 32\``)
return v
}
const optional = (name: string, fb: string) => process.env[name]?.trim() || fb
const num = (name: string, fb: number) => { const r = process.env[name]; const n = Number(r); return r && Number.isFinite(n) ? n : fb }
const bool = (name: string, fb: boolean) => { const r = process.env[name]; return r ? r === '1' || r.toLowerCase() === 'true' : fb }
const csv = (name: string) => (process.env[name] ?? '').split(',').map((s) => s.trim()).filter(Boolean)
export const config = {
githubClientId: required('GITHUB_CLIENT_ID'),
githubClientSecret: required('GITHUB_CLIENT_SECRET'),
oauthRedirectUri: required('OAUTH_REDIRECT_URI'),
authSecret: requiredSecret('AUTH_SECRET', 16),
sessionTtl: num('SESSION_TTL', 86400),
allowedLogins: csv('GITHUB_ALLOWED_LOGINS'), // empty = open
allowedOrg: optional('GITHUB_ALLOWED_ORG', ''),
// Public origin the browser actually talks to. Behind a proxy, req.url carries the
// internal listen host — derive from the (trusted) redirect URI to avoid leaking it.
get publicOrigin(): string {
const e = process.env.PUBLIC_ORIGIN?.trim()
if (e) return e.replace(/\/+$/, '')
try { return new URL(this.oauthRedirectUri).origin } catch { return '' }
},
get dbPath() { return process.env.DB_PATH?.trim() || `${optional('WORKSPACE_DIR', '/workspace')}/app.db` },
port: num('PORT', 3000),
}
export type Config = typeof config
- Prefix every app-owned var with the app's namespace (
QUAYKEEPER_GITHUB_CLIENT_ID, notGITHUB_CLIENT_ID) so several apps can share a host/env file; only shared infra knobs (PORT,DB_PATH,WORKSPACE_DIR) stay bare. The snippet above shows bare names for brevity. - Fail-fast: a missing required var throws at first import — except during
next build(NEXT_PHASE === 'phase-production-build'), where the module graph is traced before env is provided. Defer those failures to runtime boot / first request. - Secrets get a length floor (
AUTH_SECRET≥ 16).publicOriginis a getter that prefers a trusted explicit value, else the redirect URI's origin. - Never import
config(or anything underserver/) from a client component —server-onlywill fail the build if you do, which is the point. - Mirror every var in
.env.examplewith a placeholder..env.localis gitignored.
Boot Hooks — instrumentation.ts
Runs once per server start. Use it to open the DB, seed idempotently, and launch background tickers, so nothing depends on a page/route being hit first. Guard on the Node runtime and dynamic-import the services (keeps them out of the Edge/middleware bundle):
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME !== 'nodejs') return
const { initDb } = await import('@/server/data/db')
initDb() // eager open + migrate
const { ensureSeed } = await import('@/server/services/realms')
ensureSeed() // idempotent boot-seed (default rows from env)
const { ensureQuotaSweepStarted } = await import('@/server/services/quota-sweep')
const { ensureStatusPollStarted } = await import('@/server/services/status-poll')
const { ensureJobSchedulerStarted } = await import('@/server/services/job-scheduler')
ensureQuotaSweepStarted(); ensureStatusPollStarted(); ensureJobSchedulerStarted()
}
Enable in next.config.mjs with experimental: { instrumentationHook: true } if your Next version requires the opt-in (Next 16 doesn't).
The ticker triad
Every background ticker has the same three-part shape — globalThis singleton (survives dev hot-reload), unref()d interval (never keeps the process alive), and the tick body exported separately so tests drive it without timers:
const POLL_INTERVAL_MS = 60_000
declare global { var __appStatusPoll: { timer: ReturnType<typeof setInterval> } | undefined }
export function ensureStatusPollStarted(): void {
if (globalThis.__appStatusPoll) return
const timer = setInterval(() => { void pollNow().catch((e) => slog('error', 'poll', String(e))) }, POLL_INTERVAL_MS)
timer.unref?.()
globalThis.__appStatusPoll = { timer }
}
export async function pollNow(): Promise<void> { /* the actual work — tests and "run now" call this directly */ }
Per-item failures inside a tick are logged and skipped, never allowed to kill the loop. A cron-style scheduler ticks sub-minute (30 s, so minute boundaries aren't missed) and dedupes with a minute key so a job fires at most once per matching minute — const key = Math.floor(now.getTime() / 60000), skip when lastFired.get(job.id) === key — plus a globalThis running-set so a still-running job is skipped, not stacked.
Middleware — middleware.ts (optional, thin)
Edge middleware does presence only — it checks that the session cookie exists and redirects anonymous visitors. The cryptographic verification (HMAC + expiry) and the authoritative role re-read happen in the Node layer (authorize() / page-guards), where crypto and the DB/fs are available.
import { NextResponse, type NextRequest } from 'next/server'
const SESSION_COOKIE = 'app_session'
const PUBLIC = ['/login', '/api/auth/github']
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
if (pathname === '/api/health' || PUBLIC.some((p) => pathname === p || pathname.startsWith(p + '/'))) return NextResponse.next()
if (req.cookies.has(SESSION_COOKIE)) return NextResponse.next()
if (pathname.startsWith('/api/')) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
// Build the login redirect from a TRUSTED origin (PUBLIC_ORIGIN / redirect-URI origin), not
// attacker-controllable x-forwarded-* headers — otherwise it's an open redirect.
const base = process.env.PUBLIC_ORIGIN || (process.env.OAUTH_REDIRECT_URI ? new URL(process.env.OAUTH_REDIRECT_URI).origin : req.url)
return NextResponse.redirect(new URL('/login', base))
}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)'] }
Middleware is an optimization, never the security boundary — every authed route and page still calls authorize() / requireRole(). An app can skip middleware entirely and rely on the Node-layer guards.
Optional Patterns
Proven in quaykeeper; add per-feature, never by default.
Companion machine-facing listener (server/agent-server.ts)
Still one OS process — a second, cookieless node:http listener on its own port (<APP>_AGENT_PORT) carries the machine-facing surface (agent config fetch with ETag/304, client-binary download, install.sh bootstrap, /healthz), so operators can expose it independently of the human UI port. Started from instrumentation.ts via an idempotent ensureAgentServerStarted() (globalThis singleton, same idiom as the tickers). The listener owns no logic — it parses the request and re-enters the service layer.
- Machine auth:
X-<App>-Instance: <name>header +Authorization: Bearer <fetch key>; the key is minted once and only its sha256 hash is stored (see Machine credentials above). Unknown instance → 404, bad key → 401. - Config responses carry an ETag = sha256 of key-sorted (canonical) JSON, truncated;
If-None-Matchanswers 304 so polling agents are cheap. - Any path parameter that reaches the filesystem (
/v1/client/{os}/{arch}) is allowlisted (/^[a-z0-9]+$/) before use — never joined raw. - Dockerfile
EXPOSEs both ports;run-docker.shmaps both (or neither, in proxy/--networkmode).
Multi-realm tenancy (one control plane, N downstream daemons)
A realm table (id, name, admin_url, token_enc, is_default) registers each downstream daemon. Admin tokens are cipher-keyring-encrypted at rest; DTOs mask them to hasToken: boolean. Exactly one default enforced by a partial unique index: CREATE UNIQUE INDEX idx_realm_one_default ON realm(is_default) WHERE is_default = 1.
- The active realm is a signed httpOnly cookie hint, re-validated against the DB per request. Top-role users may switch (
POST /api/realms/active); standard users get an owner-assigned realm from auser_realmtable. - Services resolve a bound daemon client three ways:
clientFor(realmId),clientForActive(githubId, role)(realm-selected ops — admin pages),clientForSite(site)(realm-scoped ops — the row's ownrealm_id).
Truncated - read the full file at https://github.com/kalevski/toolcase/blob/32bb814bf6d49de3a022671404945474649dd695/examples/public/next-fullstack-app/SKILL.md.