@pikku/mongodb 0.12.13 → 0.12.15

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 CHANGED
@@ -1,5 +1,93 @@
1
1
  # @pikku/mongodb
2
2
 
3
+ ## 0.12.15
4
+
5
+ ### Patch Changes
6
+
7
+ - 6dada45: fix(workflow,ai-agent): make a run's owner, entry node and step function authoritative
8
+
9
+ A graph run may only start at a node the graph declared in `meta.entryNodeIds`, and
10
+ the generated `POST /workflow/:workflowName/graph/:nodeId` route that let an HTTP
11
+ caller pick the entry node is gone. `startNode` stays for `PikkuTriggerService`,
12
+ which names a declared entry node anyway.
13
+
14
+ `StepState` now records the `rpcName` the workflow dispatched a step with, and the
15
+ step claim rejects a queue message naming a different function with
16
+ `WorkflowStepFunctionMismatchError` before mutating any status — a step runs under
17
+ the run owner's identity and without the `expose` gate, so the message must not
18
+ choose what runs.
19
+
20
+ `approveStep` takes the caller's session, and the generated status routes and
21
+ streams assert the same `assertWorkflowRunOwner` check: a run started through a
22
+ session may only be read and approved by that session's user. A run with no
23
+ recorded owner (trigger, scheduler, unauthenticated route) has nobody to compare
24
+ against and is still gated by the entrypoint's own `auth`/`permissions`.
25
+
26
+ `AIRunStateService.resolveApproval` is now a compare-and-swap returning whether
27
+ _this_ caller made the claim, and both agent resume paths run a tool only for the
28
+ approvals they claimed — concurrent approvals of one tool call no longer all
29
+ execute it.
30
+
31
+ - Updated dependencies [41c1a95]
32
+ - Updated dependencies [ce96383]
33
+ - Updated dependencies [7e60867]
34
+ - Updated dependencies [f8f1244]
35
+ - Updated dependencies [dcf20cb]
36
+ - Updated dependencies [6512384]
37
+ - Updated dependencies [e3b4c14]
38
+ - Updated dependencies [efd0ed1]
39
+ - Updated dependencies [cba98fb]
40
+ - Updated dependencies [ce96383]
41
+ - Updated dependencies [f8f1244]
42
+ - Updated dependencies [f8f1244]
43
+ - Updated dependencies [6e93a35]
44
+ - Updated dependencies [6dada45]
45
+ - @pikku/core@0.12.80
46
+
47
+ ## 0.12.14
48
+
49
+ ### Patch Changes
50
+
51
+ - 78b29f0: `SecretService` now returns a `SecretValue<T>` rather than the bare value, so a
52
+ vault secret cannot reach a sink by accident.
53
+
54
+ `SecretValue` is nominally typed, which means it is not assignable to `string`
55
+ (or to any other concretely-typed field). Every sink with a real type — a
56
+ database column, an email body, a session payload — rejects it with no lint
57
+ rule involved. The sinks typed `any`, `unknown`, or a free generic — the logger,
58
+ queue payloads, webhook and email inputs, and a function's own output — are
59
+ guarded with `Safe<T>`, which collapses a `SecretValue` found anywhere inside
60
+ `T`, however deeply nested, to `never`.
61
+
62
+ Unwrap deliberately at the point the secret reaches the wire:
63
+
64
+ ```ts
65
+ const secret = await secrets.getSecret('BETTER_AUTH_SECRET')
66
+ betterAuth({ secret: secret.reveal() })
67
+ ```
68
+
69
+ Two behaviours cover what types cannot see. Structured serialization redacts —
70
+ `JSON.stringify` and node's inspect both yield `[secret]`, so an audit or log
71
+ write stays honest without crashing the request. String coercion throws
72
+ `SecretCoercionError`, because a template literal is always a leak.
73
+
74
+ `AuditLog.write` is guarded the same way as the logger, since an audit event
75
+ carries `input` and `metadata` as `unknown` and nominality alone cannot stop a
76
+ secret landing in one.
77
+
78
+ `.reveal()` is the deliberate escape hatch, and what it hands back is an
79
+ ordinary string as far as every sink signature is concerned. **PKU953** closes
80
+ that gap: under `pikku all --security` the inspector reports a revealed secret
81
+ that flows into a logger, an audit, a queue, an email or a webhook — `console` included.
82
+
83
+ This also fixed a real one: `remote-addon-auth.ts` called `String(token)` on an
84
+ `unknown` and wrote the result straight into an `Authorization` header.
85
+
86
+ - Updated dependencies [62ea4cc]
87
+ - Updated dependencies [9dddff8]
88
+ - Updated dependencies [78b29f0]
89
+ - @pikku/core@0.12.76
90
+
3
91
  ## 0.12.13
4
92
 
5
93
  ### Patch Changes
@@ -36,6 +36,6 @@ export declare class MongoDBAIStorageService implements AIStorageService, AIRunS
36
36
  run: AgentRunState;
37
37
  approval: NonNullable<AgentRunState['pendingApprovals']>[number];
38
38
  } | null>;
