@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/errors/errors.d.ts +14 -0
  3. package/dist/errors/errors.js +21 -0
  4. package/dist/function/function-runner.d.ts +1 -1
  5. package/dist/function/function-runner.js +8 -4
  6. package/dist/pikku-state.js +1 -0
  7. package/dist/services/credential-service.d.ts +11 -0
  8. package/dist/services/credential-wire-service.d.ts +14 -3
  9. package/dist/services/credential-wire-service.js +49 -6
  10. package/dist/services/local-credential-service.d.ts +2 -0
  11. package/dist/services/local-credential-service.js +20 -0
  12. package/dist/services/pikku-user-id.d.ts +3 -0
  13. package/dist/services/pikku-user-id.js +16 -0
  14. package/dist/services/typed-credential-service.d.ts +2 -0
  15. package/dist/services/typed-credential-service.js +6 -0
  16. package/dist/types/core.types.d.ts +6 -2
  17. package/dist/types/state.types.d.ts +7 -0
  18. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +21 -0
  19. package/dist/wirings/ai-agent/ai-agent-prepare.js +48 -0
  20. package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
  21. package/dist/wirings/ai-agent/ai-agent-stream.d.ts +2 -1
  22. package/dist/wirings/ai-agent/ai-agent-stream.js +60 -3
  23. package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -1
  24. package/dist/wirings/ai-agent/index.d.ts +1 -1
  25. package/dist/wirings/ai-agent/index.js +1 -1
  26. package/dist/wirings/cli/cli-runner.js +1 -1
  27. package/dist/wirings/http/http-runner.js +0 -4
  28. package/dist/wirings/queue/queue-runner.js +1 -0
  29. package/dist/wirings/queue/queue.types.d.ts +6 -0
  30. package/dist/wirings/rpc/rpc-runner.js +1 -0
  31. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +2 -0
  32. package/dist/wirings/workflow/pikku-workflow-service.js +2 -0
  33. package/dist/wirings/workflow/workflow.types.d.ts +2 -0
  34. package/package.json +1 -1
  35. package/src/errors/errors.ts +32 -0
  36. package/src/function/function-runner.ts +19 -9
  37. package/src/pikku-state.ts +1 -0
  38. package/src/services/credential-service.ts +13 -0
  39. package/src/services/credential-wire-service.test.ts +174 -0
  40. package/src/services/credential-wire-service.ts +52 -8
  41. package/src/services/local-credential-service.ts +22 -0
  42. package/src/services/pikku-user-id.test.ts +62 -0
  43. package/src/services/pikku-user-id.ts +17 -0
  44. package/src/services/typed-credential-service.ts +8 -0
  45. package/src/types/core.types.ts +8 -2
  46. package/src/types/state.types.ts +5 -0
  47. package/src/wirings/ai-agent/ai-agent-prepare.ts +71 -1
  48. package/src/wirings/ai-agent/ai-agent-runner.ts +6 -2
  49. package/src/wirings/ai-agent/ai-agent-stream.ts +107 -3
  50. package/src/wirings/ai-agent/ai-agent.types.ts +22 -1
  51. package/src/wirings/ai-agent/index.ts +1 -0
  52. package/src/wirings/channel/channel-handler.ts +0 -1
  53. package/src/wirings/cli/cli-runner.ts +1 -1
  54. package/src/wirings/http/http-runner.ts +0 -7
  55. package/src/wirings/mcp/mcp-runner.ts +0 -1
  56. package/src/wirings/queue/queue-runner.ts +1 -1
  57. package/src/wirings/queue/queue.types.ts +6 -0
  58. package/src/wirings/rpc/rpc-runner.ts +9 -3
  59. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +2 -0
  60. package/src/wirings/workflow/pikku-workflow-service.ts +2 -0
  61. package/src/wirings/workflow/workflow.types.ts +2 -0
  62. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.13",
3
+ "version": "0.12.14",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -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 type { PikkuCredentialWireService } from '../services/credential-wire-service.js'
32
- import { createWireServicesCredentialWireProps } from '../services/credential-wire-service.js'
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
@@ -157,6 +157,7 @@ const createEmptyPackageState = (): PikkuPackageState => ({
157
157
  factories: null,
158
158
  singletonServices: null,
159
159
  metaDir: null,
160
+ credentialsMeta: null,
160
161
  },
161
162
  })
162
163
 
@@ -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
- getAll(): Record<string, unknown> {
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
- getScoped(allowedNames: string[]): Record<string, unknown> {
13
- const scoped: Record<string, unknown> = {}
14
- for (const name of allowedNames) {
15
- if (name in this.credentials) {
16
- scoped[name] = this.credentials[name]
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
 
@@ -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 all resolved credentials (only available in pikkuWireServices) */
279
- getCredentials: () => Record<string, unknown>
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
  /**
@@ -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
  }