@pikku/core 0.12.13 → 0.12.14
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 +16 -0
- package/dist/errors/errors.d.ts +14 -0
- package/dist/errors/errors.js +21 -0
- package/dist/function/function-runner.d.ts +1 -1
- package/dist/function/function-runner.js +8 -4
- package/dist/pikku-state.js +1 -0
- package/dist/services/credential-service.d.ts +11 -0
- package/dist/services/credential-wire-service.d.ts +14 -3
- package/dist/services/credential-wire-service.js +49 -6
- package/dist/services/local-credential-service.d.ts +2 -0
- package/dist/services/local-credential-service.js +20 -0
- package/dist/services/pikku-user-id.d.ts +3 -0
- package/dist/services/pikku-user-id.js +16 -0
- package/dist/services/typed-credential-service.d.ts +2 -0
- package/dist/services/typed-credential-service.js +6 -0
- package/dist/types/core.types.d.ts +6 -2
- package/dist/types/state.types.d.ts +7 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +21 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.js +48 -0
- package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
- package/dist/wirings/ai-agent/ai-agent-stream.d.ts +2 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +60 -3
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/cli/cli-runner.js +1 -1
- package/dist/wirings/http/http-runner.js +0 -4
- package/dist/wirings/queue/queue-runner.js +1 -0
- package/dist/wirings/queue/queue.types.d.ts +6 -0
- package/dist/wirings/rpc/rpc-runner.js +1 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +2 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +2 -0
- package/dist/wirings/workflow/workflow.types.d.ts +2 -0
- package/package.json +1 -1
- package/src/errors/errors.ts +32 -0
- package/src/function/function-runner.ts +19 -9
- package/src/pikku-state.ts +1 -0
- package/src/services/credential-service.ts +13 -0
- package/src/services/credential-wire-service.test.ts +174 -0
- package/src/services/credential-wire-service.ts +52 -8
- package/src/services/local-credential-service.ts +22 -0
- package/src/services/pikku-user-id.test.ts +62 -0
- package/src/services/pikku-user-id.ts +17 -0
- package/src/services/typed-credential-service.ts +8 -0
- package/src/types/core.types.ts +8 -2
- package/src/types/state.types.ts +5 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +71 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +6 -2
- package/src/wirings/ai-agent/ai-agent-stream.ts +107 -3
- package/src/wirings/ai-agent/ai-agent.types.ts +22 -1
- package/src/wirings/ai-agent/index.ts +1 -0
- package/src/wirings/channel/channel-handler.ts +0 -1
- package/src/wirings/cli/cli-runner.ts +1 -1
- package/src/wirings/http/http-runner.ts +0 -7
- package/src/wirings/mcp/mcp-runner.ts +0 -1
- package/src/wirings/queue/queue-runner.ts +1 -1
- package/src/wirings/queue/queue.types.ts +6 -0
- package/src/wirings/rpc/rpc-runner.ts +9 -3
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +2 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +2 -0
- package/src/wirings/workflow/workflow.types.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -110,7 +110,7 @@ function registerCLICommands(commands, path = [], inheritedOptions = {}, program
|
|
|
110
110
|
programs[program].commandMiddleware[commandId] = command.middleware;
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
-
addFunction(funcName, unwrapFunc(command));
|
|
113
|
+
addFunction(funcName, unwrapFunc(command), currentMeta?.packageName);
|
|
114
114
|
// Register renderer if provided
|
|
115
115
|
if (typeof command === 'object' && command.render) {
|
|
116
116
|
if (programs[program]) {
|
|
@@ -3,7 +3,6 @@ import { closeWireServices, createWeakUID, isSerializable, } from '../../utils.j
|
|
|
3
3
|
import { getSingletonServices, getCreateWireServices, } from '../../pikku-state.js';
|
|
4
4
|
import { PikkuSessionService } from '../../services/user-session-service.js';
|
|
5
5
|
import { getErrorResponse } from '../../errors/error-handler.js';
|
|
6
|
-
import { PikkuCredentialWireService, createMiddlewareCredentialWireProps, } from '../../services/credential-wire-service.js';
|
|
7
6
|
import { handleHTTPError } from '../../handle-error.js';
|
|
8
7
|
import { pikkuState } from '../../pikku-state.js';
|
|
9
8
|
import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
|
|
@@ -198,7 +197,6 @@ export const createHTTPWire = (request, response) => {
|
|
|
198
197
|
*/
|
|
199
198
|
const executeRoute = async (services, matchedRoute, http, options) => {
|
|
200
199
|
const userSession = new PikkuSessionService();
|
|
201
|
-
const credentialWire = new PikkuCredentialWireService();
|
|
202
200
|
const { params, route, meta } = matchedRoute;
|
|
203
201
|
const { singletonServices, createWireServices, skipUserSession, requestId } = services;
|
|
204
202
|
// Attach URL parameters to the request object
|
|
@@ -259,7 +257,6 @@ const executeRoute = async (services, matchedRoute, http, options) => {
|
|
|
259
257
|
setSession: (s) => userSession.setInitial(s),
|
|
260
258
|
getSession: () => userSession.get(),
|
|
261
259
|
hasSessionChanged: () => userSession.sessionChanged,
|
|
262
|
-
...createMiddlewareCredentialWireProps(credentialWire),
|
|
263
260
|
};
|
|
264
261
|
result = await runPikkuFunc('http', `${meta.method}:${meta.route}`, meta.pikkuFuncId, {
|
|
265
262
|
singletonServices,
|
|
@@ -274,7 +271,6 @@ const executeRoute = async (services, matchedRoute, http, options) => {
|
|
|
274
271
|
tags: route.tags,
|
|
275
272
|
wire,
|
|
276
273
|
sessionService: userSession,
|
|
277
|
-
credentialWireService: credentialWire,
|
|
278
274
|
packageName: meta.packageName,
|
|
279
275
|
});
|
|
280
276
|
if (!matchedRoute.route.sse) {
|
|
@@ -89,6 +89,7 @@ export async function runQueueJob({ job, updateProgress, }) {
|
|
|
89
89
|
const queue = {
|
|
90
90
|
queueName: job.queueName,
|
|
91
91
|
jobId: job.id,
|
|
92
|
+
pikkuUserId: job.pikkuUserId,
|
|
92
93
|
updateProgress: updateProgress ||
|
|
93
94
|
(async (progress) => {
|
|
94
95
|
logger.info(`Job ${job.id} progress: ${progress}`);
|
|
@@ -87,6 +87,8 @@ export interface QueueJob<T = any, R = any> {
|
|
|
87
87
|
result?: R;
|
|
88
88
|
waitForCompletion?: (ttl?: number) => Promise<R>;
|
|
89
89
|
metadata?: () => Promise<QueueJobMetadata> | QueueJobMetadata;
|
|
90
|
+
/** Pikku user ID propagated from the job producer */
|
|
91
|
+
pikkuUserId?: string;
|
|
90
92
|
}
|
|
91
93
|
/**
|
|
92
94
|
* Job options for queue operations
|
|
@@ -102,6 +104,8 @@ export interface JobOptions {
|
|
|
102
104
|
removeOnComplete?: number;
|
|
103
105
|
removeOnFail?: number;
|
|
104
106
|
jobId?: string;
|
|
107
|
+
/** Pikku user ID to propagate to the queue worker for credential resolution */
|
|
108
|
+
pikkuUserId?: string;
|
|
105
109
|
}
|
|
106
110
|
/**
|
|
107
111
|
* Queue provider interface for job publishing operations
|
|
@@ -154,6 +158,8 @@ export interface PikkuQueue {
|
|
|
154
158
|
queueName: string;
|
|
155
159
|
/** The current job ID */
|
|
156
160
|
jobId: string;
|
|
161
|
+
/** Pikku user ID propagated from the job producer for credential resolution */
|
|
162
|
+
pikkuUserId?: string;
|
|
157
163
|
/** Update job progress (0-100 or custom value) */
|
|
158
164
|
updateProgress: (progress: number | string | object) => Promise<void>;
|
|
159
165
|
/** Fail the current job with optional reason */
|
|
@@ -178,6 +178,7 @@ export class ContextAwareRPCService {
|
|
|
178
178
|
type: this.wire.wireType ?? 'unknown',
|
|
179
179
|
id: this.wire.wireId,
|
|
180
180
|
...(parentRunId ? { parentRunId } : {}),
|
|
181
|
+
...(this.wire.pikkuUserId ? { pikkuUserId: this.wire.pikkuUserId } : {}),
|
|
181
182
|
};
|
|
182
183
|
return this.services.workflowService.startWorkflow(workflowName, input, wire, this, options);
|
|
183
184
|
}
|
|
@@ -281,6 +281,8 @@ export interface PikkuWorkflowWire {
|
|
|
281
281
|
name: string;
|
|
282
282
|
/** The current run ID */
|
|
283
283
|
runId: string;
|
|
284
|
+
/** Pikku user ID propagated from the originating request for credential resolution */
|
|
285
|
+
pikkuUserId?: string;
|
|
284
286
|
/** Get the current workflow run */
|
|
285
287
|
getRun: () => Promise<WorkflowRun>;
|
|
286
288
|
/** Execute a workflow step (overloaded - RPC or inline form) */
|
|
@@ -258,8 +258,10 @@ export class PikkuWorkflowService {
|
|
|
258
258
|
}
|
|
259
259
|
await this.withRunLock(runId, async () => {
|
|
260
260
|
const workflowWire = this.createWorkflowWire(run.workflow, runId, rpcService);
|
|
261
|
+
workflowWire.pikkuUserId = run.wire?.pikkuUserId;
|
|
261
262
|
const wire = {
|
|
262
263
|
workflow: workflowWire,
|
|
264
|
+
pikkuUserId: run.wire?.pikkuUserId,
|
|
263
265
|
session: rpcService.wire?.session,
|
|
264
266
|
rpc: rpcService.wire?.rpc,
|
|
265
267
|
};
|
|
@@ -8,6 +8,8 @@ export interface WorkflowRunWire {
|
|
|
8
8
|
id?: string;
|
|
9
9
|
parentRunId?: string;
|
|
10
10
|
parentStepId?: string;
|
|
11
|
+
/** Pikku user ID propagated from the originating request for credential resolution */
|
|
12
|
+
pikkuUserId?: string;
|
|
11
13
|
}
|
|
12
14
|
export interface WorkflowServiceConfig {
|
|
13
15
|
retries: number;
|
package/package.json
CHANGED
package/src/errors/errors.ts
CHANGED
|
@@ -76,6 +76,38 @@ addError(ForbiddenError, {
|
|
|
76
76
|
'The client does not have permission to access the requested resource.',
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* A required credential is missing. The payload contains metadata
|
|
81
|
+
* about the credential so the client can initiate a connect flow.
|
|
82
|
+
* @group Error
|
|
83
|
+
*/
|
|
84
|
+
export class MissingCredentialError extends PikkuError {
|
|
85
|
+
public payload: {
|
|
86
|
+
error: 'missing_credential'
|
|
87
|
+
credentialName: string
|
|
88
|
+
credentialType: 'oauth2' | 'apikey'
|
|
89
|
+
connectUrl?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
constructor(
|
|
93
|
+
credentialName: string,
|
|
94
|
+
credentialType: 'oauth2' | 'apikey',
|
|
95
|
+
connectUrl?: string
|
|
96
|
+
) {
|
|
97
|
+
super(`Missing credential: ${credentialName}`)
|
|
98
|
+
this.payload = {
|
|
99
|
+
error: 'missing_credential',
|
|
100
|
+
credentialName,
|
|
101
|
+
credentialType,
|
|
102
|
+
connectUrl,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
addError(MissingCredentialError, {
|
|
107
|
+
status: 403,
|
|
108
|
+
message: 'A required credential is not configured.',
|
|
109
|
+
})
|
|
110
|
+
|
|
79
111
|
/**
|
|
80
112
|
* The session is readonly and cannot access a non-readonly function.
|
|
81
113
|
* @group Error
|
|
@@ -28,8 +28,10 @@ import { parseVersionedId } from '../version.js'
|
|
|
28
28
|
import type { SessionService } from '../services/user-session-service.js'
|
|
29
29
|
import { createFunctionSessionWireProps } from '../services/user-session-service.js'
|
|
30
30
|
import { ForbiddenError, ReadonlySessionError } from '../errors/errors.js'
|
|
31
|
-
import
|
|
32
|
-
|
|
31
|
+
import {
|
|
32
|
+
PikkuCredentialWireService,
|
|
33
|
+
createWireServicesCredentialWireProps,
|
|
34
|
+
} from '../services/credential-wire-service.js'
|
|
33
35
|
import { rpcService } from '../wirings/rpc/rpc-runner.js'
|
|
34
36
|
import { closeWireServices } from '../utils.js'
|
|
35
37
|
|
|
@@ -226,6 +228,21 @@ export const runPikkuFunc = async <In = any, Out = any>(
|
|
|
226
228
|
)
|
|
227
229
|
: wire
|
|
228
230
|
|
|
231
|
+
// Set up credential wire service early so middleware can use setCredential.
|
|
232
|
+
// Skip if already set up (e.g. addon functions reuse the parent wire).
|
|
233
|
+
if (!resolvedWire.getCredentials) {
|
|
234
|
+
const resolvedCredentialWireService =
|
|
235
|
+
credentialWireService ??
|
|
236
|
+
new PikkuCredentialWireService(
|
|
237
|
+
resolvedSingletonServices.credentialService,
|
|
238
|
+
resolvedWire
|
|
239
|
+
)
|
|
240
|
+
Object.assign(
|
|
241
|
+
resolvedWire,
|
|
242
|
+
createWireServicesCredentialWireProps(resolvedCredentialWireService)
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
229
246
|
// Convert tags to PermissionMetadata and merge with inheritedPermissions
|
|
230
247
|
let mergedInheritedPermissions: PermissionMetadata[]
|
|
231
248
|
if (tags && tags.length > 0) {
|
|
@@ -316,13 +333,6 @@ export const runPikkuFunc = async <In = any, Out = any>(
|
|
|
316
333
|
})
|
|
317
334
|
}
|
|
318
335
|
|
|
319
|
-
if (credentialWireService) {
|
|
320
|
-
Object.assign(
|
|
321
|
-
resolvedWire,
|
|
322
|
-
createWireServicesCredentialWireProps(credentialWireService)
|
|
323
|
-
)
|
|
324
|
-
}
|
|
325
|
-
|
|
326
336
|
const wireServices = await resolvedCreateWireServices?.(
|
|
327
337
|
resolvedSingletonServices,
|
|
328
338
|
resolvedWire
|
package/src/pikku-state.ts
CHANGED
|
@@ -41,4 +41,17 @@ export interface CredentialService {
|
|
|
41
41
|
* @returns A record of credential name to value.
|
|
42
42
|
*/
|
|
43
43
|
getAll(userId: string): Promise<Record<string, unknown>>
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lists all user IDs that have a specific credential configured.
|
|
47
|
+
* @param name - The credential name.
|
|
48
|
+
* @returns Array of user IDs.
|
|
49
|
+
*/
|
|
50
|
+
getUsersWithCredential(name: string): Promise<string[]>
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Lists all unique user IDs that have any credential configured.
|
|
54
|
+
* @returns Array of user IDs.
|
|
55
|
+
*/
|
|
56
|
+
getAllUsers(): Promise<string[]>
|
|
44
57
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import {
|
|
4
|
+
PikkuCredentialWireService,
|
|
5
|
+
createMiddlewareCredentialWireProps,
|
|
6
|
+
createWireServicesCredentialWireProps,
|
|
7
|
+
} from './credential-wire-service.js'
|
|
8
|
+
import type { CredentialService } from './credential-service.js'
|
|
9
|
+
import type { PikkuWire } from '../types/core.types.js'
|
|
10
|
+
|
|
11
|
+
const mockCredentialService = (
|
|
12
|
+
creds: Record<string, unknown>
|
|
13
|
+
): CredentialService => ({
|
|
14
|
+
get: async (name: string) => creds[name] ?? null,
|
|
15
|
+
set: async () => {},
|
|
16
|
+
delete: async () => {},
|
|
17
|
+
has: async (name: string) => name in creds,
|
|
18
|
+
getAll: async () => creds,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('PikkuCredentialWireService', () => {
|
|
22
|
+
test('should set and get credentials manually', async () => {
|
|
23
|
+
const service = new PikkuCredentialWireService()
|
|
24
|
+
service.set('stripe', { apiKey: 'sk_test' })
|
|
25
|
+
const result = await service.getAll()
|
|
26
|
+
assert.deepStrictEqual(result, { stripe: { apiKey: 'sk_test' } })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('should return sync after first load (no credential service)', async () => {
|
|
30
|
+
const service = new PikkuCredentialWireService()
|
|
31
|
+
service.set('foo', 'bar')
|
|
32
|
+
// First call triggers lazy load (no-op without credentialService)
|
|
33
|
+
await service.getAll()
|
|
34
|
+
// Second call should be sync
|
|
35
|
+
const result = service.getAll()
|
|
36
|
+
assert.ok(!(result instanceof Promise), 'expected sync return')
|
|
37
|
+
assert.deepStrictEqual(result, { foo: 'bar' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('should lazy-load from credential service on first getAll', async () => {
|
|
41
|
+
const credService = mockCredentialService({
|
|
42
|
+
stripe: { apiKey: 'sk_live' },
|
|
43
|
+
github: { token: 'ghp_abc' },
|
|
44
|
+
})
|
|
45
|
+
const wire: PikkuWire = { session: { userId: 'user-1' } as any }
|
|
46
|
+
const service = new PikkuCredentialWireService(credService, wire)
|
|
47
|
+
|
|
48
|
+
const result = await service.getAll()
|
|
49
|
+
assert.deepStrictEqual(result, {
|
|
50
|
+
stripe: { apiKey: 'sk_live' },
|
|
51
|
+
github: { token: 'ghp_abc' },
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('should set pikkuUserId on wire after lazy load', async () => {
|
|
56
|
+
const credService = mockCredentialService({})
|
|
57
|
+
const wire: PikkuWire = { session: { userId: 'user-1' } as any }
|
|
58
|
+
const service = new PikkuCredentialWireService(credService, wire)
|
|
59
|
+
|
|
60
|
+
await service.getAll()
|
|
61
|
+
assert.strictEqual(wire.pikkuUserId, 'user-1')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('should not overwrite manually set credentials during lazy load', async () => {
|
|
65
|
+
const credService = mockCredentialService({
|
|
66
|
+
stripe: { apiKey: 'from-db' },
|
|
67
|
+
})
|
|
68
|
+
const wire: PikkuWire = { session: { userId: 'user-1' } as any }
|
|
69
|
+
const service = new PikkuCredentialWireService(credService, wire)
|
|
70
|
+
|
|
71
|
+
service.set('stripe', { apiKey: 'from-middleware' })
|
|
72
|
+
const result = await service.getAll()
|
|
73
|
+
assert.deepStrictEqual(result.stripe, { apiKey: 'from-middleware' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('should return sync on subsequent calls after lazy load', async () => {
|
|
77
|
+
const credService = mockCredentialService({ stripe: { key: '123' } })
|
|
78
|
+
const wire: PikkuWire = { session: { userId: 'user-1' } as any }
|
|
79
|
+
const service = new PikkuCredentialWireService(credService, wire)
|
|
80
|
+
|
|
81
|
+
await service.getAll()
|
|
82
|
+
const result = service.getAll()
|
|
83
|
+
assert.ok(!(result instanceof Promise), 'expected sync return')
|
|
84
|
+
assert.deepStrictEqual(result, { stripe: { key: '123' } })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('should not load when no userId resolvable', async () => {
|
|
88
|
+
let called = false
|
|
89
|
+
const credService: CredentialService = {
|
|
90
|
+
get: async () => null,
|
|
91
|
+
set: async () => {},
|
|
92
|
+
delete: async () => {},
|
|
93
|
+
has: async () => false,
|
|
94
|
+
getAll: async () => {
|
|
95
|
+
called = true
|
|
96
|
+
return {}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
const wire: PikkuWire = {}
|
|
100
|
+
const service = new PikkuCredentialWireService(credService, wire)
|
|
101
|
+
|
|
102
|
+
const result = await service.getAll()
|
|
103
|
+
assert.deepStrictEqual(result, {})
|
|
104
|
+
assert.strictEqual(called, false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('should deduplicate concurrent lazy load calls', async () => {
|
|
108
|
+
let callCount = 0
|
|
109
|
+
const credService: CredentialService = {
|
|
110
|
+
get: async () => null,
|
|
111
|
+
set: async () => {},
|
|
112
|
+
delete: async () => {},
|
|
113
|
+
has: async () => false,
|
|
114
|
+
getAll: async () => {
|
|
115
|
+
callCount++
|
|
116
|
+
return { stripe: { key: 'test' } }
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
const wire: PikkuWire = { session: { userId: 'user-1' } as any }
|
|
120
|
+
const service = new PikkuCredentialWireService(credService, wire)
|
|
121
|
+
|
|
122
|
+
const [r1, r2] = await Promise.all([service.getAll(), service.getAll()])
|
|
123
|
+
assert.strictEqual(callCount, 1)
|
|
124
|
+
assert.deepStrictEqual(r1, r2)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('getScoped should only return allowed names', async () => {
|
|
128
|
+
const credService = mockCredentialService({
|
|
129
|
+
stripe: { key: '1' },
|
|
130
|
+
github: { token: '2' },
|
|
131
|
+
slack: { webhook: '3' },
|
|
132
|
+
})
|
|
133
|
+
const wire: PikkuWire = { session: { userId: 'user-1' } as any }
|
|
134
|
+
const service = new PikkuCredentialWireService(credService, wire)
|
|
135
|
+
|
|
136
|
+
const result = await service.getScoped(['stripe', 'slack'])
|
|
137
|
+
assert.deepStrictEqual(result, {
|
|
138
|
+
stripe: { key: '1' },
|
|
139
|
+
slack: { webhook: '3' },
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('createMiddlewareCredentialWireProps', () => {
|
|
145
|
+
test('should provide setCredential that writes to service', async () => {
|
|
146
|
+
const service = new PikkuCredentialWireService()
|
|
147
|
+
const props = createMiddlewareCredentialWireProps(service)
|
|
148
|
+
props.setCredential('stripe', { apiKey: 'test' })
|
|
149
|
+
const result = await service.getAll()
|
|
150
|
+
assert.deepStrictEqual(result, { stripe: { apiKey: 'test' } })
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('createWireServicesCredentialWireProps', () => {
|
|
155
|
+
test('should provide both setCredential and getCredentials', async () => {
|
|
156
|
+
const service = new PikkuCredentialWireService()
|
|
157
|
+
const props = createWireServicesCredentialWireProps(service)
|
|
158
|
+
props.setCredential('stripe', { apiKey: 'test' })
|
|
159
|
+
const result = await props.getCredentials()
|
|
160
|
+
assert.deepStrictEqual(result, { stripe: { apiKey: 'test' } })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('should scope getCredentials when allowedNames provided', async () => {
|
|
164
|
+
const credService = mockCredentialService({
|
|
165
|
+
stripe: { key: '1' },
|
|
166
|
+
github: { token: '2' },
|
|
167
|
+
})
|
|
168
|
+
const wire: PikkuWire = { session: { userId: 'user-1' } as any }
|
|
169
|
+
const service = new PikkuCredentialWireService(credService, wire)
|
|
170
|
+
const props = createWireServicesCredentialWireProps(service, ['stripe'])
|
|
171
|
+
const result = await props.getCredentials()
|
|
172
|
+
assert.deepStrictEqual(result, { stripe: { key: '1' } })
|
|
173
|
+
})
|
|
174
|
+
})
|
|
@@ -1,22 +1,65 @@
|
|
|
1
|
+
import type { CredentialService } from './credential-service.js'
|
|
2
|
+
import { defaultPikkuUserIdResolver } from './pikku-user-id.js'
|
|
3
|
+
import type { PikkuWire } from '../types/core.types.js'
|
|
4
|
+
|
|
1
5
|
export class PikkuCredentialWireService {
|
|
2
6
|
private credentials: Record<string, unknown> = {}
|
|
7
|
+
private loaded = false
|
|
8
|
+
private loadPromise: Promise<void> | undefined
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
private credentialService?: CredentialService,
|
|
12
|
+
private wire?: PikkuWire
|
|
13
|
+
) {}
|
|
3
14
|
|
|
4
15
|
set(name: string, value: unknown): void {
|
|
5
16
|
this.credentials[name] = value
|
|
6
17
|
}
|
|
7
18
|
|
|
8
|
-
|
|
9
|
-
return this.credentials
|
|
19
|
+
get<T = unknown>(name: string): T | null | Promise<T | null> {
|
|
20
|
+
if (this.loaded) return (this.credentials[name] as T) ?? null
|
|
21
|
+
return this.lazyLoad().then(() => (this.credentials[name] as T) ?? null)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getAll(): Record<string, unknown> | Promise<Record<string, unknown>> {
|
|
25
|
+
if (this.loaded) return this.credentials
|
|
26
|
+
return this.lazyLoad().then(() => this.credentials)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getScoped(
|
|
30
|
+
allowedNames: string[]
|
|
31
|
+
): Record<string, unknown> | Promise<Record<string, unknown>> {
|
|
32
|
+
const buildScoped = () => {
|
|
33
|
+
const scoped: Record<string, unknown> = {}
|
|
34
|
+
for (const name of allowedNames) {
|
|
35
|
+
if (name in this.credentials) {
|
|
36
|
+
scoped[name] = this.credentials[name]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return scoped
|
|
40
|
+
}
|
|
41
|
+
if (this.loaded) return buildScoped()
|
|
42
|
+
return this.lazyLoad().then(buildScoped)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private lazyLoad(): Promise<void> {
|
|
46
|
+
if (this.loadPromise) return this.loadPromise
|
|
47
|
+
this.loadPromise = this.doLoad()
|
|
48
|
+
return this.loadPromise
|
|
10
49
|
}
|
|
11
50
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
51
|
+
private async doLoad(): Promise<void> {
|
|
52
|
+
this.loaded = true
|
|
53
|
+
if (!this.credentialService || !this.wire) return
|
|
54
|
+
const userId = defaultPikkuUserIdResolver(this.wire)
|
|
55
|
+
if (!userId) return
|
|
56
|
+
this.wire.pikkuUserId = userId
|
|
57
|
+
const allCreds = await this.credentialService.getAll(userId)
|
|
58
|
+
for (const [name, value] of Object.entries(allCreds)) {
|
|
59
|
+
if (!(name in this.credentials)) {
|
|
60
|
+
this.credentials[name] = value
|
|
17
61
|
}
|
|
18
62
|
}
|
|
19
|
-
return scoped
|
|
20
63
|
}
|
|
21
64
|
}
|
|
22
65
|
|
|
@@ -36,6 +79,7 @@ export function createWireServicesCredentialWireProps(
|
|
|
36
79
|
return {
|
|
37
80
|
setCredential: (name: string, value: unknown) =>
|
|
38
81
|
credentialWire.set(name, value),
|
|
82
|
+
getCredential: <T = unknown>(name: string) => credentialWire.get<T>(name),
|
|
39
83
|
getCredentials: () =>
|
|
40
84
|
allowedNames
|
|
41
85
|
? credentialWire.getScoped(allowedNames)
|
|
@@ -38,4 +38,26 @@ export class LocalCredentialService implements CredentialService {
|
|
|
38
38
|
}
|
|
39
39
|
return result
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
async getUsersWithCredential(name: string): Promise<string[]> {
|
|
43
|
+
const suffix = `:${name}`
|
|
44
|
+
const users: string[] = []
|
|
45
|
+
for (const key of this.store.keys()) {
|
|
46
|
+
if (key.endsWith(suffix)) {
|
|
47
|
+
users.push(key.slice(0, -suffix.length))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return users
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getAllUsers(): Promise<string[]> {
|
|
54
|
+
const users = new Set<string>()
|
|
55
|
+
for (const key of this.store.keys()) {
|
|
56
|
+
const colonIndex = key.indexOf(':')
|
|
57
|
+
if (colonIndex > 0) {
|
|
58
|
+
users.add(key.slice(0, colonIndex))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...users]
|
|
62
|
+
}
|
|
41
63
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import { defaultPikkuUserIdResolver } from './pikku-user-id.js'
|
|
4
|
+
import type { PikkuWire } from '../types/core.types.js'
|
|
5
|
+
|
|
6
|
+
describe('defaultPikkuUserIdResolver', () => {
|
|
7
|
+
test('should return pikkuUserId from wire', () => {
|
|
8
|
+
const wire: PikkuWire = { pikkuUserId: 'user-1' }
|
|
9
|
+
assert.strictEqual(defaultPikkuUserIdResolver(wire), 'user-1')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('should return userId from session', () => {
|
|
13
|
+
const wire: PikkuWire = { session: { userId: 'session-user' } as any }
|
|
14
|
+
assert.strictEqual(defaultPikkuUserIdResolver(wire), 'session-user')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('should prefer pikkuUserId over session', () => {
|
|
18
|
+
const wire: PikkuWire = {
|
|
19
|
+
pikkuUserId: 'explicit',
|
|
20
|
+
session: { userId: 'session-user' } as any,
|
|
21
|
+
}
|
|
22
|
+
assert.strictEqual(defaultPikkuUserIdResolver(wire), 'explicit')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('should return pikkuUserId from queue', () => {
|
|
26
|
+
const wire: PikkuWire = {
|
|
27
|
+
queue: {
|
|
28
|
+
queueName: 'test',
|
|
29
|
+
jobId: '1',
|
|
30
|
+
pikkuUserId: 'queue-user',
|
|
31
|
+
updateProgress: async () => {},
|
|
32
|
+
fail: async () => {},
|
|
33
|
+
discard: async () => {},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
assert.strictEqual(defaultPikkuUserIdResolver(wire), 'queue-user')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('should return pikkuUserId from workflow', () => {
|
|
40
|
+
const wire: PikkuWire = {
|
|
41
|
+
workflow: {
|
|
42
|
+
name: 'test',
|
|
43
|
+
runId: '1',
|
|
44
|
+
pikkuUserId: 'workflow-user',
|
|
45
|
+
getRun: async () => ({}) as any,
|
|
46
|
+
do: (() => {}) as any,
|
|
47
|
+
sleep: (() => {}) as any,
|
|
48
|
+
} as any,
|
|
49
|
+
}
|
|
50
|
+
assert.strictEqual(defaultPikkuUserIdResolver(wire), 'workflow-user')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('should return undefined when no identity source', () => {
|
|
54
|
+
const wire: PikkuWire = {}
|
|
55
|
+
assert.strictEqual(defaultPikkuUserIdResolver(wire), undefined)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('should ignore non-string session userId', () => {
|
|
59
|
+
const wire: PikkuWire = { session: { userId: 123 } as any }
|
|
60
|
+
assert.strictEqual(defaultPikkuUserIdResolver(wire), undefined)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PikkuWire } from '../types/core.types.js'
|
|
2
|
+
|
|
3
|
+
export type PikkuUserIdResolver = (wire: PikkuWire) => string | undefined
|
|
4
|
+
|
|
5
|
+
export const defaultPikkuUserIdResolver: PikkuUserIdResolver = (wire) => {
|
|
6
|
+
// Explicit pikkuUserId on wire (set by earlier middleware or runner)
|
|
7
|
+
if (wire.pikkuUserId) return wire.pikkuUserId
|
|
8
|
+
// Session userId (from auth middleware — user-defined session shape)
|
|
9
|
+
const session = wire.session as Record<string, unknown> | undefined
|
|
10
|
+
if (session?.userId && typeof session.userId === 'string')
|
|
11
|
+
return session.userId
|
|
12
|
+
// Queue job: carried in job metadata
|
|
13
|
+
if (wire.queue?.pikkuUserId) return wire.queue.pikkuUserId
|
|
14
|
+
// Workflow: carried in run wire metadata
|
|
15
|
+
if (wire.workflow?.pikkuUserId) return wire.workflow.pikkuUserId
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
@@ -52,6 +52,14 @@ export class TypedCredentialService<TMap = Record<string, unknown>>
|
|
|
52
52
|
return this.credentials.getAll(userId)
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
async getUsersWithCredential(name: string): Promise<string[]> {
|
|
56
|
+
return this.credentials.getUsersWithCredential(name)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async getAllUsers(): Promise<string[]> {
|
|
60
|
+
return this.credentials.getAllUsers()
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
async getAllStatus(userId?: string): Promise<CredentialStatusInfo[]> {
|
|
56
64
|
const results: CredentialStatusInfo[] = []
|
|
57
65
|
|
package/src/types/core.types.ts
CHANGED
|
@@ -273,10 +273,16 @@ export type PikkuWire<
|
|
|
273
273
|
getSession: () => Promise<UserSession> | UserSession | undefined
|
|
274
274
|
/** Whether the session was modified during this run */
|
|
275
275
|
hasSessionChanged: () => boolean
|
|
276
|
+
/** The resolved user identity for credential lookups */
|
|
277
|
+
pikkuUserId: string
|
|
276
278
|
/** Set a credential value (available in middleware) */
|
|
277
279
|
setCredential: (name: string, value: unknown) => void
|
|
278
|
-
/** Get
|
|
279
|
-
|
|
280
|
+
/** Get a single credential by name — lazy-loads from CredentialService on first call, sync thereafter */
|
|
281
|
+
getCredential: <T = unknown>(name: string) => T | null | Promise<T | null>
|
|
282
|
+
/** Get all resolved credentials — lazy-loads from CredentialService on first call, sync thereafter */
|
|
283
|
+
getCredentials: () =>
|
|
284
|
+
| Record<string, unknown>
|
|
285
|
+
| Promise<Record<string, unknown>>
|
|
280
286
|
}>
|
|
281
287
|
|
|
282
288
|
/**
|
package/src/types/state.types.ts
CHANGED
|
@@ -184,5 +184,10 @@ export interface PikkuPackageState {
|
|
|
184
184
|
singletonServices: CoreSingletonServices | null
|
|
185
185
|
/** Absolute path to this package's .pikku directory */
|
|
186
186
|
metaDir: string | null
|
|
187
|
+
/** Credential metadata for this addon package */
|
|
188
|
+
credentialsMeta: Record<
|
|
189
|
+
string,
|
|
190
|
+
{ name: string; displayName: string; type: string; oauth2?: boolean }
|
|
191
|
+
> | null
|
|
187
192
|
}
|
|
188
193
|
}
|