@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.
- package/CHANGELOG.md +101 -0
- package/dist/function/function-runner.js +5 -0
- package/dist/middleware-runner.js +4 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/gopass-secrets.d.ts +1 -1
- package/dist/services/local-secrets.d.ts +1 -1
- package/dist/services/local-variables.d.ts +1 -0
- package/dist/services/local-variables.js +9 -0
- package/dist/services/scoped-secret-service.d.ts +1 -1
- package/dist/services/scoped-secret-service.js +2 -2
- package/dist/services/secret-service.d.ts +6 -2
- package/dist/services/temporary-file-service.d.ts +20 -0
- package/dist/services/temporary-file-service.js +63 -0
- package/dist/services/typed-secret-service.d.ts +1 -1
- package/dist/services/typed-variables-service.d.ts +1 -0
- package/dist/services/typed-variables-service.js +3 -0
- package/dist/services/variables-service.d.ts +9 -0
- package/dist/types/state.types.d.ts +3 -0
- package/dist/wirings/http/http-runner.js +4 -1
- package/dist/wirings/http/pikku-fetch-http-request.js +34 -20
- package/dist/wirings/http/web-request.js +6 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +3 -0
- package/package.json +2 -1
- package/src/function/function-runner.ts +4 -0
- package/src/middleware-runner.test.ts +16 -1
- package/src/middleware-runner.ts +4 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/gopass-secrets.ts +4 -2
- package/src/services/local-secrets.ts +4 -2
- package/src/services/local-variables.ts +11 -0
- package/src/services/scoped-secret-service.test.ts +20 -0
- package/src/services/scoped-secret-service.ts +5 -3
- package/src/services/secret-service.ts +8 -2
- package/src/services/temporary-file-service.ts +76 -0
- package/src/services/typed-secret-service.ts +4 -2
- package/src/services/typed-variables-service.ts +6 -0
- package/src/services/variables-service.ts +11 -0
- package/src/types/state.types.ts +5 -0
- package/src/wirings/http/http-runner.test.ts +13 -0
- package/src/wirings/http/http-runner.ts +4 -1
- package/src/wirings/http/pikku-fetch-http-request.ts +37 -20
- package/src/wirings/http/web-request.test.ts +2 -2
- package/src/wirings/http/web-request.ts +6 -1
- package/src/wirings/workflow/pikku-workflow-service.ts +3 -0
- 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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
47
|
-
|
|
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>
|
package/src/types/state.types.ts
CHANGED
|
@@ -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]
|
|
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
|
-
#
|
|
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.#
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
278
|
-
'
|
|
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
|
|
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,
|