@sanoma-ai/workflows 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +193 -50
  2. package/SECRET-SUPPORT.md +33 -0
  3. package/dist/apply-steps.js +5 -2
  4. package/dist/apply-steps.js.map +1 -1
  5. package/dist/apply.d.ts +3 -0
  6. package/dist/apply.js +27 -13
  7. package/dist/apply.js.map +1 -1
  8. package/dist/approvals.d.ts +1 -0
  9. package/dist/approvals.js +6 -1
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/apps.d.ts +13 -24
  12. package/dist/apps.js +69 -68
  13. package/dist/apps.js.map +1 -1
  14. package/dist/ast.d.ts +9 -7
  15. package/dist/ast.js +2 -0
  16. package/dist/ast.js.map +1 -1
  17. package/dist/authentication.d.ts +54 -0
  18. package/dist/authentication.js +138 -0
  19. package/dist/authentication.js.map +1 -0
  20. package/dist/call-workflow.d.ts +5 -0
  21. package/dist/call-workflow.js +4 -3
  22. package/dist/call-workflow.js.map +1 -1
  23. package/dist/call.d.ts +3 -2
  24. package/dist/call.js +36 -7
  25. package/dist/call.js.map +1 -1
  26. package/dist/cedar.js +14 -1
  27. package/dist/cedar.js.map +1 -1
  28. package/dist/change-steps.js +7 -5
  29. package/dist/change-steps.js.map +1 -1
  30. package/dist/change.d.ts +7 -0
  31. package/dist/change.js +7 -0
  32. package/dist/change.js.map +1 -1
  33. package/dist/client.d.ts +62 -10
  34. package/dist/client.js +272 -47
  35. package/dist/client.js.map +1 -1
  36. package/dist/collection.d.ts +7 -0
  37. package/dist/collection.js +5 -2
  38. package/dist/collection.js.map +1 -1
  39. package/dist/config.d.ts +16 -4
  40. package/dist/config.js +89 -21
  41. package/dist/config.js.map +1 -1
  42. package/dist/connection.d.ts +389 -0
  43. package/dist/connection.js +657 -0
  44. package/dist/connection.js.map +1 -0
  45. package/dist/credentials.d.ts +7 -53
  46. package/dist/credentials.js +84 -247
  47. package/dist/credentials.js.map +1 -1
  48. package/dist/db.js +34 -2
  49. package/dist/db.js.map +1 -1
  50. package/dist/define.d.ts +9 -2
  51. package/dist/define.js.map +1 -1
  52. package/dist/definitions.d.ts +20 -0
  53. package/dist/definitions.js +16 -0
  54. package/dist/definitions.js.map +1 -0
  55. package/dist/deployment-key.d.ts +2 -0
  56. package/dist/deployment-key.js +22 -0
  57. package/dist/deployment-key.js.map +1 -0
  58. package/dist/describe.d.ts +15 -175
  59. package/dist/describe.js +122 -35
  60. package/dist/describe.js.map +1 -1
  61. package/dist/description.d.ts +532 -0
  62. package/dist/description.js +381 -0
  63. package/dist/description.js.map +1 -0
  64. package/dist/direct.js +7 -0
  65. package/dist/direct.js.map +1 -1
  66. package/dist/drift.d.ts +1 -1
  67. package/dist/drift.js +1 -1
  68. package/dist/drift.js.map +1 -1
  69. package/dist/env.d.ts +2 -0
  70. package/dist/env.js +2 -2
  71. package/dist/env.js.map +1 -1
  72. package/dist/fake.d.ts +13 -1
  73. package/dist/fake.js +70 -0
  74. package/dist/fake.js.map +1 -1
  75. package/dist/index.d.ts +11 -3
  76. package/dist/index.js +10 -2
  77. package/dist/index.js.map +1 -1
  78. package/dist/ledger.d.ts +2 -0
  79. package/dist/ledger.js +1 -1
  80. package/dist/ledger.js.map +1 -1
  81. package/dist/lint.d.ts +0 -9
  82. package/dist/lint.js +2 -17
  83. package/dist/lint.js.map +1 -1
  84. package/dist/load.d.ts +85 -0
  85. package/dist/load.js +391 -0
  86. package/dist/load.js.map +1 -0
  87. package/dist/lockfile.d.ts +2 -0
  88. package/dist/lockfile.js +8 -3
  89. package/dist/lockfile.js.map +1 -1
  90. package/dist/op.d.ts +22 -5
  91. package/dist/op.js +20 -2
  92. package/dist/op.js.map +1 -1
  93. package/dist/outline.d.ts +5 -0
  94. package/dist/outline.js +46 -5
  95. package/dist/outline.js.map +1 -1
  96. package/dist/paths.d.ts +18 -2
  97. package/dist/paths.js +19 -7
  98. package/dist/paths.js.map +1 -1
  99. package/dist/plugin.d.ts +0 -11
  100. package/dist/plugin.js +3 -18
  101. package/dist/plugin.js.map +1 -1
  102. package/dist/policy.d.ts +2 -0
  103. package/dist/policy.js.map +1 -1
  104. package/dist/resource.d.ts +10 -4
  105. package/dist/resource.js +11 -4
  106. package/dist/resource.js.map +1 -1
  107. package/dist/resources.d.ts +33 -76
  108. package/dist/resources.js +219 -227
  109. package/dist/resources.js.map +1 -1
  110. package/dist/run.d.ts +4 -0
  111. package/dist/run.js.map +1 -1
  112. package/dist/sandbox.d.ts +5 -2
  113. package/dist/sandbox.js +56 -7
  114. package/dist/sandbox.js.map +1 -1
  115. package/dist/scenario.d.ts +5 -0
  116. package/dist/scenario.js +3 -2
  117. package/dist/scenario.js.map +1 -1
  118. package/dist/scenarios.js +7 -1
  119. package/dist/scenarios.js.map +1 -1
  120. package/dist/schema.js +8 -0
  121. package/dist/schema.js.map +1 -1
  122. package/dist/secret-store-check.d.ts +11 -0
  123. package/dist/secret-store-check.js +76 -0
  124. package/dist/secret-store-check.js.map +1 -0
  125. package/dist/secret-store.d.ts +106 -0
  126. package/dist/secret-store.js +228 -0
  127. package/dist/secret-store.js.map +1 -0
  128. package/dist/secret.d.ts +2 -0
  129. package/dist/secret.js +32 -0
  130. package/dist/secret.js.map +1 -1
  131. package/dist/shared.d.ts +21 -11
  132. package/dist/shared.js +41 -7
  133. package/dist/shared.js.map +1 -1
  134. package/dist/sites.d.ts +4 -93
  135. package/dist/sites.js +76 -84
  136. package/dist/sites.js.map +1 -1
  137. package/dist/steps.d.ts +3 -0
  138. package/dist/steps.js.map +1 -1
  139. package/dist/version.d.ts +2 -1
  140. package/dist/version.js +2 -1
  141. package/dist/version.js.map +1 -1
  142. package/dist/wakeup-driver.js +15 -6
  143. package/dist/wakeup-driver.js.map +1 -1
  144. package/dist/worker.js +12 -8
  145. package/dist/worker.js.map +1 -1
  146. package/dist/writer.d.ts +53 -14
  147. package/dist/writer.js +113 -43
  148. package/dist/writer.js.map +1 -1
  149. package/oxlint.json +1 -5
  150. package/package.json +6 -1
  151. package/src/apply-steps.ts +5 -2
  152. package/src/apply.ts +54 -21
  153. package/src/approvals.ts +7 -1
  154. package/src/apps.ts +71 -86
  155. package/src/ast.ts +6 -6
  156. package/src/authentication.ts +191 -0
  157. package/src/call-workflow.ts +5 -4
  158. package/src/call.ts +52 -9
  159. package/src/cedar.ts +14 -1
  160. package/src/change-steps.ts +7 -4
  161. package/src/change.ts +10 -0
  162. package/src/client.ts +346 -45
  163. package/src/collection.ts +10 -2
  164. package/src/config.ts +107 -19
  165. package/src/connection.ts +912 -0
  166. package/src/credentials.ts +94 -281
  167. package/src/db.ts +34 -2
  168. package/src/define.ts +7 -2
  169. package/src/definitions.ts +31 -0
  170. package/src/deployment-key.ts +23 -0
  171. package/src/describe.ts +150 -227
  172. package/src/description.ts +426 -0
  173. package/src/direct.ts +7 -0
  174. package/src/drift.ts +10 -2
  175. package/src/env.ts +4 -2
  176. package/src/fake.ts +71 -0
  177. package/src/index.ts +53 -1
  178. package/src/ledger.ts +3 -1
  179. package/src/lint.ts +2 -17
  180. package/src/load.ts +466 -0
  181. package/src/lockfile.ts +10 -3
  182. package/src/op.ts +41 -7
  183. package/src/outline.ts +53 -7
  184. package/src/paths.ts +21 -6
  185. package/src/plugin.ts +3 -24
  186. package/src/policy.ts +2 -0
  187. package/src/resource.ts +24 -6
  188. package/src/resources.ts +246 -301
  189. package/src/run.ts +4 -0
  190. package/src/sandbox.ts +61 -7
  191. package/src/scenario.ts +24 -6
  192. package/src/scenarios.ts +7 -1
  193. package/src/schema.ts +10 -0
  194. package/src/secret-store-check.ts +93 -0
  195. package/src/secret-store.ts +314 -0
  196. package/src/secret.ts +29 -0
  197. package/src/shared.ts +44 -10
  198. package/src/sites.ts +85 -179
  199. package/src/steps.ts +3 -2
  200. package/src/version.ts +2 -1
  201. package/src/wakeup-driver.ts +22 -6
  202. package/src/worker.ts +12 -8
  203. package/src/writer.ts +145 -56
  204. package/dist/datafile.d.ts +0 -97
  205. package/dist/datafile.js +0 -479
  206. package/dist/datafile.js.map +0 -1
  207. package/src/datafile.ts +0 -601
