@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.
@@ -1,45 +1,90 @@
1
1
  #!/usr/bin/env node
2
2
  // Fails the build when scheduled work is half-wired. A cron route that no
3
3
  // `vercel.json` entry invokes never runs; an entry pointing at a missing route
4
- // 404s forever; a cron route without the platform-managed secret is a public
5
- // endpoint that mutates workspace data; and a cron faster than the platform's
6
- // floor keeps the app's database awake around the clock. Every one of those
7
- // passes typecheck and `next build` and only shows up after publish, when
8
- // nobody is looking — so the app's own build is the gate. Dependency free on
9
- // purpose: it runs inside the generated app's sandbox before any deps beyond
4
+ // 404s forever; a cron route that skips the platform-managed secret check is a
5
+ // public endpoint that mutates workspace data; and a cron faster than the
6
+ // platform's floor keeps the app's database awake around the clock. Every one
7
+ // of those passes typecheck and `next build` and only shows up after publish,
8
+ // when nobody is looking — so the app's own build is the gate. Dependency free
9
+ // on purpose: it runs inside the generated app's sandbox before any deps beyond
10
10
  // Node are guaranteed.
11
+ //
12
+ // The build fails only for cron wiring the current turn is changing (see
13
+ // cronWiringChangedThisTurn). Everything else — a template clone, a Vercel
14
+ // build, an update turn that never touched the cron — gets the same findings
15
+ // as warnings: those paths have no agent who can act on a failure, so failing
16
+ // them would only strand an app that already worked.
11
17
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
18
+ import { spawnSync } from 'node:child_process';
12
19
  import { join, relative, sep } from 'node:path';
13
20
 
14
21
  const ROOT = process.cwd();
15
22
  const APP_DIR = join(ROOT, 'app');
16
23
  const VERCEL_JSON = join(ROOT, 'vercel.json');
17
- const MANIFEST = join(ROOT, '.assembly', 'app.json');
18
24
 
19
25
  // The conventional home for cron routes; a route here with no schedule is the
20
26
  // "looked right in preview, never ran in production" bug this guard exists for.
21
27
  const CRON_ROUTE_PREFIX = '/api/cron/';
22
28
  // Vercel sends this env var as the bearer on every cron invocation; the platform
23
- // mints it at deploy when the manifest declares it.
29
+ // declares and mints it at deploy once vercel.json carries a cron entry.
24
30
  const CRON_SECRET_KEY = 'CRON_SECRET';
25
31
  // Platform floor: a poller faster than this never lets the app's database scale
26
32
  // to zero, which costs more than the work it does.
27
33
  const MIN_INTERVAL_MINUTES = 30;
28
34
  const ROUTE_FILE = /^route\.(tsx|ts|jsx|js)$/;
29
35
 
