@pikku/core 0.12.138 → 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 (34) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/middleware/addon-scope-cap.d.ts +19 -0
  3. package/dist/middleware/addon-scope-cap.js +34 -0
  4. package/dist/middleware/index.d.ts +1 -0
  5. package/dist/middleware/index.js +1 -0
  6. package/dist/scopes.d.ts +8 -0
  7. package/dist/scopes.js +23 -0
  8. package/dist/services/content-service.d.ts +13 -1
  9. package/dist/services/index.d.ts +2 -1
  10. package/dist/services/index.js +1 -0
  11. package/dist/services/local-content-request-handler.js +21 -60
  12. package/dist/services/local-content.d.ts +45 -1
  13. package/dist/services/local-content.js +202 -18
  14. package/dist/services/scoped-content-service.d.ts +35 -0
  15. package/dist/services/scoped-content-service.js +171 -0
  16. package/dist/types/state.types.d.ts +8 -0
  17. package/dist/wirings/addon/addon-reach.d.ts +3 -0
  18. package/dist/wirings/addon/addon-reach.js +21 -0
  19. package/dist/wirings/addon/addon-runner.d.ts +6 -0
  20. package/dist/wirings/addon/addon-runner.js +13 -0
  21. package/dist/wirings/addon/define-screens.d.ts +32 -0
  22. package/dist/wirings/addon/define-screens.js +5 -0
  23. package/dist/wirings/addon/index.d.ts +2 -0
  24. package/dist/wirings/addon/index.js +1 -0
  25. package/dist/wirings/addon/wire-addon.d.ts +27 -0
  26. package/dist/wirings/addon/wire-addon.js +4 -0
  27. package/dist/wirings/agent/agent-prepare.js +3 -0
  28. package/dist/wirings/rpc/rpc-runner.d.ts +4 -0
  29. package/dist/wirings/rpc/rpc-runner.js +41 -2
  30. package/dist/wirings/rpc/rpc-types.d.ts +6 -0
  31. package/dist/wirings/scope/index.d.ts +1 -1
  32. package/dist/wirings/scope/index.js +1 -1
  33. package/package.json +1 -1
  34. package/src/public-surface.json +59 -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
+ }
@@ -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 */
@@ -0,0 +1,3 @@
1
+ import type { PikkuRawWire } from '../../types/core.types.js';
2
+ export declare const addonMayReach: (caller: string, funcName: string) => boolean;
3
+ export declare const addonCallerOf: (wire: PikkuRawWire) => string | undefined;
@@ -0,0 +1,21 @@
1
+ import { ForbiddenError } from '../../errors/errors.js';
2
+ import { ADDON_HEADER } from '../../middleware/addon-scope-cap.js';
3
+ import { pikkuState } from '../../pikku-state.js';
4
+ export const addonMayReach = (caller, funcName) => {
5
+ const target = funcName.slice(0, Math.max(funcName.indexOf(':'), 0));
6
+ if (!target)
7
+ return false;
8
+ if (target === caller)
9
+ return true;
10
+ const uses = pikkuState(null, 'addons', 'packages').get(caller)?.uses;
11
+ return !!uses && Object.values(uses).includes(target);
12
+ };
13
+ export const addonCallerOf = (wire) => {
14
+ const name = wire.http?.request?.header(ADDON_HEADER);
15
+ if (!name)
16
+ return undefined;
17
+ if (!pikkuState(null, 'addons', 'packages').has(name)) {
18
+ throw new ForbiddenError(`Unknown addon '${name}'`);
19
+ }
20
+ return name;
21
+ };
@@ -13,6 +13,12 @@ export type AddonInstance = {
13
13
  globalSecrets?: string;
14
14
  /** Set by the consuming app to opt this instance out of credential scoping. */
15
15
  globalCredentials?: string;
16
+ /** Set by the consuming app: the folder of the content store this instance owns. */
17
+ contentBucket?: string;
18
+ /** Set by the consuming app: paths outside its folder this instance may read or write. */
19
+ contentGrants?: Record<string, 'read' | 'write'>;
20
+ /** Set by the consuming app to opt this instance out of content scoping. */
21
+ globalContent?: string;
16
22
  };
17
23
  export declare const getOrCreatePackageSingletonServices: (packageName: string, parentServices: CoreSingletonServices, addonInstance?: AddonInstance) => Promise<CoreSingletonServices>;
18
24
  export declare const addonInstanceForNamespace: (namespace: string | undefined, expectedPackage: string) => AddonInstance | undefined;
@@ -1,6 +1,7 @@
1
1
  import { pikkuState } from '../../pikku-state.js';
2
2
  import { ScopedSecretService } from '../../services/scoped-secret-service.js';
3
3
  import { ScopedCredentialService } from '../../services/scoped-credential-service.js';