package/dist/client.js CHANGED
@@ -1,4 +1,6 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { connectionDriver, connectionTargetSchema, } from "./connection.js";
2
+ import { encodeCredentials, decodeCredentials, verifyAuthentication, AuthenticationError } from "./authentication.js";
3
+ import { createHash, randomUUID } from "node:crypto";
2
4
  import { isDeepStrictEqual } from "node:util";
3
5
  import { Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
4
6
  import { z } from "zod";
@@ -7,7 +9,7 @@ import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call
7
9
  import { chatStore } from "./chats.js";
8
10
  import { collectionsDriver } from "./collection.js";
9
11
  import { resolveConfig } from "./config.js";
10
- import { deleteCredential, storeCredential, storedCredentials, storedSecrets } from "./credentials.js";
12
+ import { configuredSecrets } from "./credentials.js";
11
13
  import { dbosClient, lazyPool } from "./db.js";
12
14
  import { credentialsOf } from "./env.js";
13
15
  import { Principal } from "./define.js";
@@ -20,6 +22,7 @@ import { insertRecord, ownedLedger } from "./ledger.js";
20
22
  import { warn } from "./log.js";
21
23
  import { allowAll } from "./policy.js";
22
24
  import { admit, readPeople } from "./resources.js";
25
+ import { SecretStoreError } from "./secret-store.js";
23
26
  import { BUILT_IN_WORKFLOWS, CALL_WORKFLOW, CHANGE_EVENT, CHANGE_MERGE, CHANGE_TOPIC, CHANGE_WORKFLOW, changeIdOf, DATA_VENDOR, ENDED_STATUSES, errorMessage, HOST_CHECK_AGENT, isEnded, isVisitor, refusalOf, SCHEDULE_AGENT, WORKFLOW_ACTION, } from "./shared.js";
24
27
  import { draftOf } from "./source.js";
25
28
  /** Whether two references name one repository: a host's names are compared without case. */
@@ -88,7 +91,7 @@ export class SanomaClient {
88
91
  this.options = options;
89
92
  this.audit = ownedLedger(config);
90
93
  this.pool = lazyPool(config.databaseUrl, "client");
91
- this.storedSecrets = storedSecrets(() => this.db(), "sanoma");
94
+ this.storedSecrets = configuredSecrets(config.secrets, () => this.db());
92
95
  this.chats = chatStore(() => this.db());
93
96
  }
94
97
  /**
@@ -99,7 +102,15 @@ export class SanomaClient {
99
102
  static async connect(config, options = {}) {
100
103
  const resolved = resolveConfig(config);
101
104
  const dbos = await dbosClient(resolved.databaseUrl, resolved.appName);
102
- return new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
105
+ const client = new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
106
+ try {
107
+ await client.storedSecrets.validate();
108
+ return client;
109
+ }
110
+ catch (error) {
111
+ await client.close();
112
+ throw error;
113
+ }
103
114
  }
104
115
  /**
105
116
  * Checks the input against the workflow's schema, then queues a run for the worker. The run
@@ -211,20 +222,26 @@ export class SanomaClient {
211
222
  if (!fn)
212
223
  throw new SanomaError("invalid_input", `${op.id} has no driver: the connector package must attach one`);
213
224
  const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
225
+ const connection = await this.config.connections.select(op.vendor, options.connection, actor.id);
226
+ const selected = connection && {
227
+ $connection: connection.id,
228
+ vendor: connection.vendor,
229
+ revision: connection.revision,
230
+ };
214
231
  const runId = `direct:${actor.id}:${id}`;
215
232
  const heldId = `call:${actor.id}:${id}`;
216
- const held = { input: { op: op.id, input }, startedBy: actor };
233
+ const held = { input: { op: op.id, input, ...(selected && { connection: selected }) }, startedBy: actor };
217
234
  const logged = recorded(op, parsed);
218
235
  const write = op.effect !== "read";
219
236
  // Only an id the caller gave can have been used: a write made, or held, with it settles it.
220
237
  if (write && options.id !== undefined) {
221
- const first = (await this.recordedCall(runId, op.id, logged)) ?? (await this.heldCall(op.id, heldId, held));
238
+ const first = (await this.recordedCall(runId, op.id, logged, connection)) ?? (await this.heldCall(op.id, heldId, held));
222
239
  if (first)
223
240
  return first.output;
224
241
  }
225
242
  const before = await beforeOf(policy, op, parsed, this.config.collections, (get, key) => drivers.get(get)?.({ key }, { idempotencyKey: `${runId}:before`, runId, opId: get, attempt: 1, secrets: this.storedSecrets, by: actor.id }));
226
243
  const run = { id: runId, workflow: CALL_WORKFLOW, approvals: [] };
227
- const decision = decideCall(policy, op, parsed, actor, run, before);
244
+ const decision = decideCall(policy, op, parsed, actor, run, { ...before, ...(connection && { connection }) });
228
245
  if (decision.kind === "deny")
229
246
  throw new PolicyDeniedError(op.id, decision.reason);
230
247
  if (decision.kind === "approve") {
@@ -239,7 +256,10 @@ export class SanomaClient {
239
256
  attempt: 1,
240
257
  secrets: this.storedSecrets,
241
258
  by: actor.id,
259
+ principal: actor,
260
+ ...(connection && { connection }),
242
261
  };
262
+ await this.config.connections.assertCurrent(connection, actor.id);
243
263
  if (!write)
244
264
  return outputOf(op, () => fn(parsed, context));
245
265
  return this.directWrite({ op, fn, parsed, logged, actor, decision, context });
@@ -255,13 +275,21 @@ export class SanomaClient {
255
275
  const { op, parsed, logged, actor, decision, context } = call;
256
276
  const { runId } = context;
257
277
  const { dataStore, collections, ledger } = this.config;
258
- const conn = op.vendor === DATA_VENDOR && !ledger && dataStore ? await (await dataStore.db()).connect() : undefined;
278
+ const connectionMutation = op.id.startsWith("sanoma.connection.");
279
+ const transactionStore = connectionMutation
280
+ ? this.config.connectionPool
281
+ : op.vendor === DATA_VENDOR
282
+ ? dataStore
283
+ : undefined;
284
+ const conn = !ledger && transactionStore ? await (await transactionStore.db()).connect() : undefined;
259
285
  let broken;
260
286
  try {
261
287
  await conn?.query("BEGIN");
262
- // On the transaction's connection, the collection's own driver: it has no `env` to guard.
288
+ // The metadata or collection driver shares the ledger transaction; neither has an env guard.
263
289
  const fn = conn
264
- ? collectionsDriver([collections.get(op.resource)], { db: async () => conn }).ops[`${op.resource}.${op.name}`]
290
+ ? (connectionMutation
291
+ ? connectionDriver(this.config.connections.onDatabase(conn))
292
+ : collectionsDriver([collections.get(op.resource)], { db: async () => conn })).ops[`${op.resource}.${op.name}`]
265
293
  : call.fn;
266
294
  const at = Date.now();
267
295
  const record = (outcome) => ({
@@ -278,6 +306,7 @@ export class SanomaClient {
278
306
  effect: op.effect,
279
307
  input: logged,
280
308
  decision,
309
+ ...(context.connection && { connection: context.connection }),
281
310
  ...outcome,
282
311
  durationMs: Date.now() - at,
283
312
  });
@@ -300,7 +329,7 @@ export class SanomaClient {
300
329
  return output;
301
330
  }
302
331
  await conn.query("ROLLBACK");
303
- return (await this.recordedCall(runId, op.id, logged)).output;
332
+ return (await this.recordedCall(runId, op.id, logged, context.connection)).output;
304
333
  }
305
334
  catch (err) {
306
335
  await conn?.query("ROLLBACK").catch((e) => (broken = e));
@@ -314,11 +343,15 @@ export class SanomaClient {
314
343
  * What the direct write recorded as `runId` returned, when one did: refused (`invalid_input`)
315
344
  * when it was of another operation or input, and its failure again (`driver_failed`) when it failed.
316
345
  */
317
- async recordedCall(runId, opId, input) {
346
+ async recordedCall(runId, opId, input, connection) {
318
347
  const first = (await this.audit.store.read(runId)).find((r) => r.type === "op.called");
319
348
  if (!first)
320
349
  return undefined;
321
- const differs = [...(first.op === opId ? [] : ["op"]), ...(sameJson(first.input, input) ? [] : ["input"])];
350
+ const differs = [
351
+ ...(first.op === opId ? [] : ["op"]),
352
+ ...(sameJson(first.input, input) ? [] : ["input"]),
353
+ ...(sameJson(first.connection ?? null, connection ?? null) ? [] : ["connection"]),
354
+ ];
322
355
  if (differs.length) {
323
356
  throw new SanomaError("invalid_input", `Call ${runId} was made already with a different ${differs.join(" and ")}; use another id`, { runId, differs });
324
357
  }
@@ -794,7 +827,8 @@ export class SanomaClient {
794
827
  issues: [{ path: ["value"], message: problem, code: "custom" }],
795
828
  });
796
829
  }
797
- await storeCredential(await this.db(), { name, value, setBy });
830
+ const current = await this.storedSecrets.read(name);
831
+ await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
798
832
  }
