@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
|
@@ -11,9 +11,53 @@ export type AnonymizeStrategy =
|
|
|
11
11
|
| 'keep'
|
|
12
12
|
| null
|
|
13
13
|
|
|
14
|
+
// knowledge: decisions/internals/core-column-form-is-an-axis-of-its-own.md
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How a column's bytes are represented at rest, as distinct from how sensitive
|
|
18
|
+
* the value is (`Classification`). The two are independent: a token hash is
|
|
19
|
+
* `secret` + `hashed` and must never be encrypted, because the hash *is* the
|
|
20
|
+
* lookup key; a live bearer token is `secret` + `plain` today and should not be.
|
|
21
|
+
*
|
|
22
|
+
* `wrapped` and `sealed` are siblings rather than one being "encrypted": both
|
|
23
|
+
* are ciphertext, and what separates them is who can read it back. Wrapped is
|
|
24
|
+
* symmetric and the application holds the key. Sealed is asymmetric and the
|
|
25
|
+
* application holds only the public half, so it can write the value and never
|
|
26
|
+
* read it. Storing one where the other is expected is silent, permanent data
|
|
27
|
+
* loss, which is why they are not collapsed into a single `encrypted`.
|
|
28
|
+
*/
|
|
29
|
+
export type ColumnForm = 'plain' | 'hashed' | 'wrapped' | 'sealed'
|
|
30
|
+
|
|
31
|
+
declare const wrappedBrand: unique symbol
|
|
32
|
+
declare const sealedBrand: unique symbol
|
|
33
|
+
declare const hashedBrand: unique symbol
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ciphertext under a symmetric key the application holds — the output of
|
|
37
|
+
* `envelopeEncrypt`, `wrapDEK` or `envelopeRewrap`.
|
|
38
|
+
*
|
|
39
|
+
* Unlike `Secret<T>` the brand is REQUIRED, so a plain `string` is not
|
|
40
|
+
* assignable and a column declared `form: 'wrapped'` cannot be written with
|
|
41
|
+
* anything but genuine ciphertext. It stays assignable *to* `string`, so it
|
|
42
|
+
* still works as a query operand and serializes normally — the constraint is on
|
|
43
|
+
* construction, not on use.
|
|
44
|
+
*/
|
|
45
|
+
export type WrappedValue = string & { readonly [wrappedBrand]: true }
|
|
46
|
+
|
|
47
|
+
/** Ciphertext under a public key whose private half the application does not
|
|
48
|
+
* hold. Deliberately not assignable to `WrappedValue`: writing one where the
|
|
49
|
+
* other belongs produces a row nobody can ever open. */
|
|
50
|
+
export type SealedValue = string & { readonly [sealedBrand]: true }
|
|
51
|
+
|
|
52
|
+
/** A one-way digest of a secret input. The brand's job is narrow — stop a *raw*
|
|
53
|
+
* credential being written into the column that should hold its hash. */
|
|
54
|
+
export type HashedValue = string & { readonly [hashedBrand]: true }
|
|
55
|
+
|
|
14
56
|
export interface ColumnClassification {
|
|
15
57
|
classification: Classification
|
|
16
58
|
anonymize_strategy: AnonymizeStrategy
|
|
59
|
+
/** At-rest representation. Absent means `plain`. */
|
|
60
|
+
form?: ColumnForm
|
|
17
61
|
description?: string
|
|
18
62
|
}
|
|
19
63
|
|
|
@@ -12,6 +12,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
|
12
12
|
import type { PikkuError } from '../errors/error-handler.js'
|
|
13
13
|
import type { CoreNodeConfig } from '../wirings/node/node.types.js'
|
|
14
14
|
import type { ScenarioSurface } from '../wirings/workflow/scenario-step.types.js'
|
|
15
|
+
import type { Safe } from '../secret-value.js'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* @deprecated Use StandardSchemaV1 from @standard-schema/spec instead.
|
|
@@ -31,7 +32,9 @@ export type CorePikkuFunction<
|
|
|
31
32
|
services: Services,
|
|
32
33
|
data: In,
|
|
33
34
|
wire: Wire
|
|
34
|
-
) => Wire['channel'] extends null
|
|
35
|
+
) => Wire['channel'] extends null
|
|
36
|
+
? Promise<Safe<Out>>
|
|
37
|
+
: Promise<Safe<Out>> | Promise<void>
|
|
35
38
|
|
|
36
39
|
export type CorePikkuFunctionSessionless<
|
|
37
40
|
In,
|
|
@@ -45,7 +48,9 @@ export type CorePikkuFunctionSessionless<
|
|
|
45
48
|
services: Services,
|
|
46
49
|
data: In,
|
|
47
50
|
wire: Wire
|
|
48
|
-
) => Wire['channel'] extends null
|
|
51
|
+
) => Wire['channel'] extends null
|
|
52
|
+
? Promise<Safe<Out>>
|
|
53
|
+
: Promise<Safe<Out>> | Promise<void>
|
|
49
54
|
|
|
50
55
|
export type CorePikkuPermission<
|
|
51
56
|
In = any,
|
|
@@ -192,16 +197,18 @@ export type CorePikkuFunctionConfig<
|
|
|
192
197
|
tags?: string[]
|
|
193
198
|
expose?: boolean
|
|
194
199
|
/**
|
|
195
|
-
* The
|
|
200
|
+
* The permission check for this function lives in its body — verifying a
|
|
196
201
|
* signed token, checking a webhook signature, matching an invite code — so
|
|
197
|
-
* it is not open despite
|
|
202
|
+
* it is not open despite declaring no session, scope or permission.
|
|
198
203
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
204
|
+
* A last resort. Prefer `permissions`, which are declared, inspectable, and
|
|
205
|
+
* reusable; reach for this only when the check cannot be expressed as one.
|
|
206
|
+
*
|
|
207
|
+
* Purely declarative — it grants nothing, and asserting it falsely disables
|
|
208
|
+
* the audit that would have caught the mistake. Requires
|
|
209
|
+
* `allow.permissionsInBody` in `pikku.config.json`.
|
|
203
210
|
*/
|
|
204
|
-
|
|
211
|
+
permissionsInBody?: boolean
|
|
205
212
|
remote?: boolean
|
|
206
213
|
mcp?: boolean
|
|
207
214
|
readonly?: boolean
|
|
@@ -248,7 +255,8 @@ export type CorePikkuFunctionConfig<
|
|
|
248
255
|
* Scopes the session must hold; all are required (AND) and checked before
|
|
249
256
|
* `permissions`, which OR together — a scope can only narrow access.
|
|
250
257
|
* Narrowed to the generated `ScopeId` union, so an undeclared scope is a
|
|
251
|
-
* compile error.
|
|
258
|
+
* compile error. Requires a session — see
|
|
259
|
+
* {@link CorePikkuSessionlessFunctionConfig}.
|
|
252
260
|
*/
|
|
253
261
|
scopes?: Scope[]
|
|
254
262
|
permissions?: CorePermissionGroup<PikkuPermission>
|
|
@@ -258,3 +266,32 @@ export type CorePikkuFunctionConfig<
|
|
|
258
266
|
node?: CoreNodeConfig
|
|
259
267
|
errors?: Array<typeof PikkuError>
|
|
260
268
|
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* {@link CorePikkuFunctionConfig} for a function that runs without a session.
|
|
272
|
+
*
|
|
273
|
+
* Identical, minus `scopes`. Scopes are AND-ed and `verifyScopes` fails closed,
|
|
274
|
+
* so an anonymous caller holds none and satisfies none — a sessionless function
|
|
275
|
+
* with scopes rejects every caller it exists to serve. Gate it with
|
|
276
|
+
* `permissions`, which receive the optional session and may pass anonymous.
|
|
277
|
+
*/
|
|
278
|
+
export type CorePikkuSessionlessFunctionConfig<
|
|
279
|
+
PikkuFunction extends CorePikkuFunctionSessionless<any, any, any, any, any>,
|
|
280
|
+
PikkuPermission extends CorePikkuPermission<any, any, any> =
|
|
281
|
+
CorePikkuPermission<any>,
|
|
282
|
+
PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
|
|
283
|
+
any,
|
|
284
|
+
any
|
|
285
|
+
>,
|
|
286
|
+
InputSchema extends StandardSchemaV1 | undefined = undefined,
|
|
287
|
+
OutputSchema extends StandardSchemaV1 | undefined = undefined,
|
|
288
|
+
> = Omit<
|
|
289
|
+
CorePikkuFunctionConfig<
|
|
290
|
+
PikkuFunction,
|
|
291
|
+
PikkuPermission,
|
|
292
|
+
PikkuMiddleware,
|
|
293
|
+
InputSchema,
|
|
294
|
+
OutputSchema
|
|
295
|
+
>,
|
|
296
|
+
'scopes'
|
|
297
|
+
>
|
package/src/function/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -197,20 +197,23 @@ export type { SessionService } from './services/user-session-service.js'
|
|
|
197
197
|
export {
|
|
198
198
|
NoopAuditService,
|
|
199
199
|
createInvocationAudit,
|
|
200
|
-
resolveAuditActorFromWire,
|
|
201
200
|
resolveAuditConfig,
|
|
201
|
+
resolveAuditUserIdentityFromWire,
|
|
202
202
|
} from './services/audit-service.js'
|
|
203
203
|
export type {
|
|
204
|
-
AuditActor,
|
|
205
204
|
AuditConfig,
|
|
206
205
|
AuditDurability,
|
|
207
206
|
AuditEvent,
|
|
208
207
|
AuditEventBatch,
|
|
208
|
+
AuditFacets,
|
|
209
209
|
AuditLog,
|
|
210
210
|
AuditLogWriteInput,
|
|
211
211
|
AuditOutcome,
|
|
212
|
+
AuditQuery,
|
|
213
|
+
AuditQueryResult,
|
|
212
214
|
AuditService,
|
|
213
215
|
AuditSource,
|
|
216
|
+
AuditUserIdentity,
|
|
214
217
|
ResolvedAuditConfig,
|
|
215
218
|
} from './services/audit-service.js'
|
|
216
219
|
export type {
|
|
@@ -284,4 +287,23 @@ export type {
|
|
|
284
287
|
AnonymizeStrategy,
|
|
285
288
|
ColumnClassification,
|
|
286
289
|
ClassificationManifest,
|
|
290
|
+
ColumnForm,
|
|
291
|
+
WrappedValue,
|
|
292
|
+
SealedValue,
|
|
293
|
+
HashedValue,
|
|
287
294
|
} from './data-classification.js'
|
|
295
|
+
|
|
296
|
+
export {
|
|
297
|
+
hashToken,
|
|
298
|
+
unsafeAsWrapped,
|
|
299
|
+
unsafeAsSealed,
|
|
300
|
+
unsafeAsHashed,
|
|
301
|
+
} from './column-form.js'
|
|
302
|
+
|
|
303
|
+
export type { SecretValue, Safe } from './secret-value.js'
|
|
304
|
+
export {
|
|
305
|
+
createSecretValue,
|
|
306
|
+
isSecretValue,
|
|
307
|
+
SecretCoercionError,
|
|
308
|
+
REDACTED,
|
|
309
|
+
} from './secret-value.js'
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
createMiddlewareSessionWireProps,
|
|
9
9
|
} from '../services/user-session-service.js'
|
|
10
10
|
import { InvalidSessionError } from '../errors/errors.js'
|
|
11
|
+
import { createSecretValue } from '../secret-value.js'
|
|
11
12
|
|
|
12
13
|
beforeEach(() => {
|
|
13
14
|
resetPikkuState()
|
|
@@ -429,7 +430,7 @@ describe('authBearer middleware', () => {
|
|
|
429
430
|
const secrets = {
|
|
430
431
|
getSecret: async (secretId: string) => {
|
|
431
432
|
assert.equal(secretId, 'PIKKU_CONSOLE_TOKEN')
|
|
432
|
-
return 'secret-token-value'
|
|
433
|
+
return createSecretValue('secret-token-value')
|
|
433
434
|
},
|
|
434
435
|
}
|
|
435
436
|
|
|
@@ -500,7 +501,7 @@ describe('authBearer middleware', () => {
|
|
|
500
501
|
test('should not set session when secret-resolved token does not match', async () => {
|
|
501
502
|
const SessionService = new PikkuSessionService<CoreUserSession>()
|
|
502
503
|
const secrets = {
|
|
503
|
-
getSecret: async () => 'expected-token',
|
|
504
|
+
getSecret: async () => createSecretValue('expected-token'),
|
|
504
505
|
}
|
|
505
506
|
|
|
506
507
|
const middleware = authBearer({
|
|
@@ -55,9 +55,10 @@ export const authBearer = pikkuMiddlewareFactory<{
|
|
|
55
55
|
expected = token.value
|
|
56
56
|
} else {
|
|
57
57
|
// An unset secret means the feature is off — never a request error.
|
|
58
|
-
|
|
58
|
+
const stored = await secrets
|
|
59
59
|
?.getSecret<string>(token.secretId)
|
|
60
60
|
.catch(() => undefined)
|
|
61
|
+
expected = stored?.reveal()
|
|
61
62
|
}
|
|
62
63
|
if (expected && constantTimeEqual(bearerToken, expected)) {
|
|
63
64
|
userSession = token.userSession
|
|
@@ -4,6 +4,7 @@ import { pikkuRemoteAuthMiddleware } from './remote-auth.js'
|
|
|
4
4
|
import { UnauthorizedError } from '../errors/errors.js'
|
|
5
5
|
import { resetPikkuState } from '../pikku-state.js'
|
|
6
6
|
import { encryptWithKeyMaterial, REMOTE_SESSION_INFO } from '../crypto-utils.js'
|
|
7
|
+
import { createSecretValue } from '../secret-value.js'
|
|
7
8
|
|
|
8
9
|
beforeEach(() => {
|
|
9
10
|
resetPikkuState()
|
|
@@ -14,7 +15,7 @@ const TEST_SECRET = 'test-remote-secret-key-with-enough-entropy'
|
|
|
14
15
|
const createMockSecrets = (secret?: string) => ({
|
|
15
16
|
getSecret: async (key: string) => {
|
|
16
17
|
if (key === 'PIKKU_REMOTE_SECRET' && secret !== undefined) {
|
|
17
|
-
return secret
|
|
18
|
+
return createSecretValue(secret)
|
|
18
19
|
}
|
|
19
20
|
throw new Error(`Secret ${key} not found`)
|
|
20
21
|
},
|
|
@@ -14,7 +14,7 @@ export const pikkuRemoteAuthMiddleware = pikkuMiddleware(
|
|
|
14
14
|
|
|
15
15
|
let secret: string
|
|
16
16
|
try {
|
|
17
|
-
secret = await secrets.getSecret('PIKKU_REMOTE_SECRET')
|
|
17
|
+
secret = (await secrets.getSecret('PIKKU_REMOTE_SECRET')).reveal()
|
|
18
18
|
} catch {
|
|
19
19
|
if (http.request.path().startsWith('/remote/rpc/')) {
|
|
20
20
|
throw new UnauthorizedError()
|
package/src/remote.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { buildRemoteHeaders } from './remote.js'
|
|
|
4
4
|
import { pikkuRemoteAuthMiddleware } from './middleware/remote-auth.js'
|
|
5
5
|
import { resetPikkuState } from './pikku-state.js'
|
|
6
6
|
import { WeakKeyMaterialError } from './errors/errors.js'
|
|
7
|
+
import { createSecretValue } from './secret-value.js'
|
|
7
8
|
|
|
8
9
|
beforeEach(() => {
|
|
9
10
|
resetPikkuState()
|
|
@@ -14,7 +15,7 @@ const WEAK_SECRET = 'dev-remote-secret'
|
|
|
14
15
|
|
|
15
16
|
const createSecrets = (secret: string) => ({
|
|
16
17
|
getSecret: async (key: string) => {
|
|
17
|
-
if (key === 'PIKKU_REMOTE_SECRET') return secret
|
|
18
|
+
if (key === 'PIKKU_REMOTE_SECRET') return createSecretValue(secret)
|
|
18
19
|
throw new Error(`Secret ${key} not found`)
|
|
19
20
|
},
|
|
20
21
|
})
|
package/src/remote.ts
CHANGED
|
@@ -25,7 +25,7 @@ export async function buildRemoteHeaders(
|
|
|
25
25
|
|
|
26
26
|
let secret: string | undefined
|
|
27
27
|
try {
|
|
28
|
-
secret = await secrets?.getSecret('PIKKU_REMOTE_SECRET')
|
|
28
|
+
secret = (await secrets?.getSecret('PIKKU_REMOTE_SECRET'))?.reveal()
|
|
29
29
|
} catch {}
|
|
30
30
|
|
|
31
31
|
if (secret && jwt) {
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
createSecretValue,
|
|
5
|
+
isSecretValue,
|
|
6
|
+
REDACTED,
|
|
7
|
+
SecretCoercionError,
|
|
8
|
+
type Safe,
|
|
9
|
+
type SecretValue,
|
|
10
|
+
} from './secret-value.js'
|
|
11
|
+
import type { Logger } from './services/logger.js'
|
|
12
|
+
import type { AuditLog } from './services/audit-service.js'
|
|
13
|
+
|
|
14
|
+
const VALUE = 'sk-live-DEADBEEF'
|
|
15
|
+
|
|
16
|
+
describe('SecretValue', () => {
|
|
17
|
+
test('reveals the value', () => {
|
|
18
|
+
assert.equal(createSecretValue(VALUE).reveal(), VALUE)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('unwraps object secrets whole', () => {
|
|
22
|
+
const creds = createSecretValue({ apiKey: 'k', apiSecret: 's' })
|
|
23
|
+
assert.deepEqual(creds.reveal(), { apiKey: 'k', apiSecret: 's' })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('is recognisable at runtime', () => {
|
|
27
|
+
assert.equal(isSecretValue(createSecretValue(VALUE)), true)
|
|
28
|
+
assert.equal(
|
|
29
|
+
isSecretValue(() => VALUE),
|
|
30
|
+
false
|
|
31
|
+
)
|
|
32
|
+
assert.equal(isSecretValue(VALUE), false)
|
|
33
|
+
assert.equal(isSecretValue(undefined), false)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('structured serialization redacts', () => {
|
|
38
|
+
test('JSON.stringify renders [secret] rather than dropping the key', () => {
|
|
39
|
+
const event = {
|
|
40
|
+
type: 'fn.call',
|
|
41
|
+
input: { user: 'ada', token: createSecretValue(VALUE) },
|
|
42
|
+
}
|
|
43
|
+
assert.equal(
|
|
44
|
+
JSON.stringify(event),
|
|
45
|
+
'{"type":"fn.call","input":{"user":"ada","token":"[secret]"}}'
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('a bare secret serializes to [secret]', () => {
|
|
50
|
+
assert.equal(JSON.stringify(createSecretValue(VALUE)), `"${REDACTED}"`)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('node inspect renders [secret]', () => {
|
|
54
|
+
const inspect = Symbol.for('nodejs.util.inspect.custom')
|
|
55
|
+
const secret = createSecretValue(VALUE) as unknown as Record<
|
|
56
|
+
symbol,
|
|
57
|
+
() => string
|
|
58
|
+
>
|
|
59
|
+
assert.equal(secret[inspect]!(), REDACTED)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('structuredClone carries no secret material across the boundary', () => {
|
|
63
|
+
// The value lives in a private field, so a clone is an empty object rather
|
|
64
|
+
// than a copy — it crosses a worker or postMessage boundary carrying nothing.
|
|
65
|
+
const clone = structuredClone({ token: createSecretValue(VALUE) })
|
|
66
|
+
assert.equal(JSON.stringify(clone), '{"token":{}}')
|
|
67
|
+
assert.equal(Object.keys(clone.token).length, 0)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('string coercion throws', () => {
|
|
72
|
+
const secret = createSecretValue(VALUE)
|
|
73
|
+
|
|
74
|
+
test('template literal', () => {
|
|
75
|
+
assert.throws(() => `Bearer ${secret}`, SecretCoercionError)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('concatenation', () => {
|
|
79
|
+
assert.throws(() => ('Bearer ' + secret) as string, SecretCoercionError)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('String()', () => {
|
|
83
|
+
assert.throws(() => String(secret), SecretCoercionError)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('explicit toString()', () => {
|
|
87
|
+
assert.throws(
|
|
88
|
+
() => (secret as unknown as { toString(): string }).toString(),
|
|
89
|
+
SecretCoercionError
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('the thrown error never contains the secret', () => {
|
|
94
|
+
try {
|
|
95
|
+
String(secret)
|
|
96
|
+
assert.fail('expected a throw')
|
|
97
|
+
} catch (error) {
|
|
98
|
+
assert.equal((error as Error).message.includes(VALUE), false)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// ── Type-level assertions ────────────────────────────────────────────────────
|
|
104
|
+
// The point of the exercise: these fail `yarn tsc`, not `yarn test`. Held in a
|
|
105
|
+
// function that is never called so the runtime never evaluates them.
|
|
106
|
+
//
|
|
107
|
+
// `tsconfig.json` excludes `**/*.test.ts`, so this file is only reached through
|
|
108
|
+
// `tsconfig.type-tests.json`, which lists it under `files` — `exclude` filters
|
|
109
|
+
// `include` but not `files`. Both run from this package's `tsc` script. Without
|
|
110
|
+
// that second pass the assertions below type-check nothing at all.
|
|
111
|
+
|
|
112
|
+
const _typeAssertions = (
|
|
113
|
+
secret: SecretValue<string>,
|
|
114
|
+
creds: SecretValue<{ token: string }>,
|
|
115
|
+
anything: any,
|
|
116
|
+
sink: <T>(value: Safe<T>) => void,
|
|
117
|
+
secretPromise: Promise<SecretValue<string>>,
|
|
118
|
+
secretMap: Map<string, SecretValue<string>>,
|
|
119
|
+
secretSet: Set<SecretValue<string>>
|
|
120
|
+
) => {
|
|
121
|
+
// Nominal: not assignable to what it wraps.
|
|
122
|
+
// @ts-expect-error a secret is not a string
|
|
123
|
+
const _notAString: string = secret
|
|
124
|
+
void _notAString
|
|
125
|
+
|
|
126
|
+
// Unwrapping composes with plain types, so call sites need no cast.
|
|
127
|
+
const _unwrapped: string = secret.reveal()
|
|
128
|
+
const _unwrappedField: string = creds.reveal().token
|
|
129
|
+
void _unwrapped
|
|
130
|
+
void _unwrappedField
|
|
131
|
+
|
|
132
|
+
// @ts-expect-error a secret cannot reach a guarded sink
|
|
133
|
+
sink(secret)
|
|
134
|
+
// @ts-expect-error nor nested inside an object
|
|
135
|
+
sink({ config: { token: secret } })
|
|
136
|
+
// @ts-expect-error nor inside an array
|
|
137
|
+
sink([secret])
|
|
138
|
+
// @ts-expect-error nor as one branch of a union
|
|
139
|
+
sink(true ? secret : 'plain')
|
|
140
|
+
|
|
141
|
+
// Unwrapped values pass — disclosure is deliberate and visible.
|
|
142
|
+
sink(secret.reveal())
|
|
143
|
+
sink({ config: { token: secret.reveal() } })
|
|
144
|
+
|
|
145
|
+
// Ordinary values must not trip the guard.
|
|
146
|
+
sink('plain')
|
|
147
|
+
sink(42)
|
|
148
|
+
sink(null)
|
|
149
|
+
sink(undefined)
|
|
150
|
+
sink({ nested: { deep: [1, 2, 3] } })
|
|
151
|
+
sink(() => 'an ordinary function')
|
|
152
|
+
sink(new Date())
|
|
153
|
+
sink(new Error('boom'))
|
|
154
|
+
sink(Buffer.from('bytes'))
|
|
155
|
+
sink(new Map([['k', 'v']]))
|
|
156
|
+
sink([{ a: 1 }, { a: 2 }])
|
|
157
|
+
|
|
158
|
+
// A container is not a hiding place: a mapped type cannot reach what a
|
|
159
|
+
// `Promise`, `Map` or `Set` holds, so `Safe` recurses into them explicitly.
|
|
160
|
+
// @ts-expect-error nor awaiting later — `getSecret()` returns exactly this
|
|
161
|
+
sink(secretPromise)
|
|
162
|
+
// @ts-expect-error nor as a map value
|
|
163
|
+
sink(secretMap)
|
|
164
|
+
// @ts-expect-error nor as a set member
|
|
165
|
+
sink(secretSet)
|
|
166
|
+
|
|
167
|
+
// The same containers holding ordinary values must still pass.
|
|
168
|
+
sink(Promise.resolve('plain'))
|
|
169
|
+
sink(new Map([['k', 'v']]))
|
|
170
|
+
sink(new Set(['plain']))
|
|
171
|
+
|
|
172
|
+
// `any` cannot be guarded and must stay usable rather than collapsing to never.
|
|
173
|
+
sink(anything)
|
|
174
|
+
}
|
|
175
|
+
void _typeAssertions
|
|
176
|
+
|
|
177
|
+
// The assertions above prove `Safe<>` works against a stand-in sink. These prove
|
|
178
|
+
// the real interfaces are actually wired to it — the two can drift apart, and a
|
|
179
|
+
// sink that forgot the guard is exactly the leak this design exists to stop.
|
|
180
|
+
|
|
181
|
+
const _sinkAssertions = (
|
|
182
|
+
secret: SecretValue<string>,
|
|
183
|
+
logger: Logger,
|
|
184
|
+
auditLog: AuditLog
|
|
185
|
+
) => {
|
|
186
|
+
// @ts-expect-error a secret cannot be logged
|
|
187
|
+
logger.info(secret)
|
|
188
|
+
// @ts-expect-error nor nested in a log message
|
|
189
|
+
logger.info({ token: secret })
|
|
190
|
+
// @ts-expect-error nor in the log metadata
|
|
191
|
+
logger.info('using', { token: secret })
|
|
192
|
+
|
|
193
|
+
// @ts-expect-error nor written into an audit input
|
|
194
|
+
auditLog.write({ type: 'used', source: 'explicit', input: { token: secret } })
|
|
195
|
+
const metadata = { token: secret }
|
|
196
|
+
// @ts-expect-error nor into audit metadata
|
|
197
|
+
auditLog.write({ type: 'used', source: 'explicit', metadata })
|
|
198
|
+
|
|
199
|
+
// Ordinary logging and auditing must still compile.
|
|
200
|
+
logger.info({ msg: 'fine' })
|
|
201
|
+
logger.info('fine', { count: 1 })
|
|
202
|
+
auditLog.write({ type: 'used', source: 'explicit', input: { userId: 'u1' } })
|
|
203
|
+
}
|
|
204
|
+
void _sinkAssertions
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Secret } from './data-classification.js'
|
|
2
|
+
|
|
3
|
+
declare const secretValueBrand: unique symbol
|
|
4
|
+
|
|
5
|
+
export const REDACTED = '[secret]'
|
|
6
|
+
|
|
7
|
+
/** Runtime marker, resilient to duplicate copies of core. */
|
|
8
|
+
const SECRET_VALUE = Symbol.for('pikku.secretValue')
|
|
9
|
+
|
|
10
|
+
const NODE_INSPECT = Symbol.for('nodejs.util.inspect.custom')
|
|
11
|
+
|
|
12
|
+
export class SecretCoercionError extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super(
|
|
15
|
+
`A secret was coerced to a string, which would write it out in the clear. Unwrap it deliberately with .reveal() at the point it reaches the wire.`
|
|
16
|
+
)
|
|
17
|
+
this.name = 'SecretCoercionError'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A vault secret. Nominal, so it is not assignable to `string` and every
|
|
23
|
+
* concretely-typed sink rejects it; `.reveal()` is the one way out, and every
|
|
24
|
+
* call is a deliberate, greppable disclosure.
|
|
25
|
+
*
|
|
26
|
+
* The revealed value carries the erasable `Secret<T>` classification brand, so
|
|
27
|
+
* the inspector can still follow it one hop past the call.
|
|
28
|
+
*
|
|
29
|
+
* Structured serialization redacts to `[secret]` — an audit or log write must
|
|
30
|
+
* stay honest about the field without crashing the request. String coercion
|
|
31
|
+
* throws, because a template literal or concatenation is always a leak.
|
|
32
|
+
*/
|
|
33
|
+
export class SecretValue<T = string> {
|
|
34
|
+
declare readonly [secretValueBrand]: true
|
|
35
|
+
readonly [SECRET_VALUE] = true
|
|
36
|
+
|
|
37
|
+
readonly #value: T
|
|
38
|
+
|
|
39
|
+
constructor(value: T) {
|
|
40
|
+
this.#value = value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
reveal(): Secret<T> {
|
|
44
|
+
return this.#value as Secret<T>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
toJSON(): string {
|
|
48
|
+
return REDACTED
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
[NODE_INSPECT](): string {
|
|
52
|
+
return REDACTED
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
toString(): never {
|
|
56
|
+
throw new SecretCoercionError()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
[Symbol.toPrimitive](): never {
|
|
60
|
+
throw new SecretCoercionError()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const createSecretValue = <T>(value: T): SecretValue<T> =>
|
|
65
|
+
new SecretValue(value)
|
|
66
|
+
|
|
67
|
+
export const isSecretValue = (value: unknown): value is SecretValue<unknown> =>
|
|
68
|
+
typeof value === 'object' && value !== null && SECRET_VALUE in value
|
|
69
|
+
|
|
70
|
+
type IsAny<T> = 0 extends 1 & T ? true : false
|
|
71
|
+
|
|
72
|
+
type Passthrough =
|
|
73
|
+
| Function
|
|
74
|
+
| Date
|
|
75
|
+
| RegExp
|
|
76
|
+
| Error
|
|
77
|
+
| ArrayBuffer
|
|
78
|
+
| ArrayBufferView
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Rejects a `SecretValue` anywhere in `T`, however deeply nested, by collapsing
|
|
82
|
+
* it to `never`.
|
|
83
|
+
*
|
|
84
|
+
* For sinks whose parameters are `any`, `unknown` or a free generic — loggers,
|
|
85
|
+
* queue payloads, channel messages — where nominality alone cannot help.
|
|
86
|
+
* `any` is passed through untouched: it cannot be guarded, and collapsing it
|
|
87
|
+
* would reject every legitimate call.
|
|
88
|
+
*
|
|
89
|
+
* `Promise`, `Map` and `Set` are recursed into rather than passed through. A
|
|
90
|
+
* mapped type cannot reach what they hold — mapping their keys yields their
|
|
91
|
+
* methods, not their contents — so treating them as opaque let a secret ride
|
|
92
|
+
* through inside one. `Promise` is the case that bites: `getSecret()` returns
|
|
93
|
+
* `Promise<SecretValue<T>>`, so a forgotten `await` would otherwise log a
|
|
94
|
+
* secret.
|
|
95
|
+
*/
|
|
96
|
+
export type Safe<T> =
|
|
97
|
+
IsAny<T> extends true
|
|
98
|
+
? T
|
|
99
|
+
: [Extract<T, SecretValue<any>>] extends [never]
|
|
100
|
+
? T extends Promise<infer V>
|
|
101
|
+
? Promise<Safe<V>>
|
|
102
|
+
: T extends Map<infer K, infer V>
|
|
103
|
+
? Map<Safe<K>, Safe<V>>
|
|
104
|
+
: T extends Set<infer V>
|
|
105
|
+
? Set<Safe<V>>
|
|
106
|
+
: T extends Passthrough
|
|
107
|
+
? T
|
|
108
|
+
: T extends object
|
|
109
|
+
? { [K in keyof T]: Safe<T[K]> }
|
|
110
|
+
: T
|
|
111
|
+
: never
|