@pikku/core 0.12.74 → 0.12.78

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 (150) hide show
  1. package/CHANGELOG.md +397 -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/in-memory-workflow-service.d.ts +1 -0
  22. package/dist/services/in-memory-workflow-service.js +20 -0
  23. package/dist/services/index.d.ts +3 -3
  24. package/dist/services/index.js +1 -1
  25. package/dist/services/local-content-request-handler.d.ts +29 -0
  26. package/dist/services/local-content-request-handler.js +176 -0
  27. package/dist/services/local-secrets.d.ts +4 -3
  28. package/dist/services/local-secrets.js +7 -3
  29. package/dist/services/logger.d.ts +22 -5
  30. package/dist/services/queue-webhook-service.js +1 -1
  31. package/dist/services/scoped-secret-service.d.ts +4 -3
  32. package/dist/services/secret-service.d.ts +8 -3
  33. package/dist/services/typed-secret-service.d.ts +5 -4
  34. package/dist/services/webhook-service.d.ts +2 -1
  35. package/dist/testing/service-tests.js +6 -6
  36. package/dist/types/core.types.d.ts +25 -4
  37. package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
  38. package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
  39. package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
  40. package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
  41. package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
  42. package/dist/wirings/ai-agent/index.d.ts +1 -1
  43. package/dist/wirings/ai-agent/index.js +1 -1
  44. package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
  45. package/dist/wirings/ai-agent/voice-input.js +44 -9
  46. package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
  47. package/dist/wirings/ai-agent/voice-output.js +10 -1
  48. package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
  49. package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
  50. package/dist/wirings/cli/channel/index.d.ts +1 -0
  51. package/dist/wirings/persona/define-personas.d.ts +4 -0
  52. package/dist/wirings/persona/define-personas.js +4 -0
  53. package/dist/wirings/persona/persona.types.d.ts +11 -0
  54. package/dist/wirings/queue/queue-identity.js +2 -1
  55. package/dist/wirings/queue/queue.types.d.ts +2 -1
  56. package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
  57. package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
  58. package/dist/wirings/rpc/remote-addon-auth.js +6 -2
  59. package/dist/wirings/virtual-user/index.d.ts +3 -0
  60. package/dist/wirings/virtual-user/index.js +2 -0
  61. package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
  62. package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
  63. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
  64. package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
  65. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
  66. package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
  67. package/dist/wirings/workflow/pikku-workflow-service.d.ts +35 -0
  68. package/dist/wirings/workflow/pikku-workflow-service.js +51 -0
  69. package/dist/wirings/workflow/scenario-prose.js +1 -1
  70. package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
  71. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  72. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
  73. package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
  74. package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
  75. package/knowledge/decisions/internals/index.md +4 -0
  76. package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
  77. package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
  78. package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
  79. package/package.json +3 -2
  80. package/src/column-form.test.ts +97 -0
  81. package/src/column-form.ts +58 -0
  82. package/src/crypto-utils.ts +25 -6
  83. package/src/data-classification.ts +44 -0
  84. package/src/function/functions.types.ts +47 -10
  85. package/src/function/index.ts +1 -0
  86. package/src/index.ts +24 -2
  87. package/src/middleware/auth-bearer.test.ts +3 -2
  88. package/src/middleware/auth-bearer.ts +2 -1
  89. package/src/middleware/remote-auth.test.ts +2 -1
  90. package/src/middleware/remote-auth.ts +1 -1
  91. package/src/remote.test.ts +2 -1
  92. package/src/remote.ts +1 -1
  93. package/src/secret-value.test.ts +204 -0
  94. package/src/secret-value.ts +111 -0
  95. package/src/services/audit-service.ts +87 -9
  96. package/src/services/credential-wire-service.ts +9 -1
  97. package/src/services/email-service.ts +3 -1
  98. package/src/services/in-memory-workflow-service.ts +29 -0
  99. package/src/services/index.ts +3 -3
  100. package/src/services/local-content-request-handler.test.ts +202 -0
  101. package/src/services/local-content-request-handler.ts +267 -0
  102. package/src/services/local-secrets.test.ts +20 -5
  103. package/src/services/local-secrets.ts +15 -7
  104. package/src/services/logger.ts +27 -7
  105. package/src/services/queue-webhook-service.test.ts +2 -1
  106. package/src/services/queue-webhook-service.ts +1 -1
  107. package/src/services/scoped-secret-service.ts +4 -3
  108. package/src/services/secret-service.ts +8 -3
  109. package/src/services/typed-secret-service.ts +11 -7
  110. package/src/services/webhook-service.ts +4 -1
  111. package/src/testing/service-tests.ts +6 -6
  112. package/src/types/core.types.ts +25 -4
  113. package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
  114. package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
  115. package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
  116. package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
  117. package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
  118. package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
  119. package/src/wirings/ai-agent/index.ts +2 -0
  120. package/src/wirings/ai-agent/voice-input.test.ts +65 -0
  121. package/src/wirings/ai-agent/voice-input.ts +48 -9
  122. package/src/wirings/ai-agent/voice-output.test.ts +91 -1
  123. package/src/wirings/ai-agent/voice-output.ts +28 -1
  124. package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
  125. package/src/wirings/cli/channel/index.ts +4 -0
  126. package/src/wirings/persona/define-personas.ts +4 -0
  127. package/src/wirings/persona/persona.types.ts +11 -0
  128. package/src/wirings/queue/queue-identity.test.ts +2 -1
  129. package/src/wirings/queue/queue-identity.ts +4 -1
  130. package/src/wirings/queue/queue.types.ts +6 -1
  131. package/src/wirings/queue/signed-queue-service.ts +2 -1
  132. package/src/wirings/rpc/remote-addon-auth.ts +8 -3
  133. package/src/wirings/rpc/rpc-runner.test.ts +6 -4
  134. package/src/wirings/virtual-user/index.ts +12 -0
  135. package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
  136. package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
  137. package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
  138. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
  139. package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
  140. package/src/wirings/workflow/pikku-workflow-service.ts +67 -0
  141. package/src/wirings/workflow/scenario-prose.test.ts +5 -7
  142. package/src/wirings/workflow/scenario-prose.ts +1 -1
  143. package/src/wirings/workflow/scenario-service.test.ts +0 -1
  144. package/src/wirings/workflow/scenario-step.test.ts +4 -5
  145. package/src/wirings/workflow/scenario-step.types.ts +13 -7
  146. package/src/wirings/workflow/scenario-surface.test.ts +6 -5
  147. package/src/wirings/workflow/workflow-stalled-recovery.test.ts +106 -0
  148. package/src/wirings/workflow/workflow.types.ts +7 -0
  149. package/tsconfig.tsbuildinfo +1 -1
  150. package/tsconfig.type-tests.json +12 -0
