@pikku/core 0.12.138 → 0.12.141

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 (50) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/lifecycle.d.ts +6 -0
  3. package/dist/lifecycle.js +58 -0
  4. package/dist/middleware/addon-scope-cap.d.ts +19 -0
  5. package/dist/middleware/addon-scope-cap.js +34 -0
  6. package/dist/middleware/index.d.ts +1 -0
  7. package/dist/middleware/index.js +1 -0
  8. package/dist/scopes.d.ts +8 -0
  9. package/dist/scopes.js +23 -0
  10. package/dist/services/content-service.d.ts +13 -1
  11. package/dist/services/index.d.ts +2 -1
  12. package/dist/services/index.js +1 -0
  13. package/dist/services/local-content-request-handler.js +21 -60
  14. package/dist/services/local-content.d.ts +45 -1
  15. package/dist/services/local-content.js +202 -18
  16. package/dist/services/scoped-content-service.d.ts +35 -0
  17. package/dist/services/scoped-content-service.js +171 -0
  18. package/dist/services/scoped-secret-service.d.ts +1 -0
  19. package/dist/services/scoped-secret-service.js +3 -0
  20. package/dist/services/secret-service.d.ts +6 -0
  21. package/dist/services/typed-secret-service.d.ts +1 -0
  22. package/dist/services/typed-secret-service.js +7 -0
  23. package/dist/testing/service-tests/secret-service-tests.js +9 -0
  24. package/dist/types/state.types.d.ts +8 -0
  25. package/dist/utils.d.ts +2 -5
  26. package/dist/utils.js +1 -38
  27. package/dist/wirings/addon/addon-reach.d.ts +3 -0
  28. package/dist/wirings/addon/addon-reach.js +21 -0
  29. package/dist/wirings/addon/addon-runner.d.ts +6 -0
  30. package/dist/wirings/addon/addon-runner.js +13 -0
  31. package/dist/wirings/addon/define-screens.d.ts +32 -0
  32. package/dist/wirings/addon/define-screens.js +5 -0
  33. package/dist/wirings/addon/index.d.ts +2 -0
  34. package/dist/wirings/addon/index.js +1 -0
  35. package/dist/wirings/addon/wire-addon.d.ts +27 -0
  36. package/dist/wirings/addon/wire-addon.js +4 -0
  37. package/dist/wirings/agent/agent-prepare.js +3 -0
  38. package/dist/wirings/cli/cli-runner.js +24 -9
  39. package/dist/wirings/http/http-runner.js +4 -0
  40. package/dist/wirings/http/http.types.d.ts +1 -0
  41. package/dist/wirings/http/pikku-fetch-http-response.d.ts +1 -0
  42. package/dist/wirings/http/pikku-fetch-http-response.js +12 -0
  43. package/dist/wirings/http/web-request.js +7 -0
  44. package/dist/wirings/rpc/rpc-runner.d.ts +4 -0
  45. package/dist/wirings/rpc/rpc-runner.js +41 -2
  46. package/dist/wirings/rpc/rpc-types.d.ts +6 -0
  47. package/dist/wirings/scope/index.d.ts +1 -1
  48. package/dist/wirings/scope/index.js +1 -1
  49. package/package.json +2 -1
  50. package/src/public-surface.json +64 -15
