@pikku/core 0.12.63 → 0.12.64

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 (198) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/dist/dev/hot-reload.js +13 -55
  3. package/dist/dev/module-runner.d.ts +14 -0
  4. package/dist/dev/module-runner.js +59 -0
  5. package/dist/dev/reload-meta.d.ts +13 -0
  6. package/dist/dev/reload-meta.js +22 -0
  7. package/dist/errors/errors.d.ts +12 -0
  8. package/dist/errors/errors.js +19 -0
  9. package/dist/function/function-runner.d.ts +5 -5
  10. package/dist/function/function-runner.js +39 -111
  11. package/dist/function/functions.types.d.ts +17 -7
  12. package/dist/index.d.ts +5 -1
  13. package/dist/index.js +3 -1
  14. package/dist/middleware/auth-apikey.d.ts +1 -0
  15. package/dist/middleware/auth-bearer.d.ts +1 -0
  16. package/dist/middleware/auth-cookie.d.ts +1 -0
  17. package/dist/middleware/cors.d.ts +1 -0
  18. package/dist/middleware/index.d.ts +1 -1
  19. package/dist/middleware/index.js +1 -1
  20. package/dist/middleware/remote-auth.d.ts +1 -0
  21. package/dist/middleware/telemetry.d.ts +2 -0
  22. package/dist/middleware/timeout.d.ts +1 -0
  23. package/dist/permissions.d.ts +32 -25
  24. package/dist/permissions.js +104 -185
  25. package/dist/pikku-state.js +0 -2
  26. package/dist/scopes.d.ts +17 -0
  27. package/dist/scopes.js +59 -0
  28. package/dist/services/ai-embedding-service.d.ts +31 -0
  29. package/dist/services/ai-embedding-service.js +1 -0
  30. package/dist/services/credential-wire-service.d.ts +3 -1
  31. package/dist/services/credential-wire-service.js +10 -4
  32. package/dist/services/index.d.ts +4 -0
  33. package/dist/services/index.js +2 -0
  34. package/dist/services/meta-service.d.ts +15 -2
  35. package/dist/services/meta-service.js +51 -1
  36. package/dist/services/queue-webhook-service.d.ts +55 -0
  37. package/dist/services/queue-webhook-service.js +136 -0
  38. package/dist/services/scope-service.d.ts +67 -0
  39. package/dist/services/scope-service.js +1 -0
  40. package/dist/services/typed-secret-service.d.ts +8 -0
  41. package/dist/services/typed-secret-service.js +39 -4
  42. package/dist/services/webhook-service.d.ts +140 -0
  43. package/dist/services/webhook-service.js +44 -0
  44. package/dist/services/workflow-service.d.ts +7 -0
  45. package/dist/types/core.types.d.ts +34 -6
  46. package/dist/types/state.types.d.ts +20 -2
  47. package/dist/utils/hmac.d.ts +16 -0
  48. package/dist/utils/hmac.js +26 -0
  49. package/dist/utils/safe-fetch.d.ts +51 -0
  50. package/dist/utils/safe-fetch.js +192 -0
  51. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  53. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
  54. package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
  55. package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
  56. package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
  57. package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
  58. package/dist/wirings/ai-agent/index.d.ts +1 -0
  59. package/dist/wirings/ai-agent/index.js +1 -0
  60. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  61. package/dist/wirings/ai-agent/voice-input.js +2 -38
  62. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  63. package/dist/wirings/channel/channel-common.js +0 -1
  64. package/dist/wirings/channel/channel-handler.js +1 -4
  65. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  66. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  67. package/dist/wirings/channel/channel.types.d.ts +2 -6
  68. package/dist/wirings/cli/cli-runner.js +0 -2
  69. package/dist/wirings/cli/cli.types.d.ts +1 -2
  70. package/dist/wirings/http/http-routes.js +0 -3
  71. package/dist/wirings/http/http-runner.d.ts +9 -29
  72. package/dist/wirings/http/http-runner.js +9 -34
  73. package/dist/wirings/http/http.types.d.ts +1 -9
  74. package/dist/wirings/mcp/mcp-runner.js +0 -2
  75. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  76. package/dist/wirings/oauth2/index.d.ts +0 -3
  77. package/dist/wirings/oauth2/index.js +1 -2
  78. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  79. package/dist/wirings/rpc/addon-runner.js +173 -0
  80. package/dist/wirings/rpc/index.d.ts +5 -1
  81. package/dist/wirings/rpc/index.js +3 -1
  82. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  83. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  84. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  85. package/dist/wirings/rpc/rpc-runner.js +109 -3
  86. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  87. package/dist/wirings/rpc/wire-addon.js +9 -0
  88. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  89. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  90. package/dist/wirings/scope/index.d.ts +3 -0
  91. package/dist/wirings/scope/index.js +2 -0
  92. package/dist/wirings/scope/scope.types.d.ts +39 -0
  93. package/dist/wirings/scope/scope.types.js +1 -0
  94. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  95. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  96. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  97. package/dist/wirings/scope/wire-scope.js +32 -0
  98. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  99. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
  100. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  101. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  102. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  103. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  104. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  105. package/dist/wirings/workflow/index.d.ts +2 -2
  106. package/dist/wirings/workflow/index.js +1 -1
  107. package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
  108. package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
  109. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  110. package/package.json +5 -1
  111. package/run-tests.sh +1 -0
  112. package/src/dev/hot-reload.ts +13 -68
  113. package/src/dev/module-runner.test.ts +169 -0
  114. package/src/dev/module-runner.ts +103 -0
  115. package/src/dev/reload-meta.test.ts +31 -2
  116. package/src/dev/reload-meta.ts +26 -0
  117. package/src/errors/errors.ts +24 -0
  118. package/src/function/function-runner.test.ts +276 -142
  119. package/src/function/function-runner.ts +52 -145
  120. package/src/function/functions.types.ts +28 -38
  121. package/src/index.ts +8 -6
  122. package/src/middleware/index.ts +1 -5
  123. package/src/permissions.test.ts +153 -372
  124. package/src/permissions.ts +139 -259
  125. package/src/pikku-state.ts +0 -2
  126. package/src/scopes.test.ts +167 -0
  127. package/src/scopes.ts +69 -0
  128. package/src/services/ai-embedding-service.ts +31 -0
  129. package/src/services/credential-wire-service.ts +10 -4
  130. package/src/services/index.ts +20 -0
  131. package/src/services/meta-service.ts +68 -3
  132. package/src/services/queue-webhook-service.test.ts +408 -0
  133. package/src/services/queue-webhook-service.ts +182 -0
  134. package/src/services/scope-service.ts +75 -0
  135. package/src/services/typed-secret-service.test.ts +35 -0
  136. package/src/services/typed-secret-service.ts +39 -4
  137. package/src/services/webhook-service.ts +180 -0
  138. package/src/services/workflow-service.ts +7 -0
  139. package/src/types/core.types.ts +42 -14
  140. package/src/types/state.types.ts +23 -3
  141. package/src/utils/hmac.ts +27 -0
  142. package/src/utils/safe-fetch.test.ts +373 -0
  143. package/src/utils/safe-fetch.ts +213 -0
  144. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  145. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  146. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
  147. package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
  148. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  149. package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
  150. package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
  151. package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
  152. package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
  153. package/src/wirings/ai-agent/index.ts +1 -0
  154. package/src/wirings/ai-agent/voice-input.ts +6 -35
  155. package/src/wirings/channel/channel-common.ts +0 -1
  156. package/src/wirings/channel/channel-handler.ts +1 -9
  157. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  158. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  159. package/src/wirings/channel/channel.types.ts +0 -6
  160. package/src/wirings/cli/cli-runner.ts +0 -2
  161. package/src/wirings/cli/cli.types.ts +0 -2
  162. package/src/wirings/http/http-routes.ts +0 -3
  163. package/src/wirings/http/http-runner.test.ts +1 -11
  164. package/src/wirings/http/http-runner.ts +16 -39
  165. package/src/wirings/http/http.types.ts +1 -10
  166. package/src/wirings/mcp/mcp-runner.ts +0 -2
  167. package/src/wirings/mcp/mcp.types.ts +3 -11
  168. package/src/wirings/oauth2/index.ts +0 -3
  169. package/src/wirings/rpc/addon-runner.ts +233 -0
  170. package/src/wirings/rpc/index.ts +17 -1
  171. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  172. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  173. package/src/wirings/rpc/rpc-runner.ts +155 -3
  174. package/src/wirings/rpc/rpc-types.ts +16 -5
  175. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  176. package/src/wirings/rpc/wire-addon.ts +9 -0
  177. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  178. package/src/wirings/scope/index.ts +14 -0
  179. package/src/wirings/scope/scope.test.ts +135 -0
  180. package/src/wirings/scope/scope.types.ts +44 -0
  181. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  182. package/src/wirings/scope/wire-scope.ts +34 -0
  183. package/src/wirings/workflow/dsl/index.ts +4 -0
  184. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
  185. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  186. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  187. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  188. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  189. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  190. package/src/wirings/workflow/index.ts +5 -0
  191. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  192. package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
  193. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  194. package/src/wirings/workflow/workflow.types.ts +4 -0
  195. package/tsconfig.tsbuildinfo +1 -1
  196. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  197. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  198. package/src/wirings/oauth2/oauth2-routes.ts +0 -234