@@ -0,0 +1,43 @@
1
+ ---
2
+ type: decision
3
+ title: Both validators check personas through one shared module, and a persona counts from either the meta or the source
4
+ description: workspace validate and fabric validate are separate implementations, so the persona/actor/knowledge checks live in persona-checks.ts and are called by both — and a persona is counted from the generated meta or a definePersonas() source scan, whichever answers first
5
+ tags: cli, validate, scenarios, personas
6
+ ---
7
+
8
+ # Both validators check personas through one shared module, and a persona counts from either the meta or the source
9
+
10
+ `pikku workspace validate` and `pikku fabric validate` read like one command
11
+ with a flag. They are not. They are two implementations that walk the same
12
+ project and duplicate about eighteen findings between them verbatim —
13
+ `functions-dir-missing` exists twice, character for character, in two files.
14
+ Fabric adds the deploy-shaped checks (themes, frontends, the Cloudflare
15
+ adapter, the `.gitignore` contract); workspace adds the local-development ones
16
+ (`dev.db`, the auth migrations).
17
+
18
+ So a check written into either one is a check half the projects never run. An
19
+ app that never deploys through fabric only ever sees workspace validate; an app
20
+ that does sees fabric validate in CI. The persona checks are the kind that only
21
+ pay off when nobody remembers to ask for them, which means they had to be in
22
+ both. Rather than duplicate them a nineteenth time, they live in
23
+ `persona-checks.ts` and both validators call `runPersonaChecks`. Merging the
24
+ two validators outright is the better fix and a much larger one; this does not
25
+ block it.
26
+
27
+ **A persona counts from either source, and either alone is wrong at a
28
+ predictable moment.** The generated `pikku-personas-meta.gen.json` is empty on
29
+ a fresh clone where codegen has not run, so trusting only it means a warning
30
+ that fires on every new checkout — and a validator that cries wolf gets muted,
31
+ after which it never says anything worth hearing. A source scan for
32
+ `definePersonas(` cannot see personas an addon contributed. Declared by either
33
+ counts as declared.
34
+
35
+ **Everything here is `warn`, never `error`.** A project with no personas is
36
+ under-tested, not broken, and `validate` gating a deploy on it would make the
37
+ first thing anyone does with the check be to remove it. `knowledge-empty` stays
38
+ at the `info` the shared `@pikku/knowledge` package already assigned it rather
39
+ than being raised to match — the severity belongs to that package.
40
+
41
+ **What this rules out:** duplicating the checks into both validators;
42
+ adding them to only one; deriving the persona count from the generated meta
43
+ alone; failing a build over a missing persona.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.74",
3
+ "version": "0.12.78",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  "main": "dist/index.js",
9
9
  "type": "module",