799
833
  /**
800
834
  * Deletes the stored value of a declared variable, and tells the workers, which unset it unless
@@ -804,7 +838,9 @@ export class SanomaClient {
804
838
  async clearCredential(name, options) {
805
839
  await this.admittedBy(options);
806
840
  this.declared(name, () => undefined);
807
- await deleteCredential(await this.db(), name);
841
+ const current = await this.storedSecrets.read(name);
842
+ if (current)
843
+ await this.storedSecrets.delete(name, { expectedVersion: current.version, by: options.by });
808
844
  }
809
845
  /**
810
846
  * Stores a secret as `by`, and tells the workers: a value under any variable's name, declared by
@@ -822,50 +858,239 @@ export class SanomaClient {
822
858
  const path = VARIABLE.test(name) ? "value" : "name";
823
859
  throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
824
860
  }
825
- await storeCredential(await this.db(), { name, value, setBy });
861
+ const current = await this.storedSecrets.read(name);
862
+ await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
863
+ }
864
+ /** Removes a logical secret from the selected store; external bindings remain read-only. */
865
+ async clearSecret(name, options) {
866
+ const by = await this.admittedBy(options);
867
+ if (!VARIABLE.test(name)) {
868
+ const message = "That is not a variable name";
869
+ throw new SanomaError("invalid_input", message, {
870
+ issues: [{ path: ["name"], message, code: "custom" }],
871
+ });
872
+ }
873
+ const current = await this.storedSecrets.read(name);
874
+ if (current)
875
+ await this.storedSecrets.delete(name, { expectedVersion: current.version, by });
876
+ }
877
+ connectionAuthorization() {
878
+ return createHash("sha256")
879
+ .update(JSON.stringify({
880
+ version: this.config.version,
881
+ policies: this.config.policies,
882
+ problems: this.config.policyProblems,
883
+ }))
884
+ .digest("hex");
885
+ }
886
+ /** Safe account cards, scoped to the admitted principal. No provider or secret reads. */
887
+ async connections(options) {
888
+ const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
889
+ return this.config.connections.cards(actor.id);
890
+ }
891
+ /** Creation choices are scoped by code to the admitted person's current groups. */
892
+ async connectionRules(options) {
893
+ const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
894
+ return [...this.config.connections.catalog.rules]
895
+ .filter(([, rule]) => rule.createBy.some((by) => (typeof by === "string" ? by === actor.id : actor.groups?.includes(by.group))))
896
+ .map(([id, rule]) => ({ id, rule }));
897
+ }
898
+ /** Readiness only, without reading or listing any credential. The adapter's bootstrap
899
+ * authority is checked by validate; provider details never escape this boundary. */
900
+ async secretStoreStatus(options) {
901
+ await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
902
+ const { store, namespace } = this.storedSecrets;
903
+ let problem;
904
+ try {
905
+ await this.storedSecrets.validate();
906
+ }
907
+ catch (error) {
908
+ problem = error.code;
909
+ }
910
+ return {
911
+ store: store.id,
912
+ namespace,
913
+ capabilities: { ...store.capabilities },
914
+ ready: !problem,
915
+ ...(problem && { problem }),
916
+ };
917
+ }
918
+ /** Secure intake endpoint, never a workflow operation. Only its metadata-only prepare gate
919
+ * enters policy/approval history; callers keep the value in a password form while reviewing. */
920
+ async previewConnection(target, credentials, options) {
921
+ const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
922
+ const id = parseOrThrow(z.string().min(1).max(200), options.id, "Name the setup request");
923
+ target = parseOrThrow(connectionTargetSchema, target, "Select a declared connection or creation rule");
924
+ const setup = await this.config.connections.setup(target, actor, id);
925
+ const authorized = (await this.call("sanoma.connection.prepare", {
926
+ target,
927
+ requestId: id,
928
+ definition: setup.definition,
929
+ authorization: this.connectionAuthorization(),
930
+ }, { actor, id: `connection-prepare:${id}` }));
931
+ if (!isDeepStrictEqual(authorized, setup))
932
+ throw new SanomaError("invalid_input", "The connection changed; start a new review");
933
+ const method = this.config.connections.catalog.methods.get(setup.vendor)[setup.method];
934
+ const external = method.kind !== "workload-identity" &&
935
+ (setup.location?.management === "external" || !this.storedSecrets.store.capabilities.writable);
936
+ if (!external &&
937
+ method.kind !== "workload-identity" &&
938
+ this.storedSecrets.store.capabilities.conditionalWrites === "none")
939
+ throw new SecretStoreError("unsupported");
940
+ if (credentials !== undefined && (external || method.kind === "workload-identity"))
941
+ throw new SecretStoreError("read_only");
942
+ const existing = setup.credential ? await this.storedSecrets.read(setup.credential) : undefined;
943
+ if (external && !existing)
944
+ throw new SecretStoreError("unavailable");
945
+ const encoded = method.kind === "workload-identity"
946
+ ? undefined
947
+ : external
948
+ ? existing.value
949
+ : encodeCredentials(method, credentials);
950
+ const identity = await verifyAuthentication(method, encoded === undefined ? undefined : decodeCredentials(method, encoded));
951
+ if (setup.expectedAccount && setup.expectedAccount !== identity.account)
952
+ throw new AuthenticationError("wrong_account");
953
+ if (setup.requiredScopes.some((scope) => !identity.scopes.includes(scope)))
954
+ throw new AuthenticationError("scope_changed");
955
+ return {
956
+ ...setup,
957
+ requestId: id,
958
+ authorization: this.connectionAuthorization(),
959
+ account: identity.account,
960
+ scopes: [...identity.scopes],
961
+ credentialVersion: existing?.version ?? null,
962
+ ...(identity.expiresAt !== undefined && { expiresAt: identity.expiresAt }),
963
+ };
964
+ }
965
+ /** Reverify the reviewed authority, reserve its metadata under policy, then write the bundle
966
+ * outside durable execution. Activation has its own policy gate and verifies what was saved. */
967
+ async connectConnection(review, credentials, options) {
968
+ const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
969
+ if (review.authorization !== this.connectionAuthorization())
970
+ throw new SanomaError("invalid_input", "The policy changed; start a new connection review");
971
+ const setup = await this.config.connections.setup(review.target, actor, review.requestId);
972
+ for (const field of ["id", "vendor", "method", "definition", "credential", "location"]) {
973
+ if (!isDeepStrictEqual(setup[field], review[field]))
974
+ throw new SanomaError("invalid_input", "The destination changed; review it again before saving");
975
+ }
976
+ // Reuse only the metadata gate, never a cached credential or verification result.
977
+ await this.call("sanoma.connection.prepare", {
978
+ target: review.target,
979
+ requestId: review.requestId,
980
+ definition: setup.definition,
981
+ authorization: review.authorization,
982
+ }, { actor, id: `connection-prepare:${review.requestId}` });
983
+ const method = this.config.connections.catalog.methods.get(setup.vendor)[setup.method];
984
+ const external = method.kind !== "workload-identity" &&
985
+ (setup.location?.management === "external" || !this.storedSecrets.store.capabilities.writable);
986
+ if (!external &&
987
+ method.kind !== "workload-identity" &&
988
+ this.storedSecrets.store.capabilities.conditionalWrites === "none")
989
+ throw new SecretStoreError("unsupported");
990
+ if (credentials !== undefined && (external || method.kind === "workload-identity"))
991
+ throw new SecretStoreError("read_only");
992
+ const existing = setup.credential ? await this.storedSecrets.read(setup.credential) : undefined;
993
+ if (external && (!existing || existing.version !== review.credentialVersion))
994
+ throw new SecretStoreError("conflict");
995
+ if (setup.expectedRevision === review.expectedRevision && (existing?.version ?? null) !== review.credentialVersion)
996
+ throw new SecretStoreError("conflict");
997
+ const encoded = method.kind === "workload-identity"
998
+ ? undefined
999
+ : external
1000
+ ? existing.value
1001
+ : encodeCredentials(method, credentials);
1002
+ const identity = await verifyAuthentication(method, encoded === undefined ? undefined : decodeCredentials(method, encoded));
1003
+ if (identity.account !== review.account)
1004
+ throw new AuthenticationError("wrong_account");
1005
+ if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(review.scopes)].toSorted()))
1006
+ throw new AuthenticationError("scope_changed");
1007
+ const selected = (await this.call("sanoma.connection.configure", {
1008
+ target: review.target,
1009
+ requestId: review.requestId,
1010
+ expectedRevision: review.expectedRevision,
1011
+ definition: review.definition,
1012
+ account: identity.account,
1013
+ scopes: [...identity.scopes],
1014
+ }, { actor, id: `connection-configure:${review.requestId}` }));
1015
+ const ref = { vendor: selected.vendor, $connection: selected.id };
1016
+ if ((await this.config.connections.setupState(selected, actor)) === "pending" &&
1017
+ !external &&
1018
+ encoded !== undefined) {
1019
+ let written = false;
1020
+ try {
1021
+ const receipt = await this.config.connections.storedVersion(selected.id, selected.revision);
1022
+ if (receipt) {
1023
+ if (existing?.version !== receipt || existing.value !== encoded)
1024
+ throw new SecretStoreError("conflict");
1025
+ }
1026
+ else {
1027
+ const stored = await this.storedSecrets.write(setup.credential, encoded, {
1028
+ expectedVersion: review.credentialVersion,
1029
+ by: actor.id,
1030
+ });
1031
+ written = true;
1032
+ await this.config.connections.recordStoredVersion(selected.id, selected.revision, stored.version);
1033
+ }
1034
+ }
1035
+ catch (error) {
1036
+ if (written || error?.code === "unavailable")
1037
+ await this.config.connections.uncertain(ref, selected.revision, actor.id);
1038
+ throw error;
1039
+ }
1040
+ }
1041
+ const connected = (await this.call("sanoma.connection.activate", {
1042
+ connection: ref,
1043
+ expectedRevision: selected.revision,
1044
+ }, { actor, id: `connection-activate:${review.requestId}` }));
1045
+ await this.config.connections.assertCurrent(connected, actor.id);
1046
+ return connected;
826
1047
  }
