@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
@@ -0,0 +1,233 @@
1
+ import { pikkuState } from '../../pikku-state.js'
2
+ import type {
3
+ CoreConfig,
4
+ CoreSingletonServices,
5
+ } from '../../types/core.types.js'
6
+ import type { SecretService } from '../../services/secret-service.js'
7
+ import type { VariablesService } from '../../services/variables-service.js'
8
+
9
+ /**
10
+ * A single wired addon instance: a namespace (wireAddon `name`) plus the
11
+ * per-instance name-aliases that remap the logical names the addon reads to
12
+ * the actual project secret/variable/credential names.
13
+ */
14
+ export type AddonInstance = {
15
+ namespace: string
16
+ secretOverrides?: Record<string, string>
17
+ variableOverrides?: Record<string, string>
18
+ credentialOverrides?: Record<string, string>
19
+ }
20
+
21
+ /**
22
+ * Wrap a SecretService so that the logical secret names an addon reads are
23
+ * remapped to the actual project secret names via the instance's overrides.
24
+ */
25
+ const aliasSecretService = (
26
+ secrets: SecretService,
27
+ overrides: Record<string, string>
28
+ ): SecretService => {
29
+ const map = (key: string) => overrides[key] ?? key
30
+ return {
31
+ getSecret: <T = string>(key: string) => secrets.getSecret<T>(map(key)),
32
+ hasSecret: (key: string) => secrets.hasSecret(map(key)),
33
+ setSecret: (key: string, value: unknown) =>
34
+ secrets.setSecret(map(key), value),
35
+ deleteSecret: (key: string) => secrets.deleteSecret(map(key)),
36
+ getSecrets: async (keys) => {
37
+ const result = await secrets.getSecrets(keys.map(map))
38
+ const out: Record<string, unknown> = {}
39
+ for (const logical of keys) {
40
+ const real = map(logical)
41
+ if (real in result)
42
+ out[logical] = (result as Record<string, unknown>)[real]
43
+ }
44
+ return out as never
45
+ },
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Wrap a VariablesService so that the logical variable names an addon reads
51
+ * are remapped to the actual project variable names via the instance's overrides.
52
+ */
53
+ const aliasVariablesService = (
54
+ variables: VariablesService,
55
+ overrides: Record<string, string>
56
+ ): VariablesService => {
57
+ const map = (name: string) => overrides[name] ?? name
58
+ return {
59
+ get: <T = string>(name: string) => variables.get<T>(map(name)),
60
+ getVariables: (names) => {
61
+ const result = variables.getVariables(names.map(map) as never)
62
+ const remap = (r: Record<string, unknown>) => {
63
+ const out: Record<string, unknown> = {}
64
+ for (const logical of names) {
65
+ const real = map(logical)
66
+ if (real in r) out[logical] = r[real]
67
+ }
68
+ return out
69
+ }
70
+ return result instanceof Promise
71
+ ? (result.then(remap) as never)
72
+ : (remap(result as Record<string, unknown>) as never)
73
+ },
74
+ getAll: () => variables.getAll(),
75
+ set: (name: string, value: unknown) => variables.set(map(name), value),
76
+ has: (name: string) => variables.has(map(name)),
77
+ delete: (name: string) => variables.delete(map(name)),
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Find the consumer-defined namespace (from wireAddon) for a given addon package.
83
+ * Returns null if the package isn't registered as an addon.
84
+ */
85
+ const findAddonNamespaceForPackage = (packageName: string): string | null => {
86
+ const addons = pikkuState(null, 'addons', 'packages')
87
+ if (!addons) return null
88
+ for (const [namespace, cfg] of addons.entries()) {
89
+ if (cfg?.package === packageName) return namespace
90
+ }
91
+ return null
92
+ }
93
+
94
+ /**
95
+ * Wrap a workflow service so that bare workflow names passed from inside an
96
+ * addon function are auto-prefixed with the addon's consumer-facing namespace.
97
+ * Without this, `runToCompletion('myWorkflow')` from inside an addon misses
98
+ * the workflow registered under the addon's package scope and throws
99
+ * WorkflowNotFoundError — forcing addons to hardcode their consumer-defined
100
+ * namespace, which couples the addon to its caller.
101
+ *
102
+ * Explicit `'ns:name'` and bare names that already exist in root meta are
103
+ * unaffected; only bare names that would otherwise miss resolution get
104
+ * prefixed.
105
+ */
106
+ const wrapWorkflowServiceForPackage = <T extends object>(
107
+ service: T,
108
+ packageName: string,
109
+ namespace: string | null
110
+ ): T => {
111
+ return new Proxy(service, {
112
+ get(target, prop, receiver) {
113
+ if (prop === 'startWorkflow' || prop === 'runToCompletion') {
114
+ const original = Reflect.get(target, prop, receiver) as Function
115
+ return function (this: any, name: string, ...rest: any[]) {
116
+ if (typeof name === 'string' && !name.includes(':')) {
117
+ // Prefer the known instance namespace; fall back to the
118
+ // package's sole namespace when invoked without an instance.
119
+ const ns = namespace ?? findAddonNamespaceForPackage(packageName)
120
+ if (ns) {
121
+ name = `${ns}:${name}`
122
+ }
123
+ }
124
+ return original.call(this, name, ...rest)
125
+ }
126
+ }
127
+ return Reflect.get(target, prop, receiver)
128
+ },
129
+ })
130
+ }
131
+
132
+ /**
133
+ * Get or create singleton services for an addon instance.
134
+ * Services are cached in pikkuState to avoid recreation on each call.
135
+ *
136
+ * @param packageName - The addon package name
137
+ * @param parentServices - The parent/caller's singleton services (used as base)
138
+ * @param addonInstance - The wired instance whose overrides shape the services
139
+ * @returns The instance's singleton services
140
+ */
141
+ export const getOrCreatePackageSingletonServices = async (
142
+ packageName: string,
143
+ parentServices: CoreSingletonServices,
144
+ addonInstance?: AddonInstance
145
+ ): Promise<CoreSingletonServices> => {
146
+ // Cache per instance (namespace), not per package, so two instances of one
147
+ // package get separate services built with their own overrides. A bare
148
+ // package-scoped call (no instance) falls back to per-package caching.
149
+ const cacheKey = addonInstance?.namespace ?? packageName
150
+
151
+ const cachedServices = pikkuState(cacheKey, 'package', 'singletonServices')
152
+ if (cachedServices) {
153
+ return cachedServices
154
+ }
155
+
156
+ const factories = pikkuState(packageName, 'package', 'factories')
157
+ if (!factories || !factories.createSingletonServices) {
158
+ // No factories registered, use parent services
159
+ return parentServices
160
+ }
161
+
162
+ // Apply this instance's secret/variable overrides by aliasing the resolver
163
+ // services the addon reads from, so its createSingletonServices resolves
164
+ // instance-specific secrets/variables.
165
+ let existingServices = parentServices
166
+ if (addonInstance?.secretOverrides && parentServices.secrets) {
167
+ existingServices = {
168
+ ...existingServices,
169
+ secrets: aliasSecretService(
170
+ parentServices.secrets,
171
+ addonInstance.secretOverrides
172
+ ),
173
+ }
174
+ }
175
+ if (addonInstance?.variableOverrides && parentServices.variables) {
176
+ existingServices = {
177
+ ...existingServices,
178
+ variables: aliasVariablesService(
179
+ parentServices.variables,
180
+ addonInstance.variableOverrides
181
+ ),
182
+ }
183
+ }
184
+
185
+ // Create config for the package (use parent config if no factory)
186
+ let config: CoreConfig = existingServices.config
187
+ if (factories.createConfig) {
188
+ config = await factories.createConfig(existingServices.variables)
189
+ }
190
+
191
+ // Create singleton services for the package, passing parent services as existing
192
+ const packageServices = await factories.createSingletonServices(
193
+ config,
194
+ existingServices
195
+ )
196
+
197
+ // Wrap workflowService so that bare names used inside the addon's functions
198
+ // resolve to workflows registered under the addon's package scope.
199
+ if (
200
+ packageServices.workflowService &&
201
+ typeof packageServices.workflowService === 'object'
202
+ ) {
203
+ packageServices.workflowService = wrapWorkflowServiceForPackage(
204
+ packageServices.workflowService as object,
205
+ packageName,
206
+ addonInstance?.namespace ?? null
207
+ ) as typeof packageServices.workflowService
208
+ }
209
+
210
+ pikkuState(cacheKey, 'package', 'singletonServices', packageServices)
211
+
212
+ return packageServices
213
+ }
214
+
215
+ /**
216
+ * Build the addon instance descriptor (namespace + per-instance overrides) for
217
+ * a bare intra-addon call, using the namespace currently executing on the wire.
218
+ * Returns undefined unless that namespace maps to the resolved package.
219
+ */
220
+ export const addonInstanceForNamespace = (
221
+ namespace: string | undefined,
222
+ expectedPackage: string
223
+ ): AddonInstance | undefined => {
224
+ if (!namespace) return undefined
225
+ const cfg = pikkuState(null, 'addons', 'packages').get(namespace)
226
+ if (!cfg || cfg.package !== expectedPackage) return undefined
227
+ return {
228
+ namespace,
229
+ secretOverrides: cfg.secretOverrides,
230
+ variableOverrides: cfg.variableOverrides,
231
+ credentialOverrides: cfg.credentialOverrides,
232
+ }
233
+ }
@@ -1,4 +1,20 @@
1
- export { PikkuRPCService, rpcService, RPCNotFoundError } from './rpc-runner.js'
1
+ export {
2
+ PikkuRPCService,
3
+ rpcService,
4
+ RPCNotFoundError,
5
+ RemoteAddonConfigError,
6
+ RemoteAddonRequestError,
7
+ } from './rpc-runner.js'
8
+ export {
9
+ resolveRemoteAddonToken,
10
+ RemoteAddonAuthError,
11
+ } from './remote-addon-auth.js'
12
+ export type { RemoteAddonAuthBinding } from './remote-addon-auth.js'
2
13
  export type { PikkuRPC, RPCMeta } from './rpc-types.js'
3
14
  export { wireAddon } from './wire-addon.js'
4
15
  export type { WireAddonConfig } from './wire-addon.js'
16
+ export { wireRemoteAddon } from './wire-remote-addon.js'
17
+ export type {
18
+ WireRemoteAddonConfig,
19
+ RemoteAddonAuth,
20
+ } from './wire-remote-addon.js'
@@ -0,0 +1,69 @@
1
+ import type { CoreServices, PikkuRawWire } from '../../types/core.types.js'
2
+ import { PikkuError, addError } from '../../errors/error-handler.js'
3
+
4
+ /**
5
+ * How a `wireRemoteAddon` consumer supplies the token the hosted addon expects.
6
+ * This is a CLIENT authenticating to a hosted library — not the trusted mesh.
7
+ */
8
+ export type RemoteAddonAuthBinding =
9
+ | { credentialId: string }
10
+ | { secretId: string }
11
+ | {
12
+ resolve: (
13
+ services: CoreServices,
14
+ wire: PikkuRawWire
15
+ ) => string | Promise<string>
16
+ }
17
+
18
+ /** The addon's auth was bound but the source produced no token — fail closed. */
19
+ export class RemoteAddonAuthError extends PikkuError {
20
+ public readonly namespace: string
21
+ constructor(namespace: string, detail: string) {
22
+ super(`Remote addon '${namespace}' auth could not be resolved: ${detail}`)
23
+ this.namespace = namespace
24
+ }
25
+ }
26
+ addError(RemoteAddonAuthError, {
27
+ status: 401,
28
+ message: 'Remote addon authentication could not be resolved.',
29
+ })
30
+
31
+ /**
32
+ * Resolve the bearer token for a remote addon call from the consumer's bound
33
+ * source. Returns `null` only when no auth is bound (a public surface).
34
+ *
35
+ * Security: the resolved token is never logged or traced; per-user credentials
36
+ * are read via the wire (scoped to `pikkuUserId`); an empty result fails closed.
37
+ */
38
+ export async function resolveRemoteAddonToken(
39
+ auth: RemoteAddonAuthBinding | undefined,
40
+ services: CoreServices,
41
+ wire: PikkuRawWire,
42
+ namespace: string
43
+ ): Promise<string | null> {
44
+ if (!auth) {
45
+ // No auth bound → the addon declares its remote surface public.
46
+ return null
47
+ }
48
+
49
+ let token: unknown
50
+ if ('credentialId' in auth) {
51
+ if (typeof wire.getCredential !== 'function') {
52
+ throw new RemoteAddonAuthError(
53
+ namespace,
54
+ `credentialId '${auth.credentialId}' requires a wire with credential access`
55
+ )
56
+ }
57
+ token = await wire.getCredential(auth.credentialId)
58
+ } else if ('secretId' in auth) {
59
+ token = await services.secrets.getSecret(auth.secretId)
60
+ } else if (typeof auth.resolve === 'function') {
61
+ token = await auth.resolve(services, wire)
62
+ }
63
+
64
+ if (token === null || token === undefined || token === '') {
65
+ throw new RemoteAddonAuthError(namespace, 'resolved token was empty')
66
+ }
67
+
68
+ return String(token)
69
+ }
@@ -6,9 +6,13 @@ import { pikkuState, resetPikkuState } from '../../pikku-state.js'
6
6
  import {
7
7
  ContextAwareRPCService,
8
8
  RPCNotFoundError,
9
+ RemoteAddonRequestError,
9
10
  resolveNamespace,
10
11
  rpcService,
11
12
  } from './rpc-runner.js'
13
+ import { RemoteAddonAuthError } from './remote-addon-auth.js'
14
+ import { wireAddon } from './wire-addon.js'
15
+ import { wireRemoteAddon } from './wire-remote-addon.js'
12
16
 
13
17
  const createLogger = () => ({
14
18
  debug: () => {},
@@ -207,28 +211,12 @@ describe('ContextAwareRPCService.rpc', () => {
207
211
  { requiresAuth: true }
208
212
  )
209
213
 
210
- const seenTags: string[][] = []
211
- const addonTagGroup = pikkuState('@addon/stripe', 'permissions', 'tagGroup')
212
- addonTagGroup['addon-tag'] = [
213
- async (_services: any, _data: any, wire: any) => {
214
- seenTags.push(['addon-tag', wire.wireType])
215
- return true
216
- },
217
- ] as never
218
- addonTagGroup['function-tag'] = [
219
- async (_services: any, _data: any, wire: any) => {
220
- seenTags.push(['function-tag', wire.wireType])
221
- return true
222
- },
223
- ] as never
224
-
225
214
  const result = await service.rpc('stripe:createCharge', { amount: 10 })
226
215
 
227
216
  assert.deepEqual(result, {
228
217
  data: { amount: 10 },
229
218
  auth: 'http',
230
219
  })
231
- assert.deepEqual(seenTags, [['addon-tag', 'http']])
232
220
  })
233
221
 
234
222
  test('falls back to deploymentService when rpc meta is missing', async () => {
@@ -492,6 +480,321 @@ describe('ContextAwareRPCService.remote', () => {
492
480
  })
493
481
  })
494
482
 
483
+ describe('multi-instance addons', () => {
484
+ const createSecretService = () => ({
485
+ getSecret: async (key: string) => `secret:${key}`,
486
+ hasSecret: async () => true,
487
+ setSecret: async () => {},
488
+ deleteSecret: async () => {},
489
+ getSecrets: async () => ({}),
490
+ })
491
+
492
+ test('resolves per-instance secretOverrides and caches singletons per namespace', async () => {
493
+ const createdServices: any[] = []
494
+ pikkuState('@addon/slack', 'package', 'factories', {
495
+ createSingletonServices: async (_config: any, parent: any) => {
496
+ const token = await parent.secrets.getSecret('slackToken')
497
+ const svc = { logger: createLogger(), token }
498
+ createdServices.push(svc)
499
+ return svc
500
+ },
501
+ } as never)
502
+ registerFunction(
503
+ 'send',
504
+ async (services: any) => ({ token: services.token }),
505
+ {
506
+ packageName: '@addon/slack',
507
+ }
508
+ )
509
+
510
+ wireAddon({
511
+ name: 'slack-marketing',
512
+ package: '@addon/slack',
513
+ secretOverrides: { slackToken: 'marketing_secret' },
514
+ })
515
+ wireAddon({
516
+ name: 'slack-support',
517
+ package: '@addon/slack',
518
+ secretOverrides: { slackToken: 'support_secret' },
519
+ })
520
+
521
+ const service = new ContextAwareRPCService(
522
+ createServices({ secrets: createSecretService() }),
523
+ {} as never,
524
+ { requiresAuth: false }
525
+ )
526
+
527
+ const marketing = await service.rpc('slack-marketing:send', {})
528
+ const support = await service.rpc('slack-support:send', {})
529
+
530
+ assert.deepEqual(marketing, { token: 'secret:marketing_secret' })
531
+ assert.deepEqual(support, { token: 'secret:support_secret' })
532
+ assert.equal(createdServices.length, 2)
533
+ assert.notEqual(createdServices[0], createdServices[1])
534
+ })
535
+
536
+ test('reuses the same per-namespace singleton across intra-addon sibling calls', async () => {
537
+ let factoryCalls = 0
538
+ pikkuState('@addon/slack', 'package', 'factories', {
539
+ createSingletonServices: async (_config: any, parent: any) => {
540
+ factoryCalls++
541
+ return {
542
+ logger: createLogger(),
543
+ token: await parent.secrets.getSecret('slackToken'),
544
+ }
545
+ },
546
+ } as never)
547
+
548
+ registerFunction(
549
+ 'leaf',
550
+ async (services: any) => ({ token: services.token }),
551
+ {
552
+ packageName: '@addon/slack',
553
+ }
554
+ )
555
+ registerFunction(
556
+ 'root',
557
+ async (_services: any, _data: any, wire: any) =>
558
+ wire.rpc.invoke('leaf', {}),
559
+ { packageName: '@addon/slack' }
560
+ )
561
+
562
+ wireAddon({
563
+ name: 'slack-marketing',
564
+ package: '@addon/slack',
565
+ secretOverrides: { slackToken: 'marketing_secret' },
566
+ })
567
+
568
+ const service = new ContextAwareRPCService(
569
+ createServices({ secrets: createSecretService() }),
570
+ {} as never,
571
+ { requiresAuth: false }
572
+ )
573
+
574
+ const result = await service.rpc('slack-marketing:root', {})
575
+
576
+ assert.deepEqual(result, { token: 'secret:marketing_secret' })
577
+ assert.equal(factoryCalls, 1)
578
+ })
579
+
580
+ test('resolves per-instance credentialOverrides via wire.getCredential', async () => {
581
+ pikkuState('@addon/slack', 'package', 'factories', {
582
+ createSingletonServices: async () => ({ logger: createLogger() }),
583
+ } as never)
584
+ registerFunction(
585
+ 'whoami',
586
+ async (_services: any, _data: any, wire: any) => ({
587
+ cred: await wire.getCredential('slack'),
588
+ }),
589
+ { packageName: '@addon/slack' }
590
+ )
591
+
592
+ wireAddon({
593
+ name: 'slack-marketing',
594
+ package: '@addon/slack',
595
+ credentialOverrides: { slack: 'marketing_cred' },
596
+ })
597
+ wireAddon({
598
+ name: 'slack-support',
599
+ package: '@addon/slack',
600
+ credentialOverrides: { slack: 'support_cred' },
601
+ })
602
+
603
+ const credentialService = {
604
+ getAll: async (_userId: string) => ({
605
+ marketing_cred: { token: 'm' },
606
+ support_cred: { token: 's' },
607
+ }),
608
+ }
609
+
610
+ const service = new ContextAwareRPCService(
611
+ createServices({ credentialService }),
612
+ { pikkuUserId: 'user-1' } as never,
613
+ { requiresAuth: false }
614
+ )
615
+
616
+ const marketing = await service.rpc('slack-marketing:whoami', {})
617
+ const support = await service.rpc('slack-support:whoami', {})
618
+
619
+ assert.deepEqual(marketing, { cred: { token: 'm' } })
620
+ assert.deepEqual(support, { cred: { token: 's' } })
621
+ })
622
+ })
623
+
624
+ describe('wireRemoteAddon dispatch', () => {
625
+ const realFetch = globalThis.fetch
626
+ let calls: Array<{ url: string; init: any }> = []
627
+
628
+ const stubFetch = (
629
+ response: { status?: number; body?: unknown } = {}
630
+ ): void => {
631
+ const { status = 200, body = { ok: true } } = response
632
+ calls = []
633
+ globalThis.fetch = (async (url: any, init: any) => {
634
+ calls.push({ url: String(url), init })
635
+ return {
636
+ ok: status >= 200 && status < 300,
637
+ status,
638
+ text: async () => (body === undefined ? '' : JSON.stringify(body)),
639
+ }
640
+ }) as any
641
+ }
642
+
643
+ const restoreFetch = () => {
644
+ globalThis.fetch = realFetch
645
+ }
646
+
647
+ const secretService = () =>
648
+ ({
649
+ getSecret: async (key: string) => `secret-value-for-${key}`,
650
+ getSecrets: async () => ({}),
651
+ hasSecret: async () => true,
652
+ setSecret: async () => {},
653
+ deleteSecret: async () => {},
654
+ }) as never
655
+
656
+ test('POSTs to /remote/rpc/<fn> with { rpcName, data } and a platform bearer token', async () => {
657
+ stubFetch({ body: { echoed: 42 } })
658
+ try {
659
+ wireRemoteAddon({
660
+ name: 'registry',
661
+ package: '@pikkufabric/addon-registry',
662
+ serverUrl: 'https://api.example.com/',
663
+ auth: { secretId: 'REGISTRY_TOKEN' },
664
+ })
665
+
666
+ const service = new ContextAwareRPCService(
667
+ createServices({ secrets: secretService() }),
668
+ { traceId: 'trace-r' } as never,
669
+ {}
670
+ )
671
+
672
+ const result = await service.rpc('registry:getOpenApi', { name: 'stripe' })
673
+
674
+ assert.deepEqual(result, { echoed: 42 })
675
+ assert.equal(calls.length, 1)
676
+ assert.equal(
677
+ calls[0]!.url,
678
+ 'https://api.example.com/remote/rpc/getOpenApi'
679
+ )
680
+ assert.equal(calls[0]!.init.method, 'POST')
681
+ assert.deepEqual(JSON.parse(calls[0]!.init.body), {
682
+ rpcName: 'getOpenApi',
683
+ data: { name: 'stripe' },
684
+ })
685
+ assert.equal(
686
+ calls[0]!.init.headers.authorization,
687
+ 'Bearer secret-value-for-REGISTRY_TOKEN'
688
+ )
689
+ assert.equal(calls[0]!.init.headers['x-trace-id'], 'trace-r')
690
+ } finally {
691
+ restoreFetch()
692
+ }
693
+ })
694
+
695
+ test('resolves a per-user credential token via wire.getCredential', async () => {
696
+ stubFetch()
697
+ try {
698
+ wireRemoteAddon({
699
+ name: 'registry',
700
+ package: '@pikkufabric/addon-registry',
701
+ serverUrl: () => 'https://api.example.com',
702
+ auth: { credentialId: 'fabricRegistryToken' },
703
+ })
704
+
705
+ const service = new ContextAwareRPCService(
706
+ createServices(),
707
+ {
708
+ pikkuUserId: 'user-9',
709
+ getCredential: async (name: string) =>
710
+ name === 'fabricRegistryToken' ? 'user-token-9' : null,
711
+ } as never,
712
+ {}
713
+ )
714
+
715
+ await service.rpc('registry:getOpenApi', {})
716
+ assert.equal(
717
+ calls[0]!.init.headers.authorization,
718
+ 'Bearer user-token-9'
719
+ )
720
+ } finally {
721
+ restoreFetch()
722
+ }
723
+ })
724
+
725
+ test('omits the Authorization header for a public (no auth) surface', async () => {
726
+ stubFetch()
727
+ try {
728
+ wireRemoteAddon({
729
+ name: 'registry',
730
+ package: '@pikkufabric/addon-registry',
731
+ serverUrl: 'https://api.example.com',
732
+ })
733
+
734
+ const service = new ContextAwareRPCService(
735
+ createServices(),
736
+ {} as never,
737
+ {}
738
+ )
739
+
740
+ await service.rpc('registry:listOpenApis', {})
741
+ assert.equal(calls[0]!.init.headers.authorization, undefined)
742
+ } finally {
743
+ restoreFetch()
744
+ }
745
+ })
746
+
747
+ test('fails closed when a bound credential resolves empty', async () => {
748
+ stubFetch()
749
+ try {
750
+ wireRemoteAddon({
751
+ name: 'registry',
752
+ package: '@pikkufabric/addon-registry',
753
+ serverUrl: 'https://api.example.com',
754
+ auth: { credentialId: 'fabricRegistryToken' },
755
+ })
756
+
757
+ const service = new ContextAwareRPCService(
758
+ createServices(),
759
+ { getCredential: async () => null } as never,
760
+ {}
761
+ )
762
+
763
+ await assert.rejects(
764
+ () => service.rpc('registry:getOpenApi', {}),
765
+ RemoteAddonAuthError
766
+ )
767
+ assert.equal(calls.length, 0)
768
+ } finally {
769
+ restoreFetch()
770
+ }
771
+ })
772
+
773
+ test('throws RemoteAddonRequestError on a non-2xx response', async () => {
774
+ stubFetch({ status: 503, body: { error: 'down' } })
775
+ try {
776
+ wireRemoteAddon({
777
+ name: 'registry',
778
+ package: '@pikkufabric/addon-registry',
779
+ serverUrl: 'https://api.example.com',
780
+ })
781
+
782
+ const service = new ContextAwareRPCService(
783
+ createServices(),
784
+ {} as never,
785
+ {}
786
+ )
787
+
788
+ await assert.rejects(
789
+ () => service.rpc('registry:getOpenApi', {}),
790
+ RemoteAddonRequestError
791
+ )
792
+ } finally {
793
+ restoreFetch()
794
+ }
795
+ })
796
+ })
797
+
495
798
  describe('rpcService.getContextRPCService', () => {
496
799
  test('builds a typed rpc facade with depth and bound methods', async () => {
497
800
  pikkuState(null, 'rpc', 'meta').echo = 'echoFunc'