@pikku/core 0.12.138 → 0.12.141

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/lifecycle.d.ts +6 -0
  3. package/dist/lifecycle.js +58 -0
  4. package/dist/middleware/addon-scope-cap.d.ts +19 -0
  5. package/dist/middleware/addon-scope-cap.js +34 -0
  6. package/dist/middleware/index.d.ts +1 -0
  7. package/dist/middleware/index.js +1 -0
  8. package/dist/scopes.d.ts +8 -0
  9. package/dist/scopes.js +23 -0
  10. package/dist/services/content-service.d.ts +13 -1
  11. package/dist/services/index.d.ts +2 -1
  12. package/dist/services/index.js +1 -0
  13. package/dist/services/local-content-request-handler.js +21 -60
  14. package/dist/services/local-content.d.ts +45 -1
  15. package/dist/services/local-content.js +202 -18
  16. package/dist/services/scoped-content-service.d.ts +35 -0
  17. package/dist/services/scoped-content-service.js +171 -0
  18. package/dist/services/scoped-secret-service.d.ts +1 -0
  19. package/dist/services/scoped-secret-service.js +3 -0
  20. package/dist/services/secret-service.d.ts +6 -0
  21. package/dist/services/typed-secret-service.d.ts +1 -0
  22. package/dist/services/typed-secret-service.js +7 -0
  23. package/dist/testing/service-tests/secret-service-tests.js +9 -0
  24. package/dist/types/state.types.d.ts +8 -0
  25. package/dist/utils.d.ts +2 -5
  26. package/dist/utils.js +1 -38
  27. package/dist/wirings/addon/addon-reach.d.ts +3 -0
  28. package/dist/wirings/addon/addon-reach.js +21 -0
  29. package/dist/wirings/addon/addon-runner.d.ts +6 -0
  30. package/dist/wirings/addon/addon-runner.js +13 -0
  31. package/dist/wirings/addon/define-screens.d.ts +32 -0
  32. package/dist/wirings/addon/define-screens.js +5 -0
  33. package/dist/wirings/addon/index.d.ts +2 -0
  34. package/dist/wirings/addon/index.js +1 -0
  35. package/dist/wirings/addon/wire-addon.d.ts +27 -0
  36. package/dist/wirings/addon/wire-addon.js +4 -0
  37. package/dist/wirings/agent/agent-prepare.js +3 -0
  38. package/dist/wirings/cli/cli-runner.js +24 -9
  39. package/dist/wirings/http/http-runner.js +4 -0
  40. package/dist/wirings/http/http.types.d.ts +1 -0
  41. package/dist/wirings/http/pikku-fetch-http-response.d.ts +1 -0
  42. package/dist/wirings/http/pikku-fetch-http-response.js +12 -0
  43. package/dist/wirings/http/web-request.js +7 -0
  44. package/dist/wirings/rpc/rpc-runner.d.ts +4 -0
  45. package/dist/wirings/rpc/rpc-runner.js +41 -2
  46. package/dist/wirings/rpc/rpc-types.d.ts +6 -0
  47. package/dist/wirings/scope/index.d.ts +1 -1
  48. package/dist/wirings/scope/index.js +1 -1
  49. package/package.json +2 -1
  50. package/src/public-surface.json +64 -15
package/dist/utils.js CHANGED
@@ -1,4 +1,3 @@
1
- import { getSingletonServices, getAllPackageStates } from './pikku-state.js';
2
1
  import { BadRequestError } from './errors/errors.js';
