Imported from Joycai/simple-trpg-chat (
AGENTS.md). Install upstream withnpx skills add Joycai/simple-trpg-chat. Copyright stays with the author.
Simple TRPG Chat — Agent Guide
This is the canonical repository guide for Codex and other coding agents.
Development Workflow
- Use Node.js >= 22 and the pnpm version pinned in
package.json; install withpnpm install --frozen-lockfile. mainis the release branch; do not push directly to it. Usecodex/<task>for new Codex branches, then PR → CI → review → merge.- Repository skills live in
.agents/skills/. Read the relevantSKILL.mdbefore domain work:simple-trpg-chat: project concepts, messaging, data model, and common pitfalls.simple-trpg-chat-rules: rulesets, dice/check commands, character-sheet schemas, and rule-driven UI.simple-trpg-chat-theme: theme creation and changes.version-bump: versioning and release workflow.next-dev-loop: runtime verification after Next.js app changes; the othernext-*skills cover cache and prefetch adoption/optimization.
- Validate changes proportionally:
pnpm lint,pnpm test, andpnpm buildfor application changes;pnpm i18n:checkfor translations and relevantpnpm test:e2ecoverage for browser flows. Report missing local prerequisites and any checks not run. - Keep both
messages/zh.jsonandmessages/en.jsonin sync for user-facing text. - Durable handoff context belongs in
docs/orplans/; check current code and plan status before continuing older work. Local Claude task history is optional, not a prerequisite.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ before writing any code. Heed deprecation notices.
Environment and Shell Toolchain Rules
When running shell commands or setup scripts, ensure you run the appropriate commands based on the detected operating system:
- Windows (PowerShell): Use
setup.ps1orsetup.bat. Executepodman.exe(typically installed at~/AppData/Local/Programs/Podman/podman.exe) ordocker. Prefix commands with the&operator for absolute path execution. - macOS / Linux: Use
setup.sh. Use nativepodmanordockercommands directly.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
Project Overview
Simple TRPG Chat — lightweight web-based TRPG tool (Call of Cthulhu, D&D) with multi-player chat, dice, skill checks, inventory, clue cards, character sheets, and optional AI bot NPCs.
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 16.3.5 (App Router) |
| Language | TypeScript 6 |
| React | React 19 |
| Styling | Tailwind CSS v4 (@tailwindcss/postcss) |
| Database | PostgreSQL via postgres + Drizzle ORM |
| Auth | NextAuth v5 beta, Credentials provider |
| i18n | next-intl v4 (zh/en, default: zh) |
| AI | OpenAI-compatible API, configurable per host |
| Markdown | react-markdown + remark-gfm |
| Icons | lucide-react |
| Validation | zod |
| Testing | vitest |
Quick Commands
Requires Node.js >= 22 and pnpm >= 10 (corepack enable pnpm).
pnpm dev # Dev server (http://localhost:3000)
pnpm build # Production build
pnpm start # Start production server
pnpm lint # ESLint
pnpm test # Run tests (vitest run)
pnpm db:push # Push schema to PostgreSQL
pnpm db:studio # Drizzle Studio GUI
pnpm db:seed # Seed database (creates admin / admin123)
pnpm db:doctor # Environment & DB diagnostics
pnpm db:migrate-sheets # Upgrade stored character sheets to v2 (dry run; --apply, --room <id>)
Project Structure
src/
├── app/
│ ├── actions/ # Server Actions ("use server"), one module per concern
│ │ # room / messages / checks / skills / character / inventory …
│ ├── admin/ # Admin panel (ai/, config/, images/, rooms/, usage/, users/) + loading/error
│ ├── api/rooms/[id]/events/ # SSE endpoint — GET /api/rooms/[id]/events
│ ├── login/
│ ├── rooms/[id]/ # layout (existence check), page, loading, error
│ ├── rooms/not-found.tsx # room 404
│ └── not-found.tsx, global-error.tsx # site 404 / root-layout failure
├── components/ # React client components ("use client")
│ ├── room/ # room UI, grouped by panel (chat/, character/, notebook/ …)
│ ├── admin/ lobby/ user/ theme/
│ └── shared/ # cross-feature primitives (OverlayShell, ConfirmDialog …)
├── db/ # Drizzle client + 23-table schema
│ └── scripts/ # tsx entry points: seed, doctor, one-off backfills
├── lib/ # Framework-light logic, grouped by domain:
│ ├── actions/ # write-action result types (Fail / Done), noRoomAccess()
│ ├── ai/ # bot agent loop, tool definitions/handlers, usage, presets
│ ├── auth/ # room access checks, invites, rate limit, login history
│ ├── character/ # v2 sheet: schema-driven resolve / edit / completion, legacy upgrade
│ ├── commands/ # chat command engine, dice expressions, CSPRNG dice
│ ├── format/ # time / bytes / markdown blocks / export formatting
│ ├── media/ # uploads, room backgrounds, stickers, avatars, image cache
│ ├── messaging/ # audience router (dispatchMessage, visibility)
│ ├── room/ # notebook, story events, inventory sharing
│ ├── rules/ # pluggable rule modules (see simple-trpg-chat-rules skill)
│ ├── security/ # encryption, SSRF guard, sensitive-word filter
│ ├── server/ # SSE event hub, site config, stats
│ └── ui/ # client hooks & helpers (overlay transitions, hotkeys, useAsyncAction)
├── i18n/ # next-intl server config (default: zh)
├── themes/ # 6 themes; each has themes/<name>/theme.css
├── types/ # next-auth.d.ts type augmentation
├── auth.ts / auth.config.ts # NextAuth full config + callbacks
└── proxy.ts # Auth middleware
messages/{zh,en}.json # i18n translation files
db.config.json # DB connection config (auto-generated)
Architecture
For deep dives into specific systems, see docs/:
| Topic | File |
|---|---|
| Database — 23 tables, schema, relations | docs/arch/database.md |
| Real-time — SSE, privacy filter, DMs | docs/arch/realtime.md |
| AI — agent tools, token usage, points, SSRF | docs/arch/ai-system.md |
| Character — sheet schema, v2 storage, completion, host editing | docs/arch/character-system.md |
| Admin — users, config, stats, filtering | docs/arch/admin-panel.md |
⚠️ Critical: EventEmitter must use globalThis
Next.js production runs multiple workers. The EventEmitter singleton must be persisted to globalThis unconditionally — never gate on NODE_ENV:
// ✅ Always
const eventHub = globalThis.__eventHub || new EventEmitter();
globalThis.__eventHub = eventHub;
// ❌ Never — production workers won't share the hub
if (process.env.NODE_ENV !== "production") {
globalThis.__eventHub = eventHub;
}
Theming
6 themes: default, parchment, cthulhu, shrine, rainglass, aether. Each has src/themes/<name>/theme.css. Always use semantic Tailwind classes (bg-surface, text-text, border-border) — never hardcode colors. Variables mapped via @theme inline in globals.css. Modal/drawer backdrops use bg-scrim/N, and chrome drawn over an image (preview toolbar, thumbnail badges) uses text-on-scrim / bg-on-scrim/N — never bg-black / text-white; a theme may tint them via the optional --theme-overlay-scrim / --theme-on-scrim. Status colors come from danger / warning / success / ai, not palette classes like red-500.
Chat Commands
Prefix . or 。 (Chinese full-stop accepted):
.st <skill> <value>— set skill (batch:.st 侦查50聆听60).rc <skill>— roll check, syntax owned by the room's rule module (COC:.rc b2 侦查bonus dice /.rc p 侦查penalty dice — extra tens dice replace the tens digit, keep lowest/highest;.rc 侦查+1/-1is the suffix alias; d20:.rc 运动+5 15, advantage.rc 优势 运动+5 15/ 劣势 rolls 2d20 keep highest/lowest (nameless:.r 优势+2 15); 狩魂者:.rc 侦查+2-1 12).rd100b[n]/.rd100p[n]— COC plain bonus/penalty roll, no judgment (.rh100b2= hidden); the chat card shows each die face, the original d100, and the replaced result.rch/.rah— hidden check (same as.rc/.ra, result visible only to the roller — the check-flow counterpart of.rh).sc <s>/<f>— sanity check (COC 7th).rd<N>/.r<N>— dice roll (supports expressions like3d100k2+2d20).help— show help
Engine: src/lib/commands/engine.ts
Quick-Check Panel (快速检定面板)
The ◎ button left of the chat input (Alt+Q) opens a rule-aware panel where a
player picks one of their own stats (stored skills / sheet attributes / 理智)
and rolls the room rule's check without typing. This is rule-template
territory: what the panel renders comes from capabilities.quickCheckPanel
(pure data — COC gets a bonus/penalty-die toggle, d20 gets modifier + DC
fields, 狩魂者 gets 加骰/时髦骰 steppers, triangle declares nothing and shows
no button), and the command it sends is produced by the rule's
buildCheckCommand — so it always equals a hand-typed .rc-family command
and the server stays the single resolver. When adding or changing a
ruleset, this panel spec must be part of the change — see the
simple-trpg-chat-rules skill (§3 quickCheckPanel) for the contract.
Component: src/components/room/chat/QuickCheckPanel.tsx.
Character Sheets (角色卡)
Each rule declares its sheet as data — RuleModule.sheet (lib/rules/sheet-schema.ts):
attributes (range, default, required), resources (bar with a derived / editable max,
or an unbounded counter), derived values (computed by derive, never stored or
writable) and standard skills (base value, required). roomMembers.characterData is a
v2 sheet that stores only the values someone set (absent key = unset), so
completion is exact. Read through parseSheet / parseSheetOrNull (upgrades pre-v2
rows via migrateLegacy; pass the room rule), write only through applySheetEdit inside updateSheetRow (row lock)
— via editCharacterAction, .st, .sc, the AI tool or the skills actions — and let
broadcastCharacterUpdate send character_updated { vital, completion, origin } (read and emitted under the member row lock, so events arrive in write order; origin = <caller id>:<tabId()> of the writing tab, the only tab that skips the reload). Every write bumps the sheet's rev, which orders a save reply against a racing refresh. Relative resource changes use delta, resolved under the lock.
Permission is one rule, resolveSheetWriter: the member, the host, or an admin.
UI: CharacterPanel (own / host-edit / view modes, useSheetDraft draft over a
baseline, completion bar, close guard), HostSheetOverview (host top-bar button),
top-bar missing-count badges, member-list completion marks. Backfill:
pnpm db:migrate-sheets [--room <id>] [--apply]. Details: docs/arch/character-system.md;
adding a rule's schema: the simple-trpg-chat-rules skill.
Avatar System
Users can upload and crop custom avatars for each room they join. Avatars are stored as base64 JPEG in the database (max 512×512px per room membership).
Features:
- Individual avatars per room (same user has different avatars in different rooms)
- Canvas-based drag-to-crop UI with live preview
- Fallback to colored letter badge if no avatar set
- Avatar displayed in chat messages next to user nickname
Implementation:
- Component:
src/components/shared/ImageCropper.tsx(client-side upload/crop) - Action:
src/app/actions/room.ts→uploadAvatarAction() - Database:
roomMembers.avatar(text, nullable, base64 JPEG) - i18n:
messages/{en,zh}.json→avatar.*keys
Database Migration Required:
The avatar column was added to roomMembers table schema. If upgrading, run:
pnpm db:push # Interactive mode — answer "No" to ai_token_usages truncate prompt if it appears
Room Backgrounds
Hosts pre-upload up to 12 background images per room (RoomSettings → 背景图 tab) and switch between them live; players get a local intensity slider (TopBar gear menu → personal section, localStorage, 0 = off). Uploads (≤5MB, JPEG/PNG/WebP — GIF rejected) are re-encoded server-side via sharp to bounded WebP (2560px / q80) under cache/room-backgrounds/ (ROOM_BACKGROUND_DIR) — a separate directory from chat images because backgrounds are host prep material, not disposable cache; admin cleanup only touches them via an explicit opt-in checkbox. The image renders behind a per-theme scrim (--theme-bg-scrim* vars in each theme.css); data-room-bg on <body> softens opaque shells (globals.css). Switching broadcasts the existing room_settings_updated SSE event. Core: src/lib/media/backgrounds.ts, src/app/api/rooms/[id]/backgrounds/, src/app/actions/background.ts, RoomBackground.tsx (paints), hooks/useRoomBgIntensity.ts (intensity store shared with RoomTopBar), RoomBackgroundManager.tsx. Reverse-proxy note: nginx needs client_max_body_size 6m. Design doc: docs/design/room-background.md.
Route Boundaries and First Paint
Special files per segment (Next 16.3 — error.tsx gets { error, retry }; retry re-fetches, prefer it over reset):
- 404:
app/not-found.tsx(unmatched URLs) andapp/rooms/not-found.tsx(missing room). The room check lives inrooms/[id]/layout.tsx, not the page:rooms/[id]/loading.tsxstarts streaming before the page runs, and once it has, anotFound()can only produce a 200 soft 404. A segment's ownnot-found.tsxsits inside its layout, hence the room 404 one level up. The layout only turns a missing room intonotFound(); a failed lookup falls through to the page, because the segment'serror.tsxcan't wrap its own layout (a throw there would reachglobal-error).findRoom(lib/room/room-lookup.ts,React.cache) hands the page the same row — or the same rejection;parseRoomIdsends non-int4 ids to 404 instead of Postgres. - Loading:
rooms/[id]/loading.tsxmirrors RoomClient's shells (RoomTopBar rows, the sidebar fromlgat useSidebar's default 200px, ChatArea's input shell) so nothing jumps on arrival; admin pages shareadmin/AdminSkeleton.tsx, and a page with a different outer container passes it from its ownloading.tsx(config, usage). Change a page's shell → update its skeleton. - Errors:
rooms/[id]/error.tsxandadmin/error.tsxrendercomponents/shared/RouteError.tsx— retry, a way back, and the digest only (nevererror.message).app/global-error.tsxreplaces the root layout, so no theme, fonts or next-intl reach it: it is the one UI file that hardcodes its colors (OS light/dark, no tokens available) and writes its copy in both languages.
First paint: the room page reads loadMemberSnapshot (lib/room/initial-snapshot.ts — unread DMs per sender, sheet completion (own; every member's for the host), visible events, unread events, unread items) in its Promise.all and passes initialSnapshot to RoomClient, whose hooks seed that state from it (useUnreadDmCounts, the completions map, useRoomEventsData, useUnreadInventoryCount) — there is no "loading" state for these any more. The matching read actions are thin checkRoomAccess wrappers over the same functions, and the hooks' refresh-key effects skip their key-0 run, re-reading only when bumped. The page decides host-level reads with isRoomHostOrAdmin — the same rule checkRoomAccess uses.
Room Client
RoomClient.tsx wires the room together; its state lives in hooks under components/room/hooks/, one concern each: useMessageLog (messages + seen-id / live-arrival refs), useLivePlayers, useChatScroll, useChatSend (send, commands, local error rows), useCheckFlow, useRoomEventsData, the badge hooks, useRoomThemeMode, usePlayerCardViewer, useRoomNameEditor, useRoomShortcuts, and useRoomEvents (the SSE router). Roster derivations (mention targets, DM list, counts) are pure functions in lib/room/mention-targets.ts. The room's panels, dialogs and top-bar menus open through useOverlayVisibility — one map with a stable setter per key, passed to RoomTopBar / RoomOverlays as overlays; a new panel is a new key in ROOM_OVERLAYS, not another useState pair. Not in it: overlays that carry data rather than a flag (the check dialog/menu in useCheckFlow, the event detail id, the viewed player card, the skill / 加骰 prompts) and menus local to one component (RoomTopBar's 道具/事件 dropdown). When moving logic between these hooks, keep each effect's dependency array and its position relative to the other effects.
Notebook (记事本)
Per-user-per-room private markdown notes, opened from the TopBar icon right of the backpack. Notes are strictly private (host included) — every query is scoped by (roomId, userId), and there is no SSE for it (the panel fetches on open). Categories are user-editable (rename / recolor / add / delete, max 12) with one of 7 predefined label colors — theme-token keys (NOTEBOOK_COLORS), so labels recolor with the theme; 4 localized defaults are lazily seeded on first open, and deleting a category drops its notes into an "uncategorized" bucket (FK set null). Notes support markdown (rendered by the shared MarkdownRenderer; in the editor, Tab / Shift+Tab indent list lines by 2 spaces and Enter continues or ends a list — useMentionTextarea's listKeys, pure edits in lib/ui/textarea-edits.ts; Escape then Tab leaves the textarea), local relevance-ranked search, and @标题 links to backpack entries (inventory items/clues/characters). Mentions store the plain title and resolve by longest-title prefix match at render time, so a deleted backpack item silently degrades to plain text. A note can be sent to other members (shareNoteAction) as an independent copy — the recipient gets a new row in their own scope (uncategorized, sourceName = sender snapshot, badged "来自 X"); later edits never sync, and the copy's @ links re-resolve against the recipient's backpack, so anything they don't hold degrades to plain text. Bots are excluded as recipients on the server (users.isBot join), not just in the picker; no SSE, so copies surface on the recipient's next open. The note body's typography (section headings, list markers, quote chrome) is a shared structural layer scoped to .notebook-note-body in globals.css — values read var(--theme-nb-*, <fallback to --theme-*>), so every theme auto-tints and a theme may override any --theme-nb-* at its root (see the simple-trpg-chat-theme skill). Core: src/lib/room/notebook.ts (pure helpers + tests), src/app/actions/notebook.ts, src/components/room/notebook/. Tables: notebook_categories + notebook_notes.
Invite-Code Registration
Public /register page: new users sign up with a host-issued invite code and join as player. Hosts generate codes from the user settings panel ("邀请码" tab, host-only); each code is single-use, expires after 48h (lazy sweep refunds quota). Admin controls: per-host quota column + reset in user management, plus a registration on/off toggle and default quota (invite_registration_enabled / invite_default_quota in system_config) in system config. Core logic: src/lib/auth/invites.ts + src/app/actions/invite.ts. Design doc: docs/design/invite-registration.md.
Authentication
- NextAuth v5 beta, Credentials provider (username + bcrypt). Config split:
auth.config.ts(callbacks) +auth.ts(full config with DB). proxy.tsprotects all routes except/api,/login,/register,/_next/*,/favicon.ico.- Admin requires
role === 'admin'. Session carries:id,name,username,role.
Coding Conventions
-
Path alias:
@/*→src/* -
Module layout: new logic goes in the matching
src/lib/<domain>/folder, with tests in that folder's__tests__/. Don't add a catch-allutils.ts; name the file after what it does. Files are kebab-case; React hooks keep theuseX.tsname. -
Layering:
src/libsits belowsrc/componentsandsrc/app, and only server code reachessrc/db(components call server actions instead).pnpm lintenforces R2–R5 (eslint.config.mjs:import/no-restricted-pathsfor R2–R4, which resolves real paths so relative imports can't bypass it;no-restricted-importsfor R5); the build enforces R1.- R1 —
src/db/index.ts,src/lib/server/*,src/lib/security/{encryption,url-guard,sensitive-words}andsrc/lib/room/{initial-snapshot,room-lookup}start withimport "server-only", so a client component that reaches them fails the build.schema.tsis exempt becausedrizzle-kitloads it directly. tsx scripts that import these must run with--conditions=react-server(thedb:*scripts already do); vitest aliasesserver-onlytotests/stubs/. - R2 — within
src/,src/db/schema.tsimports only the dependency-free, client-safe@/lib/messaging/audienceand@/themes/types. - R3 — client code never imports
@/dbor@/db/schema, types included; take them from a client-safe re-export undersrc/lib/. Lint coverssrc/components/,src/themes/,src/lib/ui/and the login/register forms — a"use client"file added elsewhere needs adding to the R3 block. - R4 —
src/lib/never imports@/componentsor@/app. - R5 — server actions never import each other; shared logic goes to
src/lib/.
- R1 —
-
Server Actions:
src/app/actions/,"use server"directive -
Client components:
src/components/,"use client"directive -
Styling: Semantic Tailwind tokens only — never arbitrary colors
-
Database: Drizzle query builder;
db.config.jsonholds{ "type": "postgresql", "url": "..." } -
Error handling: Server actions return result objects —
{ success: true, ... }/{ success: false, error }, witherroralready localized via server-sidegetTranslations. Never surface a thrown message to the client: Next.js redacts server-action errors in production, soerr.messagerenders as "An error occurred in the Server Components render…".checkRoomAccessandrequireAdminstill throw (they are shared, and read actions rely on it). In a write action usetryRoomAccess(same module, returnsnullinstead of throwing — returnnoRoomAccess()fromlib/actions/no-room-access.ts, unless the module has its own key such aserrorNotHost), or wraprequireAdminasadmin.ts'sadminGuarddoes; don't write another try/catch wrapper. Type results withFail/Donefromlib/actions/result.tsrather than a local copy. Write-action status by module:- Converted:
admin·ai-import·background·bot·bot-presets·character·checks·dice-announcer·event·image-cache·inventory·invite·messages·notebook·room·skills·theme(setters;updateSiteFaviconstill returns English errors) ·user(changeOwnPassword) ·ai-providers(deleteProvider;createProvider/updateProviderkeep their older{ error } | datashape — their auth and ownership errors are localized, but SSRF-guard and DB errors still pass through in English). executeCommandActionreturns the command engine'sCommandResult({ success, error?, isCommand }), so its failures render like any command error.
Read actions may still throw — their callers render a retry state.
On the client, wrap a write in
useAsyncAction(lib/ui/useAsyncAction.ts) instead of a hand-writtensaving/errorpair: it returns{ pending, error, run }, treats a throw asfallbackError(never the thrown text), and takesonError/onSuccesswhen the message slot is shared with other checks, andkeepPendingOnSuccessfor a dialog that closes on success. - Converted:
-
Validation: Validate at the action boundary —
zodwhere a schema fits (background.ts,invite.ts), an explicit hand-written sanitizer where the rules are shared with another caller (sanitizeTimelineDividerinlib/messaging/timeline-payload.ts, used by bothroom.tsandevent.ts). Length caps belong insrc/lib/next to the feature's other constants so the editor and the action agree. -
Dialogs: Never
alert/confirm/prompt— they ignore the theme and block on mobile. Confirmations usecomponents/shared/ConfirmDialog.tsx(built onOverlayShell; always portals, so it centers correctly when opened from inside a drawer). Notifications usecomponents/shared/Notice.tsxas an inline strip — passonDismissfor a close button. No native dialog is left insrc/componentsorsrc/app. New modals are built onOverlayShell(passportalwhen opened from inside a drawer), not a hand-rolled fixed div. The inventory and notebook modals have moved over. Older ones still build their own layer:BonusDicePrompt,HostCheckDialog,TimelineDivider's withdraw confirm,TimelineDividerDialog,SkillSetPromptandImageCroppersit onuseOverlayTransitiondirectly (motion and Escape work); the login license modal and the full-screenImagePreviewhave no enter/exit motion at all.layerClassNamesets the stacking layer (the inventory modals sit atz-[60]/z-[70]; a confirm above them usesz-[80]) andscrimClassNamethe backdrop tint. Close from inside through the render-propclose(), including after a successful submit: have the parent's handler resolve to success and callclose()in the modal, so the exit plays before the parent resets its state. A panel with unsaved work guards its close paths withOverlayShell'sonDismiss, which runs before the exit animation —onCloseruns after it, too late to ask (seeEventEditor). -
Motion: overlay enter/exit is driven by
motionsprings insrc/lib/ui/useOverlayTransition.ts— attach itspanelRef/backdropRef, and callclose()(neveronClose) so the exit plays before the parent unmounts. Do NOT reintroduce CSS keyframes for overlays: an earlierlinear()-based version silently disabled all overlay animation on the ~13% of browsers lackinglinear(), because a custom property that fails to parse invalidates the wholeanimationdeclaration. Theoverlay-drawer/overlay-modalclasses still belong on the panel — they are now theme styling hooks only (rainglass frosts them, shrine reshapes their corners), not animation classes. Non-overlay motion (sidebar width,.overlay-popdropdowns) stays in CSS. -
Types: Co-locate in
src/db/schema.tsandsrc/themes/types.ts; room UI types live insrc/components/room/types.ts. A client-safe enum may have its canonical definition in a dependency-free module thatschema.tsre-exports (seeTHEME_MODES).
License
AGPL-3.0 with dual licensing — commercial closed-source use requires a separate license from the author. Attribution to Joycai and the original repo is required in all derivative works.
Environment Variables
| Variable | Required | Description |
|---|---|---|
AUTH_SECRET |
Yes | NextAuth JWT signing secret |
AI_ENCRYPTION_KEY |
Prod | AES-256-GCM key for AI API keys (dev falls back to dev-secret-key) |
