@sanoma-ai/workflows 0.10.0 → 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 (89) hide show
  1. package/README.md +33 -7
  2. package/SECRET-SUPPORT.md +33 -0
  3. package/dist/apps.d.ts +6 -0
  4. package/dist/apps.js +18 -1
  5. package/dist/apps.js.map +1 -1
  6. package/dist/authentication.d.ts +30 -0
  7. package/dist/authentication.js +71 -1
  8. package/dist/authentication.js.map +1 -1
  9. package/dist/call.js +7 -5
  10. package/dist/call.js.map +1 -1
  11. package/dist/client.d.ts +54 -6
  12. package/dist/client.js +177 -5
  13. package/dist/client.js.map +1 -1
  14. package/dist/config.d.ts +7 -4
  15. package/dist/config.js +44 -12
  16. package/dist/config.js.map +1 -1
  17. package/dist/connection.d.ts +223 -2
  18. package/dist/connection.js +334 -4
  19. package/dist/connection.js.map +1 -1
  20. package/dist/db.js +15 -2
  21. package/dist/db.js.map +1 -1
  22. package/dist/describe.d.ts +8 -0
  23. package/dist/describe.js +51 -1
  24. package/dist/describe.js.map +1 -1
  25. package/dist/direct.js +4 -0
  26. package/dist/direct.js.map +1 -1
  27. package/dist/fake.d.ts +13 -1
  28. package/dist/fake.js +70 -0
  29. package/dist/fake.js.map +1 -1
  30. package/dist/index.d.ts +4 -4
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/lint.js +1 -1
  34. package/dist/lint.js.map +1 -1
  35. package/dist/load.d.ts +11 -0
  36. package/dist/load.js +42 -8
  37. package/dist/load.js.map +1 -1
  38. package/dist/op.d.ts +14 -6
  39. package/dist/op.js +20 -2
  40. package/dist/op.js.map +1 -1
  41. package/dist/outline.d.ts +5 -0
  42. package/dist/outline.js +33 -1
  43. package/dist/outline.js.map +1 -1
  44. package/dist/paths.d.ts +2 -4
  45. package/dist/paths.js +2 -8
  46. package/dist/paths.js.map +1 -1
  47. package/dist/run.d.ts +3 -1
  48. package/dist/run.js.map +1 -1
  49. package/dist/sandbox.d.ts +4 -1
  50. package/dist/sandbox.js +51 -5
  51. package/dist/sandbox.js.map +1 -1
  52. package/dist/scenario.d.ts +5 -0
  53. package/dist/scenario.js +3 -2
  54. package/dist/scenario.js.map +1 -1
  55. package/dist/secret.d.ts +2 -0
  56. package/dist/secret.js +32 -0
  57. package/dist/secret.js.map +1 -1
  58. package/dist/shared.d.ts +4 -0
  59. package/dist/shared.js +7 -0
  60. package/dist/shared.js.map +1 -1
  61. package/dist/sites.js +18 -3
  62. package/dist/sites.js.map +1 -1
  63. package/dist/steps.d.ts +3 -0
  64. package/dist/steps.js.map +1 -1
  65. package/oxlint.json +1 -0
  66. package/package.json +2 -1
  67. package/src/apps.ts +17 -1
  68. package/src/authentication.ts +119 -2
  69. package/src/call.ts +4 -5
  70. package/src/client.ts +240 -6
  71. package/src/config.ts +45 -11
  72. package/src/connection.ts +472 -6
  73. package/src/db.ts +15 -2
  74. package/src/describe.ts +52 -1
  75. package/src/direct.ts +4 -0
  76. package/src/fake.ts +71 -0
  77. package/src/index.ts +12 -1
  78. package/src/lint.ts +1 -1
  79. package/src/load.ts +36 -7
  80. package/src/op.ts +33 -8
  81. package/src/outline.ts +35 -2
  82. package/src/paths.ts +2 -9
  83. package/src/run.ts +3 -1
  84. package/src/sandbox.ts +55 -5
  85. package/src/scenario.ts +24 -6
  86. package/src/secret.ts +29 -0
  87. package/src/shared.ts +9 -0
  88. package/src/sites.ts +19 -3
  89. package/src/steps.ts +3 -2
package/src/connection.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  import type { Principal } from "./define.ts";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
- import { checkAuthenticationStore, defineAuthentication, type Authentication } from "./authentication.ts";
4
+ import {
5
+ checkAuthenticationStore,
6
+ decodeCredentials,
7
+ defineAuthentication,
8
+ verifyAuthentication,
9
+ AuthenticationError,
10
+ authenticationProblem,
11
+ type AuthenticationProblem,
12
+ type Authentication,
13
+ type CallAuthentication,
14
+ } from "./authentication.ts";
5
15
  import { type Connector, setupOf, VENDOR, defineConnector, defineDriver, type CallContext } from "./op.ts";
