@sanoma-ai/workflows 0.9.0 → 0.10.0

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 (205) hide show
  1. package/README.md +169 -52
  2. package/dist/apply-steps.js +5 -2
  3. package/dist/apply-steps.js.map +1 -1
  4. package/dist/apply.d.ts +3 -0
  5. package/dist/apply.js +35 -21
  6. package/dist/apply.js.map +1 -1
  7. package/dist/approvals.d.ts +1 -0
  8. package/dist/approvals.js +6 -1
  9. package/dist/approvals.js.map +1 -1
  10. package/dist/apps.d.ts +7 -24
  11. package/dist/apps.js +51 -67
  12. package/dist/apps.js.map +1 -1
  13. package/dist/ast.d.ts +9 -7
  14. package/dist/ast.js +2 -0
  15. package/dist/ast.js.map +1 -1
  16. package/dist/authentication.d.ts +24 -0
  17. package/dist/authentication.js +68 -0
  18. package/dist/authentication.js.map +1 -0
  19. package/dist/call-workflow.d.ts +5 -0
  20. package/dist/call-workflow.js +4 -3
  21. package/dist/call-workflow.js.map +1 -1
  22. package/dist/call.d.ts +3 -2
  23. package/dist/call.js +34 -7
  24. package/dist/call.js.map +1 -1
  25. package/dist/cedar.js +14 -1
  26. package/dist/cedar.js.map +1 -1
  27. package/dist/change-steps.js +7 -5
  28. package/dist/change-steps.js.map +1 -1
  29. package/dist/change.d.ts +7 -0
  30. package/dist/change.js +7 -0
  31. package/dist/change.js.map +1 -1
  32. package/dist/client.d.ts +10 -6
  33. package/dist/client.js +96 -43
  34. package/dist/client.js.map +1 -1
  35. package/dist/collection.d.ts +7 -0
  36. package/dist/collection.js +5 -2
  37. package/dist/collection.js.map +1 -1
  38. package/dist/config.d.ts +14 -3
  39. package/dist/config.js +55 -14
  40. package/dist/config.js.map +1 -1
  41. package/dist/connection.d.ts +168 -0
  42. package/dist/connection.js +327 -0
  43. package/dist/connection.js.map +1 -0
  44. package/dist/credentials.d.ts +7 -53
  45. package/dist/credentials.js +84 -247
  46. package/dist/credentials.js.map +1 -1
  47. package/dist/cucumber.d.ts +4 -2
  48. package/dist/cucumber.js +8 -3
  49. package/dist/cucumber.js.map +1 -1
  50. package/dist/db.js +21 -2
  51. package/dist/db.js.map +1 -1
  52. package/dist/define.d.ts +9 -2
  53. package/dist/define.js.map +1 -1
  54. package/dist/definitions.d.ts +20 -0
  55. package/dist/definitions.js +16 -0
  56. package/dist/definitions.js.map +1 -0
  57. package/dist/deployment-key.d.ts +2 -0
  58. package/dist/deployment-key.js +22 -0
  59. package/dist/deployment-key.js.map +1 -0
  60. package/dist/describe.d.ts +9 -177
  61. package/dist/describe.js +71 -34
  62. package/dist/describe.js.map +1 -1
  63. package/dist/description.d.ts +532 -0
  64. package/dist/description.js +381 -0
  65. package/dist/description.js.map +1 -0
  66. package/dist/direct.js +3 -0
  67. package/dist/direct.js.map +1 -1
  68. package/dist/drift.d.ts +1 -1
  69. package/dist/drift.js +1 -1
  70. package/dist/drift.js.map +1 -1
  71. package/dist/env.d.ts +2 -0
  72. package/dist/env.js +2 -2
  73. package/dist/env.js.map +1 -1
  74. package/dist/index.d.ts +10 -2
  75. package/dist/index.js +10 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/ledger.d.ts +2 -0
  78. package/dist/ledger.js +1 -1
  79. package/dist/ledger.js.map +1 -1
  80. package/dist/lint.d.ts +0 -9
  81. package/dist/lint.js +1 -16
  82. package/dist/lint.js.map +1 -1
  83. package/dist/load.d.ts +74 -0
  84. package/dist/load.js +357 -0
  85. package/dist/load.js.map +1 -0
  86. package/dist/lockfile.d.ts +2 -0
  87. package/dist/lockfile.js +8 -3
  88. package/dist/lockfile.js.map +1 -1
  89. package/dist/op.d.ts +10 -1
  90. package/dist/op.js +1 -1
  91. package/dist/op.js.map +1 -1
  92. package/dist/outline.js +13 -4
  93. package/dist/outline.js.map +1 -1
  94. package/dist/paths.d.ts +18 -0
  95. package/dist/paths.js +21 -3
  96. package/dist/paths.js.map +1 -1
  97. package/dist/plugin.d.ts +0 -11
  98. package/dist/plugin.js +3 -18
  99. package/dist/plugin.js.map +1 -1
  100. package/dist/policy.d.ts +2 -0
  101. package/dist/policy.js.map +1 -1
  102. package/dist/resource.d.ts +27 -4
  103. package/dist/resource.js +49 -4
  104. package/dist/resource.js.map +1 -1
  105. package/dist/resources.d.ts +33 -76
  106. package/dist/resources.js +219 -227
  107. package/dist/resources.js.map +1 -1
  108. package/dist/run.d.ts +2 -0
  109. package/dist/run.js.map +1 -1
  110. package/dist/sandbox.d.ts +1 -1
  111. package/dist/sandbox.js +7 -3
  112. package/dist/sandbox.js.map +1 -1
  113. package/dist/scenario.d.ts +7 -0
  114. package/dist/scenario.js +33 -3
  115. package/dist/scenario.js.map +1 -1
  116. package/dist/scenarios.js +7 -1
  117. package/dist/scenarios.js.map +1 -1
  118. package/dist/schema.js +8 -0
  119. package/dist/schema.js.map +1 -1
  120. package/dist/secret-store-check.d.ts +11 -0
  121. package/dist/secret-store-check.js +76 -0
  122. package/dist/secret-store-check.js.map +1 -0
  123. package/dist/secret-store.d.ts +106 -0
  124. package/dist/secret-store.js +228 -0
  125. package/dist/secret-store.js.map +1 -0
  126. package/dist/shared.d.ts +17 -11
  127. package/dist/shared.js +34 -7
  128. package/dist/shared.js.map +1 -1
  129. package/dist/sites.d.ts +4 -93
  130. package/dist/sites.js +61 -84
  131. package/dist/sites.js.map +1 -1
  132. package/dist/source.d.ts +6 -0
  133. package/dist/source.js +13 -0
  134. package/dist/source.js.map +1 -1
  135. package/dist/steps.d.ts +3 -1
  136. package/dist/steps.js.map +1 -1
  137. package/dist/version.d.ts +2 -1
  138. package/dist/version.js +2 -1
  139. package/dist/version.js.map +1 -1
  140. package/dist/wakeup-driver.js +15 -6
  141. package/dist/wakeup-driver.js.map +1 -1
  142. package/dist/worker.js +12 -8
  143. package/dist/worker.js.map +1 -1
  144. package/dist/writer.d.ts +53 -14
  145. package/dist/writer.js +113 -43
  146. package/dist/writer.js.map +1 -1
  147. package/oxlint.json +0 -5
  148. package/package.json +6 -2
  149. package/src/apply-steps.ts +5 -2
  150. package/src/apply.ts +70 -29
  151. package/src/approvals.ts +7 -1
  152. package/src/apps.ts +54 -85
  153. package/src/ast.ts +6 -6
  154. package/src/authentication.ts +74 -0
  155. package/src/call-workflow.ts +5 -4
  156. package/src/call.ts +53 -9
  157. package/src/cedar.ts +14 -1
  158. package/src/change-steps.ts +7 -4
  159. package/src/change.ts +10 -0
  160. package/src/client.ts +108 -41
  161. package/src/collection.ts +10 -2
  162. package/src/config.ts +72 -13
  163. package/src/connection.ts +446 -0
  164. package/src/credentials.ts +94 -281
  165. package/src/cucumber.ts +16 -3
  166. package/src/db.ts +21 -2
  167. package/src/define.ts +7 -2
  168. package/src/definitions.ts +31 -0
  169. package/src/deployment-key.ts +23 -0
  170. package/src/describe.ts +102 -230
  171. package/src/description.ts +426 -0
  172. package/src/direct.ts +3 -0
  173. package/src/drift.ts +10 -2
  174. package/src/env.ts +4 -2
  175. package/src/index.ts +42 -1
  176. package/src/ledger.ts +3 -1
  177. package/src/lint.ts +1 -16
  178. package/src/load.ts +437 -0
  179. package/src/lockfile.ts +10 -3
  180. package/src/op.ts +10 -1
  181. package/src/outline.ts +18 -5
  182. package/src/paths.ts +24 -2
  183. package/src/plugin.ts +3 -24
  184. package/src/policy.ts +2 -0
  185. package/src/resource.ts +67 -6
  186. package/src/resources.ts +246 -301
  187. package/src/run.ts +2 -0
  188. package/src/sandbox.ts +11 -3
  189. package/src/scenario.ts +45 -3
  190. package/src/scenarios.ts +7 -1
  191. package/src/schema.ts +10 -0
  192. package/src/secret-store-check.ts +93 -0
  193. package/src/secret-store.ts +314 -0
  194. package/src/shared.ts +35 -10
  195. package/src/sites.ts +70 -180
  196. package/src/source.ts +14 -0
  197. package/src/steps.ts +3 -1
  198. package/src/version.ts +2 -1
  199. package/src/wakeup-driver.ts +22 -6
  200. package/src/worker.ts +12 -8
  201. package/src/writer.ts +145 -56
  202. package/dist/datafile.d.ts +0 -97
  203. package/dist/datafile.js +0 -479
  204. package/dist/datafile.js.map +0 -1
  205. package/src/datafile.ts +0 -601
