@assembly-js/app-foundation 0.0.25-rc.1 → 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 +59 -28
- package/dist/lib-client.js +2 -18
- package/dist/mint-bearer.js +1 -7
- package/package.json +1 -1
- package/skills/assembly-scheduled-work/SKILL.md +2 -2
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/dist/lib-client.js
CHANGED
|
@@ -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, to re-mint
|
|
22
|
-
//
|
|
21
|
+
// app code touches the raw session token, and only to re-mint; it is never sent
|
|
22
|
+
// to the platform API.
|
|
23
23
|
export function reexchangeBearer() {
|
|
24
24
|
if (renewalInFlight)
|
|
25
25
|
return renewalInFlight;
|
|
@@ -30,9 +30,6 @@ 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');
|
|
36
33
|
let token = AssemblyBridge.sessionToken.getCurrent()?.token ?? '';
|
|
37
34
|
// Proactively refresh: we're recovering from an expiry, so avoid re-minting
|
|
38
35
|
// from a session token that may itself be stale.
|
|
@@ -58,19 +55,6 @@ async function renewBearer() {
|
|
|
58
55
|
bridgeRefreshFailed,
|
|
59
56
|
status: res.status,
|
|
60
57
|
});
|
|
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
|
-
}
|
|
74
58
|
return res.ok;
|
|
75
59
|
}
|
|
76
60
|
catch {
|
package/dist/mint-bearer.js
CHANGED
|
@@ -55,13 +55,7 @@ export async function mintBearerFromSessionToken(token, options) {
|
|
|
55
55
|
try {
|
|
56
56
|
const resp = await assembly.fetch('/v1/session', {
|
|
57
57
|
method: 'POST',
|
|
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
|
-
},
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
65
59
|
body: JSON.stringify({
|
|
66
60
|
sessionId: payload.tokenId,
|
|
67
61
|
...(options?.cookieSessionId
|
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
|
|