@assembly-js/app-foundation 0.0.25-rc.1 → 0.0.25-rc.3

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/CLAUDE.md CHANGED
@@ -120,7 +120,12 @@ The app should **detect the viewer type from the token** and render audience-app
120
120
 
121
121
  **The CRM tab is never the only path to per-client data.** The CRM tab is a shortcut: it renders with a specific `clientId`/`companyId` already in the token. But any client/company-specific data or action it exposes must *also* be reachable from the Dashboard main view — even if it takes a few clicks. Build the main view so an internal user can browse to a specific client/company (a list, or a `UserCompanySelector` that drills into a per-entity detail screen) and reach the same data and actions there. The main-view detail screen doesn't have to match the CRM-tab layout — it can reuse the same components or present them differently — but the main view must never be empty or aggregate-only when the CRM tab has per-client functionality. Don't leave `internalUrl` as a dead end.
122
122
 
123
+ <!-- !feature:app-owned-header -->
123
124
  **More generally: every page you create needs a click path.** Next.js builds and typechecks a page nothing links to, so an orphaned route ships silently. Before a page is done, something on an already-reachable page must point at it — a `<Link>`, `router.push`, redirect, header CTA, or actions-menu item. `bun run build` enforces this (`bun run check:route-reachability` runs it alone). When a primary flow depends on managing another resource first (cases need stages, documents need templates), ship that manager's entry point together with the dependent flow — an actions-menu item on the main view plus a link on the empty state that blocks on it.
125
+ <!-- /feature:app-owned-header -->
126
+ <!-- feature:app-owned-header -->
127
+ **More generally: every page you create needs a click path.** Next.js builds and typechecks a page nothing links to, so an orphaned route ships silently. Before a page is done, something on an already-reachable page must point at it — a `<Link>`, `router.push`, redirect, or actions-menu item. `bun run build` enforces this (`bun run check:route-reachability` runs it alone). When a primary flow depends on managing another resource first (cases need stages, documents need templates), ship that manager's entry point together with the dependent flow — an actions-menu item on the main view plus a link on the empty state that blocks on it.
128
+ <!-- /feature:app-owned-header -->
124
129
 
125
130
  ### Per-client app visibility — platform setting, not the app's job
126
131
 
@@ -186,8 +191,24 @@ Because the app runs in an iframe inside the parent dashboard, several normal Ne
186
191
  - **No `window.open(..., '_self')`.** Same issue.
187
192
  - **The portal owns the chrome (header, sidebar).** Don't render your own breadcrumbs or page titles via DOM. Use `@assembly-js/app-bridge` to set them.
188
193
  - **Never put the app name in breadcrumbs.** The parent portal *automatically* prepends the app's name as breadcrumb 0. If you also pass `{ label: 'MyApp' }` as the first entry of `setBreadcrumbs`, it renders twice ("MyApp / MyApp / Reports"). For a flat one-page app pass `[]` (or skip `setBreadcrumbs` entirely). For a multi-level app pass only the sub-pages: `setBreadcrumbs([{ label: 'Reports' }, { label: 'Q4 2026' }])`. Read `.claude/skills/assembly-app-bridge-header/SKILL.md` for full examples.
194
+ <!-- !feature:app-owned-header -->
189
195
  - **Action buttons go in the header — set via the `useAssemblyHeader` hook.** Submit, Save, Edit, Create — any primary page-level action MUST live in the header (`primaryCta`), not a `<Button>` in the page body. Secondary actions use `secondaryCta`; overflow/tertiary actions use `actionsMenu`. Drive all of it through the `useAssemblyHeader` hook (`app/components/use-assembly-header.ts`) — **never call `AssemblyBridge.header.set*` from your own `useEffect`** (that hand-rolling is what makes header buttons vanish after a re-render). See `.claude/skills/assembly-app-bridge-header/SKILL.md` for the full API.
