@pikku/core 0.12.31 → 0.12.33

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 (45) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/dist/function/function-runner.js +5 -0
  3. package/dist/middleware-runner.js +4 -1
  4. package/dist/pikku-state.js +1 -0
  5. package/dist/services/gopass-secrets.d.ts +1 -1
  6. package/dist/services/local-secrets.d.ts +1 -1
  7. package/dist/services/local-variables.d.ts +1 -0
  8. package/dist/services/local-variables.js +9 -0
  9. package/dist/services/scoped-secret-service.d.ts +1 -1
  10. package/dist/services/scoped-secret-service.js +2 -2
  11. package/dist/services/secret-service.d.ts +6 -2
  12. package/dist/services/temporary-file-service.d.ts +20 -0
  13. package/dist/services/temporary-file-service.js +63 -0
  14. package/dist/services/typed-secret-service.d.ts +1 -1
  15. package/dist/services/typed-variables-service.d.ts +1 -0
  16. package/dist/services/typed-variables-service.js +3 -0
  17. package/dist/services/variables-service.d.ts +9 -0
  18. package/dist/types/state.types.d.ts +3 -0
  19. package/dist/wirings/http/http-runner.js +4 -1
  20. package/dist/wirings/http/pikku-fetch-http-request.js +34 -20
  21. package/dist/wirings/http/web-request.js +6 -1
  22. package/dist/wirings/workflow/pikku-workflow-service.js +3 -0
  23. package/package.json +2 -1
  24. package/src/function/function-runner.ts +4 -0
  25. package/src/middleware-runner.test.ts +16 -1
  26. package/src/middleware-runner.ts +4 -1
  27. package/src/pikku-state.ts +1 -0
  28. package/src/services/gopass-secrets.ts +4 -2
  29. package/src/services/local-secrets.ts +4 -2
  30. package/src/services/local-variables.ts +11 -0
  31. package/src/services/scoped-secret-service.test.ts +20 -0
  32. package/src/services/scoped-secret-service.ts +5 -3
  33. package/src/services/secret-service.ts +8 -2
  34. package/src/services/temporary-file-service.ts +76 -0
  35. package/src/services/typed-secret-service.ts +4 -2
  36. package/src/services/typed-variables-service.ts +6 -0
  37. package/src/services/variables-service.ts +11 -0
  38. package/src/types/state.types.ts +5 -0
  39. package/src/wirings/http/http-runner.test.ts +13 -0
  40. package/src/wirings/http/http-runner.ts +4 -1
  41. package/src/wirings/http/pikku-fetch-http-request.ts +37 -20
  42. package/src/wirings/http/web-request.test.ts +2 -2
  43. package/src/wirings/http/web-request.ts +6 -1
  44. package/src/wirings/workflow/pikku-workflow-service.ts +3 -0
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -7,6 +7,8 @@ const createMockSecrets = () => ({
7
7
  hasSecret: async () => true,
8
8
  setSecret: async () => {},
9
9
  deleteSecret: async () => {},
10
+ getSecrets: async <T>(keys: string[]) =>
11
+ Object.fromEntries(keys.map((key) => [key, { key }])) as T,
10
12
  })
11
13
 
12
14
  describe('ScopedSecretService', () => {
@@ -55,6 +57,24 @@ describe('ScopedSecretService', () => {
55
57
  })
56
58
  })
57
59
 
60
+ test('should return batch secrets for allowed keys', async () => {
61
+ const mock = createMockSecrets()
62
+ const scoped = new ScopedSecretService(mock, new Set(['KEY1', 'KEY2']))
63
+ const result = await scoped.getSecrets(['KEY1', 'KEY2'])
64
+ assert.deepStrictEqual(result, {
65
+ KEY1: { key: 'KEY1' },
66
+ KEY2: { key: 'KEY2' },
67
+ })
68
+ })
69
+
70
+ test('should reject batch getSecrets when any key is not allowed', async () => {
71
+ const mock = createMockSecrets()
72
+ const scoped = new ScopedSecretService(mock, new Set(['KEY1']))
73
+ await assert.rejects(() => scoped.getSecrets(['KEY1', 'KEY2']), {
74
+ message: 'Access denied to secret key: KEY2',
75
+ })
76
+ })
77
+
58
78
  test('should always throw on setSecret', async () => {
59
79
  const mock = createMockSecrets()
60
80
  const scoped = new ScopedSecretService(mock, new Set(['KEY1']))
@@ -34,8 +34,10 @@ export class ScopedSecretService implements SecretService {
34
34
  throw new Error('deleteSecret is not allowed in scoped secret service')
35
35
  }
36
36
 
37
- async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
38
- const allowed = keys.filter((k) => this.allowedKeys.has(k))
39
- return this.secrets.getSecrets(allowed)
37
+ async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
38
+ keys: (keyof T & string)[]
39
+ ): Promise<Partial<T>> {
40
+ keys.forEach((k) => this.assertAllowed(k))
41
+ return this.secrets.getSecrets<T>(keys)
40
42
  }
41
43
  }