10
10
  "scripts": {
11
- "tsc": "tsc",
11
+ "tsc": "tsc && tsc -p tsconfig.type-tests.json",
12
12
  "build": "tsc -b",
13
13
  "ncu": "npx npm-check-updates",
14
14
  "release": "npm run build && npm test",
@@ -57,6 +57,7 @@
57
57
  "./services/v8-coverage": "./dist/services/v8-coverage-service.js",
58
58
  "./services/istanbul-coverage": "./dist/services/istanbul-coverage-service.js",
59
59
  "./services/local-content": "./dist/services/local-content.js",
60
+ "./services/local-content-request-handler": "./dist/services/local-content-request-handler.js",
60
61
  "./services/temporary-file-service": "./dist/services/temporary-file-service.js",
61
62
  "./crypto-utils": "./dist/crypto-utils.js",
62
63
  "./hmac": "./dist/utils/hmac.js",
@@ -0,0 +1,97 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {
4
+ hashToken,
5
+ unsafeAsHashed,
6
+ unsafeAsSealed,
7
+ unsafeAsWrapped,
8
+ } from './column-form.js'
9
+ import type {
10
+ HashedValue,
11
+ SealedValue,
12
+ WrappedValue,
13
+ } from './data-classification.js'
14
+ import { deriveKEK, envelopeEncrypt, envelopeRewrap } from './crypto-utils.js'
15
+
16
+ const KEY_MATERIAL = 'x'.repeat(32)
17
+
18
+ describe('hashToken', () => {
19
+ test('is the sha256 of the input, lowercase hex', async () => {
20
+ // Known vector, so a refactor of the hex encoding cannot quietly change
21
+ // what every stored hash in every downstream database means.
22
+ assert.equal(
23
+ await hashToken('abc'),
24
+ 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
25
+ )
26
+ })
27
+
28
+ test('is stable across calls', async () => {
29
+ assert.equal(await hashToken('token'), await hashToken('token'))
30
+ })
31
+
32
+ test('separates different inputs', async () => {
33
+ assert.notEqual(await hashToken('a'), await hashToken('b'))
34
+ })
35
+ })
36
+
37
+ describe('the form brands', () => {
38
+ test('a plain string is not assignable to any of them', () => {
39
+ // @ts-expect-error plaintext cannot be written to a wrapped column
40
+ const wrapped: WrappedValue = 'not-ciphertext'
41
+ // @ts-expect-error plaintext cannot be written to a sealed column
42
+ const sealed: SealedValue = 'not-ciphertext'
43
+ // @ts-expect-error a raw token cannot be written where its hash belongs
44
+ const hashed: HashedValue = 'raw-token'
45
+ assert.ok(wrapped && sealed && hashed)
46
+ })
47
+
48
+ test('the three do not substitute for one another', () => {
49
+ const wrapped = unsafeAsWrapped('ct')
50
+ // @ts-expect-error a value fabric cannot read is not one it can
51
+ const asWrapped: WrappedValue = unsafeAsSealed('ct')
52
+ // @ts-expect-error a digest is not ciphertext
53
+ const asHashed: HashedValue = wrapped
54
+ assert.ok(asWrapped && asHashed)
55
+ })
56
+
57
+ test('but each still widens to string, so reads and query operands are unaffected', () => {
58
+ const asString: string = unsafeAsWrapped('ct')
59
+ assert.equal(asString, 'ct')
60
+ assert.equal(`${unsafeAsHashed('abc')}`, 'abc')
61
+ })
62
+ })
63
+
64
+ describe('the envelope primitives produce the brand', () => {
65
+ test('envelopeEncrypt returns ciphertext that is writable to a wrapped column', async () => {
66
+ const kek = await deriveKEK(KEY_MATERIAL, 'c2FsdA')
67
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(kek, 'value')
68
+ // No cast: this is the whole mechanism — the only way to satisfy the
69
+ // column type is to have actually encrypted something.
70
+ const forColumn: WrappedValue = ciphertext
71
+ const dekForColumn: WrappedValue = wrappedDEK
72
+ assert.ok(forColumn.length > 0 && dekForColumn.length > 0)
73
+ })
74
+
75
+ test('a rewrapped DEK is still writable, so rotation needs no escape hatch', async () => {
76
+ const oldKek = await deriveKEK(KEY_MATERIAL, 'c2FsdA')
77
+ const newKek = await deriveKEK('y'.repeat(32), 'c2FsdA')
78
+ const { wrappedDEK } = await envelopeEncrypt(oldKek, 'value')
79
+ const rewrapped: WrappedValue = await envelopeRewrap(
80
+ oldKek,
81
+ newKek,
82
+ wrappedDEK
83
+ )
84
+ assert.notEqual(rewrapped, wrappedDEK)
85
+ })
86
+
87
+ test('decrypting accepts a bare string, so reading a row back needs no cast', async () => {
88
+ const kek = await deriveKEK(KEY_MATERIAL, 'c2FsdA')
89
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(kek, 'value')
90
+ const { envelopeDecrypt } = await import('./crypto-utils.js')
91
+ const fromRow: string = ciphertext
92
+ assert.equal(
93
+ await envelopeDecrypt<string>(kek, fromRow, wrappedDEK),
94
+ 'value'
95
+ )
96
+ })
97
+ })
@@ -0,0 +1,58 @@
1
+ // knowledge: decisions/internals/core-column-form-is-an-axis-of-its-own.md
2
+ import type {
3
+ HashedValue,
4
+ SealedValue,
5
+ WrappedValue,
6
+ } from './data-classification.js'
7
+
8
+ /**
9
+ * SHA-256 of a credential, lowercase hex — the sole producer of `HashedValue`.
10
+ *
11
+ * Deliberately unpeppered. These are high-entropy random bearer tokens, not
12
+ * passwords: there is no candidate set to search, so a pepper would add a key
13
+ * to manage and a rotation story to own while buying nothing. A column holding
14
+ * a *low*-entropy secret is not a `hashed` column — it wants a password KDF and
15
+ * a different type entirely.
16
+ *
17
+ * Async because it runs on WebCrypto rather than `node:crypto`, so the same
18
+ * code path works in a Worker. Callers that hash a bearer token on every
19
+ * request are already async.
20
+ */
21
+ export const hashToken = async (raw: string): Promise<HashedValue> => {
22
+ const subtle = globalThis.crypto?.subtle
23
+ if (!subtle) {
24
+ throw new Error('WebCrypto not available')
25
+ }
26
+ const digest = await subtle.digest(
27
+ 'SHA-256',
28
+ new TextEncoder().encode(raw)
29
+ )
30
+ let hex = ''
31
+ for (const byte of new Uint8Array(digest)) {
32
+ hex += byte.toString(16).padStart(2, '0')
33
+ }
34
+ return hex as HashedValue
35
+ }
36
+
37
+ /**
38
+ * Assert that a string already in storage is ciphertext of the given form.
39
+ *
40
+ * The brands exist so that new writes must come from a real encrypt/hash call,
41
+ * but three paths legitimately hold such a value as a bare `string`: a
42
+ * migration backfilling rows written before the column declared its form, a
43
+ * test fixture, and a value arriving over the wire from a service that sealed
44
+ * it elsewhere. Each of those is a promise the caller is making, not something
45
+ * the type system can check — which is why these are named to be greppable and
46
+ * why there is no non-`unsafe` spelling. If one of these appears in ordinary
47
+ * request-handling code, that is the bug.
48
+ */
49
+ export const unsafeAsWrapped = (stored: string): WrappedValue =>
50
+ stored as WrappedValue
51
+
52
+ /** See `unsafeAsWrapped`. */
53
+ export const unsafeAsSealed = (stored: string): SealedValue =>
54
+ stored as SealedValue
55
+
56
+ /** See `unsafeAsWrapped`. */
57
+ export const unsafeAsHashed = (stored: string): HashedValue =>
58
+ stored as HashedValue
@@ -1,3 +1,4 @@
1
+ import type { WrappedValue } from './data-classification.js'
1
2
  import { WeakKeyMaterialError } from './errors/errors.js'
2
3
 
3
4
  const encoder = new TextEncoder()
@@ -373,11 +374,19 @@ export const deriveKEK = async (
373
374
  return deriveKey(passphrase, fromBase64Url(salt))
374
375
  }
375
376
 
377
+ /**
378
+ * The one place a `WrappedValue` is minted. Every branded return below routes
379
+ * through here, so the assertion "these bytes really are ciphertext" is made
380
+ * once and audited once instead of at each call site.
381
+ */
382
+ const asWrapped = (ciphertext: string): WrappedValue =>
383
+ ciphertext as WrappedValue
384
+
376
385
  export const wrapDEK = async (
377
386
  kek: CryptoKey,
378
387
  plaintextDEK: string
379
- ): Promise<string> => {
380
- return encryptWithCryptoKey(kek, plaintextDEK)
388
+ ): Promise<WrappedValue> => {
389
+ return asWrapped(await encryptWithCryptoKey(kek, plaintextDEK))
381
390
  }
382
391
 
383
392
  export const unwrapDEK = async (
@@ -408,8 +417,8 @@ const decryptWithDEK = async <T>(
408
417
  }
409
418
 
410
419
  export interface EnvelopeEncryptResult {
411
- ciphertext: string
412
- wrappedDEK: string
420
+ ciphertext: WrappedValue
421
+ wrappedDEK: WrappedValue
413
422
  }
414
423
 
415
424
  export const envelopeEncrypt = async (
@@ -417,11 +426,21 @@ export const envelopeEncrypt = async (
417
426
  value: unknown
418
427
  ): Promise<EnvelopeEncryptResult> => {
419
428
  const dek = await generateDEK()
420
- const ciphertext = await encryptWithDEK(dek, value)
429
+ const ciphertext = asWrapped(await encryptWithDEK(dek, value))
421
430
  const wrappedDEK = await wrapDEK(kek, dek)
422
431
  return { ciphertext, wrappedDEK }
423
432
  }
424
433
 
434
+ /**
435
+ * Note the inputs are plain `string`, not `WrappedValue`.
436
+ *
437
+ * The brand exists to stop plaintext being *written* to a wrapped column, and
438
+ * a `WrappedValue` is assignable to `string`, so a branded caller still passes
439
+ * without a cast. Demanding the brand here would buy nothing — feeding the
440
+ * wrong string in already fails at the AEAD tag — while forcing a cast into
441
+ * every path that reads ciphertext back out of a row, a parsed envelope, or the
442
+ * wire, which is exactly where casts are least reviewable.
443
+ */
425
444
  export const envelopeDecrypt = async <T>(
426
445
  kek: CryptoKey,
427
446
  ciphertext: string,
@@ -435,7 +454,7 @@ export const envelopeRewrap = async (
435
454
  oldKEK: CryptoKey,
436
455
  newKEK: CryptoKey,
437
456
  wrappedDEK: string
438
- ): Promise<string> => {
457
+ ): Promise<WrappedValue> => {
439
458
  const dek = await unwrapDEK(oldKEK, wrappedDEK)
440
459
  return wrapDEK(newKEK, dek)
441
460
  }
@@ -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) {