@pikku/core 0.12.62 → 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.
- package/CHANGELOG.md +153 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +32 -25
- package/dist/permissions.js +104 -185
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
- package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -0
- package/dist/wirings/ai-agent/index.js +1 -0
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +2 -38
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +0 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- package/dist/wirings/http/http-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +13 -35
- package/dist/wirings/http/http.types.d.ts +1 -9
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -1
- package/package.json +5 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +153 -372
- package/src/permissions.ts +139 -259
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
- package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
- package/src/wirings/ai-agent/index.ts +1 -0
- package/src/wirings/ai-agent/voice-input.ts +6 -35
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +0 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- package/src/wirings/http/http-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +19 -40
- package/src/wirings/http/http.types.ts +1 -10
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- package/src/wirings/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- 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
|
+
}
|
package/src/wirings/rpc/index.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
export {
|
|
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'
|