190
196
  **Exception: the notification surface (`/n`).** The Notification Center embeds the `/n` route in a plain `<iframe>` without app bridge header support. `useAssemblyHeader` calls are silently ignored — header CTAs never appear. On the notification surface, render primary actions as inline `<Button>` elements in the page body. Detect the notification surface by the route — code under `app/n/` IS the surface; for a shared component pass an explicit prop from the `/n` page rather than inferring from `payload.notificationId`, which is best-effort under the session-exchange flow. This exception applies ONLY to the `/n` route; all other surfaces MUST use the header. **Do not reuse a component that calls `useAssemblyHeader` on the `/n` surface** — if it also has an inline action button, both the header CTA and the inline button will render on non-notification surfaces (duplicate CTAs). Either create a separate component for `/n` or accept a prop that switches between header and inline CTAs.
197
+ <!-- /feature:app-owned-header -->
198
+ <!-- feature:app-owned-header -->
199
+
200
+ ### Your rendering surface
201
+
202
+ The app renders inside the Assembly dashboard iframe — global nav to the left, the app as the main area in the right column owning its full height, including the top. **There is no host header above the app; nothing prepends the app name.**
203
+
204
+ **The app owns its header.** Decide whether to have one at all and what goes in it: title, navigation, primary and secondary actions. Do not call `useAssemblyHeader` or `AssemblyBridge.header.*` — nothing listens to those calls.
205
+
206
+ Assembly's own conventions, offered as reference and not as a template: page-level primary CTAs sit at the top right of a page header and sometimes inline within the content; breadcrumbs, when present, sit top-left; the dashboard uses a 60 px header row with a bottom border when it has one.
207
+
208
+ **When deciding the header, title, navigation, and primary actions, prefer in this order:** (1) the user's stated preferences, (2) a light adherence to Assembly's conventions where they make sense for this app, (3) your own judgment for the app.
209
+
210
+ **The `/n` notification surface is unchanged.** Render primary actions as inline `<Button>` elements in the page body — the Notification Center has no header regardless of this flag.
211
+ <!-- /feature:app-owned-header -->
191
212
  - **Theme + fonts.** The portal pushes the workspace's theme to the iframe via `app-bridge`. Don't hardcode brand colors; use neutrals + Tailwind defaults and let the parent's theme show through where appropriate.
192
213
  - **`next.config.ts` is platform-owned — never edit it. Your Next config lives in `app.config.ts`.** The wrapper in `next.config.ts` (`withAssemblyDev` from `@assembly-js/app-foundation/next`) spreads `app.config.ts` and applies the iframe-preview invariants on top: `allowedDevOrigins` for the sandbox host (without it the app renders but has zero interactivity — hydration JavaScript is blocked cross-origin) and disabling the React debug channel (without it the preview hangs forever for users on WebSocket-blocking networks). Put your configuration in `app.config.ts`:
193
214
  ```ts
@@ -369,7 +390,12 @@ If `tsc --noEmit` is clean and the task says "verify the build", run `bun run bu
369
390
 
370
391
  `bun run build` also **fails** if any page reads `searchParams.token`. Auth is in the bearer cookie, not the URL — no page (not even a surface entry page) needs it; `getAssembly()` / `getSessionIdentity()` read the bearer. Fix as the error instructs — fetch that page's data via `useServerAction`/`authFetch` instead. Run `bun run check:token-usage` to check it on its own.
371
392
 
393
+ <!-- !feature:app-owned-header -->
372
394
  `bun run build` also **fails** if a page route is unreachable — no link, `router.push`, redirect, or header CTA anywhere else points at it. Fix as the error instructs by adding an entry point on a reachable page. Run `bun run check:route-reachability` to check it on its own.
395
+ <!-- /feature:app-owned-header -->
396
+ <!-- feature:app-owned-header -->
397
+ `bun run build` also **fails** if a page route is unreachable — no link, `router.push`, redirect, or other entry point elsewhere points at it. Fix as the error instructs by adding an entry point on a reachable page. Run `bun run check:route-reachability` to check it on its own.
398
+ <!-- /feature:app-owned-header -->
373
399
 
374
400
  ---
375
401
 
@@ -1081,10 +1107,12 @@ Pass `limit: 100` (the max) so the walk makes 5x fewer round trips than the ~20
1081
1107
  ## Client-side SDK: `@assembly-js/app-bridge`
1082
1108
 
1083
1109
  For controlling the portal chrome from your iframe. Use **only in client components** (`'use client'`).
1110
+ <!-- !feature:app-owned-header -->
1084
1111
 
1085
1112
  ### Header chrome
1086
1113
 
