@pikku/core 0.12.80 → 0.12.83
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 +345 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/function/function-runner.js +2 -5
- package/dist/function/index.d.ts +1 -1
- package/dist/index.d.ts +11 -11
- package/dist/index.js +3 -3
- package/dist/pikku-state.js +4 -0
- package/dist/services/ai-agent-runner-service.d.ts +7 -0
- package/dist/services/ai-run-state-service.d.ts +10 -0
- package/dist/services/in-memory-ai-run-state-service.d.ts +5 -1
- package/dist/services/in-memory-ai-run-state-service.js +9 -0
- package/dist/services/index.d.ts +15 -16
- package/dist/services/index.js +5 -5
- package/dist/services/meta-service.d.ts +2 -1
- package/dist/services/scoped-credential-service.d.ts +21 -0
- package/dist/services/scoped-credential-service.js +53 -0
- package/dist/testing/service-tests/ai-storage-service-tests.js +76 -0
- package/dist/types/core.types.d.ts +2 -3
- package/dist/types/state.types.d.ts +19 -1
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/ai-agent/ai-agent-finalize.d.ts +58 -0
- package/dist/wirings/ai-agent/ai-agent-finalize.js +138 -0
- package/dist/wirings/ai-agent/ai-agent-interrupt.js +1 -0
- package/dist/wirings/ai-agent/ai-agent-memory.d.ts +2 -8
- package/dist/wirings/ai-agent/ai-agent-memory.js +34 -17
- package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +7 -0
- package/dist/wirings/ai-agent/ai-agent-model-config.js +44 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -0
- package/dist/wirings/ai-agent/ai-agent-runner.js +61 -40
- package/dist/wirings/ai-agent/ai-agent-stream.js +89 -36
- package/dist/wirings/ai-agent/ai-agent-turn.d.ts +1 -0
- package/dist/wirings/ai-agent/ai-agent-turn.js +1 -0
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +46 -1
- package/dist/wirings/ai-agent/index.d.ts +8 -7
- package/dist/wirings/ai-agent/index.js +5 -4
- package/dist/wirings/ai-scorer/ai-scorer-grade.d.ts +26 -0
- package/dist/wirings/ai-scorer/ai-scorer-grade.js +33 -0
- package/dist/wirings/ai-scorer/ai-scorer-judge.d.ts +17 -0
- package/dist/wirings/ai-scorer/ai-scorer-judge.js +92 -0
- package/dist/wirings/ai-scorer/ai-scorer-live.d.ts +15 -0
- package/dist/wirings/ai-scorer/ai-scorer-live.js +38 -0
- package/dist/wirings/ai-scorer/ai-scorer-registry.d.ts +18 -0
- package/dist/wirings/ai-scorer/ai-scorer-registry.js +46 -0
- package/dist/wirings/ai-scorer/ai-scorer-sampling.d.ts +8 -0
- package/dist/wirings/ai-scorer/ai-scorer-sampling.js +31 -0
- package/dist/wirings/ai-scorer/ai-scorer-snapshots.d.ts +10 -0
- package/dist/wirings/ai-scorer/ai-scorer-snapshots.js +40 -0
- package/dist/wirings/ai-scorer/ai-scorer-worker.d.ts +15 -0
- package/dist/wirings/ai-scorer/ai-scorer-worker.js +58 -0
- package/dist/wirings/ai-scorer/ai-scorer.d.ts +39 -0
- package/dist/wirings/ai-scorer/ai-scorer.js +40 -0
- package/dist/wirings/ai-scorer/ai-scorer.types.d.ts +90 -0
- package/dist/wirings/ai-scorer/ai-scorer.types.js +4 -0
- package/dist/wirings/ai-scorer/index.d.ts +6 -0
- package/dist/wirings/ai-scorer/index.js +5 -0
- package/dist/wirings/channel/index.d.ts +5 -6
- package/dist/wirings/channel/index.js +3 -4
- package/dist/wirings/channel/local/local-channel-runner.js +8 -1
- package/dist/wirings/cli/channel/cli-raw-channel-runner.js +9 -1
- package/dist/wirings/cli/channel/index.d.ts +1 -2
- package/dist/wirings/cli/channel/index.js +0 -1
- package/dist/wirings/cli/cli-runner.js +13 -1
- package/dist/wirings/credential/index.d.ts +1 -1
- package/dist/wirings/gateway/index.d.ts +1 -1
- package/dist/wirings/http/http-runner.js +8 -2
- package/dist/wirings/http/index.d.ts +1 -2
- package/dist/wirings/mcp/index.d.ts +1 -1
- package/dist/wirings/mcp/mcp-runner.d.ts +15 -0
- package/dist/wirings/mcp/mcp-runner.js +18 -5
- package/dist/wirings/persona/index.d.ts +3 -4
- package/dist/wirings/persona/index.js +2 -3
- package/dist/wirings/queue/index.d.ts +1 -3
- package/dist/wirings/queue/index.js +1 -3
- package/dist/wirings/rpc/addon-runner.d.ts +8 -0
- package/dist/wirings/rpc/addon-runner.js +31 -3
- package/dist/wirings/rpc/rpc-runner.js +4 -0
- package/dist/wirings/rpc/rpc-types.d.ts +8 -0
- package/dist/wirings/rpc/wire-addon.d.ts +25 -0
- package/dist/wirings/rpc/wire-addon.js +8 -0
- package/dist/wirings/scheduler/index.d.ts +1 -1
- package/dist/wirings/trigger/index.d.ts +1 -1
- package/dist/wirings/virtual-user/index.d.ts +5 -6
- package/dist/wirings/virtual-user/index.js +2 -4
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +85 -15
- package/dist/wirings/workflow/feature.d.ts +2 -1
- package/dist/wirings/workflow/index.d.ts +5 -16
- package/dist/wirings/workflow/index.js +1 -9
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +17 -7
- package/dist/wirings/workflow/pikku-scenario-service.js +48 -13
- package/dist/wirings/workflow/pikku-workflow-service.js +17 -3
- package/dist/wirings/workflow/scenario-step.types.d.ts +8 -0
- package/dist/wirings/workflow/scenario.types.d.ts +37 -0
- package/dist/wirings/workflow/workflow-approval-audit.d.ts +16 -0
- package/dist/wirings/workflow/workflow-approval-audit.js +40 -0
- package/dist/wirings/workflow/workflow-approval-policy.d.ts +20 -0
- package/dist/wirings/workflow/workflow-approval-policy.js +48 -0
- package/dist/wirings/workflow/workflow-approval.d.ts +29 -1
- package/dist/wirings/workflow/workflow-approval.js +65 -2
- package/dist/wirings/workflow/workflow-run-ownership.d.ts +2 -1
- package/dist/wirings/workflow/workflow-run-ownership.js +2 -1
- package/dist/wirings/workflow/workflow.types.d.ts +2 -37
- package/knowledge/decisions/internals/addon-pikku-meta-ships-at-the-package-root-or-under-dist.md +32 -0
- package/knowledge/decisions/internals/an-addon-scope-root-loses-to-a-root-the-host-already-declares.md +39 -0
- package/knowledge/decisions/internals/index.md +30 -3
- package/knowledge/decisions/internals/validate-runs-checks-by-precondition.md +115 -0
- package/knowledge/decisions/security/a-function-never-receives-the-secret-service.md +37 -0
- package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +30 -14
- package/knowledge/decisions/security/an-approval-answer-outlives-the-run-it-answered.md +59 -0
- package/knowledge/decisions/security/index.md +3 -1
- package/knowledge/questions/index.md +1 -1
- package/package.json +3 -2
- package/scripts/generate-api-report.mts +143 -18
- package/src/api-report.test.ts +2 -2
- package/src/errors/index.ts +1 -1
- package/src/function/function-runner.test.ts +52 -0
- package/src/function/function-runner.ts +5 -9
- package/src/function/index.ts +0 -2
- package/src/index.ts +0 -35
- package/src/pikku-state.ts +5 -0
- package/src/public-surface.json +81 -118
- package/src/services/ai-agent-runner-service.ts +12 -1
- package/src/services/ai-run-state-service.ts +11 -0
- package/src/services/in-memory-ai-run-state-service.ts +13 -0
- package/src/services/index.ts +7 -58
- package/src/services/meta-service.ts +2 -4
- package/src/services/scoped-credential-service.test.ts +86 -0
- package/src/services/scoped-credential-service.ts +63 -0
- package/src/testing/service-tests/ai-storage-service-tests.ts +93 -0
- package/src/types/core.types.ts +4 -7
- package/src/types/state.types.ts +21 -1
- package/src/wirings/actor-flow/index.ts +0 -3
- package/src/wirings/ai-agent/ai-agent-finalize.test.ts +186 -0
- package/src/wirings/ai-agent/ai-agent-finalize.ts +197 -0
- package/src/wirings/ai-agent/ai-agent-interrupt.ts +1 -0
- package/src/wirings/ai-agent/ai-agent-memory.ts +54 -38
- package/src/wirings/ai-agent/ai-agent-model-config.test.ts +72 -3
- package/src/wirings/ai-agent/ai-agent-model-config.ts +49 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +71 -40
- package/src/wirings/ai-agent/ai-agent-stream-output-hooks.test.ts +353 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +116 -54
- package/src/wirings/ai-agent/ai-agent-turn.test.ts +67 -0
- package/src/wirings/ai-agent/ai-agent-turn.ts +1 -0
- package/src/wirings/ai-agent/ai-agent.types.ts +64 -4
- package/src/wirings/ai-agent/index.ts +2 -16
- package/src/wirings/ai-scorer/ai-scorer-grade.test.ts +106 -0
- package/src/wirings/ai-scorer/ai-scorer-grade.ts +55 -0
- package/src/wirings/ai-scorer/ai-scorer-judge.test.ts +143 -0
- package/src/wirings/ai-scorer/ai-scorer-judge.ts +120 -0
- package/src/wirings/ai-scorer/ai-scorer-live.test.ts +174 -0
- package/src/wirings/ai-scorer/ai-scorer-live.ts +56 -0
- package/src/wirings/ai-scorer/ai-scorer-registry.ts +63 -0
- package/src/wirings/ai-scorer/ai-scorer-sampling.test.ts +34 -0
- package/src/wirings/ai-scorer/ai-scorer-sampling.ts +36 -0
- package/src/wirings/ai-scorer/ai-scorer-snapshots.test.ts +49 -0
- package/src/wirings/ai-scorer/ai-scorer-snapshots.ts +46 -0
- package/src/wirings/ai-scorer/ai-scorer-worker.test.ts +122 -0
- package/src/wirings/ai-scorer/ai-scorer-worker.ts +69 -0
- package/src/wirings/ai-scorer/ai-scorer.ts +76 -0
- package/src/wirings/ai-scorer/ai-scorer.types.ts +107 -0
- package/src/wirings/ai-scorer/index.ts +24 -0
- package/src/wirings/channel/index.ts +1 -20
- package/src/wirings/channel/local/local-channel-runner.test.ts +68 -0
- package/src/wirings/channel/local/local-channel-runner.ts +8 -1
- package/src/wirings/cli/channel/cli-raw-channel-runner.test.ts +23 -0
- package/src/wirings/cli/channel/cli-raw-channel-runner.ts +12 -1
- package/src/wirings/cli/channel/index.ts +0 -7
- package/src/wirings/cli/cli-runner.test.ts +68 -0
- package/src/wirings/cli/cli-runner.ts +18 -1
- package/src/wirings/credential/index.ts +0 -1
- package/src/wirings/gateway/index.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +66 -0
- package/src/wirings/http/http-runner.ts +10 -2
- package/src/wirings/http/index.ts +1 -1
- package/src/wirings/mcp/index.ts +0 -1
- package/src/wirings/mcp/mcp-runner.test.ts +181 -0
- package/src/wirings/mcp/mcp-runner.ts +35 -5
- package/src/wirings/persona/index.ts +0 -8
- package/src/wirings/queue/index.ts +0 -14
- package/src/wirings/rpc/addon-runner.ts +62 -3
- package/src/wirings/rpc/addon-secrets.test.ts +391 -0
- package/src/wirings/rpc/rpc-runner.test.ts +2 -0
- package/src/wirings/rpc/rpc-runner.ts +4 -0
- package/src/wirings/rpc/rpc-types.ts +8 -0
- package/src/wirings/rpc/wire-addon.ts +33 -0
- package/src/wirings/scheduler/index.ts +0 -1
- package/src/wirings/trigger/index.ts +0 -1
- package/src/wirings/virtual-user/index.ts +0 -16
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +96 -16
- package/src/wirings/workflow/feature.ts +2 -5
- package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
- package/src/wirings/workflow/index.ts +2 -68
- package/src/wirings/workflow/pikku-scenario-service.ts +81 -16
- package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -12
- package/src/wirings/workflow/pikku-workflow-service.ts +28 -4
- package/src/wirings/workflow/scenario-expectations.test.ts +75 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +3 -2
- package/src/wirings/workflow/scenario-step.types.ts +8 -0
- package/src/wirings/workflow/scenario.types.ts +63 -0
- package/src/wirings/workflow/workflow-approval-audit.ts +47 -0
- package/src/wirings/workflow/workflow-approval-policy.test.ts +524 -0
- package/src/wirings/workflow/workflow-approval-policy.ts +68 -0
- package/src/wirings/workflow/workflow-approval.ts +113 -9
- package/src/wirings/workflow/workflow-run-authority.test.ts +12 -15
- package/src/wirings/workflow/workflow-run-ownership.ts +2 -1
- package/src/wirings/workflow/workflow.types.ts +1 -63
- package/src/wirings-stay-decoupled.test.ts +6 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/internal.d.ts +0 -3
- package/dist/internal.js +0 -2
- package/dist/middleware/timeout.d.ts +0 -9
- package/dist/middleware/timeout.js +0 -15
- package/dist/pikku-response.d.ts +0 -6
- package/dist/pikku-response.js +0 -6
- package/dist/services/gopass-secrets.d.ts +0 -15
- package/dist/services/gopass-secrets.js +0 -76
- package/dist/services/http-scenario-actors.d.ts +0 -75
- package/dist/services/http-scenario-actors.js +0 -195
- package/dist/services/http-user-flow-actors.d.ts +0 -67
- package/dist/services/http-user-flow-actors.js +0 -193
- package/dist/services/scenario-actors-service.d.ts +0 -127
- package/dist/services/scenario-actors-service.js +0 -40
- package/dist/services/user-flow-actors-service.d.ts +0 -39
- package/dist/wirings/credential/wire-credential.d.ts +0 -48
- package/dist/wirings/credential/wire-credential.js +0 -47
- package/dist/wirings/oauth2/oauth2-client.d.ts +0 -47
- package/dist/wirings/oauth2/oauth2-client.js +0 -263
- package/dist/wirings/oauth2/oauth2-routes.d.ts +0 -35
- package/dist/wirings/oauth2/oauth2-routes.js +0 -146
- package/dist/wirings/scope/wire-scope.d.ts +0 -33
- package/dist/wirings/scope/wire-scope.js +0 -32
- package/dist/wirings/workflow/dsl/index.d.ts +0 -5
- package/dist/wirings/workflow/dsl/index.js +0 -4
- package/dist/wirings/workflow/graph/index.d.ts +0 -5
- package/dist/wirings/workflow/graph/index.js +0 -4
- /package/dist/{services/user-flow-actors-service.js → wirings/workflow/scenario.types.js} +0 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
4
|
+
|
|
5
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
6
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
7
|
+
import { addWorkflow } from './dsl/workflow-runner.js'
|
|
8
|
+
import { WorkflowSuspendedException } from './workflow-errors.js'
|
|
9
|
+
import {
|
|
10
|
+
approvalPolicyRefusal,
|
|
11
|
+
WorkflowApprovalForbiddenError,
|
|
12
|
+
} from './workflow-approval-policy.js'
|
|
13
|
+
import { approvalStateKey, approvalStepNameFor } from './workflow-approval.js'
|
|
14
|
+
import type {
|
|
15
|
+
PikkuWorkflowWire,
|
|
16
|
+
WorkflowApprovalOptions,
|
|
17
|
+
} from './workflow.types.js'
|
|
18
|
+
|
|
19
|
+
const anySchema: StandardSchemaV1<unknown, unknown> = {
|
|
20
|
+
'~standard': {
|
|
21
|
+
version: 1,
|
|
22
|
+
vendor: 'test',
|
|
23
|
+
validate: (value) => ({ value }),
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const registerApprovalWorkflow = (
|
|
28
|
+
workflowName: string,
|
|
29
|
+
graphHash: string,
|
|
30
|
+
options: Omit<WorkflowApprovalOptions, 'schema'>,
|
|
31
|
+
audit?: { audit: (event: unknown) => Promise<void> }
|
|
32
|
+
) => {
|
|
33
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
34
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
35
|
+
queueService: { add: async () => {} },
|
|
36
|
+
audit,
|
|
37
|
+
} as any)
|
|
38
|
+
|
|
39
|
+
const metaState = pikkuState(null, 'workflows', 'meta')
|
|
40
|
+
metaState[workflowName] = {
|
|
41
|
+
name: workflowName,
|
|
42
|
+
pikkuFuncId: workflowName,
|
|
43
|
+
source: 'dsl',
|
|
44
|
+
graphHash,
|
|
45
|
+
}
|
|
46
|
+
const functionMetaState = pikkuState(null, 'function', 'meta')
|
|
47
|
+
functionMetaState[workflowName] = {
|
|
48
|
+
name: workflowName,
|
|
49
|
+
sessionless: true,
|
|
50
|
+
permissions: [],
|
|
51
|
+
} as any
|
|
52
|
+
|
|
53
|
+
addWorkflow(workflowName, {
|
|
54
|
+
func: async (
|
|
55
|
+
_services: any,
|
|
56
|
+
_data: any,
|
|
57
|
+
{ workflow }: { workflow: PikkuWorkflowWire }
|
|
58
|
+
) => {
|
|
59
|
+
const decision = await workflow.approval('Release funds', {
|
|
60
|
+
schema: anySchema,
|
|
61
|
+
...options,
|
|
62
|
+
})
|
|
63
|
+
return { decision }
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
delete metaState[workflowName]
|
|
69
|
+
delete functionMetaState[workflowName]
|
|
70
|
+
pikkuState(null, 'workflows', 'registrations').delete(workflowName)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Run until the gate suspends, so the gate has published its policy. */
|
|
75
|
+
const runToGate = async (ws: InMemoryWorkflowService, runId: string) => {
|
|
76
|
+
await assert.rejects(
|
|
77
|
+
ws.runWorkflowJob(runId, {}),
|
|
78
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('approval policy', () => {
|
|
83
|
+
test('the default lets anyone the entrypoint admitted answer', () => {
|
|
84
|
+
assert.equal(approvalPolicyRefusal({}, 'owner', undefined), undefined)
|
|
85
|
+
assert.equal(
|
|
86
|
+
approvalPolicyRefusal({}, 'owner', { userId: 'someone-else' }),
|
|
87
|
+
undefined
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('owner admits only the user who started the run', () => {
|
|
92
|
+
const policy = { approvers: 'owner' as const }
|
|
93
|
+
assert.equal(
|
|
94
|
+
approvalPolicyRefusal(policy, 'owner', { userId: 'owner' }),
|
|
95
|
+
undefined
|
|
96
|
+
)
|
|
97
|
+
assert.match(
|
|
98
|
+
approvalPolicyRefusal(policy, 'owner', { userId: 'other' })!,
|
|
99
|
+
/Only the user who started this run/
|
|
100
|
+
)
|
|
101
|
+
assert.match(
|
|
102
|
+
approvalPolicyRefusal(policy, 'owner', undefined)!,
|
|
103
|
+
/Only the user who started this run/
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('a run with no initiator has no owner to compare against', () => {
|
|
108
|
+
assert.equal(
|
|
109
|
+
approvalPolicyRefusal({ approvers: 'owner' }, undefined, {
|
|
110
|
+
userId: 'anyone',
|
|
111
|
+
}),
|
|
112
|
+
undefined
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('not-initiator excludes the user who started the run', () => {
|
|
117
|
+
const policy = { approvers: 'not-initiator' as const }
|
|
118
|
+
assert.match(
|
|
119
|
+
approvalPolicyRefusal(policy, 'owner', { userId: 'owner' })!,
|
|
120
|
+
/may not answer/
|
|
121
|
+
)
|
|
122
|
+
assert.equal(
|
|
123
|
+
approvalPolicyRefusal(policy, 'owner', { userId: 'reviewer' }),
|
|
124
|
+
undefined
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('not-initiator still requires a signed-in decider on an unowned run', () => {
|
|
129
|
+
assert.match(
|
|
130
|
+
approvalPolicyRefusal({ approvers: 'not-initiator' }, undefined, {})!,
|
|
131
|
+
/requires a signed-in user/
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('approverScope is required on top of the approvers rule', () => {
|
|
136
|
+
const policy = { approvers: 'not-initiator' as const, approverScope: 'sre' }
|
|
137
|
+
assert.match(
|
|
138
|
+
approvalPolicyRefusal(policy, 'owner', { userId: 'reviewer' })!,
|
|
139
|
+
/requires the 'sre' scope/
|
|
140
|
+
)
|
|
141
|
+
assert.equal(
|
|
142
|
+
approvalPolicyRefusal(policy, 'owner', {
|
|
143
|
+
userId: 'reviewer',
|
|
144
|
+
scopes: ['sre'],
|
|
145
|
+
}),
|
|
146
|
+
undefined
|
|
147
|
+
)
|
|
148
|
+
assert.match(
|
|
149
|
+
approvalPolicyRefusal(policy, 'owner', {
|
|
150
|
+
userId: 'owner',
|
|
151
|
+
scopes: ['sre'],
|
|
152
|
+
})!,
|
|
153
|
+
/may not answer/
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('approval policy enforcement', () => {
|
|
159
|
+
test('a gate with no declared policy accepts a caller who does not own the run', async () => {
|
|
160
|
+
const ws = new InMemoryWorkflowService()
|
|
161
|
+
const cleanup = registerApprovalWorkflow('openGate', 'open-gate', {})
|
|
162
|
+
|
|
163
|
+
const runId = await ws.createRun('openGate', {}, false, 'open-gate', {
|
|
164
|
+
type: 'http',
|
|
165
|
+
pikkuUserId: 'initiator',
|
|
166
|
+
})
|
|
167
|
+
await runToGate(ws, runId)
|
|
168
|
+
|
|
169
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
170
|
+
userId: 'someone-else',
|
|
171
|
+
})
|
|
172
|
+
await ws.runWorkflowJob(runId, {})
|
|
173
|
+
|
|
174
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
175
|
+
cleanup()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('a four-eyes gate refuses the initiator and admits a second user', async () => {
|
|
179
|
+
const ws = new InMemoryWorkflowService()
|
|
180
|
+
const cleanup = registerApprovalWorkflow('fourEyes', 'four-eyes', {
|
|
181
|
+
approvers: 'not-initiator',
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const runId = await ws.createRun('fourEyes', {}, false, 'four-eyes', {
|
|
185
|
+
type: 'http',
|
|
186
|
+
pikkuUserId: 'initiator',
|
|
187
|
+
})
|
|
188
|
+
await runToGate(ws, runId)
|
|
189
|
+
|
|
190
|
+
await assert.rejects(
|
|
191
|
+
ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
192
|
+
userId: 'initiator',
|
|
193
|
+
}),
|
|
194
|
+
WorkflowApprovalForbiddenError
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
198
|
+
userId: 'reviewer',
|
|
199
|
+
})
|
|
200
|
+
await ws.runWorkflowJob(runId, {})
|
|
201
|
+
|
|
202
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
203
|
+
cleanup()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('an owner-only gate refuses a caller who did not start the run', async () => {
|
|
207
|
+
const ws = new InMemoryWorkflowService()
|
|
208
|
+
const cleanup = registerApprovalWorkflow('ownerGate', 'owner-gate', {
|
|
209
|
+
approvers: 'owner',
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const runId = await ws.createRun('ownerGate', {}, false, 'owner-gate', {
|
|
213
|
+
type: 'http',
|
|
214
|
+
pikkuUserId: 'initiator',
|
|
215
|
+
})
|
|
216
|
+
await runToGate(ws, runId)
|
|
217
|
+
|
|
218
|
+
await assert.rejects(
|
|
219
|
+
ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
220
|
+
userId: 'attacker',
|
|
221
|
+
}),
|
|
222
|
+
WorkflowApprovalForbiddenError
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
226
|
+
userId: 'initiator',
|
|
227
|
+
})
|
|
228
|
+
await ws.runWorkflowJob(runId, {})
|
|
229
|
+
|
|
230
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
231
|
+
cleanup()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('a scoped gate refuses a decider without the scope', async () => {
|
|
235
|
+
const ws = new InMemoryWorkflowService()
|
|
236
|
+
const cleanup = registerApprovalWorkflow('scopedGate', 'scoped-gate', {
|
|
237
|
+
approverScope: 'payments:approve',
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const runId = await ws.createRun('scopedGate', {}, false, 'scoped-gate', {
|
|
241
|
+
type: 'http',
|
|
242
|
+
pikkuUserId: 'initiator',
|
|
243
|
+
})
|
|
244
|
+
await runToGate(ws, runId)
|
|
245
|
+
|
|
246
|
+
await assert.rejects(
|
|
247
|
+
ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
248
|
+
userId: 'reviewer',
|
|
249
|
+
scopes: ['payments:read'],
|
|
250
|
+
}),
|
|
251
|
+
WorkflowApprovalForbiddenError
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
255
|
+
userId: 'reviewer',
|
|
256
|
+
scopes: ['payments:approve'],
|
|
257
|
+
})
|
|
258
|
+
await ws.runWorkflowJob(runId, {})
|
|
259
|
+
|
|
260
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
261
|
+
cleanup()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Run state is where a decision waits, not where it is kept: the record is
|
|
266
|
+
* overwritten by the next write to that key and cleared outright whenever a
|
|
267
|
+
* decision is refused. Four-eyes exists to be audited, so who signed and when
|
|
268
|
+
* has to reach the step result, which is append-only and carried into
|
|
269
|
+
* workflowStepHistory.
|
|
270
|
+
*/
|
|
271
|
+
test('the decider and the moment reach the step result, not just run state', async () => {
|
|
272
|
+
const ws = new InMemoryWorkflowService()
|
|
273
|
+
const cleanup = registerApprovalWorkflow('signedGate', 'signed-gate', {
|
|
274
|
+
approvers: 'not-initiator',
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const runId = await ws.createRun('signedGate', {}, false, 'signed-gate', {
|
|
278
|
+
type: 'http',
|
|
279
|
+
pikkuUserId: 'initiator',
|
|
280
|
+
})
|
|
281
|
+
await runToGate(ws, runId)
|
|
282
|
+
|
|
283
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
284
|
+
userId: 'reviewer',
|
|
285
|
+
scopes: ['payments:approve'],
|
|
286
|
+
})
|
|
287
|
+
await ws.runWorkflowJob(runId, {})
|
|
288
|
+
|
|
289
|
+
const step = await ws.getStepState(
|
|
290
|
+
runId,
|
|
291
|
+
approvalStepNameFor('Release funds')
|
|
292
|
+
)
|
|
293
|
+
const outcome = step.result as {
|
|
294
|
+
status: string
|
|
295
|
+
decidedBy?: { userId?: string; scopes?: string[] }
|
|
296
|
+
decidedAt?: string
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
assert.equal(outcome.status, 'decided')
|
|
300
|
+
assert.equal(outcome.decidedBy?.userId, 'reviewer')
|
|
301
|
+
assert.deepEqual(outcome.decidedBy?.scopes, ['payments:approve'])
|
|
302
|
+
assert.ok(
|
|
303
|
+
outcome.decidedAt && !Number.isNaN(Date.parse(outcome.decidedAt)),
|
|
304
|
+
'decidedAt is an ISO timestamp'
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
cleanup()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('an expired gate has nobody to record', async () => {
|
|
311
|
+
const ws = new InMemoryWorkflowService()
|
|
312
|
+
const cleanup = registerApprovalWorkflow('expiredGate', 'expired-gate', {
|
|
313
|
+
expiry: 100,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const runId = await ws.createRun('expiredGate', {}, false, 'expired-gate', {
|
|
317
|
+
type: 'http',
|
|
318
|
+
pikkuUserId: 'initiator',
|
|
319
|
+
})
|
|
320
|
+
await runToGate(ws, runId)
|
|
321
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
322
|
+
await ws.runWorkflowJob(runId, {})
|
|
323
|
+
|
|
324
|
+
const step = await ws.getStepState(
|
|
325
|
+
runId,
|
|
326
|
+
approvalStepNameFor('Release funds')
|
|
327
|
+
)
|
|
328
|
+
assert.deepEqual(step.result, { status: 'expired' })
|
|
329
|
+
|
|
330
|
+
cleanup()
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('a decision recorded before the run reaches the gate is still judged', async () => {
|
|
334
|
+
const ws = new InMemoryWorkflowService()
|
|
335
|
+
const cleanup = registerApprovalWorkflow('earlyGate', 'early-gate', {
|
|
336
|
+
approvers: 'not-initiator',
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const runId = await ws.createRun('earlyGate', {}, false, 'early-gate', {
|
|
340
|
+
type: 'http',
|
|
341
|
+
pikkuUserId: 'initiator',
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// The gate has not been reached, so no policy has been published and the
|
|
345
|
+
// submission cannot be judged here — it is accepted and judged on replay.
|
|
346
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
347
|
+
userId: 'initiator',
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
await runToGate(ws, runId)
|
|
351
|
+
|
|
352
|
+
const stateKey = approvalStateKey(approvalStepNameFor('Release funds'))
|
|
353
|
+
const record = (await ws.getRunState(runId))[stateKey] as {
|
|
354
|
+
decision?: unknown
|
|
355
|
+
error?: Array<{ message: string }>
|
|
356
|
+
}
|
|
357
|
+
assert.equal(record.decision, undefined, 'the decision was cleared')
|
|
358
|
+
assert.match(record.error?.[0]?.message ?? '', /may not answer/)
|
|
359
|
+
assert.equal((await ws.getRun(runId))?.status, 'suspended')
|
|
360
|
+
|
|
361
|
+
cleanup()
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The step result is deleted with the run — `deleteRun` cascades to steps and
|
|
367
|
+
* to history — and a refused attempt never reaches a step at all. An approval
|
|
368
|
+
* is asked for so it can be answered for afterwards, so it also goes to the
|
|
369
|
+
* audit sink, which holds no foreign key to the run.
|
|
370
|
+
*/
|
|
371
|
+
describe('approval audit trail', () => {
|
|
372
|
+
const collectingAudit = () => {
|
|
373
|
+
const events: any[] = []
|
|
374
|
+
return {
|
|
375
|
+
events,
|
|
376
|
+
sink: { audit: async (event: any) => void events.push(event) },
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
test('an accepted decision is recorded against the user who made it', async () => {
|
|
381
|
+
const ws = new InMemoryWorkflowService()
|
|
382
|
+
const { events, sink } = collectingAudit()
|
|
383
|
+
const cleanup = registerApprovalWorkflow(
|
|
384
|
+
'auditedGate',
|
|
385
|
+
'audited-gate',
|
|
386
|
+
{ approvers: 'not-initiator' },
|
|
387
|
+
sink
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
const runId = await ws.createRun('auditedGate', {}, false, 'audited-gate', {
|
|
391
|
+
type: 'http',
|
|
392
|
+
pikkuUserId: 'initiator',
|
|
393
|
+
})
|
|
394
|
+
await runToGate(ws, runId)
|
|
395
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
396
|
+
userId: 'reviewer',
|
|
397
|
+
scopes: ['payments:approve'],
|
|
398
|
+
})
|
|
399
|
+
await ws.runWorkflowJob(runId, {})
|
|
400
|
+
|
|
401
|
+
assert.equal(events.length, 1)
|
|
402
|
+
assert.equal(events[0].type, 'workflow.approval.decided')
|
|
403
|
+
assert.equal(events[0].outcome, 'success')
|
|
404
|
+
assert.equal(events[0].wireType, 'workflow')
|
|
405
|
+
assert.equal(events[0].userIdentity.pikkuUserId, 'reviewer')
|
|
406
|
+
assert.equal(events[0].metadata.runId, runId)
|
|
407
|
+
assert.equal(events[0].metadata.reason, 'Release funds')
|
|
408
|
+
assert.deepEqual(events[0].metadata.scopes, ['payments:approve'])
|
|
409
|
+
assert.ok(!Number.isNaN(Date.parse(events[0].occurredAt)))
|
|
410
|
+
|
|
411
|
+
cleanup()
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
test('a refused attempt is recorded too — that is the one worth keeping', async () => {
|
|
415
|
+
const ws = new InMemoryWorkflowService()
|
|
416
|
+
const { events, sink } = collectingAudit()
|
|
417
|
+
const cleanup = registerApprovalWorkflow(
|
|
418
|
+
'refusedGate',
|
|
419
|
+
'refused-gate',
|
|
420
|
+
{ approvers: 'not-initiator' },
|
|
421
|
+
sink
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
const runId = await ws.createRun('refusedGate', {}, false, 'refused-gate', {
|
|
425
|
+
type: 'http',
|
|
426
|
+
pikkuUserId: 'initiator',
|
|
427
|
+
})
|
|
428
|
+
await runToGate(ws, runId)
|
|
429
|
+
|
|
430
|
+
await assert.rejects(
|
|
431
|
+
ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
432
|
+
userId: 'initiator',
|
|
433
|
+
}),
|
|
434
|
+
WorkflowApprovalForbiddenError
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
assert.equal(events.length, 1)
|
|
438
|
+
assert.equal(events[0].outcome, 'denied')
|
|
439
|
+
assert.equal(events[0].userIdentity.pikkuUserId, 'initiator')
|
|
440
|
+
assert.match(events[0].metadata.refusal, /may not answer/)
|
|
441
|
+
|
|
442
|
+
cleanup()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
test('a decision refused on replay is recorded, having been refused nowhere else', async () => {
|
|
446
|
+
const ws = new InMemoryWorkflowService()
|
|
447
|
+
const { events, sink } = collectingAudit()
|
|
448
|
+
const cleanup = registerApprovalWorkflow(
|
|
449
|
+
'replayRefused',
|
|
450
|
+
'replay-refused',
|
|
451
|
+
{ approvers: 'not-initiator' },
|
|
452
|
+
sink
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
const runId = await ws.createRun(
|
|
456
|
+
'replayRefused',
|
|
457
|
+
{},
|
|
458
|
+
false,
|
|
459
|
+
'replay-refused',
|
|
460
|
+
{ type: 'http', pikkuUserId: 'initiator' }
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
// Submitted before the gate published its policy, so it is accepted here
|
|
464
|
+
// and only refused on replay.
|
|
465
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
466
|
+
userId: 'initiator',
|
|
467
|
+
})
|
|
468
|
+
assert.equal(events[0].outcome, 'success')
|
|
469
|
+
|
|
470
|
+
await runToGate(ws, runId)
|
|
471
|
+
|
|
472
|
+
assert.equal(events.length, 2)
|
|
473
|
+
assert.equal(events[1].outcome, 'denied')
|
|
474
|
+
assert.equal(events[1].userIdentity.pikkuUserId, 'initiator')
|
|
475
|
+
assert.match(events[1].metadata.refusal, /may not answer/)
|
|
476
|
+
|
|
477
|
+
cleanup()
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test('a failing sink does not cost the decision', async () => {
|
|
481
|
+
const ws = new InMemoryWorkflowService()
|
|
482
|
+
const cleanup = registerApprovalWorkflow(
|
|
483
|
+
'brokenSink',
|
|
484
|
+
'broken-sink',
|
|
485
|
+
{},
|
|
486
|
+
{
|
|
487
|
+
audit: async () => {
|
|
488
|
+
throw new Error('sink is down')
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
const runId = await ws.createRun('brokenSink', {}, false, 'broken-sink', {
|
|
494
|
+
type: 'http',
|
|
495
|
+
pikkuUserId: 'initiator',
|
|
496
|
+
})
|
|
497
|
+
await runToGate(ws, runId)
|
|
498
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
499
|
+
userId: 'reviewer',
|
|
500
|
+
})
|
|
501
|
+
await ws.runWorkflowJob(runId, {})
|
|
502
|
+
|
|
503
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
504
|
+
cleanup()
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test('a project with no audit sink wired is unaffected', async () => {
|
|
508
|
+
const ws = new InMemoryWorkflowService()
|
|
509
|
+
const cleanup = registerApprovalWorkflow('noSink', 'no-sink', {})
|
|
510
|
+
|
|
511
|
+
const runId = await ws.createRun('noSink', {}, false, 'no-sink', {
|
|
512
|
+
type: 'http',
|
|
513
|
+
pikkuUserId: 'initiator',
|
|
514
|
+
})
|
|
515
|
+
await runToGate(ws, runId)
|
|
516
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
517
|
+
userId: 'reviewer',
|
|
518
|
+
})
|
|
519
|
+
await ws.runWorkflowJob(runId, {})
|
|
520
|
+
|
|
521
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
522
|
+
cleanup()
|
|
523
|
+
})
|
|
524
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ForbiddenError } from '../../errors/errors.js'
|
|
2
|
+
import { addError } from '../../errors/error-handler.js'
|
|
3
|
+
import type { CoreUserSession } from '../../types/core.types.js'
|
|
4
|
+
import type {
|
|
5
|
+
ApprovalDecider,
|
|
6
|
+
WorkflowApprovalPolicy,
|
|
7
|
+
} from './dsl/workflow-dsl.types.js'
|
|
8
|
+
|
|
9
|
+
export class WorkflowApprovalForbiddenError extends ForbiddenError {
|
|
10
|
+
public payload: { reason: string; detail: string }
|
|
11
|
+
constructor(reason: string, detail: string) {
|
|
12
|
+
super(detail)
|
|
13
|
+
this.payload = { reason, detail }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
addError(WorkflowApprovalForbiddenError, {
|
|
17
|
+
status: 403,
|
|
18
|
+
message: 'Not authorized to answer this approval.',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const approvalDeciderFrom = (
|
|
22
|
+
session: CoreUserSession | undefined
|
|
23
|
+
): ApprovalDecider | undefined =>
|
|
24
|
+
session ? { userId: session.userId, scopes: session.scopes } : undefined
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Judge a decision against the gate's declared policy, returning the reason it
|
|
28
|
+
* is refused or `undefined` if it stands.
|
|
29
|
+
*
|
|
30
|
+
* Returns a message rather than throwing because the same judgement is needed
|
|
31
|
+
* in two places with different outcomes: refusing a live submission with a 403,
|
|
32
|
+
* and clearing an already-recorded decision on replay.
|
|
33
|
+
*/
|
|
34
|
+
export const approvalPolicyRefusal = (
|
|
35
|
+
policy: WorkflowApprovalPolicy,
|
|
36
|
+
owner: string | undefined,
|
|
37
|
+
decider: ApprovalDecider | undefined
|
|
38
|
+
): string | undefined => {
|
|
39
|
+
if (
|
|
40
|
+
policy.approverScope &&
|
|
41
|
+
!decider?.scopes?.includes(policy.approverScope)
|
|
42
|
+
) {
|
|
43
|
+
return `Answering this approval requires the '${policy.approverScope}' scope`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
switch (policy.approvers ?? 'any') {
|
|
47
|
+
case 'owner':
|
|
48
|
+
if (!owner) {
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
if (!decider?.userId || decider.userId !== owner) {
|
|
52
|
+
return 'Only the user who started this run may answer this approval'
|
|
53
|
+
}
|
|
54
|
+
return undefined
|
|
55
|
+
|
|
56
|
+
case 'not-initiator':
|
|
57
|
+
if (!decider?.userId) {
|
|
58
|
+
return 'Answering this approval requires a signed-in user'
|
|
59
|
+
}
|
|
60
|
+
if (owner && decider.userId === owner) {
|
|
61
|
+
return 'The user who started this run may not answer this approval'
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
|
|
65
|
+
case 'any':
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
}
|