@pikku/core 0.12.122 → 0.12.125

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 (38) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/dev/module-runner.js +9 -0
  3. package/dist/function/function-meta.types.d.ts +5 -4
  4. package/dist/function/function-runner.js +7 -0
  5. package/dist/pikku-state.js +2 -0
  6. package/dist/services/incoming-webhook-service.d.ts +48 -0
  7. package/dist/services/incoming-webhook-service.js +46 -0
  8. package/dist/services/index.d.ts +1 -0
  9. package/dist/services/index.js +1 -0
  10. package/dist/services/meta-service.d.ts +8 -0
  11. package/dist/services/meta-service.js +18 -0
  12. package/dist/types/core.types.d.ts +3 -0
  13. package/dist/types/state.types.d.ts +9 -1
  14. package/dist/wirings/addon/addon-runner.js +30 -0
  15. package/dist/wirings/addon/wire-addon.d.ts +2 -0
  16. package/dist/wirings/addon/wire-addon.js +6 -0
  17. package/dist/wirings/agent/agent-rpc.d.ts +2 -5
  18. package/dist/wirings/agent/agent-rpc.js +10 -0
  19. package/dist/wirings/agent/agent.types.d.ts +13 -0
  20. package/dist/wirings/agent/index.d.ts +7 -0
  21. package/dist/wirings/agent/index.js +6 -0
  22. package/dist/wirings/rpc/rpc-runner.d.ts +6 -3
  23. package/dist/wirings/rpc/rpc-runner.js +10 -4
  24. package/dist/wirings/trigger/index.d.ts +4 -0
  25. package/dist/wirings/trigger/index.js +2 -0
  26. package/dist/wirings/trigger/webhook-source-runner.d.ts +45 -0
  27. package/dist/wirings/trigger/webhook-source-runner.js +221 -0
  28. package/dist/wirings/trigger/webhook-source.types.d.ts +106 -0
  29. package/dist/wirings/trigger/webhook-source.types.js +1 -0
  30. package/dist/wirings/webhook/define-outgoing-webhook.d.ts +28 -0
  31. package/dist/wirings/webhook/define-outgoing-webhook.js +1 -0
  32. package/dist/wirings/webhook/index.d.ts +2 -0
  33. package/dist/wirings/webhook/index.js +1 -0
  34. package/knowledge/decisions/security/a-public-function-says-so-with-auth-false.md +34 -0
  35. package/knowledge/decisions/security/an-exposed-ungated-function-is-a-codegen-warning.md +4 -1
  36. package/knowledge/decisions/security/index.md +1 -0
  37. package/package.json +3 -1
  38. package/src/public-surface.json +21 -45
package/CHANGELOG.md CHANGED
@@ -1,3 +1,61 @@
1
+ ## 0.12.125
2
+
3
+ ### Patch Changes
4
+
5
+ - 5e93f30: Add `defineOutgoingWebhook({ event, title, description?, payload })` in `@pikku/core/webhook`. The CLI collects every exported declaration into `.pikku/webhooks/pikku-outgoing-webhooks-meta.gen.json` and `pikku-outgoing-webhooks.gen.ts`, which exports `OutgoingWebhooksMap`, `TypedWebhookService` and `typedWebhookService(service)`: `send` checks `data` against the declared payload for a declared event and accepts any other event unchanged. `MetaService.getOutgoingWebhooksMeta()` and the console addon's `outgoingWebhooksMeta` serve the declarations.
6
+ - 5e93f30: Add `wireTriggerWebhookSource({ name, method?, route?, secret?, events, receive?, check?, setup?, teardown? })` in `#pikku/trigger`. Each source becomes a `POST /webhooks/<name>` route whose events are validated against their schemas and queued on `pikku-incoming-webhooks` through `IncomingWebhookService`; a generated worker runs the matching `wireTrigger({ name: '<source>:<event>' })` and the queue retries it on failure. `pikku webhooks status | setup | teardown --url --labelPrefix [--previous]` registers the routes with the provider and prints one JSON line per source.
7
+
8
+ `KyselyIncomingWebhookService` (with the `incoming-webhook` schema) records a receipt per event, drops a provider's redelivery of an event it already accepted, and keeps each dispatch's attempts and last error. `pikku dev` and `pikku serve` use it when a Kysely database is configured.
9
+
10
+ ## 0.12.124
11
+
12
+ ### Patch Changes
13
+
14
+ - 1394385: Carry the wired instance through a `ref('ns:fn')` an app writes. The wiring is
15
+ the app's, so the reference arrives with no instance and the function used to
16
+ run against the addon's declared secrets alone — the consuming app's
17
+ `secretOverrides` and grants silently did not apply, and the app's own global
18
+ middleware was denied secrets it owns.
19
+
20
+ ## 0.12.123
21
+
22
+ ### Patch Changes
23
+
24
+ - 50b59a3: Keep the agent runtime out of deployment units that hold no agent.
25
+
26
+ `ContextAwareRPCService.agent` imported `agent-rpc.ts` directly, and every unit
27
+ reaches that class through the function runner, so the agent runner, stream,
28
+ memory and AGUI modules were pinned into every bundle — 51.8 KB raw / 15.7 KB
29
+ gzip a unit. The facade is now resolved through state, registered by
30
+ `@pikku/core/agent` on import, so only a unit that actually holds an agent
31
+ bundles it. A unit that reaches `rpc.agent` without importing the agent entry
32
+ point throws rather than silently pulling the runtime back in.
33
+
34
+ - 2b946e9: fix(dev): keep `import.meta` pointing at the real file in the hot-reload runner
35
+
36
+ The dev module runner transforms each user file to `cjs` before compiling it,
37
+ and esbuild's `cjs` output rewrites `import.meta` to an empty object. Any module
38
+ that resolves its own neighbours through `createRequire(import.meta.url)` —
39
+ sharp, onnxruntime-node, essentially every package with a native binding —
40
+ therefore received `undefined` and failed with `Cannot find module
41
+ '@img/sharp-linux-x64/sharp.node' from ''`. sharp's own loader filters on
42
+ `MODULE_NOT_FOUND` and reports its generic "could not load the sharp module"
43
+ instead, so the empty referrer never appears in the error the developer sees.
44
+
45
+ `import.meta.url`, `import.meta.filename` and `import.meta.dirname` are now
46
+ defined to the file being run, so resolution behaves as it does under Node's
47
+ ESM loader.
48
+
49
+ - bc488cf: fix(inspector): an explicit `auth: false` declares an exposed sessionless function public, so PKU574 no longer warns about it
50
+
51
+ A genuinely public endpoint — a published programme, a health check — had no
52
+ honest way to quiet PKU574: the only options were an always-true permission or
53
+ `permissionsInBody: true`, both of which claim a gate that does not exist. The
54
+ inspector now records `auth` on function meta exactly as written instead of
55
+ dropping `false`, and the check treats an explicit `auth: false` as the author
56
+ declaring the function public on purpose. A sessionless function that leaves
57
+ `auth` out still warns.
58
+
1
59
  ## 0.12.122
2
60
 
3
61
  ### Patch Changes
@@ -68,6 +68,15 @@ const evaluateModule = (absPath, cache) => {
68
68
  loader: loaderFor(absPath),
69
69
  format: 'cjs',
70
70
  sourcefile: absPath,
71
+ // `cjs` output rewrites `import.meta` to an empty object, so a module
72
+ // that resolves its own neighbours through `createRequire(import.meta.url)`
73
+ // -- sharp, onnxruntime-node, any package with a native binding -- gets
74
+ // `undefined` and fails with "Cannot find module ... from ''".
75
+ define: {
76
+ 'import.meta.url': JSON.stringify(pathToFileURL(absPath).href),
77
+ 'import.meta.filename': JSON.stringify(absPath),
78
+ 'import.meta.dirname': JSON.stringify(dirname(absPath)),
79
+ },
71
80
  });
72
81
  const fn = compileFunction(code, ['require', 'exports', 'module', '__filename', '__dirname'], { filename: absPath });
73
82
  const moduleObj = { exports: {} };