1087
1114
  Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.claude/skills/assembly-app-bridge-header/SKILL.md` for the full API and code examples. `AssemblyBridge` is a singleton — no init required. Use only in `'use client'` components.
1115
+ <!-- /feature:app-owned-header -->
1088
1116
 
1089
1117
  ### Session token — the foundation uses this, not you
1090
1118
 
@@ -1102,9 +1130,11 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1102
1130
  - ❌ Don't reinvent built-in Assembly features (Messages, Files, Forms, Tasks, Billing) — call the SDK methods listed above.
1103
1131
  - ❌ Don't use `assembly.clients.list()` — the SDK is flat. Use `assembly.listClients(...)`.
1104
1132
  - ❌ Don't cache decoded payloads for >30s — token rotates every 5 min and the user could be different on the next call (rare, but possible if a session is shared across tabs).
1133
+ <!-- !feature:app-owned-header -->
1105
1134
  - ❌ Don't render your own breadcrumbs / titles in the DOM — use `AssemblyBridge.header.*`.
1106
1135
  - ❌ Don't render primary page-level actions (Submit, Save, Edit, Create) as `<Button>` elements inside the page body. These MUST go in the header via the `useAssemblyHeader` hook's `primaryCta`. Contextual inline actions (e.g. row-level edit icons, list "Add item" buttons) may stay in-page. In-page primary CTAs make the UX inconsistent with the rest of the portal. **Exception: the notification surface (`/n`).** The Notification Center renders `/n` in a headerless iframe — `useAssemblyHeader` is ignored. On this surface, primary actions MUST be inline `<Button>` elements.
1107
1136
  - ❌ Don't call `AssemblyBridge.header.set*` from your own `useEffect`. Always go through `useAssemblyHeader` — hand-rolling the imperative API (split set/teardown effects, or an unstable dep like an inline closure created fresh each render) nulls a CTA another effect owns, and the button disappears after the first re-render.
1137
+ <!-- /feature:app-owned-header -->
1108
1138
  - ❌ Don't let two mounted components call `useAssemblyHeader` at once — e.g. a parent shell's scaffolding-era stub CTA left in place after a child took real ownership. Strict Mode hides the race in dev; the hook throws in dev and ignores the second caller in production. Delete the parent's stub the moment a child owns the slot.
1109
1139
  - ❌ Don't `window.open(url, '_self')` or set `window.location` for external links — use `<a target="_blank" rel="noreferrer">`.
1110
1140
  - ❌ Don't hardcode brand colors. The portal applies the workspace theme.
@@ -1150,6 +1180,8 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1150
1180
  3. **Call `getAssembly()` — no token argument, ever.** `getAssembly()` (RSC/server actions) and `getAssemblyForRequest(req)` (route handlers) read the bearer cookie. Identity via `getSessionIdentity()` / `useAssembly()`. Never read `searchParams.token`, thread a token, or set `x-assembly-token`.
1151
1181
  4. **The SDK is flat: `assembly.listClients(...)`, not `assembly.clients.list()`.** `dist/codegen/api/index.d.ts` lists every service; `services/<Resource>Service.d.ts` has the parameter shapes.
1152
1182
  5. **The bearer expires in 60 min; `useServerAction`/`authFetch` re-exchange it silently and retry.** You don't refresh anything or touch `AssemblyBridge.sessionToken`.
1183
+ <!-- !feature:app-owned-header -->
1153
1184
  6. **Use `AssemblyBridge.header.*`** for breadcrumbs/CTAs in client components. Don't render your own. (Exception: the notification surface `/n` has no header — render CTAs as inline buttons there.)
1185
+ <!-- /feature:app-owned-header -->
1154
1186
  7. **Don't reinvent Messages/Files/Forms/Tasks/Billing.** Wire them up via SDK.
1155
1187
  8. **Keep infrastructure plumbing out of chat.** Don't volunteer ASSEMBLY_ENV, COPILOT_ENV, token encryption, API key internals, or internal file names unprompted. Mid-build SDK auth warnings are expected — never surface them to the user. (Answering a user's direct technical question is fine.)
@@ -1,45 +1,90 @@
1
1
  #!/usr/bin/env node
2
2
  // Fails the build when scheduled work is half-wired. A cron route that no
3
3
  // `vercel.json` entry invokes never runs; an entry pointing at a missing route
4
- // 404s forever; a cron route without the platform-managed secret is a public
5
- // endpoint that mutates workspace data; and a cron faster than the platform's
6
- // floor keeps the app's database awake around the clock. Every one of those
7
- // passes typecheck and `next build` and only shows up after publish, when
8
- // nobody is looking — so the app's own build is the gate. Dependency free on
9
- // purpose: it runs inside the generated app's sandbox before any deps beyond
4
+ // 404s forever; a cron route that skips the platform-managed secret check is a
5
+ // public endpoint that mutates workspace data; and a cron faster than the
6
+ // platform's floor keeps the app's database awake around the clock. Every one
7
+ // of those passes typecheck and `next build` and only shows up after publish,
8
+ // when nobody is looking — so the app's own build is the gate. Dependency free
9
+ // on purpose: it runs inside the generated app's sandbox before any deps beyond
10
10
  // Node are guaranteed.
11
+ //
12
+ // The build fails only for cron wiring the current turn is changing (see
13
+ // cronWiringChangedThisTurn). Everything else — a template clone, a Vercel
14
+ // build, an update turn that never touched the cron — gets the same findings
15
+ // as warnings: those paths have no agent who can act on a failure, so failing
16
+ // them would only strand an app that already worked.
11
17
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
18
+ import { spawnSync } from 'node:child_process';
12
19
  import { join, relative, sep } from 'node:path';
13
20
 
14
21
  const ROOT = process.cwd();
15
22
  const APP_DIR = join(ROOT, 'app');
16
23
  const VERCEL_JSON = join(ROOT, 'vercel.json');
17
- const MANIFEST = join(ROOT, '.assembly', 'app.json');
18
24
 
19
25
  // The conventional home for cron routes; a route here with no schedule is the
20
26
  // "looked right in preview, never ran in production" bug this guard exists for.
21
27
  const CRON_ROUTE_PREFIX = '/api/cron/';
22
28
  // Vercel sends this env var as the bearer on every cron invocation; the platform
23
- // mints it at deploy when the manifest declares it.
29
+ // declares and mints it at deploy once vercel.json carries a cron entry.
24
30
  const CRON_SECRET_KEY = 'CRON_SECRET';
25
31
  // Platform floor: a poller faster than this never lets the app's database scale
26
32
  // to zero, which costs more than the work it does.
27
33
  const MIN_INTERVAL_MINUTES = 30;
28
34
  const ROUTE_FILE = /^route\.(tsx|ts|jsx|js)$/;
29
35
 
30
- function fail(lines) {
36
+ // The turn program exports the SHA the sandbox started this turn at; the files
37
+ // below are the ones whose change since then makes the findings the agent's own.
38
+ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
39
+ const CRON_WIRING_PATHS = ['vercel.json', 'app/api/cron/'];
40
+
41
+ function report(lines, { fatal }) {
31
42
  console.error('');
32
- console.error('Scheduled work is not wired correctly:');
43
+ console.error(
44
+ fatal
45
+ ? 'Scheduled work is not wired correctly:'
46
+ : 'Scheduled work is not wired correctly (pre-existing; this build is not failed for it):',
47
+ );
33
48
  console.error('');
34
49
  for (const l of lines) console.error(' - ' + l);
35
50
  console.error('');
36
- console.error('Every scheduled app needs all of these together:');
51
+ console.error('Every scheduled app needs both of these together:');
37
52
  console.error(' 1. One dispatcher route under app/api/cron/ that verifies the cron secret.');
38
53
  console.error(' 2. A matching entry in vercel.json, no more often than every 30 minutes:');
39
54
  console.error(' { "crons": [{ "path": "/api/cron/dispatch", "schedule": "*/30 * * * *" }] }');
40
- console.error(` 3. "${CRON_SECRET_KEY}" declared in .assembly/app.json requiredSecrets (the platform mints it).`);
55
+ console.error(`The platform declares and mints "${CRON_SECRET_KEY}" itself once vercel.json has a cron entry.`);
41
56
  console.error('See the assembly-scheduled-work skill.');
42
- process.exit(1);
57
+ if (fatal) process.exit(1);
58
+ }
59
+
60
+ function fail(lines) {
61
+ report(lines, { fatal: true });
62
+ }
63
+
64
+ function isCronWiringPath(file) {
65
+ return CRON_WIRING_PATHS.some((p) => (p.endsWith('/') ? file.startsWith(p) : file === p));
66
+ }
67
+
68
+ // Whether this turn changed vercel.json or a cron route: commits since the
69
+ // baseline plus whatever is still uncommitted. No baseline means no turn (a
70
+ // Vercel build, a clone bootstrap), so nothing is the agent's to fix. A git
71
+ // failure with a baseline fails closed — the sandbox is the one place a
72
+ // finding can still be acted on.
73
+ function cronWiringChangedThisTurn() {
74
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
75
+ if (!baseline) return false;
76
+ const listings = [
77
+ { args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
78
+ // Porcelain rows are "XY path", or "XY old -> new" for a rename.
79
+ { args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
80
+ ];
81
+ for (const { args, parse } of listings) {
82
+ const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
83
+ if (result.status !== 0) return true;
84
+ const changed = result.stdout.split('\n').map((line) => parse(line).trim()).filter(Boolean);
85
+ if (changed.some(isCronWiringPath)) return true;
86
+ }
87
+ return false;
43
88
  }
44
89
 
45
90
  function readJson(file, label) {
@@ -151,13 +196,6 @@ function minIntervalMinutes(schedule) {
151
196
  return Math.min(...gaps);
152
197
  }
153
198
 
154
- function declaredSecrets() {
155
- if (!existsSync(MANIFEST)) return new Set();
156
- const manifest = readJson(MANIFEST, '.assembly/app.json');
157
- const secrets = Array.isArray(manifest?.requiredSecrets) ? manifest.requiredSecrets : [];
158
- return new Set(secrets.map((s) => (typeof s?.key === 'string' ? s.key.trim() : '')));
159
- }
160
-
161
199
  // Only the two forms the skill prescribes count as verifying the secret, and
162
200
  // comments are stripped first so a copied auth comment above an unguarded
163
201
  // handler cannot pass. Form 1: the exported handler IS the cronRoute wrapper.
@@ -231,13 +269,6 @@ for (const route of cronRoutes) {
231
269
  }
232
270
 
233
271
  if (entries.length > 0) {
234
- if (!declaredSecrets().has(CRON_SECRET_KEY)) {
235
- problems.push(
236
- `.assembly/app.json requiredSecrets does not declare "${CRON_SECRET_KEY}" — without it Vercel sends no ` +
237
- `bearer and the cron route cannot tell a real tick from a stranger. Add ` +
238
- `{ "key": "${CRON_SECRET_KEY}", "label": "Cron secret" }; the platform mints the value at deploy.`,
239
- );
240
- }
241
272
  for (const file of coveredRoutes) {
242
273
  if (!verifiesCronSecret(readFileSync(file, 'utf8'))) {
243
274
  problems.push(
@@ -250,4 +281,4 @@ if (entries.length > 0) {
250
281
  }
251
282
  }
252
283
 
253
- if (problems.length > 0) fail(problems);
284
+ if (problems.length > 0) report(problems, { fatal: cronWiringChangedThisTurn() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.25-rc.1",
3
+ "version": "0.0.25-rc.3",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,3 +1,4 @@
1
+ <!-- !feature:app-owned-header -->
1
2
  ---
2
3
  name: assembly-app-bridge-header
3
4
  description: Use any time the app sets breadcrumbs, primary/secondary CTA, or actions menu in the header chrome. Always drive it via useAssemblyHeader — never call AssemblyBridge.header.set* from your own useEffect. The parent auto-prepends the app name as breadcrumb 0 — never include it yourself.
@@ -86,3 +87,5 @@ The Notification Center embeds the notification surface in a plain `<iframe>` **
86
87
  **This exception applies ONLY to the `/n` route.** All other surfaces (dashboard, client portal, CRM tab) MUST continue using `useAssemblyHeader`.
87
88
 
88
89
  **Common mistake: reusing a component that calls `useAssemblyHeader` on `/n`.** If you share a form or action view between the notification surface and another surface, and the component calls `useAssemblyHeader` while also rendering an inline `<Button>`, both the header CTA and the inline button appear on non-notification surfaces — a duplicate. Fix by either creating a dedicated component for `/n` that uses only inline buttons, or by accepting a prop (e.g. `useInlineActions`) that switches between the header hook and inline buttons.
90
+
91
+ <!-- /feature:app-owned-header -->
@@ -72,13 +72,20 @@ export default async function NotificationSurface({
72
72
 
73
73
  ### CTAs on the notification surface — inline buttons, NOT the header
74
74
 
75
+ <!-- !feature:app-owned-header -->
75
76
  The Notification Center embeds the notification surface (`/n`) in a plain `<iframe>` **without app bridge header support**. `useAssemblyHeader` calls on this surface are silently ignored — `primaryCta`, `secondaryCta`, and `actionsMenu` never render.
76
77
 
77
78
  **On the notification surface, render primary action buttons (Approve, Reject, Submit, Mark Complete, etc.) as inline `<Button>` elements in the page body.** This is the one exception to the portal-wide rule that CTAs must go in the header. The exception applies ONLY to the `/n` route — all other surfaces must continue using `useAssemblyHeader`.
79
+ <!-- /feature:app-owned-header -->
80
+ <!-- feature:app-owned-header -->
81
+ **On the notification surface, render primary action buttons (Approve, Reject, Submit, Mark Complete, etc.) as inline `<Button>` elements in the page body.**
82
+ <!-- /feature:app-owned-header -->
78
83
 
79
84
  Detect the notification surface by the route itself — code under `app/n/` IS the surface. For a component shared with other surfaces, pass an explicit `variant="notification"` prop from the `/n` page rather than inferring it from `payload.notificationId`: the claim is best-effort under the session-exchange flow, so a component that keys off it can silently fall back to header CTAs that never render inside the Notification Center iframe.
85
+ <!-- !feature:app-owned-header -->
80
86
 
81
87
  **Do not reuse a component that calls `useAssemblyHeader` on the notification surface.** A component that calls `useAssemblyHeader` AND renders an inline action button produces duplicate CTAs on non-notification surfaces — the header button and the inline button both appear. Either create a dedicated component for `/n` that uses only inline buttons, or accept a prop that switches between header and inline CTAs.
88
+ <!-- /feature:app-owned-header -->
82
89
 
83
90
  ## Creating a notification
84
91
 
@@ -58,7 +58,7 @@ The repo already has a `vercel.json`. **Add the `crons` key; keep every existing
58
58
 
59
59
  ## 3. Cron secret — declare it, never ask for it
60
60
 
61
- Vercel authenticates its own invocations by sending the project's `CRON_SECRET` env var as `Authorization: Bearer <value>`. Declare it as a required secret and the platform generates the value at deploy and sets it on the Vercel project:
61
+ Vercel authenticates its own invocations by sending the project's `CRON_SECRET` env var as `Authorization: Bearer <value>`. Declare it as a required secret and the platform generates the value at deploy and sets it on the Vercel project. The platform also declares it itself for any app whose `vercel.json` has a cron entry, so a missing declaration never blocks a publish — declaring it keeps the manifest honest about what the app reads:
62
62
 
63
63
  ```json
64
64
  {
@@ -161,7 +161,7 @@ Crons **never fire in the builder preview**; only a published app has Vercel's s
161
161
  curl -sS -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/dispatch
162
162
  ```
163
163
 
164
- Insert a row with `nextRunAt` in the past, run that, and confirm the side effect and the advanced `nextRunAt`. Then tell the user, in plain words, that the schedule starts running once the app is published. `bun run build` runs `check-crons.mjs`, which fails on a cron route without an entry, an entry without a route, a missing `CRON_SECRET` declaration, or a schedule faster than every 30 minutes — fix the wiring it names rather than deleting the check.
164
+ Insert a row with `nextRunAt` in the past, run that, and confirm the side effect and the advanced `nextRunAt`. Then tell the user, in plain words, that the schedule starts running once the app is published. `bun run build` runs `check-crons.mjs`, which fails on a cron route without an entry, an entry without a route, a handler that skips the secret check, or a schedule faster than every 30 minutes — fix the wiring it names rather than deleting the check. Cron wiring that predates your turn is reported as a warning instead; fix it when the task touches it.
165
165
 
166
166
  ## Don'ts
167
167