package/src/config.ts CHANGED
@@ -1,3 +1,14 @@
1
+ import { configuredSecrets } from "./credentials.ts";
2
+ import { currentRun } from "./run.ts";
3
+ import {
4
+ connectionCatalog,
5
+ ConnectionRegistry,
6
+ connectionDriver,
7
+ connectionOps,
8
+ type ConnectionDefinition,
9
+ type ConnectionRule,
10
+ } from "./connection.ts";
11
+ import { checkSecretConfiguration, type SecretConfiguration } from "./secret-store.ts";
1
12
  import { existsSync, readFileSync } from "node:fs";
2
13
  import { dirname, join, resolve } from "node:path";
3
14
  import { pathToFileURL } from "node:url";
@@ -41,7 +52,7 @@ import { type CedarPolicySource, policyAt } from "./cedar.ts";
41
52
  import type { Policy } from "./policy.ts";
42
53
  import { type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
43
54
  import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS, CALL_WORKFLOW, CHANGE_WORKFLOW, DATA_VENDOR } from "./shared.ts";
44
- import { type RemoteHalf, sourceDriver, sourceOps } from "./source.ts";
55
+ import { fakeSourceDriver, type RemoteHalf, SOURCE_RESOLVE, sourceDriver, sourceOps } from "./source.ts";
45
56
  import { appsListDriver, appsListOps } from "./apps-list.ts";