4
+ import { ScopedContentService } from '../../services/scoped-content-service.js';
4
5
  /**
5
6
  * What the addon declared, plus what the host lent it. Scoping runs outside the
6
7
  * aliaser, so every name here is the one the addon reads — which is why an
@@ -105,6 +106,7 @@ const scopeWebhookService = (service, namespace) => {
105
106
  return scoped;
106
107
  };
107
108
  export const getOrCreatePackageSingletonServices = async (packageName, parentServices, addonInstance) => {
109
+ addonInstance ??= addonInstanceForNamespace(findAddonNamespaceForPackage(packageName) ?? undefined, packageName);
108
110
  const cacheKey = addonInstance?.namespace ?? packageName;
109
111
  const cachedServices = pikkuState(cacheKey, 'package', 'singletonServices');
110
112
  if (cachedServices) {
@@ -137,6 +139,14 @@ export const getOrCreatePackageSingletonServices = async (packageName, parentSer
137
139
  };
138
140
  }
139
141
  const namespace = addonInstance?.namespace ?? findAddonNamespaceForPackage(packageName);
142
+ if (!addonInstance?.globalContent && existingServices.content) {
143
+ existingServices = {
144
+ ...existingServices,
145
+ content: new ScopedContentService(existingServices.content, addonInstance?.contentBucket ??
146
+ namespace ??
147
+ packageName.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''), addonInstance?.contentGrants),
148
+ };
149
+ }
140
150
  if (namespace && existingServices.webhookService) {
141
151
  existingServices = {
142
152
  ...existingServices,
@@ -176,5 +186,8 @@ export const addonInstanceForNamespace = (namespace, expectedPackage) => {
176
186
  credentialGrants: cfg.credentialGrants,
177
187
  globalSecrets: cfg.globalSecrets,
178
188
  globalCredentials: cfg.globalCredentials,
189
+ contentBucket: cfg.contentBucket,
190
+ contentGrants: cfg.contentGrants,
191
+ globalContent: cfg.globalContent,
179
192
  };
180
193
  };
@@ -0,0 +1,32 @@
1
+ type AddonScreenBase = {
2
+ /** Where the screen mounts under the addon, e.g. `/` or `/:id`. */
3
+ path: string;
4
+ title: string;
5
+ /** Lists the screen in the host's navigation. */
6
+ nav?: boolean;
7
+ /** Required of the viewer to see the screen. The addon's functions must enforce the same scopes: hiding a screen is not access control. */
8
+ scopes?: string[];
9
+ };
10
+ /** A screen rendered by the host from the addon's source. */
11
+ export type AddonComponentScreen = AddonScreenBase & {
12
+ component: () => Promise<{
13
+ default: unknown;
14
+ }>;
15
+ };
16
+ /** A static program the host serves in a sandboxed iframe. */
17
+ export type AddonAppScreen = AddonScreenBase & {
18
+ /** Directory of the built program, relative to the addon package. */
19
+ app: string;
20
+ };
21
+ export type AddonScreen = AddonComponentScreen | AddonAppScreen;
22
+ export type ScreensManifest = {
23
+ title: string;
24
+ icon?: string;
25
+ screens: AddonScreen[];
26
+ };
27
+ /**
28
+ * Declares the screens an addon ships, which the host mounts when it is wired
29
+ * with `ui: true`. Read statically by `pikku all`, so `path`, `title`, `nav`, `scopes` and `app` must be literals.
30
+ */
31
+ export declare const defineScreens: (manifest: ScreensManifest) => ScreensManifest;
32
+ export {};
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Declares the screens an addon ships, which the host mounts when it is wired
3
+ * with `ui: true`. Read statically by `pikku all`, so `path`, `title`, `nav`, `scopes` and `app` must be literals.
4
+ */
5
+ export const defineScreens = (manifest) => manifest;
@@ -1,5 +1,7 @@
1
1
  export { wireAddon } from './wire-addon.js';
2
2
  export type { WireAddonConfig } from './wire-addon.js';
3
+ export { defineScreens } from './define-screens.js';
4
+ export type { ScreensManifest, AddonScreen, AddonComponentScreen, AddonAppScreen, } from './define-screens.js';
3
5
  export { wireRemoteAddon } from './wire-remote-addon.js';
4
6
  export type { WireRemoteAddonConfig, RemoteAddonAuth, } from './wire-remote-addon.js';
5
7
  export { resolveRemoteAddonToken, RemoteAddonAuthError, } from './remote-addon-auth.js';
@@ -1,3 +1,4 @@
1
1
  export { wireAddon } from './wire-addon.js';
2
+ export { defineScreens } from './define-screens.js';
2
3
  export { wireRemoteAddon } from './wire-remote-addon.js';
3
4
  export { resolveRemoteAddonToken, RemoteAddonAuthError, } from './remote-addon-auth.js';