@inbeat/standards 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/agents/app-section.md +22 -0
  3. package/agents/standard.md +114 -0
  4. package/package.json +46 -0
  5. package/skills/ib-feature/SKILL.md +40 -0
  6. package/skills/ib-toolkit/SKILL.md +125 -0
  7. package/skills/ib-ui/SKILL.md +155 -0
  8. package/src/cli.ts +85 -0
  9. package/src/create-args.ts +10 -0
  10. package/src/create.ts +240 -0
  11. package/src/foundation.ts +42 -0
  12. package/src/mcp-servers.ts +8 -0
  13. package/src/mcp.ts +383 -0
  14. package/src/sync.ts +117 -0
  15. package/src/template.ts +34 -0
  16. package/template/.agents/skills/ib-feature/SKILL.md +40 -0
  17. package/template/.agents/skills/ib-toolkit/SKILL.md +125 -0
  18. package/template/.agents/skills/ib-ui/SKILL.md +155 -0
  19. package/template/.dependency-cruiser.cjs +1 -0
  20. package/template/.env.example +12 -0
  21. package/template/.github/workflows/ci.yml +14 -0
  22. package/template/AGENTS.md +139 -0
  23. package/template/_gitignore +10 -0
  24. package/template/biome.json +6 -0
  25. package/template/next.config.ts +32 -0
  26. package/template/package.json +62 -0
  27. package/template/postcss.config.mjs +7 -0
  28. package/template/sentry.edge.config.ts +11 -0
  29. package/template/sentry.server.config.ts +12 -0
  30. package/template/src/app/api/health/route.test.ts +12 -0
  31. package/template/src/app/api/health/route.ts +7 -0
  32. package/template/src/app/error.tsx +24 -0
  33. package/template/src/app/global-error.tsx +20 -0
  34. package/template/src/app/globals.css +4 -0
  35. package/template/src/app/icon.svg +1 -0
  36. package/template/src/app/layout.tsx +24 -0
  37. package/template/src/app/loading.tsx +9 -0
  38. package/template/src/app/not-found.tsx +14 -0
  39. package/template/src/app/page.tsx +7 -0
  40. package/template/src/app/robots.ts +6 -0
  41. package/template/src/instrumentation-client.ts +14 -0
  42. package/template/src/instrumentation.ts +13 -0
  43. package/template/src/shared/config/env.ts +19 -0
  44. package/template/src/shared/config/routes.ts +3 -0
  45. package/template/src/test/server-only.ts +1 -0
  46. package/template/tsconfig.json +10 -0
  47. package/template/vitest.config.ts +14 -0
