@foldrun/core 0.3.0 → 0.5.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/README.md +27 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// What deleting one step from a flow changes, in words — for the confirm
|
|
2
|
+
// dialog on the canvas and the CLI's `flow rm-step`. Pure over parsed steps
|
|
3
|
+
// and free of the parser and the filesystem, so the browser can import it.
|
|
4
|
+
// The edit itself is flow-patterns.ts removeStep.
|
|
5
|
+
|
|
6
|
+
import type { FlowStep } from "./store.ts";
|
|
7
|
+
|
|
8
|
+
export interface RemovalImpact {
|
|
9
|
+
/** "step 4 · watchdog" — the step as a person names it. */
|
|
10
|
+
label: string;
|
|
11
|
+
/** One sentence per consequence. */
|
|
12
|
+
notes: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function groupsOf(steps: FlowStep[]): number[][] {
|
|
16
|
+
const map = new Map<number, number[]>();
|
|
17
|
+
steps.forEach((s, i) => map.set(s.group, [...(map.get(s.group) ?? []), i]));
|
|
18
|
+
return [...map.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A step as a person names it: its group number and its target. */
|
|
22
|
+
export function stepLabel(steps: FlowStep[], index: number): string {
|
|
23
|
+
const s = steps[index];
|
|
24
|
+
const column = groupsOf(steps).findIndex((g) => g.includes(index));
|
|
25
|
+
return `step ${column + 1} · ${s.subflow ? `flow:${s.subflow}` : s.agent}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What else changes when step `index` goes — said before it goes, in the
|
|
30
|
+
* words a confirm dialog or a terminal prints. Nothing here refuses; it
|
|
31
|
+
* describes. removeStep refuses.
|
|
32
|
+
*/
|
|
33
|
+
export function removalImpact(steps: FlowStep[], index: number): RemovalImpact {
|
|
34
|
+
const s = steps[index];
|
|
35
|
+
if (!s) throw new Error(`no step ${index + 1}`);
|
|
36
|
+
const groups = groupsOf(steps);
|
|
37
|
+
const column = groups.findIndex((g) => g.includes(index));
|
|
38
|
+
const alone = groups[column].length === 1;
|
|
39
|
+
const notes: string[] = [];
|
|
40
|
+
if (steps.length === 1) notes.push("It is the flow's only step — the flow will have nothing to run.");
|
|
41
|
+
const next = groups[column + 1]?.map((i) => steps[i]) ?? [];
|
|
42
|
+
const routed = next.filter((t) => t.case !== undefined || t.else || t.when !== undefined);
|
|
43
|
+
if (alone && routed.length) {
|
|
44
|
+
const before = groups[column - 1];
|
|
45
|
+
const on = before ? `step ${column}'s result (${before.map((i) => steps[i].subflow ? `flow:${steps[i].subflow}` : steps[i].agent).join(", ")})` : "nothing — they would come first";
|
|
46
|
+
notes.push(
|
|
47
|
+
`${routed.length === 1 ? "The case:/when: step" : `The ${routed.length} case:/when: steps`} after it (${routed.map((t) => t.subflow ?? t.agent).join(", ")}) will route on ${on}.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (s.case !== undefined || s.else) notes.push(`It is a branch (${s.case !== undefined ? `case: ${s.case}` : "else"}) — that route is gone.`);
|
|
51
|
+
if (alone && column < groups.length - 1) {
|
|
52
|
+
const from = column + 2, to = groups.length;
|
|
53
|
+
notes.push(from === to ? `Group ${from} after it renumbers to ${from - 1}.` : `The groups after it renumber: ${from}–${to} become ${from - 1}–${to - 1}.`);
|
|
54
|
+
}
|
|
55
|
+
if (!alone) notes.push(`The ${groups[column].length - 1} other step${groups[column].length === 2 ? "" : "s"} in group ${column + 1} still run${groups[column].length === 2 ? "s" : ""}.`);
|
|
56
|
+
if (!s.subflow) notes.push(`agents/${s.agent}/agent.md is not touched.`);
|
|
57
|
+
return { label: stepLabel(steps, index), notes };
|
|
58
|
+
}
|
|
59
|
+
|
package/src/storage.ts
CHANGED
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
// FOLDRUN_S3_ENDPOINT https://<account>.r2.cloudflarestorage.com
|
|
43
43
|
// FOLDRUN_S3_BUCKET bucket name
|
|
44
44
|
// FOLDRUN_S3_REGION default auto (R2's region)
|
|
45
|
-
// FOLDRUN_S3_ACCESS_KEY_ID
|
|
46
|
-
// FOLDRUN_S3_SECRET_ACCESS_KEY
|
|
47
|
-
// FOLDRUN_S3_PATH_STYLE default true — R2 speaks path style
|
|
45
|
+
// FOLDRUN_S3_ACCESS_KEY_ID static keys (R2); leave both unset under an
|
|
46
|
+
// FOLDRUN_S3_SECRET_ACCESS_KEY AWS role — the pod's credentials are used
|
|
47
|
+
// FOLDRUN_S3_PATH_STYLE default true — R2 speaks path style; false on S3
|
|
48
48
|
// FOLDRUN_STORAGE_MAX_MB per object, default 512
|
|
49
49
|
// FOLDRUN_STORAGE_QUOTA_MB per workspace, default 5120
|
|
50
50
|
|
|
@@ -227,7 +227,9 @@ export interface Driver {
|
|
|
227
227
|
remove(key: string): Promise<void>;
|
|
228
228
|
/** A URL a browser can GET directly, or null when the driver has no such
|
|
229
229
|
* thing (fs) and the route must stream the bytes itself. */
|
|
230
|
-
|
|
230
|
+
/** `inlineType`: show it rather than save it — only ever a type from
|
|
231
|
+
* previewType(), which cannot run script. Anything else is attachment. */
|
|
232
|
+
presignGet(key: string, filename: string, mime: string, ttlSec?: number, inlineType?: string): Promise<string | null>;
|
|
231
233
|
/** A URL a browser can PUT directly to, so upload bytes never transit the
|
|
232
234
|
* dashboard process. Null on fs, where the route takes the body. */
|
|
233
235
|
presignPut(key: string, mime: string, ttlSec?: number): Promise<string | null>;
|
|
@@ -280,9 +282,33 @@ interface S3Config {
|
|
|
280
282
|
endpoint: string;
|
|
281
283
|
bucket: string;
|
|
282
284
|
region: string;
|
|
285
|
+
/** Static keys (R2, or a self-host with an IAM user). Absent: the pod's
|
|
286
|
+
* role, read from the container credential endpoint — see credentials(). */
|
|
287
|
+
accessKeyId?: string;
|
|
288
|
+
secretAccessKey?: string;
|
|
289
|
+
pathStyle: boolean;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** What signs a request: a key pair, and a session token when the pair is
|
|
293
|
+
* temporary (a role's). The token rides in every signed request. */
|
|
294
|
+
interface Credentials {
|
|
283
295
|
accessKeyId: string;
|
|
284
296
|
secretAccessKey: string;
|
|
285
|
-
|
|
297
|
+
sessionToken?: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** `https://s3.ap-southeast-2.amazonaws.com` → `ap-southeast-2`. Null for
|
|
301
|
+
* anything else (R2, MinIO), whose region is whatever the env says. */
|
|
302
|
+
export function regionOfEndpoint(endpoint: string): string | null {
|
|
303
|
+
const m = /^s3[.-]([a-z]{2}-[a-z]+-\d)\.amazonaws\.com$/.exec(new URL(endpoint).host);
|
|
304
|
+
return m ? m[1] : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** True when this process runs under an AWS role handed out through the
|
|
308
|
+
* container credential endpoint — EKS Pod Identity, ECS, or an IRSA-style
|
|
309
|
+
* shim. The endpoint is the pod's, and the token file is mounted beside it. */
|
|
310
|
+
function containerCredentialsAvailable(): boolean {
|
|
311
|
+
return Boolean(process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI);
|
|
286
312
|
}
|
|
287
313
|
|
|
288
314
|
export function s3Config(): S3Config | null {
|
|
@@ -290,20 +316,73 @@ export function s3Config(): S3Config | null {
|
|
|
290
316
|
const bucket = process.env.FOLDRUN_S3_BUCKET;
|
|
291
317
|
const accessKeyId = process.env.FOLDRUN_S3_ACCESS_KEY_ID;
|
|
292
318
|
const secretAccessKey = process.env.FOLDRUN_S3_SECRET_ACCESS_KEY;
|
|
293
|
-
if (!endpoint || !bucket
|
|
319
|
+
if (!endpoint || !bucket) return null;
|
|
320
|
+
const staticKeys = Boolean(accessKeyId && secretAccessKey);
|
|
321
|
+
// No key and no role is a half-set environment: fall back to fs rather
|
|
322
|
+
// than fail at the first upload (driverFor says why).
|
|
323
|
+
if (!staticKeys && !containerCredentialsAvailable()) return null;
|
|
294
324
|
return {
|
|
295
325
|
endpoint: endpoint.replace(/\/+$/, ""),
|
|
296
326
|
bucket,
|
|
297
|
-
// R2 has one region and calls it `auto`. AWS
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
secretAccessKey,
|
|
327
|
+
// R2 has one region and calls it `auto`. An AWS endpoint names its own;
|
|
328
|
+
// anything else says so in the env.
|
|
329
|
+
region: process.env.FOLDRUN_S3_REGION ?? regionOfEndpoint(endpoint) ?? "auto",
|
|
330
|
+
...(staticKeys ? { accessKeyId, secretAccessKey } : {}),
|
|
301
331
|
pathStyle: process.env.FOLDRUN_S3_PATH_STYLE !== "false",
|
|
302
332
|
};
|
|
303
333
|
}
|
|
304
334
|
|
|
305
|
-
|
|
306
|
-
|
|
335
|
+
// ---------- credentials ----------
|
|
336
|
+
//
|
|
337
|
+
// Static keys are used as given. Without them, the process is running under
|
|
338
|
+
// an AWS role: EKS Pod Identity (and ECS) publish the role's credentials on a
|
|
339
|
+
// link-local HTTP endpoint named by AWS_CONTAINER_CREDENTIALS_FULL_URI, with
|
|
340
|
+
// a bearer token read from AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE. They are
|
|
341
|
+
// temporary — the reply says when — so they are cached and fetched again a
|
|
342
|
+
// few minutes before they run out. This is the SDK's default chain, written
|
|
343
|
+
// small: foldrun signs by hand and does not carry the SDK for this.
|
|
344
|
+
|
|
345
|
+
let cached: (Credentials & { expiresAt: number }) | null = null;
|
|
346
|
+
const REFRESH_EARLY_MS = 5 * 60_000;
|
|
347
|
+
|
|
348
|
+
async function credentials(cfg: S3Config): Promise<Credentials> {
|
|
349
|
+
if (cfg.accessKeyId && cfg.secretAccessKey) return { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
|
|
350
|
+
if (cached && Date.now() < cached.expiresAt - REFRESH_EARLY_MS) return cached;
|
|
351
|
+
const full = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
|
|
352
|
+
const relative = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
|
|
353
|
+
const url = full ?? (relative ? `http://169.254.170.2${relative}` : null);
|
|
354
|
+
if (!url) throw new Error("s3: no static key and no container credential endpoint");
|
|
355
|
+
const headers: Record<string, string> = {};
|
|
356
|
+
const tokenFile = process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE;
|
|
357
|
+
const token = tokenFile ? fs.readFileSync(tokenFile, "utf8").trim() : process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN;
|
|
358
|
+
if (token) headers.authorization = token;
|
|
359
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(5_000) });
|
|
360
|
+
if (!res.ok) throw new Error(`s3: the container credential endpoint answered ${res.status}`);
|
|
361
|
+
const body = (await res.json()) as { AccessKeyId?: string; SecretAccessKey?: string; Token?: string; Expiration?: string };
|
|
362
|
+
if (!body.AccessKeyId || !body.SecretAccessKey) throw new Error("s3: the container credential endpoint returned no key");
|
|
363
|
+
cached = {
|
|
364
|
+
accessKeyId: body.AccessKeyId,
|
|
365
|
+
secretAccessKey: body.SecretAccessKey,
|
|
366
|
+
...(body.Token ? { sessionToken: body.Token } : {}),
|
|
367
|
+
// No expiry in the reply: treat it as good for an hour, then ask again.
|
|
368
|
+
expiresAt: body.Expiration ? Date.parse(body.Expiration) : Date.now() + 60 * 60_000,
|
|
369
|
+
};
|
|
370
|
+
return cached;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** For tests: forget cached role credentials. */
|
|
374
|
+
export function forgetS3Credentials(): void {
|
|
375
|
+
cached = null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Narrower than crypto.BinaryLike on purpose. BinaryLike also covers views
|
|
379
|
+
// over a SharedArrayBuffer, which .update() has never accepted and which
|
|
380
|
+
// @types/node 26 finally says so about — the old typings let this compile by
|
|
381
|
+
// being vaguer than the runtime. Every caller here passes a string or a
|
|
382
|
+
// Buffer, so this is the honest signature, not a workaround.
|
|
383
|
+
type Hashable = string | Uint8Array;
|
|
384
|
+
const sha256 = (b: Hashable) => crypto.createHash("sha256").update(b).digest("hex");
|
|
385
|
+
const hmac = (key: Hashable, data: string) =>
|
|
307
386
|
crypto.createHmac("sha256", key).update(data, "utf8").digest();
|
|
308
387
|
|
|
309
388
|
/** RFC 3986. encodeURIComponent leaves !'()* alone and SigV4 does not. */
|
|
@@ -320,8 +399,8 @@ function stamps(now: Date) {
|
|
|
320
399
|
return { amzDate, dateStamp: amzDate.slice(0, 8) };
|
|
321
400
|
}
|
|
322
401
|
|
|
323
|
-
function signingKey(cfg: S3Config, dateStamp: string) {
|
|
324
|
-
const kDate = hmac(`AWS4${
|
|
402
|
+
function signingKey(cfg: S3Config, creds: Credentials, dateStamp: string) {
|
|
403
|
+
const kDate = hmac(`AWS4${creds.secretAccessKey}`, dateStamp);
|
|
325
404
|
const kRegion = hmac(kDate, cfg.region);
|
|
326
405
|
const kService = hmac(kRegion, "s3");
|
|
327
406
|
return hmac(kService, "aws4_request");
|
|
@@ -350,6 +429,7 @@ function target(cfg: S3Config, key: string) {
|
|
|
350
429
|
*/
|
|
351
430
|
function presign(
|
|
352
431
|
cfg: S3Config,
|
|
432
|
+
creds: Credentials,
|
|
353
433
|
method: "GET" | "PUT",
|
|
354
434
|
key: string,
|
|
355
435
|
ttlSec: number,
|
|
@@ -363,10 +443,13 @@ function presign(
|
|
|
363
443
|
const query: Record<string, string> = {
|
|
364
444
|
...extraQuery,
|
|
365
445
|
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
|
366
|
-
"X-Amz-Credential": `${
|
|
446
|
+
"X-Amz-Credential": `${creds.accessKeyId}/${scope}`,
|
|
367
447
|
"X-Amz-Date": amzDate,
|
|
368
448
|
"X-Amz-Expires": String(ttlSec),
|
|
369
449
|
"X-Amz-SignedHeaders": "host",
|
|
450
|
+
// A role's credentials are only valid with their token, and the token
|
|
451
|
+
// is part of what is signed.
|
|
452
|
+
...(creds.sessionToken ? { "X-Amz-Security-Token": creds.sessionToken } : {}),
|
|
370
453
|
};
|
|
371
454
|
const canonicalQuery = Object.keys(query)
|
|
372
455
|
.sort()
|
|
@@ -389,7 +472,7 @@ function presign(
|
|
|
389
472
|
sha256(canonicalRequest),
|
|
390
473
|
].join("\n");
|
|
391
474
|
|
|
392
|
-
const signature = hmac(signingKey(cfg, dateStamp), stringToSign).toString("hex");
|
|
475
|
+
const signature = hmac(signingKey(cfg, creds, dateStamp), stringToSign).toString("hex");
|
|
393
476
|
return `${origin}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
|
|
394
477
|
}
|
|
395
478
|
|
|
@@ -401,6 +484,7 @@ async function s3Request(
|
|
|
401
484
|
body?: Buffer,
|
|
402
485
|
mime?: string,
|
|
403
486
|
): Promise<Response> {
|
|
487
|
+
const creds = await credentials(cfg);
|
|
404
488
|
const { host, origin, canonicalUri } = target(cfg, key);
|
|
405
489
|
const { amzDate, dateStamp } = stamps(new Date());
|
|
406
490
|
const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
|
|
@@ -410,6 +494,7 @@ async function s3Request(
|
|
|
410
494
|
host,
|
|
411
495
|
"x-amz-content-sha256": payloadHash,
|
|
412
496
|
"x-amz-date": amzDate,
|
|
497
|
+
...(creds.sessionToken ? { "x-amz-security-token": creds.sessionToken } : {}),
|
|
413
498
|
...(mime ? { "content-type": mime } : {}),
|
|
414
499
|
};
|
|
415
500
|
const signed = Object.keys(headers).sort();
|
|
@@ -426,7 +511,7 @@ async function s3Request(
|
|
|
426
511
|
].join("\n");
|
|
427
512
|
|
|
428
513
|
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonicalRequest)].join("\n");
|
|
429
|
-
const signature = hmac(signingKey(cfg, dateStamp), stringToSign).toString("hex");
|
|
514
|
+
const signature = hmac(signingKey(cfg, creds, dateStamp), stringToSign).toString("hex");
|
|
430
515
|
|
|
431
516
|
const res = await fetch(`${origin}${canonicalUri}`, {
|
|
432
517
|
method,
|
|
@@ -434,7 +519,7 @@ async function s3Request(
|
|
|
434
519
|
headers: {
|
|
435
520
|
...headers,
|
|
436
521
|
Authorization:
|
|
437
|
-
`AWS4-HMAC-SHA256 Credential=${
|
|
522
|
+
`AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, ` +
|
|
438
523
|
`SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
439
524
|
},
|
|
440
525
|
});
|
|
@@ -465,19 +550,34 @@ function s3Driver(cfg: S3Config): Driver {
|
|
|
465
550
|
// 404 on delete is the desired end state, not an error.
|
|
466
551
|
if (!res.ok && res.status !== 404) await fail(res, "delete");
|
|
467
552
|
},
|
|
468
|
-
async presignGet(key, filename, mime, ttlSec = 300) {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
553
|
+
async presignGet(key, filename, mime, ttlSec = 300, inlineType) {
|
|
554
|
+
// Attachment, unless the caller names a preview type — and then only a
|
|
555
|
+
// type previewType() vouches for, with that type forced, so a stored
|
|
556
|
+
// page can never come back as a page. See presign()'s note.
|
|
557
|
+
const safe = inlineType && PREVIEW_TYPES_SET.has(inlineType) ? inlineType : null;
|
|
558
|
+
return presign(cfg, await credentials(cfg), "GET", key, ttlSec, {
|
|
559
|
+
"response-content-disposition": contentDisposition(safe ? "inline" : "attachment", filename),
|
|
560
|
+
"response-content-type": safe ?? mime,
|
|
473
561
|
});
|
|
474
562
|
},
|
|
475
563
|
async presignPut(key, _mime, ttlSec = 600) {
|
|
476
|
-
return presign(cfg, "PUT", key, ttlSec);
|
|
564
|
+
return presign(cfg, await credentials(cfg), "PUT", key, ttlSec);
|
|
477
565
|
},
|
|
478
566
|
};
|
|
479
567
|
}
|
|
480
568
|
|
|
569
|
+
/**
|
|
570
|
+
* The Content-Disposition a presigned GET asks the store to send back. The
|
|
571
|
+
* store echoes it as a header, and header values are bytes: a raw "報告.pdf"
|
|
572
|
+
* comes back mangled or refused. RFC 6266: an ASCII `filename` for old
|
|
573
|
+
* clients, the exact name percent-encoded as UTF-8 in `filename*`.
|
|
574
|
+
*/
|
|
575
|
+
export function contentDisposition(type: "inline" | "attachment", filename: string): string {
|
|
576
|
+
const ascii = filename.replace(/[^\x20-\x7e]/gu, "_").replace(/["\\]/g, "_");
|
|
577
|
+
const exact = encodeURIComponent(filename).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
578
|
+
return `${type}; filename="${ascii}"; filename*=UTF-8''${exact}`;
|
|
579
|
+
}
|
|
580
|
+
|
|
481
581
|
/**
|
|
482
582
|
* The driver in force. `fs` unless S3 is both selected and configured —
|
|
483
583
|
* a half-set environment falls back rather than failing at the first upload,
|
|
@@ -640,14 +740,39 @@ export async function downloadUrl(
|
|
|
640
740
|
tenant: string,
|
|
641
741
|
workspace: string,
|
|
642
742
|
record: FileRecord,
|
|
743
|
+
opts: { inline?: boolean } = {},
|
|
643
744
|
): Promise<string | null> {
|
|
745
|
+
const inlineType = opts.inline ? previewType(record.path) ?? undefined : undefined;
|
|
644
746
|
return driverFor(tenant, workspace).presignGet(
|
|
645
747
|
blobKey(tenant, workspace, record.sha),
|
|
646
748
|
path.basename(record.path),
|
|
647
749
|
record.mime,
|
|
750
|
+
300,
|
|
751
|
+
inlineType,
|
|
648
752
|
);
|
|
649
753
|
}
|
|
650
754
|
|
|
755
|
+
/**
|
|
756
|
+
* The types a stored file may be shown as, rather than saved: pictures,
|
|
757
|
+
* video, audio and PDF, by extension. None of them runs script in a page —
|
|
758
|
+
* SVG is left out for exactly that reason, and so is anything HTML-shaped.
|
|
759
|
+
* Null means "download it".
|
|
760
|
+
*/
|
|
761
|
+
const PREVIEW_TYPES: Record<string, string> = {
|
|
762
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".jfif": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif",
|
|
763
|
+
".bmp": "image/bmp", ".ico": "image/x-icon", ".cur": "image/x-icon", ".apng": "image/apng",
|
|
764
|
+
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".m4v": "video/mp4", ".mkv": "video/x-matroska",
|
|
765
|
+
".ogv": "video/ogg", ".3gp": "video/3gpp", ".3gpp": "video/3gpp", ".3g2": "video/3gpp2", ".mpg": "video/mpeg", ".mpeg": "video/mpeg", ".avi": "video/x-msvideo",
|
|
766
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4", ".m4r": "audio/mp4", ".m4b": "audio/mp4", ".aac": "audio/aac",
|
|
767
|
+
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg", ".flac": "audio/flac", ".aif": "audio/aiff", ".aiff": "audio/aiff", ".weba": "audio/webm",
|
|
768
|
+
".pdf": "application/pdf", ".ai": "application/pdf",
|
|
769
|
+
".ttf": "font/ttf", ".otf": "font/otf", ".woff": "font/woff", ".woff2": "font/woff2",
|
|
770
|
+
};
|
|
771
|
+
const PREVIEW_TYPES_SET = new Set(Object.values(PREVIEW_TYPES));
|
|
772
|
+
export function previewType(rel: string): string | null {
|
|
773
|
+
return PREVIEW_TYPES[path.extname(rel).toLowerCase()] ?? null;
|
|
774
|
+
}
|
|
775
|
+
|
|
651
776
|
/** A link the browser can PUT to, plus the key it will land at. Null on fs. */
|
|
652
777
|
export async function uploadUrl(
|
|
653
778
|
tenant: string,
|