6
16
  import { isSecret, type Secret } from "./secret.ts";
7
17
  import { SanomaError } from "./errors.ts";
8
- import type { SecretAccess } from "./secret-store.ts";
18
+ import { SecretStoreError, type SecretAccess, type SecretLocation } from "./secret-store.ts";
9
19
  import type { Db } from "./db.ts";
10
20
 
11
21
  /** Serializable account reference; vendor literal prevents cross-connector selection in typed calls. */
@@ -40,6 +50,51 @@ export interface ConnectionSnapshot {
40
50
  /** A logical reference only, never the value or a provider credential. */
41
51
  credentials?: Secret;
42
52
  }
53
+ export type ConnectionTarget = { connection: ConnectionRef } | { rule: string };
54
+ export interface ConnectionCard {
55
+ id: string;
56
+ vendor: string;
57
+ name: string;
58
+ method: string;
59
+ account?: string;
60
+ scopes: readonly string[];
61
+ requiredScopes: readonly string[];
62
+ expectedAccount?: string;
63
+ revision?: string;
64
+ owner?: string;
65
+ status: "missing" | "connected" | "disconnected" | "pending" | "uncertain" | "configuration_changed";
66
+ location?: SecretLocation;
67
+ boundLocation?: SecretLocation;
68
+ updatedBy?: string;
69
+ updatedAt?: string;
70
+ health?: ConnectionHealth;
71
+ }
72
+ export interface ConnectionHealth {
73
+ state:
74
+ | AuthenticationProblem
75
+ | "connected"
76
+ | "disconnected"
77
+ | "pending"
78
+ | "configuration_changed"
79
+ | "store_denied"
80
+ | "invalid";
81
+ checkedAt: string;
82
+ externallyRenewed: boolean;
83
+ }
84
+ export interface ConnectionSetup {
85
+ target: ConnectionTarget;
86
+ id: string;
87
+ vendor: string;
88
+ method: string;
89
+ definition: string;
90
+ credential?: string;
91
+ location?: SecretLocation;
92
+ expectedRevision: string | null;
93
+ requiredScopes: readonly string[];
94
+ expectedAccount?: string;
95
+ previousAccount?: string;
96
+ previousLocation?: SecretLocation;
97
+ }
43
98
  const idPart = /^[a-z][a-z0-9-]{0,63}$/;
