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

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
@@ -54,6 +54,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
54
54
  - `assembly-db-transactions` — REQUIRED when a route makes more than one DB write: `db.transaction()` is unsupported on neon-http and 500s. Use `db.batch([...])` for atomic multi-writes, sequential awaits otherwise.
55
55
  - `assembly-session-token` — the bearer lifecycle and how to read identity or fetch data: `getSessionIdentity()`/`useAssembly()`, `useServerAction`/`callAction`, `authFetch`. You never handle a token yourself.
56
56
  - `assembly-route-auth` — REQUIRED on every `app/api/.../[id]/route.ts` handler: `requireAuth` + `requireCompanyScope` before returning or mutating data.
57
+ - `assembly-scheduled-work` — REQUIRED when the prompt implies anything on a schedule or while nobody has the app open (remind, daily digest, every week, follow up in N days, at a specific time): one `*/30 * * * *` Vercel cron in `vercel.json` hitting one dispatcher route, every scheduled action a row in a schedule table, `CRON_SECRET` declared in `requiredSecrets` and verified on every tick, `getOfflineAssembly()` for platform calls. Checking for due work on page load ships a schedule that never fires.
57
58
  - `assembly-route-errors` — REQUIRED in every route handler: use `handleRouteError` from `@assembly-js/app-foundation/server` in catch blocks — never return `err.message`. Validate values before they hit typed DB columns (e.g. `parseUuid` before a uuid-column lookup).
58
59
  - `assembly-dev-server-logs` — the dev server's stdout/stderr (server-side errors, stack traces, console output) is readable at `/tmp/dev-server.log`; check it before guessing at a 500 or broken preview.
59
60
 
