@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
@@ -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 ? Promise<Out> : Promise<Out> | Promise<void>
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 ? Promise<Out> : Promise<Out> | Promise<void>
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 function performs its own authorization in its body — verifying a
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 carrying no session, scope or permission.
202
+ * it is not open despite declaring no session, scope or permission.
198
203
  *
199
- * Purely declarative: nothing at runtime reads it, and it grants nothing. It
200
- * exists so the claim is written down where an audit can find it, and so
201
- * codegen stops warning about a gate it has no way to see. Asserting it
202
- * falsely disables the one check that would have caught the mistake.
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
- selfAuthenticated?: boolean
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
+ >
@@ -9,6 +9,7 @@ export type {
9
9
  CorePikkuFunction,
10
10
  CorePikkuFunctionSessionless,
11
11
  CorePikkuFunctionConfig,
12
+ CorePikkuSessionlessFunctionConfig,
12
13
  CorePikkuAuth,
13
14
  CorePikkuAuthConfig,
14
15
  CorePikkuPermission,
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
- expected = await secrets
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()
@@ -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