@arcote.tech/arc-cli 0.6.1 → 0.7.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/dist/index.js +1214 -1217
- package/package.json +7 -7
- package/src/builder/access-extractor.ts +79 -47
- package/src/builder/build-cache.ts +3 -1
- package/src/builder/chunk-planner.ts +107 -0
- package/src/builder/dependency-collector.ts +86 -32
- package/src/builder/framework-peers.ts +81 -0
- package/src/builder/module-builder.ts +186 -110
- package/src/commands/platform-deploy.ts +103 -55
- package/src/commands/platform-dev.ts +11 -100
- package/src/commands/platform-start.ts +4 -90
- package/src/deploy/bootstrap.ts +157 -6
- package/src/deploy/caddyfile.ts +19 -23
- package/src/deploy/compose.ts +43 -27
- package/src/deploy/config.ts +29 -0
- package/src/deploy/deploy-env.ts +129 -0
- package/src/deploy/htpasswd.ts +28 -0
- package/src/deploy/image-template.ts +74 -0
- package/src/deploy/image.ts +237 -0
- package/src/deploy/registry.ts +79 -0
- package/src/deploy/ssh.ts +5 -124
- package/src/deploy/survey.ts +64 -0
- package/src/index.ts +15 -13
- package/src/platform/server.ts +69 -44
- package/src/platform/shared.ts +124 -65
- package/src/platform/startup.ts +160 -0
- package/runtime/Dockerfile +0 -29
- package/runtime/build-and-push.sh +0 -23
- package/runtime/entrypoint.sh +0 -58
- package/src/commands/build-shell.ts +0 -152
- package/src/deploy/remote-sync.ts +0 -323
- package/src/platform/deploy-api.ts +0 -396
package/src/deploy/ssh.ts
CHANGED
|
@@ -20,23 +20,17 @@ export interface SshExecResult {
|
|
|
20
20
|
exitCode: number;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function baseSshArgs(target: DeployTarget): string[] {
|
|
24
|
-
|
|
25
|
-
// offers every loaded identity and trips MaxAuthTries on hardened sshd
|
|
26
|
-
// (ansible's sshd hardening + fail2ban lowers the threshold).
|
|
27
|
-
const key = target.sshKey ?? `${process.env.HOME}/.ssh/id_ed25519`;
|
|
28
|
-
return [
|
|
23
|
+
export function baseSshArgs(target: DeployTarget): string[] {
|
|
24
|
+
const args = [
|
|
29
25
|
"-o",
|
|
30
26
|
"BatchMode=yes",
|
|
31
27
|
"-o",
|
|
32
28
|
"StrictHostKeyChecking=accept-new",
|
|
33
|
-
"-o",
|
|
34
|
-
"IdentitiesOnly=yes",
|
|
35
|
-
"-i",
|
|
36
|
-
key,
|
|
37
29
|
"-p",
|
|
38
30
|
String(target.port),
|
|
39
31
|
];
|
|
32
|
+
if (target.sshKey) args.push("-i", target.sshKey);
|
|
33
|
+
return args;
|
|
40
34
|
}
|
|
41
35
|
|
|
42
36
|
/**
|
|
@@ -121,19 +115,15 @@ export async function scpUpload(
|
|
|
121
115
|
localPath: string,
|
|
122
116
|
remotePath: string,
|
|
123
117
|
): Promise<void> {
|
|
124
|
-
const key = target.sshKey ?? `${process.env.HOME}/.ssh/id_ed25519`;
|
|
125
118
|
const args = [
|
|
126
119
|
"-o",
|
|
127
120
|
"BatchMode=yes",
|
|
128
121
|
"-o",
|
|
129
122
|
"StrictHostKeyChecking=accept-new",
|
|
130
|
-
"-o",
|
|
131
|
-
"IdentitiesOnly=yes",
|
|
132
|
-
"-i",
|
|
133
|
-
key,
|
|
134
123
|
"-P",
|
|
135
124
|
String(target.port),
|
|
136
125
|
];
|
|
126
|
+
if (target.sshKey) args.push("-i", target.sshKey);
|
|
137
127
|
args.push(localPath, `${target.user}@${target.host}:${remotePath}`);
|
|
138
128
|
|
|
139
129
|
const proc = spawn({ cmd: ["scp", ...args], stderr: "pipe" });
|
|
@@ -145,112 +135,3 @@ export async function scpUpload(
|
|
|
145
135
|
throw new Error(`scp failed (${exitCode}): ${stderr}`);
|
|
146
136
|
}
|
|
147
137
|
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Rsync a directory to the remote host (preserving permissions, deleting stale files).
|
|
151
|
-
*
|
|
152
|
-
* `-L` dereferences symlinks on the source side. This is essential because Arc
|
|
153
|
-
* projects typically use `bun link` for framework packages — `node_modules/@arcote.tech/*`
|
|
154
|
-
* and workspace `@ndt/*` packages are symlinks pointing at paths that don't
|
|
155
|
-
* exist on the remote host. Without `-L`, rsync copies them as dangling
|
|
156
|
-
* symlinks and the container can't resolve `node_modules/.bin/arc`.
|
|
157
|
-
*/
|
|
158
|
-
export async function rsyncDir(
|
|
159
|
-
target: DeployTarget,
|
|
160
|
-
localDir: string,
|
|
161
|
-
remoteDir: string,
|
|
162
|
-
opts: { delete?: boolean } = {},
|
|
163
|
-
): Promise<void> {
|
|
164
|
-
const sshCmdParts = ["ssh", "-p", String(target.port)];
|
|
165
|
-
if (target.sshKey) sshCmdParts.push("-i", target.sshKey);
|
|
166
|
-
const sshCmd = sshCmdParts.join(" ");
|
|
167
|
-
|
|
168
|
-
const args: string[] = ["-azL", "-e", sshCmd];
|
|
169
|
-
if (opts.delete) args.push("--delete");
|
|
170
|
-
// Trailing slash: sync contents, not the dir itself
|
|
171
|
-
const src = localDir.endsWith("/") ? localDir : `${localDir}/`;
|
|
172
|
-
args.push(src, `${target.user}@${target.host}:${remoteDir}`);
|
|
173
|
-
|
|
174
|
-
const proc = spawn({
|
|
175
|
-
cmd: ["rsync", ...args],
|
|
176
|
-
stderr: "pipe",
|
|
177
|
-
stdout: "pipe",
|
|
178
|
-
});
|
|
179
|
-
const [stderr, exitCode] = await Promise.all([
|
|
180
|
-
streamToString(proc.stderr),
|
|
181
|
-
proc.exited,
|
|
182
|
-
]);
|
|
183
|
-
if (exitCode !== 0) {
|
|
184
|
-
throw new Error(`rsync failed (${exitCode}): ${stderr}`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Open an SSH -L tunnel. Returns a Disposable-like object with `.close()`.
|
|
190
|
-
* Caller MUST call close() (or use `using` in Bun) to release the tunnel.
|
|
191
|
-
*/
|
|
192
|
-
export interface SshTunnel {
|
|
193
|
-
localPort: number;
|
|
194
|
-
close(): void;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export async function openTunnel(
|
|
198
|
-
target: DeployTarget,
|
|
199
|
-
localPort: number,
|
|
200
|
-
remoteHost: string,
|
|
201
|
-
remotePort: number,
|
|
202
|
-
): Promise<SshTunnel> {
|
|
203
|
-
const args = [
|
|
204
|
-
...baseSshArgs(target),
|
|
205
|
-
"-N",
|
|
206
|
-
"-L",
|
|
207
|
-
`${localPort}:${remoteHost}:${remotePort}`,
|
|
208
|
-
`${target.user}@${target.host}`,
|
|
209
|
-
];
|
|
210
|
-
const proc = spawn({
|
|
211
|
-
cmd: ["ssh", ...args],
|
|
212
|
-
stdin: "ignore",
|
|
213
|
-
stdout: "pipe",
|
|
214
|
-
stderr: "pipe",
|
|
215
|
-
});
|
|
216
|
-
// Wait briefly for the tunnel to establish. ssh prints nothing on success
|
|
217
|
-
// with -N, so we poll a TCP connect on localPort.
|
|
218
|
-
const deadline = Date.now() + 10_000;
|
|
219
|
-
let lastErr: unknown;
|
|
220
|
-
while (Date.now() < deadline) {
|
|
221
|
-
if (proc.exitCode !== null) {
|
|
222
|
-
const stderr = await streamToString(proc.stderr);
|
|
223
|
-
throw new Error(`ssh tunnel exited early: ${stderr}`);
|
|
224
|
-
}
|
|
225
|
-
try {
|
|
226
|
-
const probe = await Bun.connect({
|
|
227
|
-
hostname: "127.0.0.1",
|
|
228
|
-
port: localPort,
|
|
229
|
-
socket: { data() {}, open() {}, close() {}, error() {} },
|
|
230
|
-
});
|
|
231
|
-
probe.end();
|
|
232
|
-
return {
|
|
233
|
-
localPort,
|
|
234
|
-
close() {
|
|
235
|
-
try {
|
|
236
|
-
proc.kill();
|
|
237
|
-
} catch {
|
|
238
|
-
// ignore
|
|
239
|
-
}
|
|
240
|
-
},
|
|
241
|
-
};
|
|
242
|
-
} catch (e) {
|
|
243
|
-
lastErr = e;
|
|
244
|
-
await Bun.sleep(200);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
try {
|
|
248
|
-
proc.kill();
|
|
249
|
-
} catch {
|
|
250
|
-
// ignore
|
|
251
|
-
}
|
|
252
|
-
throw new Error(
|
|
253
|
-
`Failed to establish SSH tunnel on localhost:${localPort}: ${String(lastErr)}`,
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
|
package/src/deploy/survey.ts
CHANGED
|
@@ -151,6 +151,55 @@ export async function runSurvey(): Promise<DeployConfig> {
|
|
|
151
151
|
})) as string;
|
|
152
152
|
if (clack.isCancel(email)) cancel();
|
|
153
153
|
|
|
154
|
+
// Phase 6: Private Docker Registry
|
|
155
|
+
clack.note(
|
|
156
|
+
"The host runs a private Docker registry behind Caddy.\nCreate an A record for the registry domain pointing to your host before deploy.",
|
|
157
|
+
"Registry",
|
|
158
|
+
);
|
|
159
|
+
const registryDomain = (await clack.text({
|
|
160
|
+
message: "Registry domain (full FQDN)",
|
|
161
|
+
placeholder: "registry.example.com",
|
|
162
|
+
validate: (v) =>
|
|
163
|
+
/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(v) ? undefined : "Expected a domain",
|
|
164
|
+
})) as string;
|
|
165
|
+
if (clack.isCancel(registryDomain)) cancel();
|
|
166
|
+
|
|
167
|
+
const registryUser = (await clack.text({
|
|
168
|
+
message: "Registry username",
|
|
169
|
+
initialValue: "deploy",
|
|
170
|
+
validate: (v) =>
|
|
171
|
+
/^[a-z_][a-z0-9_-]*$/.test(v) ? undefined : "Invalid username",
|
|
172
|
+
})) as string;
|
|
173
|
+
if (clack.isCancel(registryUser)) cancel();
|
|
174
|
+
|
|
175
|
+
const registryPasswordEnv = (await clack.text({
|
|
176
|
+
message: "Env var holding the registry password",
|
|
177
|
+
initialValue: "ARC_REGISTRY_PASSWORD",
|
|
178
|
+
validate: (v) =>
|
|
179
|
+
/^[A-Z][A-Z0-9_]*$/.test(v)
|
|
180
|
+
? undefined
|
|
181
|
+
: "Must be UPPER_SNAKE_CASE",
|
|
182
|
+
})) as string;
|
|
183
|
+
if (clack.isCancel(registryPasswordEnv)) cancel();
|
|
184
|
+
|
|
185
|
+
const generatePassword = (await clack.confirm({
|
|
186
|
+
message: "Generate a random password now?",
|
|
187
|
+
initialValue: true,
|
|
188
|
+
})) as boolean;
|
|
189
|
+
if (clack.isCancel(generatePassword)) cancel();
|
|
190
|
+
if (generatePassword) {
|
|
191
|
+
const random = generateRandomPassword(32);
|
|
192
|
+
clack.note(
|
|
193
|
+
`Save this password — you'll need it on every deploy.\n\n export ${registryPasswordEnv}=${random}\n\nThis prompt is the only time it's shown.`,
|
|
194
|
+
"Registry password",
|
|
195
|
+
);
|
|
196
|
+
} else {
|
|
197
|
+
clack.note(
|
|
198
|
+
`Set ${registryPasswordEnv} in your shell before running deploy.`,
|
|
199
|
+
"Registry password",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
154
203
|
clack.outro("Configuration ready — writing deploy.arc.json");
|
|
155
204
|
|
|
156
205
|
return {
|
|
@@ -162,10 +211,25 @@ export async function runSurvey(): Promise<DeployConfig> {
|
|
|
162
211
|
},
|
|
163
212
|
envs,
|
|
164
213
|
caddy: { email },
|
|
214
|
+
registry: {
|
|
215
|
+
domain: registryDomain,
|
|
216
|
+
username: registryUser,
|
|
217
|
+
passwordEnv: registryPasswordEnv,
|
|
218
|
+
},
|
|
165
219
|
provision,
|
|
166
220
|
};
|
|
167
221
|
}
|
|
168
222
|
|
|
223
|
+
function generateRandomPassword(bytes: number): string {
|
|
224
|
+
// 32 random bytes → 43-char base64url. Plenty of entropy for a registry.
|
|
225
|
+
const buf = new Uint8Array(bytes);
|
|
226
|
+
crypto.getRandomValues(buf);
|
|
227
|
+
return btoa(String.fromCharCode(...buf))
|
|
228
|
+
.replace(/\+/g, "-")
|
|
229
|
+
.replace(/\//g, "_")
|
|
230
|
+
.replace(/=+$/, "");
|
|
231
|
+
}
|
|
232
|
+
|
|
169
233
|
function cancel(): never {
|
|
170
234
|
clack.cancel("Cancelled.");
|
|
171
235
|
process.exit(0);
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { build } from "./commands/build";
|
|
5
|
-
import { buildShell } from "./commands/build-shell";
|
|
6
5
|
import { dev } from "./commands/dev";
|
|
7
6
|
import { platformBuild } from "./commands/platform-build";
|
|
8
7
|
import { platformDeploy } from "./commands/platform-deploy";
|
|
@@ -59,20 +58,23 @@ platform
|
|
|
59
58
|
)
|
|
60
59
|
.option("--skip-build", "Skip local build step")
|
|
61
60
|
.option("--rebuild", "Force rebuild before deploy")
|
|
62
|
-
.
|
|
63
|
-
|
|
61
|
+
.option("--build-only", "Build the Docker image locally, then exit (no remote push)")
|
|
62
|
+
.option(
|
|
63
|
+
"--image-tag <hash>",
|
|
64
|
+
"Roll back / pin to an existing image tag instead of building a new one",
|
|
65
|
+
)
|
|
66
|
+
.action(
|
|
67
|
+
(
|
|
68
|
+
env: string | undefined,
|
|
69
|
+
opts: {
|
|
70
|
+
skipBuild?: boolean;
|
|
71
|
+
rebuild?: boolean;
|
|
72
|
+
buildOnly?: boolean;
|
|
73
|
+
imageTag?: string;
|
|
74
|
+
},
|
|
75
|
+
) => platformDeploy(env, opts),
|
|
64
76
|
);
|
|
65
77
|
|
|
66
|
-
// Hidden subcommand used by the runtime container's entrypoint / API handlers.
|
|
67
|
-
// Not shown in --help; runs against an installed node_modules tree to emit
|
|
68
|
-
// browser shell bundles for the discovered framework peers.
|
|
69
|
-
program
|
|
70
|
-
.command("_build-shell", { hidden: true })
|
|
71
|
-
.description("Build framework shell bundles from a node_modules dir")
|
|
72
|
-
.requiredOption("--out <dir>", "Output directory for shell .js bundles")
|
|
73
|
-
.requiredOption("--from <dir>", "node_modules directory to discover packages in")
|
|
74
|
-
.action((opts: { out: string; from: string }) => buildShell(opts));
|
|
75
|
-
|
|
76
78
|
// Parse command line arguments
|
|
77
79
|
program.parse(process.argv);
|
|
78
80
|
|
package/src/platform/server.ts
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
import { existsSync, mkdirSync } from "fs";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { readTranslationsConfig } from "../i18n";
|
|
14
|
-
import { createDeployApiHandler } from "./deploy-api";
|
|
15
14
|
import type { BuildManifest, ModuleDescriptor, WorkspaceInfo } from "./shared";
|
|
16
15
|
import type { ModuleAccess } from "@arcote.tech/platform";
|
|
17
16
|
|
|
@@ -31,10 +30,6 @@ export interface PlatformServerOptions {
|
|
|
31
30
|
dbPath?: string;
|
|
32
31
|
/** If true, enables SSE reload stream + mutable manifest (dev mode) */
|
|
33
32
|
devMode?: boolean;
|
|
34
|
-
/** If true, mounts /api/deploy/* endpoints for remote hot-swap. Off by default;
|
|
35
|
-
* opt-in via ARC_DEPLOY_API=1 or explicit CLI flag. In production, network-layer
|
|
36
|
-
* (Caddy + docker-compose port binding) restricts access to SSH tunnel only. */
|
|
37
|
-
deployApi?: boolean;
|
|
38
33
|
/** Arc shell entries [shortName, fullPkgName][] for import map. Auto-detected if omitted. */
|
|
39
34
|
arcEntries?: [string, string][];
|
|
40
35
|
}
|
|
@@ -168,19 +163,30 @@ function serveFile(
|
|
|
168
163
|
const MODULE_SIG_SECRET = process.env.ARC_MODULE_SECRET ?? crypto.randomUUID();
|
|
169
164
|
const MODULE_SIG_TTL = 3600; // 1 hour
|
|
170
165
|
|
|
171
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Signed URL for a chunk-scoped module file. HMAC payload includes the chunk
|
|
168
|
+
* name so a sig minted for `/modules/admin/foo.js` cannot be replayed as
|
|
169
|
+
* `/modules/user/foo.js` — even if the same filename exists in both groups.
|
|
170
|
+
* Public chunks are NEVER signed (no protection needed).
|
|
171
|
+
*/
|
|
172
|
+
function signChunkUrl(chunk: string, file: string): string {
|
|
172
173
|
const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
|
|
173
174
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
174
|
-
hasher.update(`${
|
|
175
|
+
hasher.update(`${chunk}/${file}:${exp}:${MODULE_SIG_SECRET}`);
|
|
175
176
|
const sig = hasher.digest("hex").slice(0, 16);
|
|
176
|
-
return `/modules/${
|
|
177
|
+
return `/modules/${chunk}/${file}?sig=${sig}&exp=${exp}`;
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
function
|
|
180
|
+
function verifyChunkSignature(
|
|
181
|
+
chunk: string,
|
|
182
|
+
file: string,
|
|
183
|
+
sig: string | null,
|
|
184
|
+
exp: string | null,
|
|
185
|
+
): boolean {
|
|
180
186
|
if (!sig || !exp) return false;
|
|
181
187
|
if (Number(exp) < Date.now() / 1000) return false;
|
|
182
188
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
183
|
-
hasher.update(`${
|
|
189
|
+
hasher.update(`${chunk}/${file}:${exp}:${MODULE_SIG_SECRET}`);
|
|
184
190
|
return hasher.digest("hex").slice(0, 16) === sig;
|
|
185
191
|
}
|
|
186
192
|
|
|
@@ -216,40 +222,59 @@ function parseArcTokensHeader(header: string | null): any[] {
|
|
|
216
222
|
return payloads;
|
|
217
223
|
}
|
|
218
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Filter manifest to modules the caller's tokens grant access to. Two-stage
|
|
227
|
+
* check: chunk-level (token type matches chunk name → group is unlocked) +
|
|
228
|
+
* per-rule async `check` callback (if declared in `protectedBy(token, check)`).
|
|
229
|
+
*
|
|
230
|
+
* Public chunk modules pass unconditionally; non-public modules are returned
|
|
231
|
+
* with a signed URL (chunk-aware HMAC, 1h TTL) — the URL is what the browser
|
|
232
|
+
* fetches for the actual JS file.
|
|
233
|
+
*/
|
|
219
234
|
async function filterManifestForTokens(
|
|
220
235
|
manifest: BuildManifest,
|
|
221
236
|
moduleAccessMap: Map<string, ModuleAccess>,
|
|
222
237
|
tokenPayloads: any[],
|
|
223
238
|
): Promise<BuildManifest> {
|
|
239
|
+
const allowedChunks = new Set<string>(["public"]);
|
|
240
|
+
for (const t of tokenPayloads) {
|
|
241
|
+
if (t?.tokenType) allowedChunks.add(t.tokenType);
|
|
242
|
+
}
|
|
243
|
+
|
|
224
244
|
const filtered: ModuleDescriptor[] = [];
|
|
225
245
|
|
|
226
246
|
for (const mod of manifest.modules) {
|
|
227
|
-
|
|
247
|
+
if (!allowedChunks.has(mod.chunk)) continue;
|
|
228
248
|
|
|
229
|
-
if (
|
|
249
|
+
if (mod.chunk === "public") {
|
|
230
250
|
filtered.push(mod);
|
|
231
251
|
continue;
|
|
232
252
|
}
|
|
233
253
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
let granted =
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
254
|
+
// Caller has the right token type — run per-rule `check` if declared.
|
|
255
|
+
const access = moduleAccessMap.get(mod.name);
|
|
256
|
+
let granted = true;
|
|
257
|
+
if (access && access.rules.length > 0) {
|
|
258
|
+
granted = false;
|
|
259
|
+
for (const rule of access.rules) {
|
|
260
|
+
if (rule.token.name !== mod.chunk) continue;
|
|
261
|
+
const matching = tokenPayloads.find(
|
|
262
|
+
(t) => t.tokenType === rule.token.name,
|
|
263
|
+
);
|
|
264
|
+
if (!matching) continue;
|
|
242
265
|
granted = rule.check ? await rule.check(matching) : true;
|
|
243
266
|
if (granted) break;
|
|
244
267
|
}
|
|
245
268
|
}
|
|
246
269
|
|
|
247
270
|
if (granted) {
|
|
248
|
-
filtered.push({ ...mod, url:
|
|
271
|
+
filtered.push({ ...mod, url: signChunkUrl(mod.chunk, mod.file) });
|
|
249
272
|
}
|
|
250
273
|
}
|
|
274
|
+
|
|
251
275
|
return {
|
|
252
276
|
modules: filtered,
|
|
277
|
+
chunks: manifest.chunks,
|
|
253
278
|
shellHash: manifest.shellHash,
|
|
254
279
|
stylesHash: manifest.stylesHash,
|
|
255
280
|
buildTime: manifest.buildTime,
|
|
@@ -260,30 +285,43 @@ async function filterManifestForTokens(
|
|
|
260
285
|
// Platform-specific HTTP handlers
|
|
261
286
|
// ---------------------------------------------------------------------------
|
|
262
287
|
|
|
288
|
+
/** Chunk names must be alphanumeric + dash/underscore — defends against
|
|
289
|
+
* path traversal in URL segments like `/modules/../../etc/passwd`. */
|
|
290
|
+
const CHUNK_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
291
|
+
/** Module filenames are Bun.build outputs — `<safeName>.js` or `chunk-<hash>.js`. */
|
|
292
|
+
const MODULE_FILE_RE = /^[A-Za-z0-9_.-]+\.js$/;
|
|
293
|
+
|
|
263
294
|
function staticFilesHandler(
|
|
264
295
|
ws: WorkspaceInfo,
|
|
265
296
|
devMode: boolean,
|
|
266
|
-
moduleAccessMap: Map<string, ModuleAccess>,
|
|
267
297
|
): ArcHttpHandler {
|
|
268
298
|
return (_req, url, ctx) => {
|
|
269
299
|
const path = url.pathname;
|
|
270
300
|
if (path.startsWith("/shell/"))
|
|
271
301
|
return serveFile(join(ws.shellDir, path.slice(7)), ctx.corsHeaders);
|
|
272
302
|
if (path.startsWith("/modules/")) {
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
const
|
|
303
|
+
// Expected: /modules/<chunk>/<file>.js
|
|
304
|
+
const rest = path.slice(9);
|
|
305
|
+
const slash = rest.indexOf("/");
|
|
306
|
+
if (slash <= 0) {
|
|
307
|
+
return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
|
|
308
|
+
}
|
|
309
|
+
const chunk = rest.slice(0, slash);
|
|
310
|
+
const file = rest.slice(slash + 1);
|
|
276
311
|
|
|
277
|
-
|
|
278
|
-
|
|
312
|
+
if (!CHUNK_NAME_RE.test(chunk) || !MODULE_FILE_RE.test(file)) {
|
|
313
|
+
return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (chunk !== "public") {
|
|
279
317
|
const sig = url.searchParams.get("sig");
|
|
280
318
|
const exp = url.searchParams.get("exp");
|
|
281
|
-
if (!
|
|
319
|
+
if (!verifyChunkSignature(chunk, file, sig, exp)) {
|
|
282
320
|
return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
|
|
283
321
|
}
|
|
284
322
|
}
|
|
285
323
|
|
|
286
|
-
return serveFile(join(ws.modulesDir,
|
|
324
|
+
return serveFile(join(ws.modulesDir, chunk, file), {
|
|
287
325
|
...ctx.corsHeaders,
|
|
288
326
|
"Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
|
|
289
327
|
});
|
|
@@ -418,17 +456,6 @@ export async function startPlatformServer(
|
|
|
418
456
|
}
|
|
419
457
|
};
|
|
420
458
|
|
|
421
|
-
const deployApiEnabled =
|
|
422
|
-
opts.deployApi ?? process.env.ARC_DEPLOY_API === "1";
|
|
423
|
-
const deployApiHandler = deployApiEnabled
|
|
424
|
-
? createDeployApiHandler({
|
|
425
|
-
ws,
|
|
426
|
-
getManifest,
|
|
427
|
-
setManifest,
|
|
428
|
-
notifyReload,
|
|
429
|
-
})
|
|
430
|
-
: null;
|
|
431
|
-
|
|
432
459
|
if (!context) {
|
|
433
460
|
// No context — serve static files only (no WS/commands/queries)
|
|
434
461
|
const cors = {
|
|
@@ -458,10 +485,9 @@ export async function startPlatformServer(
|
|
|
458
485
|
|
|
459
486
|
// Platform handlers only
|
|
460
487
|
const handlers: ArcHttpHandler[] = [
|
|
461
|
-
...(deployApiHandler ? [deployApiHandler] : []),
|
|
462
488
|
apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
|
|
463
489
|
devReloadHandler(sseClients),
|
|
464
|
-
staticFilesHandler(ws, !!devMode
|
|
490
|
+
staticFilesHandler(ws, !!devMode),
|
|
465
491
|
spaFallbackHandler(shellHtml),
|
|
466
492
|
];
|
|
467
493
|
|
|
@@ -498,10 +524,9 @@ export async function startPlatformServer(
|
|
|
498
524
|
port,
|
|
499
525
|
httpHandlers: [
|
|
500
526
|
// Platform-specific handlers (checked AFTER arc handlers)
|
|
501
|
-
...(deployApiHandler ? [deployApiHandler] : []),
|
|
502
527
|
apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
|
|
503
528
|
devReloadHandler(sseClients),
|
|
504
|
-
staticFilesHandler(ws, !!devMode
|
|
529
|
+
staticFilesHandler(ws, !!devMode),
|
|
505
530
|
spaFallbackHandler(shellHtml),
|
|
506
531
|
],
|
|
507
532
|
onWsClose: (clientId) => cleanupClientSubs(clientId),
|