Skip to content

Commit 212e9e5

Browse files
MajorTalclaude
andcommitted
feat(sdk): reject Astro adapter build-tree shipped as site content (gh#411)
A deploy that roots its file source at the build root (`dist/`) instead of `dist/run402/client/` ships the @run402/astro adapter tree (`run402/adapter.json`, `run402/server/**`) as static assets and lands every page under a `run402/client/` path prefix. The static manifest then has no reachable pages, every URL falls through to the SSR catchall and 404s, and the SSR bundle becomes publicly downloadable - an apply that "succeeds" but takes the whole site down (#411). validateSpec now rejects this locally with `ASTRO_ADAPTER_TREE_IN_SITE` before any CAS upload or plan - both for literal FileSets (synchronously) and for `dir()` LocalDirRefs (post-expansion). Not suppressible by RUN402_ALLOW_WARNINGS. Also: CLI `deploy apply --dir` tolerates the slice omitting `routes`, and llms-cli.txt documents the anti-pattern + the new guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 55e57c4 commit 212e9e5

4 files changed

Lines changed: 195 additions & 10 deletions

File tree

cli/lib/deploy-v2.mjs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -603,14 +603,16 @@ async function mergeAstroReleaseSlice(spec, dirArg) {
603603
throw err;
604604
}
605605

606-
// Slice owns site/functions/routes. The caller's manifest can declare
607-
// cross-cutting slices (database, secrets, i18n, subdomains) that the
608-
// slice doesn't touch. On collision in `functions.replace`, the slice
609-
// wins for its own function name; the caller's other functions are
610-
// preserved. `site` and `routes` are whole-resource replacements — slice
611-
// wins entirely on those.
606+
// Slice owns site/functions. The caller's manifest can declare cross-cutting
607+
// slices (database, secrets, i18n, subdomains, routes) that the slice doesn't
608+
// touch. On collision in `functions.replace`, the slice wins for its own
609+
// function name; the caller's other functions are preserved. `site` is a
610+
// whole-resource replacement — slice wins entirely. The slice omits `routes`
611+
// by default (the SSR catchall is implicit; base routes carry forward), so we
612+
// only set `spec.routes` if the slice explicitly provides one; otherwise the
613+
// caller's manifest `routes` (if any) is preserved.
612614
spec.site = slice.site;
613-
spec.routes = slice.routes;
615+
if (slice.routes !== undefined) spec.routes = slice.routes;
614616
const sliceFns = slice.functions?.replace ?? {};
615617
const existingFns =
616618
spec.functions && typeof spec.functions === "object" && spec.functions.replace

cli/llms-cli.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ run402 deploy apply --dir ./dist --project prj_...
312312
run402 deploy apply --dir ./dist --manifest run402.config.json --project prj_...
313313
```
314314

315-
The CLI dynamically imports `@run402/astro/release-slice` (must be installed in the consuming project, **`@run402/astro >=1.2.1` + `@run402/sdk >=2.18.0`** — older SDKs reject `FunctionSpec.class: 'ssr'` locally in `validateKnownFields` before the spec ever reaches the gateway; the helper preflights this and emits `R402_ASTRO_SDK_VERSION_TOO_OLD` with a precise upgrade command). The helper bundles the SSR server output with esbuild into a single `source` (the gateway rejects multi-file function specs), emits no `/*` catchall route (the gateway routes every unmatched path to the project's `class: 'ssr'` function automatically), defaults `site.public_paths: { mode: "implicit" }` to opt out of inherited explicit-mode paths from the base release, and colocates the `_assets-manifest.json` inside the resolved `build.client` dir (so the slice's site replace dir carries it). On a missing/incompatible manifest the error envelope carries `code: "R402_ASTRO_ADAPTER_MANIFEST_MISSING"` or `R402_ASTRO_ADAPTER_MANIFEST_VERSION_UNSUPPORTED` with `hint` + `docs` fields pointing at run402.com/errors. Direct-SDK callers get the same primitive via `import { buildAstroReleaseSlice } from "@run402/astro/release-slice"`.
315+
The CLI dynamically imports `@run402/astro/release-slice` (must be installed in the consuming project, **`@run402/astro >=1.2.1` + `@run402/sdk >=2.18.0`** — older SDKs reject `FunctionSpec.class: 'ssr'` locally in `validateKnownFields` before the spec ever reaches the gateway; the helper preflights this and emits `R402_ASTRO_SDK_VERSION_TOO_OLD` with a precise upgrade command). The helper bundles the SSR server output with esbuild into a single `source` (the gateway rejects multi-file function specs), roots the site at the resolved `build.client` dir (`dist/run402/client/`, NOT `dist/`), **omits `routes` entirely from the returned slice** (the gateway routes every unmatched path to the project's `class: 'ssr'` function automatically, so no route table is needed; omitting `routes` — rather than sending an empty `replace` that would CLEAR the table — carries forward any base-release routes such as a separately-declared `/api/*` function and keeps the slice safe to submit from a CI OIDC session without route scopes), defaults `site.public_paths: { mode: "implicit" }` to opt out of inherited explicit-mode paths from the base release, and colocates the `_assets-manifest.json` inside the resolved `build.client` dir (so the slice's site replace dir carries it). On a missing/incompatible manifest the error envelope carries `code: "R402_ASTRO_ADAPTER_MANIFEST_MISSING"` or `R402_ASTRO_ADAPTER_MANIFEST_VERSION_UNSUPPORTED` with `hint` + `docs` fields pointing at run402.com/errors. Direct-SDK callers get the same primitive via `import { buildAstroReleaseSlice } from "@run402/astro/release-slice"`. Do NOT hand-roll `site` / `public_paths`: if a deploy ships the adapter build tree (`run402/adapter.json`, `run402/server/**`) as site content — the symptom of rooting a file source at `dist/` instead of `dist/run402/client/` — the SDK rejects it locally with `ASTRO_ADAPTER_TREE_IN_SITE` before any upload, and the gateway warns `SITE_NO_REACHABLE_HTML` when a release ships HTML that isn't reachable at any public path (kychee-com/run402#411).
316316

317317
Recovery from a stuck deploy: when an `apply` ends in `activation_pending` (rare; transient gateway failure between SQL commit and the pointer-swap activation), the gateway auto-resumes on the hourly tick. Static spec/config activation failures are classified promptly and thrown as structured deploy errors instead of polling until timeout. For genuinely resumable operations, call resume explicitly:
318318

sdk/src/namespaces/deploy.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import { describe, it, before, beforeEach } from "node:test";
1212
import assert from "node:assert/strict";
1313
import { createHash } from "node:crypto";
14-
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
14+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
1515
import { tmpdir } from "node:os";
1616
import { join } from "node:path";
1717

@@ -5102,3 +5102,105 @@ describe("Deploy release observability", () => {
51025102
});
51035103

51045104
});
5105+
5106+
describe("Deploy.apply (Astro adapter build-tree guard — gh#411)", () => {
5107+
it("rejects a site.replace FileSet that ships the adapter build tree", async () => {
5108+
const w = makeWiring();
5109+
const deploy = new Deploy(w.client);
5110+
await assert.rejects(
5111+
() =>
5112+
deploy.apply({
5113+
project: "prj_test",
5114+
site: {
5115+
replace: {
5116+
"run402/adapter.json": "{}",
5117+
"run402/server/entry.mjs": "export {}",
5118+
"run402/client/index.html": "<html></html>",
5119+
},
5120+
},
5121+
}),
5122+
(err: unknown) => {
5123+
assert(err instanceof Run402DeployError);
5124+
assert.equal(err.code, "ASTRO_ADAPTER_TREE_IN_SITE");
5125+
assert.equal(err.resource, "site.replace");
5126+
assert.match(err.message, /dist\/run402\/client/);
5127+
assert.match(err.message, /buildAstroReleaseSlice/);
5128+
assert.equal(err.fix?.expected_dir, "dist/run402/client");
5129+
return true;
5130+
},
5131+
);
5132+
assert.equal(w.requests.length, 0, "fails before any gateway request");
5133+
assert.equal(w.puts.length, 0, "fails before any S3 upload");
5134+
});
5135+
5136+
it("rejects a site.patch.put FileSet that ships the adapter build tree", async () => {
5137+
const w = makeWiring();
5138+
const deploy = new Deploy(w.client);
5139+
await assert.rejects(
5140+
() =>
5141+
deploy.apply({
5142+
project: "prj_test",
5143+
site: { patch: { put: { "run402/server/chunks/x.mjs": "export {}" } } },
5144+
}),
5145+
(err: unknown) => {
5146+
assert(err instanceof Run402DeployError);
5147+
assert.equal(err.code, "ASTRO_ADAPTER_TREE_IN_SITE");
5148+
assert.equal(err.resource, "site.patch.put");
5149+
return true;
5150+
},
5151+
);
5152+
assert.equal(w.requests.length, 0, "fails before any gateway request");
5153+
});
5154+
5155+
it("rejects a mis-rooted dir() (LocalDirRef) post-expansion", async () => {
5156+
const root = mkdtempSync(join(tmpdir(), "astro-misroot-"));
5157+
try {
5158+
mkdirSync(join(root, "run402", "server"), { recursive: true });
5159+
mkdirSync(join(root, "run402", "client"), { recursive: true });
5160+
writeFileSync(join(root, "run402", "adapter.json"), "{}");
5161+
writeFileSync(join(root, "run402", "server", "entry.mjs"), "export {}");
5162+
writeFileSync(join(root, "run402", "client", "index.html"), "<html></html>");
5163+
5164+
const w = makeWiring();
5165+
const deploy = new Deploy(w.client);
5166+
await assert.rejects(
5167+
() =>
5168+
deploy.apply({
5169+
project: "prj_test",
5170+
site: { replace: dir(root) },
5171+
}),
5172+
(err: unknown) => {
5173+
assert(err instanceof Run402DeployError);
5174+
assert.equal(err.code, "ASTRO_ADAPTER_TREE_IN_SITE");
5175+
assert.equal(err.resource, "site.replace");
5176+
return true;
5177+
},
5178+
);
5179+
assert.equal(w.requests.length, 0, "fails before any gateway request");
5180+
} finally {
5181+
rmSync(root, { recursive: true, force: true });
5182+
}
5183+
});
5184+
5185+
it("does NOT flag a correctly client-rooted site (no run402/ prefix)", async () => {
5186+
const w = makeWiring();
5187+
w.setHandler((req) => {
5188+
if (req.path === "/apply/v1/plans") return noContentPlan("plan_ok", "op_ok");
5189+
if (req.path === "/apply/v1/plans/plan_ok/commit") return readyCommit("op_ok", "rel_ok");
5190+
throw new Error(`unexpected path ${req.path}`);
5191+
});
5192+
const deploy = new Deploy(w.client);
5193+
const result = await deploy.apply({
5194+
project: "prj_test",
5195+
site: {
5196+
replace: {
5197+
"index.html": "<html></html>",
5198+
"events.html": "<html></html>",
5199+
"_astro/app.css": "body{}",
5200+
},
5201+
public_paths: { mode: "implicit" },
5202+
},
5203+
});
5204+
assert.equal(result.release_id, "rel_ok");
5205+
});
5206+
});

sdk/src/namespaces/deploy.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2237,6 +2237,79 @@ function validateFunctionMap(value: unknown, resource: string): void {
22372237
}
22382238
}
22392239

2240+
/**
2241+
* Detect a site path key that belongs to the `@run402/astro` SSR adapter's
2242+
* build tree rather than to deployable static content. The adapter writes
2243+
* `dist/run402/{adapter.json, server/**, client/**}`; only `client/**` is
2244+
* servable. `adapter.json` and `server/**` are build internals — their
2245+
* presence in a site spec means the caller rooted their file source at the
2246+
* build root (`dist/`) instead of `dist/run402/client/`.
2247+
*/
2248+
function isAstroAdapterTreeSitePath(path: string): boolean {
2249+
return path === "run402/adapter.json" || path.startsWith("run402/server/");
2250+
}
2251+
2252+
/**
2253+
* Return the synchronously-knowable keys of a site file container. Plain
2254+
* path-keyed `FileSet`s expose their keys directly; a `LocalDirRef`
2255+
* (`dir(path)`) or any future source sentinel carries an `__source` marker
2256+
* and is only knowable after expansion — those return `[]` here and are
2257+
* re-checked post-normalization.
2258+
*/
2259+
function siteFileSetKeysForGuard(container: unknown): string[] {
2260+
if (
2261+
!container ||
2262+
typeof container !== "object" ||
2263+
Array.isArray(container) ||
2264+
(container as { __source?: unknown }).__source !== undefined
2265+
) {
2266+
return [];
2267+
}
2268+
return Object.keys(container as Record<string, unknown>);
2269+
}
2270+
2271+
/**
2272+
* Reject a site slice that ships the `@run402/astro` adapter build tree as
2273+
* static content. This is the mis-rooting behind kychee-com/run402#411: a
2274+
* deploy pointed `fileSetFromDir`/`dir()` at `dist/` (not `dist/run402/client/`),
2275+
* so every page landed under a `run402/client/` path prefix while
2276+
* `run402/adapter.json` + `run402/server/**` leaked in as assets — producing a
2277+
* release that 404'd every URL and exposed the SSR bundle. Fail fast, locally,
2278+
* with the fix, before any CAS upload or plan.
2279+
*/
2280+
function assertNoAstroAdapterTreeInSite(
2281+
paths: Iterable<string>,
2282+
resource: string,
2283+
): void {
2284+
const offenders: string[] = [];
2285+
for (const p of paths) {
2286+
if (isAstroAdapterTreeSitePath(p)) offenders.push(p);
2287+
if (offenders.length >= 3) break;
2288+
}
2289+
if (offenders.length === 0) return;
2290+
throw new Run402DeployError(
2291+
`${resource} ships the @run402/astro adapter build tree (e.g. ${offenders
2292+
.map((p) => `\`${p}\``)
2293+
.join(", ")}) as static content. Only \`dist/run402/client/\` is deployable; ` +
2294+
`\`run402/adapter.json\` and \`run402/server/**\` are build internals. You likely ` +
2295+
`rooted your file source at the build root (\`dist/\`) instead of \`dist/run402/client/\`. ` +
2296+
`Use \`buildAstroReleaseSlice("dist")\` from @run402/astro (it roots the site and bundles ` +
2297+
`the SSR function correctly), or point your dir at \`dist/run402/client\`.`,
2298+
{
2299+
code: "ASTRO_ADAPTER_TREE_IN_SITE",
2300+
phase: "validate",
2301+
resource,
2302+
retryable: false,
2303+
fix: {
2304+
action: "reroot_site_to_astro_client_dir",
2305+
path: resource,
2306+
expected_dir: "dist/run402/client",
2307+
},
2308+
context: "validating spec",
2309+
},
2310+
);
2311+
}
2312+
22402313
function validateSiteSpec(site: unknown): void {
22412314
if (site === undefined) return;
22422315
const obj = requireObject(site, "site");
@@ -2249,11 +2322,15 @@ function validateSiteSpec(site: unknown): void {
22492322
}
22502323
if (obj.replace !== undefined) {
22512324
requireObject(obj.replace, "site.replace");
2325+
assertNoAstroAdapterTreeInSite(siteFileSetKeysForGuard(obj.replace), "site.replace");
22522326
}
22532327
if (obj.patch !== undefined) {
22542328
const patch = requireObject(obj.patch, "site.patch");
22552329
validateKnownFields(patch, "site.patch", SITE_PATCH_FIELDS);
2256-
if (patch.put !== undefined) requireObject(patch.put, "site.patch.put");
2330+
if (patch.put !== undefined) {
2331+
requireObject(patch.put, "site.patch.put");
2332+
assertNoAstroAdapterTreeInSite(siteFileSetKeysForGuard(patch.put), "site.patch.put");
2333+
}
22572334
if (patch.delete !== undefined) validateStringArray(patch.delete, "site.patch.delete");
22582335
}
22592336
if (obj.public_paths !== undefined) {
@@ -3113,6 +3190,9 @@ async function normalizeReleaseSpec(
31133190
"public_paths" in spec.site ? spec.site.public_paths : undefined;
31143191
if ("replace" in spec.site && spec.site.replace) {
31153192
const map = await normalizeFileSet(spec.site.replace, rememberRelease);
3193+
// Re-check post-expansion so `dir("dist")` (a LocalDirRef whose keys are
3194+
// unknown at validateSpec time) is caught too, not just literal FileSets.
3195+
assertNoAstroAdapterTreeInSite(Object.keys(map), "site.replace");
31163196
normalized.site = {
31173197
replace: map,
31183198
...(publicPaths ? { public_paths: publicPaths } : {}),
@@ -3121,6 +3201,7 @@ async function normalizeReleaseSpec(
31213201
const patch: { put?: Record<string, ContentRef>; delete?: string[] } = {};
31223202
if (spec.site.patch.put) {
31233203
patch.put = await normalizeFileSet(spec.site.patch.put, rememberRelease);
3204+
assertNoAstroAdapterTreeInSite(Object.keys(patch.put), "site.patch.put");
31243205
}
31253206
if (spec.site.patch.delete) patch.delete = spec.site.patch.delete;
31263207
normalized.site = {

0 commit comments

Comments
 (0)