39
- resolveApproval(toolCallId: string, status: 'approved' | 'denied'): Promise<void>;
39
+ resolveApproval(toolCallId: string, status: 'approved' | 'denied'): Promise<boolean>;
40
40
  close(): Promise<void>;
41
41
  }
@@ -402,7 +402,9 @@ export class MongoDBAIStorageService {
402
402
  return { run, approval };
403
403
  }
404
404
  async resolveApproval(toolCallId, status) {
405
- await this.toolCalls.updateOne({ _id: toolCallId }, { $set: { approvalStatus: status } });
405
+ // Only the caller that moves the tool call off 'pending' has claimed it.
406
+ const result = await this.toolCalls.updateOne({ _id: toolCallId, approvalStatus: 'pending' }, { $set: { approvalStatus: status } });
407
+ return result.modifiedCount > 0;
406
408
  }
407
409
  async close() { }
408
410
  }
@@ -1,4 +1,5 @@
1
- import type { SecretService } from '@pikku/core/services';
1
+ import { type SecretValue } from '@pikku/core';
2
+ import type { SecretService, SecretValues } from '@pikku/core/services';
2
3
  import type { Db } from 'mongodb';
3
4
  export interface MongoDBSecretServiceConfig {
4
5
  key: string;
@@ -25,10 +26,10 @@ export declare class MongoDBSecretService implements SecretService {
25
26
  private logAudit;
26
27
  private getKEKSalt;
27
28
  private getKEK;
28
- getSecret<T = string>(key: string): Promise<T>;
29
+ getSecret<T = string>(key: string): Promise<SecretValue<T>>;
29
30
  hasSecret(key: string): Promise<boolean>;
30
31
  setSecret(key: string, value: unknown): Promise<void>;
31
32
  deleteSecret(key: string): Promise<void>;
32
- getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<T>;
33
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
33
34
  rotateKEK(): Promise<number>;
34
35
  }
@@ -1,3 +1,4 @@
1
+ import { createSecretValue, isSecretValue } from '@pikku/core';
1
2
  import { deriveKEK, generateKEKSalt, envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
2
3
  export class MongoDBSecretService {
3
4
  db;
@@ -74,14 +75,16 @@ export class MongoDBSecretService {
74
75
  const kek = await this.getKEK(row.keyVersion);
75
76
  const result = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
76
77
  await this.logAudit(key, 'read');
77
- return result;
78
+ return createSecretValue(result);
78
79
  }
79
80
  async hasSecret(key) {
80
81
  const count = await this.secrets.countDocuments({ _id: key }, { limit: 1 });
81
82
  return count > 0;
82
83
  }
83
84
  async setSecret(key, value) {
84
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(await this.getKEK(this.keyVersion), value);
85
+ // Encrypting the wrapper would store its redaction, not the secret —
86
+ // writing a secret back to the vault is exactly what this method is for.
87
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(await this.getKEK(this.keyVersion), isSecretValue(value) ? value.reveal() : value);
85
88
  const now = new Date();
86
89
  await this.secrets.updateOne({ _id: key }, {
87
90
  $set: {
@@ -109,7 +112,7 @@ export class MongoDBSecretService {
109
112
  for (const row of rows) {
110
113
  try {
111
114
  const kek = await this.getKEK(row.keyVersion);
112
- out[row._id] = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
115
+ out[row._id] = createSecretValue(await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek));
113
116
  }
114
117
  catch (cause) {
115
118
  throw new Error(`Failed to decrypt secret "${row._id}" (key_version ${row.keyVersion}): ` +
@@ -85,6 +85,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
85
85
  return {
86
86
  stepId,
87
87
  status: 'pending',
88
+ rpcName,
88
89
  result: undefined,
89
90
  error: undefined,
90
91
  attemptCount: 1,
@@ -109,6 +110,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
109
110
  return {
110
111
  stepId: row._id,
111
112
  status: row.status,
113
+ rpcName: row.rpcName ?? null,
112
114
  result: row.result ?? undefined,
113
115
  error: row.error ?? undefined,
114
116
  attemptCount,
@@ -238,6 +240,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
238
240
  return {
239
241
  stepId: row._id,
240
242
  status: row.status,
243
+ rpcName: row.rpcName ?? null,
241
244
  result: row.result ?? undefined,
242
245
  error: row.error ?? undefined,
243
246
  attemptCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/mongodb",
3
- "version": "0.12.13",
3
+ "version": "0.12.15",
4
4
  "description": "MongoDB services for Pikku — workflow state, agent runs, secrets and channel stores",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "prepublishOnly": "yarn build"
19
19
  },
20
20
  "peerDependencies": {
21
- "@pikku/core": "^0.12.74"
21
+ "@pikku/core": "^0.12.80"
22
22
  },
23
23
  "dependencies": {
24
24
  "mongodb": "^6.12.0"
@@ -565,11 +565,13 @@ export class MongoDBAIStorageService
565
565
  async resolveApproval(
566
566
  toolCallId: string,
567
567
  status: 'approved' | 'denied'
568
- ): Promise<void> {
569
- await this.toolCalls.updateOne(
570
- { _id: toolCallId },
568
+ ): Promise<boolean> {
569
+ // Only the caller that moves the tool call off 'pending' has claimed it.
570
+ const result = await this.toolCalls.updateOne(
571
+ { _id: toolCallId, approvalStatus: 'pending' },
571
572
  { $set: { approvalStatus: status } }
572
573
  )
574
+ return result.modifiedCount > 0
573
575
  }
574
576
 
575
577
  public async close(): Promise<void> {}
@@ -1,4 +1,5 @@
1
- import type { SecretService } from '@pikku/core/services'
1
+ import { createSecretValue, isSecretValue, type SecretValue } from '@pikku/core'
2
+ import type { SecretService, SecretValues } from '@pikku/core/services'
2
3
  import type { Db, Collection } from 'mongodb'
3
4
  import {
4
5
  deriveKEK,
@@ -124,14 +125,14 @@ export class MongoDBSecretService implements SecretService {
124
125
  return kek
125
126
  }
126
127
 
127
- async getSecret<T = string>(key: string): Promise<T> {
128
+ async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
128
129
  const row = await this.secrets.findOne({ _id: key })
129
130
  if (!row) throw new Error('Requested secret not found')
130
131
 
131
132
  const kek = await this.getKEK(row.keyVersion)
132
133
  const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
133
134
  await this.logAudit(key, 'read')
134
- return result
135
+ return createSecretValue(result)
135
136
  }
136
137
 
137
138
  async hasSecret(key: string): Promise<boolean> {
@@ -140,9 +141,11 @@ export class MongoDBSecretService implements SecretService {
140
141
  }
141
142
 
142
143
  async setSecret(key: string, value: unknown): Promise<void> {
144
+ // Encrypting the wrapper would store its redaction, not the secret —
145
+ // writing a secret back to the vault is exactly what this method is for.
143
146
  const { ciphertext, wrappedDEK } = await envelopeEncrypt(
144
147
  await this.getKEK(this.keyVersion),
145
- value
148
+ isSecretValue(value) ? value.reveal() : value
146
149
  )
147
150
  const now = new Date()
148
151
 
@@ -173,7 +176,7 @@ export class MongoDBSecretService implements SecretService {
173
176
 
174
177
  async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
175
178
  keys: (keyof T & string)[]
176
- ): Promise<T> {
179
+ ): Promise<Partial<SecretValues<T>>> {
177
180
  const rows = await this.secrets
178
181
  .find({ _id: { $in: keys } } as any)
179
182
  .toArray()
@@ -181,10 +184,8 @@ export class MongoDBSecretService implements SecretService {
181
184
  for (const row of rows) {
182
185
  try {
183
186
  const kek = await this.getKEK(row.keyVersion)
184
- out[row._id] = await envelopeDecrypt(
185
- kek,
186
- row.ciphertext,
187
- row.wrappedDek
187
+ out[row._id] = createSecretValue(
188
+ await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
188
189
  )
189
190
  } catch (cause) {
190
191
  throw new Error(
@@ -194,7 +195,7 @@ export class MongoDBSecretService implements SecretService {
194
195
  )
195
196
  }
196
197
  }
197
- return out as T
198
+ return out as Partial<SecretValues<T>>
198
199
  }
199
200
 
200
201
  async rotateKEK(): Promise<number> {
@@ -171,7 +171,8 @@ function registerTests(name: string, getDb: () => Db) {
171
171
  'batch-b': string
172
172
  }>(['batch-a', 'batch-b'])
173
173
 
174
- assert.deepEqual(out, { 'batch-a': { v: 1 }, 'batch-b': 'two' })
174
+ assert.deepEqual(out['batch-a']!.reveal(), { v: 1 })
175
+ assert.equal(out['batch-b']!.reveal(), 'two')
175
176
  })
176
177
 
177
178
  test('getSecrets omits keys that were never stored', async () => {
@@ -181,7 +182,8 @@ function registerTests(name: string, getDb: () => Db) {
181
182
 
182
183
  const out = await service.getSecrets(['present', 'never-stored'])
183
184
 
184
- assert.deepEqual(out, { present: 'here' })
185
+ assert.deepEqual(Object.keys(out), ['present'])
186
+ assert.equal(out.present!.reveal(), 'here')
185
187
  })
186
188
  })
187
189
  }
@@ -203,6 +203,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
203
203
  return {
204
204
  stepId,
205
205
  status: 'pending',
206
+ rpcName,
206
207
  result: undefined,
207
208
  error: undefined,
208
209
  attemptCount: 1,
@@ -233,6 +234,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
233
234
  return {
234
235
  stepId: row._id,
235
236
  status: row.status as StepState['status'],
237
+ rpcName: row.rpcName ?? null,
236
238
  result: row.result ?? undefined,
237
239
  error: row.error ?? undefined,
238
240
  attemptCount,
@@ -426,6 +428,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
426
428
  return {
427
429
  stepId: row._id,
428
430
  status: row.status as StepState['status'],
431
+ rpcName: row.rpcName ?? null,
429
432
  result: row.result ?? undefined,
430
433
  error: row.error ?? undefined,
431
434
  attemptCount,