44
99
  function refuse(message: string): never {
45
100
  throw new SanomaError("invalid_input", message);
@@ -59,6 +114,28 @@ const snapshotSchema = z.object({
59
114
  definition: z.string(),
60
115
  credentials: z.object({ $secret: z.string() }).optional(),
61
116
  });
117
+ export const connectionTargetSchema = z.union([
118
+ z.object({ connection: connectionRefSchema }),
119
+ z.object({ rule: z.string().min(1) }),
120
+ ]);
121
+ const setupSchema = z.object({
122
+ target: connectionTargetSchema,
123
+ id: z.string(),
124
+ vendor: z.string(),
125
+ method: z.string(),
126
+ definition: z.string(),
127
+ credential: z.string().optional(),
128
+ location: z
129
+ .object({ store: z.string(), namespace: z.string(), path: z.string(), management: z.enum(["sanoma", "external"]) })
130
+ .optional(),
131
+ expectedRevision: z.string().nullable(),
132
+ requiredScopes: z.array(z.string()),
133
+ expectedAccount: z.string().optional(),
134
+ previousAccount: z.string().optional(),
135
+ previousLocation: z
136
+ .object({ store: z.string(), namespace: z.string(), path: z.string(), management: z.enum(["sanoma", "external"]) })
137
+ .optional(),
138
+ });
62
139
 
63
140
  export function defineConnection<C extends Connector<any, any>>(
64
141
  connector: C,
@@ -189,7 +266,13 @@ type ConnectionRow = {
189
266
  revision: string;
190
267
  definition: string;
191
268
  credential: string | null;
192
- status: "connected" | "disconnected";
269
+ location?: SecretLocation | null;
270
+ status: "connected" | "disconnected" | "pending" | "uncertain";
271
+ updated_by?: string;
272
+ updated_at?: Date;
273
+ health?: ConnectionHealth["state"];
274
+ checked_at?: Date;
275
+ externally_renewed?: boolean;
193
276
  };
194
277
  const snapshot = (r: ConnectionRow): ConnectionSnapshot => ({
195
278
  id: r.id,
@@ -222,6 +305,282 @@ export class ConnectionRegistry {
222
305
  return new ConnectionRegistry(this.catalog, async () => db, this.secrets);
223
306
  }
224
307
 
308
+ /** Safe card data only. Listing does not read credential values or contact a vendor. */
309
+ async cards(actor: string): Promise<ConnectionCard[]> {
310
+ const rows = (
311
+ await (
312
+ await this.db()
313
+ ).query<ConnectionRow>(
314
+ "SELECT c.*, h.health, h.checked_at, h.externally_renewed FROM sanoma_connections c LEFT JOIN sanoma_connection_health h ON c.id=h.connection_id AND c.revision=h.revision WHERE c.owner IS NULL OR c.owner=$1",
315
+ [actor],
316
+ )
317
+ ).rows;
318
+ const found = new Map(rows.map((r) => [r.id, r]));
319
+ const ids = new Set([...this.catalog.definitions.keys(), ...rows.filter((r) => r.rule).map((r) => r.id)]);
320
+ const cards: ConnectionCard[] = [];
321
+ for (const id of ids) {
322
+ const row = found.get(id);
323
+ const declaration = row?.rule ? this.catalog.rules.get(row.rule) : this.catalog.definitions.get(id);
324
+ if (!declaration || (row?.rule && row.owner !== actor)) continue;
325
+ const method = this.catalog.methods.get(declaration.vendor)?.[declaration.method];
326
+ if (!method) continue;
327
+ const credential =
328
+ method.kind === "workload-identity"
329
+ ? undefined
330
+ : "default" in declaration
331
+ ? declaration.credentials?.$secret
332
+ : row?.credential;
333
+ cards.push({
334
+ id,
335
+ vendor: declaration.vendor,
336
+ name: row?.rule ? id.slice(declaration.vendor.length + 1) : declaration.name,
337
+ method: declaration.method,
338
+ ...(row && { account: row.account, revision: row.revision }),
339
+ scopes: row?.scopes ?? [],
340
+ requiredScopes: declaration.scopes,
341
+ ...("account" in declaration && declaration.account && { expectedAccount: declaration.account }),
342
+ ...(row?.owner && { owner: row.owner }),
343
+ status: !row
344
+ ? "missing"
345
+ : row.definition !== this.bindingFingerprint(declaration, method, credential)
346
+ ? "configuration_changed"
347
+ : row.status,
348
+ ...(credential && this.secrets && { location: this.secrets.location(credential) }),
349
+ ...(row?.location && { boundLocation: row.location }),
350
+ ...(row?.updated_by && { updatedBy: row.updated_by }),
351
+ ...(row?.updated_at && { updatedAt: row.updated_at.toISOString() }),
352
+ ...(row?.health &&
353
+ row.checked_at && {
354
+ health: {
355
+ state: row.health,
356
+ checkedAt: row.checked_at.toISOString(),
357
+ externallyRenewed: row.externally_renewed ?? false,
358
+ },
359
+ }),
360
+ });
361
+ }
362
+ return cards;
363
+ }
364
+
365
+ /** Resolve code-defined setup authority without reading a credential. The request id stays
366
+ * stable across preview, approval and retry; account allocation is scoped to its principal. */
367
+ async setup(target: ConnectionTarget, actor: Principal, requestId: string): Promise<ConnectionSetup> {
368
+ let declaration: ConnectionDefinition | ConnectionRule | undefined;
369
+ let row: ConnectionRow | undefined;
370
+ let id: string;
371
+ let credential: string | undefined;
372
+ if ("rule" in target) {
373
+ const rule = this.catalog.rules.get(target.rule);
374
+ if (
375
+ !rule ||
376
+ !rule.createBy.some((by) => (typeof by === "string" ? by === actor.id : actor.groups?.includes(by.group)))
377
+ )
378
+ throw new SanomaError("policy_denied", "This principal cannot create connections under this rule");
379
+ declaration = rule;
380
+ const allocation = createHash("sha256")
381
+ .update(JSON.stringify([actor.id, target.rule, requestId]))
382
+ .digest("hex");
383
+ id = `${target.rule}/${allocation}`;
384
+ credential = `CONNECTION_${allocation.toUpperCase()}`;
385
+ row = await this.row(id);
386
+ } else {
387
+ id = target.connection.$connection;
388
+ row = await this.row(id);
389
+ declaration = row?.rule ? this.catalog.rules.get(row.rule) : this.catalog.definitions.get(id);
390
+ if (!declaration || declaration.vendor !== target.connection.vendor) refuse("The connection is not declared");
391
+ if (row?.rule && row.owner !== actor.id)
392
+ throw new SanomaError("policy_denied", "The connection belongs to another principal");
393
+ credential = "credentials" in declaration ? declaration.credentials?.$secret : (row?.credential ?? undefined);
394
+ }
395
+ const method = this.catalog.methods.get(declaration.vendor)?.[declaration.method];
396
+ if (!method?.verify) throw new AuthenticationError("unsupported");
397
+ if (method.kind === "workload-identity") credential = undefined;
398
+ this.checkStore(method, credential);
399
+ return {
400
+ target,
401
+ id,
402
+ vendor: declaration.vendor,
403
+ method: declaration.method,
404
+ definition: this.bindingFingerprint(declaration, method, credential),
405
+ ...(credential && { credential, ...(this.secrets && { location: this.secrets.location(credential) }) }),
406
+ expectedRevision: row?.revision ?? null,
407
+ requiredScopes: declaration.scopes,
408
+ ...("account" in declaration && declaration.account && { expectedAccount: declaration.account }),
409
+ ...(row && { previousAccount: row.account }),
410
+ ...(row?.location && { previousLocation: row.location }),
411
+ };
412
+ }
413
+
414
+ /** Metadata-only, policy-authorized reservation. Calls cannot use it until activation verifies
415
+ * the saved bundle. A store write failure leaves an explicit uncertain, unusable account. */
416
+ async configure(
417
+ target: ConnectionTarget,
418
+ requestId: string,
419
+ expectedRevision: string | null,
420
+ definition: string,
421
+ account: string,
422
+ scopes: string[],
423
+ actor: Principal,
424
+ ): Promise<ConnectionSnapshot> {
425
+ const setup = await this.setup(target, actor, requestId);
426
+ if (setup.definition !== definition || setup.expectedRevision !== expectedRevision)
427
+ refuse("The connection changed; review it again before saving");
428
+ if (setup.expectedAccount && setup.expectedAccount !== account) throw new AuthenticationError("wrong_account");
429
+ if (setup.requiredScopes.some((s) => !scopes.includes(s))) throw new AuthenticationError("scope_changed");
430
+ const current = await this.row(setup.id);
431
+ const row: ConnectionRow = {
432
+ id: setup.id,
433
+ vendor: setup.vendor,
434
+ method: setup.method,
435
+ owner: "rule" in target ? actor.id : (current?.owner ?? null),
436
+ rule: "rule" in target ? target.rule : (current?.rule ?? null),
437
+ account,
438
+ scopes,
439
+ revision: randomUUID(),
440
+ definition,
441
+ credential: setup.credential ?? null,
442
+ status: "pending",
443
+ };
444
+ await this.save(row, expectedRevision, actor.id);
445
+ return snapshot(row);
446
+ }
447
+
448
+ async uncertain(ref: ConnectionRef, revision: string, actor: string): Promise<void> {
449
+ await (
450
+ await this.db()
451
+ ).query(
452
+ "UPDATE sanoma_connections SET status='uncertain',updated_by=$3,updated_at=now() WHERE id=$1 AND revision=$2 AND status='pending'",
453
+ [ref.$connection, revision, actor],
454
+ );
455
+ }
456
+
457
+ async storedVersion(id: string, revision: string): Promise<string | undefined> {
458
+ return (
459
+ await (
460
+ await this.db()
461
+ ).query<{ credential_version: string }>(
462
+ "SELECT credential_version FROM sanoma_connection_setups WHERE connection_id=$1 AND revision=$2",
463
+ [id, revision],
464
+ )
465
+ ).rows[0]?.credential_version;
466
+ }
467
+
468
+ async recordStoredVersion(id: string, revision: string, version: string): Promise<void> {
469
+ await (
470
+ await this.db()
471
+ ).query(
472
+ "INSERT INTO sanoma_connection_setups (connection_id,revision,credential_version) SELECT id,revision,$3 FROM sanoma_connections WHERE id=$1 AND revision=$2 AND status='pending' ON CONFLICT DO NOTHING",
473
+ [id, revision, version],
474
+ );
475
+ if ((await this.storedVersion(id, revision)) !== version) throw new SecretStoreError("conflict");
476
+ }
477
+
478
+ private async health(
479
+ id: string,
480
+ revision: string,
481
+ state: ConnectionHealth["state"],
482
+ version?: string,
483
+ external = false,
484
+ ): Promise<ConnectionHealth> {
485
+ const previous = (
486
+ await (
487
+ await this.db()
488
+ ).query<{ credential_version: string | null }>(
489
+ "SELECT credential_version FROM sanoma_connection_health WHERE connection_id=$1 AND revision=$2",
490
+ [id, revision],
491
+ )
492
+ ).rows[0];
493
+ const renewed =
494
+ external &&
495
+ previous?.credential_version != null &&
496
+ version !== undefined &&
497
+ previous.credential_version !== version;
498
+ const result = await (
499
+ await this.db()
500
+ ).query<{ checked_at: Date }>(
501
+ "INSERT INTO sanoma_connection_health (connection_id,revision,health,credential_version,externally_renewed) SELECT id,revision,$3,$4,$5 FROM sanoma_connections WHERE id=$1 AND revision=$2 ON CONFLICT(connection_id) DO UPDATE SET revision=excluded.revision,health=excluded.health,credential_version=excluded.credential_version,externally_renewed=excluded.externally_renewed,checked_at=now() RETURNING checked_at",
502
+ [id, revision, state, version ?? previous?.credential_version ?? null, renewed],
503
+ );
504
+ const checked = result.rows[0];
505
+ if (!checked) refuse("The connection changed during the test; reload before testing it again");
506
+ return { state, checkedAt: checked.checked_at.toISOString(), externallyRenewed: renewed };
507
+ }
508
+
509
+ async test(ref: ConnectionRef, actor: Principal): Promise<ConnectionHealth> {
510
+ const setup = await this.setup({ connection: ref }, actor, "test");
511
+ const row = await this.row(setup.id);
512
+ if (!row) return { state: "missing", checkedAt: new Date().toISOString(), externallyRenewed: false };
513
+ if (row.definition !== setup.definition)
514
+ return { state: "configuration_changed", checkedAt: new Date().toISOString(), externallyRenewed: false };
515
+ if (row.status !== "connected") return this.health(row.id, row.revision, row.status);
516
+ try {
517
+ const material = await this.authenticate(snapshot(row), actor.id);
518
+ return this.health(
519
+ row.id,
520
+ row.revision,
521
+ "connected",
522
+ material.credentialVersion,
523
+ setup.location?.management === "external" || !this.secrets?.store.capabilities.writable,
524
+ );
525
+ } catch (error) {
526
+ const code = (error as { code?: string })?.code;
527
+ const state =
528
+ authenticationProblem(error) ??
529
+ (code === "denied" ? "store_denied" : code === "invalid_value" ? "invalid" : "unavailable");
530
+ return this.health(row.id, row.revision, state);
531
+ }
532
+ }
533
+
534
+ async setupState(selected: ConnectionSnapshot, actor: Principal): Promise<"pending" | "connected"> {
535
+ const setup = await this.setup(
536
+ { connection: { vendor: selected.vendor, $connection: selected.id } },
537
+ actor,
538
+ "state",
539
+ );
540
+ const row = await this.row(selected.id);
541
+ if (
542
+ !row ||
543
+ row.definition !== setup.definition ||
544
+ row.account !== selected.account ||
545
+ JSON.stringify(row.scopes.toSorted()) !== JSON.stringify(selected.scopes.toSorted())
546
+ )
547
+ refuse("The connection changed; review it again before saving");
548
+ if (row.status === "connected") return "connected";
549
+ if (row.status !== "pending" || row.revision !== selected.revision)
550
+ refuse("The connection changed; review it again before saving");
551
+ return "pending";
552
+ }
553
+
554
+ /** Used inside an authorized operation; verification reads stay inside its driver step. */
555
+ async activate(ref: ConnectionRef, revision: string, actor: Principal): Promise<ConnectionSnapshot> {
556
+ const setup = await this.setup({ connection: ref }, actor, "activate");
557
+ const row = await this.row(setup.id);
558
+ if (
559
+ !row ||
560
+ row.revision !== revision ||
561
+ row.definition !== setup.definition ||
562
+ !["pending", "uncertain"].includes(row.status)
563
+ )
564
+ refuse("The connection changed; review it again before activating");
565
+ const method = this.catalog.methods.get(row.vendor)![row.method]!;
566
+ const bundle = row.credential ? await this.secrets?.read(row.credential) : undefined;
567
+ if (row.credential && !bundle) throw new SecretStoreError("unavailable");
568
+ const identity = await verifyAuthentication(method, bundle && decodeCredentials(method, bundle.value));
569
+ if (identity.account !== row.account) throw new AuthenticationError("wrong_account");
570
+ if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(row.scopes)].toSorted()))
571
+ throw new AuthenticationError("scope_changed");
572
+ const next = randomUUID();
573
+ const result = await (
574
+ await this.db()
575
+ ).query(
576
+ "UPDATE sanoma_connections SET status='connected',revision=$3,updated_by=$4,updated_at=now() WHERE id=$1 AND revision=$2 RETURNING id",
577
+ [row.id, revision, next, actor.id],
578
+ );
579
+ if (!result.rowCount) refuse("The connection changed; review it again before activating");
580
+ await this.health(row.id, next, "connected", bundle?.version);
581
+ return snapshot({ ...row, revision: next, status: "connected" });
582
+ }
583
+
225
584
  private bindingFingerprint(
226
585
  declaration: ConnectionDefinition | ConnectionRule,
227
586
  method: Authentication,
@@ -286,10 +645,40 @@ export class ConnectionRegistry {
286
645
  async assertCurrent(selected: ConnectionSnapshot | undefined, actor: string): Promise<void> {
287
646
  if (!selected) return;
288
647
  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)
648
+ if (
649
+ !current ||
650
+ current.revision !== selected.revision ||
651
+ current.definition !== selected.definition ||
652
+ current.method !== selected.method ||
653
+ current.account !== selected.account ||
654
+ current.credentials?.$secret !== selected.credentials?.$secret ||
655
+ JSON.stringify(current.scopes.toSorted()) !== JSON.stringify(selected.scopes.toSorted())
656
+ )
290
657
  refuse("The connection changed after authorization; start a new call");
291
658
  }
292
659
 
660
+ /** Only called inside an authorized driver boundary. No value escapes into workflow history. */
661
+ async authenticate(selected: ConnectionSnapshot, actor: string): Promise<CallAuthentication> {
662
+ await this.assertCurrent(selected, actor);
663
+ const method = this.catalog.methods.get(selected.vendor)?.[selected.method];
664
+ if (!method?.verify) throw new AuthenticationError("unsupported");
665
+ const material: CallAuthentication = { method: selected.method, kind: method.kind };
666
+ if (method.kind !== "workload-identity") {
667
+ if (!this.secrets || !selected.credentials) throw new SecretStoreError("unavailable");
668
+ const bundle = await this.secrets.read(selected.credentials.$secret);
669
+ if (!bundle) throw new AuthenticationError("missing");
670
+ material.credentials = decodeCredentials(method, bundle.value);
671
+ material.credentialVersion = bundle.version;
672
+ }
673
+ const identity = await verifyAuthentication(method, material.credentials);
674
+ if (identity.account !== selected.account) throw new AuthenticationError("wrong_account");
675
+ if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(selected.scopes)].toSorted()))
676
+ throw new AuthenticationError("scope_changed");
677
+ // A disconnect or rebind while verification was in flight revokes this invocation too.
678
+ await this.assertCurrent(selected, actor);
679
+ return material;
680
+ }
681
+
293
682
  async bind(
294
683
  ref: ConnectionRef,
295
684
  account: string,
@@ -395,11 +784,13 @@ export class ConnectionRegistry {
395
784
  row.definition,
396
785
  row.credential,
397
786
  actor,
787
+ row.status,
788
+ row.credential && this.secrets ? JSON.stringify(this.secrets.location(row.credential)) : null,
398
789
  ];
399
790
  const query =
400
791
  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";
792
+ ? "INSERT INTO sanoma_connections (id,vendor,method,owner,rule,account,scopes,revision,definition,credential,updated_by,status,location) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(id) DO NOTHING RETURNING id"
793
+ : "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=$12,location=$13 WHERE id=$1 AND revision=$14 RETURNING id";
403
794
  const result = await (await this.db()).query(query, [...values, ...(expected === null ? [] : [expected])]);
404
795
  if (!result.rowCount) refuse("The connection changed; reload before binding it");
405
796
  }