@@ -29,10 +29,11 @@ export type FunctionRuntimeMeta = {
29
29
  featureFlag?: string;
30
30
  expose?: boolean;
31
31
  /**
32
- * A sessionless function's own `auth: true`. `sessionless` carries the
33
- * baseline — a `pikkuFunc` always requires a session — and this carries the
34
- * tightening a `pikkuSessionlessFunc` applies to itself. Both are needed to
35
- * know whether a function is gated without running it.
32
+ * A sessionless function's own `auth`, as written. `sessionless` carries the
33
+ * baseline — a `pikkuFunc` always requires a session — and this carries what
34
+ * a `pikkuSessionlessFunc` declares about itself: `true` requires a session,
35
+ * `false` says it is public on purpose, absent says nothing. All three are
36
+ * needed to know whether an exposed function is gated without running it.
36
37
  */
37
38
  auth?: boolean;
38
39
  /**
@@ -98,6 +98,13 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
98
98
  funcMeta = pikkuState(addonTarget.packageName, 'function', 'meta')[addonTarget.localName];
99
99
  if (funcMeta) {
100
100
  funcPackageName = addonTarget.packageName;
101
+ // A `ref('ns:fn')` the app wired arrives with no instance, because the
102
+ // wiring is the app's. Without adopting the one the namespace resolves
103
+ // to, the function runs with the addon's declared secrets alone — the
104
+ // consuming app's `secretOverrides` and grants silently do not apply,
105
+ // and the app's own global middleware, running inside that scope, is
106
+ // denied secrets it owns.
107
+ addonInstance = addonInstance ?? addonTarget.instance;
101
108
  }
102
109
  }
103
110
  }
@@ -73,6 +73,8 @@ const createEmptyPackageState = () => ({
73
73
  triggerSources: new Map(),
74
74
  meta: {},
75
75
  sourceMeta: {},
76
+ webhookSources: new Map(),
77
+ webhookSourceMeta: {},
76
78
  },
77
79
  mcp: {
78
80
  resources: new Map(),
@@ -0,0 +1,48 @@
1
+ import type { QueueService } from '../wirings/queue/queue.types.js';
2
+ import { type TriggerEvent, type WebhookRequest, type WebhookSourceJob } from '../wirings/trigger/webhook-source.types.js';
3
+ export type IncomingWebhookAttempt = {
4
+ trigger: string;
5
+ error?: string;
6
+ };
7
+ export type IncomingWebhookReceiptRecord = {
8
+ receiptId: string;
9
+ source: string;
10
+ providerEventId: string | null;
11
+ event: string;
12
+ status: 'pending' | 'delivered' | 'failed';
13
+ attempts: number;
14
+ lastError: string | null;
15
+ createdAt: Date;
16
+ deliveredAt: Date | null;
17
+ };
18
+ /**
19
+ * Queues each event a webhook source received for the
20
+ * `pikku-incoming-webhooks` worker, so the provider is answered once the event
21
+ * is safe and our own processing is retried by the queue, not by the provider.
22
+ *
23
+ * The queue is a constructor argument, as in `QueueWebhookService`: a webhook
24
+ * source wired without one fails to compile rather than on its first delivery.
25
+ */
26
+ export declare class IncomingWebhookService {
27
+ protected queueService: QueueService;
28
+ /** How often a failing trigger is retried before the event is given up on. */
29
+ protected retries: number;
30
+ constructor(queueService: QueueService,
31
+ /** How often a failing trigger is retried before the event is given up on. */
32
+ retries?: number);
33
+ /** Returns how many events were queued. A store-backed service drops ones it has already seen. */
34
+ accept({ source, events, }: {
35
+ source: string;
36
+ request: WebhookRequest;
37
+ events: TriggerEvent[];
38
+ }): Promise<number>;
39
+ /** The job id is the provider's event id under its event name, which de-duplicates on queues that honour job ids. */
40
+ protected enqueue(job: WebhookSourceJob): Promise<string>;
41
+ /** Keeps nothing by default; a store-backed service records each dispatch. */
42
+ recordAttempt(_receiptId: string, _attempt: IncomingWebhookAttempt): Promise<void>;
43
+ /** Most recent first. Empty when nothing is stored. */
44
+ listReceipts(_opts?: {
45
+ source?: string;
46
+ limit?: number;
47
+ }): Promise<IncomingWebhookReceiptRecord[]>;
48
+ }
@@ -0,0 +1,46 @@
1
+ import { DEFAULT_WEBHOOK_RETRIES } from './webhook-service.js';
2
+ import { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME, } from '../wirings/trigger/webhook-source.types.js';
3
+ /**
4
+ * Queues each event a webhook source received for the
5
+ * `pikku-incoming-webhooks` worker, so the provider is answered once the event
6
+ * is safe and our own processing is retried by the queue, not by the provider.
7
+ *
8
+ * The queue is a constructor argument, as in `QueueWebhookService`: a webhook
9
+ * source wired without one fails to compile rather than on its first delivery.
10
+ */
11
+ export class IncomingWebhookService {
12
+ queueService;
13
+ retries;
14
+ constructor(queueService,
15
+ /** How often a failing trigger is retried before the event is given up on. */
16
+ retries = DEFAULT_WEBHOOK_RETRIES) {
17
+ this.queueService = queueService;
18
+ this.retries = retries;
19
+ }
20
+ /** Returns how many events were queued. A store-backed service drops ones it has already seen. */
21
+ async accept({ source, events, }) {
22
+ for (const event of events) {
23
+ await this.enqueue({ source, event });
24
+ }
25
+ return events.length;
26
+ }
27
+ /** The job id is the provider's event id under its event name, which de-duplicates on queues that honour job ids. */
28
+ async enqueue(job) {
29
+ const jobId = job.receiptId ??
30
+ (job.event.id
31
+ ? `${job.source}:${job.event.name}:${job.event.id}`
32
+ : undefined);
33
+ // knowledge: decisions/internals/queue-jobs-always-carry-an-explicit-attempts-count.md
34
+ return this.queueService.add(PIKKU_INCOMING_WEBHOOK_QUEUE_NAME, job, {
35
+ attempts: this.retries + 1,
36
+ ...(this.retries > 0 ? { backoff: 'exponential' } : {}),
37
+ ...(jobId ? { jobId } : {}),
38
+ });
39
+ }
40
+ /** Keeps nothing by default; a store-backed service records each dispatch. */
41
+ async recordAttempt(_receiptId, _attempt) { }
42
+ /** Most recent first. Empty when nothing is stored. */
43
+ async listReceipts(_opts) {
44
+ return [];
45
+ }
46
+ }
@@ -12,6 +12,7 @@ export { LocalVariablesService } from './local-variables.js';
12
12
  export { ConsoleLogger, JsonConsoleLogger } from './logger-console.js';
13
13
  export { InMemoryWorkflowService } from './in-memory-workflow-service.js';
14
14
  export { QueueWebhookService, pikkuWebhookWorkerFunc, } from './queue-webhook-service.js';
15
+ export { IncomingWebhookService, type IncomingWebhookAttempt, type IncomingWebhookReceiptRecord, } from './incoming-webhook-service.js';
15
16
  export { REMOTE_JOBS_SECRET_HEADER, REMOTE_JOBS_SECRET_VARIABLE, REMOTE_QUEUE_JOB_PATH, REMOTE_SCHEDULER_JOB_PATH, pikkuRemoteQueueJobFunc, pikkuRemoteScheduledJobFunc, type RemoteQueueJobData, type RemoteScheduledJobData, } from './remote-jobs.js';
16
17
  export { InMemoryQueueService } from './in-memory-queue-service.js';
17
18
  export { InMemoryTriggerService } from './in-memory-trigger-service.js';
@@ -12,6 +12,7 @@ export { LocalVariablesService } from './local-variables.js';
12
12
  export { ConsoleLogger, JsonConsoleLogger } from './logger-console.js';
13
13
  export { InMemoryWorkflowService } from './in-memory-workflow-service.js';
14
14
  export { QueueWebhookService, pikkuWebhookWorkerFunc, } from './queue-webhook-service.js';
15
+ export { IncomingWebhookService, } from './incoming-webhook-service.js';
15
16
  export { REMOTE_JOBS_SECRET_HEADER, REMOTE_JOBS_SECRET_VARIABLE, REMOTE_QUEUE_JOB_PATH, REMOTE_SCHEDULER_JOB_PATH, pikkuRemoteQueueJobFunc, pikkuRemoteScheduledJobFunc, } from './remote-jobs.js';
16
17
  export { InMemoryQueueService } from './in-memory-queue-service.js';
17
18
  export { InMemoryTriggerService } from './in-memory-trigger-service.js';
@@ -15,6 +15,8 @@ import type { TriggerMeta, TriggerSourceMeta } from '../wirings/trigger/trigger.
15
15
  import type { SecretDefinitionsMeta } from '../wirings/secret/secret.types.js';
16
16
  import type { CredentialDefinitionsMeta } from '../wirings/credential/credential.types.js';
17
17
  import type { VariableDefinitionsMeta } from '../wirings/variable/variable.types.js';
18
+ import type { OutgoingWebhooksMeta } from '../wirings/webhook/define-outgoing-webhook.js';
19
+ import type { WebhookSourcesMeta } from '../wirings/trigger/webhook-source.types.js';
18
20
  import type { FunctionMeta, FunctionsMeta, PermissionMetadata } from '../function/function-meta.types.js';
19
21
  import type { MiddlewareMetadata } from '../middleware/middleware.types.js';
20
22
  import type { AgentsMeta } from '../wirings/agent/agent.types.js';
@@ -174,6 +176,8 @@ export interface MetaService {
174
176
  getSecretsMeta(): Promise<SecretDefinitionsMeta>;
175
177
  getCredentialsMeta(): Promise<CredentialDefinitionsMeta>;
176
178
  getVariablesMeta(): Promise<VariableDefinitionsMeta>;
179
+ getOutgoingWebhooksMeta(): Promise<OutgoingWebhooksMeta>;
180
+ getWebhookSourcesMeta(): Promise<WebhookSourcesMeta>;
177
181
  getEmailMeta(): Promise<EmailsMeta>;
178
182
  getEmailTemplateAssets(templateName: string, locale: string): Promise<EmailTemplateAssets>;
179
183
  getServicesMeta(): Promise<ServicesMetaRecord>;
@@ -184,6 +188,7 @@ export interface MetaService {
184
188
  export declare class LocalMetaService implements MetaService {
185
189
  readonly basePath: string;
186
190
  private httpMetaCache;
191
+ private webhookSourcesMetaCache;
187
192
  private channelsMetaCache;
188
193
  private schedulerMetaCache;
189
194
  private queueMetaCache;
@@ -204,6 +209,7 @@ export declare class LocalMetaService implements MetaService {
204
209
  private secretsMetaCache;
205
210
  private credentialsMetaCache;
206
211
  private variablesMetaCache;
212
+ private outgoingWebhooksMetaCache;
207
213
  private middlewareGroupsMetaCache;
208
214
  private permissionsGroupsMetaCache;
209
215
  private agentsMetaCache;
@@ -242,6 +248,8 @@ export declare class LocalMetaService implements MetaService {
242
248
  getSecretsMeta(): Promise<SecretDefinitionsMeta>;
243
249
  getCredentialsMeta(): Promise<CredentialDefinitionsMeta>;
244
250
  getVariablesMeta(): Promise<VariableDefinitionsMeta>;
251
+ getOutgoingWebhooksMeta(): Promise<OutgoingWebhooksMeta>;
252
+ getWebhookSourcesMeta(): Promise<WebhookSourcesMeta>;
245
253
  getEmailMeta(): Promise<EmailsMeta>;
246
254
  getEmailTemplateAssets(templateName: string, locale: string): Promise<EmailTemplateAssets>;
247
255
  getServicesMeta(): Promise<ServicesMetaRecord>;
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  export class LocalMetaService {
6
6
  basePath;
7
7
  httpMetaCache = null;
8
+ webhookSourcesMetaCache = null;
8
9
  channelsMetaCache = null;
9
10
  schedulerMetaCache = null;
10
11
  queueMetaCache = null;
@@ -25,6 +26,7 @@ export class LocalMetaService {
25
26
  secretsMetaCache = null;
26
27
  credentialsMetaCache = null;
27
28
  variablesMetaCache = null;
29
+ outgoingWebhooksMetaCache = null;
28
30
  middlewareGroupsMetaCache = null;
29
31
  permissionsGroupsMetaCache = null;
30
32
  agentsMetaCache = null;
@@ -123,6 +125,8 @@ export class LocalMetaService {
123
125
  this.secretsMetaCache = null;
124
126
  this.credentialsMetaCache = null;
125
127
  this.variablesMetaCache = null;
128
+ this.outgoingWebhooksMetaCache = null;
129
+ this.webhookSourcesMetaCache = null;
126
130
  this.middlewareGroupsMetaCache = null;
127
131
  this.permissionsGroupsMetaCache = null;
128
132
  this.agentsMetaCache = null;
@@ -375,6 +379,20 @@ export class LocalMetaService {
375
379
  this.variablesMetaCache = content ? JSON.parse(content) : {};
376
380
  return this.variablesMetaCache;
377
381
  }
382
+ async getOutgoingWebhooksMeta() {
383
+ if (this.outgoingWebhooksMetaCache)
384
+ return this.outgoingWebhooksMetaCache;
385
+ const content = await this.readFile('webhooks/pikku-outgoing-webhooks-meta.gen.json');
386
+ this.outgoingWebhooksMetaCache = content ? JSON.parse(content) : {};
387
+ return this.outgoingWebhooksMetaCache;
388
+ }
389
+ async getWebhookSourcesMeta() {
390
+ if (this.webhookSourcesMetaCache)
391
+ return this.webhookSourcesMetaCache;
392
+ const content = await this.readFile('webhooks/pikku-webhook-sources-meta.gen.json');
393
+ this.webhookSourcesMetaCache = content ? JSON.parse(content) : {};
394
+ return this.webhookSourcesMetaCache;
395
+ }
378
396
  async getEmailMeta() {
379
397
  // knowledge: decisions/internals/email-meta-is-read-uncached-because-codegen-rewrites-it-mid-session.md
380
398
  const content = await this.readFile('email/pikku-emails-meta.gen.json');
@@ -1,3 +1,4 @@
1
+ import type { IncomingWebhookService } from '../services/incoming-webhook-service.js';
1
2
  import type { Logger, LogLevel } from '../services/logger.js';
2
3
  import type { VariablesService } from '../services/variables-service.js';
3
4
  import type { SecretService } from '../services/secret-service.js';
@@ -133,6 +134,8 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
133
134
  * the delivery-read methods; a store-backed implementation records history.
134
135
  */
135
136
  webhookService?: WebhookService;
137
+ /** Queues what webhook trigger sources receive. Required once any `wireTriggerWebhookSource` is wired. */
138
+ incomingWebhookService?: IncomingWebhookService;
136
139
  metaService?: MetaService;
137
140
  /**
138
141
  * Where virtual-user runs are recorded. A run is dispatched and answered for
@@ -1,3 +1,4 @@
1
+ import type { CoreTriggerWebhookSource, WebhookSourcesMeta } from '../wirings/trigger/webhook-source.types.js';
1
2
  import type { CredentialOverrides } from '../wirings/credential/credential-overrides.js';
2
3
  import type { PikkuErrorConstructor, ErrorDetails } from '../errors/error-handler.js';
3
4
  import type { CorePikkuFunctionConfig, CorePermissionGroup, CorePikkuPermission } from '../function/functions.types.js';
@@ -6,7 +7,7 @@ import type { CoreChannel, ChannelsMeta } from '../wirings/channel/channel.types
6
7
  import type { CLIMeta, CLIProgramState } from '../wirings/cli/cli.types.js';
7
8
  import type { HTTPMethod, CoreHTTPFunctionWiring, HTTPWiringsMeta } from '../wirings/http/http.types.js';
8
9
  import type { CoreMCPResource, MCPResourceMeta, MCPToolMeta, CoreMCPPrompt, MCPPromptMeta } from '../wirings/mcp/mcp.types.js';
9
- import type { CoreAgent, AgentsMeta } from '../wirings/agent/agent.types.js';
10
+ import type { CoreAgent, AgentsMeta, AgentRPCFactory } from '../wirings/agent/agent.types.js';
10
11
  import type { PikkuAgentScorer, ScorerMeta } from '../wirings/agent-scorer/agent-scorer.types.js';
11
12
  import type { CoreGateway, GatewaysMeta } from '../wirings/gateway/gateway.types.js';
12
13
  import type { CoreQueueWorker, QueueWorkersMeta } from '../wirings/queue/queue.types.js';
@@ -101,6 +102,8 @@ export interface PikkuPackageState {
101
102
  triggerSources: Map<string, CoreTriggerSource>;
102
103
  meta: TriggerMeta;
103
104
  sourceMeta: TriggerSourceMeta;
105
+ webhookSources: Map<string, CoreTriggerWebhookSource>;
106
+ webhookSourceMeta: WebhookSourcesMeta;
104
107
  };
105
108
  mcp: {
106
109
  resources: Map<string, CoreMCPResource>;
@@ -116,6 +119,11 @@ export interface PikkuPackageState {
116
119
  scorersMeta: ScorerMeta;
117
120
  /** Alias -> `provider/model`, from the `models` table in pikku.config.json. */
118
121
  modelAliases: Record<string, string>;
122
+ /**
123
+ * Set by `@pikku/core/agent` on import. Absent in a deployment unit that
124
+ * holds no agent, which is what keeps the agent runtime out of its bundle.
125
+ */
126
+ rpcFactory?: AgentRPCFactory;
119
127
  };
120
128
  gateway: {
121
129
  gateways: Map<string, CoreGateway>;
@@ -84,6 +84,26 @@ const wrapWorkflowServiceForPackage = (service, packageName, namespace) => {
84
84
  },
85
85
  });
86
86
  };
87
+ const scopedWebhookServices = new WeakSet();
88
+ /** An addon names its outgoing events locally; they leave as `<instance>:<event>`. */
89
+ const scopeWebhookService = (service, namespace) => {
90
+ if (scopedWebhookServices.has(service))
91
+ return service;
92
+ const scoped = new Proxy(service, {
93
+ get(target, prop, receiver) {
94
+ const value = Reflect.get(target, prop, receiver);
95
+ if (prop !== 'send' || typeof value !== 'function')
96
+ return value;
97
+ return function (input, ...rest) {
98
+ return value.call(this, input?.event && !input.event.startsWith(`${namespace}:`)
99
+ ? { ...input, event: `${namespace}:${input.event}` }
100
+ : input, ...rest);
101
+ };
102
+ },
103
+ });
104
+ scopedWebhookServices.add(scoped);
105
+ return scoped;
106
+ };
87
107
  export const getOrCreatePackageSingletonServices = async (packageName, parentServices, addonInstance) => {
88
108
  const cacheKey = addonInstance?.namespace ?? packageName;
89
109
  const cachedServices = pikkuState(cacheKey, 'package', 'singletonServices');
@@ -116,6 +136,13 @@ export const getOrCreatePackageSingletonServices = async (packageName, parentSer
116
136
  credentialService: new ScopedCredentialService(existingServices.credentialService, allowedNames(Object.keys(pikkuState(packageName, 'package', 'credentialsMeta') ?? {}), addonInstance?.credentialGrants, addonInstance?.credentialOverrides)),
117
137
  };
118
138
  }
139
+ const namespace = addonInstance?.namespace ?? findAddonNamespaceForPackage(packageName);
140
+ if (namespace && existingServices.webhookService) {
141
+ existingServices = {
142
+ ...existingServices,
143
+ webhookService: scopeWebhookService(existingServices.webhookService, namespace),
144
+ };
145
+ }
119
146
  if (!factories || !factories.createSingletonServices) {
120
147
  return existingServices;
121
148
  }
@@ -128,6 +155,9 @@ export const getOrCreatePackageSingletonServices = async (packageName, parentSer
128
155
  typeof packageServices.workflowService === 'object') {
129
156
  packageServices.workflowService = wrapWorkflowServiceForPackage(packageServices.workflowService, packageName, addonInstance?.namespace ?? null);
130
157
  }
158
+ if (namespace && packageServices.webhookService) {
159
+ packageServices.webhookService = scopeWebhookService(packageServices.webhookService, namespace);
160
+ }
131
161
  pikkuState(cacheKey, 'package', 'singletonServices', packageServices);
132
162
  return packageServices;
133
163
  };
@@ -1,5 +1,6 @@
1
1
  import type { CredentialOverrides } from '../credential/credential-overrides.js';
2
2
  import type { CorePikkuMiddleware } from '../../middleware/middleware.types.js';
3
+ import type { AddonInstance } from './addon-runner.js';
3
4
  export type WireAddonConfig = {
4
5
  /** How this instance is addressed. One package may be wired more than once, and the name is what tells the instances apart. */
5
6
  name: string;
@@ -112,6 +113,7 @@ export declare const resolveAddonAuth: (packageName: string | null, namespace?:
112
113
  export declare const resolveAddonFunctionTarget: (funcName: string, packageName: string | null) => {
113
114
  packageName: string;
114
115
  localName: string;
116
+ instance: AddonInstance;
115
117
  } | null;
116
118
  /**
117
119
  * Addon tags name middleware the *consuming app* registered, so they resolve
@@ -125,6 +125,12 @@ export const resolveAddonFunctionTarget = (funcName, packageName) => {
125
125
  return {
126
126
  packageName: config.package,
127
127
  localName: funcName.slice(separator + 1),
128
+ // The instance, not just its package: everything the consuming app lent
129
+ // this addon — `secretOverrides`, grants, the scoping opt-outs — hangs off
130
+ // here, and a caller that only learns the package runs the function with
131
+ // none of it. The namespace is the map key rather than a field on the
132
+ // stored config, so it is put back on the way out.
133
+ instance: { namespace, ...config },
128
134
  };
129
135
  };
130
136
  /**
@@ -1,10 +1,7 @@
1
1
  import type { PikkuRawWire } from '../../types/core.types.js';
2
- import type { SessionService } from '../../services/user-session-service.js';
3
- import type { CoreUserSession } from '../../types/core.types.js';
4
2
  import type { PikkuRPC } from '../rpc/rpc-types.js';
5
- export type AgentRPCOptions = {
6
- sessionService?: SessionService<CoreUserSession>;
7
- };
3
+ import type { AgentRPCOptions } from './agent.types.js';
4
+ export type { AgentRPCOptions };
8
5
  /**
9
6
  * `wire.rpc.agent`, implemented.
10
7
  *
@@ -1,3 +1,4 @@
1
+ import { pikkuState } from '../../pikku-state.js';
1
2
  import { runAgent, resumeAgentSync } from './agent-runner.js';
2
3
  import { streamAgent, resumeAgent, interruptAgent } from './agent-stream.js';
3
4
  import { wrapChannelWithAGUI } from './agent-agui.js';
@@ -51,3 +52,12 @@ export const createAgentRPC = (wire, options) => {
51
52
  approve: async (runId, approvals, expectedAgentName) => asRunResult(await resumeAgentSync(runId, approvals, { sessionService: options.sessionService }, expectedAgentName)),
52
53
  };
53
54
  };
55
+ /**
56
+ * The RPC primitive offers `rpc.agent` but must not import this file — a static
57
+ * reference from `rpc-runner` pins the whole agent runtime into every
58
+ * deployment unit, including the ones that hold no agent. Registering the
59
+ * factory here inverts that: only a unit that imports `@pikku/core/agent` pays
60
+ * for it, which is the same seam the inspector already uses to decide whether
61
+ * a unit requires the agent services.
62
+ */
63
+ pikkuState(null, 'agent', 'rpcFactory', createAgentRPC);
@@ -6,6 +6,19 @@ import type { AIProviderOptions } from '../../services/agent-runner-service.js';
6
6
  import type { PikkuChannel } from '../channel/channel.types.js';
7
7
  import type { CorePikkuChannelMiddleware } from '../channel/channel.types.js';
8
8
  import type { ApprovalPolicy } from '../channel/channel-rpc.js';
9
+ import type { PikkuRawWire } from '../../types/core.types.js';
10
+ import type { PikkuRPC } from '../rpc/rpc-types.js';
11
+ import type { SessionService } from '../../services/user-session-service.js';
12
+ import type { CoreUserSession } from '../../types/core.types.js';
13
+ export type AgentRPCOptions = {
14
+ sessionService?: SessionService<CoreUserSession>;
15
+ };
16
+ /**
17
+ * Builds `wire.rpc.agent`. Registered by `agent-rpc.ts` when a unit imports
18
+ * `@pikku/core/agent`, so the RPC primitive can offer the agent surface
19
+ * without naming the agent runtime.
20
+ */
21
+ export type AgentRPCFactory = (wire: PikkuRawWire, options: AgentRPCOptions) => PikkuRPC['agent'];
9
22
  export interface AgentThread {
10
23
  id: string;
11
24
  resourceId: string;
@@ -1,4 +1,11 @@
1
+ /**
2
+ * Registers `wire.rpc.agent`. Imported for the side effect alone so the RPC
3
+ * primitive can offer the agent surface without naming the agent runtime, which
4
+ * would pin it into every deployment unit.
5
+ */
6
+ import './agent-rpc.js';
1
7
  export { agent, agentStream, agentResume, agentApprove, agentInterrupt, } from './agent-helpers.js';
8
+ export type { AgentRPCOptions, AgentRPCFactory } from './agent.types.js';
2
9
  export { wrapChannelWithAGUI } from './agent-agui.js';
3
10
  export { runAgent, resumeAgentSync } from './agent-runner.js';
4
11
  export { resolveModelAlias } from './agent-model-config.js';
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Registers `wire.rpc.agent`. Imported for the side effect alone so the RPC
3
+ * primitive can offer the agent surface without naming the agent runtime, which
4
+ * would pin it into every deployment unit.
5
+ */
6
+ import './agent-rpc.js';
1
7
  export { agent, agentStream, agentResume, agentApprove, agentInterrupt, } from './agent-helpers.js';
2
8
  export { wrapChannelWithAGUI } from './agent-agui.js';
3
9
  export { runAgent, resumeAgentSync } from './agent-runner.js';
@@ -52,9 +52,12 @@ export declare class ContextAwareRPCService {
52
52
  /**
53
53
  * The agent facade, built on access.
54
54
  *
55
- * The implementation lives in `agent/agent-rpc.ts` so the agent surface is
56
- * one file rather than a wing of this one; a getter rather than a field so a
57
- * request that never touches an agent never builds it.
55
+ * Resolved through state rather than imported: naming `agent-rpc.ts` here
56
+ * pinned the agent runtime — runner, stream, memory, AGUI — into every
57
+ * deployment unit, because every unit reaches this file through
58
+ * `function-runner`. `@pikku/core/agent` registers the factory on import, so
59
+ * a unit that holds an agent has one and a unit that does not never bundles
60
+ * the tree behind it.
58
61
  */
59
62
  get agent(): PikkuRPC['agent'];
60
63
  remote<In = any, Out = any>(funcName: string, data: In): Promise<Out>;
@@ -5,7 +5,6 @@ import { pikkuState } from '../../pikku-state.js';
5
5
  import { PikkuError, addError } from '../../errors/error-handler.js';
6
6
  import { parseVersionedId } from '../../version.js';
7
7
  import { resolveRemoteAddonToken } from '../addon/remote-addon-auth.js';
8
- import { createAgentRPC } from '../agent/agent-rpc.js';
9
8
  /**
10
9
  * The session for a wire: read through `getSession` when a runner attached one,
11
10
  * otherwise whatever was placed on the wire directly.
@@ -330,11 +329,18 @@ export class ContextAwareRPCService {
330
329
  /**
331
330
  * The agent facade, built on access.
332
331
  *
333
- * The implementation lives in `agent/agent-rpc.ts` so the agent surface is
334
- * one file rather than a wing of this one; a getter rather than a field so a
335
- * request that never touches an agent never builds it.
332
+ * Resolved through state rather than imported: naming `agent-rpc.ts` here
333
+ * pinned the agent runtime — runner, stream, memory, AGUI — into every
334
+ * deployment unit, because every unit reaches this file through
335
+ * `function-runner`. `@pikku/core/agent` registers the factory on import, so
336
+ * a unit that holds an agent has one and a unit that does not never bundles
337
+ * the tree behind it.
336
338
  */
337
339
  get agent() {
340
+ const createAgentRPC = pikkuState(null, 'agent', 'rpcFactory');
341
+ if (!createAgentRPC) {
342
+ throw new Error('Agent runtime not available: import @pikku/core/agent in this deployment unit to use rpc.agent');
343
+ }
338
344
  return createAgentRPC(this.wire, this.options);
339
345
  }
340
346
  async remote(funcName, data) {
@@ -1,3 +1,7 @@
1
1
  export { wireTrigger, wireTriggerSource } from './trigger-runner.js';
2
+ export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
+ export type { WebhookSourceOutcome } from './webhook-source-runner.js';
4
+ export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
5
+ export type { CoreTriggerWebhookSource, TriggerEvent, WebhookCheckResult, WebhookLifecycleInput, WebhookReceiveResult, WebhookRequest, WebhookSetupResult, WebhookSourceJob, WebhookSourceMeta, WebhookSourcesMeta, WebhookSourceState, WebhookTeardownInput, WebhookTeardownResult, } from './webhook-source.types.js';
2
6
  export { PikkuTriggerService } from './pikku-trigger-service.js';
3
7
  export type { TriggerMeta, TriggerSourceMeta, CorePikkuTriggerFunction, CorePikkuTriggerFunctionConfig, CoreTrigger, } from './trigger.types.js';
@@ -1,2 +1,4 @@
1
1
  export { wireTrigger, wireTriggerSource } from './trigger-runner.js';
2
+ export { wireTriggerWebhookSource, receiveWebhookSourceRequest, dispatchWebhookSourceJob, runWebhookSourceLifecycle, subscribedWebhookEvents, } from './webhook-source-runner.js';
3
+ export { PIKKU_INCOMING_WEBHOOK_QUEUE_NAME } from './webhook-source.types.js';
2
4
  export { PikkuTriggerService } from './pikku-trigger-service.js';
@@ -0,0 +1,45 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import type { CoreSingletonServices } from '../../types/core.types.js';
3
+ import type { PikkuHTTP } from '../http/http.types.js';
4
+ import type { CoreTriggerWebhookSource, WebhookCheckResult, WebhookSetupResult, WebhookSourceJob, WebhookSourceState, WebhookTeardownResult } from './webhook-source.types.js';
5
+ export declare const wireTriggerWebhookSource: <Events extends Record<string, StandardSchemaV1>>(source: CoreTriggerWebhookSource<Events>) => void;
6
+ /** The declared events some trigger is wired to: what `setup` registers with the provider. */
7
+ export declare const subscribedWebhookEvents: (source: string) => string[];
8
+ /**
9
+ * The body of a webhook source's route: `receive`, validate, and queue every
10
+ * event some trigger listens for. Events nobody listens for are answered and
11
+ * dropped, so the provider does not retry them forever.
12
+ */
13
+ export declare const receiveWebhookSourceRequest: (sourceName: string, wire: {
14
+ http?: PikkuHTTP;
15
+ }) => Promise<Response | {
16
+ received: number;
17
+ }>;
18
+ /** The `pikku-incoming-webhooks` worker: runs the trigger an event was queued for. Throws so the queue retries. */
19
+ export declare const dispatchWebhookSourceJob: (job: WebhookSourceJob) => Promise<void>;
20
+ export type WebhookSourceOutcome = {
21
+ source: string;
22
+ url: string;
23
+ status: WebhookCheckResult['status'] | WebhookSetupResult['status'] | WebhookTeardownResult['status'] | 'skipped' | 'failed';
24
+ reason?: string;
25
+ state?: WebhookSourceState;
26
+ /** The name the produced signing secret must be stored under. */
27
+ secretName?: string;
28
+ secret?: string;
29
+ instructions?: string;
30
+ error?: string;
31
+ };
32
+ /**
33
+ * Runs one lifecycle step for every webhook source, as `pikku webhooks
34
+ * status | setup | teardown` does at deploy. `setup` runs only where `check`
35
+ * does not report `ok`. One failing source does not stop the rest.
36
+ */
37
+ export declare const runWebhookSourceLifecycle: ({ action, baseUrl, labelPrefix, previous, singletonServices, }: {
38
+ action: 'check' | 'setup' | 'teardown';
39
+ /** Where the app's routes are served, e.g. `https://shop.example.com/api`. */
40
+ baseUrl: string;
41
+ labelPrefix: string;
42
+ /** What the last `setup` returned as `state`, by source name. */
43
+ previous?: Record<string, WebhookSourceState>;
44
+ singletonServices?: CoreSingletonServices;
45
+ }) => Promise<WebhookSourceOutcome[]>;
@@ -0,0 +1,221 @@
1
+ import { getSingletonServices, pikkuState } from '../../pikku-state.js';
2
+ import { addFunction, runPikkuFunc } from '../../function/function-runner.js';
3
+ import { PikkuMissingMetaError } from '../../errors/errors.js';
4
+ const LIFECYCLE = ['receive', 'check', 'setup', 'teardown'];
5
+ export const wireTriggerWebhookSource = (source) => {
6
+ const meta = pikkuState(null, 'trigger', 'webhookSourceMeta')[source.name];
7
+ if (!meta) {
8
+ console.warn(`[pikku] Skipping webhook source '${source.name}' — metadata not found. Consider moving this wiring to its own file.`);
9
+ return;
10
+ }
11
+ const sources = pikkuState(null, 'trigger', 'webhookSources');
12
+ if (sources.has(source.name)) {
13
+ throw new Error(`Webhook source already exists: ${source.name}`);
14
+ }
15
+ sources.set(source.name, source);
16
+ for (const step of LIFECYCLE) {
17
+ const funcId = meta[step];
18
+ const config = source[step];
19
+ if (funcId && typeof config?.func === 'function') {
20
+ addFunction(funcId, config);
21
+ }
22
+ }
23
+ };
24
+ const triggerName = (source, event) => event ? `${source}:${event}` : source;
25
+ /** The declared events some trigger is wired to: what `setup` registers with the provider. */
26
+ export const subscribedWebhookEvents = (source) => {
27
+ const triggers = pikkuState(null, 'trigger', 'meta');
28
+ const meta = pikkuState(null, 'trigger', 'webhookSourceMeta')[source];
29
+ return (meta?.events ?? []).filter((event) => triggers[triggerName(source, event)]);
30
+ };
31
+ const getSourceMeta = (source) => {
32
+ const meta = pikkuState(null, 'trigger', 'webhookSourceMeta')[source];
33
+ if (!meta) {
34
+ throw new PikkuMissingMetaError(`Missing generated metadata for webhook source '${source}'`);
35
+ }
36
+ return meta;
37
+ };
38
+ const runSourceStep = (singletonServices, source, funcId, data) => runPikkuFunc('trigger', source, funcId, {
39
+ singletonServices,
40
+ auth: false,
41
+ data: () => data,
42
+ wire: {},
43
+ });
44
+ const readRequest = async (http) => {
45
+ const request = http?.request;
46
+ if (!request) {
47
+ throw new Error('A webhook source can only be reached over HTTP');
48
+ }
49
+ const query = {};
50
+ for (const [key, value] of Object.entries(request.query() ?? {})) {
51
+ if (value !== undefined)
52
+ query[key] = String(value);
53
+ }
54
+ return {
55
+ body: new Uint8Array(await request.arrayBuffer()),
56
+ headers: request.headers(),
57
+ method: request.method(),
58
+ url: request.path(),
59
+ query,
60
+ };
61
+ };
62
+ const validateEvents = async (source, events, logger) => {
63
+ const valid = [];
64
+ for (const event of events) {
65
+ const schema = source?.events?.[event.name];
66
+ if (!schema) {
67
+ valid.push(event);
68
+ continue;
69
+ }
70
+ const result = await schema['~standard'].validate(event.data);
71
+ if (result.issues) {
72
+ logger.warn(`Webhook source '${source.name}' dropped '${event.name}'${event.id ? ` (${event.id})` : ''}: ${result.issues.map((issue) => issue.message).join('; ')}`);
73
+ continue;
74
+ }
75
+ valid.push({ ...event, data: result.value });
76
+ }
77
+ return valid;
78
+ };
79
+ /**
80
+ * The body of a webhook source's route: `receive`, validate, and queue every
81
+ * event some trigger listens for. Events nobody listens for are answered and
82
+ * dropped, so the provider does not retry them forever.
83
+ */
84
+ export const receiveWebhookSourceRequest = async (sourceName, wire) => {
85
+ const singletonServices = getSingletonServices();
86
+ const meta = getSourceMeta(sourceName);
87
+ const source = pikkuState(null, 'trigger', 'webhookSources').get(sourceName);
88
+ const request = await readRequest(wire.http);
89
+ const result = meta.receive
90
+ ? await runSourceStep(singletonServices, sourceName, meta.receive, request)
91
+ : {
92
+ events: [
93
+ {
94
+ name: '',
95
+ data: request.body.length
96
+ ? JSON.parse(new TextDecoder().decode(request.body))
97
+ : undefined,
98
+ },
99
+ ],
100
+ };
101
+ if ('respond' in result) {
102
+ const { status, body, headers } = result.respond;
103
+ const text = body === undefined
104
+ ? null
105
+ : typeof body === 'string'
106
+ ? body
107
+ : JSON.stringify(body);
108
+ return new Response(text, { status, headers });
109
+ }
110
+ const triggers = pikkuState(null, 'trigger', 'meta');
111
+ const listened = result.events.filter((event) => triggers[triggerName(sourceName, event.name)]);
112
+ const events = await validateEvents(source, listened, singletonServices.logger);
113
+ if (events.length === 0)
114
+ return { received: 0 };
115
+ const service = singletonServices.incomingWebhookService;
116
+ if (!service) {
117
+ throw new Error(`Webhook source '${sourceName}' received events but no incomingWebhookService is configured to queue them.`);
118
+ }
119
+ return {
120
+ received: await service.accept({ source: sourceName, request, events }),
121
+ };
122
+ };
123
+ /** The `pikku-incoming-webhooks` worker: runs the trigger an event was queued for. Throws so the queue retries. */
124
+ export const dispatchWebhookSourceJob = async (job) => {
125
+ const singletonServices = getSingletonServices();
126
+ const name = triggerName(job.source, job.event.name);
127
+ const trigger = pikkuState(null, 'trigger', 'meta')[name];
128
+ if (!trigger) {
129
+ singletonServices.logger.warn(`No trigger named '${name}' is wired; dropping ${job.event.id ?? 'an event'} from '${job.source}'.`);
130
+ return;
131
+ }
132
+ let error;
133
+ try {
134
+ await runPikkuFunc('trigger', name, trigger.pikkuFuncId, {
135
+ singletonServices,
136
+ auth: false,
137
+ data: () => job.event.data,
138
+ wire: {},
139
+ });
140
+ }
141
+ catch (e) {
142
+ error = e instanceof Error ? e.message : String(e);
143
+ if (job.receiptId) {
144
+ await recordAttempt(singletonServices, job.receiptId, name, error);
145
+ }
146
+ throw e;
147
+ }
148
+ if (job.receiptId) {
149
+ await recordAttempt(singletonServices, job.receiptId, name);
150
+ }
151
+ };
152
+ const recordAttempt = (singletonServices, receiptId, trigger, error) => singletonServices.incomingWebhookService
153
+ ?.recordAttempt(receiptId, { trigger, ...(error ? { error } : {}) })
154
+ .catch((storeError) => singletonServices.logger.error(`Failed to record the attempt for webhook receipt ${receiptId}`, storeError));
155
+ /**
156
+ * Runs one lifecycle step for every webhook source, as `pikku webhooks
157
+ * status | setup | teardown` does at deploy. `setup` runs only where `check`
158
+ * does not report `ok`. One failing source does not stop the rest.
159
+ */
160
+ export const runWebhookSourceLifecycle = async ({ action, baseUrl, labelPrefix, previous = {}, singletonServices = getSingletonServices(), }) => {
161
+ const base = baseUrl.replace(/\/+$/, '');
162
+ const outcomes = [];
163
+ for (const meta of Object.values(pikkuState(null, 'trigger', 'webhookSourceMeta'))) {
164
+ const url = `${base}${meta.route}`;
165
+ const label = `${labelPrefix}:${meta.name}`;
166
+ const input = {
167
+ url,
168
+ label,
169
+ events: subscribedWebhookEvents(meta.name),
170
+ ...(previous[meta.name] ? { previous: previous[meta.name] } : {}),
171
+ };
172
+ const outcome = (status, extra = {}) => outcomes.push({ source: meta.name, url, status, ...extra });
173
+ try {
174
+ if (action === 'teardown') {
175
+ if (!meta.teardown) {
176
+ outcome('skipped');
177
+ continue;
178
+ }
179
+ const result = await runSourceStep(singletonServices, meta.name, meta.teardown, { label, ...(input.previous ? { previous: input.previous } : {}) });
180
+ outcome(result.status);
181
+ continue;
182
+ }
183
+ if (meta.check) {
184
+ const checked = await runSourceStep(singletonServices, meta.name, meta.check, input);
185
+ if (action === 'check' || checked.status === 'ok') {
186
+ outcome(checked.status === 'ok' && action === 'setup'
187
+ ? 'unchanged'
188
+ : checked.status, checked.status === 'drifted' ? { reason: checked.reason } : {});
189
+ continue;
190
+ }
191
+ }
192
+ else if (action === 'check') {
193
+ outcome('skipped');
194
+ continue;
195
+ }
196
+ if (!meta.setup) {
197
+ outcome('manual', {
198
+ instructions: `Register ${url} with the provider for: ${input.events.join(', ') || 'every event'}.`,
199
+ });
200
+ continue;
201
+ }
202
+ const result = await runSourceStep(singletonServices, meta.name, meta.setup, input);
203
+ if (result.status === 'manual') {
204
+ outcome('manual', { instructions: result.instructions });
205
+ continue;
206
+ }
207
+ outcome(result.status, {
208
+ ...(result.state ? { state: result.state } : {}),
209
+ ...(result.secret && meta.secret
210
+ ? { secretName: meta.secret, secret: result.secret }
211
+ : {}),
212
+ });
213
+ }
214
+ catch (error) {
215
+ outcome('failed', {
216
+ error: error instanceof Error ? error.message : String(error),
217
+ });
218
+ }
219
+ }
220
+ return outcomes;
221
+ };
@@ -0,0 +1,106 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
3
+ export declare const PIKKU_INCOMING_WEBHOOK_QUEUE_NAME = "pikku-incoming-webhooks";
4
+ /** What every trigger source produces, whichever way it arrives. */
5
+ export type TriggerEvent<Name extends string = string, Data = unknown> = {
6
+ /** The provider's event type; dispatched to the trigger named `<source>:<name>`. Empty dispatches to `<source>`. */
7
+ name: Name;
8
+ /** The provider's event id, which de-duplicates redeliveries. */
9
+ id?: string;
10
+ data: Data;
11
+ };
12
+ /** The request exactly as it arrived: providers sign the raw bytes, not re-serialised JSON. */
13
+ export type WebhookRequest = {
14
+ body: Uint8Array;
15
+ headers: Record<string, string>;
16
+ method: string;
17
+ url: string;
18
+ query: Record<string, string>;
19
+ };
20
+ export type WebhookReceiveResult = {
21
+ events: TriggerEvent[];
22
+ }
23
+ /** A handshake, such as Slack's `url_verification`: answered directly, nothing is dispatched. */
24
+ | {
25
+ respond: {
26
+ status: number;
27
+ body?: unknown;
28
+ headers?: Record<string, string>;
29
+ };
30
+ };
31
+ /** Whatever `setup` wants back on the next deploy, for providers whose endpoints cannot be found by label. */
32
+ export type WebhookSourceState = Record<string, unknown>;
33
+ export type WebhookLifecycleInput = {
34
+ /** Always this deployment's own route: nothing can point it elsewhere. */
35
+ url: string;
36
+ /** Stable across deploys of one app and stage, for providers that let endpoints be tagged and listed. */
37
+ label: string;
38
+ /** The declared events some trigger is wired to. */
39
+ events: string[];
40
+ previous?: WebhookSourceState;
41
+ };
42
+ export type WebhookCheckResult = {
43
+ status: 'ok';
44
+ } | {
45
+ status: 'missing';
46
+ } | {
47
+ status: 'drifted';
48
+ reason: string;
49
+ };
50
+ export type WebhookSetupResult = {
51
+ status: 'created' | 'updated' | 'unchanged';
52
+ state?: WebhookSourceState;
53
+ /** Only when the provider issued a new signing secret. */
54
+ secret?: string;
55
+ }
56
+ /** The provider has no API for this: `instructions` tell a person what to set by hand. */
57
+ | {
58
+ status: 'manual';
59
+ instructions: string;
60
+ };
61
+ export type WebhookTeardownInput = {
62
+ label: string;
63
+ previous?: WebhookSourceState;
64
+ };
65
+ export type WebhookTeardownResult = {
66
+ status: 'deleted' | 'absent';
67
+ };
68
+ type SourceFunction<In, Out> = CorePikkuFunctionConfig<any, any> & {
69
+ func: (services: any, data: In, wire: any) => Promise<Out>;
70
+ };
71
+ export type CoreTriggerWebhookSource<Events extends Record<string, StandardSchemaV1> = Record<string, StandardSchemaV1>> = {
72
+ /** Triggers subscribe to `<name>:<event>`. Unique across every kind of trigger source. */
73
+ name: string;
74
+ method?: 'post' | 'put' | 'get';
75
+ /** Defaults to `/webhooks/<name>`. */
76
+ route?: string;
77
+ /** The secret `receive` verifies with, which `setup` produces. */
78
+ secret?: string;
79
+ /** What the source can produce. Each event's data is validated against its schema before it is queued. */
80
+ events?: Events;
81
+ /** Omitted: the JSON body is one event dispatched to the trigger named `<name>`. */
82
+ receive?: SourceFunction<WebhookRequest, WebhookReceiveResult>;
83
+ check?: SourceFunction<WebhookLifecycleInput, WebhookCheckResult>;
84
+ setup?: SourceFunction<WebhookLifecycleInput, WebhookSetupResult>;
85
+ teardown?: SourceFunction<WebhookTeardownInput, WebhookTeardownResult>;
86
+ };
87
+ export type WebhookSourceMeta = {
88
+ name: string;
89
+ method: 'post' | 'put' | 'get';
90
+ route: string;
91
+ secret?: string;
92
+ events: string[];
93
+ receive?: string;
94
+ check?: string;
95
+ setup?: string;
96
+ teardown?: string;
97
+ };
98
+ export type WebhookSourcesMeta = Record<string, WebhookSourceMeta>;
99
+ /** One queued event on its way to its trigger. */
100
+ export type WebhookSourceJob = {
101
+ source: string;
102
+ event: TriggerEvent;
103
+ /** Present when a store-backed service recorded the receipt. */
104
+ receiptId?: string;
105
+ };
106
+ export {};
@@ -0,0 +1 @@
1
+ export const PIKKU_INCOMING_WEBHOOK_QUEUE_NAME = 'pikku-incoming-webhooks';
@@ -0,0 +1,28 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import type { Safe } from '../../classification/secret-value.js';
3
+ import type { SendWebhookInput, SendWebhookResult, WebhookService } from '../../services/webhook-service.js';
4
+ export type CoreOutgoingWebhook<Event extends string = string, Payload extends StandardSchemaV1 = StandardSchemaV1> = {
5
+ event: Event;
6
+ title: string;
7
+ description?: string;
8
+ payload: Payload;
9
+ };
10
+ export type OutgoingWebhookMeta = {
11
+ event: string;
12
+ title: string;
13
+ description?: string;
14
+ payload?: Record<string, string>;
15
+ exportedName?: string;
16
+ sourceFile?: string;
17
+ };
18
+ export type OutgoingWebhooksMeta = Record<string, OutgoingWebhookMeta>;
19
+ export type OutgoingWebhookPayloadOf<W> = W extends CoreOutgoingWebhook<string, infer S> ? StandardSchemaV1.InferInput<S> : never;
20
+ export type OutgoingWebhookDataFor<TMap, Input> = Input extends {
21
+ event: infer Event;
22
+ } ? Event extends keyof TMap ? {
23
+ data: Safe<TMap[Event]>;
24
+ } : unknown : unknown;
25
+ export interface TypedWebhookService<TMap = Record<string, unknown>> extends Omit<WebhookService, 'send'> {
26
+ send<const T extends SendWebhookInput>(input: Safe<T> & OutgoingWebhookDataFor<TMap, T>): Promise<SendWebhookResult>;
27
+ }
28
+ export declare const defineOutgoingWebhook: <const Event extends string, Payload extends StandardSchemaV1>(webhook: CoreOutgoingWebhook<Event, Payload>) => CoreOutgoingWebhook<Event, Payload>;
@@ -0,0 +1 @@
1
+ export const defineOutgoingWebhook = (webhook) => webhook;
@@ -0,0 +1,2 @@
1
+ export { defineOutgoingWebhook } from './define-outgoing-webhook.js';
2
+ export type { CoreOutgoingWebhook, OutgoingWebhookDataFor, TypedWebhookService, OutgoingWebhookMeta, OutgoingWebhooksMeta, OutgoingWebhookPayloadOf, } from './define-outgoing-webhook.js';
@@ -0,0 +1 @@
1
+ export { defineOutgoingWebhook } from './define-outgoing-webhook.js';
@@ -0,0 +1,34 @@
1
+ ---
2
+ type: decision
3
+ title: A public function says so with auth: false
4
+ description: An exposed sessionless function that writes auth: false is public on purpose and PKU574 stays quiet; one that leaves auth out still warns
5
+ tags: authorization, inspector, rpc
6
+ ---
7
+
8
+ # A public function says so with auth: false
9
+
10
+ PKU574 warns about an exposed sessionless function with no gate. Some of those
11
+ are exactly what the author meant: a published programme, a price list, a health
12
+ check. Before this, the only way to quiet the warning on one was a gate that
13
+ gated nothing — an always-true permission, or `permissionsInBody: true`, which
14
+ claims a check the body does not make. Both put a false statement into meta to
15
+ silence a true one, and the alternative, living with the warning, teaches
16
+ everyone to stop reading it.
17
+
18
+ `auth: false` is already the field that says "no session required". Writing it
19
+ on a `pikkuSessionlessFunc` is redundant at runtime — sessionless already means
20
+ that — which is what makes it useful as a declaration: nobody writes it by
21
+ accident, so its presence is the author deciding the function is public. The
22
+ inspector now records `auth` as written (`true`, `false` or absent) instead of
23
+ folding `false` into absent, and the check treats `false` as a gate the author
24
+ declared, the same way it treats `permissionsInBody`.
25
+
26
+ The case the warning exists for is unchanged. A sessionless function that picked
27
+ up `expose: true` later, with nobody thinking about who may call it, has no
28
+ `auth` at all — and still warns.
29
+
30
+ **What this rules out:** a separate `public: true` flag, which would be a second
31
+ field meaning what `auth: false` already means; reading `readonly` or the
32
+ function's name as evidence it is safe to expose; and silencing PKU574 for every
33
+ sessionless function, which would drop the accidental-exposure case with the
34
+ intentional one.
@@ -42,7 +42,10 @@ case it would catch.
42
42
  Severity is `warn`, not `critical`. `expose: true` on an ungated sessionless
43
43
  function is legitimate for a genuinely public endpoint — a health check, a
44
44
  sign-up — so this cannot block a build without breaking correct programs. It
45
- fails a build only under `--fail-on-warn`.
45
+ fails a build only under `--fail-on-warn`. And the public endpoint is not left
46
+ warning forever: an explicit `auth: false` on a sessionless function declares it
47
+ public on purpose and silences the check (see
48
+ [public is declared with auth: false](./a-public-function-says-so-with-auth-false.md)).
46
49
 
47
50
  **What this rules out:** checking at boot, where the report arrives after
48
51
  deployment; checking in `pikku validate` from `.pikku` JSON, which does not
@@ -58,6 +58,7 @@ A rule about who may do what, and which way it fails when it is unsure.
58
58
  - [A scaffolded surface is authenticated unless the config opts out in writing](scaffold-features-are-authenticated-unless-opted-out.md) — SUPERSEDED; retains why the legacy 'auth' | 'no-auth' strings are refused rather than coerced
59
59
  - [Scenario-step functions are never externally invocable over RPC](scenario-step-functions-are-never-externally-invocable.md) — rpcExposed requires expose and rejects scenarioStep, so test steps stay reachable only from inside a scenario run
60
60
  - [Scope resolution happens at the session boundary and scope sync never deletes](scope-resolution-happens-at-the-session-boundary-and-sync-never-deletes.md) — ScopeService is called when a session is built, never by the function runner, and syncScopes only ever adds — revoking is an explicit operation
61
+ - [A public function says so with auth: false](a-public-function-says-so-with-auth-false.md) — An exposed sessionless function that writes auth: false is public on purpose and PKU574 stays quiet; one that leaves auth out still warns
61
62
  - [Self-authentication is declared, not detected](self-authentication-is-declared-not-detected.md) — A function that authorizes callers in its own body says so with selfAuthenticated; codegen never tries to infer it
62
63
  - [Signed content URLs bind the request path and verify fail-closed](signed-content-urls-bind-the-request-path.md) — A signature that only carries timestamps authorizes every asset, and a verifier with no key must refuse rather than allow
63
64
  - [Webhook bodies are signed before they are enqueued](webhook-bodies-are-signed-before-they-are-enqueued.md) — QueueWebhookService computes the HMAC at enqueue time so the signing key never travels in the queue payload
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.122",
3
+ "version": "0.12.125",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -24,6 +24,7 @@
24
24
  "sideEffects": [
25
25
  "./dist/errors/errors.js",
26
26
  "./dist/wirings/addon/remote-addon-auth.js",
27
+ "./dist/wirings/agent/agent-rpc.js",
27
28
  "./dist/wirings/rpc/rpc-runner.js",
28
29
  "./dist/wirings/workflow/pikku-scenario-service.js",
29
30
  "./dist/wirings/workflow/workflow-approval-policy.js",
@@ -68,6 +69,7 @@
68
69
  "./persona": "./dist/wirings/persona/index.js",
69
70
  "./secret": "./dist/wirings/secret/index.js",
70
71
  "./variable": "./dist/wirings/variable/index.js",
72
+ "./webhook": "./dist/wirings/webhook/index.js",
71
73
  "./oauth2": "./dist/wirings/oauth2/index.js",
72
74
  "./errors": "./dist/errors/index.js",
73
75
  "./services": "./dist/services/index.js",
@@ -100,9 +100,7 @@
100
100
  "reconstructStateAt"
101
101
  ],
102
102
  "./workflow/types": [],
103
- "./actor-flow": [
104
- "runConversation"
105
- ],
103
+ "./actor-flow": ["runConversation"],
106
104
  "./virtual-user": [
107
105
  "DEFAULT_MAX_INTERVAL_MS",
108
106
  "DEFAULT_MIN_INTERVAL_MS",
@@ -165,9 +163,7 @@
165
163
  "wireHTTP",
166
164
  "wireHTTPRoutes"
167
165
  ],
168
- "./remote": [
169
- "buildRemoteHeaders"
170
- ],
166
+ "./remote": ["buildRemoteHeaders"],
171
167
  "./queue": [
172
168
  "QueueJobDiscardedError",
173
169
  "QueueJobFailedError",
@@ -184,9 +180,15 @@
184
180
  "wireScheduler"
185
181
  ],
186
182
  "./trigger": [
183
+ "PIKKU_INCOMING_WEBHOOK_QUEUE_NAME",
187
184
  "PikkuTriggerService",
185
+ "dispatchWebhookSourceJob",
186
+ "receiveWebhookSourceRequest",
187
+ "runWebhookSourceLifecycle",
188
+ "subscribedWebhookEvents",
188
189
  "wireTrigger",
189
- "wireTriggerSource"
190
+ "wireTriggerSource",
191
+ "wireTriggerWebhookSource"
190
192
  ],
191
193
  "./rpc": [
192
194
  "PikkuRPCService",
@@ -207,10 +209,7 @@
207
209
  "safeFetch",
208
210
  "setDefaultHostResolver"
209
211
  ],
210
- "./node-host-resolver": [
211
- "installNodeHostResolver",
212
- "nodeHostResolver"
213
- ],
212
+ "./node-host-resolver": ["installNodeHostResolver", "nodeHostResolver"],
214
213
  "./mcp": [
215
214
  "MCPEndpointRegistry",
216
215
  "MCPError",
@@ -297,10 +296,7 @@
297
296
  "wantsStackTrace",
298
297
  "wireCLI"
299
298
  ],
300
- "./cli/command-parser": [
301
- "generateCommandHelp",
302
- "parseCLIArguments"
303
- ],
299
+ "./cli/command-parser": ["generateCommandHelp", "parseCLIArguments"],
304
300
  "./cli/channel": [
305
301
  "executeCLIViaChannel",
306
302
  "executeRawCLIViaChannel",
@@ -360,10 +356,8 @@
360
356
  "deriveOAuth2AppSecrets",
361
357
  "validateAndBuildSecretDefinitionsMeta"
362
358
  ],
363
- "./variable": [
364
- "defineVariable",
365
- "validateAndBuildVariableDefinitionsMeta"
366
- ],
359
+ "./variable": ["defineVariable", "validateAndBuildVariableDefinitionsMeta"],
360
+ "./webhook": ["defineOutgoingWebhook"],
367
361
  "./oauth2": [],
368
362
  "./errors": [
369
363
  "AIProviderAuthError",
@@ -428,6 +422,7 @@
428
422
  "InMemorySessionStore",
429
423
  "InMemoryTriggerService",
430
424
  "InMemoryWorkflowService",
425
+ "IncomingWebhookService",
431
426
  "JsonConsoleLogger",
432
427
  "LocalCredentialService",
433
428
  "LocalEmailService",
@@ -486,19 +481,10 @@
486
481
  "subjectIdOf",
487
482
  "validateAndBuildFeatureFlagDefinitionsMeta"
488
483
  ],
489
- "./services/local-meta": [
490
- "LocalMetaService"
491
- ],
492
- "./services/v8-coverage": [
493
- "V8CoverageService"
494
- ],
495
- "./services/istanbul-coverage": [
496
- "IstanbulCoverageService"
497
- ],
498
- "./services/local-content": [
499
- "LocalContent",
500
- "signedContentPath"
501
- ],
484
+ "./services/local-meta": ["LocalMetaService"],
485
+ "./services/v8-coverage": ["V8CoverageService"],
486
+ "./services/istanbul-coverage": ["IstanbulCoverageService"],
487
+ "./services/local-content": ["LocalContent", "signedContentPath"],
502
488
  "./services/local-content-request-handler": [
503
489
  "createLocalContentRequestHandler",
504
490
  "verifySignedContentRequest"
@@ -528,10 +514,7 @@
528
514
  "verifyWithKeyMaterial",
529
515
  "wrapDEK"
530
516
  ],
531
- "./hmac": [
532
- "hmacSha256Hex",
533
- "timingSafeStringEqual"
534
- ],
517
+ "./hmac": ["hmacSha256Hex", "timingSafeStringEqual"],
535
518
  "./state": [
536
519
  "addPackageServiceFactories",
537
520
  "getAllPackageStates",
@@ -578,13 +561,6 @@
578
561
  "getSchema",
579
562
  "validateSchema"
580
563
  ],
581
- "./testing": [
582
- "clearPikkuRuntimeState",
583
- "defineServiceTests"
584
- ],
585
- "./dev": [
586
- "pikkuDevReloader",
587
- "reconcileAddonRegistry",
588
- "reloadGeneratedMeta"
589
- ]
564
+ "./testing": ["clearPikkuRuntimeState", "defineServiceTests"],
565
+ "./dev": ["pikkuDevReloader", "reconcileAddonRegistry", "reloadGeneratedMeta"]
590
566
  }