@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 0.12.139
2
+
3
+ ### Patch Changes
4
+
5
+ - a196a21: A call over HTTP that names an add-on in `x-pikku-addon` can only reach that add-on's own functions and the add-ons it lists in `uses`. `rpc.exposed` now refuses anything else, including host functions and an add-on nobody wired, and `AddonNotUsedError` answers 403.
6
+ - 53ac40d: An addon can ship screens: `wireAddon({ ui: true })` mounts what the package declares with `defineScreens`, the inspector reads them, `pikku all` emits the screens manifest and the installed-addons registry, and `x-pikku-addon` narrows a session to the addon's role. `pikku new addon --ui` scaffolds one; its tables must be prefixed `<name>_`.
7
+ - 443fd40: Local content uploads stream to disk instead of being buffered in memory, and a rejected or aborted upload leaves no file behind. A 300 MB upload and read no longer grows the server's memory with the file.
8
+ - 56c8047: Content services support `visibility: 'private' | 'public'` (default private). `ContentService` gains `getDownloadURL`, `deleteByPrefix` and `listFilesByPrefix`. `LocalContent` stores under `private/` and `public/` and serves public files unsigned at `<assetUrlPrefix>/_public/<bucket>/<key>`; unprefixed files from before still read as private, and a private bucket may not be named `_public`. S3 makes public objects `public-read` per object (the bucket must allow ACLs), Backblaze uses `publicBucketId` when set, and the scoped addon content service passes visibility through and keeps the new methods inside the addon's folder.
9
+
1
10
  ## 0.12.138
2
11
 
3
12
  ### Patch Changes
@@ -0,0 +1,19 @@
1
+ export declare const ADDON_HEADER = "x-pikku-addon";
2
+ /**
3
+ * Narrows the session to what an addon is allowed to do. A call that names an
4
+ * addon in `x-pikku-addon` keeps only the scopes both the viewer and the
5
+ * addon's role hold, so an addon's screens can never act beyond what it
6
+ * declared, whoever is looking at them.
7
+ *
8
+ * The header is a selector, not a credential: naming an addon can only
9
+ * reduce a session, so a caller who sends it by hand has capped themselves. An
10
+ * addon the host does not know is refused, because ignoring it would let a
11
+ * misspelt name run uncapped.
12
+ *
13
+ * Register it after the session middleware: it narrows a session that is
14
+ * already resolved, and a call with no session has nothing to narrow.
15
+ */
16
+ export declare const addonScopeCap: import("./middleware.types.js").CorePikkuMiddlewareFactory<{
17
+ /** The scopes of the addon's role, or `undefined` when no such addon is installed. */
18
+ resolve: (name: string) => readonly string[] | undefined | Promise<readonly string[] | undefined>;
19
+ }>;
@@ -0,0 +1,34 @@
1
+ import { ForbiddenError } from '../errors/errors.js';
2
+ import { intersectScopes } from '../scopes.js';
3
+ import { pikkuMiddleware, pikkuMiddlewareFactory, } from './middleware-factories.js';
4
+ export const ADDON_HEADER = 'x-pikku-addon';
5
+ /**
6
+ * Narrows the session to what an addon is allowed to do. A call that names an
7
+ * addon in `x-pikku-addon` keeps only the scopes both the viewer and the
8
+ * addon's role hold, so an addon's screens can never act beyond what it
9
+ * declared, whoever is looking at them.
10
+ *
11
+ * The header is a selector, not a credential: naming an addon can only
12
+ * reduce a session, so a caller who sends it by hand has capped themselves. An
13
+ * addon the host does not know is refused, because ignoring it would let a
14
+ * misspelt name run uncapped.
15
+ *
16
+ * Register it after the session middleware: it narrows a session that is
17
+ * already resolved, and a call with no session has nothing to narrow.
18
+ */
19
+ export const addonScopeCap = pikkuMiddlewareFactory(({ resolve }) => pikkuMiddleware(async (_services, { http, setSession, getSession }, next) => {
20
+ const name = http?.request?.header(ADDON_HEADER);
21
+ const session = await getSession?.();
22
+ if (!name || !setSession || !session) {
23
+ return next();
24
+ }
25
+ const role = await resolve(name);
26
+ if (!role) {
27
+ throw new ForbiddenError(`Unknown addon '${name}'`);
28
+ }
29
+ await setSession({
30
+ ...session,
31
+ scopes: intersectScopes(session.scopes, role),
32
+ });
33
+ return next();
34
+ }));
@@ -1,6 +1,7 @@
1
1
  export { authAPIKey } from './auth-apikey.js';