@@ -408,6 +799,64 @@ export class ConnectionRegistry {
408
799
  /** Metadata mutations are access operations, so they are policy-reviewed and audited like other effects. */
409
800
  export const connectionOps = defineConnector("sanoma", {
410
801
  connection: {
802
+ test: {
803
+ effect: "read",
804
+ input: z.object({ connection: connectionRefSchema }),
805
+ output: z.object({
806
+ state: z.enum([
807
+ "missing",
808
+ "expired",
809
+ "revoked",
810
+ "rejected",
811
+ "wrong_account",
812
+ "scope_changed",
813
+ "uncertain",
814
+ "unavailable",
815
+ "unsupported",
816
+ "connected",
817
+ "disconnected",
818
+ "pending",
819
+ "configuration_changed",
820
+ "store_denied",
821
+ "invalid",
822
+ ]),
823
+ checkedAt: z.string(),
824
+ externallyRenewed: z.boolean(),
825
+ }),
826
+ target: (input: { connection: ConnectionRef }) => input.connection.$connection,
827
+ },
828
+ prepare: {
829
+ effect: "access",
830
+ input: z.object({
831
+ target: connectionTargetSchema,
832
+ requestId: z.string().min(1),
833
+ definition: z.string(),
834
+ authorization: z.string(),
835
+ }),
836
+ output: setupSchema,
837
+ target: (input: { target: ConnectionTarget }) =>
838
+ "connection" in input.target ? input.target.connection.$connection : input.target.rule,
839
+ },
840
+ configure: {
841
+ effect: "access",
842
+ input: z.object({
843
+ target: connectionTargetSchema,
844
+ requestId: z.string().min(1),
845
+ expectedRevision: z.string().nullable(),
846
+ definition: z.string(),
847
+ account: z.string().min(1),
848
+ scopes: z.array(z.string()),
849
+ }),
850
+ output: snapshotSchema,
851
+ target: (input: { target: ConnectionTarget }) =>
852
+ "connection" in input.target ? input.target.connection.$connection : input.target.rule,
853
+ },
854
+ activate: {
855
+ effect: "access",
856
+ input: z.object({ connection: connectionRefSchema, expectedRevision: z.string() }),
857
+ output: snapshotSchema,
858
+ target: (input: { connection: ConnectionRef }) => input.connection.$connection,
859
+ },
411
860
  disconnect: {
412
861
  effect: "access",
413
862
  input: z.object({ connection: connectionRefSchema, expectedRevision: z.string() }),
@@ -437,6 +886,23 @@ const actor = (call: CallContext) => call.principal ?? refuse("Connection mutati
437
886
  export function connectionDriver(registry: ConnectionRegistry) {
438
887
  return defineDriver(connectionOps, {
439
888
  connection: {
889
+ test: (input, call) => registry.test(input.connection, actor(call)),
890
+ prepare: async (input, call) => {
891
+ const setup = await registry.setup(input.target, actor(call), input.requestId);
892
+ if (setup.definition !== input.definition) refuse("The connection changed; review its requirements again");
893
+ return { ...setup, requiredScopes: [...setup.requiredScopes] };
894
+ },
895
+ configure: (input, call) =>
896
+ registry.configure(
897
+ input.target,
898
+ input.requestId,
899
+ input.expectedRevision,
900
+ input.definition,
901
+ input.account,
902
+ input.scopes,
903
+ actor(call),
904
+ ),
905
+ activate: (input, call) => registry.activate(input.connection, input.expectedRevision, actor(call)),
440
906
  disconnect: (input, call) => registry.disconnect(input.connection, input.expectedRevision, actor(call).id),
441
907
  bind: (input, call) =>
442
908
  registry.bind(input.connection, input.account, input.scopes, input.expectedRevision, actor(call).id),
package/src/db.ts CHANGED
@@ -31,13 +31,26 @@ export const CHAT_MESSAGES_TABLE = "sanoma_chat_messages";
31
31
 
32
32
  /** The runtime's tables, each with the word the messages name it by. */
33
33
  const TABLES: readonly [what: string, ddl: string][] = [
34
+ [
35
+ "connection health",
36
+ `CREATE TABLE IF NOT EXISTS sanoma_connection_health (
37
+ connection_id text PRIMARY KEY, revision text NOT NULL, health text NOT NULL,
38
+ credential_version text, externally_renewed boolean NOT NULL, checked_at timestamptz NOT NULL DEFAULT now())`,
39
+ ],
40
+ [
41
+ "connection setup receipts",
42
+ `CREATE TABLE IF NOT EXISTS sanoma_connection_setups (
43
+ connection_id text NOT NULL, revision text NOT NULL, credential_version text NOT NULL,
44
+ PRIMARY KEY(connection_id, revision))`,
45
+ ],
34
46
  [
35
47
  "connections",
36
48
  `CREATE TABLE IF NOT EXISTS sanoma_connections (
37
49
  id text PRIMARY KEY, vendor text NOT NULL, method text NOT NULL, owner text, rule text,
38
50
  account text NOT NULL, scopes jsonb NOT NULL, revision text NOT NULL, definition text NOT NULL,
39
- credential text, status text NOT NULL DEFAULT 'connected', updated_by text NOT NULL,
40
- updated_at timestamptz NOT NULL DEFAULT now())`,
51
+ credential text, location jsonb, status text NOT NULL DEFAULT 'connected', updated_by text NOT NULL,
52
+ updated_at timestamptz NOT NULL DEFAULT now());
53
+ ALTER TABLE sanoma_connections ADD COLUMN IF NOT EXISTS location jsonb`,
41
54
  ],
42
55
  [
43
56
  "credentials",
package/src/describe.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import type { z } from "zod";
2
3
  import { type AppEntry, appsAt } from "./apps.ts";
3
4
  import { readSource, type SourceFile } from "./ast.ts";
@@ -17,13 +18,16 @@ import {
17
18
  import { isDirect } from "./direct.ts";
18
19
  import { type CredentialStatus, credentialsOf } from "./env.ts";
19
20
  import { lintWorkflow } from "./lint.ts";
21
+ import { fresh, stampOf, stampsOf } from "./load.ts";
22
+ import { LOCKFILE } from "./lockfile.ts";
20
23
  import { VENDOR, type VendorInfo } from "./op.ts";
21
24
  import { outlineWithSource } from "./outline.ts";
25
+ import { APPS_DIR, kindDirs } from "./paths.ts";
22
26
  import { allowAll, policyOpOf } from "./policy.ts";
23
27
  import { type Resource, resourceTypesOf, valueAt } from "./resource.ts";
24
28
  import { type DeclaredResource, inAppPeople, loadDataFiles, type ResourceProblem } from "./resources.ts";
25
29
  import { apiNameProblem } from "./sdk.ts";
26
- import { apiNameOf, BUILT_IN_WORKFLOWS, DATA_VENDOR, problemLine } from "./shared.ts";
30
+ import { apiNameOf, BUILT_IN_WORKFLOWS, DATA_VENDOR, errorMessage, problemLine } from "./shared.ts";
27
31
  import { readSites, type SiteEntry } from "./sites.ts";
28
32
 
29
33
  // `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
@@ -79,6 +83,53 @@ export { previewRequests } from "./shared.ts";
79
83
  // The apply workflow's plan and report, which the app renders.
80
84
  export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
81
85
 
86
+ /**
87
+ * `describeConfig(config)`, kept and made again only once the files it loaded have changed: each
88
+ * company file a load read and its folder (`stampsOf`), the lockfile, and the folders a new data
89
+ * file, app or site would appear in, one `stat` each per call. What the config imports (its
90
+ * workflows, connectors and policies) is read once, as the worker reads it. When describing again
91
+ * throws, the last description stands until the files change again.
92
+ */
93
+ export function liveDescription(config: SanomaConfig): () => ConfigDescription {
94
+ const root = rootOf(config);
95
+ if (root === undefined) {
96
+ const description = describeConfig(config);
97
+ return () => description;
98
+ }
99
+ /** The description and what it read; `last`, kept (and said) when describing again throws. */
100
+ const described = (last?: ConfigDescription) => {
101
+ // The folders and the lockfile as they are before describing, so a change while it reads is seen after.
102
+ const extra = [
103
+ resolve(root, LOCKFILE),
104
+ resolve(root, APPS_DIR),
105
+ ...kindDirs(root, "sites"),
106
+ ...kindDirs(root, "resources"),
107
+ ].map((path) => [path, stampOf(path)] as const);
108
+ let description: ConfigDescription;
109
+ try {
110
+ description = describeConfig(config);
111
+ } catch (err) {
112
+ if (last === undefined) throw err;
113
+ console.warn(
114
+ `sanoma: the files could not be described again, so the last description stands: ${errorMessage(err)}`,
115
+ );
116
+ description = last;
117
+ }
118
+ return { description, stamps: new Map([...extra, ...stampsOf(root)]) };
119
+ };
120
+ let now = described();
121
+ // The workflows as the config imported them, which the worker runs: their outlines and sources
122
+ // from the first description, never a file edited since.
123
+ const { workflows } = now.description;
124
+ return () => {
125
+ if (!fresh(now.stamps)) {
126
+ now = described(now.description);
127
+ now.description.workflows = workflows;
128
+ }
129
+ return now.description;
130
+ };
131
+ }
132
+
82
133
  /**
83
134
  * Describes a config. Throws what `startWorker` would refuse (see `resolveConfig`), except missing
84
135
  * credentials (`SanomaClient.credentials` gives their statuses) and what is wrong in a workflow's
package/src/direct.ts CHANGED
@@ -19,4 +19,8 @@ export const isDirect = (
19
19
  op.id === "sanoma.connection.disconnect" ||
20
20
  op.id === "sanoma.connection.bind" ||
21
21
  op.id === "sanoma.connection.create" ||
22
+ op.id === "sanoma.connection.prepare" ||
23
+ op.id === "sanoma.connection.configure" ||
24
+ op.id === "sanoma.connection.activate" ||
25
+ op.id === "sanoma.connection.test" ||
22
26
  (op.effect === "read" && op.vendor !== "sanoma" && !isResourceOp(op, types));