827
1048
  /**
828
- * The values of these secrets, as a driver's `secrets.get` reads them: this process's
829
- * environment, which wins, else the stored value, with who set it and when. A name with neither
830
- * is left out. For server code: a page is told whether one is set, never its value.
1049
+ * Values resolved through the selected store and explicit bindings, with version and change
1050
+ * metadata. Missing names are left out. Server code only; pages receive status, never values.
831
1051
  */
832
1052
  async secrets(names) {
833
- const rows = await storedCredentials(await this.db(), names);
834
1053
  const found = new Map();
835
1054
  for (const name of names) {
836
- const env = process.env[name];
837
- const row = rows.get(name);
838
- if (env && env !== row?.value)
839
- found.set(name, { value: env, source: "environment" });
840
- else if (row)
841
- found.set(name, { ...row, source: "stored" });
1055
+ const row = await this.storedSecrets.read(name);
1056
+ const location = this.storedSecrets.location(name);
1057
+ if (row)
1058
+ found.set(name, {
1059
+ value: row.value,
1060
+ source: location.store === "environment" ? "environment" : "stored",
1061
+ version: row.version,
1062
+ location,
1063
+ ...(row.updatedBy && { setBy: row.updatedBy }),
1064
+ ...(row.updatedAt && { setAt: row.updatedAt }),
1065
+ });
842
1066
  }
843
1067
  return found;
844
1068
  }