30
- function fail(lines) {
36
+ // The turn program exports the SHA the sandbox started this turn at; the files
37
+ // below are the ones whose change since then makes the findings the agent's own.
38
+ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
39
+ const CRON_WIRING_PATHS = ['vercel.json', 'app/api/cron/'];
40
+
41
+ function report(lines, { fatal }) {
31
42
  console.error('');
32
- console.error('Scheduled work is not wired correctly:');
43
+ console.error(
44
+ fatal
45
+ ? 'Scheduled work is not wired correctly:'
46
+ : 'Scheduled work is not wired correctly (pre-existing; this build is not failed for it):',
47
+ );
33
48
  console.error('');
34
49
  for (const l of lines) console.error(' - ' + l);
35
50
  console.error('');
36
- console.error('Every scheduled app needs all of these together:');
51
+ console.error('Every scheduled app needs both of these together:');
37
52
  console.error(' 1. One dispatcher route under app/api/cron/ that verifies the cron secret.');
38
53
  console.error(' 2. A matching entry in vercel.json, no more often than every 30 minutes:');
39
54
  console.error(' { "crons": [{ "path": "/api/cron/dispatch", "schedule": "*/30 * * * *" }] }');
40
- console.error(` 3. "${CRON_SECRET_KEY}" declared in .assembly/app.json requiredSecrets (the platform mints it).`);
55
+ console.error(`The platform declares and mints "${CRON_SECRET_KEY}" itself once vercel.json has a cron entry.`);
41
56
  console.error('See the assembly-scheduled-work skill.');
42
- process.exit(1);
57
+ if (fatal) process.exit(1);
58
+ }
59
+
60
+ function fail(lines) {
61
+ report(lines, { fatal: true });
62
+ }
63
+
64
+ function isCronWiringPath(file) {
65
+ return CRON_WIRING_PATHS.some((p) => (p.endsWith('/') ? file.startsWith(p) : file === p));
66
+ }
67
+
68
+ // Whether this turn changed vercel.json or a cron route: commits since the
69
+ // baseline plus whatever is still uncommitted. No baseline means no turn (a
70
+ // Vercel build, a clone bootstrap), so nothing is the agent's to fix. A git
71
+ // failure with a baseline fails closed — the sandbox is the one place a
72
+ // finding can still be acted on.
73
+ function cronWiringChangedThisTurn() {
74
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
75
+ if (!baseline) return false;
76
+ const listings = [
77
+ { args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
78
+ // Porcelain rows are "XY path", or "XY old -> new" for a rename.
79
+ { args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
80
+ ];
81
+ for (const { args, parse } of listings) {
82
+ const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
83
+ if (result.status !== 0) return true;
84
+ const changed = result.stdout.split('\n').map((line) => parse(line).trim()).filter(Boolean);
85
+ if (changed.some(isCronWiringPath)) return true;
86
+ }
87
+ return false;
43
88
  }
44
89
 
45
90
  function readJson(file, label) {
@@ -151,13 +196,6 @@ function minIntervalMinutes(schedule) {
151
196
  return Math.min(...gaps);
152
197
  }
153
198
 
154
- function declaredSecrets() {
155
- if (!existsSync(MANIFEST)) return new Set();
156
- const manifest = readJson(MANIFEST, '.assembly/app.json');
157
- const secrets = Array.isArray(manifest?.requiredSecrets) ? manifest.requiredSecrets : [];
158
- return new Set(secrets.map((s) => (typeof s?.key === 'string' ? s.key.trim() : '')));
159
- }
160
-
161
199
  // Only the two forms the skill prescribes count as verifying the secret, and
162
200
  // comments are stripped first so a copied auth comment above an unguarded
163
201
  // handler cannot pass. Form 1: the exported handler IS the cronRoute wrapper.
@@ -231,13 +269,6 @@ for (const route of cronRoutes) {
231
269
  }
232
270
 
233
271
  if (entries.length > 0) {
234
- if (!declaredSecrets().has(CRON_SECRET_KEY)) {
235
- problems.push(
236
- `.assembly/app.json requiredSecrets does not declare "${CRON_SECRET_KEY}" — without it Vercel sends no ` +
237
- `bearer and the cron route cannot tell a real tick from a stranger. Add ` +
238
- `{ "key": "${CRON_SECRET_KEY}", "label": "Cron secret" }; the platform mints the value at deploy.`,
239
- );
240
- }
241
272
  for (const file of coveredRoutes) {
242
273
  if (!verifiesCronSecret(readFileSync(file, 'utf8'))) {
243
274
  problems.push(
@@ -250,4 +281,4 @@ if (entries.length > 0) {
250
281
  }
251
282
  }
252
283
 
253
- if (problems.length > 0) fail(problems);
284
+ if (problems.length > 0) report(problems, { fatal: cronWiringChangedThisTurn() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.25-rc.0",
3
+ "version": "0.0.25-rc.2",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -58,7 +58,7 @@ The repo already has a `vercel.json`. **Add the `crons` key; keep every existing
58
58
 
59
59
  ## 3. Cron secret — declare it, never ask for it
60
60
 
61
- Vercel authenticates its own invocations by sending the project's `CRON_SECRET` env var as `Authorization: Bearer <value>`. Declare it as a required secret and the platform generates the value at deploy and sets it on the Vercel project:
61
+ Vercel authenticates its own invocations by sending the project's `CRON_SECRET` env var as `Authorization: Bearer <value>`. Declare it as a required secret and the platform generates the value at deploy and sets it on the Vercel project. The platform also declares it itself for any app whose `vercel.json` has a cron entry, so a missing declaration never blocks a publish — declaring it keeps the manifest honest about what the app reads:
62
62
 
63
63
  ```json
64
64
  {
@@ -161,7 +161,7 @@ Crons **never fire in the builder preview**; only a published app has Vercel's s
161
161
  curl -sS -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/dispatch
162
162
  ```
163
163
 
164
- Insert a row with `nextRunAt` in the past, run that, and confirm the side effect and the advanced `nextRunAt`. Then tell the user, in plain words, that the schedule starts running once the app is published. `bun run build` runs `check-crons.mjs`, which fails on a cron route without an entry, an entry without a route, a missing `CRON_SECRET` declaration, or a schedule faster than every 30 minutes — fix the wiring it names rather than deleting the check.
164
+ Insert a row with `nextRunAt` in the past, run that, and confirm the side effect and the advanced `nextRunAt`. Then tell the user, in plain words, that the schedule starts running once the app is published. `bun run build` runs `check-crons.mjs`, which fails on a cron route without an entry, an entry without a route, a handler that skips the secret check, or a schedule faster than every 30 minutes — fix the wiring it names rather than deleting the check. Cron wiring that predates your turn is reported as a warning instead; fix it when the task touches it.
165
165
 
166
166
  ## Don'ts
167
167