@assembly-js/app-foundation 0.0.25-rc.0 → 0.0.25-rc.2
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/bin/check-crons.mjs
CHANGED
|
@@ -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
|
|
5
|
-
// endpoint that mutates workspace data; and a cron faster than the
|
|
6
|
-
// floor keeps the app's database awake around the clock. Every one
|
|
7
|
-
// passes typecheck and `next build` and only shows up after publish,
|
|
8
|
-
// nobody is looking — so the app's own build is the gate. Dependency free
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(`
|
|
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)
|
|
284
|
+
if (problems.length > 0) report(problems, { fatal: cronWiringChangedThisTurn() });
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|