@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,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.
|
|
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
|
package/src/crypto-utils.ts
CHANGED
|
@@ -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<
|
|
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:
|
|
412
|
-
wrappedDEK:
|
|
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<
|
|
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
|
|
35
|
+
) => Wire['channel'] extends null
|
|
36
|
+
? Promise<Safe<Out>>
|
|
37
|
+
: Promise<Safe<Out>> | Promise<void>
|
|
35
38
|
|
|
36
39
|
export type CorePikkuFunctionSessionless<
|
|
37
40
|
In,
|
|
@@ -45,7 +48,9 @@ export type CorePikkuFunctionSessionless<
|
|
|
45
48
|
services: Services,
|
|
46
49
|
data: In,
|
|
47
50
|
wire: Wire
|
|
48
|
-
) => Wire['channel'] extends null
|
|
51
|
+
) => Wire['channel'] extends null
|
|
52
|
+
? Promise<Safe<Out>>
|
|
53
|
+
: Promise<Safe<Out>> | Promise<void>
|
|
49
54
|
|
|
50
55
|
export type CorePikkuPermission<
|
|
51
56
|
In = any,
|
|
@@ -192,16 +197,18 @@ export type CorePikkuFunctionConfig<
|
|
|
192
197
|
tags?: string[]
|
|
193
198
|
expose?: boolean
|
|
194
199
|
/**
|
|
195
|
-
* The
|
|
200
|
+
* The permission check for this function lives in its body — verifying a
|
|
196
201
|
* signed token, checking a webhook signature, matching an invite code — so
|
|
197
|
-
* it is not open despite
|
|
202
|
+
* it is not open despite declaring no session, scope or permission.
|
|
198
203
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
204
|
+
* A last resort. Prefer `permissions`, which are declared, inspectable, and
|
|
205
|
+
* reusable; reach for this only when the check cannot be expressed as one.
|
|
206
|
+
*
|
|
207
|
+
* Purely declarative — it grants nothing, and asserting it falsely disables
|
|
208
|
+
* the audit that would have caught the mistake. Requires
|
|
209
|
+
* `allow.permissionsInBody` in `pikku.config.json`.
|
|
203
210
|
*/
|
|
204
|
-
|
|
211
|
+
permissionsInBody?: boolean
|
|
205
212
|
remote?: boolean
|
|
206
213
|
mcp?: boolean
|
|
207
214
|
readonly?: boolean
|
|
@@ -248,7 +255,8 @@ export type CorePikkuFunctionConfig<
|
|
|
248
255
|
* Scopes the session must hold; all are required (AND) and checked before
|
|
249
256
|
* `permissions`, which OR together — a scope can only narrow access.
|
|
250
257
|
* Narrowed to the generated `ScopeId` union, so an undeclared scope is a
|
|
251
|
-
* compile error.
|
|
258
|
+
* compile error. Requires a session — see
|
|
259
|
+
* {@link CorePikkuSessionlessFunctionConfig}.
|
|
252
260
|
*/
|
|
253
261
|
scopes?: Scope[]
|
|
254
262
|
permissions?: CorePermissionGroup<PikkuPermission>
|
|
@@ -258,3 +266,32 @@ export type CorePikkuFunctionConfig<
|
|
|
258
266
|
node?: CoreNodeConfig
|
|
259
267
|
errors?: Array<typeof PikkuError>
|
|
260
268
|
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* {@link CorePikkuFunctionConfig} for a function that runs without a session.
|
|
272
|
+
*
|
|
273
|
+
* Identical, minus `scopes`. Scopes are AND-ed and `verifyScopes` fails closed,
|
|
274
|
+
* so an anonymous caller holds none and satisfies none — a sessionless function
|
|
275
|
+
* with scopes rejects every caller it exists to serve. Gate it with
|
|
276
|
+
* `permissions`, which receive the optional session and may pass anonymous.
|
|
277
|
+
*/
|
|
278
|
+
export type CorePikkuSessionlessFunctionConfig<
|
|
279
|
+
PikkuFunction extends CorePikkuFunctionSessionless<any, any, any, any, any>,
|
|
280
|
+
PikkuPermission extends CorePikkuPermission<any, any, any> =
|
|
281
|
+
CorePikkuPermission<any>,
|
|
282
|
+
PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
|
|
283
|
+
any,
|
|
284
|
+
any
|
|
285
|
+
>,
|
|
286
|
+
InputSchema extends StandardSchemaV1 | undefined = undefined,
|
|
287
|
+
OutputSchema extends StandardSchemaV1 | undefined = undefined,
|
|
288
|
+
> = Omit<
|
|
289
|
+
CorePikkuFunctionConfig<
|
|
290
|
+
PikkuFunction,
|
|
291
|
+
PikkuPermission,
|
|
292
|
+
PikkuMiddleware,
|
|
293
|
+
InputSchema,
|
|
294
|
+
OutputSchema
|
|
295
|
+
>,
|
|
296
|
+
'scopes'
|
|
297
|
+
>
|
package/src/function/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -197,20 +197,23 @@ export type { SessionService } from './services/user-session-service.js'
|
|
|
197
197
|
export {
|
|
198
198
|
NoopAuditService,
|
|
199
199
|
createInvocationAudit,
|
|
200
|
-
resolveAuditActorFromWire,
|
|
201
200
|
resolveAuditConfig,
|
|
201
|
+
resolveAuditUserIdentityFromWire,
|
|
202
202
|
} from './services/audit-service.js'
|
|
203
203
|
export type {
|
|
204
|
-
AuditActor,
|
|
205
204
|
AuditConfig,
|
|
206
205
|
AuditDurability,
|
|
207
206
|
AuditEvent,
|
|
208
207
|
AuditEventBatch,
|
|
208
|
+
AuditFacets,
|
|
209
209
|
AuditLog,
|
|
210
210
|
AuditLogWriteInput,
|
|
211
211
|
AuditOutcome,
|
|
212
|
+
AuditQuery,
|
|
213
|
+
AuditQueryResult,
|
|
212
214
|
AuditService,
|
|
213
215
|
AuditSource,
|
|
216
|
+
AuditUserIdentity,
|
|
214
217
|
ResolvedAuditConfig,
|
|
215
218
|
} from './services/audit-service.js'
|
|
216
219
|
export type {
|
|
@@ -284,4 +287,23 @@ export type {
|
|
|
284
287
|
AnonymizeStrategy,
|
|
285
288
|
ColumnClassification,
|
|
286
289
|
ClassificationManifest,
|
|
290
|
+
ColumnForm,
|
|
291
|
+
WrappedValue,
|
|
292
|
+
SealedValue,
|
|
293
|
+
HashedValue,
|
|
287
294
|
} from './data-classification.js'
|
|
295
|
+
|
|
296
|
+
export {
|
|
297
|
+
hashToken,
|
|
298
|
+
unsafeAsWrapped,
|
|
299
|
+
unsafeAsSealed,
|
|
300
|
+
unsafeAsHashed,
|
|
301
|
+
} from './column-form.js'
|
|
302
|
+
|
|
303
|
+
export type { SecretValue, Safe } from './secret-value.js'
|
|
304
|
+
export {
|
|
305
|
+
createSecretValue,
|
|
306
|
+
isSecretValue,
|
|
307
|
+
SecretCoercionError,
|
|
308
|
+
REDACTED,
|
|
309
|
+
} from './secret-value.js'
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
createMiddlewareSessionWireProps,
|
|
9
9
|
} from '../services/user-session-service.js'
|
|
10
10
|
import { InvalidSessionError } from '../errors/errors.js'
|
|
11
|
+
import { createSecretValue } from '../secret-value.js'
|
|
11
12
|
|
|
12
13
|
beforeEach(() => {
|
|
13
14
|
resetPikkuState()
|
|
@@ -429,7 +430,7 @@ describe('authBearer middleware', () => {
|
|
|
429
430
|
const secrets = {
|
|
430
431
|
getSecret: async (secretId: string) => {
|
|
431
432
|
assert.equal(secretId, 'PIKKU_CONSOLE_TOKEN')
|
|
432
|
-
return 'secret-token-value'
|
|
433
|
+
return createSecretValue('secret-token-value')
|
|
433
434
|
},
|
|
434
435
|
}
|
|
435
436
|
|
|
@@ -500,7 +501,7 @@ describe('authBearer middleware', () => {
|
|
|
500
501
|
test('should not set session when secret-resolved token does not match', async () => {
|
|
501
502
|
const SessionService = new PikkuSessionService<CoreUserSession>()
|
|
502
503
|
const secrets = {
|
|
503
|
-
getSecret: async () => 'expected-token',
|
|
504
|
+
getSecret: async () => createSecretValue('expected-token'),
|
|
504
505
|
}
|
|
505
506
|
|
|
506
507
|
const middleware = authBearer({
|
|
@@ -55,9 +55,10 @@ export const authBearer = pikkuMiddlewareFactory<{
|
|
|
55
55
|
expected = token.value
|
|
56
56
|
} else {
|
|
57
57
|
// An unset secret means the feature is off — never a request error.
|
|
58
|
-
|
|
58
|
+
const stored = await secrets
|
|
59
59
|
?.getSecret<string>(token.secretId)
|
|
60
60
|
.catch(() => undefined)
|
|
61
|
+
expected = stored?.reveal()
|
|
61
62
|
}
|
|
62
63
|
if (expected && constantTimeEqual(bearerToken, expected)) {
|
|
63
64
|
userSession = token.userSession
|
|
@@ -4,6 +4,7 @@ import { pikkuRemoteAuthMiddleware } from './remote-auth.js'
|
|
|
4
4
|
import { UnauthorizedError } from '../errors/errors.js'
|
|
5
5
|
import { resetPikkuState } from '../pikku-state.js'
|
|
6
6
|
import { encryptWithKeyMaterial, REMOTE_SESSION_INFO } from '../crypto-utils.js'
|
|
7
|
+
import { createSecretValue } from '../secret-value.js'
|
|
7
8
|
|
|
8
9
|
beforeEach(() => {
|
|
9
10
|
resetPikkuState()
|
|
@@ -14,7 +15,7 @@ const TEST_SECRET = 'test-remote-secret-key-with-enough-entropy'
|
|
|
14
15
|
const createMockSecrets = (secret?: string) => ({
|
|
15
16
|
getSecret: async (key: string) => {
|
|
16
17
|
if (key === 'PIKKU_REMOTE_SECRET' && secret !== undefined) {
|
|
17
|
-
return secret
|
|
18
|
+
return createSecretValue(secret)
|
|
18
19
|
}
|
|
19
20
|
throw new Error(`Secret ${key} not found`)
|
|
20
21
|
},
|
|
@@ -14,7 +14,7 @@ export const pikkuRemoteAuthMiddleware = pikkuMiddleware(
|
|
|
14
14
|
|
|
15
15
|
let secret: string
|
|
16
16
|
try {
|
|
17
|
-
secret = await secrets.getSecret('PIKKU_REMOTE_SECRET')
|
|
17
|
+
secret = (await secrets.getSecret('PIKKU_REMOTE_SECRET')).reveal()
|
|
18
18
|
} catch {
|
|
19
19
|
if (http.request.path().startsWith('/remote/rpc/')) {
|
|
20
20
|
throw new UnauthorizedError()
|
package/src/remote.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { buildRemoteHeaders } from './remote.js'
|
|
|
4
4
|
import { pikkuRemoteAuthMiddleware } from './middleware/remote-auth.js'
|
|
5
5
|
import { resetPikkuState } from './pikku-state.js'
|
|
6
6
|
import { WeakKeyMaterialError } from './errors/errors.js'
|
|
7
|
+
import { createSecretValue } from './secret-value.js'
|
|
7
8
|
|
|
8
9
|
beforeEach(() => {
|
|
9
10
|
resetPikkuState()
|
|
@@ -14,7 +15,7 @@ const WEAK_SECRET = 'dev-remote-secret'
|
|
|
14
15
|
|
|
15
16
|
const createSecrets = (secret: string) => ({
|
|
16
17
|
getSecret: async (key: string) => {
|
|
17
|
-
if (key === 'PIKKU_REMOTE_SECRET') return secret
|
|
18
|
+
if (key === 'PIKKU_REMOTE_SECRET') return createSecretValue(secret)
|
|
18
19
|
throw new Error(`Secret ${key} not found`)
|
|
19
20
|
},
|
|
20
21
|
})
|
package/src/remote.ts
CHANGED
|
@@ -25,7 +25,7 @@ export async function buildRemoteHeaders(
|
|
|
25
25
|
|
|
26
26
|
let secret: string | undefined
|
|
27
27
|
try {
|
|
28
|
-
secret = await secrets?.getSecret('PIKKU_REMOTE_SECRET')
|
|
28
|
+
secret = (await secrets?.getSecret('PIKKU_REMOTE_SECRET'))?.reveal()
|
|
29
29
|
} catch {}
|
|
30
30
|
|
|
31
31
|
if (secret && jwt) {
|