@@ -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 });
@@ -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
+ }
@@ -0,0 +1,171 @@
1
+ const MAX_DECODE_PASSES = 4;
2
+ const denied = (value) => {
3
+ throw new Error(`Access denied to content path: ${value}`);
4
+ };
5
+ const decoded = (value) => {
6
+ const passes = [value];
7
+ let current = value;
8
+ for (let i = 0; i < MAX_DECODE_PASSES; i++) {
9
+ let next;
10
+ try {
11
+ next = decodeURIComponent(current);
12
+ }
13
+ catch {
14
+ return denied(value);
15
+ }
16
+ if (next === current)
17
+ return passes;
18
+ passes.push(next);
19
+ current = next;
20
+ }
21
+ return denied(value);
22
+ };
23
+ export const normaliseContentPath = (value) => {
24
+ for (const pass of decoded(value)) {
25
+ if (pass.includes('\0'))
26
+ denied(value);
27
+ if (pass.startsWith('/') || pass.startsWith('\\'))
28
+ denied(value);
29
+ for (const segment of pass.split(/[\\/]/)) {
30
+ if (segment === '..' || segment === '.')
31
+ denied(value);
32
+ }
33
+ }
34
+ const segments = value.split('/').filter((segment) => segment !== '');
35
+ return segments.join('/');
36
+ };
37
+ const under = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
38
+ const join = (...parts) => parts.filter((part) => part !== '').join('/');
39
+ /**
40
+ * A `ContentService` narrowed to one folder of the store.
41
+ *
42
+ * Whatever bucket the addon names lands under `<root>/<bucket>`, and the keys
43
+ * and buckets it passes are refused if they climb out. Starting a bucket with
44
+ * `@` names a path from the root of the store instead, which only works under
45
+ * a prefix the host granted: `read` allows reads and signed downloads, `write`
46
+ * adds uploads, writes and deletes, and the longest matching prefix decides.
47
+ * `signURL` and `copyFile` take a finished URL or a local filesystem path, so
48
+ * neither can be narrowed to a folder and both are refused.
49
+ */
50
+ export class ScopedContentService {
51
+ content;
52
+ root;
53
+ grants;
54
+ constructor(content, root, grants = {}) {
55
+ this.content = content;
56
+ this.root = normaliseContentPath(root);
57
+ if (!this.root)
58
+ denied(root);
59
+ this.grants = Object.entries(grants)
60
+ .map(([prefix, mode]) => {
61
+ const clean = normaliseContentPath(prefix);
62
+ if (!clean)
63
+ denied(prefix);
64
+ if (mode !== 'read' && mode !== 'write') {
65
+ throw new Error(`Content grant '${prefix}' must be 'read' or 'write'`);
66
+ }
67
+ return [clean, mode];
68
+ })
69
+ .sort(([a], [b]) => b.length - a.length);
70
+ }
71
+ resolve(bucket, key, need) {
72
+ const cleanKey = normaliseContentPath(key);
73
+ return this.resolveClean(bucket, cleanKey, need);
74
+ }
75
+ resolveClean(bucket, cleanKey, need) {
76
+ if (!bucket.startsWith('@')) {
77
+ return {
78
+ bucket: join(this.root, normaliseContentPath(bucket)),
79
+ key: cleanKey,
80
+ };
81
+ }
82
+ const path = normaliseContentPath(bucket.slice(1));
83
+ const full = join(path, cleanKey);
84
+ if (under(full, this.root))
85
+ return { bucket: path, key: cleanKey };
86
+ const grant = this.grants.find(([prefix]) => under(full, prefix));
87
+ if (!grant || (need === 'write' && grant[1] !== 'write')) {
88
+ return denied(bucket);
89
+ }
90
+ return { bucket: path, key: cleanKey };
91
+ }
92
+ async signContentKey(args) {
93
+ const { bucket, key } = this.resolve(args.bucket, args.contentKey, 'read');
94
+ return this.content.signContentKey({ ...args, bucket, contentKey: key });
95
+ }
96
+ async signURL(_args) {
97
+ throw new Error('signURL is not allowed in scoped content service');
98
+ }
99
+ async getUploadURL(args) {
100
+ const { bucket, key } = this.resolve(args.bucket, args.fileKey, 'write');
101
+ return this.content.getUploadURL({ ...args, bucket, fileKey: key });
102
+ }
103
+ async deleteFile(args) {
104
+ const { bucket, key } = this.resolve(args.bucket, args.key, 'write');
105
+ return this.content.deleteFile({ ...args, bucket, key });
106
+ }
107
+ async writeFile(args) {
108
+ const { bucket, key } = this.resolve(args.bucket, args.key, 'write');
109
+ return this.content.writeFile({ ...args, bucket, key });
110
+ }
111
+ async copyFile(_args) {
112
+ throw new Error('copyFile is not allowed in scoped content service');
113
+ }
114
+ async readFile(args) {
115
+ const { bucket, key } = this.resolve(args.bucket, args.key, 'read');
116
+ return this.content.readFile({ ...args, bucket, key });
117
+ }
118
+ async readFileAsBuffer(args) {
119
+ const { bucket, key } = this.resolve(args.bucket, args.key, 'read');
120
+ return this.content.readFileAsBuffer({ ...args, bucket, key });
121
+ }
122
+ async getDownloadURL(args) {
123
+ const { bucket, key } = this.resolve(args.bucket, args.key, 'read');
124
+ return this.content.getDownloadURL({ ...args, bucket, key });
125
+ }
126
+ async deleteByPrefix(bucket, prefix, visibility) {
127
+ const keys = await this.keysUnder(bucket, prefix, 'write', visibility);
128
+ const target = this.resolvePrefix(bucket, prefix, 'write');
129
+ let deleted = 0;
130
+ for (const key of keys) {
131
+ if (await this.content.deleteFile({
132
+ bucket: target.bucket,
133
+ key,
134
+ visibility,
135
+ })) {
136
+ deleted++;
137
+ }
138
+ }
139
+ return deleted;
140
+ }
141
+ async listFilesByPrefix(bucket, prefix, visibility) {
142
+ return this.keysUnder(bucket, prefix, 'read', visibility);
143
+ }
144
+ async keysUnder(bucket, prefix, need, visibility) {
145
+ const target = this.resolvePrefix(bucket, prefix, need);
146
+ const keys = await this.content.listFilesByPrefix(target.bucket, target.prefix, visibility);
147
+ if (!bucket.startsWith('@'))
148
+ return keys;
149
+ return keys.filter((key) => {
150
+ try {
151
+ this.resolveClean(bucket, normaliseContentPath(key), need);
152
+ return true;
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ });
158
+ }
159
+ resolvePrefix(bucket, prefix, need) {
160
+ const clean = normaliseContentPath(prefix);
161
+ const cleanPrefix = clean && prefix.endsWith('/') ? `${clean}/` : clean;
162
+ if (!bucket.startsWith('@')) {
163
+ return {
164
+ bucket: join(this.root, normaliseContentPath(bucket)),
165
+ prefix: cleanPrefix,
166
+ };
167
+ }
168
+ const resolved = this.resolveClean(bucket, clean, need);
169
+ return { bucket: resolved.bucket, prefix: cleanPrefix };
170
+ }
171
+ }
@@ -8,6 +8,7 @@ export declare class ScopedSecretService implements SecretService {
8
8
  getSecret<T = string>(key: string): Promise<SecretValue<T>>;
9
9
  hasSecret(key: string): Promise<boolean>;
10
10
  setSecret(_key: string, _value: unknown): Promise<void>;
11
+ setEncryptedSecret(_key: string, _sealed: string): Promise<void>;
11
12
  deleteSecret(_key: string): Promise<void>;
12
13
  getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
13
14
  }
@@ -21,6 +21,9 @@ export class ScopedSecretService {
21
21
  async setSecret(_key, _value) {
22
22
  throw new Error('setSecret is not allowed in scoped secret service');
23
23
  }
24
+ async setEncryptedSecret(_key, _sealed) {
25
+ throw new Error('setEncryptedSecret is not allowed in scoped secret service');
26
+ }
24
27
  async deleteSecret(_key) {
25
28
  throw new Error('deleteSecret is not allowed in scoped secret service');
26
29
  }
@@ -13,6 +13,12 @@ export interface SecretService {
13
13
  /** Answers for any key, including a disallowed one — it must not throw. */
14
14
  hasSecret(key: string): Promise<boolean>;
15
15
  setSecret(key: string, value: unknown): Promise<void>;
16
+ /**
17
+ * Stores a value sealed to this service's public key, so the plaintext never
18
+ * reaches the caller's process. `sealed` is the serialized envelope, opaque
19
+ * here. Optional: only stores that hold a sealing key implement it.
20
+ */
21
+ setEncryptedSecret?(key: string, sealed: string): Promise<void>;
16
22
  deleteSecret(key: string): Promise<void>;
17
23
  /**
18
24
  * Missing keys are omitted rather than throwing, hence `Partial<T>`: callers
@@ -41,6 +41,7 @@ export declare class TypedSecretService<TMap = Record<string, unknown>> implemen
41
41
  getSecret<T = string>(key: string): Promise<SecretValue<T>>;
42
42
  hasSecret(key: string): Promise<boolean>;
43
43
  setSecret<K extends string>(key: K, value: K extends keyof TMap ? TMap[K] : unknown): Promise<void>;
44
+ setEncryptedSecret(key: string, sealed: string): Promise<void>;
44
45
  deleteSecret(key: string): Promise<void>;
45
46
  getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
46
47
  getAllStatus(): Promise<CredentialStatus[]>;
@@ -33,6 +33,13 @@ export class TypedSecretService {
33
33
  await this.secrets.setSecret(key, value);
34
34
  this.cache.delete(key);
35
35
  }
36
+ async setEncryptedSecret(key, sealed) {
37
+ if (!this.secrets.setEncryptedSecret) {
38
+ throw new Error('This secret service does not accept sealed secrets');
39
+ }
40
+ await this.secrets.setEncryptedSecret(key, sealed);
41
+ this.cache.delete(key);
42
+ }
36
43
  async deleteSecret(key) {
37
44
  await this.secrets.deleteSecret(key);
38
45
  this.cache.delete(key);
@@ -48,6 +48,15 @@ export const defineSecretServiceTests = (name, secretService) => {
48
48
  await service.deleteSecret('to-delete');
49
49
  assert.strictEqual(await service.hasSecret('to-delete'), false);
50
50
  });
51
+ test('setEncryptedSecret stores a sealed value without opening it', async (t) => {
52
+ const service = await factory({ key: kek });
53
+ if (!service.setEncryptedSecret) {
54
+ t.skip('store does not accept sealed secrets');
55
+ return;
56
+ }
57
+ await service.setEncryptedSecret('sealed-key', 'opaque-envelope');
58
+ assert.strictEqual(await service.hasSecret('sealed-key'), true);
59
+ });
51
60
  test('rotateKEK re-wraps all secrets', async () => {
52
61
  const newKEK = 'new-key-encryption-key-rotated!';
53
62
  const oldService = await factory({ key: kek });
@@ -42,6 +42,8 @@ export interface PikkuPackageState {
42
42
  expose?: boolean | string[];
43
43
  /** Required of every function in this package, on top of the function's own */
44
44
  scopes?: string[];
45
+ /** Addons this instance may call: the package it was written against -> the namespace it is wired under here */
46
+ uses?: Record<string, string>;
45
47
  /** Per-instance name-aliases: logical name the addon reads -> actual project secret name */
46
48
  secretOverrides?: Record<string, string>;
47
49
  /** Per-instance name-aliases: logical name the addon reads -> actual project variable name */
@@ -56,6 +58,12 @@ export interface PikkuPackageState {
56
58
  globalSecrets?: string;
57
59
  /** Why this instance gets the whole `CredentialService` rather than one scoped to its declared credentials */
58
60
  globalCredentials?: string;
61
+ /** The folder of the content store this instance owns; defaults to its wired name */
62
+ contentBucket?: string;
63
+ /** Paths outside its own folder this instance may read or write, by path prefix */
64
+ contentGrants?: Record<string, 'read' | 'write'>;
65
+ /** Why this instance gets the whole `ContentService` rather than one scoped to its folder */
66
+ globalContent?: string;
59
67
  /** Set by `wireRemoteAddon`: this namespace is consumed remotely over HTTP, not bundled */
60
68
  remote?: boolean;
61
69
  /** Remote host base URL (wireRemoteAddon) — string or resolver over singleton services */
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from './services/logger.js';
2
- import type { CoreSingletonServices, ServerLifecycle, WireServices } from './types/core.types.js';
2
+ import type { WireServices } from './types/core.types.js';
3
3
  export declare const closeWireServices: (logger: Logger, wireServices: WireServices) => Promise<void>;
4
4
  export declare const createWeakUID: () => string;
5
5
  export declare const isSerializable: (data: any) => boolean;
@@ -12,10 +12,7 @@ export declare const getTagGroups: <T>(tagGroups: Record<string, T>, tag: string
12
12
  */
13
13
  export declare const parseJson: <T = any>(input: string | Uint8Array) => T;
14
14
  export declare const freezeDedupe: <T>(arr?: readonly T[] | T[] | undefined) => readonly T[];
15
- /** Stops addon package services first, then the parent singleton services. */
16
- export declare const stopSingletonServices: () => Promise<void>;
17
- /** Wrap server lifecycle hooks so the inspector can discover them. */
18
- export declare const pikkuServerLifecycle: <SS extends CoreSingletonServices = CoreSingletonServices>(lifecycle: ServerLifecycle<SS>) => ServerLifecycle<SS>;
15
+ export { stopSingletonServices, pikkuServerLifecycle } from './lifecycle.js';
19
16
  export type MakeRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
20
17
  export type JSONPrimitive = string | number | boolean | null | undefined;
21
18
  export type JSONValue = JSONPrimitive | JSONValue[] | {