2
2
  export { authCookie } from './auth-cookie.js';
3
3
  export { authBearer } from './auth-bearer.js';
4
+ export { addonScopeCap, ADDON_HEADER, } from './addon-scope-cap.js';
4
5
  export { remoteJobsSecret } from './remote-jobs-secret.js';
5
6
  export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
6
7
  export { cors } from './cors.js';
@@ -1,6 +1,7 @@
1
1
  export { authAPIKey } from './auth-apikey.js';
2
2
  export { authCookie } from './auth-cookie.js';
3
3
  export { authBearer } from './auth-bearer.js';
4
+ export { addonScopeCap, ADDON_HEADER, } from './addon-scope-cap.js';
4
5
  export { remoteJobsSecret } from './remote-jobs-secret.js';
5
6
  export { pikkuRemoteAuthMiddleware } from './remote-auth.js';
6
7
  export { cors } from './cors.js';
package/dist/scopes.d.ts CHANGED
@@ -21,3 +21,11 @@ export declare const hasAnyScope: (candidates: readonly string[] | undefined, he
21
21
  * nothing.
22
22
  */
23
23
  export declare const verifyScopes: (required: readonly string[] | undefined, session: CoreUserSession | undefined) => void;
24
+ /**
25
+ * The scopes both `held` and `cap` grant, by the same parent and wildcard rules
26
+ * as {@link hasScopes}. Where one side is broader, the narrower grant is kept:
27
+ * a holder of `invoices:*` capped to `invoices:read` ends with `invoices:read`,
28
+ * and a holder of `invoices:read` capped to `invoices:*` keeps `invoices:read`.
29
+ * Fails closed: nothing held, or an empty cap, yields nothing.
30
+ */
31
+ export declare const intersectScopes: (held: Iterable<string> | undefined, cap: Iterable<string> | undefined) => string[];
package/dist/scopes.js CHANGED
@@ -64,3 +64,26 @@ export const verifyScopes = (required, session) => {
64
64
  throw new MissingScopeError(missing);
65
65
  }
66
66
  };
67
+ /**
68
+ * The scopes both `held` and `cap` grant, by the same parent and wildcard rules
69
+ * as {@link hasScopes}. Where one side is broader, the narrower grant is kept:
70
+ * a holder of `invoices:*` capped to `invoices:read` ends with `invoices:read`,
71
+ * and a holder of `invoices:read` capped to `invoices:*` keeps `invoices:read`.
72
+ * Fails closed: nothing held, or an empty cap, yields nothing.
73
+ */
74
+ export const intersectScopes = (held, cap) => {
75
+ const heldSet = new Set(held ?? []);
76
+ const capSet = new Set(cap ?? []);
77
+ const result = new Set();
78
+ for (const scope of heldSet) {
79
+ if (holds(capSet, scope)) {
80
+ result.add(scope);
81
+ }
82
+ }
83
+ for (const scope of capSet) {
84
+ if (holds(heldSet, scope)) {
85
+ result.add(scope);
86
+ }
87
+ }
88
+ return [...result];
89
+ };
@@ -1,6 +1,8 @@
1
+ export type ContentVisibility = 'private' | 'public';
1
2
  export interface SignContentKeyArgs<TBucket extends string = string> {
2
3
  bucket: TBucket;
3
4
  contentKey: string;
5
+ visibility?: ContentVisibility;
4
6
  dateLessThan: Date;
5
7
  dateGreaterThan?: Date;
6
8
  }
@@ -14,7 +16,7 @@ export interface GetUploadURLArgs<TBucket extends string = string> {
14
16
  fileKey: string;
15
17
  contentType: string;
16
18
  size?: number;
17
- visibility?: 'private' | 'public';
19
+ visibility?: ContentVisibility;
18
20
  }
