@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.
- package/CHANGELOG.md +9 -0
- package/dist/middleware/addon-scope-cap.d.ts +19 -0
- package/dist/middleware/addon-scope-cap.js +34 -0
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/scopes.d.ts +8 -0
- package/dist/scopes.js +23 -0
- package/dist/services/content-service.d.ts +13 -1
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/local-content-request-handler.js +21 -60
- package/dist/services/local-content.d.ts +45 -1
- package/dist/services/local-content.js +202 -18
- package/dist/services/scoped-content-service.d.ts +35 -0
- package/dist/services/scoped-content-service.js +171 -0
- package/dist/types/state.types.d.ts +8 -0
- package/dist/wirings/addon/addon-reach.d.ts +3 -0
- package/dist/wirings/addon/addon-reach.js +21 -0
- package/dist/wirings/addon/addon-runner.d.ts +6 -0
- package/dist/wirings/addon/addon-runner.js +13 -0
- package/dist/wirings/addon/define-screens.d.ts +32 -0
- package/dist/wirings/addon/define-screens.js +5 -0
- package/dist/wirings/addon/index.d.ts +2 -0
- package/dist/wirings/addon/index.js +1 -0
- package/dist/wirings/addon/wire-addon.d.ts +27 -0
- package/dist/wirings/addon/wire-addon.js +4 -0
- package/dist/wirings/agent/agent-prepare.js +3 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +4 -0
- package/dist/wirings/rpc/rpc-runner.js +41 -2
- package/dist/wirings/rpc/rpc-types.d.ts +6 -0
- package/dist/wirings/scope/index.d.ts +1 -1
- package/dist/wirings/scope/index.js +1 -1
- package/package.json +1 -1
- package/src/public-surface.json +59 -15
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createReadStream, createWriteStream, promises } from 'fs';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
46
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
228
|
+
const origin = this.config.server ?? '';
|
|
229
|
+
if (visibility === 'public') {
|
|
230
|
+
return `${this.publicBase()}/${fullKey}`;
|
|
231
|
+
}
|
|
89
232
|
return this.signURL({
|
|
90
|
-
url:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,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 {};
|
|
@@ -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';
|