@@ -21,15 +21,18 @@ import type { AIStorageService } from '../services/ai-storage-service.js';
21
21
  import type { ContentService } from '../services/content-service.js';
22
22
  import type { ScenarioActors } from '../services/scenario-actors-service.js';
23
23
  import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.js';
24
+ import type { AIEmbeddingService } from '../services/ai-embedding-service.js';
24
25
  import type { AIRunStateService } from '../services/ai-run-state-service.js';
25
26
  import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js';
26
27
  import type { PikkuAIMiddlewareHooks } from '../wirings/ai-agent/ai-agent.types.js';
27
28
  import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js';
28
29
  import type { CredentialService } from '../services/credential-service.js';
29
30
  import type { EmailService } from '../services/email-service.js';
31
+ import type { WebhookService, WebhookServiceConfig } from '../services/webhook-service.js';
30
32
  import type { MetaService } from '../services/meta-service.js';
31
33
  import type { CoverageService } from '../services/v8-coverage-service.js';
32
34
  import type { SessionStore } from '../services/session-store.js';
35
+ import type { ScopeService } from '../services/scope-service.js';
33
36
  import type { AuditDurability, AuditLog, AuditService } from '../services/audit-service.js';
34
37
  export type PikkuWiringTypes = 'http' | 'scheduler' | 'trigger' | 'channel' | 'rpc' | 'queue' | 'mcp' | 'cli' | 'workflow' | 'agent' | 'gateway';