@@ -31,8 +31,14 @@ export interface SecretService {
31
31
  /**
32
32
  * Retrieves multiple secrets in a single batch operation.
33
33
  * Returns a map of key → value for successfully fetched secrets; missing
34
- * keys are omitted rather than throwing.
34
+ * keys are omitted rather than throwing, so the result is typed as
35
+ * `Partial<T>` and callers must handle keys that may be absent at runtime.
36
+ *
37
+ * Pass a shape as `T` to get a typed result without casting, e.g.
38
+ * `await secrets.getSecrets<{ FOO: string; BAR: { id: string } }>(['FOO', 'BAR'])`.
35
39
  * @param keys - The keys of the secrets to retrieve.
36
40
  */
37
- getSecrets(keys: string[]): Promise<Record<string, unknown>>
41
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
42
+ keys: (keyof T & string)[]
43
+ ): Promise<Partial<T>>
38
44
  }
@@ -0,0 +1,76 @@
1
+ import { createReadStream, createWriteStream } from 'fs'
2
+ import { mkdir, rm, stat } from 'fs/promises'
3
+ import { resolve } from 'path'
4
+ import { pipeline } from 'stream/promises'
5
+ import type { Readable } from 'stream'
6
+ import type { Logger } from './logger.js'
7
+
8
+ export class TemporaryFileInstance {
9
+ private files: string[] = []
10
+
11
+ constructor(
12
+ private logger: Logger,
13
+ private tempDir: string
14
+ ) {
15
+ this.tempDir = resolve(tempDir)
16
+ logger.debug(`Using temp dir ${this.tempDir}`)
17
+ }
18
+
19
+ public getFile(key: string): ReadableStream | NodeJS.ReadableStream {
20
+ return createReadStream(`${this.tempDir}/${key}`)
21
+ }
22
+
23
+ public async writeFile(
24
+ key: string,
25
+ stream: ReadableStream | NodeJS.ReadableStream
26
+ ): Promise<void> {
27
+ await this.createDir(key)
28
+ const filePath = `${this.tempDir}/${key}`
29
+ const fileStream = createWriteStream(filePath)
30
+ await pipeline(stream as Readable, fileStream)
31
+ this.files.push(filePath)
32
+ this.logger.debug(`Wrote file: ${filePath}`)
33
+ }
34
+
35
+ public async deleteFile(key: string): Promise<void> {
36
+ await rm(`${this.tempDir}/${key}`)
37
+ this.logger.debug(`Deleted file ${this.tempDir}/${key}`)
38
+ }
39
+
40
+ public async hasFile(key: string): Promise<boolean> {
41
+ try {
42
+ return !!(await stat(`${this.tempDir}/${key}`))
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ public async getTempFileAbsolutePath(key: string): Promise<string> {
49
+ await this.createDir(key)
50
+ return `${this.tempDir}/${key}`
51
+ }
52
+
53
+ private async createDir(key: string) {
54
+ const dir = `${this.tempDir}/${key}`.split('/').slice(0, -1).join('/')
55
+ if (!(await this.hasFile(dir))) {
56
+ await mkdir(dir, { recursive: true })
57
+ }
58
+ }
59
+
60
+ public async cleanup() {
61
+ await Promise.all(this.files.map((file) => rm(file)))
62
+ }
63
+ }
64
+
65
+ export class TemporaryFileService {
66
+ constructor(
67
+ private logger: Logger,
68
+ private tempDir: string
69
+ ) {
70
+ this.tempDir = resolve(tempDir)
71
+ }
72
+
73
+ public createInstance() {
74
+ return new TemporaryFileInstance(this.logger, this.tempDir)
75
+ }
76
+ }
@@ -43,8 +43,10 @@ export class TypedSecretService<
43
43
  return this.secrets.deleteSecret(key)
44
44
  }
45
45
 
46
- async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
47
- return this.secrets.getSecrets(keys)
46
+ async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
47
+ keys: (keyof T & string)[]
48
+ ): Promise<Partial<T>> {
49
+ return this.secrets.getSecrets<T>(keys)
48
50
  }
49
51
 
50
52
  async getAllStatus(): Promise<CredentialStatus[]> {
@@ -28,6 +28,12 @@ export class TypedVariablesService<
28
28
  return this.variables.get(name)
29
29
  }
30
30
 
31
+ getVariables<T extends Record<string, unknown> = Record<string, unknown>>(
32
+ names: (keyof T & string)[]
33
+ ): Promise<Partial<T>> | Partial<T> {
34
+ return this.variables.getVariables<T>(names)
35
+ }
36
+
31
37
  getAll():
