@pikku/core 0.12.137 → 0.12.139
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/dist/dev/hot-reload.js +5 -2
- package/dist/env.d.ts +18 -0
- package/dist/env.js +60 -1
- package/dist/function/abort-scope.d.ts +6 -5
- package/dist/function/abort-scope.js +6 -17
- package/dist/function/function-runner.js +2 -1
- package/dist/middleware/addon-scope-cap.d.ts +19 -0
- package/dist/middleware/addon-scope-cap.js +34 -0
- package/dist/middleware/auth-apikey.js +1 -1
- package/dist/middleware/auth-bearer.js +1 -1
- package/dist/middleware/auth-cookie.js +1 -1
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/schema.js +3 -1
- 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/in-memory-queue-service.js +4 -2
- package/dist/services/in-memory-workflow-service.js +7 -6
- package/dist/services/index.d.ts +2 -2
- package/dist/services/index.js +1 -1
- 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/local-variables.d.ts +5 -0
- package/dist/services/local-variables.js +7 -2
- package/dist/services/queue-webhook-service.js +1 -1
- package/dist/services/scoped-content-service.d.ts +35 -0
- package/dist/services/scoped-content-service.js +171 -0
- package/dist/services/webhook-service.d.ts +2 -2
- package/dist/services/webhook-service.js +6 -4
- package/dist/types/core.types.d.ts +10 -1
- package/dist/types/state.types.d.ts +8 -0
- package/dist/utils/hmac.d.ts +25 -10
- package/dist/utils/hmac.js +202 -24
- 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-agui.js +65 -62
- package/dist/wirings/agent/agent-interrupt.d.ts +4 -1
- package/dist/wirings/agent/agent-interrupt.js +3 -3
- package/dist/wirings/agent/agent-memory.js +5 -6
- package/dist/wirings/agent/agent-model-config.js +2 -1
- package/dist/wirings/agent/agent-prepare.js +24 -13
- package/dist/wirings/agent/agent-stream.js +38 -34
- package/dist/wirings/agent/agent.types.d.ts +8 -1
- package/dist/wirings/channel/channel-handler.js +1 -1
- package/dist/wirings/channel/local/local-channel-handler.js +5 -1
- package/dist/wirings/channel/local/local-channel-runner.js +6 -4
- package/dist/wirings/channel/local/local-eventhub-service.d.ts +1 -0
- package/dist/wirings/channel/local/local-eventhub-service.js +4 -1
- package/dist/wirings/cli/channel/cli-channel-runner.js +7 -2
- package/dist/wirings/cli/channel/cli-raw-client-runner.js +8 -4
- package/dist/wirings/cli/cli-runner.js +1 -1
- package/dist/wirings/gateway/gateway-runner.js +3 -3
- package/dist/wirings/http/http-runner.js +6 -6
- 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/scheduler/scheduler-runner.js +1 -1
- package/dist/wirings/scope/define-scope.d.ts +19 -0
- package/dist/wirings/scope/define-scope.js +19 -0
- package/dist/wirings/scope/index.d.ts +2 -2
- package/dist/wirings/scope/index.js +2 -2
- package/dist/wirings/trigger/trigger-runner.js +3 -1
- package/dist/wirings/trigger/webhook-source-runner.js +5 -2
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +3 -2
- package/dist/wirings/workflow/workflow-invocation-id.js +85 -8
- package/package.json +3 -1
- package/src/public-surface.json +65 -18
|
@@ -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
|
+
}
|
|
@@ -87,9 +87,9 @@ export declare const DEFAULT_WEBHOOK_RETRIES = 3;
|
|
|
87
87
|
export declare abstract class WebhookService {
|
|
88
88
|
abstract send<T extends SendWebhookInput>(input: Safe<T>): Promise<SendWebhookResult>;
|
|
89
89
|
/** Produces the header value, `sha256=<hex>`, not the bare digest. */
|
|
90
|
-
protected sign(secret: string, body: string): string
|
|
90
|
+
protected sign(secret: string, body: string): Promise<string>;
|
|
91
91
|
/** Public because receivers verify with it; they share the signing scheme. */
|
|
92
|
-
verify(secret: string, signature: string, body: string): boolean
|
|
92
|
+
verify(secret: string, signature: string, body: string): Promise<boolean>;
|
|
93
93
|
/**
|
|
94
94
|
* The three methods below are optional capability: the default queue-only
|
|
95
95
|
* service keeps no history, and a store-backed implementation (e.g.
|
|
@@ -5,12 +5,14 @@ export const DEFAULT_WEBHOOK_SIGNATURE_HEADER = 'X-Pikku-Signature';
|
|
|
5
5
|
export const DEFAULT_WEBHOOK_RETRIES = 3;
|
|
6
6
|
export class WebhookService {
|
|
7
7
|
/** Produces the header value, `sha256=<hex>`, not the bare digest. */
|
|
8
|
-
sign(secret, body) {
|
|
9
|
-
return `sha256=${hmacSha256Hex(secret, body)}`;
|
|
8
|
+
async sign(secret, body) {
|
|
9
|
+
return `sha256=${await hmacSha256Hex(secret, body)}`;
|
|
10
10
|
}
|
|
11
11
|
/** Public because receivers verify with it; they share the signing scheme. */
|
|
12
|
-
verify(secret, signature, body) {
|
|
13
|
-
|
|
12
|
+
async verify(secret, signature, body) {
|
|
13
|
+
if (!secret || !signature)
|
|
14
|
+
return false;
|
|
15
|
+
return timingSafeStringEqual(await this.sign(secret, body), signature);
|
|
14
16
|
}
|
|
15
17
|
/**
|
|
16
18
|
* The three methods below are optional capability: the default queue-only
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AbortScope } from '../function/abort-scope.js';
|
|
1
2
|
import type { IncomingWebhookService } from '../services/incoming-webhook-service.js';
|
|
2
3
|
import type { TriggerSourceStore } from '../services/trigger-source-store.js';
|
|
3
4
|
import type { LeaseService } from '../services/lease-service.js';
|
|
@@ -287,7 +288,15 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
|
|
|
287
288
|
beginChanges: () => Promise<void>;
|
|
288
289
|
}>;
|
|
289
290
|
/** Wire as constructed by runners, before the function runner adds `rpc` and the credential readers. */
|
|
290
|
-
export type PikkuRawWire = Omit<PikkuWire, 'rpc' | 'getCredential' | 'getCredentials'> & Partial<Pick<PikkuWire, 'getCredential' | 'getCredentials'
|
|
291
|
+
export type PikkuRawWire = Omit<PikkuWire, 'rpc' | 'getCredential' | 'getCredentials'> & Partial<Pick<PikkuWire, 'getCredential' | 'getCredentials'>> & {
|
|
292
|
+
/**
|
|
293
|
+
* Whether whatever asked for this work is still there. Set by wirings that
|
|
294
|
+
* can be abandoned (an interrupted agent run); the function runner binds
|
|
295
|
+
* `wire.beginChanges` to it. Carried through nested `rpc` calls by the
|
|
296
|
+
* wire spread, so a sub-call observes the same scope.
|
|
297
|
+
*/
|
|
298
|
+
abortScope?: AbortScope;
|
|
299
|
+
};
|
|
291
300
|
export type CoreServices<SingletonServices = CoreSingletonServices> = SingletonServices;
|
|
292
301
|
/** Strips `secrets` from a services type. */
|
|
293
302
|
export type SecretlessServices<Services> = Omit<Services, 'secrets'>;
|
|
@@ -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/hmac.d.ts
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Web Crypto only: no `node:crypto` and no `Buffer`, so this loads on edge
|
|
3
|
+
* runtimes. Web Crypto is async, so everything that touches a key returns a
|
|
4
|
+
* Promise; `timingSafeStringEqual` is plain JS and stays synchronous.
|
|
4
5
|
*/
|
|
5
|
-
export declare function hmacSha256Hex(secret: string, payload: string): string;
|
|
6
6
|
/**
|
|
7
|
-
* Constant-time
|
|
8
|
-
*
|
|
7
|
+
* Constant-time string comparison (pure JS; for opaque tokens, not HMACs, which
|
|
8
|
+
* `verifyHmacSignature` checks with Web Crypto). Returns false on a length mismatch
|
|
9
|
+
* (the lengths of the two values are not secret).
|
|
9
10
|
*/
|
|
10
11
|
export declare function timingSafeStringEqual(a: string, b: string): boolean;
|
|
11
12
|
export type WebhookPayload = string | Uint8Array;
|
|
12
13
|
export type HmacAlgorithm = 'sha1' | 'sha256' | 'sha512';
|
|
13
14
|
export type SecretEncoding = 'utf8' | 'hex' | 'base64';
|
|
15
|
+
/**
|
|
16
|
+
* HMAC-SHA256 of a payload, hex-encoded. Senders wrap this in their own scheme
|
|
17
|
+
* prefix (`sha256=`, `v0=`, …).
|
|
18
|
+
*/
|
|
19
|
+
export declare function hmacSha256Hex(secret: string, payload: string): Promise<string>;
|
|
14
20
|
/** The HMAC of `payload` under `secret`, for handshakes that answer with a digest. */
|
|
15
|
-
export declare function hmacDigest(secret: string, algorithm: HmacAlgorithm, payload: WebhookPayload, encoding: 'hex' | 'base64', secretEncoding?: SecretEncoding): string
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
export declare function hmacDigest(secret: string, algorithm: HmacAlgorithm, payload: WebhookPayload, encoding: 'hex' | 'base64', secretEncoding?: SecretEncoding): Promise<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Whether `signature` is the HMAC of `payload` under `secret`. Fails closed on
|
|
24
|
+
* an empty secret (or one that decodes to an empty key) and on a missing or
|
|
25
|
+
* malformed signature. The comparison is `crypto.subtle.verify('HMAC', ...)`,
|
|
26
|
+
* so it runs in the platform's constant-time implementation.
|
|
27
|
+
*/
|
|
28
|
+
export declare function verifyHmacSignature(secret: string, signature: string | undefined, algorithm: HmacAlgorithm, payload: WebhookPayload, encoding: 'hex' | 'base64', secretEncoding?: SecretEncoding): Promise<boolean>;
|
|
29
|
+
/**
|
|
30
|
+
* Whether `signature` (base64) is `payload` signed by the private half of
|
|
31
|
+
* `publicKey` (an RSA, EC or Ed25519 PEM `SPKI` key). RSA-PSS keys are not
|
|
32
|
+
* supported and verify as false. Ed25519 ignores `algorithm` and `dsaEncoding`.
|
|
33
|
+
*/
|
|
19
34
|
export declare function verifyPublicKeySignature(publicKey: string, signature: string | undefined, payload: WebhookPayload, options?: {
|
|
20
35
|
algorithm?: string;
|
|
21
36
|
dsaEncoding?: 'der' | 'ieee-p1363';
|
|
22
|
-
}): boolean
|
|
37
|
+
}): Promise<boolean>;
|
package/dist/utils/hmac.js
CHANGED
|
@@ -1,42 +1,220 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Web Crypto only: no `node:crypto` and no `Buffer`, so this loads on edge
|
|
3
|
+
* runtimes. Web Crypto is async, so everything that touches a key returns a
|
|
4
|
+
* Promise; `timingSafeStringEqual` is plain JS and stays synchronous.
|
|
5
|
+
*/
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const subtle = () => {
|
|
8
|
+
const s = globalThis.crypto?.subtle;
|
|
9
|
+
if (!s)
|
|
10
|
+
throw new Error('WebCrypto not available');
|
|
11
|
+
return s;
|
|
12
|
+
};
|
|
13
|
+
const bytesToHex = (bytes) => {
|
|
14
|
+
let out = '';
|
|
15
|
+
for (const b of bytes)
|
|
16
|
+
out += b.toString(16).padStart(2, '0');
|
|
17
|
+
return out;
|
|
18
|
+
};
|
|
19
|
+
const bytesToBase64 = (bytes) => {
|
|
20
|
+
let binary = '';
|
|
21
|
+
for (const b of bytes)
|
|
22
|
+
binary += String.fromCharCode(b);
|
|
23
|
+
return btoa(binary);
|
|
24
|
+
};
|
|
25
|
+
/** Lenient like Node's `Buffer.from(_, 'hex')`: stops at the first bad pair. */
|
|
26
|
+
const hexToBytes = (hex) => {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (let i = 0; i + 1 < hex.length; i += 2) {
|
|
29
|
+
const pair = hex.slice(i, i + 2);
|
|
30
|
+
if (!/^[0-9a-fA-F]{2}$/.test(pair))
|
|
31
|
+
break;
|
|
32
|
+
out.push(parseInt(pair, 16));
|
|
33
|
+
}
|
|
34
|
+
return Uint8Array.from(out);
|
|
35
|
+
};
|
|
36
|
+
/** Lenient like Node's `Buffer.from(_, 'base64')`: accepts base64url, ignores junk. */
|
|
37
|
+
const base64ToBytes = (value) => {
|
|
38
|
+
let clean = value
|
|
39
|
+
.replace(/-/g, '+')
|
|
40
|
+
.replace(/_/g, '/')
|
|
41
|
+
.replace(/[^A-Za-z0-9+/]/g, '');
|
|
42
|
+
if (clean.length % 4 === 1)
|
|
43
|
+
clean = clean.slice(0, -1);
|
|
44
|
+
clean += '='.repeat((4 - (clean.length % 4)) % 4);
|
|
45
|
+
const binary = atob(clean);
|
|
46
|
+
const out = new Uint8Array(binary.length);
|
|
47
|
+
for (let i = 0; i < binary.length; i++)
|
|
48
|
+
out[i] = binary.charCodeAt(i);
|
|
49
|
+
return out;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Constant-time string comparison (pure JS; for opaque tokens, not HMACs, which
|
|
53
|
+
* `verifyHmacSignature` checks with Web Crypto). Returns false on a length mismatch
|
|
54
|
+
* (the lengths of the two values are not secret).
|
|
55
|
+
*/
|
|
56
|
+
export function timingSafeStringEqual(a, b) {
|
|
57
|
+
const left = encoder.encode(a);
|
|
58
|
+
const right = encoder.encode(b);
|
|
59
|
+
if (left.length !== right.length)
|
|
60
|
+
return false;
|
|
61
|
+
let diff = 0;
|
|
62
|
+
for (let i = 0; i < left.length; i++)
|
|
63
|
+
diff |= left[i] ^ right[i];
|
|
64
|
+
return diff === 0;
|
|
65
|
+
}
|
|
66
|
+
const HMAC_HASH = {
|
|
67
|
+
sha1: 'SHA-1',
|
|
68
|
+
sha256: 'SHA-256',
|
|
69
|
+
sha512: 'SHA-512',
|
|
70
|
+
};
|
|
71
|
+
const rawSecretBytes = (secret, encoding) => encoding === 'hex'
|
|
72
|
+
? hexToBytes(secret)
|
|
73
|
+
: encoding === 'base64'
|
|
74
|
+
? base64ToBytes(secret)
|
|
75
|
+
: encoder.encode(secret);
|
|
76
|
+
const secretBytes = (secret, encoding) => {
|
|
77
|
+
const bytes = rawSecretBytes(secret, encoding);
|
|
78
|
+
// Web Crypto refuses a zero-length key. HMAC zero-pads the key to the block
|
|
79
|
+
// size, so a single zero byte is the same key as an empty one. Only signing
|
|
80
|
+
// reaches here: verification refuses an empty key outright.
|
|
81
|
+
return bytes.length ? bytes : new Uint8Array(1);
|
|
82
|
+
};
|
|
83
|
+
const hmacBytes = async (secret, algorithm, payload, secretEncoding = 'utf8') => {
|
|
84
|
+
const hash = HMAC_HASH[algorithm];
|
|
85
|
+
if (!hash)
|
|
86
|
+
throw new Error(`Unsupported HMAC algorithm '${algorithm}'`);
|
|
87
|
+
const key = await subtle().importKey('raw', secretBytes(secret, secretEncoding), { name: 'HMAC', hash }, false, ['sign']);
|
|
88
|
+
const data = typeof payload === 'string' ? encoder.encode(payload) : payload;
|
|
89
|
+
return new Uint8Array(await subtle().sign('HMAC', key, data));
|
|
90
|
+
};
|
|
2
91
|
/**
|
|
3
92
|
* HMAC-SHA256 of a payload, hex-encoded. Senders wrap this in their own scheme
|
|
4
93
|
* prefix (`sha256=`, `v0=`, …).
|
|
5
94
|
*/
|
|
6
|
-
export function hmacSha256Hex(secret, payload) {
|
|
7
|
-
return
|
|
95
|
+
export async function hmacSha256Hex(secret, payload) {
|
|
96
|
+
return bytesToHex(await hmacBytes(secret, 'sha256', payload));
|
|
97
|
+
}
|
|
98
|
+
/** The HMAC of `payload` under `secret`, for handshakes that answer with a digest. */
|
|
99
|
+
export async function hmacDigest(secret, algorithm, payload, encoding, secretEncoding = 'utf8') {
|
|
100
|
+
const digest = await hmacBytes(secret, algorithm, payload, secretEncoding);
|
|
101
|
+
return encoding === 'hex' ? bytesToHex(digest) : bytesToBase64(digest);
|
|
8
102
|
}
|
|
9
103
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
104
|
+
* Whether `signature` is the HMAC of `payload` under `secret`. Fails closed on
|
|
105
|
+
* an empty secret (or one that decodes to an empty key) and on a missing or
|
|
106
|
+
* malformed signature. The comparison is `crypto.subtle.verify('HMAC', ...)`,
|
|
107
|
+
* so it runs in the platform's constant-time implementation.
|
|
12
108
|
*/
|
|
13
|
-
export function
|
|
109
|
+
export async function verifyHmacSignature(secret, signature, algorithm, payload, encoding, secretEncoding = 'utf8') {
|
|
110
|
+
if (!secret || !signature)
|
|
111
|
+
return false;
|
|
14
112
|
try {
|
|
15
|
-
|
|
113
|
+
const hash = HMAC_HASH[algorithm];
|
|
114
|
+
if (!hash)
|
|
115
|
+
return false;
|
|
116
|
+
const keyBytes = rawSecretBytes(secret, secretEncoding);
|
|
117
|
+
if (!keyBytes.length)
|
|
118
|
+
return false;
|
|
119
|
+
if (encoding === 'hex' && !/^(?:[0-9a-fA-F]{2})+$/.test(signature)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const sig = encoding === 'hex' ? hexToBytes(signature) : base64ToBytes(signature);
|
|
123
|
+
if (!sig.length)
|
|
124
|
+
return false;
|
|
125
|
+
const key = await subtle().importKey('raw', keyBytes, { name: 'HMAC', hash }, false, ['verify']);
|
|
126
|
+
const data = typeof payload === 'string' ? encoder.encode(payload) : payload;
|
|
127
|
+
return await subtle().verify('HMAC', key, sig, data);
|
|
16
128
|
}
|
|
17
129
|
catch {
|
|
18
130
|
return false;
|
|
19
131
|
}
|
|
20
132
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
133
|
+
// DER prefixes of the SPKI algorithm identifiers we can import.
|
|
134
|
+
const OID_RSA = '06092a864886f70d010101';
|
|
135
|
+
const OID_EC = '06072a8648ce3d0201';
|
|
136
|
+
const OID_ED25519 = '06032b6570';
|
|
137
|
+
const EC_CURVES = {
|
|
138
|
+
'06082a8648ce3d030107': { name: 'P-256', size: 32 },
|
|
139
|
+
'06052b81040022': { name: 'P-384', size: 48 },
|
|
140
|
+
'06052b81040023': { name: 'P-521', size: 66 },
|
|
141
|
+
};
|
|
142
|
+
const SIGNATURE_HASH = {
|
|
143
|
+
sha1: 'SHA-1',
|
|
144
|
+
sha256: 'SHA-256',
|
|
145
|
+
sha384: 'SHA-384',
|
|
146
|
+
sha512: 'SHA-512',
|
|
147
|
+
};
|
|
148
|
+
const pemToDer = (pem) => base64ToBytes(pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, ''));
|
|
149
|
+
/** Reads an ASN.1 DER length at `offset`; returns [length, bytesUsed]. */
|
|
150
|
+
const readLength = (der, offset) => {
|
|
151
|
+
const first = der[offset];
|
|
152
|
+
if (first < 0x80)
|
|
153
|
+
return [first, 1];
|
|
154
|
+
const count = first & 0x7f;
|
|
155
|
+
let length = 0;
|
|
156
|
+
for (let i = 0; i < count; i++)
|
|
157
|
+
length = (length << 8) | der[offset + 1 + i];
|
|
158
|
+
return [length, 1 + count];
|
|
159
|
+
};
|
|
160
|
+
/** An ECDSA signature as DER `SEQUENCE { r, s }` to the fixed-width `r || s` Web Crypto expects. */
|
|
161
|
+
const derToP1363 = (der, size) => {
|
|
162
|
+
if (der[0] !== 0x30)
|
|
163
|
+
throw new Error('Invalid DER signature');
|
|
164
|
+
let offset = 1 + readLength(der, 1)[1];
|
|
165
|
+
const out = new Uint8Array(size * 2);
|
|
166
|
+
for (let part = 0; part < 2; part++) {
|
|
167
|
+
if (der[offset] !== 0x02)
|
|
168
|
+
throw new Error('Invalid DER signature');
|
|
169
|
+
const [length, used] = readLength(der, offset + 1);
|
|
170
|
+
let integer = der.slice(offset + 1 + used, offset + 1 + used + length);
|
|
171
|
+
while (integer.length > size && integer[0] === 0)
|
|
172
|
+
integer = integer.slice(1);
|
|
173
|
+
if (integer.length > size)
|
|
174
|
+
throw new Error('Invalid DER signature');
|
|
175
|
+
out.set(integer, part * size + (size - integer.length));
|
|
176
|
+
offset += 1 + used + length;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Whether `signature` (base64) is `payload` signed by the private half of
|
|
182
|
+
* `publicKey` (an RSA, EC or Ed25519 PEM `SPKI` key). RSA-PSS keys are not
|
|
183
|
+
* supported and verify as false. Ed25519 ignores `algorithm` and `dsaEncoding`.
|
|
184
|
+
*/
|
|
185
|
+
export async function verifyPublicKeySignature(publicKey, signature, payload, options = {}) {
|
|
186
|
+
if (!publicKey || !signature)
|
|
35
187
|
return false;
|
|
36
188
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
189
|
+
const der0 = pemToDer(publicKey);
|
|
190
|
+
if (bytesToHex(der0).includes(OID_ED25519)) {
|
|
191
|
+
const key = await subtle().importKey('spki', der0, { name: 'Ed25519' }, false, ['verify']);
|
|
192
|
+
return await subtle().verify({ name: 'Ed25519' }, key, base64ToBytes(signature), (typeof payload === 'string'
|
|
193
|
+
? encoder.encode(payload)
|
|
194
|
+
: payload));
|
|
195
|
+
}
|
|
196
|
+
const hash = SIGNATURE_HASH[(options.algorithm ?? 'sha256')
|
|
197
|
+
.toLowerCase()
|
|
198
|
+
.replace(/^rsa-/, '')
|
|
199
|
+
.replace(/-/g, '')];
|
|
200
|
+
if (!hash)
|
|
201
|
+
return false;
|
|
202
|
+
const der = pemToDer(publicKey);
|
|
203
|
+
const hex = bytesToHex(der);
|
|
204
|
+
const data = typeof payload === 'string' ? encoder.encode(payload) : payload;
|
|
205
|
+
const sig = base64ToBytes(signature);
|
|
206
|
+
if (hex.includes(OID_RSA)) {
|
|
207
|
+
const key = await subtle().importKey('spki', der, { name: 'RSASSA-PKCS1-v1_5', hash }, false, ['verify']);
|
|
208
|
+
return await subtle().verify('RSASSA-PKCS1-v1_5', key, sig, data);
|
|
209
|
+
}
|
|
210
|
+
const curve = hex.includes(OID_EC)
|
|
211
|
+
? Object.entries(EC_CURVES).find(([oid]) => hex.includes(oid))?.[1]
|
|
212
|
+
: undefined;
|
|
213
|
+
if (!curve)
|
|
214
|
+
return false;
|
|
215
|
+
const key = await subtle().importKey('spki', der, { name: 'ECDSA', namedCurve: curve.name }, false, ['verify']);
|
|
216
|
+
const raw = options.dsaEncoding === 'ieee-p1363' ? sig : derToP1363(sig, curve.size);
|
|
217
|
+
return await subtle().verify({ name: 'ECDSA', hash }, key, raw, data);
|
|
40
218
|
}
|
|
41
219
|
catch {
|
|
42
220
|
return false;
|
|
@@ -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 {};
|