@@ -709,6 +710,8 @@ export async function PATCH(
709
710
 
710
711
  #### Offline bearer — background work with no user (cron, webhooks)
711
712
 
713
+ **Anything on a schedule — a reminder, a digest, a recurring send — must first read the `assembly-scheduled-work` skill.** The route below only ever runs if `vercel.json` declares a cron for it and the route verifies `CRON_SECRET`; the offline bearer is the auth half, the skill is the scheduling half.
714
+
712
715
  Everything above resolves the **viewer's** bearer from the request cookie. Some code runs with **no user in the loop and no cookie**: a scheduled job, or an incoming third-party webhook. There, call `getOfflineAssembly()` — it mints a short-lived, **app-scoped** bearer from your app API key (`ASSEMBLY_API_KEY`) via `POST /v1/token` and returns a bearer-only client, exactly like the user path but authenticated as the app rather than a viewer.
713
716
 
714
717
  ```ts
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when scheduled work is half-wired. A cron route that no
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
10
+ // Node are guaranteed.
11
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
12
+ import { join, relative, sep } from 'node:path';
13
+
14
+ const ROOT = process.cwd();
15
+ const APP_DIR = join(ROOT, 'app');
16
+ const VERCEL_JSON = join(ROOT, 'vercel.json');
17
+ const MANIFEST = join(ROOT, '.assembly', 'app.json');
18
+
19
+ // The conventional home for cron routes; a route here with no schedule is the
20
+ // "looked right in preview, never ran in production" bug this guard exists for.
21
+ const CRON_ROUTE_PREFIX = '/api/cron/';
22
+ // Vercel sends this env var as the bearer on every cron invocation; the platform
23
+ // mints it at deploy when the manifest declares it.
24
+ const CRON_SECRET_KEY = 'CRON_SECRET';
25
+ // Platform floor: a poller faster than this never lets the app's database scale
26
+ // to zero, which costs more than the work it does.
27
+ const MIN_INTERVAL_MINUTES = 30;
28
+ const ROUTE_FILE = /^route\.(tsx|ts|jsx|js)$/;
29
+
30
+ function fail(lines) {
31
+ console.error('');
32
+ console.error('Scheduled work is not wired correctly:');
33
+ console.error('');
34
+ for (const l of lines) console.error(' - ' + l);
35
+ console.error('');
36
+ console.error('Every scheduled app needs all of these together:');
37
+ console.error(' 1. One dispatcher route under app/api/cron/ that verifies the cron secret.');
38
+ console.error(' 2. A matching entry in vercel.json, no more often than every 30 minutes:');
39
+ 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).`);
41
+ console.error('See the assembly-scheduled-work skill.');
42
+ process.exit(1);
43
+ }
44
+
45
+ function readJson(file, label) {
46
+ let doc;
47
+ try {
48
+ doc = JSON.parse(readFileSync(file, 'utf8'));
49
+ } catch (e) {
50
+ fail([`${label} is not valid JSON: ${e.message}`]);
51
+ }
52
+ return doc;
53
+ }
54
+
55
+ function routeFiles(dir) {
56
+ const out = [];
57
+ if (!existsSync(dir)) return out;
58
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
59
+ const full = join(dir, entry.name);
60
+ if (entry.isDirectory()) {
61
+ if (entry.name === 'node_modules' || entry.name === '.next') continue;
62
+ out.push(...routeFiles(full));
63
+ } else if (ROUTE_FILE.test(entry.name)) {
64
+ out.push(full);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ // The URL segments a route file serves; route groups `(group)` and parallel
71
+ // slots `@slot` never appear in the URL.
72
+ function urlSegmentsForRouteFile(file) {
73
+ return relative(APP_DIR, file)
74
+ .split(sep)
75
+ .slice(0, -1)
76
+ .filter((seg) => !(seg.startsWith('(') && seg.endsWith(')')) && !seg.startsWith('@'));
77
+ }
78
+
79
+ // A dynamic segment (`[id]`, `[...slug]`) matches anything Vercel could put in
80
+ // that position of a cron path.
81
+ function routeServes(routeSegments, pathSegments) {
82
+ if (routeSegments.some((seg) => seg.startsWith('[...') || seg.startsWith('[['))) {
83
+ const fixed = routeSegments.findIndex((seg) => seg.startsWith('[...') || seg.startsWith('[['));
84
+ return pathSegments.length >= fixed && routeSegments.slice(0, fixed).every((seg, i) => seg === pathSegments[i]);
85
+ }
86
+ if (routeSegments.length !== pathSegments.length) return false;
87
+ return routeSegments.every((seg, i) => (seg.startsWith('[') && seg.endsWith(']')) || seg === pathSegments[i]);
88
+ }
89
+
90
+ function expandField(field, lo, hi) {
91
+ const values = new Set();
92
+ for (const part of field.split(',')) {
93
+ let range = part;
94
+ let step = 1;
95
+ if (part.includes('/')) {
96
+ const [r, s] = part.split('/');
97
+ range = r;
98
+ step = Number(s);
99
+ if (!Number.isInteger(step) || step < 1) return null;
100
+ }
101
+ let start;
102
+ let end;
103
+ if (range === '*') {
104
+ start = lo;
105
+ end = hi;
106
+ } else if (range.includes('-')) {
107
+ [start, end] = range.split('-').map(Number);
108
+ } else {
109
+ start = Number(range);
110
+ end = part.includes('/') ? hi : start;
111
+ }
112
+ if (![start, end].every(Number.isInteger) || start < lo || end > hi || start > end) return null;
113
+ for (let v = start; v <= end; v += step) values.add(v);
114
+ }
115
+ return values;
116
+ }
117
+
118
+ const MONTH_NAMES = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
119
+ const DAY_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
120
+
121
+ // Month and day-of-week accept names as well as numbers; normalise to numbers
122
+ // so one range parser covers all five fields. Only a whole token is a name —
123
+ // `JAN-MAR` becomes `1-3`, while `JANJAN` or `MONDAY` stay as they are and
124
+ // fail the numeric parse, as they would on Vercel.
125
+ function numericField(field, names, base) {
126
+ return names.reduce(
127
+ (acc, name, i) => acc.replace(new RegExp(`\\b${name}\\b`, 'g'), String(i + base)),
128
+ field.toUpperCase(),
129
+ );
130
+ }
131
+
132
+ // Smallest gap, in minutes, between two consecutive firings of a 5-field cron
133
+ // expression. Returns null for an expression Vercel would reject, in any field.
134
+ function minIntervalMinutes(schedule) {
135
+ const fields = schedule.trim().split(/\s+/);
136
+ if (fields.length !== 5) return null;
137
+ const minutes = expandField(fields[0], 0, 59);
138
+ const hours = expandField(fields[1], 0, 23);
139
+ const daysOfMonth = expandField(fields[2], 1, 31);
140
+ const months = expandField(numericField(fields[3], MONTH_NAMES, 1), 1, 12);
141
+ // 7 is Sunday again in most cron dialects, so the range runs to 7.
142
+ const daysOfWeek = expandField(numericField(fields[4], DAY_NAMES, 0), 0, 7);
143
+ if ([minutes, hours, daysOfMonth, months, daysOfWeek].some((f) => !f || f.size === 0)) return null;
144
+ if (minutes.size === 1) return 60;
145
+ const sorted = [...minutes].sort((a, b) => a - b);
146
+ const gaps = sorted.slice(1).map((m, i) => m - sorted[i]);
147
+ // The last firing of one hour and the first of the next only neighbour when
148
+ // the expression fires in two consecutive hours.
149
+ const consecutiveHours = [...hours].some((h) => hours.has((h + 1) % 24));
150
+ if (consecutiveHours) gaps.push(60 - sorted[sorted.length - 1] + sorted[0]);
151
+ return Math.min(...gaps);
152
+ }
153
+
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
+ // Only the two forms the skill prescribes count as verifying the secret, and
162
+ // comments are stripped first so a copied auth comment above an unguarded
163
+ // handler cannot pass. Form 1: the exported handler IS the cronRoute wrapper.
164
+ // Form 2: the hand-rolled check the Go-tree skill has the agent copy — reads
165
+ // the env var, compares the authorization header with the Bearer prefix, and
166
+ // returns 401.
167
+ const EXPORTED_CRON_ROUTE = /export\s+(?:const|let)\s+(?:GET|POST)\s*(?::[^=]+)?=\s*cronRoute\s*\(/;
168
+ function verifiesCronSecret(source) {
169
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
170
+ if (EXPORTED_CRON_ROUTE.test(code)) return true;
171
+ return (
172
+ code.includes(`process.env.${CRON_SECRET_KEY}`) &&
173
+ /headers\.get\(\s*['"`]authorization['"`]\s*\)/i.test(code) &&
174
+ /['"`]Bearer\s/.test(code) &&
175
+ /\b401\b/.test(code)
176
+ );
177
+ }
178
+
179
+ const crons = existsSync(VERCEL_JSON) ? readJson(VERCEL_JSON, 'vercel.json').crons : undefined;
180
+ const allRoutes = routeFiles(APP_DIR).map((file) => ({ file, segments: urlSegmentsForRouteFile(file) }));
181
+ const cronRoutes = allRoutes.filter((r) => ('/' + r.segments.join('/') + '/').startsWith(CRON_ROUTE_PREFIX));
182
+
183
+ if (crons === undefined && cronRoutes.length === 0) process.exit(0);
184
+
185
+ const problems = [];
186
+ if (crons !== undefined && !Array.isArray(crons)) {
187
+ problems.push('vercel.json "crons" must be an array of { "path", "schedule" } entries.');
188
+ }
189
+ const entries = Array.isArray(crons) ? crons : [];
190
+ const coveredRoutes = new Set();
191
+
192
+ entries.forEach((entry, i) => {
193
+ const label = `vercel.json crons[${i}]`;
194
+ if (typeof entry?.path !== 'string' || !entry.path.startsWith('/')) {
195
+ problems.push(`${label}: "path" must be a string starting with "/".`);
196
+ return;
197
+ }
198
+ if (typeof entry?.schedule !== 'string' || !entry.schedule.trim()) {
199
+ problems.push(`${label} (${entry.path}): "schedule" must be a 5-field cron expression.`);
200
+ return;
201
+ }
202
+ const pathSegments = entry.path.replace(/\/+$/, '').split('/').filter(Boolean);
203
+ const route = allRoutes.find((r) => routeServes(r.segments, pathSegments));
204
+ if (!route) {
205
+ problems.push(
206
+ `${label}: "${entry.path}" has no route file — Vercel would call it and get a 404 on every tick. ` +
207
+ `Add app${entry.path}/route.ts or fix the path.`,
208
+ );
209
+ } else {
210
+ coveredRoutes.add(route.file);
211
+ }
212
+ const interval = minIntervalMinutes(entry.schedule);
213
+ if (interval === null) {
214
+ problems.push(`${label} (${entry.path}): "${entry.schedule}" is not a valid 5-field cron expression.`);
215
+ } else if (interval < MIN_INTERVAL_MINUTES) {
216
+ problems.push(
217
+ `${label} (${entry.path}): "${entry.schedule}" fires every ${interval} minutes; the floor is every ` +
218
+ `${MIN_INTERVAL_MINUTES}. Use "*/30 * * * *" and keep per-action cadences in the app's schedule table.`,
219
+ );
220
+ }
221
+ });
222
+
223
+ for (const route of cronRoutes) {
224
+ const url = '/' + route.segments.join('/');
225
+ if (!coveredRoutes.has(route.file)) {
226
+ problems.push(
227
+ `${relative(ROOT, route.file)} serves ${url} but no vercel.json cron entry invokes it — it will never run ` +
228
+ `after publish. Add { "path": "${url}", "schedule": "*/30 * * * *" } to "crons" or remove the route.`,
229
+ );
230
+ }
231
+ }
232
+
233
+ 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
+ for (const file of coveredRoutes) {
242
+ if (!verifiesCronSecret(readFileSync(file, 'utf8'))) {
243
+ problems.push(
244
+ `${relative(ROOT, file)} is invoked by a cron but its exported handler does not verify ${CRON_SECRET_KEY} — ` +
245
+ `anyone who guesses the URL can trigger it. Either export the handler as ` +
246
+ `\`export const GET = cronRoute(async () => …)\`, or read \`process.env.${CRON_SECRET_KEY}\`, compare it ` +
247
+ `to the request's \`authorization\` header with the \`Bearer \` prefix, and return 401 before any work.`,
248
+ );
249
+ }
250
+ }
251
+ }
252
+
253
+ if (problems.length > 0) fail(problems);
package/dist/cron.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { type NextRequest } from 'next/server';
2
+ export type CronHandler = (request: NextRequest) => Promise<Response> | Response;
3
+ /**
4
+ * Reports whether a request carries the cron secret Vercel attaches to
5
+ * scheduled invocations. Fails closed when the env var is unset: a deploy that
6
+ * lost the secret must reject every tick rather than accept every caller.
7
+ */
8
+ export declare function isCronRequest(request: NextRequest): boolean;
9
+ /**
10
+ * Wraps a cron route handler so it only runs for Vercel's scheduler. Every
11
+ * other caller gets a 401 and the handler never executes.
12
+ *
13
+ * ```ts
14
+ * // app/api/cron/dispatch/route.ts
15
+ * export const GET = cronRoute(async () => {
16
+ * // claim due rows, run them, advance next run
17
+ * return Response.json({ ok: true });
18
+ * });
19
+ * ```
20
+ */
21
+ export declare function cronRoute(handler: CronHandler): (request: NextRequest) => Promise<Response>;
22
+ /** The fields of a schedule row that decide when it runs next. */
23
+ export interface ScheduleCadence {
24
+ /** The run that just happened (or was due). Advancing from it, not from now, keeps a 9:00 daily at 9:00. */
25
+ nextRunAt: Date;
26
+ /** Minutes between runs. Whole days keep their wall-clock time across DST. */
27
+ cadenceMinutes: number;
28
+ /** IANA zone the wall-clock time is anchored in, e.g. `America/New_York`. */
29
+ timezone: string;
30
+ }
31
+ /**
32
+ * The first occurrence of a recurring schedule strictly after `now`.
33
+ *
34
+ * Advances from the scheduled time rather than from `now`, so a 9:00 daily
35
+ * stays a 9:00 daily, and skips every missed occurrence so a gap never causes
36
+ * a catch-up storm. Sub-daily cadences are fixed intervals; daily and coarser
37
+ * ones keep their wall-clock time in the row's timezone, so a 9:00 reminder is
38
+ * still 9:00 after a daylight-saving change.
39
+ */
40
+ export declare function nextRunAfter(row: ScheduleCadence, now: Date): Date;
package/dist/cron.js ADDED
@@ -0,0 +1,87 @@
1
+ import { NextResponse } from 'next/server';
2
+ // Vercel sends the project's CRON_SECRET env var as `Authorization: Bearer
3
+ // <value>` on every cron invocation. The platform mints that value at deploy,
4
+ // so a route only has to compare — but hand-rolled comparisons are the single
5
+ // most common way generated cron routes fail (wrong header, missing prefix,
6
+ // comparing against a user-typed value), and a route that skips the check is a
7
+ // public endpoint that mutates workspace data.
8
+ const CRON_SECRET_ENV = 'CRON_SECRET';
9
+ const BEARER_PREFIX = 'Bearer ';
10
+ /**
11
+ * Reports whether a request carries the cron secret Vercel attaches to
12
+ * scheduled invocations. Fails closed when the env var is unset: a deploy that
13
+ * lost the secret must reject every tick rather than accept every caller.
14
+ */
15
+ export function isCronRequest(request) {
16
+ const expected = process.env[CRON_SECRET_ENV];
17
+ if (!expected)
18
+ return false;
19
+ return request.headers.get('authorization') === `${BEARER_PREFIX}${expected}`;
20
+ }
21
+ /**
22
+ * Wraps a cron route handler so it only runs for Vercel's scheduler. Every
23
+ * other caller gets a 401 and the handler never executes.
24
+ *
25
+ * ```ts
26
+ * // app/api/cron/dispatch/route.ts
27
+ * export const GET = cronRoute(async () => {
28
+ * // claim due rows, run them, advance next run
29
+ * return Response.json({ ok: true });
30
+ * });
31
+ * ```
32
+ */
33
+ export function cronRoute(handler) {
34
+ return async (request) => {
35
+ if (!isCronRequest(request)) {
36
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
37
+ }
38
+ return handler(request);
39
+ };
40
+ }
41
+ const DAY_MINUTES = 24 * 60;
42
+ function wallClock(at, timeZone) {
43
+ const parts = new Intl.DateTimeFormat('en-US', {
44
+ timeZone,
45
+ hourCycle: 'h23',
46
+ year: 'numeric',
47
+ month: 'numeric',
48
+ day: 'numeric',
49
+ hour: 'numeric',
50
+ minute: 'numeric',
51
+ }).formatToParts(at);
52
+ const read = (type) => Number(parts.find((p) => p.type === type)?.value);
53
+ return { year: read('year'), month: read('month'), day: read('day'), hour: read('hour'), minute: read('minute') };
54
+ }
55
+ // The instant at which `timeZone` reads `wc`. Date.UTC rolls an overflowing
56
+ // day into the next month, which is what makes "+N days" safe at month ends.
57
+ function zonedToUtc(wc, timeZone) {
58
+ const guess = Date.UTC(wc.year, wc.month - 1, wc.day, wc.hour, wc.minute);
59
+ const seen = wallClock(new Date(guess), timeZone);
60
+ const seenAsUtc = Date.UTC(seen.year, seen.month - 1, seen.day, seen.hour, seen.minute);
61
+ return new Date(guess - (seenAsUtc - guess));
62
+ }
63
+ /**
64
+ * The first occurrence of a recurring schedule strictly after `now`.
65
+ *
66
+ * Advances from the scheduled time rather than from `now`, so a 9:00 daily
67
+ * stays a 9:00 daily, and skips every missed occurrence so a gap never causes
68
+ * a catch-up storm. Sub-daily cadences are fixed intervals; daily and coarser
69
+ * ones keep their wall-clock time in the row's timezone, so a 9:00 reminder is
70
+ * still 9:00 after a daylight-saving change.
71
+ */
72
+ export function nextRunAfter(row, now) {
73
+ const step = row.cadenceMinutes;
74
+ if (step % DAY_MINUTES !== 0) {
75
+ let next = row.nextRunAt.getTime() + step * 60_000;
76
+ while (next <= now.getTime())
77
+ next += step * 60_000;
78
+ return new Date(next);
79
+ }
80
+ const days = step / DAY_MINUTES;
81
+ let next = row.nextRunAt;
82
+ do {
83
+ const wc = wallClock(next, row.timezone);
84
+ next = zonedToUtc({ ...wc, day: wc.day + days }, row.timezone);
85
+ } while (next.getTime() <= now.getTime());
86
+ return next;
87
+ }
@@ -18,8 +18,8 @@ let renewalInFlight = null;
18
18
  // for the freshest session token (the parent keeps it alive) and posts it to the
19
19
  // re-exchange route, which mints a new bearer and sets the HttpOnly cookie — no
20
20
  // page reload. Returns true iff a fresh bearer was set. This is the only place
21
- // app code touches the raw session token, and only to re-mint; it is never sent
22
- // to the platform API.
21
+ // app code touches the raw session token, to re-mint and keep the URL aligned
22
+ // with the cookie; it is never sent to the platform API.
23
23
  export function reexchangeBearer() {
24
24
  if (renewalInFlight)
25
25
  return renewalInFlight;
@@ -30,6 +30,9 @@ export function reexchangeBearer() {
30
30
  return renewal;
31
31
  }
32
32
  async function renewBearer() {
33
+ const previousUrlToken = typeof window === 'undefined'
34
+ ? null
35
+ : new URL(window.location.href).searchParams.get('token');
33
36
  let token = AssemblyBridge.sessionToken.getCurrent()?.token ?? '';
34
37
  // Proactively refresh: we're recovering from an expiry, so avoid re-minting
35
38
  // from a session token that may itself be stale.
@@ -55,6 +58,19 @@ async function renewBearer() {
55
58
  bridgeRefreshFailed,
56
59
  status: res.status,
57
60
  });
61
+ if (res.ok && previousUrlToken !== null) {
62
+ try {
63
+ const url = new URL(window.location.href);
64
+ // Don't overwrite a new preview session opened while renewal was in flight.
65
+ if (url.searchParams.get('token') === previousUrlToken) {
66
+ url.searchParams.set('token', token);
67
+ window.history.replaceState(window.history.state, '', url.href);
68
+ }
69
+ }
70
+ catch {
71
+ // A history update failure must not invalidate the cookie just renewed.
72
+ }
73
+ }
58
74
  return res.ok;
59
75
  }
60
76
  catch {
@@ -55,7 +55,13 @@ export async function mintBearerFromSessionToken(token, options) {
55
55
  try {
56
56
  const resp = await assembly.fetch('/v1/session', {
57
57
  method: 'POST',
58
- headers: { 'Content-Type': 'application/json' },
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ // Let /session validate the token in the body. The SDK's three-part
61
+ // key makes the authorizer reject expired tokens with a generic 403
62
+ // before the handler can distinguish a replay from an outage.
63
+ 'X-API-Key': `${payload.workspaceId}/${process.env.ASSEMBLY_API_KEY ?? ''}`,
64
+ },
59
65
  body: JSON.stringify({
60
66
  sessionId: payload.tokenId,
61
67
  ...(options?.cookieSessionId
package/dist/server.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './gmail.js';
9
9
  export * from './app-storage.js';
10
10
  export * from './labels.js';
11
11
  export * from './notifications.js';
12
+ export * from './cron.js';
12
13
  export * from './api-errors.js';
13
14
  export * from './params.js';
14
15
  export * from './client.js';
package/dist/server.js CHANGED
@@ -13,6 +13,7 @@ export * from './gmail.js';
13
13
  export * from './app-storage.js';
14
14
  export * from './labels.js';
15
15
  export * from './notifications.js';
16
+ export * from './cron.js';
16
17
  // Returns NextResponse, so route handlers only — but nothing here reads a
17
18
  // secret, which is why a client file may still `import type` from them.
18
19
  export * from './api-errors.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.24",
3
+ "version": "0.0.25-rc.1",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,174 @@
1
+ ---
2
+ name: assembly-scheduled-work
3
+ description: REQUIRED when the prompt implies anything happening on a schedule or while nobody has the app open — "remind", "every day/week/month", "daily digest", "follow up in N days", "at a specific time", "recurring", "expire after", "send later". Covers the one-dispatcher Vercel cron pattern (a schedule table + one cron entry + CRON_SECRET + cronRoute + getOfflineAssembly), the 30-minute floor, and the check-on-page-load anti-pattern that silently ships a schedule that never fires.
4
+ ---
5
+
6
+ A published app has no process running between requests. Anything that must happen at a time — not because someone clicked — only happens if **Vercel's scheduler calls a route of yours**. That is a cron, and it is declared in `vercel.json`, not in code.
7
+
8
+ ## The anti-pattern this skill exists to stop
9
+
10
+ ❌ Store a `dueAt` on a row and check for due rows when a page loads, in a `useEffect`, or on a `setInterval`. It passes every test and works in the builder preview, because the preview always has the app open. Published, nothing runs until someone happens to open the app — a "remind me at 3pm" arrives the next morning. This is the single most common way scheduled apps fail.
11
+
12
+ ✅ One cron entry, one dispatcher route, every scheduled action as a row the dispatcher reads. The four parts below always ship **together** — any one missing makes the schedule silently dead, and the build check `check-crons.mjs` fails the build when they disagree.
13
+
14
+ ## The shape: one dispatcher, everything is a row
15
+
16
+ | Part | Where | What |
17
+ |---|---|---|
18
+ | Schedule table | `lib/schema.ts` | one row per scheduled action: what to do, its cadence, when it next runs |
19
+ | Cron entry | `vercel.json` | `{ "path": "/api/cron/dispatch", "schedule": "*/30 * * * *" }` |
20
+ | Cron secret | `.assembly/app.json` → `requiredSecrets` | `{ "key": "CRON_SECRET", "label": "Cron secret" }` — the platform mints the value |
21
+ | Dispatcher route | `app/api/cron/dispatch/route.ts` | wrapped in `cronRoute`; claims due rows, runs them, advances `nextRunAt` |
22
+
23
+ Fixed cadences from the prompt ("daily summary at 9") and schedules users create at runtime ("remind Sarah on the 21st") are the **same thing**: rows. The cron list only changes on deploy, so never add a cron entry per action and never let a user-created schedule depend on one. "Notify every hour", "daily at 9", "monthly on the 1st" is one cron entry and three rows.
24
+
25
+ ## 1. Schedule table
26
+
27
+ ```ts
28
+ // lib/schema.ts
29
+ export const scheduledActions = pgTable('scheduled_actions', {
30
+ id: uuid('id').defaultRandom().primaryKey(),
31
+ action: text('action').notNull(), // which handler runs, e.g. 'client-reminder'
32
+ payload: jsonb('payload').$type<Record<string, unknown>>().notNull(),
33
+ cadenceMinutes: integer('cadence_minutes'), // null ⇒ one-off
34
+ timezone: text('timezone').notNull(), // IANA, e.g. 'America/New_York' — for "9:00 local"
35
+ nextRunAt: timestamp('next_run_at', { withTimezone: true }).notNull(),
36
+ status: text('status').notNull().default('pending'), // pending | running | done | failed
37
+ claimedAt: timestamp('claimed_at', { withTimezone: true }), // set while running; frees a stuck row
38
+ lastRunAt: timestamp('last_run_at', { withTimezone: true }),
39
+ lastError: text('last_error'),
40
+ });
41
+ ```
42
+
43
+ Validate on write: **a recurrence is a multiple of 30 minutes** (`cadenceMinutes >= 30 && cadenceMinutes % 30 === 0`) and **a one-off time is on the hour or half hour**. Reject anything else in the route; how the form keeps the user inside those bounds is your design.
44
+
45
+ ## 2. Cron entry — merge into the existing `vercel.json`
46
+
47
+ The repo already has a `vercel.json`. **Add the `crons` key; keep every existing key.** Replacing the file drops platform settings.
48
+
49
+ ```json
50
+ {
51
+ "$schema": "https://openapi.vercel.sh/vercel.json",
52
+ "git": { "deploymentEnabled": false },
53
+ "crons": [{ "path": "/api/cron/dispatch", "schedule": "*/30 * * * *" }]
54
+ }
55
+ ```
56
+
57
+ `*/30 * * * *` is the tick. One entry per app is the rule; a second entry is only for a fixed job that genuinely cannot be a row. Never schedule anything more often than every 30 minutes — the build check rejects it, and a faster poller keeps the app's database awake around the clock for nothing.
58
+
59
+ ## 3. Cron secret — declare it, never ask for it
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:
62
+
63
+ ```json
64
+ {
65
+ "surfaces": { "internal": { "path": "/" } },
66
+ "requiredSecrets": [{ "key": "CRON_SECRET", "label": "Cron secret" }]
67
+ }
68
+ ```
69
+
70
+ It is **platform-managed**: it never appears in the user's config form, never gates publish, and the user never sees or types it. Do not invent a value, do not put it in `.env*`, do not fall back to a default when it is unset.
71
+
72
+ ## 4. Dispatcher route — claim, run, advance
73
+
74
+ Create `lib/scheduled-actions.ts` yourself: it exports `runAction(assembly, row)`, which switches on `row.action` and does the actual work for that kind (send the notification, update the record). The dispatcher below stays generic — it never knows what an action does, only how to claim, run, and advance it.
75
+
76
+ ```ts
77
+ // app/api/cron/dispatch/route.ts
78
+ import { and, eq, lt, lte, or } from 'drizzle-orm';
79
+ import { cronRoute, getOfflineAssembly, nextRunAfter } from '@assembly-js/app-foundation/server';
80
+ import { db } from '@/lib/db';
81
+ import { scheduledActions } from '@/lib/schema';
82
+ import { runAction } from '@/lib/scheduled-actions';
83
+
84
+ // A tick Vercel stops mid-run never reaches its catch block, so its rows stay
85
+ // "running"; after this long the next tick may take them back.
86
+ const STALE_CLAIM_MS = 15 * 60_000;
87
+
88
+ export const GET = cronRoute(async () => {
89
+ const now = new Date();
90
+ const claimable = or(
91
+ eq(scheduledActions.status, 'pending'),
92
+ and(
93
+ eq(scheduledActions.status, 'running'),
94
+ lt(scheduledActions.claimedAt, new Date(now.getTime() - STALE_CLAIM_MS)),
95
+ ),
96
+ );
97
+ const due = await db.query.scheduledActions.findMany({
98
+ where: and(claimable, lte(scheduledActions.nextRunAt, now)),
99
+ limit: 50,
100
+ });
101
+
102
+ const assembly = await getOfflineAssembly(); // app-scoped bearer — no viewer here
103
+ let ran = 0;
104
+ for (const row of due) {
105
+ // Claim first: the conditional update wins for exactly one caller, so an
106
+ // overlapping or duplicated tick cannot run the same row twice.
107
+ const [claimed] = await db
108
+ .update(scheduledActions)
109
+ .set({ status: 'running', claimedAt: now })
110
+ .where(and(eq(scheduledActions.id, row.id), claimable))
111
+ .returning();
112
+ if (!claimed) continue;
113
+
114
+ try {
115
+ await runAction(assembly, claimed);
116
+ await db.update(scheduledActions).set(advance(claimed, now)).where(eq(scheduledActions.id, claimed.id));
117
+ ran++;
118
+ } catch (err) {
119
+ // One failing row must not poison the batch; leave it for the next tick.
120
+ await db
121
+ .update(scheduledActions)
122
+ .set({ status: 'pending', claimedAt: null, lastError: err instanceof Error ? err.message : String(err) })
123
+ .where(eq(scheduledActions.id, claimed.id));
124
+ }
125
+ }
126
+ return Response.json({ ok: true, ran });
127
+ });
128
+
129
+ // One-offs finish. Recurring rows get their next occurrence from the scheduled
130
+ // time — never from now — skipping anything missed, and daily or coarser
131
+ // cadences keep their wall-clock time in the row's timezone across DST.
132
+ function advance(row: typeof scheduledActions.$inferSelect, now: Date) {
133
+ const cleared = { lastRunAt: now, lastError: null, claimedAt: null };
134
+ if (row.cadenceMinutes == null) return { ...cleared, status: 'done' as const };
135
+ const next = nextRunAfter({ ...row, cadenceMinutes: row.cadenceMinutes }, now);
136
+ return { ...cleared, status: 'pending' as const, nextRunAt: next };
137
+ }
138
+ ```
139
+
140
+ Rules that fall out of this:
141
+
142
+ - **`cronRoute` is not optional.** The route is a public URL; without the wrapper anyone who guesses it can trigger the app's scheduled work under the app's own credentials. Never hand-roll the header check.
143
+ - **Use `getOfflineAssembly()`** for every platform call — there is no viewer and no cookie. `getSessionIdentity()` is `null` here, so a notification's `senderId` must be chosen explicitly (a system/admin internal user); see `assembly-notifications`.
144
+ - **A missed tick is harmless.** Vercel does not retry, but the row stays due until it is marked, so the next tick picks it up. Never mark a row done before its work succeeded.
145
+ - **A stuck row frees itself.** If Vercel stops the function mid-run, the catch never runs and the row stays `running`; the stale-claim clause lets the next tick reclaim it after 15 minutes. Keep `claimedAt` and that clause — without them one hang parks a row forever.
146
+ - **Never compute the next run by adding minutes to `now`.** Use `nextRunAfter`: it anchors to the scheduled time, skips missed occurrences, and keeps daily cadences on their local wall-clock time across daylight-saving changes.
147
+ - **Keep each tick short.** Bound the batch (`limit`), do the work per row, return. A tick that runs past 30 minutes overlaps the next one; the claim makes that safe, but it is still a sign the work belongs in smaller pieces.
148
+
149
+ ## Cadence and time
150
+
151
+ - **Every target is a tick.** Because recurrences are multiples of 30 minutes and one-off times sit on :00 or :30, each action fires on the tick that matches its target; a tick Vercel misses is picked up by the next one. Nothing needs to explain a delivery window.
152
+ - **Cron runs in UTC.** Store `nextRunAt` as an absolute instant and keep the user's IANA `timezone` on the row so "every day at 9:00" recomputes correctly across DST. Convert the user's local pick to UTC when writing the row; never store a naive local time.
153
+ - **Calendar cadences** that are not a fixed number of days ("monthly on the 1st", "last Friday") are one-off rows: `runAction` inserts the next occurrence when it runs, computing the date in the row's timezone.
154
+ - **Floor is 30 minutes** for recurrence, and every recurrence is a multiple of it. "Every 5 minutes" and "every 45 minutes" are both rejected.
155
+
156
+ ## Verify before publish
157
+
158
+ Crons **never fire in the builder preview**; only a published app has Vercel's scheduler. Exercise the dispatcher directly — `CRON_SECRET` is in the preview shell env:
159
+
160
+ ```bash
161
+ curl -sS -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/dispatch
162
+ ```
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.
165
+
166
+ ## Don'ts
167
+
168
+ - ❌ Don't check for due work on page load, in a client timer, or in middleware. Only the cron runs when nobody is looking.
169
+ - ❌ Don't add one cron entry per action or per user. Actions are rows; there is one dispatcher.
170
+ - ❌ Don't declare a cron faster than `*/30 * * * *`, and don't accept a recurrence that is not a multiple of 30 minutes or a one-off time off the half hour.
171
+ - ❌ Don't skip `cronRoute` or compare the header yourself.
172
+ - ❌ Don't type, invent, or default `CRON_SECRET`. Declare it; the platform mints it.
173
+ - ❌ Don't call `getAssembly()` in the dispatcher. There is no viewer; use `getOfflineAssembly()`.
174
+ - ❌ Don't replace `vercel.json`. Add `crons` to it.