@foldrun/core 0.4.0 → 0.6.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.
Files changed (170) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/browser-pod.d.ts +148 -0
  9. package/dist/src/browser-pod.js +333 -0
  10. package/dist/src/catalog.js +2 -1
  11. package/dist/src/clock.d.ts +49 -0
  12. package/dist/src/clock.js +121 -0
  13. package/dist/src/completions.js +17 -8
  14. package/dist/src/confine.d.ts +22 -0
  15. package/dist/src/confine.js +218 -3
  16. package/dist/src/container.d.ts +9 -0
  17. package/dist/src/container.js +115 -5
  18. package/dist/src/context-tools.d.ts +1 -1
  19. package/dist/src/deploy.d.ts +33 -0
  20. package/dist/src/deploy.js +145 -6
  21. package/dist/src/egress.d.ts +7 -0
  22. package/dist/src/egress.js +6 -0
  23. package/dist/src/eol.d.ts +2 -0
  24. package/dist/src/eol.js +7 -0
  25. package/dist/src/evals.d.ts +5 -0
  26. package/dist/src/evals.js +9 -2
  27. package/dist/src/flow-lint.d.ts +8 -0
  28. package/dist/src/flow-lint.js +223 -1
  29. package/dist/src/flow-patterns.d.ts +141 -0
  30. package/dist/src/flow-patterns.js +568 -0
  31. package/dist/src/gitrepo.d.ts +18 -0
  32. package/dist/src/gitrepo.js +49 -0
  33. package/dist/src/host-env.js +5 -0
  34. package/dist/src/kinds.js +1 -1
  35. package/dist/src/language.d.ts +17 -0
  36. package/dist/src/language.js +55 -0
  37. package/dist/src/layout.d.ts +69 -0
  38. package/dist/src/layout.js +200 -0
  39. package/dist/src/library.d.ts +9 -0
  40. package/dist/src/library.js +30 -3
  41. package/dist/src/limits.d.ts +84 -0
  42. package/dist/src/limits.js +287 -0
  43. package/dist/src/locale.d.ts +47 -0
  44. package/dist/src/locale.js +177 -0
  45. package/dist/src/mail.d.ts +88 -0
  46. package/dist/src/mail.js +247 -0
  47. package/dist/src/model-credential.d.ts +14 -0
  48. package/dist/src/model-credential.js +58 -0
  49. package/dist/src/model-probe.d.ts +3 -0
  50. package/dist/src/model-probe.js +1 -0
  51. package/dist/src/notify.d.ts +53 -7
  52. package/dist/src/notify.js +194 -126
  53. package/dist/src/openapi.js +5 -3
  54. package/dist/src/operator.d.ts +201 -0
  55. package/dist/src/operator.js +251 -0
  56. package/dist/src/paths.d.ts +28 -0
  57. package/dist/src/paths.js +55 -0
  58. package/dist/src/platform.d.ts +43 -3
  59. package/dist/src/platform.js +9 -1
  60. package/dist/src/provider-health.js +3 -2
  61. package/dist/src/providers.d.ts +481 -0
  62. package/dist/src/providers.js +1105 -6
  63. package/dist/src/refusal.d.ts +83 -0
  64. package/dist/src/refusal.js +281 -0
  65. package/dist/src/run-container.d.ts +149 -13
  66. package/dist/src/run-container.js +424 -43
  67. package/dist/src/runner.d.ts +177 -9
  68. package/dist/src/runner.js +1146 -154
  69. package/dist/src/runtime.d.ts +52 -0
  70. package/dist/src/runtime.js +493 -58
  71. package/dist/src/script-tools.d.ts +17 -2
  72. package/dist/src/script-tools.js +82 -10
  73. package/dist/src/secret-files.d.ts +6 -5
  74. package/dist/src/secret-files.js +24 -11
  75. package/dist/src/secret-health.d.ts +30 -6
  76. package/dist/src/secret-health.js +42 -20
  77. package/dist/src/secrets.d.ts +19 -0
  78. package/dist/src/secrets.js +155 -86
  79. package/dist/src/starter.d.ts +9 -0
  80. package/dist/src/starter.js +92 -11
  81. package/dist/src/step-exec.d.ts +72 -2
  82. package/dist/src/step-exec.js +431 -37
  83. package/dist/src/step-removal.d.ts +15 -0
  84. package/dist/src/step-removal.js +49 -0
  85. package/dist/src/storage.d.ts +23 -4
  86. package/dist/src/storage.js +125 -23
  87. package/dist/src/store.d.ts +222 -37
  88. package/dist/src/store.js +349 -62
  89. package/dist/src/subagents.d.ts +75 -0
  90. package/dist/src/subagents.js +149 -0
  91. package/dist/src/test-mode.d.ts +17 -2
  92. package/dist/src/test-mode.js +53 -13
  93. package/dist/src/tool-names.d.ts +13 -1
  94. package/dist/src/tool-names.js +38 -11
  95. package/dist/src/tool-programs.d.ts +26 -0
  96. package/dist/src/tool-programs.js +172 -0
  97. package/dist/src/tool-test.js +104 -12
  98. package/dist/src/translator.d.ts +8 -2
  99. package/dist/src/translator.js +69 -23
  100. package/dist/src/trigger-log.d.ts +1 -1
  101. package/dist/src/triggers.d.ts +3 -0
  102. package/dist/src/triggers.js +10 -2
  103. package/dist/src/untrusted-fetch.d.ts +11 -0
  104. package/dist/src/untrusted-fetch.js +77 -0
  105. package/dist/src/web-actions.d.ts +91 -0
  106. package/dist/src/web-actions.js +199 -0
  107. package/dist/src/webhook.d.ts +4 -0
  108. package/dist/src/webhook.js +39 -0
  109. package/index.ts +4 -0
  110. package/package.json +3 -3
  111. package/src/account.ts +149 -0
  112. package/src/api-tools.ts +32 -4
  113. package/src/browser-pod.ts +350 -0
  114. package/src/catalog.ts +2 -1
  115. package/src/clock.ts +149 -0
  116. package/src/completions.ts +17 -8
  117. package/src/confine.ts +218 -3
  118. package/src/container.ts +112 -5
  119. package/src/context-tools.ts +1 -1
  120. package/src/deploy.ts +137 -6
  121. package/src/egress.ts +13 -1
  122. package/src/eol.ts +7 -0
  123. package/src/evals.ts +14 -2
  124. package/src/flow-lint.ts +253 -1
  125. package/src/flow-patterns.ts +597 -0
  126. package/src/gitrepo.ts +48 -0
  127. package/src/host-env.ts +5 -0
  128. package/src/kinds.ts +1 -1
  129. package/src/language.ts +62 -0
  130. package/src/layout.ts +228 -0
  131. package/src/library.ts +37 -3
  132. package/src/limits.ts +290 -0
  133. package/src/locale.ts +217 -0
  134. package/src/mail.ts +318 -0
  135. package/src/model-credential.ts +71 -0
  136. package/src/model-probe.ts +4 -0
  137. package/src/notify.ts +189 -103
  138. package/src/openapi.ts +5 -3
  139. package/src/operator.ts +300 -0
  140. package/src/paths.ts +54 -0
  141. package/src/platform.ts +52 -3
  142. package/src/provider-health.ts +3 -2
  143. package/src/providers.ts +1368 -6
  144. package/src/refusal.ts +320 -0
  145. package/src/run-container.ts +460 -44
  146. package/src/runner.ts +1168 -155
  147. package/src/runtime.ts +487 -55
  148. package/src/script-tools.ts +81 -10
  149. package/src/secret-files.ts +22 -10
  150. package/src/secret-health.ts +64 -19
  151. package/src/secrets.ts +149 -87
  152. package/src/starter.ts +93 -11
  153. package/src/step-exec.ts +447 -38
  154. package/src/step-removal.ts +59 -0
  155. package/src/storage.ts +141 -22
  156. package/src/store.ts +506 -70
  157. package/src/subagents.ts +189 -0
  158. package/src/test-mode.ts +51 -11
  159. package/src/tool-names.ts +40 -11
  160. package/src/tool-programs.ts +179 -0
  161. package/src/tool-test.ts +110 -12
  162. package/src/translator.ts +72 -22
  163. package/src/trigger-log.ts +1 -0
  164. package/src/triggers.ts +10 -2
  165. package/src/untrusted-fetch.ts +88 -0
  166. package/src/web-actions.ts +237 -0
  167. package/src/webhook.ts +41 -0
  168. package/templates/blog-desk/agents/writer/agent.md +1 -1
  169. package/templates/hello/agents/curator/agent.md +4 -4
  170. package/templates/hello/agents/notetaker/agent.md +3 -3