@@ -0,0 +1,125 @@
1
+ ---
2
+ name: ib-toolkit
3
+ description: Use the inBeat toolkit (@inbeat/core, @inbeat/next, @inbeat/config) correctly in an inBeat app. Use when writing server actions, route handlers, auth checks, env, rate limits, webhooks, Sentry or logging, or when the user mentions ib-toolkit or @ib-toolkit.
4
+ ---
5
+
6
+ # ib-toolkit
7
+
8
+ Read the "inBeat toolkit standard" block in `AGENTS.md` first. This skill is the how-to. Examples use `authorizeStaff` as a stand-in for the app's own `authorize` check; public endpoints use `publicAccess` plus a rate limiter.
9
+
10
+ ## Server action
11
+
12
+ ```ts
13
+ "use server";
14
+ import { ok } from "@inbeat/core/result";
15
+ import { action } from "@inbeat/next/action";
16
+ import { authorizeStaff } from "@/shared/lib/auth";
17
+ import { createClientSchema } from "../schemas";
18
+ import { createClientRecord } from "../server/create-client-record";
19
+
20
+ export const createClient = action({
21
+ name: "clients.create",
22
+ schema: createClientSchema,
23
+ authorize: authorizeStaff,
24
+ run: async (input, viewer) => ok(await createClientRecord(input, viewer.userId)),
25
+ revalidate: () => ["/clients"],
26
+ });
27
+ ```
28
+
29
+ Client side: `const result = await createClient(values); if (!result.ok) toast.error(result.error);`
30
+
31
+ Public action: `authorize: publicAccess`, then first thing in `run`:
32
+ `await rateLimiter.enforce(\`apply:${clientIp(await headers())}\`, { limit: 5, window: "10 m" })`.
33
+ The limiter is one shared instance in `src/shared/lib/rate-limit.ts` (`createRateLimiter({ prefix, upstash })`
34
+ with the `upstashEnv` values) imported by every action and route. Get the IP only from `clientIp` in
35
+ `@inbeat/next/rate-limit` (routes pass `request.headers`); never write your own header parsing.
36
+ `clientIp` is reliable on Vercel only; self-hosted apps behind a proxy pass `{ trustedProxyHops: n }`,
37
+ otherwise it returns `"unknown"`. Public forms using Turnstile without Clerk must add its hosts via
38
+ `securityHeaders({ sources: { script: ["https://challenges.cloudflare.com"], frame: ["https://challenges.cloudflare.com"] } })`.
39
+
40
+ ## Route handler
41
+
42
+ ```ts
43
+ import { route } from "@inbeat/next/route";
44
+ export const GET = route({
45
+ name: "clients.list",
46
+ authorize: authorizeStaff,
47
+ query: z.object({ cursor: z.string().optional() }),
48
+ handler: async ({ query }) => listClients(query.cursor),
49
+ });
50
+ ```
51
+
52
+ ## Errors
53
+
54
+ - Deny: `throw new AppError("forbidden", "reason for logs")`. The user sees safe default copy.
55
+ - Expected business failure: `return fail("That handle is taken", "conflict")`.
56
+ - Anything else: let it throw. The wrapper classifies, reports to Sentry and returns `ERR_xxxx`.
57
+ - Postgres: `isUniqueViolation(err)` from `@inbeat/core/errors`, never string matching.
58
+
59
+ ## Authorization
60
+
61
+ `authorize` is the app's own check: it returns the viewer or throws `AppError("auth" | "forbidden")`. Roles, admin rules and ownership are app decisions; write them in the app (for example `src/shared/lib/auth.ts`) and pass them to `action()` and `route()`.
62
+
63
+ ## Integrations
64
+
65
+ Sentry is already wired in the template.
66
+
67
+ Run `ib mcp` to give your AI tool access to Vercel, GitHub, Sentry, Neon, Clerk, Cloudflare.
68
+
69
+ ### Auth (Clerk)
70
+
71
+ 1. Run `npx -y clerk@latest init` (works with `bunx`). It detects Next.js and applies Clerk's setup; `npx -y clerk@latest doctor` verifies it.
72
+ 2. In `proxy.ts`, exclude `/monitoring` (the Sentry tunnel) from the matcher, and keep `/api/health` and `/robots.txt` public.
73
+ 3. Pass `clerkFrontendApi(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY)` to `securityHeaders` (the base `next.config.ts` already does).
74
+ 4. Add `clerkServerEnv` and `clerkClientEnv` from `@inbeat/core/env` to `src/shared/config/env.ts` (see Env below).
75
+
76
+ Access pattern. Clerk's development and production instances are configured separately: apply every
77
+ dashboard step to both.
78
+
79
+ - Who gets in: a staff-only app turns on the Clerk allowlist (`*@inbeat.agency`, …). It only blocks
80
+ new sign-ups, so review existing users once after enabling it. Apps with external users keep
81
+ sign-ups open and rely on roles.
82
+ - Roles: `publicMetadata.role`, assigned in the Clerk dashboard (users cannot edit it). Expose it
83
+ with the session token claim `{"metadata": "{{user.public_metadata}}"}`, read it from
84
+ `sessionClaims.metadata.role`. No role or an unknown one means no access.
85
+ - Checks run on the server, twice: the proxy requires sign-in outside the public routes and blocks
86
+ role-gated areas; every role-gated page, action and route checks again (`authorize`). Hiding UI
87
+ is not access control.
88
+ - Stays in the app: users table (if joins need it), ownership ("owner or admin"), rate limits,
89
+ maintenance mode.
90
+ - Deviation: deriving a role from a verified email domain is allowed when the app documents it in
91
+ AGENTS.md "Deliberate deviations".
92
+
93
+ ### Database (Neon + Drizzle)
94
+
95
+ 1. Run `npx neon@latest init` to link the Neon project and install the agent tooling.
96
+ 2. Set up Drizzle per its Neon guide: `drizzle-orm`, `@neondatabase/serverless`, `drizzle-kit`, and a `drizzle.config.ts` with `dialect: "postgresql"`.
97
+ 3. Conventions from the standard's Data section: tables in `src/db/schema/<table>.ts`, UUID primary keys, migrations generated with `drizzle-kit generate` and reviewed, and never run migrations or destructive SQL against production.
98
+
99
+ ## Env
100
+
101
+ ```ts
102
+ import { createEnv } from "@t3-oss/env-nextjs";
103
+ import { clerkClientEnv, clerkServerEnv, dbEnv, upstashEnv } from "@inbeat/core/env";
104
+ export const env = createEnv({
105
+ server: { ...dbEnv, ...clerkServerEnv, ...upstashEnv },
106
+ client: { ...clerkClientEnv },
107
+ experimental__runtimeEnv: { NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY },
108
+ emptyStringAsUndefined: true,
109
+ });
110
+ ```
111
+
112
+ ## Other building blocks
113
+
114
+ | Need | Import |
115
+ |---|---|
116
+ | Neon pool | `createDb` from `@inbeat/next/db` |
117
+ | Clerk webhook | `createClerkWebhookHandler` from `@inbeat/next/clerk-webhook` |
118
+ | Headers/CSP | `securityHeaders`, `clerkFrontendApi` from `@inbeat/next/security-headers` |
119
+ | Sentry | `sentryServerOptions` from `@inbeat/next/sentry` (server, edge); `sentryClientOptions` from `@inbeat/next/sentry-client` (browser only) |
120
+ | Redirect param | `safeRedirectPath` from `@inbeat/next/safe-redirect` |
121
+
122
+ ## Changing the toolkit
123
+
124
+ Edit `~/Desktop/@inbeat/ib-toolkit`, run its tests, then `bun run sync <app-dir>` there to copy
125
+ the sources into the app's `node_modules/@inbeat/*` (use `--watch` while iterating).
@@ -0,0 +1,155 @@
1
+ ---
2
+ name: ib-ui
3
+ description: Build UI in an inBeat app with @inbeat/ui primitives and tokens. Use when creating or editing components, pages, dialogs, tables, forms, empty states, or when the user mentions ib-ui or @ib-ui.
4
+ ---
5
+
6
+ # ib-ui
7
+
8
+ ## Before writing any UI
9
+
10
+ Check the [Catalog](#catalog). Build pages and domain compositions only from catalog components
11
+ and tokens. Icons are always `lucide-react`.
12
+
13
+ **Allowed in the app**: page layouts and domain compositions (a `CreatorCard` from `Avatar` +
14
+ `Badge`) in `src/shared/components/blocks` or the feature's `components/`. Different apps may
15
+ compose different layouts.
16
+
17
+ **Not allowed**: new generic primitives or variants (a local button, modal, select, table or
18
+ toast), copying or restyling a toolkit component, faking a variant with override classes,
19
+ installing another UI library, running `shadcn add`.
20
+
21
+ **Missing component**: stop. Tell the user which component or variant is missing and why, and
22
+ propose adding it to ib-toolkit (`packages/ui` plus its story). Only with the user's explicit
23
+ agreement, use a workaround and mark it `// toolkit-gap: <what is missing>` so it is greppable.
24
+
25
+ ## Rules
26
+
27
+ - Import `@inbeat/ui/components/<name>`; `cn` from `@inbeat/ui/lib/utils`.
28
+ - Tokens only: `bg-background`, `text-muted-foreground`, `border-border`, `bg-primary`, radius
29
+ scale `rounded-{sm..4xl}`, shadows `shadow-hairline|subtle|elevated`. No hex colors, no
30
+ arbitrary spacing unless no token fits.
31
+ - App shells: `AppShell` + `AppNav`, never Sidebar primitives. Links come in via `renderLink`;
32
+ app sidebar content goes in the `sidebar` slot. Products differ only through `AppShell` props
33
+ (`variant`, `collapsible`, `sidebarWidth`, `headerVariant`), never classes or local shell markup.
34
+ - Variants are only the ones an app uses. `Button`: default, secondary, outline, ghost, link,
35
+ dots; sizes xs, sm, default, lg, icon*; always `rounded-full`, no destructive variant.
36
+ `Badge`: default, secondary, outline. `TabsList` is pills only.
37
+ - Tabs: `Tabs` + `TabsList tabs={[{ value, label, count? }]}` + `TabsContent`; drop
38
+ `TabsContent` for a filter bar.
39
+ - Destructive flows open `DestructiveActionDialog` (owns the confirm step; `variant="typed"`
40
+ makes the user type `DELETE <name>`). Confirm never closes it: call `onOpenChange(false)` on
41
+ success, so a pending or failed action keeps it open.
42
+ - Action menus: `Button variant="dots"` + `DropdownMenu`. Inside an element with `data-row` it
43
+ reveals on row hover; elsewhere it stays visible.
44
+ - Icon-only buttons are `IconButton` (required `label` = tooltip + `aria-label`), except `dots`.
45
+ It works as a `DropdownMenuTrigger asChild` child; with `asChild` it renders a link. Show/hide
46
+ toggles: `label="Show filters" pressedLabel="Hide filters" pressed={isOpen}`; `pressed` owns the
47
+ look. Menu and popover triggers are not toggles: static label, no `pressed`.
48
+ - Labels are one to three words, no ids or emails.
49
+ - Lists: `DataTableShell` + `DataTable`. With no data render `EmptyState` instead of the table;
50
+ DataTable's "No results." is only for an empty filter result.
51
+ - Forms (client leaf): `schema.safeParse(values)` with the schema shared with the action; on
52
+ failure keep one message per field from `issues` (`issue.path[0]`). Per field:
53
+ `<Field data-invalid={Boolean(errors.name)}><Label/><Input aria-invalid={Boolean(errors.name)}
54
+ aria-describedby="name-error"/><FieldError id="name-error">{errors.name}</FieldError></Field>`.
55
+ On success `const result = await action(parsed.data)`; `if (!result.ok) toast.error(result.error)`
56
+ (`sonner`; `result.errorId` when present is the support reference).
57
+ - Animation: `motion/react` only, respecting `prefers-reduced-motion`.
58
+ - `page.tsx` stays a Server Component (title, layout, data loading); only the stateful leaf (a
59
+ table with dialogs, a form) is `"use client"`.
60
+
61
+ ## Catalog
62
+
63
+ Every module of `@inbeat/ui`, imported as `@inbeat/ui/<path>`.
64
+
65
+ ### Actions
66
+ - `components/button` — Button with variants, sizes and `buttonVariants` for link styling.
67
+ - `components/icon-button` — icon-only Button with a required label, tooltip and toggle state.
68
+
69
+ ### Forms
70
+ - `components/field` — Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldSet and friends for form layout.
71
+ - `components/label` — accessible form Label.
72
+ - `components/input` — text Input.
73
+ - `components/textarea` — multi-line Textarea.
74
+ - `components/checkbox` — Checkbox.
75
+ - `components/select` — single-choice Select.
76
+ - `components/combobox` — searchable Combobox with chips for multiple values.
77
+ - `components/multi-select-popover` — popover multi-select over a list of options.
78
+ - `components/toggle-pills` — pill buttons toggling a set of string options, for filters.
79
+ - `components/input-group` — Input or Textarea with inline addons, buttons and text.
80
+ - `components/search-input` — controlled search Input with a loading state.
81
+ - `components/calendar` — date Calendar (react-day-picker) in the toolkit style.
82
+
83
+ ### Display
84
+ - `components/avatar` — Avatar, AvatarImage, AvatarFallback, AvatarGroup and AvatarGroupCount.
85
+ - `components/person-identity` — PersonAvatar, PersonName and PersonIdentity for a person's image and name.
86
+ - `components/member-avatar-stack` — overlapping avatars of up to three members plus a remainder count.
87
+ - `components/badge` — Badge chip.
88
+ - `components/number-dot` — small count pill, renders nothing at zero.
89
+ - `components/labeled-field` — read-only label and value pair.
90
+ - `components/empty-state` — EmptyState with icon, title, description and an optional labelled action.
91
+ - `components/separator` — horizontal or vertical Separator.
92
+ - `components/carousel` — Embla Carousel, CarouselContent and CarouselItem.
93
+ - `components/collapsible-section` — animated CollapsibleSection with trigger and content.
94
+
95
+ ### Feedback
96
+ - `components/alert` — Alert, AlertTitle and AlertDescription (default, destructive).
97
+ - `components/sonner` — Toaster for `sonner` toasts; mount once in the root layout.
98
+ - `components/spinner` — Spinner and SectionSpinner for loading states.
99
+ - `components/skeleton` — Skeleton placeholder.
100
+ - `components/progress` — Progress bar.
101
+ - `components/platform-disclaimer` — dismissible notice whose dismissal is stored in a cookie.
102
+
103
+ ### Navigation
104
+ - `components/app-nav` — sidebar navigation list driven by `items` and `renderLink`.
105
+ - `components/nav-user` — sidebar user menu with name, email, avatar and dropdown.
106
+ - `components/breadcrumb` — Breadcrumb trail.
107
+ - `components/tabs` — Tabs, TabsList (pills from a `tabs` array) and TabsContent.
108
+ - `components/sidebar` — Sidebar primitives that AppShell is built from; apps do not use them directly.
109
+
110
+ ### Overlays
111
+ - `components/dialog` — modal Dialog.
112
+ - `components/alert-dialog` — AlertDialog for interrupting confirmations.
113
+ - `components/destructive-action-dialog` — confirm step for destructive actions, optionally typed.
114
+ - `components/sheet` — side Sheet panel.
115
+ - `components/popover` — Popover.
116
+ - `components/dropdown-menu` — DropdownMenu.
117
+ - `components/tooltip` — Tooltip and TooltipProvider.
118
+ - `components/portal-container` — PortalContainerProvider and usePortalContainer to portal overlays into a chosen element.
119
+
120
+ ### Data
121
+ - `components/data-table` — DataTable driven by TanStack `columns` and `data`.
122
+ - `components/data-table-shell` — scrollable wrapper that holds a DataTable.
123
+ - `components/data-table-sortable-header` — sortable column header for DataTable columns.
124
+ - `components/table` — Table, TableHeader, TableBody, TableRow, TableHead and TableCell primitives.
125
+ - `components/load-more-sentinel` — invisible marker that triggers infinite loading.
126
+
127
+ ### Layout
128
+ - `components/app-shell` — AppShell page frame with sidebar and header, and AppShellLogo.
129
+
130
+ ### Hooks and lib
131
+ - `hooks/use-mobile` — `useIsMobile()` breakpoint hook.
132
+ - `lib/utils` — `cn()` class merging.
133
+ - `lib/easing` — `EASING_FUNCTION` curves shared with motion animations.
134
+ - `lib/initials` — `getInitials(name, fallback)`.
135
+
136
+ ## Setup in an app
137
+
138
+ ```css
139
+ /* src/app/globals.css */
140
+ @import "tailwindcss";
141
+ @import "tw-animate-css";
142
+ @import "shadcn/tailwind.css";
143
+ @import "@inbeat/ui/theme.css";
144
+ ```
145
+
146
+ `next.config.ts`: `transpilePackages: ["@inbeat/ui"]` (ui ships source; core and next ship compiled JS).
147
+ The template installs every peer the catalog needs.
148
+
149
+ ## Adding or changing a component
150
+
151
+ Do it in `ib-toolkit/packages/ui/src/components`, add or update its story in `packages/ui/stories`
152
+ (`bun run storybook`; stories are grouped like the catalog, render every variant and size from a
153
+ declared list, and use the neutral data in `stories/lib/fixtures.ts`), add it to the Catalog
154
+ above (a test enforces this), then `bun run sync <app-dir>`. Domain compositions tied to one app's
155
+ data stay in the app; once a second app needs one, promote it here.
@@ -0,0 +1 @@
1
+ module.exports = require("@inbeat/config/dependency-cruiser");
@@ -0,0 +1,12 @@
1
+ # Origin for links that leave the app; Vercel provides VERCEL_URL and VERCEL_PROJECT_PRODUCTION_URL.
2
+ APP_URL=http://localhost:3000
3
+
4
+ # Optional
5
+ NEXT_PUBLIC_SENTRY_DSN=
6
+ # Set by Vercel: VERCEL_ENV. Mirror it as NEXT_PUBLIC_VERCEL_ENV for browser Sentry events.
7
+ NEXT_PUBLIC_VERCEL_ENV=
8
+
9
+ # Build-time only (source map upload in next.config.ts)
10
+ SENTRY_ORG=inbeat-67
11
+ SENTRY_PROJECT=
12
+ SENTRY_AUTH_TOKEN=
@@ -0,0 +1,14 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+ pull-requests: read
11
+
12
+ jobs:
13
+ verify:
14
+ uses: Genoux/ib-toolkit/.github/workflows/verify.yml@main
@@ -0,0 +1,139 @@
1
+ # AGENTS.md
2
+
3
+ <!-- ib:standard:start — generated by @inbeat/standards, edit in ib-toolkit -->
4
+ ## inBeat toolkit standard
5
+
6
+ Stack: Next.js App Router, TypeScript strict, React 19, Drizzle + Neon, Clerk, Sentry, Vercel, Bun.
7
+ Shared code comes from `@inbeat/*` (ui ships source via `transpilePackages`; core and next ship compiled JS). Never fork a toolkit
8
+ file into the app; change it in ib-toolkit.
9
+
10
+ ### Before you build
11
+
12
+ - If "Product brief" under "This app" is empty, write the user's brief there first (problem,
13
+ users, core flows), then build.
14
+ - Read the matching guide before the work, whatever tool you are: `.agents/skills/ib-ui/SKILL.md`
15
+ (component catalog, UI rules) before any UI, `.agents/skills/ib-feature/SKILL.md` for a new
16
+ feature, `.agents/skills/ib-toolkit/SKILL.md` for actions, routes, auth, env and rate limits.
17
+ - Read the version-matched Next.js docs in `node_modules/next/dist/docs/` before Next.js work.
18
+
19
+ ### Two tiers
20
+
21
+ - **Required** (CI enforces): `@inbeat/config` presets (extend them: biome, tsconfig, dependency-cruiser, vitest; `ib check`
22
+ enforces it), the action/route error contract,
23
+ `securityHeaders()`, Sentry presets, rate limiting on public entry points, this block.
24
+ - **Opt-in**: `@inbeat/ui` blocks, extra layer rules.
25
+ - A deliberate departure is allowed when the app states it under **Deliberate deviations**
26
+ in its own section of this file (what, why, owner). Undeclared drift is a bug.
27
+
28
+ ### Layout
29
+
30
+ ```
31
+ src/app/ routes + composition; private pieces in _components/
32
+ src/features/<f>/actions/ "use server" mutations, one per file, via action()
33
+ src/features/<f>/queries/ server-only reads for Server Components
34
+ src/features/<f>/server/ domain commands and policies ("server-only", no "use server")
35
+ src/features/<f>/components/ hooks/ lib/ schemas.ts types.ts
36
+ src/entities/ leaf: domain types, zod schemas, constants
37
+ src/integrations/<vendor>/ external adapters
38
+ src/shared/{components,config,lib,hooks}/
39
+ src/db/schema/<table>.ts, src/db/migrations/
40
+ ```
41
+
42
+ Layers (dependency-cruiser, `@inbeat/config/dependency-cruiser`): features never import each
43
+ other; entities import nothing internal; db imports entities only; shared and integrations never
44
+ import features or app.
45
+
46
+ ### Reads and writes
47
+
48
+ | Need | Use |
49
+ |---|---|
50
+ | Page data | Server Component calls `queries/*` |
51
+ | Mutation from own UI | `action()` from `@inbeat/next/action`, returns `Result` |
52
+ | Client polling, infinite lists | `GET` via `route()`, React Query |
53
+ | Webhooks, presign, redirects, cron | `route()` |
54
+
55
+ Server actions are queued per client; never use them for reads.
56
+
57
+ ### Errors
58
+
59
+ - Actions resolve to `{ ok: true, data } | { ok: false, error, code?, errorId? }`.
60
+ Routes answer `{ error, code, errorId? }` with the status from `APP_ERROR_STATUS`.
61
+ - Throw `AppError(kind)` from `@inbeat/core/errors`; never `new Error("Forbidden")`.
62
+ Kinds: validation, auth, forbidden, not_found, conflict, rate_limited, db_transient,
63
+ db_query, timeout, external_service, unknown.
64
+ - Unexpected errors are classified, reported once, and replaced with safe copy + `ERR_xxxx`.
65
+ - Log with `createLogger()`; ids only, never emails, names or payloads. No `console.log`.
66
+
67
+ ### Auth
68
+
69
+ - New apps start without authentication; set it up per "Integrations" in the `ib-toolkit` skill.
70
+ - `authorize` is required on every `action()` and `route()`, and it is the app's own check
71
+ (session, roles, ownership). `publicAccess` is an explicit choice and must be rate limited.
72
+ - Redirect targets go through `safeRedirectPath`. `APP_URL` comes from env, never `Host`.
73
+
74
+ ### Security baseline
75
+
76
+ - `securityHeaders()` in `next.config.ts`; CSP report-only until a clean week, then enforce.
77
+ - `createRateLimiter()` (Upstash in prod) on every unauthenticated entry, presign and LLM call.
78
+ - Webhooks verify the sender's signature. Secrets only in Vercel env.
79
+ - `import "server-only"` in every module touching secrets or the DB.
80
+ - Env through `@t3-oss/env-nextjs` + `@inbeat/core/env` fragments; no `process.env` elsewhere.
81
+
82
+ ### Data
83
+
84
+ - UUID PKs, `timestamp({ withTimezone: true })`, snake_case, one table per file.
85
+ - Migrations are generated, reviewed, forward-only, expand then contract; they run after the
86
+ build resolves. Never run migrations or destructive SQL against production unless the user
87
+ says "apply migrations to prod".
88
+ - Deletes that own storage objects collect keys in the transaction and delete after commit.
89
+
90
+ ### UI
91
+
92
+ - UI comes only from `@inbeat/ui` (catalog in the `ib-ui` skill) plus layouts composed from it. If a
93
+ component or variant is missing, stop and flag it for ib-toolkit; never invent, copy or patch
94
+ one (a workaround needs the user's OK and a `// toolkit-gap:` comment).
95
+ - Primitives and generic blocks from `@inbeat/ui/components/*`; tokens from
96
+ `@inbeat/ui/theme.css`. A block moves to the toolkit only once an app actually uses it.
97
+ - Buttons are `rounded-full` with no destructive variant; destructive flows use
98
+ `DestructiveActionDialog` from `@inbeat/ui`. Icon-only buttons get a tooltip. Lists use
99
+ `DataTableShell` + `DataTable`; empty lists use `EmptyState`.
100
+ - Radix via `radix-ui`; animation via `motion` only.
101
+
102
+ ### Simplicity
103
+
104
+ - Reuse before writing: the `ib-ui` catalog, `@inbeat/*`, the stdlib, then the platform. No speculative abstractions, config or "for later" scaffolding.
105
+ - Comments carry only what code cannot. A deliberate shortcut is marked `// shortcut: <ceiling>; <upgrade path>`.
106
+ - Claude Code reads `AGENTS.md` only when the app has no `CLAUDE.md`; if one exists it must contain an `@AGENTS.md` line (`ib check` fails otherwise).
107
+
108
+ ### Tests and CI
109
+
110
+ - Vitest colocated `*.test.ts` for every `server/` command, policy and `lib/` function;
111
+ `*.integration.test.ts` against a Neon branch; Playwright in `tests/e2e` against previews.
112
+ - CI: biome, tsc, dependency-cruiser, vitest, migration check, `ib check`, gitleaks.
113
+
114
+ ### Commits
115
+
116
+ Conventional commits, lowercase, present tense, title under 60 chars. No AI attribution.
117
+ Never commit, push or open a PR unless explicitly asked.
118
+ <!-- ib:standard:end -->
119
+
120
+ ## This app
121
+
122
+ ### Stack deltas
123
+
124
+ - No authentication by default; see "Integrations" in the `ib-toolkit` skill.
125
+ - No database: add `@inbeat/next/db` with Drizzle and Neon when the app needs one.
126
+ - No Playwright: add it when the app needs e2e coverage.
127
+
128
+ ### Deliberate deviations
129
+
130
+ | What | Why | Owner |
131
+ |---|---|---|
132
+
133
+ ### Known gaps
134
+
135
+ -
136
+
137
+ ### Learned facts
138
+
139
+ -
@@ -0,0 +1,10 @@
1
+ node_modules
2
+ .next
3
+ next-env.d.ts
4
+ .env*
5
+ !.env.example
6
+ *.tsbuildinfo
7
+ .turbo
8
+ coverage
9
+ .vercel
10
+ .DS_Store
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": ["@inbeat/config/biome"],
3
+ "files": {
4
+ "includes": ["**", "!!**/.next", "!**/next-env.d.ts"]
5
+ }
6
+ }
@@ -0,0 +1,32 @@
1
+ import { clerkFrontendApi, securityHeaders } from "@inbeat/next/security-headers";
2
+ import { withSentryConfig } from "@sentry/nextjs/config";
3
+ import type { NextConfig } from "next";
4
+
5
+ const nextConfig: NextConfig = {
6
+ devIndicators: false,
7
+ transpilePackages: ["@inbeat/ui"],
8
+ headers: async () => [
9
+ {
10
+ source: "/(.*)",
11
+ headers: securityHeaders({
12
+ clerkFrontendApi: clerkFrontendApi(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY),
13
+ dev: process.env.NODE_ENV !== "production",
14
+ }),
15
+ },
16
+ ],
17
+ };
18
+
19
+ export default withSentryConfig(nextConfig, {
20
+ org: process.env.SENTRY_ORG,
21
+ project: process.env.SENTRY_PROJECT,
22
+ silent: !process.env.CI,
23
+ widenClientFileUpload: true,
24
+ // Browser events go through this rewrite so ad-blockers don't drop them; the proxy matcher
25
+ // must never catch it.
26
+ tunnelRoute: "/monitoring",
27
+ webpack: {
28
+ treeshake: {
29
+ removeDebugLogging: true,
30
+ },
31
+ },
32
+ });
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@inbeat/template",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "next dev",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "typecheck": "tsc --noEmit",
11
+ "test": "vitest run",
12
+ "lint": "biome check .",
13
+ "lint:fix": "biome check . --write",
14
+ "format": "biome format --write .",
15
+ "lint:arch": "depcruise src --config .dependency-cruiser.cjs",
16
+ "ib:sync": "ib sync",
17
+ "ib:check": "ib check"
18
+ },
19
+ "dependencies": {
20
+ "@base-ui/react": "^1.2.0",
21
+ "@inbeat/core": "workspace:^",
22
+ "@inbeat/next": "workspace:^",
23
+ "@inbeat/ui": "workspace:^",
24
+ "@sentry/nextjs": "^10",
25
+ "@t3-oss/env-nextjs": "^0.13.8",
26
+ "@tanstack/react-table": "^8.21.3",
27
+ "class-variance-authority": "^0.7.1",
28
+ "embla-carousel-react": "^8.6.0",
29
+ "geist": "^1.7.2",
30
+ "lucide-react": "^1.14.0",
31
+ "motion": "^12.34.0",
32
+ "next": "16.3.8",
33
+ "next-themes": "^0.4.6",
34
+ "radix-ui": "^1.4.3",
35
+ "react": "19.2.3",
36
+ "react-day-picker": "^9.14.0",
37
+ "react-dom": "19.2.3",
38
+ "sonner": "^2.0.7",
39
+ "tailwind-merge": "^3.4.0",
40
+ "tw-animate-css": "^1.4.0",
41
+ "zod": "^4.3.6"
42
+ },
43
+ "devDependencies": {
44
+ "@biomejs/biome": "^2.5.15",
45
+ "@inbeat/config": "workspace:^",
46
+ "@inbeat/standards": "workspace:^",
47
+ "@tailwindcss/postcss": "^4.3.3",
48
+ "@types/node": "^22",
49
+ "@types/react": "^19",
50
+ "@types/react-dom": "^19",
51
+ "dependency-cruiser": "^17.0.1",
52
+ "shadcn": "^4.21.0",
53
+ "tailwindcss": "^4.3.3",
54
+ "typescript": "^5",
55
+ "vitest": "^4.1.1"
56
+ },
57
+ "packageManager": "bun@1.3.5",
58
+ "trustedDependencies": [
59
+ "@sentry/cli",
60
+ "sharp"
61
+ ]
62
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1,11 @@
1
+ import { sentryServerOptions } from "@inbeat/next/sentry";
2
+ import * as Sentry from "@sentry/nextjs";
3
+ import { env } from "@/shared/config/env";
4
+
5
+ Sentry.init(
6
+ sentryServerOptions({
7
+ dsn: env.NEXT_PUBLIC_SENTRY_DSN,
8
+ enabled: process.env.NODE_ENV === "production",
9
+ environment: env.VERCEL_ENV,
10
+ }),
11
+ );
@@ -0,0 +1,12 @@
1
+ import { sentryServerOptions } from "@inbeat/next/sentry";
2
+ import * as Sentry from "@sentry/nextjs";
3
+ import { env } from "@/shared/config/env";
4
+
5
+ Sentry.init({
6
+ ...sentryServerOptions({
7
+ dsn: env.NEXT_PUBLIC_SENTRY_DSN,
8
+ enabled: process.env.NODE_ENV === "production",
9
+ environment: env.VERCEL_ENV,
10
+ }),
11
+ integrations: [Sentry.consoleLoggingIntegration({ levels: ["log", "warn", "error"] })],
12
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { GET } from "./route";
3
+
4
+ describe("GET /api/health", () => {
5
+ it("answers ok without authentication", async () => {
6
+ const response = await GET(new Request("http://localhost/api/health"), {
7
+ params: Promise.resolve({}),
8
+ });
9
+ expect(response.status).toBe(200);
10
+ expect(await response.json()).toEqual({ status: "ok" });
11
+ });
12
+ });
@@ -0,0 +1,7 @@
1
+ import { route } from "@inbeat/next/route";
2
+
3
+ export const GET = route({
4
+ name: "health",
5
+ authorize: async () => null,
6
+ handler: async () => ({ status: "ok" }),
7
+ });
@@ -0,0 +1,24 @@
1
+ "use client";
2
+
3
+ import { Button } from "@inbeat/ui/components/button";
4
+
5
+ type ErrorPageProps = {
6
+ error: Error & { digest?: string };
7
+ reset: () => void;
8
+ };
9
+
10
+ // `error.message` is never rendered: in production it is Next's generic copy, and in dev it can
11
+ // carry internals. The digest is enough for support to find the server log.
12
+ export default function ErrorPage({ error, reset }: ErrorPageProps) {
13
+ return (
14
+ <main className="flex min-h-screen flex-col items-center justify-center gap-2 px-6 text-center">
15
+ <h1 className="text-2xl font-medium">Something went wrong</h1>
16
+ {error.digest ? (
17
+ <p className="font-mono text-xs text-muted-foreground">{error.digest}</p>
18
+ ) : null}
19
+ <Button type="button" variant="outline" onClick={reset} className="mt-6">
20
+ Try again
21
+ </Button>
22
+ </main>
23
+ );
24
+ }