@pikku/core 0.12.137 → 0.12.139

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 (78) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/dev/hot-reload.js +5 -2
  3. package/dist/env.d.ts +18 -0
  4. package/dist/env.js +60 -1
  5. package/dist/function/abort-scope.d.ts +6 -5
  6. package/dist/function/abort-scope.js +6 -17
  7. package/dist/function/function-runner.js +2 -1
  8. package/dist/middleware/addon-scope-cap.d.ts +19 -0
  9. package/dist/middleware/addon-scope-cap.js +34 -0
  10. package/dist/middleware/auth-apikey.js +1 -1
  11. package/dist/middleware/auth-bearer.js +1 -1
  12. package/dist/middleware/auth-cookie.js +1 -1
  13. package/dist/middleware/index.d.ts +1 -0
  14. package/dist/middleware/index.js +1 -0
  15. package/dist/schema.js +3 -1
  16. package/dist/scopes.d.ts +8 -0
  17. package/dist/scopes.js +23 -0
  18. package/dist/services/content-service.d.ts +13 -1
  19. package/dist/services/in-memory-queue-service.js +4 -2
  20. package/dist/services/in-memory-workflow-service.js +7 -6
  21. package/dist/services/index.d.ts +2 -2
  22. package/dist/services/index.js +1 -1
  23. package/dist/services/local-content-request-handler.js +21 -60
  24. package/dist/services/local-content.d.ts +45 -1
  25. package/dist/services/local-content.js +202 -18
  26. package/dist/services/local-variables.d.ts +5 -0
  27. package/dist/services/local-variables.js +7 -2
  28. package/dist/services/queue-webhook-service.js +1 -1
  29. package/dist/services/scoped-content-service.d.ts +35 -0
  30. package/dist/services/scoped-content-service.js +171 -0
  31. package/dist/services/webhook-service.d.ts +2 -2
  32. package/dist/services/webhook-service.js +6 -4
  33. package/dist/types/core.types.d.ts +10 -1
  34. package/dist/types/state.types.d.ts +8 -0
  35. package/dist/utils/hmac.d.ts +25 -10
  36. package/dist/utils/hmac.js +202 -24
  37. package/dist/wirings/addon/addon-reach.d.ts +3 -0
  38. package/dist/wirings/addon/addon-reach.js +21 -0
  39. package/dist/wirings/addon/addon-runner.d.ts +6 -0
  40. package/dist/wirings/addon/addon-runner.js +13 -0
  41. package/dist/wirings/addon/define-screens.d.ts +32 -0
  42. package/dist/wirings/addon/define-screens.js +5 -0
  43. package/dist/wirings/addon/index.d.ts +2 -0
  44. package/dist/wirings/addon/index.js +1 -0
  45. package/dist/wirings/addon/wire-addon.d.ts +27 -0
  46. package/dist/wirings/addon/wire-addon.js +4 -0
  47. package/dist/wirings/agent/agent-agui.js +65 -62
  48. package/dist/wirings/agent/agent-interrupt.d.ts +4 -1
  49. package/dist/wirings/agent/agent-interrupt.js +3 -3
  50. package/dist/wirings/agent/agent-memory.js +5 -6
  51. package/dist/wirings/agent/agent-model-config.js +2 -1
  52. package/dist/wirings/agent/agent-prepare.js +24 -13
  53. package/dist/wirings/agent/agent-stream.js +38 -34
  54. package/dist/wirings/agent/agent.types.d.ts +8 -1
  55. package/dist/wirings/channel/channel-handler.js +1 -1
  56. package/dist/wirings/channel/local/local-channel-handler.js +5 -1
  57. package/dist/wirings/channel/local/local-channel-runner.js +6 -4
  58. package/dist/wirings/channel/local/local-eventhub-service.d.ts +1 -0
  59. package/dist/wirings/channel/local/local-eventhub-service.js +4 -1
  60. package/dist/wirings/cli/channel/cli-channel-runner.js +7 -2
  61. package/dist/wirings/cli/channel/cli-raw-client-runner.js +8 -4
  62. package/dist/wirings/cli/cli-runner.js +1 -1
  63. package/dist/wirings/gateway/gateway-runner.js +3 -3
  64. package/dist/wirings/http/http-runner.js +6 -6
  65. package/dist/wirings/rpc/rpc-runner.d.ts +4 -0
  66. package/dist/wirings/rpc/rpc-runner.js +41 -2
  67. package/dist/wirings/rpc/rpc-types.d.ts +6 -0
  68. package/dist/wirings/scheduler/scheduler-runner.js +1 -1
  69. package/dist/wirings/scope/define-scope.d.ts +19 -0
  70. package/dist/wirings/scope/define-scope.js +19 -0
  71. package/dist/wirings/scope/index.d.ts +2 -2
  72. package/dist/wirings/scope/index.js +2 -2
  73. package/dist/wirings/trigger/trigger-runner.js +3 -1
  74. package/dist/wirings/trigger/webhook-source-runner.js +5 -2
  75. package/dist/wirings/virtual-user/virtual-user-scaffold.js +3 -2
  76. package/dist/wirings/workflow/workflow-invocation-id.js +85 -8
  77. package/package.json +3 -1
  78. package/src/public-surface.json +65 -18