@@ -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
- presignGet(key: string, filename: string, mime: string, ttlSec?: number): Promise<string | null>;
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
- pathStyle: boolean;
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,18 +316,65 @@ 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 || !accessKeyId || !secretAccessKey) return null;
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 callers set their own.
298
- region: process.env.FOLDRUN_S3_REGION ?? "auto",
299
- accessKeyId,
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
 
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
+
305
378
  // Narrower than crypto.BinaryLike on purpose. BinaryLike also covers views
306
379
  // over a SharedArrayBuffer, which .update() has never accepted and which
307
380
  // @types/node 26 finally says so about — the old typings let this compile by
@@ -326,8 +399,8 @@ function stamps(now: Date) {
326
399
  return { amzDate, dateStamp: amzDate.slice(0, 8) };
327
400
  }
328
401
 
329
- function signingKey(cfg: S3Config, dateStamp: string) {
330
- const kDate = hmac(`AWS4${cfg.secretAccessKey}`, dateStamp);
402
+ function signingKey(cfg: S3Config, creds: Credentials, dateStamp: string) {
403
+ const kDate = hmac(`AWS4${creds.secretAccessKey}`, dateStamp);
331
404
  const kRegion = hmac(kDate, cfg.region);
332
405
  const kService = hmac(kRegion, "s3");
333
406
  return hmac(kService, "aws4_request");
@@ -356,6 +429,7 @@ function target(cfg: S3Config, key: string) {
356
429
  */
357
430
  function presign(
358
431
  cfg: S3Config,
432
+ creds: Credentials,
359
433
  method: "GET" | "PUT",
360
434
  key: string,
361
435
  ttlSec: number,
@@ -369,10 +443,13 @@ function presign(
369
443
  const query: Record<string, string> = {
370
444
  ...extraQuery,
371
445
  "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
372
- "X-Amz-Credential": `${cfg.accessKeyId}/${scope}`,
446
+ "X-Amz-Credential": `${creds.accessKeyId}/${scope}`,
373
447
  "X-Amz-Date": amzDate,
374
448
  "X-Amz-Expires": String(ttlSec),
375
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 } : {}),
376
453
  };
377
454
  const canonicalQuery = Object.keys(query)
378
455
  .sort()
@@ -395,7 +472,7 @@ function presign(
395
472
  sha256(canonicalRequest),
396
473
  ].join("\n");
397
474
 
398
- const signature = hmac(signingKey(cfg, dateStamp), stringToSign).toString("hex");
475
+ const signature = hmac(signingKey(cfg, creds, dateStamp), stringToSign).toString("hex");
399
476
  return `${origin}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
400
477
  }
401
478
 
@@ -407,6 +484,7 @@ async function s3Request(
407
484
  body?: Buffer,
408
485
  mime?: string,
409
486
  ): Promise<Response> {
487
+ const creds = await credentials(cfg);
410
488
  const { host, origin, canonicalUri } = target(cfg, key);
411
489
  const { amzDate, dateStamp } = stamps(new Date());
412
490
  const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
@@ -416,6 +494,7 @@ async function s3Request(
416
494
  host,
417
495
  "x-amz-content-sha256": payloadHash,
418
496
  "x-amz-date": amzDate,
497
+ ...(creds.sessionToken ? { "x-amz-security-token": creds.sessionToken } : {}),
419
498
  ...(mime ? { "content-type": mime } : {}),
420
499
  };
421
500
  const signed = Object.keys(headers).sort();
@@ -432,7 +511,7 @@ async function s3Request(
432
511
  ].join("\n");
433
512
 
434
513
  const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonicalRequest)].join("\n");
435
- const signature = hmac(signingKey(cfg, dateStamp), stringToSign).toString("hex");
514
+ const signature = hmac(signingKey(cfg, creds, dateStamp), stringToSign).toString("hex");
436
515
 
437
516
  const res = await fetch(`${origin}${canonicalUri}`, {
438
517
  method,
@@ -440,7 +519,7 @@ async function s3Request(
440
519
  headers: {
441
520
  ...headers,
442
521
  Authorization:
443
- `AWS4-HMAC-SHA256 Credential=${cfg.accessKeyId}/${scope}, ` +
522
+ `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, ` +
444
523
  `SignedHeaders=${signedHeaders}, Signature=${signature}`,
445
524
  },
446
525
  });
@@ -471,19 +550,34 @@ function s3Driver(cfg: S3Config): Driver {
471
550
  // 404 on delete is the desired end state, not an error.
472
551
  if (!res.ok && res.status !== 404) await fail(res, "delete");
473
552
  },
474
- async presignGet(key, filename, mime, ttlSec = 300) {
475
- return presign(cfg, "GET", key, ttlSec, {
476
- // Never inline. See presign()'s note — this is the XSS boundary.
477
- "response-content-disposition": `attachment; filename="${filename.replace(/["\\]/g, "")}"`,
478
- "response-content-type": mime,
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,
479
561
  });
480
562
  },
481
563
  async presignPut(key, _mime, ttlSec = 600) {
482
- return presign(cfg, "PUT", key, ttlSec);
564
+ return presign(cfg, await credentials(cfg), "PUT", key, ttlSec);
483
565
  },
484
566
  };
485
567
  }
486
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
+
487
581
  /**
488
582
  * The driver in force. `fs` unless S3 is both selected and configured —
489
583
  * a half-set environment falls back rather than failing at the first upload,
@@ -646,14 +740,39 @@ export async function downloadUrl(
646
740
  tenant: string,
647
741
  workspace: string,
648
742
  record: FileRecord,
743
+ opts: { inline?: boolean } = {},
649
744
  ): Promise<string | null> {
745
+ const inlineType = opts.inline ? previewType(record.path) ?? undefined : undefined;
650
746
  return driverFor(tenant, workspace).presignGet(
651
747
  blobKey(tenant, workspace, record.sha),
652
748
  path.basename(record.path),
653
749
  record.mime,
750
+ 300,
751
+ inlineType,
654
752
  );
655
753
  }
656
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
+
657
776
  /** A link the browser can PUT to, plus the key it will land at. Null on fs. */
658
777
  export async function uploadUrl(
659
778
  tenant: string,