@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.
- package/CHANGELOG.md +397 -0
- package/dist/column-form.d.ts +32 -0
- package/dist/column-form.js +42 -0
- package/dist/crypto-utils.d.ts +15 -4
- package/dist/crypto-utils.js +18 -2
- package/dist/data-classification.d.ts +44 -0
- package/dist/function/functions.types.d.ts +23 -10
- package/dist/function/index.d.ts +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/middleware/auth-bearer.js +2 -1
- package/dist/middleware/remote-auth.js +1 -1
- package/dist/remote.js +1 -1
- package/dist/secret-value.d.ts +56 -0
- package/dist/secret-value.js +46 -0
- package/dist/services/audit-service.d.ts +74 -4
- package/dist/services/audit-service.js +7 -5
- package/dist/services/credential-wire-service.d.ts +5 -0
- package/dist/services/credential-wire-service.js +9 -1
- package/dist/services/email-service.d.ts +2 -1
- package/dist/services/in-memory-workflow-service.d.ts +1 -0
- package/dist/services/in-memory-workflow-service.js +20 -0
- package/dist/services/index.d.ts +3 -3
- package/dist/services/index.js +1 -1
- package/dist/services/local-content-request-handler.d.ts +29 -0
- package/dist/services/local-content-request-handler.js +176 -0
- package/dist/services/local-secrets.d.ts +4 -3
- package/dist/services/local-secrets.js +7 -3
- package/dist/services/logger.d.ts +22 -5
- package/dist/services/queue-webhook-service.js +1 -1
- package/dist/services/scoped-secret-service.d.ts +4 -3
- package/dist/services/secret-service.d.ts +8 -3
- package/dist/services/typed-secret-service.d.ts +5 -4
- package/dist/services/webhook-service.d.ts +2 -1
- package/dist/testing/service-tests.js +6 -6
- package/dist/types/core.types.d.ts +25 -4
- package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
- package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
- package/dist/wirings/ai-agent/voice-input.js +44 -9
- package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
- package/dist/wirings/ai-agent/voice-output.js +10 -1
- package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
- package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
- package/dist/wirings/cli/channel/index.d.ts +1 -0
- package/dist/wirings/persona/define-personas.d.ts +4 -0
- package/dist/wirings/persona/define-personas.js +4 -0
- package/dist/wirings/persona/persona.types.d.ts +11 -0
- package/dist/wirings/queue/queue-identity.js +2 -1
- package/dist/wirings/queue/queue.types.d.ts +2 -1
- package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.js +6 -2
- package/dist/wirings/virtual-user/index.d.ts +3 -0
- package/dist/wirings/virtual-user/index.js +2 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
- package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +35 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +51 -0
- package/dist/wirings/workflow/scenario-prose.js +1 -1
- package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
- package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
- package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
- package/knowledge/decisions/internals/index.md +4 -0
- package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
- package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
- package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
- package/package.json +3 -2
- package/src/column-form.test.ts +97 -0
- package/src/column-form.ts +58 -0
- package/src/crypto-utils.ts +25 -6
- package/src/data-classification.ts +44 -0
- package/src/function/functions.types.ts +47 -10
- package/src/function/index.ts +1 -0
- package/src/index.ts +24 -2
- package/src/middleware/auth-bearer.test.ts +3 -2
- package/src/middleware/auth-bearer.ts +2 -1
- package/src/middleware/remote-auth.test.ts +2 -1
- package/src/middleware/remote-auth.ts +1 -1
- package/src/remote.test.ts +2 -1
- package/src/remote.ts +1 -1
- package/src/secret-value.test.ts +204 -0
- package/src/secret-value.ts +111 -0
- package/src/services/audit-service.ts +87 -9
- package/src/services/credential-wire-service.ts +9 -1
- package/src/services/email-service.ts +3 -1
- package/src/services/in-memory-workflow-service.ts +29 -0
- package/src/services/index.ts +3 -3
- package/src/services/local-content-request-handler.test.ts +202 -0
- package/src/services/local-content-request-handler.ts +267 -0
- package/src/services/local-secrets.test.ts +20 -5
- package/src/services/local-secrets.ts +15 -7
- package/src/services/logger.ts +27 -7
- package/src/services/queue-webhook-service.test.ts +2 -1
- package/src/services/queue-webhook-service.ts +1 -1
- package/src/services/scoped-secret-service.ts +4 -3
- package/src/services/secret-service.ts +8 -3
- package/src/services/typed-secret-service.ts +11 -7
- package/src/services/webhook-service.ts +4 -1
- package/src/testing/service-tests.ts +6 -6
- package/src/types/core.types.ts +25 -4
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
- package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
- package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
- package/src/wirings/ai-agent/index.ts +2 -0
- package/src/wirings/ai-agent/voice-input.test.ts +65 -0
- package/src/wirings/ai-agent/voice-input.ts +48 -9
- package/src/wirings/ai-agent/voice-output.test.ts +91 -1
- package/src/wirings/ai-agent/voice-output.ts +28 -1
- package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
- package/src/wirings/cli/channel/index.ts +4 -0
- package/src/wirings/persona/define-personas.ts +4 -0
- package/src/wirings/persona/persona.types.ts +11 -0
- package/src/wirings/queue/queue-identity.test.ts +2 -1
- package/src/wirings/queue/queue-identity.ts +4 -1
- package/src/wirings/queue/queue.types.ts +6 -1
- package/src/wirings/queue/signed-queue-service.ts +2 -1
- package/src/wirings/rpc/remote-addon-auth.ts +8 -3
- package/src/wirings/rpc/rpc-runner.test.ts +6 -4
- package/src/wirings/virtual-user/index.ts +12 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
- package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
- package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
- package/src/wirings/workflow/pikku-workflow-service.ts +67 -0
- package/src/wirings/workflow/scenario-prose.test.ts +5 -7
- package/src/wirings/workflow/scenario-prose.ts +1 -1
- package/src/wirings/workflow/scenario-service.test.ts +0 -1
- package/src/wirings/workflow/scenario-step.test.ts +4 -5
- package/src/wirings/workflow/scenario-step.types.ts +13 -7
- package/src/wirings/workflow/scenario-surface.test.ts +6 -5
- package/src/wirings/workflow/workflow-stalled-recovery.test.ts +106 -0
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
261
|
+
export const resolveAuditUserIdentityFromWire = (
|
|
188
262
|
wire: PikkuWire<any, any, any, CoreUserSession>
|
|
189
|
-
):
|
|
263
|
+
): AuditUserIdentity | undefined => {
|
|
190
264
|
const session = wire.session as CoreUserSession | undefined
|
|
191
|
-
const
|
|
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 (
|
|
271
|
+
if (
|
|
272
|
+
!userIdentity.userId &&
|
|
273
|
+
!userIdentity.orgId &&
|
|
274
|
+
!userIdentity.pikkuUserId
|
|
275
|
+
) {
|
|
198
276
|
return undefined
|
|
199
277
|
}
|
|
200
278
|
|
|
201
|
-
return
|
|
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:
|
|
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
|
package/src/services/index.ts
CHANGED
|
@@ -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'
|