46
57
  import type { Steps } from "./steps.ts";
47
58
  import { checkSchedule } from "./schedules.ts";
@@ -81,6 +92,10 @@ export interface SanomaConfig {
81
92
  * only, for tests.
82
93
  */
83
94
  ledger?: LedgerStore;
95
+ /** Credential storage and explicit bindings. Omitted: encrypted Postgres, no environment fallback. */
96
+ secrets?: SecretConfiguration;
97
+ connections?: readonly ConnectionDefinition[];
98
+ connectionRules?: readonly ConnectionRule[];
84
99
  /** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then `DEFAULT_DATABASE_URL`. */
85
100
  databaseUrl?: string;
86
101
  /** The file that defined the config, absolute, when known. Set by `defineConfig` from its call site. */
@@ -118,6 +133,9 @@ export interface SanomaConfig {
118
133
 
119
134
  /** A config, checked, with everything a worker, client or app derives from it. */
120
135
  export interface ResolvedConfig {
136
+ secrets: SecretConfiguration;
137
+ connections: ConnectionRegistry;
138
+ connectionPool: LazyPool;
121
139
  /**
122
140
  * Scopes workflows, queues and versions in the system database: the `name` of
123
141
  * `<root>/package.json` when it names one, else "sanoma".
@@ -252,6 +270,7 @@ export function rootOf(config: Pick<SanomaConfig, "root" | "file">): string | un
252
270
  * `startApp`, the `sanoma` bin when it loads the config.
253
271
  */
254
272
  export function resolveConfig(config: SanomaConfig): ResolvedConfig {
273
+ checkSecretConfiguration(config.secrets ?? {});
255
274
  if (!Array.isArray(config.connectors)) {
256
275
  throw new Error("The config needs `connectors`: the connectors its workflows call, as their packages export them");
257
276
  }
@@ -280,6 +299,12 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
280
299
  const hostTyped = [...types.values()].some((type) => type.host);
281
300
  // Built-in: the collections' operations, `data.<name>.<op>`, on the config's database.
282
301
  const databaseUrl = resolveDatabaseUrl(config);
302
+ const connectionPool = lazyPool(databaseUrl, "connections");
303
+ const connections = new ConnectionRegistry(
304
+ connectionCatalog(config.connectors, config.connections, config.connectionRules),
305
+ () => connectionPool.db(),
306
+ configuredSecrets(config.secrets ?? {}, () => connectionPool.db()),
307
+ );
283
308
  const declared = [...collections.values()];
284
309
  const dataStore = declared.length ? lazyPool(databaseUrl, "data") : undefined;
285
310
  // Built-in, in every config: a change's operations, which the template's policy names
@@ -287,6 +312,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
287
312
  const ops = indexConnectors([
288
313
  ...running,
289
314
  changeOps,
315
+ connectionOps,
290
316
  appsListOps,
291
317
  ...(hostTyped ? [sourceOps] : []),
292
318
  ...(dataStore ? [collectionsConnector(declared)] : []),
@@ -299,7 +325,8 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
299
325
  [
300
326
  ...driversByVendor.values(),
301
327
  changeDriver(remotes),
302
- ...sandboxDrivers,
328
+ connectionDriver(connections),
329
+ appsListDriver,
303
330
  ...(hostTyped ? [sourceDriver] : []),
304
331
  ...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
305
332
  ],
@@ -326,7 +353,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
326
353
  const connectors = connectorsOf(config);
327
354
  // The types the data files may declare: the vendors' and the people's, whose resources are left out.
328
355
  const declarable = resourceTypesOf(connectors);
329
- const read = () => readDeclared(root, connectors, declarable, types);
356
+ const read = () => readDeclared(root, connectors, declarable, types, connections);
330
357
  if (types.size) {
331
358
  names.set(
332
359
  DRIFT_WORKFLOW,
@@ -343,6 +370,9 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
343
370
  if (call.uses.length) names.set(CALL_WORKFLOW, call);
344
371
  const { policy, policies, problems: policyProblems } = policyAt(root, { ops, workflows: names, collections });
345
372
  return {
373
+ secrets: config.secrets ?? {},
374
+ connections,
375
+ connectionPool,
346
376
  appName,
347
377
  databaseUrl,
348
378
  // The built-in's code and operations are part of the app's version, like the config's own.
@@ -396,10 +426,15 @@ function collectionsOf(config: SanomaConfig): Map<string, Collection> {
396
426
  }
397
427
 
398
428
  /**
399
- * The built-in drivers no fake implements that a sandbox run calls beside its fakes (`seedSandbox`):
400
- * those that need no credentials and change nothing, as `sanoma.apps.list` (GitHub's public search).
429
+ * The built-in drivers a sandbox run calls beside its fakes (`seedSandbox`), for the built-in
430
+ * operations among `ops` that no fake implements: `sanoma.apps.list`'s own, which needs no
431
+ * credentials and changes nothing (GitHub's public search), and `sanoma.source.resolve`'s fake
432
+ * (`fakeSourceDriver`), for a config with a host type.
401
433
  */
402
- export const sandboxDrivers: readonly Driver[] = [appsListDriver];
434
+ export const sandboxDrivers = (ops: ReadonlyMap<string, unknown>): Driver[] => [
435
+ appsListDriver,
436
+ ...(ops.has(SOURCE_RESOLVE) ? [fakeSourceDriver] : []),
437
+ ];
403
438
 
404
439
  /**
405
440
  * `sanoma.wakeup`, the runtime's own: the type's operations and `sanoma.wakeup.register` (effect
@@ -490,9 +525,9 @@ export async function declaredWakeup(config: SanomaConfig): Promise<DeclaredWake
490
525
  const connectors = connectorsOf(config);
491
526
  const types = resourceTypesOf(connectors);
492
527
  if (![...types.values()].some((type) => type.wakeup)) return undefined;
493
- // Loaded when it is needed, not with the worker: the reader parses with oxc-parser.
494
- const { readDataFiles } = await import("./resources.ts");
495
- const { resources } = readDataFiles(rootOf(config), connectors, types);
528
+ // Loaded when it is needed, not with the worker.
529
+ const { loadDataFiles } = await import("./resources.ts");
530
+ const { resources } = loadDataFiles(rootOf(config), connectors, types);
496
531
  const typeOf = (r: DeclaredResource) => types.get(`${r.vendor}.${r.type}`)!;
497
532
  const declared = resources.find((r) => typeOf(r).wakeup);
498
533
  if (!declared) return undefined;
@@ -518,15 +553,38 @@ async function readDeclared(
518
553
  connectors: readonly Connector<any, any>[],
519
554
  declarable: ReadonlyMap<string, Resource>,
520
555
  types: ReadonlyMap<string, Resource>,
556
+ connections: ConnectionRegistry,
521
557
  ): Promise<Omit<ApplyDeclared, "rebinds">> {
522
- // Loaded when a run reads the files, not with the worker: the reader parses with oxc-parser.
523
- const { readDataFiles } = await import("./resources.ts");
524
- const { resources, bindings, orphans, problems } = readDataFiles(root, connectors, declarable);
558
+ // Loaded when a run reads the files, not with the worker.
559
+ const { loadDataFiles } = await import("./resources.ts");
560
+ const { resources, bindings, orphans, problems } = loadDataFiles(root, connectors, declarable);
561
+ // Read inside the data-file durable step: every operation of the plan uses one pinned account.
562
+ const run = currentRun.getStore();
563
+ if (run && !run.sandbox) {
564
+ for (const resource of resources.filter((r) => types.has(`${r.vendor}.${r.type}`))) {
565
+ try {
566
+ const selected = await connections.select(resource.vendor, resource.connection, run.actor.id);
567
+ if (selected) {
568
+ const pinned = { vendor: selected.vendor, $connection: selected.id, revision: selected.revision };
569
+ const locked = bindings[resource.id]?.connection;
570
+ if (locked && (locked.$connection !== pinned.$connection || locked.revision !== pinned.revision))
571
+ throw new Error("The bound resource's account changed; explicitly unbind before using a new account");
572
+ resource.connection = pinned;
573
+ }
574
+ } catch {
575
+ problems.push({
576
+ file: resource.file,
577
+ message: `${resource.id}: its connection is unavailable or changed; reconnect and explicitly rebind`,
578
+ });
579
+ resources.splice(resources.indexOf(resource), 1);
580
+ }
581
+ }
582
+ }
525
583
  return {
526
584
  // Only `types`' resources have a vendor object to check or apply: not the people's (vendorless).
527
585
  resources: resources
528
586
  .filter((r) => types.has(`${r.vendor}.${r.type}`))
529
- .map(({ id, vendor, type, name, identity, binding, desired, refs }) => ({
587
+ .map(({ id, vendor, type, name, identity, binding, desired, refs, connection }) => ({
530
588
  id,
531
589
  vendor,
532
590
  type,
@@ -535,6 +593,7 @@ async function readDeclared(
535
593
  ...(binding !== undefined && { binding }),
536
594
  desired: withoutWriteOnly(types.get(`${vendor}.${type}`)!.fields, desired),
537
595
  refs,
596
+ ...(connection && { connection }),
538
597
  })),
539
598
  bindings,
540
599
  orphans,
@@ -0,0 +1,446 @@
1
+ import type { Principal } from "./define.ts";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { z } from "zod";
4
+ import { checkAuthenticationStore, defineAuthentication, type Authentication } from "./authentication.ts";
5
+ import { type Connector, setupOf, VENDOR, defineConnector, defineDriver, type CallContext } from "./op.ts";
6
+ import { isSecret, type Secret } from "./secret.ts";
7
+ import { SanomaError } from "./errors.ts";
8
+ import type { SecretAccess } from "./secret-store.ts";
9
+ import type { Db } from "./db.ts";
10
+
11
+ /** Serializable account reference; vendor literal prevents cross-connector selection in typed calls. */
12
+ export interface ConnectionRef<V extends string = string> {
13
+ readonly $connection: string;
14
+ readonly vendor: V;
15
+ readonly revision?: string;
16
+ }
17
+ export interface ConnectionDefinition<V extends string = string> extends ConnectionRef<V> {
18
+ readonly name: string;
19
+ readonly method: string;
20
+ readonly credentials?: Secret;
21
+ readonly scopes: readonly string[];
22
+ readonly account?: string;
23
+ readonly default: boolean;
24
+ }
25
+ export interface ConnectionRule<V extends string = string> {
26
+ readonly name: string;
27
+ readonly vendor: V;
28
+ readonly method: string;
29
+ readonly createBy: readonly (string | { readonly group: string })[];
30
+ readonly scopes: readonly string[];
31
+ }
32
+ export interface ConnectionSnapshot {
33
+ id: string;
34
+ vendor: string;
35
+ method: string;
36
+ account: string;
37
+ scopes: string[];
38
+ revision: string;
39
+ definition: string;
40
+ /** A logical reference only, never the value or a provider credential. */
41
+ credentials?: Secret;
42
+ }
43
+ const idPart = /^[a-z][a-z0-9-]{0,63}$/;
44
+ function refuse(message: string): never {
45
+ throw new SanomaError("invalid_input", message);
46
+ }
47
+ export const connectionRefSchema = z.object({
48
+ $connection: z.string().min(1),
49
+ vendor: z.string().min(1),
50
+ revision: z.string().optional(),
51
+ });
52
+ const snapshotSchema = z.object({
53
+ id: z.string(),
54
+ vendor: z.string(),
55
+ method: z.string(),
56
+ account: z.string(),
57
+ scopes: z.array(z.string()),
58
+ revision: z.string(),
59
+ definition: z.string(),
60
+ credentials: z.object({ $secret: z.string() }).optional(),
61
+ });
62
+
63
+ export function defineConnection<C extends Connector<any, any>>(
64
+ connector: C,
65
+ options: {
66
+ name: string;
67
+ method: string;
68
+ credentials?: Secret;
69
+ scopes?: readonly string[];
70
+ account?: string;
71
+ default?: boolean;
72
+ },
73
+ ): ConnectionDefinition<C[typeof VENDOR]["id"]> {
74
+ if (!idPart.test(options.name)) refuse("A connection needs a stable lowercase name");
75
+ const vendor = connector[VENDOR].id;
76
+ const auth = setupOf(connector).authentication?.[options.method];
77
+ if (!auth) refuse("The connector does not declare this authentication method");
78
+ if (auth.kind === "workload-identity" ? options.credentials !== undefined : !isSecret(options.credentials))
79
+ refuse("Credentials must match the authentication method");
80
+ return Object.freeze({
81
+ $connection: `${vendor}/${options.name}`,
82
+ vendor,
83
+ name: options.name,
84
+ method: options.method,
85
+ ...(options.credentials && { credentials: Object.freeze({ $secret: options.credentials.$secret }) }),
86
+ scopes: Object.freeze([...(options.scopes ?? [])]),
87
+ ...(options.account && { account: options.account }),
88
+ default: options.default ?? false,
89
+ });
90
+ }
91
+
92
+ export function defineConnectionRule<C extends Connector<any, any>>(
93
+ connector: C,
94
+ options: {
95
+ name: string;
96
+ method: string;
97
+ createBy: readonly (string | { readonly group: string })[];
98
+ scopes?: readonly string[];
99
+ },
100
+ ): ConnectionRule<C[typeof VENDOR]["id"]> {
101
+ if (
102
+ !idPart.test(options.name) ||
103
+ !options.createBy.length ||
104
+ options.createBy.some((x) =>
105
+ typeof x === "string" ? !x.trim() : !x || typeof x.group !== "string" || !x.group.trim(),
106
+ )
107
+ )
108
+ refuse("A connection rule needs a name and allowed creators");
109
+ if (!setupOf(connector).authentication?.[options.method])
110
+ refuse("The connector does not declare this authentication method");
111
+ return Object.freeze({
112
+ name: options.name,
113
+ vendor: connector[VENDOR].id,
114
+ method: options.method,
115
+ createBy: Object.freeze(options.createBy.map((x) => (typeof x === "string" ? x : Object.freeze({ ...x })))),
116
+ scopes: Object.freeze([...(options.scopes ?? [])]),
117
+ });
118
+ }
119
+
120
+ /** Reconstitutes a dynamic reference; authority is checked from the persisted record on every call. */
121
+ export function connectionRef<V extends string>(rule: ConnectionRule<V>, id: string): ConnectionRef<V> {
122
+ if (!id.startsWith(`${rule.vendor}/${rule.name}/`)) refuse("The connection is outside this rule");
123
+ return Object.freeze({ $connection: id, vendor: rule.vendor });
124
+ }
125
+
126
+ export interface ConnectionCatalog {
127
+ definitions: ReadonlyMap<string, ConnectionDefinition>;
128
+ rules: ReadonlyMap<string, ConnectionRule>;
129
+ methods: ReadonlyMap<string, Readonly<Record<string, Authentication>>>;
130
+ }
131
+ export function connectionCatalog(
132
+ connectors: readonly Connector<any, any>[],
133
+ definitions: readonly ConnectionDefinition[] = [],
134
+ rules: readonly ConnectionRule[] = [],
135
+ ): ConnectionCatalog {
136
+ const methods = new Map<string, Record<string, Authentication>>();
137
+ for (const connector of connectors) {
138
+ const vendor = connector[VENDOR].id;
139
+ const entries = methods.get(vendor) ?? {};
140
+ for (const [name, method] of Object.entries(setupOf(connector).authentication ?? {})) {
141
+ if (entries[name] && entries[name] !== method) refuse("Conflicting authentication methods for a connector");
142
+ defineAuthentication(method);
143
+ entries[name] = method;
144
+ }
145
+ methods.set(vendor, entries);
146
+ }
147
+ const defined = new Map<string, ConnectionDefinition>();
148
+ const defaults = new Set<string>();
149
+ for (const d of definitions) {
150
+ if (
151
+ !d ||
152
+ !idPart.test(d.name) ||
153
+ d.$connection !== `${d.vendor}/${d.name}` ||
154
+ !methods.get(d.vendor)?.[d.method] ||
155
+ defined.has(d.$connection)
156
+ )
157
+ refuse("Invalid or duplicate connection declaration");
158
+ const auth = methods.get(d.vendor)![d.method]!;
159
+ if (auth.kind === "workload-identity" ? d.credentials !== undefined : !isSecret(d.credentials))
160
+ refuse("Credentials must match the authentication method");
161
+ if (d.default && defaults.has(d.vendor)) refuse("A connector can have only one explicit default connection");
162
+ if (d.default) defaults.add(d.vendor);
163
+ defined.set(d.$connection, d);
164
+ }
165
+ const dynamic = new Map<string, ConnectionRule>();
166
+ for (const r of rules) {
167
+ const id = `${r.vendor}/${r.name}`;
168
+ if (
169
+ !idPart.test(r.name) ||
170
+ !methods.get(r.vendor)?.[r.method] ||
171
+ dynamic.has(id) ||
172
+ defined.has(id) ||
173
+ !r.createBy.length
174
+ )
175
+ refuse("Invalid or duplicate connection rule");
176
+ dynamic.set(id, r);
177
+ }
178
+ return { definitions: defined, rules: dynamic, methods };
179
+ }
180
+
181
+ type ConnectionRow = {
182
+ id: string;
183
+ vendor: string;
184
+ method: string;
185
+ owner: string | null;
186
+ rule: string | null;
187
+ account: string;
188
+ scopes: string[];
189
+ revision: string;
190
+ definition: string;
191
+ credential: string | null;
192
+ status: "connected" | "disconnected";
193
+ };
194
+ const snapshot = (r: ConnectionRow): ConnectionSnapshot => ({
195
+ id: r.id,
196
+ vendor: r.vendor,
197
+ method: r.method,
198
+ account: r.account,
199
+ scopes: r.scopes,
200
+ revision: r.revision,
201
+ definition: r.definition,
202
+ ...(r.credential && { credentials: { $secret: r.credential } }),
203
+ });
204
+ const fingerprint = (declaration: ConnectionDefinition | ConnectionRule, method: Authentication): string =>
205
+ createHash("sha256")
206
+ .update(JSON.stringify({ declaration, kind: method.kind, version: method.version, storage: method.storage }))
207
+ .digest("hex");
208
+
209
+ /** Persists account metadata only. Secret values are handled outside these operations. */
210
+ export class ConnectionRegistry {
211
+ readonly catalog: ConnectionCatalog;
212
+ private readonly db: () => Promise<Db>;
213
+ private readonly secrets?: SecretAccess;
214
+ constructor(catalog: ConnectionCatalog, db: () => Promise<Db>, secrets?: SecretAccess) {
215
+ this.catalog = catalog;
216
+ this.db = db;
217
+ this.secrets = secrets;
218
+ }
219
+
220
+ /** A direct metadata write uses the same transaction as its default ledger record. */
221
+ onDatabase(db: Db): ConnectionRegistry {
222
+ return new ConnectionRegistry(this.catalog, async () => db, this.secrets);
223
+ }
224
+
225
+ private bindingFingerprint(
226
+ declaration: ConnectionDefinition | ConnectionRule,
227
+ method: Authentication,
228
+ credential?: string | null,
229
+ ): string {
230
+ const name = credential ?? ("credentials" in declaration ? declaration.credentials?.$secret : undefined);
231
+ const location = name ? this.secrets?.location(name) : undefined;
232
+ return createHash("sha256")
233
+ .update(JSON.stringify({ contract: fingerprint(declaration, method), location }))
234
+ .digest("hex");
235
+ }
236
+
237
+ private checkStore(method: Authentication, credential?: string | null): void {
238
+ if (!this.secrets || !credential) return;
239
+ const location = this.secrets.location(credential);
240
+ const caps =
241
+ location.namespace === ""
242
+ ? {
243
+ writable: false,
244
+ maxBytes: Number.MAX_SAFE_INTEGER,
245
+ conditionalWrites: "none" as const,
246
+ deletion: "unsupported" as const,
247
+ changeDetection: "read" as const,
248
+ }
249
+ : this.secrets.store.capabilities;
250
+ checkAuthenticationStore(method, caps, location.management);
251
+ }
252
+
253
+ private async row(id: string): Promise<ConnectionRow | undefined> {
254
+ return (await (await this.db()).query<ConnectionRow>("SELECT * FROM sanoma_connections WHERE id=$1", [id])).rows[0];
255
+ }
256
+
257
+ async select(vendor: string, ref: ConnectionRef | undefined, actor: string): Promise<ConnectionSnapshot | undefined> {
258
+ if (!ref) {
259
+ const defaults = [...this.catalog.definitions.values()].filter((d) => d.vendor === vendor && d.default);
260
+ if (defaults[0]) ref = defaults[0];
261
+ else if (
262
+ Object.keys(this.catalog.methods.get(vendor) ?? {}).length ||
263
+ [...this.catalog.definitions.values()].some((d) => d.vendor === vendor)
264
+ )
265
+ refuse("Select a named connection or declare an explicit default");
266
+ else return undefined; // Operations without an authentication method need no connection.
267
+ }
268
+ if (!ref || ref.vendor !== vendor) refuse("The selected connection belongs to another connector");
269
+ const row = await this.row(ref.$connection);
270
+ if (!row || row.vendor !== vendor || row.status !== "connected")
271
+ refuse("The connection is missing or disconnected");
272
+ if (ref.revision !== undefined && ref.revision !== row.revision)
273
+ refuse("The connection changed after authorization; start a new call");
274
+ const declaration = row.rule ? this.catalog.rules.get(row.rule) : this.catalog.definitions.get(row.id);
275
+ if (!declaration || declaration.vendor !== vendor || declaration.method !== row.method)
276
+ refuse("The connection is not declared");
277
+ if (row.rule && row.owner !== actor)
278
+ throw new SanomaError("policy_denied", "The connection belongs to another principal");
279
+ const method = this.catalog.methods.get(vendor)?.[row.method];
280
+ if (!method || row.definition !== this.bindingFingerprint(declaration, method, row.credential))
281
+ refuse("The connection declaration changed; reconnect before use");
282
+ this.checkStore(method, row.credential);
283
+ return snapshot(row);
284
+ }
285
+
286
+ async assertCurrent(selected: ConnectionSnapshot | undefined, actor: string): Promise<void> {
287
+ if (!selected) return;
288
+ const current = await this.select(selected.vendor, { vendor: selected.vendor, $connection: selected.id }, actor);
289
+ if (!current || current.revision !== selected.revision || current.definition !== selected.definition)
290
+ refuse("The connection changed after authorization; start a new call");
291
+ }
292
+
293
+ async bind(
294
+ ref: ConnectionRef,
295
+ account: string,
296
+ scopes: string[],
297
+ expectedRevision: string | null,
298
+ actor: string,
299
+ ): Promise<ConnectionSnapshot> {
300
+ const declaration = this.catalog.definitions.get(ref.$connection);
301
+ if (!declaration || declaration.vendor !== ref.vendor) refuse("The connection is not declared");
302
+ if (declaration.account && account !== declaration.account)
303
+ refuse("The account does not match the connection requirement");
304
+ if (declaration.scopes.some((s) => !scopes.includes(s))) refuse("The account has not granted the required scopes");
305
+ const method = this.catalog.methods.get(ref.vendor)![declaration.method]!;
306
+ const row: ConnectionRow = {
307
+ id: ref.$connection,
308
+ vendor: ref.vendor,
309
+ method: declaration.method,
310
+ owner: null,
311
+ rule: null,
312
+ account,
313
+ scopes,
314
+ revision: randomUUID(),
315
+ definition: this.bindingFingerprint(declaration, method),
316
+ credential: declaration.credentials?.$secret ?? null,
317
+ status: "connected",
318
+ };
319
+ this.checkStore(method, row.credential);
320
+ await this.save(row, expectedRevision, actor);
321
+ return snapshot(row);
322
+ }
323
+
324
+ async create(
325
+ ruleId: string,
326
+ account: string,
327
+ scopes: string[],
328
+ actor: Principal | string,
329
+ ): Promise<ConnectionSnapshot> {
330
+ const rule = this.catalog.rules.get(ruleId);
331
+ const person = typeof actor === "string" ? { id: actor } : actor;
332
+ if (
333
+ !rule ||
334
+ !rule.createBy.some((by) => (typeof by === "string" ? by === person.id : person.groups?.includes(by.group)))
335
+ )
336
+ throw new SanomaError("policy_denied", "This principal cannot create connections under this rule");
337
+ if (rule.scopes.some((s) => !scopes.includes(s))) refuse("The account has not granted the required scopes");
338
+ const method = this.catalog.methods.get(rule.vendor)![rule.method]!;
339
+ const id = `${ruleId}/${randomUUID()}`;
340
+ const credential =
341
+ method.kind === "workload-identity" ? null : `CONNECTION_${randomUUID().replaceAll("-", "").toUpperCase()}`;
342
+ const row: ConnectionRow = {
343
+ id,
344
+ vendor: rule.vendor,
345
+ method: rule.method,
346
+ owner: person.id,
347
+ rule: ruleId,
348
+ account,
349
+ scopes,
350
+ revision: randomUUID(),
351
+ definition: this.bindingFingerprint(rule, method, credential),
352
+ credential,
353
+ status: "connected",
354
+ };
355
+ this.checkStore(method, row.credential);
356
+ await this.save(row, null, person.id);
357
+ return snapshot(row);
358
+ }
359
+
360
+ async disconnect(
361
+ ref: ConnectionRef,
362
+ expectedRevision: string,
363
+ actor: string,
364
+ ): Promise<{ id: string; revision: string; status: "disconnected" }> {
365
+ const row = await this.row(ref.$connection);
366
+ if (
367
+ !row ||
368
+ row.vendor !== ref.vendor ||
369
+ (row.rule ? !this.catalog.rules.has(row.rule) : !this.catalog.definitions.has(row.id))
370
+ )
371
+ refuse("The connection is not declared");
372
+ if (row.rule && row.owner !== actor)
373
+ throw new SanomaError("policy_denied", "The connection belongs to another principal");
374
+ const revision = randomUUID();
375
+ const changed = await (
376
+ await this.db()
377
+ ).query(
378
+ "UPDATE sanoma_connections SET status='disconnected',revision=$2,updated_by=$3,updated_at=now() WHERE id=$1 AND revision=$4 RETURNING id",
379
+ [row.id, revision, actor, expectedRevision],
380
+ );
381
+ if (!changed.rowCount) refuse("The connection changed; reload before disconnecting it");
382
+ return { id: row.id, revision, status: "disconnected" };
383
+ }
384
+
385
+ private async save(row: ConnectionRow, expected: string | null, actor: string): Promise<void> {
386
+ const values = [
387
+ row.id,
388
+ row.vendor,
389
+ row.method,
390
+ row.owner,
391
+ row.rule,
392
+ row.account,
393
+ JSON.stringify(row.scopes),
394
+ row.revision,
395
+ row.definition,
396
+ row.credential,
397
+ actor,
398
+ ];
399
+ const query =
400
+ expected === null
401
+ ? "INSERT INTO sanoma_connections (id,vendor,method,owner,rule,account,scopes,revision,definition,credential,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT(id) DO NOTHING RETURNING id"
402
+ : "UPDATE sanoma_connections SET vendor=$2,method=$3,owner=$4,rule=$5,account=$6,scopes=$7,revision=$8,definition=$9,credential=$10,updated_by=$11,updated_at=now(),status='connected' WHERE id=$1 AND revision=$12 RETURNING id";
403
+ const result = await (await this.db()).query(query, [...values, ...(expected === null ? [] : [expected])]);
404
+ if (!result.rowCount) refuse("The connection changed; reload before binding it");
405
+ }
406
+ }
407
+
408
+ /** Metadata mutations are access operations, so they are policy-reviewed and audited like other effects. */
409
+ export const connectionOps = defineConnector("sanoma", {
410
+ connection: {
411
+ disconnect: {
412
+ effect: "access",
413
+ input: z.object({ connection: connectionRefSchema, expectedRevision: z.string() }),
414
+ output: z.object({ id: z.string(), revision: z.string(), status: z.literal("disconnected") }),
415
+ target: (input: { connection: ConnectionRef }) => input.connection.$connection,
416
+ },
417
+ bind: {
418
+ effect: "access",
419
+ input: z.object({
420
+ connection: connectionRefSchema,
421
+ account: z.string().min(1),
422
+ scopes: z.array(z.string()),
423
+ expectedRevision: z.string().nullable(),
424
+ }),
425
+ output: snapshotSchema,
426
+ target: (input: { connection: ConnectionRef }) => input.connection.$connection,
427
+ },
428
+ create: {
429
+ effect: "access",
430
+ input: z.object({ rule: z.string(), account: z.string().min(1), scopes: z.array(z.string()) }),
431
+ output: snapshotSchema,
432
+ target: (input: { rule: string }) => input.rule,
433
+ },
434
+ },
435
+ });
436
+ const actor = (call: CallContext) => call.principal ?? refuse("Connection mutation needs an authenticated principal");
437
+ export function connectionDriver(registry: ConnectionRegistry) {
438
+ return defineDriver(connectionOps, {
439
+ connection: {
440
+ disconnect: (input, call) => registry.disconnect(input.connection, input.expectedRevision, actor(call).id),
441
+ bind: (input, call) =>
442
+ registry.bind(input.connection, input.account, input.scopes, input.expectedRevision, actor(call).id),
443
+ create: (input, call) => registry.create(input.rule, input.account, input.scopes, actor(call)),
444
+ },
445
+ });
446
+ }