@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,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
@@ -4,6 +4,7 @@ import type {
4
4
  PikkuWiringTypes,
5
5
  } from '../types/core.types.js'
6
6
  import type { Logger } from './logger.js'
7
+ import type { Safe } from '../secret-value.js'
7
8
 
8
9
  export type AuditDurability = 'best-effort' | 'transactional'
9
10
  export type AuditOutcome = 'success' | 'failed' | 'denied'
@@ -19,7 +20,19 @@ export type ResolvedAuditConfig = {
19
20
  durability: AuditDurability
20
21
  }
21
22
 
22
- export type AuditActor = {
23
+ /**
24
+ * Who an event happened under.
25
+ *
26
+ * A user, not an "actor": in pikku an actor is a synthetic person a scenario
27
+ * drives, flagged `actor` on the user row, and the overwhelming majority of
28
+ * audited events are caused by ordinary customers. Naming this `actor` made the
29
+ * synthetic case unsayable — `actor.actor === true` — and implied every
30
+ * recorded action was a test.
31
+ *
32
+ * `pikkuUserId` is the identity pikku resolves for every wire, so it is the one
33
+ * field a signed-out caller still leaves behind.
34
+ */
35
+ export type AuditUserIdentity = {
23
36
  userId?: string
24
37
  orgId?: string
25
38
  pikkuUserId?: string
@@ -37,16 +50,67 @@ export type AuditEvent = {
37
50
  traceId?: string
38
51
  transactionId?: string | null
39
52
  queryId?: string | null
40
- actor?: AuditActor
53
+ userIdentity?: AuditUserIdentity
41
54
  input?: unknown
42
55
  metadata?: Record<string, unknown>
43
56
  }
44
57
 
45
58
  export type AuditEventBatch = AuditEvent[]
46
59
 
60
+ /**
61
+ * A page of the trail, newest first, narrowed by the filters a reader picked.
62
+ *
63
+ * Every field is a conjunction, and an empty array is not a filter — it is
64
+ * "match nothing", which would otherwise read as "match everything" and quietly
65
+ * widen a scoped query.
66
+ */
67
+ export type AuditQuery = {
68
+ /** Restrict to these users. */
69
+ userIds?: string[]
70
+ /** Restrict to these `AuditEvent['type']` values. */
71
+ types?: string[]
72
+ /** Restrict to one organisation. */
73
+ orgId?: string
74
+ /** Inclusive lower bound on `occurredAt` (ISO 8601). */
75
+ from?: string
76
+ /** Exclusive upper bound on `occurredAt` (ISO 8601). */
77
+ to?: string
78
+ limit?: number
79
+ offset?: number
80
+ }
81
+
82
+ /**
83
+ * The distinct values present in the trail, for populating filter controls.
84
+ *
85
+ * Computed over the whole trail rather than the current page — a filter list
86
+ * that only offered what the current page happens to show could never be used
87
+ * to reach anything else.
88
+ */
89
+ export type AuditFacets = {
90
+ userIds: string[]
91
+ types: string[]
92
+ }
93
+
94
+ export type AuditQueryResult = {
95
+ events: AuditEvent[]
96
+ /** Offset of the next page, or `null` at the end. */
97
+ nextCursor: number | null
98
+ /** Present only when the caller asked for it — it costs two extra scans. */
99
+ facets?: AuditFacets
100
+ }
101
+
47
102
  export interface AuditService {
48
103
  audit(event: AuditEvent): Promise<void>
49
104
  write?(batch: AuditEventBatch): Promise<void>
105
+ /**
106
+ * The read side. Optional because a sink can legitimately be write-only — a
107
+ * queue producer that hands events to another system has nothing to read
108
+ * back. A reader that finds this absent should say the trail is not readable
109
+ * here rather than that it is empty; the two are very different answers.
110
+ */
111
+ query?(query: AuditQuery): Promise<AuditQueryResult>
112
+ /** Distinct users and types across the whole trail. Paired with {@link query}. */
113
+ facets?(): Promise<AuditFacets>
50
114
  }
51
115
 
52
116
  export class NoopAuditService implements AuditService {
@@ -57,9 +121,18 @@ export class NoopAuditService implements AuditService {
57
121
 
58
122
  export type AuditLogWriteInput = Omit<AuditEvent, 'occurredAt'>
59
123
 
124
+ /**
125
+ * The audit an invocation writes to. `write` is `Safe<>`-guarded like the
126
+ * logger: an audit event carries `input` and `metadata` as `unknown`, so
127
+ * nominality alone cannot stop a `SecretValue` landing in one. A secret
128
+ * anywhere in the event, however deeply nested, collapses to `never`.
129
+ *
130
+ * An unrevealed `SecretValue` would serialize as `[secret]` anyway; the guard
131
+ * is what makes that an explicit choice rather than a near miss.
132
+ */
60
133
  export interface AuditLog {
61
134
  readonly config: ResolvedAuditConfig | undefined
62
- write(event: AuditLogWriteInput): Promise<void>
135
+ write<E extends AuditLogWriteInput>(event: Safe<E>): Promise<void>
63
136
  flush(): Promise<void>
64
137
  close(): Promise<void>
65
138
  }
@@ -149,7 +222,8 @@ class InvocationAuditLog implements AuditLog {
149
222
  wireType: this.wire.wireType,
150
223
  wireId: this.wire.wireId,
151
224
  traceId: this.wire.traceId,
152
- actor: event.actor ?? resolveAuditActorFromWire(this.wire),
225
+ userIdentity:
226
+ event.userIdentity ?? resolveAuditUserIdentityFromWire(this.wire),
153
227
  ...event,
154
228
  occurredAt: new Date().toISOString(),
155
229
  }
@@ -184,19 +258,23 @@ export const createInvocationAudit = (
184
258
  return new InvocationAuditLog(wire.audit, service, wire, logger)
185
259
  }
186
260
 
187
- export const resolveAuditActorFromWire = (
261
+ export const resolveAuditUserIdentityFromWire = (
188
262
  wire: PikkuWire<any, any, any, CoreUserSession>
189
- ): AuditActor | undefined => {
263
+ ): AuditUserIdentity | undefined => {
190
264
  const session = wire.session as CoreUserSession | undefined
191
- const actor: AuditActor = {
265
+ const userIdentity: AuditUserIdentity = {
192
266
  userId: session?.userId,
193
267
  orgId: session?.orgId,
194
268
  pikkuUserId: wire.pikkuUserId,
195
269
  }
196
270
 
197
- if (!actor.userId && !actor.orgId && !actor.pikkuUserId) {
271
+ if (
272
+ !userIdentity.userId &&
273
+ !userIdentity.orgId &&
274
+ !userIdentity.pikkuUserId
275
+ ) {
198
276
  return undefined
199
277
  }
200
278
 
201
- return actor
279
+ return userIdentity
202
280
  }
@@ -1,3 +1,4 @@
1
+ import { isSecretValue } from '../secret-value.js'
1
2
  import type { CredentialService } from './credential-service.js'
2
3
  import { defaultPikkuUserIdResolver } from './pikku-user-id.js'
3
4
  import type { PikkuRawWire } from '../types/core.types.js'
@@ -17,8 +18,15 @@ export class PikkuCredentialWireService {
17
18
  return this.aliases?.[name] ?? name
18
19
  }
19
20
 
21
+ /**
22
+ * A credential is one of the few places vault material is meant to end up, so
23
+ * a `SecretValue` is unwrapped here rather than rejected — `get` promises the
24
+ * raw material, and storing the wrapper would make that a lie.
25
+ */
20
26
  set(name: string, value: unknown): void {
21
- this.credentials[this.resolveName(name)] = value
27
+ this.credentials[this.resolveName(name)] = isSecretValue(value)
28
+ ? value.reveal()
29
+ : value
22
30
  }
23
31
 
24
32
  get<T = unknown>(name: string): T | null | Promise<T | null> {
@@ -1,3 +1,5 @@
1
+ import type { Safe } from '../secret-value.js'
2
+
1
3
  export interface EmailTemplateReference {
2
4
  name: string
3
5
  locale?: string
@@ -42,5 +44,5 @@ export interface SendEmailResult {
42
44
  }
43
45
 
44
46
  export interface EmailService {
45
- send(input: SendEmailInput): Promise<SendEmailResult>
47
+ send<T extends SendEmailInput>(input: Safe<T>): Promise<SendEmailResult>
46
48
  }
@@ -301,6 +301,35 @@ export class InMemoryWorkflowService
301
301
  return newStep
302
302
  }
303
303
 
304
+ protected async findStalledRunIds(
305
+ before: Date,
306
+ limit: number
307
+ ): Promise<string[]> {
308
+ const stalled: string[] = []
309
+ for (const [runId, run] of this.runs) {
310
+ if (run.status !== 'running') continue
311
+ const steps = this.stepHistory.get(runId) ?? []
312
+ if (
313
+ steps.some(
314
+ (step) =>
315
+ step.status === 'running' ||
316
+ step.status === 'scheduled' ||
317
+ step.status === 'suspended'
318
+ )
319
+ ) {
320
+ continue
321
+ }
322
+ const lastActivity = steps.reduce(
323
+ (latest, step) => (step.updatedAt > latest ? step.updatedAt : latest),
324
+ run.updatedAt
325
+ )
326
+ if (lastActivity >= before) continue
327
+ stalled.push(runId)
328
+ if (stalled.length >= limit) break
329
+ }
330
+ return stalled
331
+ }
332
+
304
333
  async listRuns(options?: {
305
334
  workflowName?: string
306
335
  status?: string
@@ -75,7 +75,7 @@ export {
75
75
  type WebhookServiceConfig,
76
76
  } from './webhook-service.js'
77
77
  export type { Logger } from './logger.js'
78
- export type { SecretService } from './secret-service.js'
78
+ export type { SecretService, SecretValues } from './secret-service.js'
79
79
  export type { VariablesService } from './variables-service.js'
80
80
  export type { SchemaService } from './schema-service.js'
81
81
  export type { SessionService } from './user-session-service.js'
@@ -141,11 +141,10 @@ export type { IsSystemRole } from './system-role-guard.js'
141
141
  export {
142
142
  NoopAuditService,
143
143
  createInvocationAudit,
144
- resolveAuditActorFromWire,
145
144
  resolveAuditConfig,
145
+ resolveAuditUserIdentityFromWire,
146
146
  } from './audit-service.js'
147
147
  export type {
148
- AuditActor,
149
148
  AuditConfig,
150
149
  AuditDurability,
151
150
  AuditEvent,
@@ -155,6 +154,7 @@ export type {
155
154
  AuditOutcome,
156
155
  AuditService,
157
156
  AuditSource,
157
+ AuditUserIdentity,
158
158
  ResolvedAuditConfig,
159
159
  } from './audit-service.js'
160
160
  export { InMemorySessionStore } from './in-memory-session-store.js'