19
21
  export interface UploadURLResult {
20
22
  uploadUrl: string;
@@ -25,6 +27,10 @@ export interface UploadURLResult {
25
27
  export interface BucketKeyArgs<TBucket extends string = string> {
26
28
  bucket: TBucket;
27
29
  key: string;
30
+ visibility?: ContentVisibility;
31
+ }
32
+ export interface GetDownloadURLArgs<TBucket extends string = string> extends BucketKeyArgs<TBucket> {
33
+ expiresInSeconds?: number;
28
34
  }
29
35
  export interface WriteFileArgs<TBucket extends string = string> extends BucketKeyArgs<TBucket> {
30
36
  stream: ReadableStream | NodeJS.ReadableStream;
@@ -42,4 +48,10 @@ export interface ContentService<TBucket extends string = string> {
42
48
  copyFile(args: CopyFileArgs<TBucket>): Promise<boolean>;
43
49
  readFile(args: BucketKeyArgs<TBucket>): Promise<ReadableStream | NodeJS.ReadableStream>;
44
50
  readFileAsBuffer(args: BucketKeyArgs<TBucket>): Promise<Buffer>;
51
+ /** A signed URL for private content, the plain URL for public content. */
52
+ getDownloadURL(args: GetDownloadURLArgs<TBucket>): Promise<string>;
53
+ /** Returns how many files were deleted. */
54
+ deleteByPrefix(bucket: TBucket, prefix: string, visibility?: ContentVisibility): Promise<number>;
55
+ /** Returns the keys, relative to the bucket, that start with the prefix. */
56
+ listFilesByPrefix(bucket: TBucket, prefix: string, visibility?: ContentVisibility): Promise<string[]>;
45
57
  }
@@ -1,6 +1,7 @@
1
1
  export { LogLevel } from './logger.js';
2
2
  export { ScopedSecretService } from './scoped-secret-service.js';
3
3
  export { ScopedCredentialService } from './scoped-credential-service.js';
4
+ export { ScopedContentService } from './scoped-content-service.js';
4
5
  export { PikkuSessionService, createMiddlewareSessionWireProps, } from './user-session-service.js';
5
6
  export { TypedSecretService } from './typed-secret-service.js';
6
7
  export { PikkuCredentialWireService } from './credential-wire-service.js';
@@ -21,7 +22,7 @@ export { LeaseLostError, LeaseTakenError, holdLease, type Lease, type LeaseServi
21
22
  export { InMemoryLeaseService } from './in-memory-lease-service.js';
22
23
  export { InMemoryAgentRunStateService } from './in-memory-agent-run-state-service.js';
23
24
  export { LocalGatewayService } from './local-gateway-service.js';
24
- export type { ContentService, SignContentKeyArgs, SignURLArgs, GetUploadURLArgs, UploadURLResult, BucketKeyArgs, WriteFileArgs, CopyFileArgs, } from './content-service.js';
25
+ export type { ContentService, ContentVisibility, GetDownloadURLArgs, SignContentKeyArgs, SignURLArgs, GetUploadURLArgs, UploadURLResult, BucketKeyArgs, WriteFileArgs, CopyFileArgs, } from './content-service.js';
25
26
  export type { ScenarioPersona, ResolvedPersona, ScenarioPersonas, } from './personas-service.js';
26
27
  export type { JWTService } from './jwt-service.js';
27
28
  export type { EmailAttachment, EmailService, SendEmailInput, SendEmailResult, SendHTMLEmailInput, SendTemplateEmailInput, SendTextEmailInput, } from './email-service.js';
@@ -1,6 +1,7 @@
1
1
  export { LogLevel } from './logger.js';
2
2
  export { ScopedSecretService } from './scoped-secret-service.js';
3
3
  export { ScopedCredentialService } from './scoped-credential-service.js';
4
+ export { ScopedContentService } from './scoped-content-service.js';
4
5
  export { PikkuSessionService, createMiddlewareSessionWireProps, } from './user-session-service.js';
5
6
  export { TypedSecretService } from './typed-secret-service.js';
6
7
  export { PikkuCredentialWireService } from './credential-wire-service.js';
@@ -1,37 +1,9 @@
1
1
  import { createReadStream } from 'fs';
2
- import { mkdir, stat, writeFile } from 'fs/promises';
3
- import { normalize, resolve } from 'path';
2
+ import { stat } from 'fs/promises';
4
3
  import { Readable } from 'stream';
5
- import { signedContentPath } from './local-content.js';
4
+ import { UploadTooLargeError, parseContentSizeLimit, resolveContentRequestTarget, signedContentPath, streamUploadToFile, } from './local-content.js';
6
5
  const matchesPrefix = (pathname, prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`);
7
6
  const contentKey = (pathname, prefix) => pathname.slice(prefix.length).replace(/^\/+/, '');
8
- /**
9
- * Resolve a key against the content root, or `null` if it escapes. `normalize`
10
- * first so `..` segments are collapsed before the prefix check, and the
11
- * comparison carries a trailing separator so a sibling directory whose name
12
- * merely starts with the root's cannot pass as being inside it.
13
- */
14
- const toTargetPath = (basePath, key) => {
15
- const normalizedBasePath = resolve(basePath);
16
- const targetPath = resolve(normalizedBasePath, normalize(key));
17
- return targetPath.startsWith(`${normalizedBasePath}/`) ? targetPath : null;
18
- };
19
- const parseSizeLimit = (sizeLimit) => {
20
- const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(sizeLimit.trim());
21
- if (!match) {
22
- throw new Error(`Invalid size limit: ${sizeLimit}`);
23
- }
24
- const value = Number(match[1]);
25
- const unit = (match[2] ?? 'b').toLowerCase();
26
- const multiplier = unit === 'gb'
27
- ? 1024 * 1024 * 1024
28
- : unit === 'mb'
29
- ? 1024 * 1024
30
- : unit === 'kb'
31
- ? 1024
32
- : 1;
33
- return value * multiplier;
34
- };
35
7
  const text = (status, body) => new Response(body, {
36
8
  status,
37
9
  headers: { 'content-type': 'text/plain; charset=utf-8' },
@@ -112,47 +84,36 @@ export const createLocalContentRequestHandler = ({ content, logger, getJWT, }) =
112
84
  return text(signed.status, signed.body);
113
85
  }
114
86
  const key = contentKey(pathname, content.uploadUrlPrefix);
115
- const targetPath = toTargetPath(content.localFileUploadPath, key);
116
- if (!targetPath) {
87
+ const target = await resolveContentRequestTarget(content.localFileUploadPath, key, 'write');
88
+ if (!target) {
117
89
  return text(400, 'Invalid path');
118
90
  }
119
- const maxBytes = parseSizeLimit(content.sizeLimit ?? '1mb');
120
- // knowledge: decisions/security/an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md
121
- const chunks = [];
122
- let bytesRead = 0;
123
- const reader = request.body?.getReader();
124
- if (reader) {
125
- try {
126
- for (;;) {
127
- const { done, value } = await reader.read();
128
- if (done)
129
- break;
130
- bytesRead += value.byteLength;
131
- if (bytesRead > maxBytes) {
132
- await reader.cancel();
133
- return text(413, 'Content too large');
134
- }
135
- chunks.push(Buffer.from(value));
136
- }
137
- }
138
- finally {
139
- reader.releaseLock();
91
+ try {
92
+ await streamUploadToFile(request.body
93
+ ? Readable.fromWeb(request.body)
94
+ : (async function* () { })(), target.path, parseContentSizeLimit(content.sizeLimit ?? '1mb'));
95
+ }
96
+ catch (error) {
97
+ if (error instanceof UploadTooLargeError) {
98
+ return text(413, 'Content too large');
140
99
  }
100
+ throw error;
141
101
  }
142
- await mkdir(resolve(targetPath, '..'), { recursive: true });
143
- await writeFile(targetPath, Buffer.concat(chunks));
144
102
  return new Response(null, { status: 200 });
145
103
  };
146
104
  const handleAsset = async (request, requestUrl, pathname) => {
147
105
  const key = contentKey(pathname, content.assetUrlPrefix);
148
- const targetPath = toTargetPath(content.localFileUploadPath, key);
149
- if (!targetPath) {
106
+ const target = await resolveContentRequestTarget(content.localFileUploadPath, key, 'read');
107
+ if (!target) {
150
108
  return text(400, 'Invalid path');
151
109
  }
152
- const signed = await validateSignedAssetRequest(requestUrl);
153
- if (!signed.ok) {
154
- return text(signed.status, signed.body);
110
+ if (target.visibility === 'private') {
111
+ const signed = await validateSignedAssetRequest(requestUrl);
112
+ if (!signed.ok) {
113
+ return text(signed.status, signed.body);
114
+ }
155
115
  }
116
+ const targetPath = target.path;
156
117
  try {
157
118
  const file = await stat(targetPath);
158
119
  if (!file.isFile()) {
@@ -1,4 +1,4 @@
1
- import type { BucketKeyArgs, ContentService, CopyFileArgs, GetUploadURLArgs, JWTService, Logger, SignContentKeyArgs, SignURLArgs, UploadURLResult, WriteFileArgs } from '@pikku/core/services';
1
+ import type { BucketKeyArgs, ContentService, ContentVisibility, GetDownloadURLArgs, CopyFileArgs, GetUploadURLArgs, JWTService, Logger, SignContentKeyArgs, SignURLArgs, UploadURLResult, WriteFileArgs } from '@pikku/core/services';
2
2
  export interface LocalContentConfig {
3
3
  localFileUploadPath: string;
4
4
  uploadUrlPrefix: string;
@@ -17,17 +17,59 @@ export interface LocalContentConfig {
17
17
  * rather than throwing, which keeps both sides in agreement.
18
18
  */
19
19
  export declare const signedContentPath: (urlOrPath: string) => string;
20
+ export declare const PUBLIC_URL_SEGMENT = "_public";
21
+ export declare const CONTENT_VISIBILITIES: readonly ContentVisibility[];
22
+ export type ContentUrlPath = {
23
+ visibility: ContentVisibility;
24
+ rest: string;
25
+ prefixed: boolean;
26
+ };
27
+ /**
28
+ * Splits the part of a content URL after the asset/upload prefix into the
29
+ * visibility it names and the `<bucket>/<key>` remainder. Only a leading
30
+ * `_public/` segment names the public side; anything else is private.
31
+ * Private buckets may therefore not be named `_public`.
32
+ */
33
+ export declare const parseContentUrlPath: (key: string) => ContentUrlPath;
34
+ /**
35
+ * Resolves `<bucket>/<key>` against one side of the store. The side is a
36
+ * directory under the content root, and both the bucket and the key must stay
37
+ * inside it, so neither side can climb out or reach the other.
38
+ */
39
+ export declare const resolveVisibilityPath: (basePath: string, visibility: ContentVisibility, bucket: string, key: string) => string | null;
40
+ export type ContentRequestTarget = {
41
+ path: string;
42
+ visibility: ContentVisibility;
43
+ };
44
+ /**
45
+ * Maps the part of a content URL after the asset/upload prefix to a file under
46
+ * the content root: `_public/...` to the public side, anything else to the
47
+ * private side, falling back for reads to an unprefixed file from before the
48
+ * split. Returns `null` for a path that escapes its side or names no file.
49
+ * Every server of local content resolves through here, and skips the signature
50
+ * check only when the result is public.
51
+ */
52
+ export declare const resolveContentRequestTarget: (basePath: string, key: string, mode: 'read' | 'write') => Promise<ContentRequestTarget | null>;
53
+ export declare class UploadTooLargeError extends Error {
54
+ constructor();
55
+ }
56
+ export declare const parseContentSizeLimit: (sizeLimit: string) => number;
57
+ export declare const streamUploadToFile: (source: AsyncIterable<Uint8Array>, targetPath: string, maxBytes: number) => Promise<number>;
20
58
  export declare class LocalContent implements ContentService {
21
59
  private config;
22
60
  private logger;
23
61
  private jwt;
24
62
  constructor(config: LocalContentConfig, logger: Logger, jwt: JWTService);
25
63
  private safePath;
64
+ private existingPath;
65
+ private legacyPath;
66
+ private publicBase;
26
67
  private joinKey;
27
68
  init(): Promise<void>;
28
69
  private signParams;
29
70
  signURL(args: SignURLArgs): Promise<string>;
30
71
  signContentKey(args: SignContentKeyArgs): Promise<string>;
72
+ getDownloadURL(args: GetDownloadURLArgs): Promise<string>;
31
73
  /**
32
74
  * Presigned upload, mirroring the read side: the URL carries a short-lived,
33
75
  * path-bound signature that {@link createLocalContentRequestHandler} verifies
@@ -40,5 +82,7 @@ export declare class LocalContent implements ContentService {
40
82
  readFile(args: BucketKeyArgs): Promise<ReadableStream | NodeJS.ReadableStream>;
41
83
  readFileAsBuffer(args: BucketKeyArgs): Promise<Buffer>;
42
84
  deleteFile(args: BucketKeyArgs): Promise<boolean>;
85
+ listFilesByPrefix(bucket: string, prefix: string, visibility?: ContentVisibility): Promise<string[]>;
86
+ deleteByPrefix(bucket: string, prefix: string, visibility?: ContentVisibility): Promise<number>;
43
87
  private createDirectoryForFile;
44
88
  }