32
38
  | Promise<Record<string, string | undefined>>
33
39
  | Record<string, string | undefined> {
@@ -1,5 +1,16 @@
1
1
  export interface VariablesService {
2
2
  get<T = string>(name: string): Promise<T | undefined> | T | undefined
3
+ /**
4
+ * Retrieves multiple variables in a single batch operation, mirroring
5
+ * `SecretService.getSecrets`. Missing variables are omitted rather than
6
+ * throwing, so the result is typed as `Partial<T>` and callers must handle
7
+ * keys that may be absent at runtime. Pass a shape as `T` to get a typed
8
+ * result without casting, e.g.
9
+ * `await variables.getVariables<{ FOO: string; BAR: string }>(['FOO', 'BAR'])`.
10
+ */
11
+ getVariables<T extends Record<string, unknown> = Record<string, unknown>>(
12
+ names: (keyof T & string)[]
13
+ ): Promise<Partial<T>> | Partial<T>
3
14
  getAll: () =>
4
15
  | Promise<Record<string, string | undefined>>
5
16
  | Record<string, string | undefined>
@@ -171,6 +171,11 @@ export interface PikkuPackageState {
171
171
  } | null
172
172
  /** Cached singleton services for this package */
173
173
  singletonServices: CoreSingletonServices | null
174
+ /** The pikkuBetterAuth factory, self-registered when auth.ts is evaluated.
175
+ * pikkuServices reads it to build and inject the `auth` singleton. */
176
+ authFactory:
177
+ | ((services: CoreSingletonServices) => unknown | Promise<unknown>)
178
+ | null
174
179
  /** Credential metadata for this addon package */
175
180
  credentialsMeta: Record<
176
181
  string,
@@ -272,6 +272,19 @@ describe('http-runner helpers', () => {
272
272
  )
273
273
  })
274
274
 
275
+ test('addHTTPMiddleware composes repeated registrations for the same pattern', () => {
276
+ const first: CorePikkuMiddleware = async (_s, _w, next) => next()
277
+ const second: CorePikkuMiddleware = async (_s, _w, next) => next()
278
+
279
+ addHTTPMiddleware('*', [first])
280
+ addHTTPMiddleware('*', [second])
281
+
282
+ assert.deepEqual(pikkuState(null, 'middleware', 'httpGroup')['*'], [
283
+ first,
284
+ second,
285
+ ])
286
+ })
287
+
275
288
  test('createHTTPWire combines request and response when present', () => {
276
289
  const req = new TestRequest('/x', 'get')
277
290
  const res = new TestResponse()
@@ -96,7 +96,10 @@ export const addHTTPMiddleware = <PikkuMiddleware extends CorePikkuMiddleware>(
96
96
  packageName: string | null = null
97
97
  ): CorePikkuMiddlewareGroup => {
98
98
  const httpGroups = pikkuState(packageName, 'middleware', 'httpGroup')
99
- httpGroups[pattern] = middleware
99
+ const existing = httpGroups[pattern] as CorePikkuMiddleware[] | undefined
100
+ httpGroups[pattern] = existing
101
+ ? [...existing, ...(middleware as CorePikkuMiddleware[])]
102
+ : middleware
100
103
  return middleware
101
104
  }
102
105
 
@@ -16,7 +16,7 @@ export class PikkuFetchHTTPRequest<
16
16
  #url: URL
17
17
  #rawBodyText: string | undefined
18
18
  #rawBodyBuffer: ArrayBuffer | undefined
19
- #bodyRead = false
19
+ #rawBufferPromise: Promise<ArrayBuffer> | undefined
20
20
 
21
21
  constructor(private request: Request) {
22
22
  this.#url = new URL(request.url)
@@ -44,36 +44,53 @@ export class PikkuFetchHTTPRequest<
44
44
  * @returns A promise that resolves to the raw request body.
45
45
  */
46
46
  public async arrayBuffer(): Promise<ArrayBuffer> {
47
+ return this.#readRawBuffer()
48
+ }
49
+
50
+ /**
51
+ * Reads the underlying single-use request body exactly once and memoises both
52
+ * the in-flight promise and the result. `json()`, `arrayBuffer()`, `body()`
53
+ * and `toWebRequest()` all funnel through here, so a request whose body is
54
+ * consumed in more than one place can no longer race on the underlying stream
55
+ * and fail with "Body has already been used".
56
+ *
57
+ * If a second consumer asks for the body while the first read is still in
58
+ * flight, that is the concurrent double-read that used to crash — now safe,
59
+ * but warned about so the redundant consumer is found and removed at the
60
+ * source rather than silently leaning on this cache.
61
+ */
62
+ async #readRawBuffer(): Promise<ArrayBuffer> {
47
63
  if (this.#rawBodyBuffer !== undefined) {
48
64
  return this.#rawBodyBuffer
49
65
  }
50
- if (this.#bodyRead) {
51
- if (this.#rawBodyText !== undefined) {
52
- const buf = new TextEncoder().encode(this.#rawBodyText)
53
- .buffer as ArrayBuffer
54
- this.#rawBodyBuffer = buf
55
- return buf
56
- }
57
- return new ArrayBuffer(0)
66
+ if (this.#rawBodyText !== undefined) {
67
+ const buf = new TextEncoder().encode(this.#rawBodyText)
68
+ .buffer as ArrayBuffer
69
+ this.#rawBodyBuffer = buf
70
+ return buf
71
+ }
72
+ if (this.#rawBufferPromise !== undefined) {
73
+ console.warn(
74
+ `[pikku] request body for ${this.method().toUpperCase()} ${this.path()} ` +
75
+ `was requested again before the first read resolved — the read is now ` +
76
+ `shared, but a duplicate body consumer (e.g. middleware calling ` +
77
+ `toWebRequest just for headers) should be removed.`
78
+ )
79
+ return this.#rawBufferPromise
58
80
  }
59
- const buf = await this.request.arrayBuffer()
60
- this.#rawBodyBuffer = buf
61
- this.#bodyRead = true
62
- return buf
81
+ this.#rawBufferPromise = this.request.arrayBuffer().then((buf) => {
82
+ this.#rawBodyBuffer = buf
83
+ return buf
84
+ })
85
+ return this.#rawBufferPromise
63
86
  }
64
87
 
65
88
  async #readRawText(): Promise<string> {
66
89
  if (this.#rawBodyText !== undefined) {
67
90
  return this.#rawBodyText
68
91
  }
69
- if (this.#rawBodyBuffer !== undefined) {
70
- const text = new TextDecoder().decode(this.#rawBodyBuffer)
71
- this.#rawBodyText = text
72
- return text
73
- }
74
- const text = await this.request.text()
92
+ const text = new TextDecoder().decode(await this.#readRawBuffer())
75
93
  this.#rawBodyText = text
76
- this.#bodyRead = true
77
94
  return text
78
95
  }
79
96
 
@@ -274,8 +274,8 @@ describe('applyWebResponse', () => {
274
274
  const { res, state } = createMockResponse()
275
275
  const webRes = new Response('{"csrfToken":"abc"}', { status: 200 })
276
276
  const cookies = [
277
- 'authjs.csrf-token=abc; Path=/; HttpOnly',
278
- 'authjs.callback-url=http%3A%2F%2Flocalhost; Path=/; HttpOnly',
277
+ 'better-auth.session_token=abc; Path=/; HttpOnly',
278
+ 'better-auth.callback-url=http%3A%2F%2Flocalhost; Path=/; HttpOnly',
279
279
  ]
280
280
  ;(webRes.headers as any).getSetCookie = () => cookies
281
281
 
@@ -30,8 +30,13 @@ export function toWebRequest(req: PikkuHTTPRequest, baseUrl?: string): Request {
30
30
  return new Request(url, {
31
31
  method,
32
32
  headers,
33
+ // `pull` (lazy) rather than `start` (eager): the body is only read from the
34
+ // underlying request when this stream is actually consumed. A consumer that
35
+ // builds a web Request just to read its headers (e.g. a session middleware
36
+ // calling getSession({ headers })) never touches the body, so it does zero
37
+ // body I/O and cannot race the route handler's read of the same request.
33
38
  body: new ReadableStream({
34
- async start(controller) {
39
+ async pull(controller) {
35
40
  try {
36
41
  const buffer = await req.arrayBuffer()
37
42
  if (buffer.byteLength > 0) {
@@ -1174,6 +1174,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1174
1174
  const wire: PikkuWire = {
1175
1175
  workflow: workflowWire,
1176
1176
  pikkuUserId: run.wire?.pikkuUserId,
1177
+ session: rpcService.wire?.session,
1177
1178
  rpc: rpcService.wire?.rpc,
1178
1179
  }
1179
1180
  try {
@@ -1358,6 +1359,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1358
1359
  id: rpcName,
1359
1360
  parentRunId: runId,
1360
1361
  parentStepId: stepState.stepId,
1362
+ pikkuUserId: rpcService.wire?.pikkuUserId,
1361
1363
  }
1362
1364
  const shouldInline = !getSingletonServices()?.queueService
1363
1365
  const { runId: childRunId } = await this.startWorkflow(
@@ -1555,6 +1557,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1555
1557
  type: 'workflow',
1556
1558
  id: rpcName,
1557
1559
  parentRunId: runId,
1560
+ pikkuUserId: rpcService.wire?.pikkuUserId,
1558
1561
  }
1559
1562
  const { runId: childRunId } = await this.startWorkflow(
1560
1563
  rpcName,