@assembly-js/app-foundation 0.0.23 → 0.0.25-rc.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/CLAUDE.md +3 -0
- package/bin/check-crons.mjs +253 -0
- package/dist/cron.d.ts +40 -0
- package/dist/cron.js +87 -0
- package/dist/lib-client.js +11 -2
- package/dist/nango.d.ts +2 -0
- package/dist/nango.js +116 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/session-exchange.d.ts +6 -0
- package/dist/session-exchange.js +19 -0
- package/package.json +1 -1
- package/skills/assembly-integrations/providers/slack.md +4 -1
- package/skills/assembly-scheduled-work/SKILL.md +174 -0
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
|
+
}
|
package/dist/lib-client.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// never be dragged into the Edge middleware bundle that imports ./client
|
|
6
6
|
// (proxy.ts pulls constants from that barrel).
|
|
7
7
|
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
8
|
-
import { APP_CSRF_HEADER, TOKEN_EXPIRED_HEADER } from './session-exchange.js';
|
|
8
|
+
import { APP_CSRF_HEADER, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
9
9
|
const REEXCHANGE_PATH = '/api/assembly-reexchange';
|
|
10
10
|
// Single-flight gate for renewals. Session tokens are single-use for exchange:
|
|
11
11
|
// when the 60-minute bearer expires, every in-flight fetch on the page 401s at
|
|
@@ -33,23 +33,32 @@ async function renewBearer() {
|
|
|
33
33
|
let token = AssemblyBridge.sessionToken.getCurrent()?.token ?? '';
|
|
34
34
|
// Proactively refresh: we're recovering from an expiry, so avoid re-minting
|
|
35
35
|
// from a session token that may itself be stale.
|
|
36
|
+
let bridgeRefreshFailed = false;
|
|
36
37
|
try {
|
|
37
38
|
const refreshed = await AssemblyBridge.sessionToken.refresh();
|
|
38
39
|
token = refreshed?.token ?? token;
|
|
39
40
|
}
|
|
40
41
|
catch {
|
|
41
42
|
// Ignore: fall back to the current token below.
|
|
43
|
+
bridgeRefreshFailed = true;
|
|
42
44
|
}
|
|
43
|
-
if (!token)
|
|
45
|
+
if (!token) {
|
|
46
|
+
logRenewOutcome('no_token', { bridgeRefreshFailed });
|
|
44
47
|
return false;
|
|
48
|
+
}
|
|
45
49
|
try {
|
|
46
50
|
const res = await fetch(REEXCHANGE_PATH, {
|
|
47
51
|
method: 'POST',
|
|
48
52
|
headers: { 'x-assembly-token': token, [APP_CSRF_HEADER]: '1' },
|
|
49
53
|
});
|
|
54
|
+
logRenewOutcome(res.ok ? 'renewed' : 'rejected', {
|
|
55
|
+
bridgeRefreshFailed,
|
|
56
|
+
status: res.status,
|
|
57
|
+
});
|
|
50
58
|
return res.ok;
|
|
51
59
|
}
|
|
52
60
|
catch {
|
|
61
|
+
logRenewOutcome('unavailable', { bridgeRefreshFailed });
|
|
53
62
|
return false;
|
|
54
63
|
}
|
|
55
64
|
}
|
package/dist/nango.d.ts
CHANGED
|
@@ -217,3 +217,5 @@ export declare function slackPostMessage(slack: IntegrationClient, args: {
|
|
|
217
217
|
channel: string;
|
|
218
218
|
text: string;
|
|
219
219
|
}): Promise<SlackPostMessageResult>;
|
|
220
|
+
/** Slack history returns mention tokens, not names. Resolve them before render. */
|
|
221
|
+
export declare function slackResolveMentions(slack: IntegrationClient, text: string): Promise<string>;
|
package/dist/nango.js
CHANGED
|
@@ -496,3 +496,119 @@ export async function slackPostMessage(slack, args) {
|
|
|
496
496
|
}
|
|
497
497
|
return { ok: result.ok, ts: result.ts, channelId, error: result.error };
|
|
498
498
|
}
|
|
499
|
+
// Memoize successful mention lookups so a history page does not re-hit Slack
|
|
500
|
+
// for the same ID. Misses stay uncached so a later reconnect can succeed.
|
|
501
|
+
const SLACK_MENTION_RE = /<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|<#([CG][A-Z0-9]+)(?:\|([^>]+))?>|<!subteam\^(S[A-Z0-9]+)(?:\|([^>]+))?>/g;
|
|
502
|
+
const slackMentionNameCache = new Map();
|
|
503
|
+
const SLACK_MENTION_FALLBACK = {
|
|
504
|
+
user: 'a user',
|
|
505
|
+
channel: 'a channel',
|
|
506
|
+
subteam: 'a user group',
|
|
507
|
+
};
|
|
508
|
+
function slackMentionRe() {
|
|
509
|
+
return new RegExp(SLACK_MENTION_RE.source, 'g');
|
|
510
|
+
}
|
|
511
|
+
function withMentionSigil(label, sigil) {
|
|
512
|
+
return label.startsWith(sigil) ? label : `${sigil}${label}`;
|
|
513
|
+
}
|
|
514
|
+
async function slackLookupJson(slack, endpoint, params) {
|
|
515
|
+
try {
|
|
516
|
+
const res = await slack.get({ endpoint, params });
|
|
517
|
+
const data = (await res.json());
|
|
518
|
+
if (!data.ok)
|
|
519
|
+
return null;
|
|
520
|
+
return data;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
// Lookup is best-effort: a missing scope or a vanished user must not
|
|
524
|
+
// fail the whole history render.
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function slackUserDisplayName(user) {
|
|
529
|
+
const display = user.profile?.display_name?.trim();
|
|
530
|
+
if (display)
|
|
531
|
+
return display;
|
|
532
|
+
const real = user.profile?.real_name?.trim() || user.real_name?.trim();
|
|
533
|
+
if (real)
|
|
534
|
+
return real;
|
|
535
|
+
return user.name?.trim() || undefined;
|
|
536
|
+
}
|
|
537
|
+
async function slackResolveUserName(slack, id) {
|
|
538
|
+
const cacheKey = `user:${id}`;
|
|
539
|
+
const cached = slackMentionNameCache.get(cacheKey);
|
|
540
|
+
if (cached)
|
|
541
|
+
return cached;
|
|
542
|
+
const data = await slackLookupJson(slack, '/users.info', { user: id });
|
|
543
|
+
const user = data?.user;
|
|
544
|
+
const name = user ? slackUserDisplayName(user) : undefined;
|
|
545
|
+
if (!name)
|
|
546
|
+
return SLACK_MENTION_FALLBACK.user;
|
|
547
|
+
const label = withMentionSigil(name, '@');
|
|
548
|
+
slackMentionNameCache.set(cacheKey, label);
|
|
549
|
+
return label;
|
|
550
|
+
}
|
|
551
|
+
async function slackResolveChannelName(slack, id) {
|
|
552
|
+
const cacheKey = `channel:${id}`;
|
|
553
|
+
const cached = slackMentionNameCache.get(cacheKey);
|
|
554
|
+
if (cached)
|
|
555
|
+
return cached;
|
|
556
|
+
const data = await slackLookupJson(slack, '/conversations.info', { channel: id });
|
|
557
|
+
const channel = data?.channel;
|
|
558
|
+
const name = channel?.name?.trim();
|
|
559
|
+
if (!name)
|
|
560
|
+
return SLACK_MENTION_FALLBACK.channel;
|
|
561
|
+
const label = withMentionSigil(name, '#');
|
|
562
|
+
slackMentionNameCache.set(cacheKey, label);
|
|
563
|
+
return label;
|
|
564
|
+
}
|
|
565
|
+
async function slackResolveSubteamName(slack, id) {
|
|
566
|
+
const cacheKey = `subteam:${id}`;
|
|
567
|
+
const cached = slackMentionNameCache.get(cacheKey);
|
|
568
|
+
if (cached)
|
|
569
|
+
return cached;
|
|
570
|
+
const data = await slackLookupJson(slack, '/usergroups.info', { usergroup: id });
|
|
571
|
+
const group = data?.usergroup;
|
|
572
|
+
const name = group?.handle?.trim() || group?.name?.trim();
|
|
573
|
+
if (!name)
|
|
574
|
+
return SLACK_MENTION_FALLBACK.subteam;
|
|
575
|
+
const label = withMentionSigil(name, '@');
|
|
576
|
+
slackMentionNameCache.set(cacheKey, label);
|
|
577
|
+
return label;
|
|
578
|
+
}
|
|
579
|
+
/** Slack history returns mention tokens, not names. Resolve them before render. */
|
|
580
|
+
export async function slackResolveMentions(slack, text) {
|
|
581
|
+
if (!text)
|
|
582
|
+
return text;
|
|
583
|
+
const replacements = new Map();
|
|
584
|
+
const lookups = new Map();
|
|
585
|
+
for (const match of text.matchAll(slackMentionRe())) {
|
|
586
|
+
const [raw, userId, userLabel, channelId, channelLabel, subteamId, subteamLabel] = match;
|
|
587
|
+
if (replacements.has(raw) || lookups.has(raw))
|
|
588
|
+
continue;
|
|
589
|
+
if (userLabel) {
|
|
590
|
+
replacements.set(raw, withMentionSigil(userLabel, '@'));
|
|
591
|
+
}
|
|
592
|
+
else if (channelLabel) {
|
|
593
|
+
replacements.set(raw, withMentionSigil(channelLabel, '#'));
|
|
594
|
+
}
|
|
595
|
+
else if (subteamLabel) {
|
|
596
|
+
replacements.set(raw, withMentionSigil(subteamLabel, '@'));
|
|
597
|
+
}
|
|
598
|
+
else if (userId) {
|
|
599
|
+
lookups.set(raw, slackResolveUserName(slack, userId));
|
|
600
|
+
}
|
|
601
|
+
else if (channelId) {
|
|
602
|
+
lookups.set(raw, slackResolveChannelName(slack, channelId));
|
|
603
|
+
}
|
|
604
|
+
else if (subteamId) {
|
|
605
|
+
lookups.set(raw, slackResolveSubteamName(slack, subteamId));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
await Promise.all([...lookups.entries()].map(async ([raw, pending]) => {
|
|
609
|
+
replacements.set(raw, await pending);
|
|
610
|
+
}));
|
|
611
|
+
if (replacements.size === 0)
|
|
612
|
+
return text;
|
|
613
|
+
return text.replace(slackMentionRe(), (raw) => replacements.get(raw) ?? raw);
|
|
614
|
+
}
|
package/dist/server.d.ts
CHANGED
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';
|
|
@@ -15,6 +15,12 @@ export type CookieOutcome = 'served_from_cookie' | 'minted' | `fail_closed:${str
|
|
|
15
15
|
export declare function logCookieOutcome(outcome: CookieOutcome, detail?: {
|
|
16
16
|
rejectedToken?: string;
|
|
17
17
|
}): void;
|
|
18
|
+
export declare const RENEW_OUTCOME_EVENT = "assembly.renew_outcome";
|
|
19
|
+
export type RenewOutcome = 'renewed' | 'no_token' | 'rejected' | 'unavailable';
|
|
20
|
+
export declare function logRenewOutcome(outcome: RenewOutcome, detail?: {
|
|
21
|
+
bridgeRefreshFailed?: boolean;
|
|
22
|
+
status?: number;
|
|
23
|
+
}): void;
|
|
18
24
|
export declare function isDocumentRequest(request: {
|
|
19
25
|
method: string;
|
|
20
26
|
headers: {
|
package/dist/session-exchange.js
CHANGED
|
@@ -88,6 +88,25 @@ detail) {
|
|
|
88
88
|
...(detail?.rejectedToken ? { rejected_token: detail.rejectedToken } : {}),
|
|
89
89
|
}));
|
|
90
90
|
}
|
|
91
|
+
// One structured line per bearer renewal attempt. The renewal path is the one
|
|
92
|
+
// an idle session takes on its way back: the 60-minute bearer has expired and
|
|
93
|
+
// everything now depends on the parent still holding an exchangeable session
|
|
94
|
+
// token. Today every way that can fail collapses into the same `false`, so a
|
|
95
|
+
// dead parent bridge, a refused token and a network blip are indistinguishable
|
|
96
|
+
// after the fact. Outcomes are a closed set, mirroring COOKIE_OUTCOME_EVENT.
|
|
97
|
+
export const RENEW_OUTCOME_EVENT = 'assembly.renew_outcome';
|
|
98
|
+
export function logRenewOutcome(outcome,
|
|
99
|
+
// `bridgeRefreshFailed` records that the pre-renewal token refresh threw and
|
|
100
|
+
// we fell back to the token already in hand — on its own that is survivable,
|
|
101
|
+
// but paired with a rejection it names the parent as the failing side.
|
|
102
|
+
detail) {
|
|
103
|
+
console.log(JSON.stringify({
|
|
104
|
+
event_type: RENEW_OUTCOME_EVENT,
|
|
105
|
+
outcome,
|
|
106
|
+
...(detail?.bridgeRefreshFailed ? { bridge_refresh_failed: true } : {}),
|
|
107
|
+
...(typeof detail?.status === 'number' ? { status: detail.status } : {}),
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
91
110
|
// Two-tier detection: Sec-Fetch-Dest when the browser sends it (all evergreen
|
|
92
111
|
// browsers; 'iframe' is what an embedded app's navigations carry), Accept
|
|
93
112
|
// text/html otherwise. Deliberately NOT matching `*/*`: browser fetch() GETs
|
package/package.json
CHANGED
|
@@ -8,8 +8,9 @@ Everything except `send-message` needs membership, not just visibility. Posting
|
|
|
8
8
|
- **List channels with `slackListChannels` from `@assembly-js/app-foundation/server` — never page the `list-channels` action yourself.** That action returns at most 200 per page, so a large workspace is dozens of sequential calls and a 20–30 second dropdown; the helper walks at Slack's maximum page size and handles rate limits and the private-channel scope fallback. It returns `{ id, name, isPrivate }` sorted by name.
|
|
9
9
|
- **Resolve channel names with `slackFindChannelId` from `@assembly-js/app-foundation/server` for every other per-channel call.** It handles cursor pagination (a single `conversations.list` misses channels in large workspaces), name normalization, scope fallback, rate limits, and caching. Use the returned ID; most Slack methods reject a `#name` with `invalid_arguments`.
|
|
10
10
|
- **Read the Slack error code with `slackErrorCode(err)` from `@assembly-js/app-foundation/server` — never read `err.detail` yourself.** The vendored scripts put the code in different places inside `detail`; the helper returns it (`'not_in_channel'`, `'channel_not_found'`, `'is_archived'`, …) or `undefined` when the error isn't a Slack rejection.
|
|
11
|
+
- **Resolve mention IDs with `slackResolveMentions` from `@assembly-js/app-foundation/server` before rendering Slack message text.** History returns raw `<@U…>`, `<#C…|name>`, and `<!subteam^ID>` tokens. The helper replaces them with display names and falls back to "a user" / "a channel" / "a user group" when a lookup fails or `usergroups:read` is missing. Leave `<!here>` / `<!channel>` / `<!everyone>` as-is.
|
|
11
12
|
- **Every other per-channel call (history, reactions, invites):** when `slackErrorCode(err) === 'not_in_channel'`, call `join-channel` with the channel ID, then retry once. Private channels fail the join with `method_not_supported_for_channel_type` — tell the user to invite the bot instead of retrying.
|
|
12
|
-
- **Scopes:** `chat:write.public` lets posting reach any public channel without joining, so only the other calls need `join-channel` (which needs `channels:join`). Add `groups:read` if the app must find private channels.
|
|
13
|
+
- **Scopes:** `chat:write.public` lets posting reach any public channel without joining, so only the other calls need `join-channel` (which needs `channels:join`). Add `groups:read` if the app must find private channels. `usergroups:read` is what lets `slackResolveMentions` name a `<!subteam^…>` token; without it the helper falls back to "a user group".
|
|
13
14
|
|
|
14
15
|
```typescript
|
|
15
16
|
import {
|
|
@@ -18,12 +19,14 @@ import {
|
|
|
18
19
|
slackFindChannelId,
|
|
19
20
|
slackListChannels,
|
|
20
21
|
slackPostMessage,
|
|
22
|
+
slackResolveMentions,
|
|
21
23
|
} from '@assembly-js/app-foundation/server';
|
|
22
24
|
|
|
23
25
|
const slack = await getIntegration('slack');
|
|
24
26
|
const channels = await slackListChannels(slack); // dropdown options
|
|
25
27
|
const channelId = await slackFindChannelId(slack, 'general');
|
|
26
28
|
const posted = await slackPostMessage(slack, { channel: '#general', text: 'Hello' });
|
|
29
|
+
const readable = await slackResolveMentions(slack, rawMessageText);
|
|
27
30
|
```
|
|
28
31
|
|
|
29
32
|
```typescript
|
|
@@ -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.
|