845
- /**
846
- * Each vendor's declared variables and their statuses now: from this process's environment,
847
- * which wins, else from the stored credentials, with `source` saying which (a value equal to
848
- * the stored one, as a worker in this process loads it, is `stored`), and who set a stored one
849
- * and when. Never a value.
850
- */
1069
+ /** Non-secret statuses for the selected store and declared bindings. */
851
1070
  async credentials() {
852
1071
  const names = [...credentialsOf(this.drivers, () => undefined).values()].flat().map((c) => c.name);
853
- if (!names.length)
854
- return new Map();
855
- const rows = await storedCredentials(await this.db(), names);
856
- const statuses = credentialsOf(this.drivers, (name) => process.env[name] || rows.get(name)?.value);
857
- const sourced = (c) => {
858
- const row = rows.get(c.name);
859
- const env = process.env[c.name];
860
- // A worker in this process loads the stored values into its environment: those are stored.
861
- if (env && env !== row?.value)
862
- return { ...c, source: "environment" };
863
- return row ? { ...c, source: "stored", setBy: row.setBy, setAt: row.setAt } : c;
864
- };
865
- return new Map([...statuses].map(([vendor, list]) => [vendor, list.map(sourced)]));
1072
+ const rows = await this.secrets(names);
1073
+ const statuses = credentialsOf(this.drivers, (name) => rows.get(name)?.value);
1074
+ return new Map([...statuses].map(([vendor, list]) => [
1075
+ vendor,
1076
+ list.map((c) => {
1077
+ const row = rows.get(c.name);
1078
+ return {
1079
+ ...c,
1080
+ location: this.storedSecrets.location(c.name),
1081
+ ...(row && { source: row.source, setBy: row.setBy, setAt: row.setAt }),
1082
+ };
1083
+ }),
1084
+ ]));
866
1085
  }
