@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.
- package/CHANGELOG.md +100 -0
- package/agents/app-section.md +22 -0
- package/agents/standard.md +114 -0
- package/package.json +46 -0
- package/skills/ib-feature/SKILL.md +40 -0
- package/skills/ib-toolkit/SKILL.md +125 -0
- package/skills/ib-ui/SKILL.md +155 -0
- package/src/cli.ts +85 -0
- package/src/create-args.ts +10 -0
- package/src/create.ts +240 -0
- package/src/foundation.ts +42 -0
- package/src/mcp-servers.ts +8 -0
- package/src/mcp.ts +383 -0
- package/src/sync.ts +117 -0
- package/src/template.ts +34 -0
- package/template/.agents/skills/ib-feature/SKILL.md +40 -0
- package/template/.agents/skills/ib-toolkit/SKILL.md +125 -0
- package/template/.agents/skills/ib-ui/SKILL.md +155 -0
- package/template/.dependency-cruiser.cjs +1 -0
- package/template/.env.example +12 -0
- package/template/.github/workflows/ci.yml +14 -0
- package/template/AGENTS.md +139 -0
- package/template/_gitignore +10 -0
- package/template/biome.json +6 -0
- package/template/next.config.ts +32 -0
- package/template/package.json +62 -0
- package/template/postcss.config.mjs +7 -0
- package/template/sentry.edge.config.ts +11 -0
- package/template/sentry.server.config.ts +12 -0
- package/template/src/app/api/health/route.test.ts +12 -0
- package/template/src/app/api/health/route.ts +7 -0
- package/template/src/app/error.tsx +24 -0
- package/template/src/app/global-error.tsx +20 -0
- package/template/src/app/globals.css +4 -0
- package/template/src/app/icon.svg +1 -0
- package/template/src/app/layout.tsx +24 -0
- package/template/src/app/loading.tsx +9 -0
- package/template/src/app/not-found.tsx +14 -0
- package/template/src/app/page.tsx +7 -0
- package/template/src/app/robots.ts +6 -0
- package/template/src/instrumentation-client.ts +14 -0
- package/template/src/instrumentation.ts +13 -0
- package/template/src/shared/config/env.ts +19 -0
- package/template/src/shared/config/routes.ts +3 -0
- package/template/src/test/server-only.ts +1 -0
- package/template/tsconfig.json +10 -0
- 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,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,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,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,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
|
+
}
|