@pikku/core 0.12.74 → 0.12.77
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 +378 -0
- package/dist/column-form.d.ts +32 -0
- package/dist/column-form.js +42 -0
- package/dist/crypto-utils.d.ts +15 -4
- package/dist/crypto-utils.js +18 -2
- package/dist/data-classification.d.ts +44 -0
- package/dist/function/functions.types.d.ts +23 -10
- package/dist/function/index.d.ts +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/middleware/auth-bearer.js +2 -1
- package/dist/middleware/remote-auth.js +1 -1
- package/dist/remote.js +1 -1
- package/dist/secret-value.d.ts +56 -0
- package/dist/secret-value.js +46 -0
- package/dist/services/audit-service.d.ts +74 -4
- package/dist/services/audit-service.js +7 -5
- package/dist/services/credential-wire-service.d.ts +5 -0
- package/dist/services/credential-wire-service.js +9 -1
- package/dist/services/email-service.d.ts +2 -1
- package/dist/services/index.d.ts +3 -3
- package/dist/services/index.js +1 -1
- package/dist/services/local-content-request-handler.d.ts +29 -0
- package/dist/services/local-content-request-handler.js +176 -0
- package/dist/services/local-secrets.d.ts +4 -3
- package/dist/services/local-secrets.js +7 -3
- package/dist/services/logger.d.ts +22 -5
- package/dist/services/queue-webhook-service.js +1 -1
- package/dist/services/scoped-secret-service.d.ts +4 -3
- package/dist/services/secret-service.d.ts +8 -3
- package/dist/services/typed-secret-service.d.ts +5 -4
- package/dist/services/webhook-service.d.ts +2 -1
- package/dist/testing/service-tests.js +6 -6
- package/dist/types/core.types.d.ts +25 -4
- package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
- package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
- package/dist/wirings/ai-agent/voice-input.js +44 -9
- package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
- package/dist/wirings/ai-agent/voice-output.js +10 -1
- package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
- package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
- package/dist/wirings/cli/channel/index.d.ts +1 -0
- package/dist/wirings/persona/define-personas.d.ts +4 -0
- package/dist/wirings/persona/define-personas.js +4 -0
- package/dist/wirings/persona/persona.types.d.ts +11 -0
- package/dist/wirings/queue/queue-identity.js +2 -1
- package/dist/wirings/queue/queue.types.d.ts +2 -1
- package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.js +6 -2
- package/dist/wirings/virtual-user/index.d.ts +3 -0
- package/dist/wirings/virtual-user/index.js +2 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
- package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
- package/dist/wirings/workflow/scenario-prose.js +1 -1
- package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
- package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
- package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
- package/knowledge/decisions/internals/index.md +4 -0
- package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
- package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
- package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
- package/package.json +3 -2
- package/src/column-form.test.ts +97 -0
- package/src/column-form.ts +58 -0
- package/src/crypto-utils.ts +25 -6
- package/src/data-classification.ts +44 -0
- package/src/function/functions.types.ts +47 -10
- package/src/function/index.ts +1 -0
- package/src/index.ts +24 -2
- package/src/middleware/auth-bearer.test.ts +3 -2
- package/src/middleware/auth-bearer.ts +2 -1
- package/src/middleware/remote-auth.test.ts +2 -1
- package/src/middleware/remote-auth.ts +1 -1
- package/src/remote.test.ts +2 -1
- package/src/remote.ts +1 -1
- package/src/secret-value.test.ts +204 -0
- package/src/secret-value.ts +111 -0
- package/src/services/audit-service.ts +87 -9
- package/src/services/credential-wire-service.ts +9 -1
- package/src/services/email-service.ts +3 -1
- package/src/services/index.ts +3 -3
- package/src/services/local-content-request-handler.test.ts +202 -0
- package/src/services/local-content-request-handler.ts +267 -0
- package/src/services/local-secrets.test.ts +20 -5
- package/src/services/local-secrets.ts +15 -7
- package/src/services/logger.ts +27 -7
- package/src/services/queue-webhook-service.test.ts +2 -1
- package/src/services/queue-webhook-service.ts +1 -1
- package/src/services/scoped-secret-service.ts +4 -3
- package/src/services/secret-service.ts +8 -3
- package/src/services/typed-secret-service.ts +11 -7
- package/src/services/webhook-service.ts +4 -1
- package/src/testing/service-tests.ts +6 -6
- package/src/types/core.types.ts +25 -4
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
- package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
- package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
- package/src/wirings/ai-agent/index.ts +2 -0
- package/src/wirings/ai-agent/voice-input.test.ts +65 -0
- package/src/wirings/ai-agent/voice-input.ts +48 -9
- package/src/wirings/ai-agent/voice-output.test.ts +91 -1
- package/src/wirings/ai-agent/voice-output.ts +28 -1
- package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
- package/src/wirings/cli/channel/index.ts +4 -0
- package/src/wirings/persona/define-personas.ts +4 -0
- package/src/wirings/persona/persona.types.ts +11 -0
- package/src/wirings/queue/queue-identity.test.ts +2 -1
- package/src/wirings/queue/queue-identity.ts +4 -1
- package/src/wirings/queue/queue.types.ts +6 -1
- package/src/wirings/queue/signed-queue-service.ts +2 -1
- package/src/wirings/rpc/remote-addon-auth.ts +8 -3
- package/src/wirings/rpc/rpc-runner.test.ts +6 -4
- package/src/wirings/virtual-user/index.ts +12 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
- package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
- package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
- package/src/wirings/workflow/scenario-prose.test.ts +5 -7
- package/src/wirings/workflow/scenario-prose.ts +1 -1
- package/src/wirings/workflow/scenario-service.test.ts +0 -1
- package/src/wirings/workflow/scenario-step.test.ts +4 -5
- package/src/wirings/workflow/scenario-step.types.ts +13 -7
- package/src/wirings/workflow/scenario-surface.test.ts +6 -5
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tsconfig.type-tests.json +12 -0
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
PikkuWiringTypes,
|
|
5
5
|
} from '../types/core.types.js'
|
|
6
6
|
import type { Logger } from './logger.js'
|
|
7
|
+
import type { Safe } from '../secret-value.js'
|
|
7
8
|
|
|
8
9
|
export type AuditDurability = 'best-effort' | 'transactional'
|
|
9
10
|
export type AuditOutcome = 'success' | 'failed' | 'denied'
|
|
@@ -19,7 +20,19 @@ export type ResolvedAuditConfig = {
|
|
|
19
20
|
durability: AuditDurability
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Who an event happened under.
|
|
25
|
+
*
|
|
26
|
+
* A user, not an "actor": in pikku an actor is a synthetic person a scenario
|
|
27
|
+
* drives, flagged `actor` on the user row, and the overwhelming majority of
|
|
28
|
+
* audited events are caused by ordinary customers. Naming this `actor` made the
|
|
29
|
+
* synthetic case unsayable — `actor.actor === true` — and implied every
|
|
30
|
+
* recorded action was a test.
|
|
31
|
+
*
|
|
32
|
+
* `pikkuUserId` is the identity pikku resolves for every wire, so it is the one
|
|
33
|
+
* field a signed-out caller still leaves behind.
|
|
34
|
+
*/
|
|
35
|
+
export type AuditUserIdentity = {
|
|
23
36
|
userId?: string
|
|
24
37
|
orgId?: string
|
|
25
38
|
pikkuUserId?: string
|
|
@@ -37,16 +50,67 @@ export type AuditEvent = {
|
|
|
37
50
|
traceId?: string
|
|
38
51
|
transactionId?: string | null
|
|
39
52
|
queryId?: string | null
|
|
40
|
-
|
|
53
|
+
userIdentity?: AuditUserIdentity
|
|
41
54
|
input?: unknown
|
|
42
55
|
metadata?: Record<string, unknown>
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
export type AuditEventBatch = AuditEvent[]
|
|
46
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A page of the trail, newest first, narrowed by the filters a reader picked.
|
|
62
|
+
*
|
|
63
|
+
* Every field is a conjunction, and an empty array is not a filter — it is
|
|
64
|
+
* "match nothing", which would otherwise read as "match everything" and quietly
|
|
65
|
+
* widen a scoped query.
|
|
66
|
+
*/
|
|
67
|
+
export type AuditQuery = {
|
|
68
|
+
/** Restrict to these users. */
|
|
69
|
+
userIds?: string[]
|
|
70
|
+
/** Restrict to these `AuditEvent['type']` values. */
|
|
71
|
+
types?: string[]
|
|
72
|
+
/** Restrict to one organisation. */
|
|
73
|
+
orgId?: string
|
|
74
|
+
/** Inclusive lower bound on `occurredAt` (ISO 8601). */
|
|
75
|
+
from?: string
|
|
76
|
+
/** Exclusive upper bound on `occurredAt` (ISO 8601). */
|
|
77
|
+
to?: string
|
|
78
|
+
limit?: number
|
|
79
|
+
offset?: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The distinct values present in the trail, for populating filter controls.
|
|
84
|
+
*
|
|
85
|
+
* Computed over the whole trail rather than the current page — a filter list
|
|
86
|
+
* that only offered what the current page happens to show could never be used
|
|
87
|
+
* to reach anything else.
|
|
88
|
+
*/
|
|
89
|
+
export type AuditFacets = {
|
|
90
|
+
userIds: string[]
|
|
91
|
+
types: string[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type AuditQueryResult = {
|
|
95
|
+
events: AuditEvent[]
|
|
96
|
+
/** Offset of the next page, or `null` at the end. */
|
|
97
|
+
nextCursor: number | null
|
|
98
|
+
/** Present only when the caller asked for it — it costs two extra scans. */
|
|
99
|
+
facets?: AuditFacets
|
|
100
|
+
}
|
|
101
|
+
|
|
47
102
|
export interface AuditService {
|
|
48
103
|
audit(event: AuditEvent): Promise<void>
|
|
49
104
|
write?(batch: AuditEventBatch): Promise<void>
|
|
105
|
+
/**
|
|
106
|
+
* The read side. Optional because a sink can legitimately be write-only — a
|
|
107
|
+
* queue producer that hands events to another system has nothing to read
|
|
108
|
+
* back. A reader that finds this absent should say the trail is not readable
|
|
109
|
+
* here rather than that it is empty; the two are very different answers.
|
|
110
|
+
*/
|
|
111
|
+
query?(query: AuditQuery): Promise<AuditQueryResult>
|
|
112
|
+
/** Distinct users and types across the whole trail. Paired with {@link query}. */
|
|
113
|
+
facets?(): Promise<AuditFacets>
|
|
50
114
|
}
|
|
51
115
|
|
|
52
116
|
export class NoopAuditService implements AuditService {
|
|
@@ -57,9 +121,18 @@ export class NoopAuditService implements AuditService {
|
|
|
57
121
|
|
|
58
122
|
export type AuditLogWriteInput = Omit<AuditEvent, 'occurredAt'>
|
|
59
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The audit an invocation writes to. `write` is `Safe<>`-guarded like the
|
|
126
|
+
* logger: an audit event carries `input` and `metadata` as `unknown`, so
|
|
127
|
+
* nominality alone cannot stop a `SecretValue` landing in one. A secret
|
|
128
|
+
* anywhere in the event, however deeply nested, collapses to `never`.
|
|
129
|
+
*
|
|
130
|
+
* An unrevealed `SecretValue` would serialize as `[secret]` anyway; the guard
|
|
131
|
+
* is what makes that an explicit choice rather than a near miss.
|
|
132
|
+
*/
|
|
60
133
|
export interface AuditLog {
|
|
61
134
|
readonly config: ResolvedAuditConfig | undefined
|
|
62
|
-
write(event:
|
|
135
|
+
write<E extends AuditLogWriteInput>(event: Safe<E>): Promise<void>
|
|
63
136
|
flush(): Promise<void>
|
|
64
137
|
close(): Promise<void>
|
|
65
138
|
}
|
|
@@ -149,7 +222,8 @@ class InvocationAuditLog implements AuditLog {
|
|
|
149
222
|
wireType: this.wire.wireType,
|
|
150
223
|
wireId: this.wire.wireId,
|
|
151
224
|
traceId: this.wire.traceId,
|
|
152
|
-
|
|
225
|
+
userIdentity:
|
|
226
|
+
event.userIdentity ?? resolveAuditUserIdentityFromWire(this.wire),
|
|
153
227
|
...event,
|
|
154
228
|
occurredAt: new Date().toISOString(),
|
|
155
229
|
}
|
|
@@ -184,19 +258,23 @@ export const createInvocationAudit = (
|
|
|
184
258
|
return new InvocationAuditLog(wire.audit, service, wire, logger)
|
|
185
259
|
}
|
|
186
260
|
|
|
187
|
-
export const
|
|
261
|
+
export const resolveAuditUserIdentityFromWire = (
|
|
188
262
|
wire: PikkuWire<any, any, any, CoreUserSession>
|
|
189
|
-
):
|
|
263
|
+
): AuditUserIdentity | undefined => {
|
|
190
264
|
const session = wire.session as CoreUserSession | undefined
|
|
191
|
-
const
|
|
265
|
+
const userIdentity: AuditUserIdentity = {
|
|
192
266
|
userId: session?.userId,
|
|
193
267
|
orgId: session?.orgId,
|
|
194
268
|
pikkuUserId: wire.pikkuUserId,
|
|
195
269
|
}
|
|
196
270
|
|
|
197
|
-
if (
|
|
271
|
+
if (
|
|
272
|
+
!userIdentity.userId &&
|
|
273
|
+
!userIdentity.orgId &&
|
|
274
|
+
!userIdentity.pikkuUserId
|
|
275
|
+
) {
|
|
198
276
|
return undefined
|
|
199
277
|
}
|
|
200
278
|
|
|
201
|
-
return
|
|
279
|
+
return userIdentity
|
|
202
280
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSecretValue } from '../secret-value.js'
|
|
1
2
|
import type { CredentialService } from './credential-service.js'
|
|
2
3
|
import { defaultPikkuUserIdResolver } from './pikku-user-id.js'
|
|
3
4
|
import type { PikkuRawWire } from '../types/core.types.js'
|
|
@@ -17,8 +18,15 @@ export class PikkuCredentialWireService {
|
|
|
17
18
|
return this.aliases?.[name] ?? name
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A credential is one of the few places vault material is meant to end up, so
|
|
23
|
+
* a `SecretValue` is unwrapped here rather than rejected — `get` promises the
|
|
24
|
+
* raw material, and storing the wrapper would make that a lie.
|
|
25
|
+
*/
|
|
20
26
|
set(name: string, value: unknown): void {
|
|
21
|
-
this.credentials[this.resolveName(name)] = value
|
|
27
|
+
this.credentials[this.resolveName(name)] = isSecretValue(value)
|
|
28
|
+
? value.reveal()
|
|
29
|
+
: value
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
get<T = unknown>(name: string): T | null | Promise<T | null> {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Safe } from '../secret-value.js'
|
|
2
|
+
|
|
1
3
|
export interface EmailTemplateReference {
|
|
2
4
|
name: string
|
|
3
5
|
locale?: string
|
|
@@ -42,5 +44,5 @@ export interface SendEmailResult {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export interface EmailService {
|
|
45
|
-
send(input:
|
|
47
|
+
send<T extends SendEmailInput>(input: Safe<T>): Promise<SendEmailResult>
|
|
46
48
|
}
|
package/src/services/index.ts
CHANGED
|
@@ -75,7 +75,7 @@ export {
|
|
|
75
75
|
type WebhookServiceConfig,
|
|
76
76
|
} from './webhook-service.js'
|
|
77
77
|
export type { Logger } from './logger.js'
|
|
78
|
-
export type { SecretService } from './secret-service.js'
|
|
78
|
+
export type { SecretService, SecretValues } from './secret-service.js'
|
|
79
79
|
export type { VariablesService } from './variables-service.js'
|
|
80
80
|
export type { SchemaService } from './schema-service.js'
|
|
81
81
|
export type { SessionService } from './user-session-service.js'
|
|
@@ -141,11 +141,10 @@ export type { IsSystemRole } from './system-role-guard.js'
|
|
|
141
141
|
export {
|
|
142
142
|
NoopAuditService,
|
|
143
143
|
createInvocationAudit,
|
|
144
|
-
resolveAuditActorFromWire,
|
|
145
144
|
resolveAuditConfig,
|
|
145
|
+
resolveAuditUserIdentityFromWire,
|
|
146
146
|
} from './audit-service.js'
|
|
147
147
|
export type {
|
|
148
|
-
AuditActor,
|
|
149
148
|
AuditConfig,
|
|
150
149
|
AuditDurability,
|
|
151
150
|
AuditEvent,
|
|
@@ -155,6 +154,7 @@ export type {
|
|
|
155
154
|
AuditOutcome,
|
|
156
155
|
AuditService,
|
|
157
156
|
AuditSource,
|
|
157
|
+
AuditUserIdentity,
|
|
158
158
|
ResolvedAuditConfig,
|
|
159
159
|
} from './audit-service.js'
|
|
160
160
|
export { InMemorySessionStore } from './in-memory-session-store.js'
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, test, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import type { JWTService, Logger } from './index.js'
|
|
14
|
+
import { LocalContent } from './local-content.js'
|
|
15
|
+
import { createLocalContentRequestHandler } from './local-content-request-handler.js'
|
|
16
|
+
|
|
17
|
+
const noopLogger = {
|
|
18
|
+
info: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
warn: () => {},
|
|
21
|
+
debug: () => {},
|
|
22
|
+
trace: () => {},
|
|
23
|
+
setLevel: () => {},
|
|
24
|
+
} as unknown as Logger
|
|
25
|
+
|
|
26
|
+
const fakeJWT = {
|
|
27
|
+
encode: async (_expiry: string, payload: unknown) =>
|
|
28
|
+
Buffer.from(JSON.stringify(payload)).toString('base64url'),
|
|
29
|
+
decode: async (token: string) =>
|
|
30
|
+
JSON.parse(Buffer.from(token, 'base64url').toString()),
|
|
31
|
+
} as unknown as JWTService
|
|
32
|
+
|
|
33
|
+
describe('createLocalContentRequestHandler', () => {
|
|
34
|
+
let tmpDir: string
|
|
35
|
+
let handler: ReturnType<typeof createLocalContentRequestHandler>
|
|
36
|
+
let content: LocalContent
|
|
37
|
+
|
|
38
|
+
before(() => {
|
|
39
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'pikku-content-handler-'))
|
|
40
|
+
mkdirSync(join(tmpDir, 'bucket'), { recursive: true })
|
|
41
|
+
writeFileSync(join(tmpDir, 'bucket', 'existing.bin'), 'already here')
|
|
42
|
+
|
|
43
|
+
const config = {
|
|
44
|
+
localFileUploadPath: tmpDir,
|
|
45
|
+
uploadUrlPrefix: '/upload',
|
|
46
|
+
assetUrlPrefix: '/assets',
|
|
47
|
+
sizeLimit: '1mb',
|
|
48
|
+
}
|
|
49
|
+
content = new LocalContent(config, noopLogger, fakeJWT)
|
|
50
|
+
handler = createLocalContentRequestHandler({
|
|
51
|
+
content: config,
|
|
52
|
+
logger: noopLogger,
|
|
53
|
+
getJWT: () => fakeJWT,
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
after(() => {
|
|
58
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('returns null for a path outside both prefixes', async () => {
|
|
62
|
+
const result = await handler(
|
|
63
|
+
new Request('http://localhost/uploadsomething')
|
|
64
|
+
)
|
|
65
|
+
assert.equal(result, null)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('returns null for a method neither prefix handles', async () => {
|
|
69
|
+
const result = await handler(
|
|
70
|
+
new Request('http://localhost/upload/bucket/x.bin', { method: 'GET' })
|
|
71
|
+
)
|
|
72
|
+
assert.equal(result, null)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Encoded SEPARATORS, not encoded dots. `%2e%2e` never reaches the handler:
|
|
76
|
+
// the URL parser treats it as a double-dot segment and resolves it away, so
|
|
77
|
+
// the path arrives already outside the prefix. `%2f` survives parsing intact
|
|
78
|
+
// and only becomes a separator when the handler decodes it — which is the one
|
|
79
|
+
// way a `..` can still be sitting in the key by the time it is resolved, and
|
|
80
|
+
// therefore the case the guard actually has to catch.
|
|
81
|
+
test('refuses an upload whose key escapes the content directory', async () => {
|
|
82
|
+
writeFileSync(join(tmpDir, '..', 'pikku-handler-secret.txt'), 'secret')
|
|
83
|
+
try {
|
|
84
|
+
const response = await handler(
|
|
85
|
+
new Request(
|
|
86
|
+
'http://localhost/upload/bucket/..%2f..%2fpikku-handler-secret.txt',
|
|
87
|
+
{ method: 'PUT', body: 'overwritten' }
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
assert.equal(response?.status, 400)
|
|
91
|
+
assert.equal(
|
|
92
|
+
readFileSync(join(tmpDir, '..', 'pikku-handler-secret.txt'), 'utf8'),
|
|
93
|
+
'secret'
|
|
94
|
+
)
|
|
95
|
+
} finally {
|
|
96
|
+
rmSync(join(tmpDir, '..', 'pikku-handler-secret.txt'), { force: true })
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('refuses an asset read whose key escapes the content directory', async () => {
|
|
101
|
+
const response = await handler(
|
|
102
|
+
new Request('http://localhost/assets/bucket/..%2f..%2fanything.txt')
|
|
103
|
+
)
|
|
104
|
+
assert.equal(response?.status, 400)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('round-trips an upload and a signed read', async () => {
|
|
108
|
+
const { uploadUrl } = await content.getUploadURL({
|
|
109
|
+
bucket: 'bucket',
|
|
110
|
+
fileKey: 'round/trip.bin',
|
|
111
|
+
contentType: 'application/octet-stream',
|
|
112
|
+
} as any)
|
|
113
|
+
const put = await handler(
|
|
114
|
+
new Request(`http://localhost${uploadUrl}`, {
|
|
115
|
+
method: 'PUT',
|
|
116
|
+
body: 'payload',
|
|
117
|
+
})
|
|
118
|
+
)
|
|
119
|
+
assert.equal(put?.status, 200)
|
|
120
|
+
|
|
121
|
+
const signed = await content.signContentKey({
|
|
122
|
+
bucket: 'bucket',
|
|
123
|
+
contentKey: 'round/trip.bin',
|
|
124
|
+
dateLessThan: new Date(Date.now() + 60_000),
|
|
125
|
+
} as any)
|
|
126
|
+
const get = await handler(new Request(`http://localhost${signed}`))
|
|
127
|
+
assert.equal(get?.status, 200)
|
|
128
|
+
assert.equal(await get!.text(), 'payload')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('HEAD returns the length without the body', async () => {
|
|
132
|
+
const signed = await content.signContentKey({
|
|
133
|
+
bucket: 'bucket',
|
|
134
|
+
contentKey: 'existing.bin',
|
|
135
|
+
dateLessThan: new Date(Date.now() + 60_000),
|
|
136
|
+
} as any)
|
|
137
|
+
const response = await handler(
|
|
138
|
+
new Request(`http://localhost${signed}`, { method: 'HEAD' })
|
|
139
|
+
)
|
|
140
|
+
assert.equal(response?.status, 200)
|
|
141
|
+
assert.equal(
|
|
142
|
+
response?.headers.get('content-length'),
|
|
143
|
+
String('already here'.length)
|
|
144
|
+
)
|
|
145
|
+
assert.equal(await response!.text(), '')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('refuses a read with no signature at all', async () => {
|
|
149
|
+
const response = await handler(
|
|
150
|
+
new Request('http://localhost/assets/bucket/existing.bin')
|
|
151
|
+
)
|
|
152
|
+
assert.equal(response?.status, 403)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('refuses a signature that verifies but names another path', async () => {
|
|
156
|
+
const signed = await content.signContentKey({
|
|
157
|
+
bucket: 'bucket',
|
|
158
|
+
contentKey: 'existing.bin',
|
|
159
|
+
dateLessThan: new Date(Date.now() + 60_000),
|
|
160
|
+
} as any)
|
|
161
|
+
writeFileSync(join(tmpDir, 'bucket', 'sibling.bin'), 'sibling')
|
|
162
|
+
const query = signed.slice(signed.indexOf('?'))
|
|
163
|
+
const response = await handler(
|
|
164
|
+
new Request(`http://localhost/assets/bucket/sibling.bin${query}`)
|
|
165
|
+
)
|
|
166
|
+
assert.equal(response?.status, 403)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('refuses signed reads when no JWT service is available', async () => {
|
|
170
|
+
const unverifiable = createLocalContentRequestHandler({
|
|
171
|
+
content: {
|
|
172
|
+
localFileUploadPath: tmpDir,
|
|
173
|
+
uploadUrlPrefix: '/upload',
|
|
174
|
+
assetUrlPrefix: '/assets',
|
|
175
|
+
},
|
|
176
|
+
logger: noopLogger,
|
|
177
|
+
getJWT: () => undefined,
|
|
178
|
+
})
|
|
179
|
+
const signed = await content.signContentKey({
|
|
180
|
+
bucket: 'bucket',
|
|
181
|
+
contentKey: 'existing.bin',
|
|
182
|
+
dateLessThan: new Date(Date.now() + 60_000),
|
|
183
|
+
} as any)
|
|
184
|
+
const response = await unverifiable(
|
|
185
|
+
new Request(`http://localhost${signed}`)
|
|
186
|
+
)
|
|
187
|
+
assert.equal(response?.status, 403)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('rejects a body over the size limit without writing it', async () => {
|
|
191
|
+
const response = await handler(
|
|
192
|
+
new Request('http://localhost/upload/bucket/big.bin', {
|
|
193
|
+
method: 'PUT',
|
|
194
|
+
body: Buffer.alloc(1024 * 1024 + 1),
|
|
195
|
+
})
|
|
196
|
+
)
|
|
197
|
+
assert.equal(response?.status, 413)
|
|
198
|
+
// Abandoning the stream must leave nothing behind — a partial file here
|
|
199
|
+
// would be a truncated asset that later reads would serve as if whole.
|
|
200
|
+
assert.equal(existsSync(join(tmpDir, 'bucket', 'big.bin')), false)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { createReadStream } from 'fs'
|
|
2
|
+
import { mkdir, stat, writeFile } from 'fs/promises'
|
|
3
|
+
import { normalize, resolve } from 'path'
|
|
4
|
+
import { Readable } from 'stream'
|
|
5
|
+
import type { JWTService, Logger } from '@pikku/core/services'
|
|
6
|
+
import { signedContentPath, type LocalContentConfig } from './local-content.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The server half of {@link LocalContent}.
|
|
10
|
+
*
|
|
11
|
+
* `LocalContent` hands out `PUT <uploadUrlPrefix>/<key>` upload URLs and signed
|
|
12
|
+
* `GET <assetUrlPrefix>/<key>` read URLs, but it cannot answer either: it is a
|
|
13
|
+
* `ContentService`, not a transport. Something in the serving path has to, and
|
|
14
|
+
* until now only `@pikku/node-http-server` did — so the very same project served
|
|
15
|
+
* under Bun handed the browser upload URLs that 404ed, with nothing naming the
|
|
16
|
+
* cause.
|
|
17
|
+
*
|
|
18
|
+
* Expressed in Web `Request`/`Response` so every runtime can share one
|
|
19
|
+
* implementation rather than each re-deriving the signature check. Returns
|
|
20
|
+
* `null` for anything that is not a content request, which is the caller's
|
|
21
|
+
* signal to carry on with its normal routing.
|
|
22
|
+
*/
|
|
23
|
+
export type LocalContentRequestHandler = (
|
|
24
|
+
request: Request
|
|
25
|
+
) => Promise<Response | null>
|
|
26
|
+
|
|
27
|
+
export type LocalContentRequestHandlerOptions = {
|
|
28
|
+
content: LocalContentConfig
|
|
29
|
+
logger: Logger
|
|
30
|
+
/**
|
|
31
|
+
* Resolved per request rather than passed by value: a runtime may only be
|
|
32
|
+
* able to reach the signing service through `singletonServices`, which is not
|
|
33
|
+
* populated until after the server is constructed.
|
|
34
|
+
*/
|
|
35
|
+
getJWT: () => JWTService | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const matchesPrefix = (pathname: string, prefix: string) =>
|
|
39
|
+
pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
40
|
+
|
|
41
|
+
const contentKey = (pathname: string, prefix: string) =>
|
|
42
|
+
pathname.slice(prefix.length).replace(/^\/+/, '')
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a key against the content root, or `null` if it escapes. `normalize`
|
|
46
|
+
* first so `..` segments are collapsed before the prefix check, and the
|
|
47
|
+
* comparison carries a trailing separator so a sibling directory whose name
|
|
48
|
+
* merely starts with the root's cannot pass as being inside it.
|
|
49
|
+
*/
|
|
50
|
+
const toTargetPath = (basePath: string, key: string): string | null => {
|
|
51
|
+
const normalizedBasePath = resolve(basePath)
|
|
52
|
+
const targetPath = resolve(normalizedBasePath, normalize(key))
|
|
53
|
+
return targetPath.startsWith(`${normalizedBasePath}/`) ? targetPath : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const parseSizeLimit = (sizeLimit: string): number => {
|
|
57
|
+
const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(sizeLimit.trim())
|
|
58
|
+
if (!match) {
|
|
59
|
+
throw new Error(`Invalid size limit: ${sizeLimit}`)
|
|
60
|
+
}
|
|
61
|
+
const value = Number(match[1])
|
|
62
|
+
const unit = (match[2] ?? 'b').toLowerCase()
|
|
63
|
+
const multiplier =
|
|
64
|
+
unit === 'gb'
|
|
65
|
+
? 1024 * 1024 * 1024
|
|
66
|
+
: unit === 'mb'
|
|
67
|
+
? 1024 * 1024
|
|
68
|
+
: unit === 'kb'
|
|
69
|
+
? 1024
|
|
70
|
+
: 1
|
|
71
|
+
return value * multiplier
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const text = (status: number, body: string) =>
|
|
75
|
+
new Response(body, {
|
|
76
|
+
status,
|
|
77
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
export const createLocalContentRequestHandler = ({
|
|
81
|
+
content,
|
|
82
|
+
logger,
|
|
83
|
+
getJWT,
|
|
84
|
+
}: LocalContentRequestHandlerOptions): LocalContentRequestHandler => {
|
|
85
|
+
// Logged at most once. An unverifiable request is attacker-triggerable, so
|
|
86
|
+
// this reports a startup misconfiguration rather than per-request news.
|
|
87
|
+
let loggedMissingJWT = false
|
|
88
|
+
|
|
89
|
+
const validateSignedAssetRequest = async (
|
|
90
|
+
requestUrl: URL
|
|
91
|
+
): Promise<{ ok: true } | { ok: false; status: number; body: string }> => {
|
|
92
|
+
const signedAtValue = requestUrl.searchParams.get('signedAt')
|
|
93
|
+
const expiresAtValue = requestUrl.searchParams.get('expiresAt')
|
|
94
|
+
const notBeforeValue = requestUrl.searchParams.get('notBefore')
|
|
95
|
+
const signature = requestUrl.searchParams.get('signature')
|
|
96
|
+
|
|
97
|
+
if (!signedAtValue || !expiresAtValue) {
|
|
98
|
+
return { ok: false, status: 403, body: 'Signed URL required' }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const signedAt = Number(signedAtValue)
|
|
102
|
+
const expiresAt = Number(expiresAtValue)
|
|
103
|
+
const notBefore =
|
|
104
|
+
notBeforeValue == null ? undefined : Number(notBeforeValue)
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
!Number.isFinite(signedAt) ||
|
|
108
|
+
!Number.isFinite(expiresAt) ||
|
|
109
|
+
(notBefore != null && !Number.isFinite(notBefore))
|
|
110
|
+
) {
|
|
111
|
+
return { ok: false, status: 403, body: 'Invalid signed URL' }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const now = Date.now()
|
|
115
|
+
if (now > expiresAt || (notBefore != null && now < notBefore)) {
|
|
116
|
+
return { ok: false, status: 403, body: 'Signed URL expired' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const jwt = getJWT()
|
|
120
|
+
if (!jwt) {
|
|
121
|
+
if (!loggedMissingJWT) {
|
|
122
|
+
loggedMissingJWT = true
|
|
123
|
+
logger.error(
|
|
124
|
+
'pikku: refusing signed asset reads — no JWTService is available to verify them. Pass `contentSigningJWT` (the same service LocalContent signs with) or expose it as `singletonServices.jwt`.'
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return { ok: false, status: 403, body: 'Invalid signed URL' }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!signature) {
|
|
131
|
+
return { ok: false, status: 403, body: 'Signed URL signature required' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const payload = await jwt.decode<{
|
|
136
|
+
signedAt?: number
|
|
137
|
+
expiresAt?: number
|
|
138
|
+
notBefore?: number
|
|
139
|
+
path?: string
|
|
140
|
+
}>(signature)
|
|
141
|
+
|
|
142
|
+
// Every claim is compared, the path included: without it a signature
|
|
143
|
+
// minted for one asset would read any other.
|
|
144
|
+
if (
|
|
145
|
+
payload.signedAt !== signedAt ||
|
|
146
|
+
payload.expiresAt !== expiresAt ||
|
|
147
|
+
payload.notBefore !== notBefore ||
|
|
148
|
+
payload.path !== signedContentPath(requestUrl.pathname)
|
|
149
|
+
) {
|
|
150
|
+
return { ok: false, status: 403, body: 'Invalid signed URL' }
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
return { ok: false, status: 403, body: 'Invalid signed URL' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { ok: true }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const handleUpload = async (
|
|
160
|
+
request: Request,
|
|
161
|
+
pathname: string
|
|
162
|
+
): Promise<Response> => {
|
|
163
|
+
const key = contentKey(pathname, content.uploadUrlPrefix)
|
|
164
|
+
const targetPath = toTargetPath(content.localFileUploadPath, key)
|
|
165
|
+
if (!targetPath) {
|
|
166
|
+
return text(400, 'Invalid path')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const maxBytes = parseSizeLimit(content.sizeLimit ?? '1mb')
|
|
170
|
+
|
|
171
|
+
// Counted as it arrives and abandoned the moment it goes over, so an
|
|
172
|
+
// oversized upload costs the limit rather than its own size — `arrayBuffer()`
|
|
173
|
+
// would have to hold all of it first, which hands an unauthenticated caller
|
|
174
|
+
// a way to spend the server's memory. Mirrors node-http-server's
|
|
175
|
+
// `readRequestBody`, which aborts the same way.
|
|
176
|
+
const chunks: Buffer[] = []
|
|
177
|
+
let bytesRead = 0
|
|
178
|
+
const reader = request.body?.getReader()
|
|
179
|
+
if (reader) {
|
|
180
|
+
try {
|
|
181
|
+
for (;;) {
|
|
182
|
+
const { done, value } = await reader.read()
|
|
183
|
+
if (done) break
|
|
184
|
+
bytesRead += value.byteLength
|
|
185
|
+
if (bytesRead > maxBytes) {
|
|
186
|
+
await reader.cancel()
|
|
187
|
+
return text(413, 'Content too large')
|
|
188
|
+
}
|
|
189
|
+
chunks.push(Buffer.from(value))
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
reader.releaseLock()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await mkdir(resolve(targetPath, '..'), { recursive: true })
|
|
197
|
+
await writeFile(targetPath, Buffer.concat(chunks))
|
|
198
|
+
return new Response(null, { status: 200 })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const handleAsset = async (
|
|
202
|
+
request: Request,
|
|
203
|
+
requestUrl: URL,
|
|
204
|
+
pathname: string
|
|
205
|
+
): Promise<Response> => {
|
|
206
|
+
const key = contentKey(pathname, content.assetUrlPrefix)
|
|
207
|
+
const targetPath = toTargetPath(content.localFileUploadPath, key)
|
|
208
|
+
if (!targetPath) {
|
|
209
|
+
return text(400, 'Invalid path')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const signed = await validateSignedAssetRequest(requestUrl)
|
|
213
|
+
if (!signed.ok) {
|
|
214
|
+
return text(signed.status, signed.body)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const file = await stat(targetPath)
|
|
219
|
+
if (!file.isFile()) {
|
|
220
|
+
return new Response(null, { status: 404 })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const headers = {
|
|
224
|
+
'content-length': String(file.size),
|
|
225
|
+
'content-type': 'application/octet-stream',
|
|
226
|
+
}
|
|
227
|
+
if (request.method === 'HEAD') {
|
|
228
|
+
return new Response(null, { status: 200, headers })
|
|
229
|
+
}
|
|
230
|
+
// Streamed rather than buffered: assets are user uploads, and their size
|
|
231
|
+
// is bounded by `sizeLimit` at write time, not by anything here.
|
|
232
|
+
return new Response(
|
|
233
|
+
Readable.toWeb(createReadStream(targetPath)) as ReadableStream,
|
|
234
|
+
{ status: 200, headers }
|
|
235
|
+
)
|
|
236
|
+
} catch {
|
|
237
|
+
return new Response(null, { status: 404 })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return async (request) => {
|
|
242
|
+
let requestUrl: URL
|
|
243
|
+
try {
|
|
244
|
+
requestUrl = new URL(request.url)
|
|
245
|
+
} catch {
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const pathname = decodeURIComponent(requestUrl.pathname)
|
|
250
|
+
|
|
251
|
+
if (
|
|
252
|
+
request.method === 'PUT' &&
|
|
253
|
+
matchesPrefix(pathname, content.uploadUrlPrefix)
|
|
254
|
+
) {
|
|
255
|
+
return handleUpload(request, pathname)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (
|
|
259
|
+
(request.method === 'GET' || request.method === 'HEAD') &&
|
|
260
|
+
matchesPrefix(pathname, content.assetUrlPrefix)
|
|
261
|
+
) {
|
|
262
|
+
return handleAsset(request, requestUrl, pathname)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
}
|