35
38
  export interface FunctionServicesMeta {
@@ -63,13 +66,13 @@ export type MiddlewareMetadata = {
63
66
  * - type: 'tag' = Tag-based permission group (references tagGroup in pikkuState)
64
67
  * - type: 'wire' = Wire-level individual permission
65
68
  */
69
+ /**
70
+ * A reference to a permission function a function declares, resolved by name.
71
+ * Used at filter time (e.g. listing agent tools) to run the session-only
72
+ * (pikkuAuth) predicates without request data. Permissions are function-scoped
73
+ * only — there are no wire- or tag-level permission references.
74
+ */
66
75
  export type PermissionMetadata = {
67
- type: 'http';
68
- route: string;
69
- } | {
70
- type: 'tag';
71
- tag: string;
72
- } | {
73
76
  type: 'wire';
74
77
  name: string;
75
78
  inline?: boolean;
@@ -78,6 +81,8 @@ export type FunctionRuntimeMeta = {
78
81
  pikkuFuncId: string;
79
82
  inputSchemaName: string | null;
80
83
  outputSchemaName: string | null;
84
+ /** Scopes the session must hold to run this function. All are required (AND). */
85
+ scopes?: string[];
81
86
  expose?: boolean;
82
87
  remote?: boolean;
83
88
  mcp?: boolean;
@@ -184,6 +189,8 @@ export type CoreConfig<Config extends Record<string, unknown> = {}> = {
184
189
  /** Secrets used by the application (optional). */
185
190
  secrets?: {};
186
191
  workflow?: WorkflowServiceConfig;
192
+ /** Default retry and signing settings for outgoing webhooks. */
193
+ webhook?: WebhookServiceConfig;
187
194
  /** Runtime Postgres adapter options (pool sizing). */
188
195
  postgres?: PostgresConfig;
189
196
  } & Config;
@@ -195,6 +202,12 @@ export interface CoreUserSession {
195
202
  orgId?: string;
196
203
  /** True when the session belongs to a synthetic scenario actor — lets audits/analytics address synthetic traffic */
197
204
  actor?: boolean;
205
+ /**
206
+ * Scopes granted to this session, checked against a function's `scopes`.
207
+ * Populated by whoever builds the session (e.g. better-auth's `mapSession`
208
+ * resolving them via a ScopeService) — core reads them and never fetches.
209
+ */
210
+ scopes?: string[];
198
211
  }
199
212
  /**
200
213
  * Interface for core singleton services provided by Pikku.
@@ -228,6 +241,8 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
228
241
  content?: ContentService;
229
242
  /** AI agent runner service (model calls + tool loop) */
230
243
  aiAgentRunner?: AIAgentRunnerService;
244
+ /** Dedicated embedding service (vector stores use it at index & query time) */
245
+ aiEmbedding?: AIEmbeddingService;
231
246
  /** AI run state service (run lifecycle + approval persistence) */
232
247
  aiRunState?: AIRunStateService;
233
248
  /** Agent run service (listing threads, runs, steps) */
@@ -238,6 +253,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
238
253
  credentialService?: CredentialService;
239
254
  /** Email service for outbound messages and template-backed delivery */
240
255
  emailService?: EmailService;
256
+ /**
257
+ * Webhook service for outgoing webhook delivery via a queue. A store-backed
258
+ * implementation (e.g. `KyselyWebhookService`) additionally records delivery
259
+ * history; the queue-only default throws on the delivery-read methods.
260
+ */
261
+ webhookService?: WebhookService;
241
262
  /** Meta service for reading .pikku metadata files (filesystem on Node, R2/KV on CF) */
242
263
  metaService?: MetaService;
243
264
  /** V8 precise-coverage collector (`pikku dev --coverage` only) */
@@ -254,6 +275,11 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
254
275
  auditLog?: AuditLog;
255
276
  /** Session store for persisting user sessions keyed by pikkuUserId */
256
277
  sessionStore?: SessionStore;
278
+ /**
279
+ * Resolves and administers user scopes. Called when building a session (e.g.
280
+ * better-auth's `mapSession`), never by the function runner.
281
+ */
282
+ scopeService?: ScopeService;
257
283
  }
258
284
  /**
259
285
  * Represents different forms of wire within Pikku and the outside world.
@@ -268,6 +294,8 @@ export type PikkuWire<In = unknown, Out = unknown, HasInitialSession extends boo
268
294
  traceId: string;
269
295
  /** Function id for the current invocation */
270
296
  functionId: string;
297
+ /** The addon instance namespace (wireAddon name) currently executing, if any */
298
+ addonNamespace: string;
271
299
  http: PikkuHTTP<In>;
272
300
  mcp: PikkuMCP<MCPTools>;
273
301
  channel: [IsChannel] extends [null] ? PikkuChannel<unknown, Out> : PikkuChannel<unknown, Out> | undefined;
@@ -35,6 +35,26 @@ export interface PikkuPackageState {
35
35
  rpcEndpoint?: string;
36
36
  auth?: boolean;
37
37
  tags?: string[];
38
+ /** Per-instance name-aliases: logical name the addon reads -> actual project secret name */
39
+ secretOverrides?: Record<string, string>;
40
+ /** Per-instance name-aliases: logical name the addon reads -> actual project variable name */
41
+ variableOverrides?: Record<string, string>;
42
+ /** Per-instance name-aliases: logical name the addon reads -> actual project credential name */
43
+ credentialOverrides?: Record<string, string>;
44
+ /** Set by `wireRemoteAddon`: this namespace is consumed remotely over HTTP, not bundled */
45
+ remote?: boolean;
46
+ /** Remote host base URL (wireRemoteAddon) — string or resolver over singleton services */
47
+ serverUrl?: string | ((services: any) => string | Promise<string>);
48
+ /** Consumer-side auth binding for the hosted addon (wireRemoteAddon) */
49
+ remoteAuth?: {
50
+ credentialId: string;
51
+ } | {
52
+ secretId: string;
53
+ } | {
54
+ resolve: (services: any, wire: any) => string | Promise<string>;
55
+ };
56
+ /** Map consumer-facing fn name → remote fn name (wireRemoteAddon) */
57
+ remoteName?: (fn: string) => string;
38
58
  }>;
39
59
  };
40
60
  http: {
@@ -94,8 +114,6 @@ export interface PikkuPackageState {
94
114
  tagGroup: Record<string, CorePikkuChannelMiddleware[]>;
95
115
  };
96
116
  permissions: {
97
- tagGroup: Record<string, CorePermissionGroup | CorePikkuPermission[]>;
98
- httpGroup: Record<string, CorePermissionGroup | CorePikkuPermission[]>;
99
117
  global: (CorePermissionGroup | CorePikkuPermission)[];
100
118
  };
101
119
  misc: {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * HMAC-SHA256 a payload, hex-encoded.
3
+ *
4
+ * Senders wrap this in their own scheme prefix (`sha256=`, `v0=`, …).
5
+ *
6
+ * @param secret - The signing key shared with the other side
7
+ * @param payload - The exact bytes that were signed
8
+ */
9
+ export declare function hmacSha256Hex(secret: string, payload: string): string;
10
+ /**
11
+ * Compare two signatures without leaking their contents through timing.
12
+ *
13
+ * Returns false rather than throwing on a length mismatch, which is what
14
+ * `timingSafeEqual` does when the buffers differ in size.
15
+ */
16
+ export declare function timingSafeStringEqual(a: string, b: string): boolean;
@@ -0,0 +1,26 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * HMAC-SHA256 a payload, hex-encoded.
4
+ *
5
+ * Senders wrap this in their own scheme prefix (`sha256=`, `v0=`, …).
6
+ *
7
+ * @param secret - The signing key shared with the other side
8
+ * @param payload - The exact bytes that were signed
9
+ */
10
+ export function hmacSha256Hex(secret, payload) {
11
+ return createHmac('sha256', secret).update(payload).digest('hex');
12
+ }
13
+ /**
14
+ * Compare two signatures without leaking their contents through timing.
15
+ *
16
+ * Returns false rather than throwing on a length mismatch, which is what
17
+ * `timingSafeEqual` does when the buffers differ in size.
18
+ */
19
+ export function timingSafeStringEqual(a, b) {
20
+ try {
21
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * SSRF-aware fetch helpers.
3
+ *
4
+ * `@pikku/core` runs in edge runtimes (Cloudflare Workers) with no Node `dns`,
5
+ * so we cannot resolve hostnames to check for private targets. We reject the
6
+ * obvious internal literals and, crucially, re-validate every redirect hop —
7
+ * a public URL that 302s to `169.254.169.254` is the common bypass. This does
8
+ * NOT defend against a public hostname that itself resolves to a private IP
9
+ * (DNS rebinding), which is out of reach without DNS resolution.
10
+ */
11
+ /**
12
+ * Whether a hostname is an obvious internal/private target (loopback, private
13
+ * IPv4 ranges, link-local incl. the cloud metadata endpoint, or private IPv6).
14
+ *
15
+ * Alias/encoded forms that resolve to the same targets are also rejected: a
16
+ * trailing-dot FQDN (`localhost.`), the `*.localhost` reserved name, IPv4-mapped
17
+ * IPv6 (`::ffff:127.0.0.1`), and octal/decimal/hex-encoded IPv4. This is
18
+ * best-effort literal matching only — it cannot catch a public hostname that
19
+ * *resolves* to a private IP (DNS rebinding), which needs DNS resolution
20
+ * unavailable in edge runtimes.
21
+ */
22
+ export declare function isPrivateHost(hostname: string): boolean;
23
+ export interface SafeFetchOptions {
24
+ /**
25
+ * When set, the host of every hop must appear in this allowlist. When omitted,
26
+ * any host that is not {@link isPrivateHost} is permitted.
27
+ */
28
+ allowedHosts?: string[];
29
+ /** Maximum redirect hops to follow (each re-validated). Defaults to 3. */
30
+ maxRedirects?: number;
31
+ }
32
+ /**
33
+ * Parse and validate a URL for outbound fetching: only http(s), and — unless an
34
+ * `allowedHosts` allowlist is supplied — not an obvious private/internal host.
35
+ * Returns the parsed URL or throws.
36
+ */
37
+ export declare function assertFetchableUrl(url: string, options?: SafeFetchOptions): URL;
38
+ /**
39
+ * `fetch` with SSRF protection. The initial URL and every redirect target are
40
+ * validated with {@link assertFetchableUrl}. Redirects are followed manually
41
+ * (`redirect: 'manual'`) so an unsafe `Location` can never be followed into the
42
+ * internal network; only the redirect statuses in {@link REDIRECT_STATUSES} are
43
+ * followed, and the method/body are transformed per {@link redirectInit}. Each
44
+ * intermediate redirect response body is cancelled before the next hop so it is
45
+ * not left dangling. When a redirect cannot or should not be followed (no
46
+ * `Location`, or the hop budget is exhausted) the raw redirect response is
47
+ * returned for the caller to handle by status. Credential headers
48
+ * (`Authorization`, `Cookie`) are stripped whenever a redirect crosses origin,
49
+ * so they never leak to a redirected host.
50
+ */
51
+ export declare function safeFetch(url: string, init?: RequestInit, options?: SafeFetchOptions): Promise<Response>;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * SSRF-aware fetch helpers.
3
+ *
4
+ * `@pikku/core` runs in edge runtimes (Cloudflare Workers) with no Node `dns`,
5
+ * so we cannot resolve hostnames to check for private targets. We reject the
6
+ * obvious internal literals and, crucially, re-validate every redirect hop —
7
+ * a public URL that 302s to `169.254.169.254` is the common bypass. This does
8
+ * NOT defend against a public hostname that itself resolves to a private IP
9
+ * (DNS rebinding), which is out of reach without DNS resolution.
10
+ */
11
+ /**
12
+ * Parse the many textual encodings of an IPv4 address that `fetch`/`undici`
13
+ * (and `inet_aton`-style parsers) accept — a dotted quad whose octets may be
14
+ * decimal, octal (`0177`) or hex (`0x7f`), or the whole address as a single
15
+ * 32-bit integer (decimal `2130706433`, hex `0x7f000001`). Returns the four
16
+ * octets, or `null` when the host is not a numeric IPv4 literal.
17
+ */
18
+ function parseIPv4Octets(host) {
19
+ const toInt = (part) => {
20
+ let n;
21
+ if (/^0x[0-9a-f]+$/.test(part))
22
+ n = parseInt(part, 16);
23
+ else if (/^0[0-7]+$/.test(part))
24
+ n = parseInt(part, 8);
25
+ else if (/^\d+$/.test(part))
26
+ n = parseInt(part, 10);
27
+ else
28
+ return null;
29
+ return Number.isInteger(n) ? n : null;
30
+ };
31
+ if (!host.includes('.')) {
32
+ const n = toInt(host);
33
+ if (n === null || n < 0 || n > 0xffffffff)
34
+ return null;
35
+ return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
36
+ }
37
+ const parts = host.split('.');
38
+ if (parts.length !== 4)
39
+ return null;
40
+ const octets = [];
41
+ for (const part of parts) {
42
+ const n = toInt(part);
43
+ if (n === null || n < 0 || n > 0xff)
44
+ return null;
45
+ octets.push(n);
46
+ }
47
+ return octets;
48
+ }
49
+ /**
50
+ * Whether a hostname is an obvious internal/private target (loopback, private
51
+ * IPv4 ranges, link-local incl. the cloud metadata endpoint, or private IPv6).
52
+ *
53
+ * Alias/encoded forms that resolve to the same targets are also rejected: a
54
+ * trailing-dot FQDN (`localhost.`), the `*.localhost` reserved name, IPv4-mapped
55
+ * IPv6 (`::ffff:127.0.0.1`), and octal/decimal/hex-encoded IPv4. This is
56
+ * best-effort literal matching only — it cannot catch a public hostname that
57
+ * *resolves* to a private IP (DNS rebinding), which needs DNS resolution
58
+ * unavailable in edge runtimes.
59
+ */
60
+ export function isPrivateHost(hostname) {
61
+ const host = hostname
62
+ .replace(/^\[|\]$/g, '')
63
+ .replace(/\.$/, '')
64
+ .toLowerCase();
65
+ if (host === '' || host === 'localhost' || host.endsWith('.localhost'))
66
+ return true;
67
+ if (host.includes(':')) {
68
+ if (host === '::' || host === '::1')
69
+ return true;
70
+ const mappedV4 = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
71
+ if (mappedV4)
72
+ return isPrivateHost(mappedV4[1]);
73
+ const mappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
74
+ if (mappedHex) {
75
+ const hi = parseInt(mappedHex[1], 16);
76
+ const lo = parseInt(mappedHex[2], 16);
77
+ return isPrivateHost(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
78
+ }
79
+ if (/^fe[89ab]/.test(host))
80
+ return true; // link-local fe80::/10
81
+ if (host.startsWith('fc') || host.startsWith('fd'))
82
+ return true; // unique-local fc00::/7
83
+ return false;
84
+ }
85
+ const v4 = parseIPv4Octets(host);
86
+ if (v4) {
87
+ const [a, b] = v4;
88
+ if (a === 127 || a === 10 || a === 0)
89
+ return true;
90
+ if (a === 169 && b === 254)
91
+ return true; // link-local incl. cloud metadata
92
+ if (a === 172 && b >= 16 && b <= 31)
93
+ return true;
94
+ if (a === 192 && b === 168)
95
+ return true;
96
+ }
97
+ return false;
98
+ }
99
+ /**
100
+ * Parse and validate a URL for outbound fetching: only http(s), and — unless an
101
+ * `allowedHosts` allowlist is supplied — not an obvious private/internal host.
102
+ * Returns the parsed URL or throws.
103
+ */
104
+ export function assertFetchableUrl(url, options = {}) {
105
+ const parsed = new URL(url);
106
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
107
+ throw new Error(`Refusing to fetch non-HTTP(S) URL: ${parsed.protocol}`);
108
+ }
109
+ if (options.allowedHosts) {
110
+ if (!options.allowedHosts.includes(parsed.hostname)) {
111
+ throw new Error(`URL host is not in the allowlist: ${parsed.hostname}`);
112
+ }
113
+ }
114
+ else if (isPrivateHost(parsed.hostname)) {
115
+ throw new Error(`Refusing to fetch from a private/internal host: ${parsed.hostname}`);
116
+ }
117
+ return parsed;
118
+ }
119
+ /**
120
+ * The only 3xx statuses that request a redirect be followed. `300` (Multiple
121
+ * Choices), `304` (Not Modified), `305` (Use Proxy) and `306` are returned to
122
+ * the caller as-is rather than followed.
123
+ */
124
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
125
+ /**
126
+ * Drop credential-bearing headers (`Authorization`, `Cookie`) so they are not
127
+ * replayed to a different origin across a redirect.
128
+ */
129
+ function stripCredentialHeaders(init) {
130
+ if (!init.headers)
131
+ return init;
132
+ const headers = new Headers(init.headers);
133
+ headers.delete('authorization');
134
+ headers.delete('cookie');
135
+ return { ...init, headers };
136
+ }
137
+ /**
138
+ * Apply the WHATWG-fetch method/body transform for a redirect: a `303` (and a
139
+ * `301`/`302` on a `POST`) becomes a bodyless `GET`; `307`/`308` preserve the
140
+ * original method and body. When the method changes to `GET` the request body
141
+ * and its `Content-*` headers are dropped.
142
+ */
143
+ function redirectInit(status, init) {
144
+ const method = (init.method ?? 'GET').toUpperCase();
145
+ const toGet = (status === 303 && method !== 'GET' && method !== 'HEAD') ||
146
+ ((status === 301 || status === 302) && method === 'POST');
147
+ if (!toGet)
148
+ return init;
149
+ const headers = new Headers(init.headers);
150
+ headers.delete('content-length');
151
+ headers.delete('content-type');
152
+ return { ...init, method: 'GET', body: undefined, headers };
153
+ }
154
+ /**
155
+ * `fetch` with SSRF protection. The initial URL and every redirect target are
156
+ * validated with {@link assertFetchableUrl}. Redirects are followed manually
157
+ * (`redirect: 'manual'`) so an unsafe `Location` can never be followed into the
158
+ * internal network; only the redirect statuses in {@link REDIRECT_STATUSES} are
159
+ * followed, and the method/body are transformed per {@link redirectInit}. Each
160
+ * intermediate redirect response body is cancelled before the next hop so it is
161
+ * not left dangling. When a redirect cannot or should not be followed (no
162
+ * `Location`, or the hop budget is exhausted) the raw redirect response is
163
+ * returned for the caller to handle by status. Credential headers
164
+ * (`Authorization`, `Cookie`) are stripped whenever a redirect crosses origin,
165
+ * so they never leak to a redirected host.
166
+ */
167
+ export async function safeFetch(url, init = {}, options = {}) {
168
+ const maxRedirects = options.maxRedirects ?? 3;
169
+ let currentUrl = assertFetchableUrl(url, options).toString();
170
+ let currentInit = init;
171
+ for (let hop = 0;; hop++) {
172
+ const response = await fetch(currentUrl, {
173
+ ...currentInit,
174
+ redirect: 'manual',
175
+ });
176
+ if (!REDIRECT_STATUSES.has(response.status)) {
177
+ return response;
178
+ }
179
+ const location = response.headers.get('location');
180
+ if (!location || hop >= maxRedirects) {
181
+ return response;
182
+ }
183
+ const nextUrl = assertFetchableUrl(new URL(location, currentUrl).toString(), options).toString();
184
+ await response.body?.cancel();
185
+ let nextInit = redirectInit(response.status, currentInit);
186
+ if (new URL(nextUrl).origin !== new URL(currentUrl).origin) {
187
+ nextInit = stripCredentialHeaders(nextInit);
188
+ }
189
+ currentInit = nextInit;
190
+ currentUrl = nextUrl;
191
+ }
192
+ }
@@ -0,0 +1,84 @@
1
+ import type { AIStreamChannel } from './ai-agent.types.js';
2
+ type AGUIEvent = {
3
+ type: 'TEXT_MESSAGE_START';
4
+ messageId: string;
5
+ } | {
6
+ type: 'TEXT_MESSAGE_CONTENT';
7
+ messageId: string;
8
+ delta: string;
9
+ } | {
10
+ type: 'TEXT_MESSAGE_END';
11
+ messageId: string;
12
+ } | {
13
+ type: 'TOOL_CALL_START';
14
+ toolCallId: string;
15
+ toolCallName: string;
16
+ } | {
17
+ type: 'TOOL_CALL_ARGS';
18
+ toolCallId: string;
19
+ delta: string;
20
+ } | {
21
+ type: 'TOOL_CALL_END';
22
+ toolCallId: string;
23
+ toolCallName: string;
24
+ } | {
25
+ type: 'TOOL_CALL_RESULT';
26
+ messageId: string;
27
+ toolCallId: string;
28
+ role: 'tool';
29
+ content: string;
30
+ } | {
31
+ type: 'THINKING_START';
32
+ } | {
33
+ type: 'THINKING_TEXT_MESSAGE_START';
34
+ messageId: string;
35
+ } | {
36
+ type: 'THINKING_TEXT_MESSAGE_CONTENT';
37
+ messageId: string;
38
+ delta: string;
39
+ } | {
40
+ type: 'THINKING_TEXT_MESSAGE_END';
41
+ messageId: string;
42
+ } | {
43
+ type: 'THINKING_END';
44
+ } | {
45
+ type: 'RUN_STARTED';
46
+ threadId: string;
47
+ runId: string;
48
+ } | {
49
+ type: 'RUN_FINISHED';
50
+ threadId: string;
51
+ runId: string;
52
+ model?: string;
53
+ usage?: {
54
+ promptTokens: number;
55
+ completionTokens: number;
56
+ totalTokens: number;
57
+ };
58
+ } | {
59
+ type: 'RUN_ERROR';
60
+ message: string;
61
+ code?: string;
62
+ } | {
63
+ type: 'STEP_STARTED';
64
+ stepName: string;
65
+ } | {
66
+ type: 'STEP_FINISHED';
67
+ stepName: string;
68
+ } | {
69
+ type: 'CUSTOM';
70
+ name: string;
71
+ value: unknown;
72
+ };
73
+ export type { AGUIEvent };
74
+ export type AGUIChannelOptions = {
75
+ threadId?: string;
76
+ runId?: string;
77
+ /**
78
+ * Late-bound runId source, resolved when the run is opened (first event).
79
+ * Lets the stream path report the AIRunStateService runId on RUN_STARTED
80
+ * even though that id is only created after the channel is wrapped.
81
+ */
82
+ getRunId?: () => string | undefined;
83
+ };
84
+ export declare function wrapChannelWithAGUI(inner: AIStreamChannel, options?: AGUIChannelOptions): AIStreamChannel;