@@ -1,37 +1,9 @@
1
1
  import { createReadStream } from 'fs';
2
- import { mkdir, stat, writeFile } from 'fs/promises';
3
- import { normalize, resolve } from 'path';
2
+ import { stat } from 'fs/promises';
4
3
  import { Readable } from 'stream';
5
- import { signedContentPath } from './local-content.js';
4
+ import { UploadTooLargeError, parseContentSizeLimit, resolveContentRequestTarget, signedContentPath, streamUploadToFile, } from './local-content.js';
6
5
  const matchesPrefix = (pathname, prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`);
7
6
  const contentKey = (pathname, prefix) => pathname.slice(prefix.length).replace(/^\/+/, '');
8
- /**
9
- * Resolve a key against the content root, or `null` if it escapes. `normalize`
10
- * first so `..` segments are collapsed before the prefix check, and the
11
- * comparison carries a trailing separator so a sibling directory whose name
12
- * merely starts with the root's cannot pass as being inside it.
13
- */
14
- const toTargetPath = (basePath, key) => {
15
- const normalizedBasePath = resolve(basePath);
16
- const targetPath = resolve(normalizedBasePath, normalize(key));
17
- return targetPath.startsWith(`${normalizedBasePath}/`) ? targetPath : null;
18
- };
19
- const parseSizeLimit = (sizeLimit) => {
20
- const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(sizeLimit.trim());
21
- if (!match) {
22
- throw new Error(`Invalid size limit: ${sizeLimit}`);
23
- }
24
- const value = Number(match[1]);
25
- const unit = (match[2] ?? 'b').toLowerCase();
26
- const multiplier = unit === 'gb'
27
- ? 1024 * 1024 * 1024
28
- : unit === 'mb'
29
- ? 1024 * 1024
30
- : unit === 'kb'
31
- ? 1024
32
- : 1;
33
- return value * multiplier;
34
- };
35
7
  const text = (status, body) => new Response(body, {
36
8
  status,
37
9
  headers: { 'content-type': 'text/plain; charset=utf-8' },
@@ -112,47 +84,36 @@ export const createLocalContentRequestHandler = ({ content, logger, getJWT, }) =
112
84
  return text(signed.status, signed.body);
113
85
  }
114
86
  const key = contentKey(pathname, content.uploadUrlPrefix);
115
- const targetPath = toTargetPath(content.localFileUploadPath, key);
116
- if (!targetPath) {
87
+ const target = await resolveContentRequestTarget(content.localFileUploadPath, key, 'write');
88
+ if (!target) {
117
89
  return text(400, 'Invalid path');
118
90
  }
119
- const maxBytes = parseSizeLimit(content.sizeLimit ?? '1mb');
120
- // knowledge: decisions/security/an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md
121
- const chunks = [];
122
- let bytesRead = 0;
123
- const reader = request.body?.getReader();
124
- if (reader) {
125
- try {
126
- for (;;) {
127
- const { done, value } = await reader.read();
128
- if (done)
129
- break;
130
- bytesRead += value.byteLength;
131
- if (bytesRead > maxBytes) {
132
- await reader.cancel();
133
- return text(413, 'Content too large');
134
- }
135
- chunks.push(Buffer.from(value));
136
- }
137
- }
138
- finally {
139
- reader.releaseLock();
91
+ try {
92
+ await streamUploadToFile(request.body
93
+ ? Readable.fromWeb(request.body)
94
+ : (async function* () { })(), target.path, parseContentSizeLimit(content.sizeLimit ?? '1mb'));
95
+ }
96
+ catch (error) {
97
+ if (error instanceof UploadTooLargeError) {
98
+ return text(413, 'Content too large');
140
99
  }
100
+ throw error;
141
101
  }
142
- await mkdir(resolve(targetPath, '..'), { recursive: true });
143
- await writeFile(targetPath, Buffer.concat(chunks));
144
102
  return new Response(null, { status: 200 });
145
103
  };
146
104
  const handleAsset = async (request, requestUrl, pathname) => {
147
105
  const key = contentKey(pathname, content.assetUrlPrefix);
148
- const targetPath = toTargetPath(content.localFileUploadPath, key);
149
- if (!targetPath) {
106
+ const target = await resolveContentRequestTarget(content.localFileUploadPath, key, 'read');
107
+ if (!target) {
150
108
  return text(400, 'Invalid path');
151
109
  }
152
- const signed = await validateSignedAssetRequest(requestUrl);
153
- if (!signed.ok) {
154
- return text(signed.status, signed.body);
110
+ if (target.visibility === 'private') {
111
+ const signed = await validateSignedAssetRequest(requestUrl);
112
+ if (!signed.ok) {
113
+ return text(signed.status, signed.body);
114
+ }
155
115
  }
116
+ const targetPath = target.path;
156
117
  try {
157
118
  const file = await stat(targetPath);
158
119
  if (!file.isFile()) {
@@ -1,4 +1,4 @@
1
- import type { BucketKeyArgs, ContentService, CopyFileArgs, GetUploadURLArgs, JWTService, Logger, SignContentKeyArgs, SignURLArgs, UploadURLResult, WriteFileArgs } from '@pikku/core/services';
1
+ import type { BucketKeyArgs, ContentService, ContentVisibility, GetDownloadURLArgs, CopyFileArgs, GetUploadURLArgs, JWTService, Logger, SignContentKeyArgs, SignURLArgs, UploadURLResult, WriteFileArgs } from '@pikku/core/services';
2
2
  export interface LocalContentConfig {
3
3
  localFileUploadPath: string;
4
4
  uploadUrlPrefix: string;
@@ -17,17 +17,59 @@ export interface LocalContentConfig {
17
17
  * rather than throwing, which keeps both sides in agreement.
18
18
  */
19
19
  export declare const signedContentPath: (urlOrPath: string) => string;
20
+ export declare const PUBLIC_URL_SEGMENT = "_public";
21
+ export declare const CONTENT_VISIBILITIES: readonly ContentVisibility[];
22
+ export type ContentUrlPath = {
23
+ visibility: ContentVisibility;
24
+ rest: string;
25
+ prefixed: boolean;
26
+ };
27
+ /**
28
+ * Splits the part of a content URL after the asset/upload prefix into the
29
+ * visibility it names and the `<bucket>/<key>` remainder. Only a leading
30
+ * `_public/` segment names the public side; anything else is private.
31
+ * Private buckets may therefore not be named `_public`.
32
+ */
33
+ export declare const parseContentUrlPath: (key: string) => ContentUrlPath;
34
+ /**
35
+ * Resolves `<bucket>/<key>` against one side of the store. The side is a
36
+ * directory under the content root, and both the bucket and the key must stay
37
+ * inside it, so neither side can climb out or reach the other.
38
+ */
39
+ export declare const resolveVisibilityPath: (basePath: string, visibility: ContentVisibility, bucket: string, key: string) => string | null;
40
+ export type ContentRequestTarget = {
41
+ path: string;
42
+ visibility: ContentVisibility;
43
+ };
44
+ /**
45
+ * Maps the part of a content URL after the asset/upload prefix to a file under
46
+ * the content root: `_public/...` to the public side, anything else to the
47
+ * private side, falling back for reads to an unprefixed file from before the
48
+ * split. Returns `null` for a path that escapes its side or names no file.
49
+ * Every server of local content resolves through here, and skips the signature
50
+ * check only when the result is public.
51
+ */
52
+ export declare const resolveContentRequestTarget: (basePath: string, key: string, mode: 'read' | 'write') => Promise<ContentRequestTarget | null>;
53
+ export declare class UploadTooLargeError extends Error {
54
+ constructor();
55
+ }
56
+ export declare const parseContentSizeLimit: (sizeLimit: string) => number;
57
+ export declare const streamUploadToFile: (source: AsyncIterable<Uint8Array>, targetPath: string, maxBytes: number) => Promise<number>;
20
58
  export declare class LocalContent implements ContentService {
21
59
  private config;
22
60
  private logger;
23
61
  private jwt;
24
62
  constructor(config: LocalContentConfig, logger: Logger, jwt: JWTService);
25
63
  private safePath;
64
+ private existingPath;
65
+ private legacyPath;
66
+ private publicBase;
26
67
  private joinKey;
27
68
  init(): Promise<void>;
28
69
  private signParams;
29
70
  signURL(args: SignURLArgs): Promise<string>;
30
71
  signContentKey(args: SignContentKeyArgs): Promise<string>;
72
+ getDownloadURL(args: GetDownloadURLArgs): Promise<string>;
31
73
  /**
32
74
  * Presigned upload, mirroring the read side: the URL carries a short-lived,
33
75
  * path-bound signature that {@link createLocalContentRequestHandler} verifies
@@ -40,5 +82,7 @@ export declare class LocalContent implements ContentService {
40
82
  readFile(args: BucketKeyArgs): Promise<ReadableStream | NodeJS.ReadableStream>;
41
83
  readFileAsBuffer(args: BucketKeyArgs): Promise<Buffer>;
42
84
  deleteFile(args: BucketKeyArgs): Promise<boolean>;
85
+ listFilesByPrefix(bucket: string, prefix: string, visibility?: ContentVisibility): Promise<string[]>;
86
+ deleteByPrefix(bucket: string, prefix: string, visibility?: ContentVisibility): Promise<number>;
43
87
  private createDirectoryForFile;
44
88
  }
@@ -1,7 +1,9 @@
1
1
  import { createReadStream, createWriteStream, promises } from 'fs';
2
- import { mkdir, readFile } from 'fs/promises';
3
- import { resolve, normalize } from 'path';
2
+ import { randomUUID } from 'crypto';
3
+ import { mkdir, readFile, readdir, rename, rm, stat } from 'fs/promises';
4
+ import { resolve, normalize, sep } from 'path';
4
5
  import { pipeline } from 'stream/promises';
6
+ import { Transform } from 'stream';
5
7
  /**
6
8
  * The representation of an asset that gets bound into a signed URL's
7
9
  * signature, and that a server can reconstruct from an incoming request:
@@ -27,8 +29,122 @@ export const signedContentPath = (urlOrPath) => {
27
29
  return pathname;
28
30
  }
29
31
  };
32
+ export const PUBLIC_URL_SEGMENT = '_public';
33
+ export const CONTENT_VISIBILITIES = [
34
+ 'private',
35
+ 'public',
36
+ ];
37
+ /**
38
+ * Splits the part of a content URL after the asset/upload prefix into the
39
+ * visibility it names and the `<bucket>/<key>` remainder. Only a leading
40
+ * `_public/` segment names the public side; anything else is private.
41
+ * Private buckets may therefore not be named `_public`.
42
+ */
43
+ export const parseContentUrlPath = (key) => {
44
+ const [first, ...rest] = key.split('/');
45
+ if (first === PUBLIC_URL_SEGMENT) {
46
+ return { visibility: 'public', rest: rest.join('/'), prefixed: true };
47
+ }
48
+ return { visibility: 'private', rest: key, prefixed: false };
49
+ };
50
+ /**
51
+ * Resolves `<bucket>/<key>` against one side of the store. The side is a
52
+ * directory under the content root, and both the bucket and the key must stay
53
+ * inside it, so neither side can climb out or reach the other.
54
+ */
55
+ export const resolveVisibilityPath = (basePath, visibility, bucket, key) => {
56
+ if (!CONTENT_VISIBILITIES.includes(visibility))
57
+ return null;
58
+ if (bucket.includes('\0') || key.includes('\0'))
59
+ return null;
60
+ if (visibility === 'private') {
61
+ const [first] = normalize(bucket).split(/[\\/]/);
62
+ if (first === PUBLIC_URL_SEGMENT)
63
+ return null;
64
+ }
65
+ const root = resolve(resolve(basePath), visibility);
66
+ const scoped = resolve(root, normalize(bucket));
67
+ const target = resolve(scoped, normalize(key));
68
+ const inside = (path) => path === root || path.startsWith(root + sep);
69
+ if (!inside(scoped) || !inside(target))
70
+ return null;
71
+ return target;
72
+ };
73
+ /**
74
+ * Maps the part of a content URL after the asset/upload prefix to a file under
75
+ * the content root: `_public/...` to the public side, anything else to the
76
+ * private side, falling back for reads to an unprefixed file from before the
77
+ * split. Returns `null` for a path that escapes its side or names no file.
78
+ * Every server of local content resolves through here, and skips the signature
79
+ * check only when the result is public.
80
+ */
81
+ export const resolveContentRequestTarget = async (basePath, key, mode) => {
82
+ const parsed = parseContentUrlPath(key);
83
+ const root = resolve(resolve(basePath), parsed.visibility);
84
+ const path = resolveVisibilityPath(basePath, parsed.visibility, parsed.rest, '');
85
+ if (!path || path === root)
86
+ return null;
87
+ if (mode === 'read' && !parsed.prefixed) {
88
+ const exists = await stat(path).then(() => true, () => false);
89
+ if (!exists) {
90
+ const [first] = normalize(parsed.rest).split(/[\\/]/);
91
+ const base = resolve(basePath);
92
+ const legacy = resolve(base, normalize(parsed.rest));
93
+ if (first !== PUBLIC_URL_SEGMENT &&
94
+ first !== 'private' &&
95
+ legacy.startsWith(base + sep)) {
96
+ return { path: legacy, visibility: 'private' };
97
+ }
98
+ }
99
+ }
100
+ return { path, visibility: parsed.visibility };
101
+ };
30
102
  /** How long a presigned upload URL stays valid. Short, like an S3 PUT URL. */
31
103
  const UPLOAD_URL_TTL_MS = 15 * 60 * 1000;
104
+ export class UploadTooLargeError extends Error {
105
+ constructor() {
106
+ super('content_too_large');
107
+ }
108
+ }
109
+ export const parseContentSizeLimit = (sizeLimit) => {
110
+ const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(sizeLimit.trim());
111
+ if (!match) {
112
+ throw new Error(`Invalid size limit: ${sizeLimit}`);
113
+ }
114
+ const unit = (match[2] ?? 'b').toLowerCase();
115
+ const multiplier = unit === 'gb'
116
+ ? 1024 * 1024 * 1024
117
+ : unit === 'mb'
118
+ ? 1024 * 1024
119
+ : unit === 'kb'
120
+ ? 1024
121
+ : 1;
122
+ return Number(match[1]) * multiplier;
123
+ };
124
+ export const streamUploadToFile = async (source, targetPath, maxBytes) => {
125
+ await mkdir(resolve(targetPath, '..'), { recursive: true });
126
+ const partial = `${targetPath}.${process.pid}.${Date.now()}.${randomUUID()}.part`;
127
+ let bytes = 0;
128
+ const counter = new Transform({
129
+ transform(chunk, _encoding, callback) {
130
+ bytes += chunk.length;
131
+ if (bytes > maxBytes) {
132
+ callback(new UploadTooLargeError());
133
+ return;
134
+ }
135
+ callback(null, chunk);
136
+ },
137
+ });
138
+ try {
139
+ await pipeline(source, counter, createWriteStream(partial));
140
+ await rename(partial, targetPath);
141
+ return bytes;
142
+ }
143
+ catch (error) {
144
+ await rm(partial, { force: true });
145
+ throw error;
146
+ }
147
+ };
32
148
  export class LocalContent {
33
149
  config;
34
150
  logger;
@@ -41,15 +157,39 @@ export class LocalContent {
41
157
  throw new Error('LocalContent requires a JWTService: without one it cannot sign asset URLs, and unsigned URLs cannot be verified.');
42
158
  }
43
159
  }
44
- safePath(bucket, key) {
45
- const base = resolve(this.config.localFileUploadPath);
46
- const scoped = resolve(base, normalize(bucket));
47
- const target = resolve(scoped, normalize(key));
48
- if (!target.startsWith(base + '/') && target !== base) {
160
+ safePath(bucket, key, visibility = 'private') {
161
+ const target = resolveVisibilityPath(this.config.localFileUploadPath, visibility, bucket, key);
162
+ if (!target) {
49
163
  throw new Error('Invalid asset key');
50
164
  }
51
165
  return target;
52
166
  }
167
+ async existingPath(bucket, key, visibility = 'private') {
168
+ const path = this.safePath(bucket, key, visibility);
169
+ if (visibility === 'private') {
170
+ const exists = await stat(path).then(() => true, () => false);
171
+ if (!exists) {
172
+ const legacy = this.legacyPath(bucket, key);
173
+ if (legacy &&
174
+ (await stat(legacy).then(() => true, () => false))) {
175
+ return legacy;
176
+ }
177
+ }
178
+ }
179
+ return path;
180
+ }
181
+ legacyPath(bucket, key) {
182
+ const [first] = normalize(bucket).split(/[\\/]/);
183
+ if (first === PUBLIC_URL_SEGMENT || first === 'private')
184
+ return null;
185
+ const base = resolve(this.config.localFileUploadPath);
186
+ const target = resolve(base, normalize(bucket), normalize(key));
187
+ return target.startsWith(base + sep) ? target : null;
188
+ }
189
+ publicBase() {
190
+ const origin = this.config.server ?? '';
191
+ return `${origin}${this.config.assetUrlPrefix}/${PUBLIC_URL_SEGMENT}`;
192
+ }
53
193
  joinKey(bucket, key) {
54
194
  return `${bucket}/${key}`;
55
195
  }
@@ -82,16 +222,27 @@ export class LocalContent {
82
222
  return `${args.url}?${params}`;
83
223
  }
84
224
  async signContentKey(args) {
225
+ const visibility = args.visibility ?? 'private';
226
+ this.safePath(args.bucket, args.contentKey, visibility);
85
227
  const fullKey = this.joinKey(args.bucket, args.contentKey);
86
- const base = this.config.server
87
- ? `${this.config.server}${this.config.assetUrlPrefix}/${fullKey}`
88
- : `${this.config.assetUrlPrefix}/${fullKey}`;
228
+ const origin = this.config.server ?? '';
229
+ if (visibility === 'public') {
230
+ return `${this.publicBase()}/${fullKey}`;
231
+ }
89
232
  return this.signURL({
90
- url: base,
233
+ url: `${origin}${this.config.assetUrlPrefix}/${fullKey}`,
91
234
  dateLessThan: args.dateLessThan,
92
235
  dateGreaterThan: args.dateGreaterThan,
93
236
  });
94
237
  }
238
+ async getDownloadURL(args) {
239
+ return this.signContentKey({
240
+ bucket: args.bucket,
241
+ contentKey: args.key,
242
+ visibility: args.visibility,
243
+ dateLessThan: new Date(Date.now() + (args.expiresInSeconds ?? 3600) * 1000),
244
+ });
245
+ }
95
246
  /**
96
247
  * Presigned upload, mirroring the read side: the URL carries a short-lived,
97
248
  * path-bound signature that {@link createLocalContentRequestHandler} verifies
@@ -99,11 +250,12 @@ export class LocalContent {
99
250
  * now refuses one — so this must sign, or every upload 403s.
100
251
  */
101
252
  async getUploadURL(args) {
102
- void args.visibility;
253
+ const visibility = args.visibility ?? 'private';
254
+ this.safePath(args.bucket, args.fileKey, visibility);
103
255
  const fullKey = this.joinKey(args.bucket, args.fileKey);
104
256
  this.logger.debug(`Going to upload with key: ${fullKey}`);
105
257
  const uploadUrl = await this.signURL({
106
- url: `${this.config.uploadUrlPrefix}/${fullKey}`,
258
+ url: `${this.config.uploadUrlPrefix}/${visibility === 'public' ? `${PUBLIC_URL_SEGMENT}/` : ''}${fullKey}`,
107
259
  dateLessThan: new Date(Date.now() + UPLOAD_URL_TTL_MS),
108
260
  });
109
261
  return {
@@ -114,7 +266,7 @@ export class LocalContent {
114
266
  }
115
267
  async writeFile(args) {
116
268
  this.logger.debug(`Writing file: ${args.bucket}/${args.key}`);
117
- const path = this.safePath(args.bucket, args.key);
269
+ const path = this.safePath(args.bucket, args.key, args.visibility);
118
270
  try {
119
271
  await this.createDirectoryForFile(path);
120
272
  const fileStream = createWriteStream(path);
@@ -130,7 +282,7 @@ export class LocalContent {
130
282
  async copyFile(args) {
131
283
  this.logger.debug(`Writing file: ${args.bucket}/${args.key}`);
132
284
  try {
133
- const path = this.safePath(args.bucket, args.key);
285
+ const path = this.safePath(args.bucket, args.key, args.visibility);
134
286
  await this.createDirectoryForFile(path);
135
287
  await promises.copyFile(args.fromAbsolutePath, path);
136
288
  return true;
@@ -143,7 +295,7 @@ export class LocalContent {
143
295
  }
144
296
  async readFile(args) {
145
297
  this.logger.debug(`Getting key: ${args.bucket}/${args.key}`);
146
- const filePath = this.safePath(args.bucket, args.key);
298
+ const filePath = await this.existingPath(args.bucket, args.key, args.visibility);
147
299
  try {
148
300
  const stream = createReadStream(filePath);
149
301
  stream.on('error', (err) => {
@@ -157,14 +309,14 @@ export class LocalContent {
157
309
  }
158
310
  }
159
311
  async readFileAsBuffer(args) {
160
- const filePath = this.safePath(args.bucket, args.key);
312
+ const filePath = await this.existingPath(args.bucket, args.key, args.visibility);
161
313
  this.logger.debug(`Reading file as buffer: ${args.bucket}/${args.key}`);
162
314
  return readFile(filePath);
163
315
  }
164
316
  async deleteFile(args) {
165
317
  this.logger.debug(`deleting key: ${args.bucket}/${args.key}`);
166
318
  try {
167
- await promises.unlink(this.safePath(args.bucket, args.key));
319
+ await promises.unlink(this.safePath(args.bucket, args.key, args.visibility));
168
320
  return true;
169
321
  }
170
322
  catch (e) {
@@ -172,6 +324,38 @@ export class LocalContent {
172
324
  }
173
325
  return false;
174
326
  }
327
+ async listFilesByPrefix(bucket, prefix, visibility = 'private') {
328
+ const root = this.safePath(bucket, '', visibility);
329
+ const keys = [];
330
+ const walk = async (dir, rel) => {
331
+ let entries;
332
+ try {
333
+ entries = await readdir(dir, { withFileTypes: true });
334
+ }
335
+ catch {
336
+ return;
337
+ }
338
+ for (const entry of entries) {
339
+ const next = rel ? `${rel}/${entry.name}` : entry.name;
340
+ if (entry.isDirectory())
341
+ await walk(resolve(dir, entry.name), next);
342
+ else if (entry.isFile() && next.startsWith(prefix))
343
+ keys.push(next);
344
+ }
345
+ };
346
+ this.safePath(bucket, prefix, visibility);
347
+ await walk(root, '');
348
+ return keys.sort();
349
+ }
350
+ async deleteByPrefix(bucket, prefix, visibility = 'private') {
351
+ const keys = await this.listFilesByPrefix(bucket, prefix, visibility);
352
+ let deleted = 0;
353
+ for (const key of keys) {
354
+ if (await this.deleteFile({ bucket, key, visibility }))
355
+ deleted++;
356
+ }
357
+ return deleted;
358
+ }
175
359
  async createDirectoryForFile(path) {
176
360
  const dir = path.split('/').slice(0, -1).join('/');
177
361
  await mkdir(dir, { recursive: true });
@@ -1,6 +1,11 @@
1
1
  import type { VariablesService } from './variables-service.js';
2
2
  export declare class LocalVariablesService implements VariablesService {
3
3
  private variables;
4
+ /**
5
+ * Defaults to `process.env`, resolved when the service is built rather than
6
+ * when this module loads, and guarded so a runtime without `process` gets an
7
+ * empty store instead of a ReferenceError.
8
+ */
4
9
  constructor(variables?: Record<string, string | undefined>);
5
10
  getAll(): Record<string, string | undefined>;
6
11
  getVariables<T extends Record<string, unknown> = Record<string, unknown>>(names: (keyof T & string)[]): Partial<T>;
@@ -1,7 +1,12 @@
1
1
  export class LocalVariablesService {
2
2
  variables;
3
- constructor(variables = process.env) {
4
- this.variables = variables;
3
+ /**
4
+ * Defaults to `process.env`, resolved when the service is built rather than
5
+ * when this module loads, and guarded so a runtime without `process` gets an
6
+ * empty store instead of a ReferenceError.
7
+ */
8
+ constructor(variables) {
9
+ this.variables = variables ?? globalThis.process?.env ?? {};
5
10
  }
6
11
  getAll() {
7
12
  return this.variables || {};
@@ -39,7 +39,7 @@ export class QueueWebhookService extends WebhookService {
39
39
  }
40
40
  if (secret) {
41
41
  const signatureHeader = webhookConfig?.signatureHeader ?? DEFAULT_WEBHOOK_SIGNATURE_HEADER;
42
- headers[signatureHeader] = this.sign(secret, body);
42
+ headers[signatureHeader] = await this.sign(secret, body);
43
43
  }
44
44
  const jobData = {
45
45
  url: input.url,
@@ -0,0 +1,35 @@
1
+ import type { BucketKeyArgs, ContentService, ContentVisibility, CopyFileArgs, GetDownloadURLArgs, GetUploadURLArgs, SignContentKeyArgs, SignURLArgs, UploadURLResult, WriteFileArgs } from './content-service.js';
2
+ export type ContentGrantMode = 'read' | 'write';
3
+ export declare const normaliseContentPath: (value: string) => string;
4
+ /**
5
+ * A `ContentService` narrowed to one folder of the store.
6
+ *
7
+ * Whatever bucket the addon names lands under `<root>/<bucket>`, and the keys
8
+ * and buckets it passes are refused if they climb out. Starting a bucket with
9
+ * `@` names a path from the root of the store instead, which only works under
10
+ * a prefix the host granted: `read` allows reads and signed downloads, `write`
11
+ * adds uploads, writes and deletes, and the longest matching prefix decides.
12
+ * `signURL` and `copyFile` take a finished URL or a local filesystem path, so
13
+ * neither can be narrowed to a folder and both are refused.
14
+ */
15
+ export declare class ScopedContentService implements ContentService {
16
+ private content;
17
+ private root;
18
+ private grants;
19
+ constructor(content: ContentService, root: string, grants?: Record<string, ContentGrantMode>);
20
+ private resolve;
21
+ private resolveClean;
22
+ signContentKey(args: SignContentKeyArgs): Promise<string>;
23
+ signURL(_args: SignURLArgs): Promise<string>;
24
+ getUploadURL(args: GetUploadURLArgs): Promise<UploadURLResult>;
25
+ deleteFile(args: BucketKeyArgs): Promise<boolean>;
26
+ writeFile(args: WriteFileArgs): Promise<boolean>;
27
+ copyFile(_args: CopyFileArgs): Promise<boolean>;
28
+ readFile(args: BucketKeyArgs): Promise<ReadableStream | NodeJS.ReadableStream>;
29
+ readFileAsBuffer(args: BucketKeyArgs): ReturnType<ContentService['readFileAsBuffer']>;
30
+ getDownloadURL(args: GetDownloadURLArgs): Promise<string>;
31
+ deleteByPrefix(bucket: string, prefix: string, visibility?: ContentVisibility): Promise<number>;
32
+ listFilesByPrefix(bucket: string, prefix: string, visibility?: ContentVisibility): Promise<string[]>;
33
+ private keysUnder;
34
+ private resolvePrefix;
35
+ }