@foldrun/core 0.4.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/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/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/catalog.js +2 -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 +218 -3
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +6 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -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/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 +30 -3
- 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 +53 -7
- package/dist/src/notify.js +194 -126
- 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 +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- 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 +149 -13
- package/dist/src/run-container.js +403 -42
- package/dist/src/runner.d.ts +142 -9
- package/dist/src/runner.js +1027 -122
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +3 -2
- package/dist/src/script-tools.js +35 -3
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +61 -2
- package/dist/src/step-exec.js +414 -36
- 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 +218 -37
- package/dist/src/store.js +347 -60
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -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 +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- 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 +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +3 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +313 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +11 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- 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 +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +439 -43
- package/src/runner.ts +1050 -122
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +38 -5
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +429 -37
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +500 -68
- package/src/subagents.ts +189 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FlowStep } from "./store.ts";
|
|
2
|
+
export interface RemovalImpact {
|
|
3
|
+
/** "step 4 · watchdog" — the step as a person names it. */
|
|
4
|
+
label: string;
|
|
5
|
+
/** One sentence per consequence. */
|
|
6
|
+
notes: string[];
|
|
7
|
+
}
|
|
8
|
+
/** A step as a person names it: its group number and its target. */
|
|
9
|
+
export declare function stepLabel(steps: FlowStep[], index: number): string;
|
|
10
|
+
/**
|
|
11
|
+
* What else changes when step `index` goes — said before it goes, in the
|
|
12
|
+
* words a confirm dialog or a terminal prints. Nothing here refuses; it
|
|
13
|
+
* describes. removeStep refuses.
|
|
14
|
+
*/
|
|
15
|
+
export declare function removalImpact(steps: FlowStep[], index: number): RemovalImpact;
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
function groupsOf(steps) {
|
|
6
|
+
const map = new Map();
|
|
7
|
+
steps.forEach((s, i) => map.set(s.group, [...(map.get(s.group) ?? []), i]));
|
|
8
|
+
return [...map.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
|
|
9
|
+
}
|
|
10
|
+
/** A step as a person names it: its group number and its target. */
|
|
11
|
+
export function stepLabel(steps, index) {
|
|
12
|
+
const s = steps[index];
|
|
13
|
+
const column = groupsOf(steps).findIndex((g) => g.includes(index));
|
|
14
|
+
return `step ${column + 1} · ${s.subflow ? `flow:${s.subflow}` : s.agent}`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* What else changes when step `index` goes — said before it goes, in the
|
|
18
|
+
* words a confirm dialog or a terminal prints. Nothing here refuses; it
|
|
19
|
+
* describes. removeStep refuses.
|
|
20
|
+
*/
|
|
21
|
+
export function removalImpact(steps, index) {
|
|
22
|
+
const s = steps[index];
|
|
23
|
+
if (!s)
|
|
24
|
+
throw new Error(`no step ${index + 1}`);
|
|
25
|
+
const groups = groupsOf(steps);
|
|
26
|
+
const column = groups.findIndex((g) => g.includes(index));
|
|
27
|
+
const alone = groups[column].length === 1;
|
|
28
|
+
const notes = [];
|
|
29
|
+
if (steps.length === 1)
|
|
30
|
+
notes.push("It is the flow's only step — the flow will have nothing to run.");
|
|
31
|
+
const next = groups[column + 1]?.map((i) => steps[i]) ?? [];
|
|
32
|
+
const routed = next.filter((t) => t.case !== undefined || t.else || t.when !== undefined);
|
|
33
|
+
if (alone && routed.length) {
|
|
34
|
+
const before = groups[column - 1];
|
|
35
|
+
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";
|
|
36
|
+
notes.push(`${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}.`);
|
|
37
|
+
}
|
|
38
|
+
if (s.case !== undefined || s.else)
|
|
39
|
+
notes.push(`It is a branch (${s.case !== undefined ? `case: ${s.case}` : "else"}) — that route is gone.`);
|
|
40
|
+
if (alone && column < groups.length - 1) {
|
|
41
|
+
const from = column + 2, to = groups.length;
|
|
42
|
+
notes.push(from === to ? `Group ${from} after it renumbers to ${from - 1}.` : `The groups after it renumber: ${from}–${to} become ${from - 1}–${to - 1}.`);
|
|
43
|
+
}
|
|
44
|
+
if (!alone)
|
|
45
|
+
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" : ""}.`);
|
|
46
|
+
if (!s.subflow)
|
|
47
|
+
notes.push(`agents/${s.agent}/agent.md is not touched.`);
|
|
48
|
+
return { label: stepLabel(steps, index), notes };
|
|
49
|
+
}
|
package/dist/src/storage.d.ts
CHANGED
|
@@ -60,7 +60,9 @@ export interface Driver {
|
|
|
60
60
|
remove(key: string): Promise<void>;
|
|
61
61
|
/** A URL a browser can GET directly, or null when the driver has no such
|
|
62
62
|
* thing (fs) and the route must stream the bytes itself. */
|
|
63
|
-
|
|
63
|
+
/** `inlineType`: show it rather than save it — only ever a type from
|
|
64
|
+
* previewType(), which cannot run script. Anything else is attachment. */
|
|
65
|
+
presignGet(key: string, filename: string, mime: string, ttlSec?: number, inlineType?: string): Promise<string | null>;
|
|
64
66
|
/** A URL a browser can PUT directly to, so upload bytes never transit the
|
|
65
67
|
* dashboard process. Null on fs, where the route takes the body. */
|
|
66
68
|
presignPut(key: string, mime: string, ttlSec?: number): Promise<string | null>;
|
|
@@ -72,11 +74,25 @@ interface S3Config {
|
|
|
72
74
|
endpoint: string;
|
|
73
75
|
bucket: string;
|
|
74
76
|
region: string;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
/** Static keys (R2, or a self-host with an IAM user). Absent: the pod's
|
|
78
|
+
* role, read from the container credential endpoint — see credentials(). */
|
|
79
|
+
accessKeyId?: string;
|
|
80
|
+
secretAccessKey?: string;
|
|
77
81
|
pathStyle: boolean;
|
|
78
82
|
}
|
|
83
|
+
/** `https://s3.ap-southeast-2.amazonaws.com` → `ap-southeast-2`. Null for
|
|
84
|
+
* anything else (R2, MinIO), whose region is whatever the env says. */
|
|
85
|
+
export declare function regionOfEndpoint(endpoint: string): string | null;
|
|
79
86
|
export declare function s3Config(): S3Config | null;
|
|
87
|
+
/** For tests: forget cached role credentials. */
|
|
88
|
+
export declare function forgetS3Credentials(): void;
|
|
89
|
+
/**
|
|
90
|
+
* The Content-Disposition a presigned GET asks the store to send back. The
|
|
91
|
+
* store echoes it as a header, and header values are bytes: a raw "報告.pdf"
|
|
92
|
+
* comes back mangled or refused. RFC 6266: an ASCII `filename` for old
|
|
93
|
+
* clients, the exact name percent-encoded as UTF-8 in `filename*`.
|
|
94
|
+
*/
|
|
95
|
+
export declare function contentDisposition(type: "inline" | "attachment", filename: string): string;
|
|
80
96
|
/**
|
|
81
97
|
* The driver in force. `fs` unless S3 is both selected and configured —
|
|
82
98
|
* a half-set environment falls back rather than failing at the first upload,
|
|
@@ -90,7 +106,10 @@ export declare function registerUpload(tenant: string, workspace: string, rel: s
|
|
|
90
106
|
export declare function readFileBytes(tenant: string, workspace: string, rel: string): Promise<Buffer | null>;
|
|
91
107
|
export declare function deleteFile(tenant: string, workspace: string, rel: string): Promise<boolean>;
|
|
92
108
|
/** A link the browser can follow, or null when the route must stream it. */
|
|
93
|
-
export declare function downloadUrl(tenant: string, workspace: string, record: FileRecord
|
|
109
|
+
export declare function downloadUrl(tenant: string, workspace: string, record: FileRecord, opts?: {
|
|
110
|
+
inline?: boolean;
|
|
111
|
+
}): Promise<string | null>;
|
|
112
|
+
export declare function previewType(rel: string): string | null;
|
|
94
113
|
/** A link the browser can PUT to, plus the key it will land at. Null on fs. */
|
|
95
114
|
export declare function uploadUrl(tenant: string, workspace: string, rel: string, sha: string): Promise<string | null>;
|
|
96
115
|
/**
|
package/dist/src/storage.js
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
|
import fs from "node:fs";
|
|
@@ -222,23 +222,85 @@ function fsDriver(tenant, workspace) {
|
|
|
222
222
|
},
|
|
223
223
|
};
|
|
224
224
|
}
|
|
225
|
+
/** `https://s3.ap-southeast-2.amazonaws.com` → `ap-southeast-2`. Null for
|
|
226
|
+
* anything else (R2, MinIO), whose region is whatever the env says. */
|
|
227
|
+
export function regionOfEndpoint(endpoint) {
|
|
228
|
+
const m = /^s3[.-]([a-z]{2}-[a-z]+-\d)\.amazonaws\.com$/.exec(new URL(endpoint).host);
|
|
229
|
+
return m ? m[1] : null;
|
|
230
|
+
}
|
|
231
|
+
/** True when this process runs under an AWS role handed out through the
|
|
232
|
+
* container credential endpoint — EKS Pod Identity, ECS, or an IRSA-style
|
|
233
|
+
* shim. The endpoint is the pod's, and the token file is mounted beside it. */
|
|
234
|
+
function containerCredentialsAvailable() {
|
|
235
|
+
return Boolean(process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI);
|
|
236
|
+
}
|
|
225
237
|
export function s3Config() {
|
|
226
238
|
const endpoint = process.env.FOLDRUN_S3_ENDPOINT;
|
|
227
239
|
const bucket = process.env.FOLDRUN_S3_BUCKET;
|
|
228
240
|
const accessKeyId = process.env.FOLDRUN_S3_ACCESS_KEY_ID;
|
|
229
241
|
const secretAccessKey = process.env.FOLDRUN_S3_SECRET_ACCESS_KEY;
|
|
230
|
-
if (!endpoint || !bucket
|
|
242
|
+
if (!endpoint || !bucket)
|
|
243
|
+
return null;
|
|
244
|
+
const staticKeys = Boolean(accessKeyId && secretAccessKey);
|
|
245
|
+
// No key and no role is a half-set environment: fall back to fs rather
|
|
246
|
+
// than fail at the first upload (driverFor says why).
|
|
247
|
+
if (!staticKeys && !containerCredentialsAvailable())
|
|
231
248
|
return null;
|
|
232
249
|
return {
|
|
233
250
|
endpoint: endpoint.replace(/\/+$/, ""),
|
|
234
251
|
bucket,
|
|
235
|
-
// R2 has one region and calls it `auto`. AWS
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
secretAccessKey,
|
|
252
|
+
// R2 has one region and calls it `auto`. An AWS endpoint names its own;
|
|
253
|
+
// anything else says so in the env.
|
|
254
|
+
region: process.env.FOLDRUN_S3_REGION ?? regionOfEndpoint(endpoint) ?? "auto",
|
|
255
|
+
...(staticKeys ? { accessKeyId, secretAccessKey } : {}),
|
|
239
256
|
pathStyle: process.env.FOLDRUN_S3_PATH_STYLE !== "false",
|
|
240
257
|
};
|
|
241
258
|
}
|
|
259
|
+
// ---------- credentials ----------
|
|
260
|
+
//
|
|
261
|
+
// Static keys are used as given. Without them, the process is running under
|
|
262
|
+
// an AWS role: EKS Pod Identity (and ECS) publish the role's credentials on a
|
|
263
|
+
// link-local HTTP endpoint named by AWS_CONTAINER_CREDENTIALS_FULL_URI, with
|
|
264
|
+
// a bearer token read from AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE. They are
|
|
265
|
+
// temporary — the reply says when — so they are cached and fetched again a
|
|
266
|
+
// few minutes before they run out. This is the SDK's default chain, written
|
|
267
|
+
// small: foldrun signs by hand and does not carry the SDK for this.
|
|
268
|
+
let cached = null;
|
|
269
|
+
const REFRESH_EARLY_MS = 5 * 60_000;
|
|
270
|
+
async function credentials(cfg) {
|
|
271
|
+
if (cfg.accessKeyId && cfg.secretAccessKey)
|
|
272
|
+
return { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey };
|
|
273
|
+
if (cached && Date.now() < cached.expiresAt - REFRESH_EARLY_MS)
|
|
274
|
+
return cached;
|
|
275
|
+
const full = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
|
|
276
|
+
const relative = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
|
|
277
|
+
const url = full ?? (relative ? `http://169.254.170.2${relative}` : null);
|
|
278
|
+
if (!url)
|
|
279
|
+
throw new Error("s3: no static key and no container credential endpoint");
|
|
280
|
+
const headers = {};
|
|
281
|
+
const tokenFile = process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE;
|
|
282
|
+
const token = tokenFile ? fs.readFileSync(tokenFile, "utf8").trim() : process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN;
|
|
283
|
+
if (token)
|
|
284
|
+
headers.authorization = token;
|
|
285
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(5_000) });
|
|
286
|
+
if (!res.ok)
|
|
287
|
+
throw new Error(`s3: the container credential endpoint answered ${res.status}`);
|
|
288
|
+
const body = (await res.json());
|
|
289
|
+
if (!body.AccessKeyId || !body.SecretAccessKey)
|
|
290
|
+
throw new Error("s3: the container credential endpoint returned no key");
|
|
291
|
+
cached = {
|
|
292
|
+
accessKeyId: body.AccessKeyId,
|
|
293
|
+
secretAccessKey: body.SecretAccessKey,
|
|
294
|
+
...(body.Token ? { sessionToken: body.Token } : {}),
|
|
295
|
+
// No expiry in the reply: treat it as good for an hour, then ask again.
|
|
296
|
+
expiresAt: body.Expiration ? Date.parse(body.Expiration) : Date.now() + 60 * 60_000,
|
|
297
|
+
};
|
|
298
|
+
return cached;
|
|
299
|
+
}
|
|
300
|
+
/** For tests: forget cached role credentials. */
|
|
301
|
+
export function forgetS3Credentials() {
|
|
302
|
+
cached = null;
|
|
303
|
+
}
|
|
242
304
|
const sha256 = (b) => crypto.createHash("sha256").update(b).digest("hex");
|
|
243
305
|
const hmac = (key, data) => crypto.createHmac("sha256", key).update(data, "utf8").digest();
|
|
244
306
|
/** RFC 3986. encodeURIComponent leaves !'()* alone and SigV4 does not. */
|
|
@@ -250,8 +312,8 @@ function stamps(now) {
|
|
|
250
312
|
const amzDate = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
251
313
|
return { amzDate, dateStamp: amzDate.slice(0, 8) };
|
|
252
314
|
}
|
|
253
|
-
function signingKey(cfg, dateStamp) {
|
|
254
|
-
const kDate = hmac(`AWS4${
|
|
315
|
+
function signingKey(cfg, creds, dateStamp) {
|
|
316
|
+
const kDate = hmac(`AWS4${creds.secretAccessKey}`, dateStamp);
|
|
255
317
|
const kRegion = hmac(kDate, cfg.region);
|
|
256
318
|
const kService = hmac(kRegion, "s3");
|
|
257
319
|
return hmac(kService, "aws4_request");
|
|
@@ -276,17 +338,20 @@ function target(cfg, key) {
|
|
|
276
338
|
* wrote can never render as a page on any origin. Without it, "the agent
|
|
277
339
|
* produced a report.html" and "stored XSS" are the same event.
|
|
278
340
|
*/
|
|
279
|
-
function presign(cfg, method, key, ttlSec, extraQuery = {}, now = new Date()) {
|
|
341
|
+
function presign(cfg, creds, method, key, ttlSec, extraQuery = {}, now = new Date()) {
|
|
280
342
|
const { host, origin, canonicalUri } = target(cfg, key);
|
|
281
343
|
const { amzDate, dateStamp } = stamps(now);
|
|
282
344
|
const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
|
|
283
345
|
const query = {
|
|
284
346
|
...extraQuery,
|
|
285
347
|
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
|
286
|
-
"X-Amz-Credential": `${
|
|
348
|
+
"X-Amz-Credential": `${creds.accessKeyId}/${scope}`,
|
|
287
349
|
"X-Amz-Date": amzDate,
|
|
288
350
|
"X-Amz-Expires": String(ttlSec),
|
|
289
351
|
"X-Amz-SignedHeaders": "host",
|
|
352
|
+
// A role's credentials are only valid with their token, and the token
|
|
353
|
+
// is part of what is signed.
|
|
354
|
+
...(creds.sessionToken ? { "X-Amz-Security-Token": creds.sessionToken } : {}),
|
|
290
355
|
};
|
|
291
356
|
const canonicalQuery = Object.keys(query)
|
|
292
357
|
.sort()
|
|
@@ -306,11 +371,12 @@ function presign(cfg, method, key, ttlSec, extraQuery = {}, now = new Date()) {
|
|
|
306
371
|
scope,
|
|
307
372
|
sha256(canonicalRequest),
|
|
308
373
|
].join("\n");
|
|
309
|
-
const signature = hmac(signingKey(cfg, dateStamp), stringToSign).toString("hex");
|
|
374
|
+
const signature = hmac(signingKey(cfg, creds, dateStamp), stringToSign).toString("hex");
|
|
310
375
|
return `${origin}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
|
|
311
376
|
}
|
|
312
377
|
/** The header-signed form, for the calls the platform itself makes. */
|
|
313
378
|
async function s3Request(cfg, method, key, body, mime) {
|
|
379
|
+
const creds = await credentials(cfg);
|
|
314
380
|
const { host, origin, canonicalUri } = target(cfg, key);
|
|
315
381
|
const { amzDate, dateStamp } = stamps(new Date());
|
|
316
382
|
const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
|
|
@@ -319,6 +385,7 @@ async function s3Request(cfg, method, key, body, mime) {
|
|
|
319
385
|
host,
|
|
320
386
|
"x-amz-content-sha256": payloadHash,
|
|
321
387
|
"x-amz-date": amzDate,
|
|
388
|
+
...(creds.sessionToken ? { "x-amz-security-token": creds.sessionToken } : {}),
|
|
322
389
|
...(mime ? { "content-type": mime } : {}),
|
|
323
390
|
};
|
|
324
391
|
const signed = Object.keys(headers).sort();
|
|
@@ -333,13 +400,13 @@ async function s3Request(cfg, method, key, body, mime) {
|
|
|
333
400
|
payloadHash,
|
|
334
401
|
].join("\n");
|
|
335
402
|
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonicalRequest)].join("\n");
|
|
336
|
-
const signature = hmac(signingKey(cfg, dateStamp), stringToSign).toString("hex");
|
|
403
|
+
const signature = hmac(signingKey(cfg, creds, dateStamp), stringToSign).toString("hex");
|
|
337
404
|
const res = await fetch(`${origin}${canonicalUri}`, {
|
|
338
405
|
method,
|
|
339
406
|
body: body,
|
|
340
407
|
headers: {
|
|
341
408
|
...headers,
|
|
342
|
-
Authorization: `AWS4-HMAC-SHA256 Credential=${
|
|
409
|
+
Authorization: `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, ` +
|
|
343
410
|
`SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
344
411
|
},
|
|
345
412
|
});
|
|
@@ -372,18 +439,32 @@ function s3Driver(cfg) {
|
|
|
372
439
|
if (!res.ok && res.status !== 404)
|
|
373
440
|
await fail(res, "delete");
|
|
374
441
|
},
|
|
375
|
-
async presignGet(key, filename, mime, ttlSec = 300) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
442
|
+
async presignGet(key, filename, mime, ttlSec = 300, inlineType) {
|
|
443
|
+
// Attachment, unless the caller names a preview type — and then only a
|
|
444
|
+
// type previewType() vouches for, with that type forced, so a stored
|
|
445
|
+
// page can never come back as a page. See presign()'s note.
|
|
446
|
+
const safe = inlineType && PREVIEW_TYPES_SET.has(inlineType) ? inlineType : null;
|
|
447
|
+
return presign(cfg, await credentials(cfg), "GET", key, ttlSec, {
|
|
448
|
+
"response-content-disposition": contentDisposition(safe ? "inline" : "attachment", filename),
|
|
449
|
+
"response-content-type": safe ?? mime,
|
|
380
450
|
});
|
|
381
451
|
},
|
|
382
452
|
async presignPut(key, _mime, ttlSec = 600) {
|
|
383
|
-
return presign(cfg, "PUT", key, ttlSec);
|
|
453
|
+
return presign(cfg, await credentials(cfg), "PUT", key, ttlSec);
|
|
384
454
|
},
|
|
385
455
|
};
|
|
386
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* The Content-Disposition a presigned GET asks the store to send back. The
|
|
459
|
+
* store echoes it as a header, and header values are bytes: a raw "報告.pdf"
|
|
460
|
+
* comes back mangled or refused. RFC 6266: an ASCII `filename` for old
|
|
461
|
+
* clients, the exact name percent-encoded as UTF-8 in `filename*`.
|
|
462
|
+
*/
|
|
463
|
+
export function contentDisposition(type, filename) {
|
|
464
|
+
const ascii = filename.replace(/[^\x20-\x7e]/gu, "_").replace(/["\\]/g, "_");
|
|
465
|
+
const exact = encodeURIComponent(filename).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
466
|
+
return `${type}; filename="${ascii}"; filename*=UTF-8''${exact}`;
|
|
467
|
+
}
|
|
387
468
|
/**
|
|
388
469
|
* The driver in force. `fs` unless S3 is both selected and configured —
|
|
389
470
|
* a half-set environment falls back rather than failing at the first upload,
|
|
@@ -518,8 +599,29 @@ async function collect(tenant, workspace, sha) {
|
|
|
518
599
|
}
|
|
519
600
|
}
|
|
520
601
|
/** A link the browser can follow, or null when the route must stream it. */
|
|
521
|
-
export async function downloadUrl(tenant, workspace, record) {
|
|
522
|
-
|
|
602
|
+
export async function downloadUrl(tenant, workspace, record, opts = {}) {
|
|
603
|
+
const inlineType = opts.inline ? previewType(record.path) ?? undefined : undefined;
|
|
604
|
+
return driverFor(tenant, workspace).presignGet(blobKey(tenant, workspace, record.sha), path.basename(record.path), record.mime, 300, inlineType);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* The types a stored file may be shown as, rather than saved: pictures,
|
|
608
|
+
* video, audio and PDF, by extension. None of them runs script in a page —
|
|
609
|
+
* SVG is left out for exactly that reason, and so is anything HTML-shaped.
|
|
610
|
+
* Null means "download it".
|
|
611
|
+
*/
|
|
612
|
+
const PREVIEW_TYPES = {
|
|
613
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".jfif": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif",
|
|
614
|
+
".bmp": "image/bmp", ".ico": "image/x-icon", ".cur": "image/x-icon", ".apng": "image/apng",
|
|
615
|
+
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".m4v": "video/mp4", ".mkv": "video/x-matroska",
|
|
616
|
+
".ogv": "video/ogg", ".3gp": "video/3gpp", ".3gpp": "video/3gpp", ".3g2": "video/3gpp2", ".mpg": "video/mpeg", ".mpeg": "video/mpeg", ".avi": "video/x-msvideo",
|
|
617
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4", ".m4r": "audio/mp4", ".m4b": "audio/mp4", ".aac": "audio/aac",
|
|
618
|
+
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg", ".flac": "audio/flac", ".aif": "audio/aiff", ".aiff": "audio/aiff", ".weba": "audio/webm",
|
|
619
|
+
".pdf": "application/pdf", ".ai": "application/pdf",
|
|
620
|
+
".ttf": "font/ttf", ".otf": "font/otf", ".woff": "font/woff", ".woff2": "font/woff2",
|
|
621
|
+
};
|
|
622
|
+
const PREVIEW_TYPES_SET = new Set(Object.values(PREVIEW_TYPES));
|
|
623
|
+
export function previewType(rel) {
|
|
624
|
+
return PREVIEW_TYPES[path.extname(rel).toLowerCase()] ?? null;
|
|
523
625
|
}
|
|
524
626
|
/** A link the browser can PUT to, plus the key it will land at. Null on fs. */
|
|
525
627
|
export async function uploadUrl(tenant, workspace, rel, sha) {
|