@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.
Files changed (143) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/dist/column-form.d.ts +32 -0
  3. package/dist/column-form.js +42 -0
  4. package/dist/crypto-utils.d.ts +15 -4
  5. package/dist/crypto-utils.js +18 -2
  6. package/dist/data-classification.d.ts +44 -0
  7. package/dist/function/functions.types.d.ts +23 -10
  8. package/dist/function/index.d.ts +1 -1
  9. package/dist/index.d.ts +6 -3
  10. package/dist/index.js +3 -1
  11. package/dist/middleware/auth-bearer.js +2 -1
  12. package/dist/middleware/remote-auth.js +1 -1
  13. package/dist/remote.js +1 -1
  14. package/dist/secret-value.d.ts +56 -0
  15. package/dist/secret-value.js +46 -0
  16. package/dist/services/audit-service.d.ts +74 -4
  17. package/dist/services/audit-service.js +7 -5
  18. package/dist/services/credential-wire-service.d.ts +5 -0
  19. package/dist/services/credential-wire-service.js +9 -1
  20. package/dist/services/email-service.d.ts +2 -1
  21. package/dist/services/index.d.ts +3 -3
  22. package/dist/services/index.js +1 -1
  23. package/dist/services/local-content-request-handler.d.ts +29 -0
  24. package/dist/services/local-content-request-handler.js +176 -0
  25. package/dist/services/local-secrets.d.ts +4 -3
  26. package/dist/services/local-secrets.js +7 -3
  27. package/dist/services/logger.d.ts +22 -5
  28. package/dist/services/queue-webhook-service.js +1 -1
  29. package/dist/services/scoped-secret-service.d.ts +4 -3
  30. package/dist/services/secret-service.d.ts +8 -3
  31. package/dist/services/typed-secret-service.d.ts +5 -4
  32. package/dist/services/webhook-service.d.ts +2 -1
  33. package/dist/testing/service-tests.js +6 -6
  34. package/dist/types/core.types.d.ts +25 -4
  35. package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
  36. package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
  37. package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
  38. package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
  39. package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
  40. package/dist/wirings/ai-agent/index.d.ts +1 -1
  41. package/dist/wirings/ai-agent/index.js +1 -1
  42. package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
  43. package/dist/wirings/ai-agent/voice-input.js +44 -9
  44. package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
  45. package/dist/wirings/ai-agent/voice-output.js +10 -1
  46. package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
  47. package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
  48. package/dist/wirings/cli/channel/index.d.ts +1 -0
  49. package/dist/wirings/persona/define-personas.d.ts +4 -0
  50. package/dist/wirings/persona/define-personas.js +4 -0
  51. package/dist/wirings/persona/persona.types.d.ts +11 -0
  52. package/dist/wirings/queue/queue-identity.js +2 -1
  53. package/dist/wirings/queue/queue.types.d.ts +2 -1
  54. package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
  55. package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
  56. package/dist/wirings/rpc/remote-addon-auth.js +6 -2
  57. package/dist/wirings/virtual-user/index.d.ts +3 -0
  58. package/dist/wirings/virtual-user/index.js +2 -0
  59. package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
  60. package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
  61. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
  62. package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
  63. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
  64. package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
  65. package/dist/wirings/workflow/scenario-prose.js +1 -1
  66. package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
  67. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  68. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
  69. package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
  70. package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
  71. package/knowledge/decisions/internals/index.md +4 -0
  72. package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
  73. package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
  74. package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
  75. package/package.json +3 -2
  76. package/src/column-form.test.ts +97 -0
  77. package/src/column-form.ts +58 -0
  78. package/src/crypto-utils.ts +25 -6
  79. package/src/data-classification.ts +44 -0
  80. package/src/function/functions.types.ts +47 -10
  81. package/src/function/index.ts +1 -0
  82. package/src/index.ts +24 -2
  83. package/src/middleware/auth-bearer.test.ts +3 -2
  84. package/src/middleware/auth-bearer.ts +2 -1
  85. package/src/middleware/remote-auth.test.ts +2 -1
  86. package/src/middleware/remote-auth.ts +1 -1
  87. package/src/remote.test.ts +2 -1
  88. package/src/remote.ts +1 -1
  89. package/src/secret-value.test.ts +204 -0
  90. package/src/secret-value.ts +111 -0
  91. package/src/services/audit-service.ts +87 -9
  92. package/src/services/credential-wire-service.ts +9 -1
  93. package/src/services/email-service.ts +3 -1
  94. package/src/services/index.ts +3 -3
  95. package/src/services/local-content-request-handler.test.ts +202 -0
  96. package/src/services/local-content-request-handler.ts +267 -0
  97. package/src/services/local-secrets.test.ts +20 -5
  98. package/src/services/local-secrets.ts +15 -7
  99. package/src/services/logger.ts +27 -7
  100. package/src/services/queue-webhook-service.test.ts +2 -1
  101. package/src/services/queue-webhook-service.ts +1 -1
  102. package/src/services/scoped-secret-service.ts +4 -3
  103. package/src/services/secret-service.ts +8 -3
  104. package/src/services/typed-secret-service.ts +11 -7
  105. package/src/services/webhook-service.ts +4 -1
  106. package/src/testing/service-tests.ts +6 -6
  107. package/src/types/core.types.ts +25 -4
  108. package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
  109. package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
  110. package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
  111. package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
  112. package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
  113. package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
  114. package/src/wirings/ai-agent/index.ts +2 -0
  115. package/src/wirings/ai-agent/voice-input.test.ts +65 -0
  116. package/src/wirings/ai-agent/voice-input.ts +48 -9
  117. package/src/wirings/ai-agent/voice-output.test.ts +91 -1
  118. package/src/wirings/ai-agent/voice-output.ts +28 -1
  119. package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
  120. package/src/wirings/cli/channel/index.ts +4 -0
  121. package/src/wirings/persona/define-personas.ts +4 -0
  122. package/src/wirings/persona/persona.types.ts +11 -0
  123. package/src/wirings/queue/queue-identity.test.ts +2 -1
  124. package/src/wirings/queue/queue-identity.ts +4 -1
  125. package/src/wirings/queue/queue.types.ts +6 -1
  126. package/src/wirings/queue/signed-queue-service.ts +2 -1
  127. package/src/wirings/rpc/remote-addon-auth.ts +8 -3
  128. package/src/wirings/rpc/rpc-runner.test.ts +6 -4
  129. package/src/wirings/virtual-user/index.ts +12 -0
  130. package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
  131. package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
  132. package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
  133. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
  134. package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
  135. package/src/wirings/workflow/scenario-prose.test.ts +5 -7
  136. package/src/wirings/workflow/scenario-prose.ts +1 -1
  137. package/src/wirings/workflow/scenario-service.test.ts +0 -1
  138. package/src/wirings/workflow/scenario-step.test.ts +4 -5
  139. package/src/wirings/workflow/scenario-step.types.ts +13 -7
  140. package/src/wirings/workflow/scenario-surface.test.ts +6 -5
  141. package/src/wirings/workflow/workflow.types.ts +7 -0
  142. package/tsconfig.tsbuildinfo +1 -1
  143. 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
- export type AuditActor = {
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
- actor?: AuditActor
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: AuditLogWriteInput): Promise<void>
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
- actor: event.actor ?? resolveAuditActorFromWire(this.wire),
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 resolveAuditActorFromWire = (
261
+ export const resolveAuditUserIdentityFromWire = (
188
262
  wire: PikkuWire<any, any, any, CoreUserSession>
189
- ): AuditActor | undefined => {
263
+ ): AuditUserIdentity | undefined => {
190
264
  const session = wire.session as CoreUserSession | undefined
191
- const actor: AuditActor = {
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 (!actor.userId && !actor.orgId && !actor.pikkuUserId) {
271
+ if (
272
+ !userIdentity.userId &&
273
+ !userIdentity.orgId &&
274
+ !userIdentity.pikkuUserId
275
+ ) {
198
276
  return undefined
199
277
  }
200
278
 
201
- return actor
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: SendEmailInput): Promise<SendEmailResult>
47
+ send<T extends SendEmailInput>(input: Safe<T>): Promise<SendEmailResult>
46
48
  }
@@ -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
+ }