3
2
  export const closeWireServices = async (logger, wireServices) => {
4
3
  await Promise.all(Object.values(wireServices).map(async (service) => {
@@ -77,42 +76,6 @@ export const freezeDedupe = (arr) => {
77
76
  }
78
77
  return Object.freeze(out);
79
78
  };
80
- const stopService = async (logger, name, service) => {
81
- const stop = service?.stop;
82
- if (stop) {
83
- logger.info(`Stopping singleton service: ${name}`);
84
- try {
85
- await stop.call(service);
86
- }
87
- catch (e) {
88
- logger.error(`Error stopping service ${name}:`, e);
89
- }
90
- }
91
- };
92
- /** Stops addon package services first, then the parent singleton services. */
93
- export const stopSingletonServices = async () => {
94
- const singletonServices = getSingletonServices();
95
- const logger = singletonServices.logger;
96
- const stateMap = getAllPackageStates();
97
- if (stateMap.size > 0) {
98
- for (const [packageName, packageState] of stateMap) {
99
- if (packageName === '__main__')
100
- continue;
101
- const packageServices = packageState.package?.singletonServices;
102
- if (packageServices) {
103
- logger.info(`Stopping singleton services for package: ${packageName}`);
104
- for (const [name, service] of Object.entries(packageServices)) {
105
- await stopService(logger, `${packageName}/${name}`, service);
106
- }
107
- packageState.package.singletonServices = null;
108
- }
109
- }
110
- }
111
- for (const [name, service] of Object.entries(singletonServices)) {
112
- await stopService(logger, name, service);
113
- }
114
- };
115
- /** Wrap server lifecycle hooks so the inspector can discover them. */
116
- export const pikkuServerLifecycle = (lifecycle) => lifecycle;
79
+ export { stopSingletonServices, pikkuServerLifecycle } from './lifecycle.js';
117
80
  export { getDurationInMilliseconds, getRelativeTimeOffset, getRelativeTimeOffsetFromNow, parseDurationString, } from './time-utils.js';
118
81
  export { formatVersionedId, isVersionedId, parseVersionedId, } from './version.js';
@@ -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';
@@ -41,8 +41,21 @@ export type WireAddonConfig = {
41
41
  mcpEndpoint?: boolean | string;
42
42
  /** Filters this addon in and out of a build — see the `tags` option on `pikku all`. It has no effect at runtime. */
43
43
  tags?: string[];
44
+ /**
45
+ * Mounts the screens the addon ships (see `defineScreens`) in the host under
46
+ * `/addons/<name>`. Without it the addon's screens are ignored. The package
47
+ * must declare at least one, or `pikku all` rejects the wiring.
48
+ */
49
+ ui?: boolean;
44
50
  /** Required of every function in the addon, on top of the function's own. */
45
51
  scopes?: string[];
52
+ /**
53
+ * The other addons this one may call, as `{ '<package>': '<name>' }`: the
54
+ * package it was written against, and the name that package is wired under
55
+ * in this app. The addon calls `'<package>:<function>'`; anything it did not
56
+ * list is refused, whatever else the app has wired.
57
+ */
58
+ uses?: Record<string, string>;
46
59
  /** Points a secret the addon reads at a different key in this deployment, so two instances can hold different credentials. */
47
60
  secretOverrides?: Record<string, string>;
48
61
  /** Points a variable the addon reads at a different key in this deployment. */
@@ -65,6 +78,20 @@ export type WireAddonConfig = {
65
78
  * deploy manifest, and only the consuming app can grant it.
66
79
  */
67
80
  globalCredentials?: string;
81
+ /**
82
+ * The folder of the content store this instance owns. Everything it stores
83
+ * lands under it and it cannot see past it. Defaults to `name`.
84
+ */
85
+ contentBucket?: string;
86
+ /**
87
+ * Paths outside its own folder this instance may use, by path prefix:
88
+ * `read` allows reads and signed downloads, `write` adds uploads, writes and
89
+ * deletes. The longest matching prefix decides. The addon names a granted
90
+ * path by starting its bucket with `@`.
91
+ */
92
+ contentGrants?: Record<string, 'read' | 'write'>;
93
+ /** Hands over the whole `ContentService` unscoped. The value is the reason, recorded in the deploy manifest. */
94
+ globalContent?: string;
68
95
  };
69
96
  /**
70
97
  * Installs an addon into this project: its functions, wirings and scopes become
@@ -14,6 +14,7 @@ export const wireAddon = (config) => {
14
14
  tags: config.tags,
15
15
  ...(config.expose !== undefined ? { expose: config.expose } : {}),
16
16
  ...(config.scopes ? { scopes: config.scopes } : {}),
17
+ ...(config.uses ? { uses: config.uses } : {}),
17
18
  ...(config.secretOverrides
18
19
  ? { secretOverrides: config.secretOverrides }
19
20
  : {}),
@@ -31,6 +32,9 @@ export const wireAddon = (config) => {
31
32
  ...(config.globalCredentials
32
33
  ? { globalCredentials: config.globalCredentials }
33
34
  : {}),
35
+ ...(config.contentBucket ? { contentBucket: config.contentBucket } : {}),
36
+ ...(config.contentGrants ? { contentGrants: config.contentGrants } : {}),
37
+ ...(config.globalContent ? { globalContent: config.globalContent } : {}),
34
38
  });
35
39
  };
36
40
  /**
@@ -389,6 +389,9 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
389
389
  credentialGrants: capturedAddonConfig?.credentialGrants,
390
390
  globalSecrets: capturedAddonConfig?.globalSecrets,
391
391
  globalCredentials: capturedAddonConfig?.globalCredentials,
392
+ contentBucket: capturedAddonConfig?.contentBucket,
393
+ contentGrants: capturedAddonConfig?.contentGrants,
394
+ globalContent: capturedAddonConfig?.globalContent,
392
395
  }
393
396
  : undefined;
394
397
  services = await getOrCreatePackageSingletonServices(capturedPkg, singletonServices, addonInstance);
@@ -95,11 +95,26 @@ function registerCLICommands(commands, path = [], inheritedOptions = {}, program
95
95
  const unwrapped = unwrapFunc(command);
96
96
  const commandAuth = typeof command === 'object' ? command.auth : undefined;
97
97
  const commandPermissions = typeof command === 'object' ? command.permissions : undefined;
98
- addFunction(funcName, {
99
- ...unwrapped,
100
- auth: commandAuth ?? unwrapped.auth,
101
- permissions: commandPermissions ?? unwrapped.permissions,
102
- }, currentMeta?.packageName);
98
+ if (currentMeta?.packageName && !funcName.includes(':')) {
99
+ if (commandAuth !== undefined || commandPermissions !== undefined) {
100
+ const registered = pikkuState(currentMeta.packageName, 'function', 'functions');
101
+ const existing = registered.get(funcName);
102
+ if (existing) {
103
+ registered.set(funcName, {
104
+ ...existing,
105
+ auth: commandAuth ?? existing.auth,
106
+ permissions: commandPermissions ?? existing.permissions,
107
+ });
108
+ }
109
+ }
110
+ }
111
+ if (!currentMeta?.packageName || funcName.includes(':')) {
112
+ addFunction(funcName, {
113
+ ...unwrapped,
114
+ auth: commandAuth ?? unwrapped.auth,
115
+ permissions: commandPermissions ?? unwrapped.permissions,
116
+ }, currentMeta?.packageName);
117
+ }
103
118
  if (typeof command === 'object' && command.render) {
104
119
  if (programs[program]) {
105
120
  programs[program].renderers[commandId] = command.render;
@@ -110,11 +125,11 @@ function registerCLICommands(commands, path = [], inheritedOptions = {}, program
110
125
  }
111
126
  }
112
127
  }
113
- function pluckCLIData(mergedData, funcName, availableOptions) {
114
- const funcMeta = pikkuState(null, 'function', 'meta')[funcName];
128
+ function pluckCLIData(mergedData, funcName, availableOptions, packageName) {
129
+ const funcMeta = pikkuState(packageName ?? null, 'function', 'meta')[funcName];
115
130
  const schemaName = funcMeta?.inputSchemaName;
116
131
  const schema = schemaName
117
- ? pikkuState(funcMeta?.packageName ?? null, 'misc', 'schemas').get(schemaName)
132
+ ? pikkuState(packageName ?? funcMeta?.packageName ?? null, 'misc', 'schemas').get(schemaName)
118
133
  : null;
119
134
  if (schema && schema.properties) {
120
135
  const result = {};
@@ -172,7 +187,7 @@ export async function runCLICommand({ program, commandPath, data, singletonServi
172
187
  }
173
188
  const commandId = commandPath.join('.');
174
189
  const availableOptions = programData?.commandOptions?.[commandId] || {};
175
- const pluckedData = () => pluckCLIData(data, funcName, availableOptions);
190
+ const pluckedData = () => pluckCLIData(data, funcName, availableOptions, currentCommand.packageName);
176
191
  const renderer = programData?.renderers[commandId] || programData?.defaultRenderer;
177
192
  let cliState;
178
193
  const channel = {
@@ -164,6 +164,10 @@ const executeRoute = async (services, matchedRoute, http, options) => {
164
164
  await originalClose();
165
165
  };
166
166
  }
167
+ response.onClose?.(() => {
168
+ channel.state = 'closed';
169
+ singletonServices.eventHub?.onChannelClosed(channelId);
170
+ });
167
171
  }
168
172
  const wire = {
169
173
  traceId: requestId,
@@ -185,6 +185,7 @@ export interface PikkuHTTPResponse<Out = unknown> {
185
185
  send?(data: string | ArrayBuffer | ArrayBufferView): this;
186
186
  redirect(location: string, status?: number): this;
187
187
  close?: () => void;
188
+ onClose?: (callback: () => void) => void;
188
189
  setMode?: (mode: 'stream') => void;
189
190
  flushHeaders?: () => void;
190
191
  }
@@ -14,6 +14,7 @@ export declare class PikkuFetchHTTPResponse implements PikkuHTTPResponse {
14
14
  send(data: any): this;
15
15
  redirect(location: string, status?: number): this;
16
16
  close(): this;
17
+ onClose(callback: () => void): void;
17
18
  toResponse(args?: Record<string, any>): Response;
18
19
  private createStream;
19
20
  }
@@ -8,6 +8,7 @@ export class PikkuFetchHTTPResponse {
8
8
  #send = null;
9
9
  #close = null;
10
10
  #cancelStream = null;
11
+ #onClose = [];
11
12
  setMode(mode) {
12
13
  this.#responseMode = 'stream';
13
14
  if (mode === 'stream') {
@@ -95,6 +96,15 @@ export class PikkuFetchHTTPResponse {
95
96
  }
96
97
  return this;
97
98
  }
99
+ onClose(callback) {
100
+ this.#onClose.push(callback);
101
+ }
102
+ #closed() {
103
+ const callbacks = this.#onClose;
104
+ this.#onClose = [];
105
+ for (const callback of callbacks)
106
+ callback();
107
+ }
98
108
  toResponse(args) {
99
109
  const cookieHeader = Array.from(this.#cookies.entries()).map(([name, { value, flags }]) => stringifySetCookie({ name, value, ...flags }, flags));
100
110
  cookieHeader.forEach((cookie) => this.#headers.append('Set-Cookie', cookie));
@@ -119,9 +129,11 @@ export class PikkuFetchHTTPResponse {
119
129
  return;
120
130
  closed = true;
121
131
  controller.close();
132
+ this.#closed();
122
133
  };
123
134
  this.#cancelStream = () => {
124
135
  closed = true;
136
+ this.#closed();
125
137
  };
126
138
  this.#send = send;
127
139
  this.#close = close;
@@ -1,3 +1,4 @@
1
+ import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
1
2
  export function toWebRequest(req, baseUrl) {
2
3
  const proto = req.header('x-forwarded-proto') ?? 'http';
3
4
  const host = req.header('x-forwarded-host') ?? req.header('host') ?? 'localhost';
@@ -120,6 +121,12 @@ export async function applyWebResponse(res, webResponse) {
120
121
  if (setCookieValues.length > 0) {
121
122
  res.header('Set-Cookie', setCookieValues);
122
123
  }
124
+ if (webResponse.body &&
125
+ res instanceof PikkuFetchHTTPResponse &&
126
+ webResponse.headers.get('content-type')?.startsWith('text/event-stream')) {
127
+ res.send(webResponse.body);
128
+ return;
129
+ }
123
130
  const body = await readBody(webResponse);
124
131
  if (body !== null) {
125
132
  if (res.send) {
@@ -14,6 +14,9 @@ export declare class RemoteAddonRequestError extends PikkuError {
14
14
  readonly httpStatus: number;
15
15
  constructor(namespace: string, fnName: string, status: number, detail: string);
16
16
  }
17
+ export declare class AddonNotUsedError extends PikkuError {
18
+ constructor(caller: string, target: string);
19
+ }
17
20
  export declare const resolveNamespace: (namespacedFunction: string) => ResolvedFunction | null;
18
21
  export declare class ContextAwareRPCService {
19
22
  private services;
@@ -26,6 +29,7 @@ export declare class ContextAwareRPCService {
26
29
  }, packageName?: string | null);
27
30
  rpcExposed(funcName: string, data: any): Promise<any>;
28
31
  rpc<In = any, Out = any>(funcName: string, data: In): Promise<Out>;
32
+ private mapUsedAddon;
29
33
  /**
30
34
  * Resolution half of an addon RPC call: never executes anything, so an
31
35
  * `RPCNotFoundError` thrown by the function itself can never be mistaken for
@@ -1,3 +1,4 @@
1
+ import { addonCallerOf, addonMayReach } from '../addon/addon-reach.js';
1
2
  import { forwardStepName, isCompensationStepName, } from '../../function/compensation-name.js';
2
3
  import { runPikkuFunc } from '../../function/function-runner.js';
3
4
  import { addonInstanceForNamespace } from '../addon/addon-runner.js';
@@ -43,6 +44,15 @@ addError(RemoteAddonRequestError, {
43
44
  status: 502,
44
45
  message: 'Remote addon request failed.',
45
46
  });
47
+ export class AddonNotUsedError extends PikkuError {
48
+ constructor(caller, target) {
49
+ super(`Addon '${caller}' called '${target}' but does not list it in its wireAddon 'uses'.`);
50
+ }
51
+ }
52
+ addError(AddonNotUsedError, {
53
+ status: 403,
54
+ message: 'Addon called another addon it does not use.',
55
+ });
46
56
  export const resolveNamespace = (namespacedFunction) => {
47
57
  const colonIndex = namespacedFunction.indexOf(':');
48
58
  if (colonIndex === -1) {
@@ -68,6 +78,7 @@ const resolvePikkuFunction = (rpcName, packageName = null) => {
68
78
  if (pkgMeta) {
69
79
  return { pikkuFuncId: pkgMeta.pikkuFuncId || rpcName, packageName };
70
80
  }
81
+ throw new RPCNotFoundError(rpcName);
71
82
  }
72
83
  const rpc = pikkuState(null, 'rpc', 'meta');
73
84
  let rpcMeta = rpc[rpcName];
@@ -119,6 +130,10 @@ export class ContextAwareRPCService {
119
130
  this.packageName = packageName;
120
131
  }
121
132
  async rpcExposed(funcName, data) {
133
+ const addonCaller = addonCallerOf(this.wire);
134
+ if (addonCaller && !addonMayReach(addonCaller, funcName)) {
135
+ throw new AddonNotUsedError(addonCaller, funcName);
136
+ }
122
137
  let functionMeta;
123
138
  let resolvedAddon = null;
124
139
  if (funcName.includes(':')) {
@@ -159,6 +174,7 @@ export class ContextAwareRPCService {
159
174
  ...this.wire,
160
175
  };
161
176
  if (funcName.includes(':')) {
177
+ funcName = this.mapUsedAddon(funcName);
162
178
  const addonCall = this.resolveAddonFunction(funcName);
163
179
  if (addonCall !== NOT_RESOLVED) {
164
180
  return await this.executeAddonFunction(addonCall, data, updatedWire);
@@ -170,7 +186,7 @@ export class ContextAwareRPCService {
170
186
  }
171
187
  catch (e) {
172
188
  if (e instanceof RPCNotFoundError) {
173
- if (this.services.deploymentService) {
189
+ if (!this.packageName && this.services.deploymentService) {
174
190
  const session = await resolveWireSession(this.wire);
175
191
  return this.services.deploymentService.invoke(funcName, data, session, this.wire.traceId);
176
192
  }
@@ -189,6 +205,20 @@ export class ContextAwareRPCService {
189
205
  addonInstance,
190
206
  });
191
207
  }
208
+ mapUsedAddon(funcName) {
209
+ const caller = this.wire.addonNamespace;
210
+ if (!caller)
211
+ return funcName;
212
+ const colon = funcName.indexOf(':');
213
+ const prefix = funcName.slice(0, colon);
214
+ if (prefix === caller)
215
+ return funcName;
216
+ const uses = pikkuState(null, 'addons', 'packages').get(caller)?.uses;
217
+ const mapped = uses?.[prefix];
218
+ if (mapped)
219
+ return `${mapped}${funcName.slice(colon)}`;
220
+ throw new AddonNotUsedError(caller, funcName);
221
+ }
192
222
  /**
193
223
  * Resolution half of an addon RPC call: never executes anything, so an
194
224
  * `RPCNotFoundError` thrown by the function itself can never be mistaken for
@@ -243,6 +273,9 @@ export class ContextAwareRPCService {
243
273
  credentialGrants: resolved.addonConfig?.credentialGrants,
244
274
  globalSecrets: resolved.addonConfig?.globalSecrets,
245
275
  globalCredentials: resolved.addonConfig?.globalCredentials,
276
+ contentBucket: resolved.addonConfig?.contentBucket,
277
+ contentGrants: resolved.addonConfig?.contentGrants,
278
+ globalContent: resolved.addonConfig?.globalContent,
246
279
  },
247
280
  };
248
281
  }
@@ -305,6 +338,7 @@ export class ContextAwareRPCService {
305
338
  ...wire,
306
339
  };
307
340
  if (rpcName.includes(':')) {
341
+ rpcName = this.mapUsedAddon(rpcName);
308
342
  const addonCall = this.resolveAddonFunction(rpcName);
309
343
  if (addonCall !== NOT_RESOLVED) {
310
344
  return await this.executeAddonFunction(addonCall, data, mergedWire);
@@ -315,7 +349,9 @@ export class ContextAwareRPCService {
315
349
  resolved = resolvePikkuFunction(rpcName, this.packageName);
316
350
  }
317
351
  catch (e) {
318
- if (e instanceof RPCNotFoundError && this.services.deploymentService) {
352
+ if (e instanceof RPCNotFoundError &&
353
+ !this.packageName &&
354
+ this.services.deploymentService) {
319
355
  const session = await resolveWireSession(mergedWire);
320
356
  return this.services.deploymentService.invoke(rpcName, data, session, mergedWire.traceId);
321
357
  }
@@ -337,6 +373,9 @@ export class ContextAwareRPCService {
337
373
  if (!this.services.workflowService) {
338
374
  throw new Error('WorkflowService service not available');
339
375
  }
376
+ if (workflowName.includes(':')) {
377
+ workflowName = this.mapUsedAddon(workflowName);
378
+ }
340
379
  const parentRunId = this.wire.workflowStep?.runId ?? this.wire.graph?.runId;
341
380
  const wire = options?.wire ?? {
342
381
  type: this.wire.wireType ?? 'unknown',
@@ -59,6 +59,12 @@ export interface ResolvedFunction {
59
59
  globalSecrets?: string;
60
60
  /** Set by the consuming app: hand this instance the unscoped `CredentialService` */
61
61
  globalCredentials?: string;
62
+ /** Set by the consuming app: the content folder this instance owns */
63
+ contentBucket?: string;
64
+ /** Set by the consuming app: paths outside its folder this instance may read or write */
65
+ contentGrants?: Record<string, 'read' | 'write'>;
66
+ /** Set by the consuming app: hand this instance the unscoped `ContentService` */
67
+ globalContent?: string;
62
68
  /** Set by `wireRemoteAddon`: dispatch this namespace's RPCs over HTTP */
63
69
  remote?: boolean;
64
70
  serverUrl?: string | ((services: any) => string | Promise<string>);
@@ -1,4 +1,4 @@
1
1
  export { defineScope, declareScopes } from './define-scope.js';
2
2
  export { flattenScopeDefinitions, validateAndBuildScopeDefinitionsMeta, } from './validate-scope-definitions.js';
3
3
  export type { CoreScopes, CoreScopeNode, FlatScope, ScopeDefinitionMeta, ScopeDefinitionsMeta, ScopeDefinitions, ScopeNodeMeta, ScopeOrigin, } from './scope.types.js';
4
- export { hasScopes, verifyScopes } from '../../scopes.js';
4
+ export { hasScopes, intersectScopes, verifyScopes } from '../../scopes.js';
@@ -1,3 +1,3 @@
1
1
  export { defineScope, declareScopes } from './define-scope.js';
2
2
  export { flattenScopeDefinitions, validateAndBuildScopeDefinitionsMeta, } from './validate-scope-definitions.js';
3
- export { hasScopes, verifyScopes } from '../../scopes.js';
3
+ export { hasScopes, intersectScopes, verifyScopes } from '../../scopes.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.138",
3
+ "version": "0.12.141",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -87,6 +87,7 @@
87
87
  "./state": "./dist/pikku-state.js",
88
88
  "./classification": "./dist/classification/index.js",
89
89
  "./utils": "./dist/utils.js",
90
+ "./lifecycle": "./dist/lifecycle.js",
90
91
  "./schema": "./dist/schema.js",
91
92
  "./testing": "./dist/testing/index.js",
92
93
  "./dev": "./dist/dev/hot-reload.js"