867
1086
  async close() {
868
- await Promise.all([this.dbos.destroy(), this.pool.close(), this.audit.close(), this.config.dataStore?.close()]);
1087
+ await Promise.all([
1088
+ this.dbos.destroy(),
1089
+ this.pool.close(),
1090
+ this.audit.close(),
1091
+ this.config.dataStore?.close(),
1092
+ this.config.connectionPool.close(),
1093
+ ]);
869
1094
  }
870
1095
  /**
871
1096
  * The name's statuses with `lookup`'s value, one per vendor whose drivers declare it, or
@@ -988,7 +1213,7 @@ const asJson = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v
988
1213
  /** True when the two are the same JSON, whatever the order of their keys. */
989
1214
  const sameJson = (a, b) => isDeepStrictEqual(asJson(a), asJson(b));
990
1215
  /** What an environment variable's name looks like. */
991
- const VARIABLE = /^[A-Z_][A-Z0-9_]*$/;
1216
+ const VARIABLE = /^[A-Z][A-Z0-9_]*$/;
992
1217
  const alreadyDecided = (runId, a) => new SanomaError("already_decided", a.status === "withdrawn"
993
1218
  ? `${a.id} on run ${runId} was already withdrawn: ${a.withdrawnReason}`
994
1219
  : `${a.id} on run ${runId} was already ${a.status} by ${a.decidedBy}`, {