@sanoma-ai/workflows 0.10.0 → 0.10.2

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 (99) hide show
  1. package/CLOUD-IDENTITY.md +140 -0
  2. package/KMS-SECRETS.md +99 -0
  3. package/README.md +39 -7
  4. package/SECRET-SUPPORT.md +33 -0
  5. package/dist/apps.d.ts +6 -0
  6. package/dist/apps.js +18 -1
  7. package/dist/apps.js.map +1 -1
  8. package/dist/authentication.d.ts +35 -0
  9. package/dist/authentication.js +71 -1
  10. package/dist/authentication.js.map +1 -1
  11. package/dist/call.js +7 -5
  12. package/dist/call.js.map +1 -1
  13. package/dist/client.d.ts +54 -6
  14. package/dist/client.js +177 -5
  15. package/dist/client.js.map +1 -1
  16. package/dist/cloud-identity.d.ts +52 -0
  17. package/dist/cloud-identity.js +222 -0
  18. package/dist/cloud-identity.js.map +1 -0
  19. package/dist/config.d.ts +7 -4
  20. package/dist/config.js +44 -12
  21. package/dist/config.js.map +1 -1
  22. package/dist/connection.d.ts +223 -2
  23. package/dist/connection.js +340 -4
  24. package/dist/connection.js.map +1 -1
  25. package/dist/db.js +15 -2
  26. package/dist/db.js.map +1 -1
  27. package/dist/describe.d.ts +8 -0
  28. package/dist/describe.js +51 -1
  29. package/dist/describe.js.map +1 -1
  30. package/dist/direct.js +4 -0
  31. package/dist/direct.js.map +1 -1
  32. package/dist/fake.d.ts +13 -1
  33. package/dist/fake.js +70 -0
  34. package/dist/fake.js.map +1 -1
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.js +2 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/kms-secrets.d.ts +24 -0
  39. package/dist/kms-secrets.js +209 -0
  40. package/dist/kms-secrets.js.map +1 -0
  41. package/dist/lint.js +1 -1
  42. package/dist/lint.js.map +1 -1
  43. package/dist/load.d.ts +11 -0
  44. package/dist/load.js +42 -8
  45. package/dist/load.js.map +1 -1
  46. package/dist/op.d.ts +14 -6
  47. package/dist/op.js +20 -2
  48. package/dist/op.js.map +1 -1
  49. package/dist/outline.d.ts +5 -0
  50. package/dist/outline.js +33 -1
  51. package/dist/outline.js.map +1 -1
  52. package/dist/paths.d.ts +2 -4
  53. package/dist/paths.js +2 -8
  54. package/dist/paths.js.map +1 -1
  55. package/dist/run.d.ts +3 -1
  56. package/dist/run.js.map +1 -1
  57. package/dist/sandbox.d.ts +4 -1
  58. package/dist/sandbox.js +51 -5
  59. package/dist/sandbox.js.map +1 -1
  60. package/dist/scenario.d.ts +5 -0
  61. package/dist/scenario.js +3 -2
  62. package/dist/scenario.js.map +1 -1
  63. package/dist/secret.d.ts +2 -0
  64. package/dist/secret.js +32 -0
  65. package/dist/secret.js.map +1 -1
  66. package/dist/shared.d.ts +4 -0
  67. package/dist/shared.js +7 -0
  68. package/dist/shared.js.map +1 -1
  69. package/dist/sites.js +18 -3
  70. package/dist/sites.js.map +1 -1
  71. package/dist/steps.d.ts +3 -0
  72. package/dist/steps.js.map +1 -1
  73. package/oxlint.json +3 -0
  74. package/package.json +16 -2
  75. package/src/apps.ts +17 -1
  76. package/src/authentication.ts +126 -2
  77. package/src/call.ts +4 -5
  78. package/src/client.ts +242 -6
  79. package/src/cloud-identity.ts +266 -0
  80. package/src/config.ts +45 -11
  81. package/src/connection.ts +478 -6
  82. package/src/db.ts +15 -2
  83. package/src/describe.ts +52 -1
  84. package/src/direct.ts +4 -0
  85. package/src/fake.ts +71 -0
  86. package/src/index.ts +13 -1
  87. package/src/kms-secrets.ts +271 -0
  88. package/src/lint.ts +1 -1
  89. package/src/load.ts +36 -7
  90. package/src/op.ts +33 -8
  91. package/src/outline.ts +35 -2
  92. package/src/paths.ts +2 -9
  93. package/src/run.ts +3 -1
  94. package/src/sandbox.ts +55 -5
  95. package/src/scenario.ts +24 -6
  96. package/src/secret.ts +29 -0
  97. package/src/shared.ts +9 -0
  98. package/src/sites.ts +19 -3
  99. package/src/steps.ts +3 -2
package/src/fake.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { fail, type Instance, UNREGISTERED, type WakeupApi, wakeupCalls } from "./wakeup-driver.ts";
12
12
  import { INSTANCE_TOKEN, instanceUrl } from "./wakeup-service.ts";
13
13
  import { wakeupOps } from "./wakeup-type.ts";
14
+ import type { ConnectionRef } from "./connection.ts";
14
15
 
15
16
  /*
16
17
  * Fake vendors for tests, at `@sanoma-ai/workflows/fake`. Kept off the main entry so the
@@ -25,6 +26,76 @@ export interface FakeCall {
25
26
  at: string;
26
27
  idempotencyKey: string;
27
28
  attempt: number;
29
+ connection?: string;
30
+ }
31
+
32
+ /** A factory per named account: state, faults and idempotent replies never cross connections. */
33
+ export function connectionFakes<F extends Fake<unknown>>(make: () => F) {
34
+ const seen = new WeakSet<object>();
35
+ const fresh = () => {
36
+ const fake = make();
37
+ const parts = [
38
+ fake,
39
+ fake.driver,
40
+ fake.driver.ops,
41
+ ...(fake.state && typeof fake.state === "object" ? [fake.state] : []),
42
+ ];
43
+ if (parts.some((part) => seen.has(part)))
44
+ throw new Error("A named fake factory must create separate state for each connection");
45
+ for (const part of parts) seen.add(part);
46
+ return fake;
47
+ };
48
+ const first = fresh();
49
+ const { vendor } = first.driver;
50
+ let unused: F | undefined = first;
51
+ const accounts = new Map<string, { fake: F; identity?: string }>();
52
+ const calls: FakeCall[] = [];
53
+ const account = (ref: ConnectionRef): F => {
54
+ if (ref.vendor !== vendor || !ref.$connection.startsWith(`${vendor}/`))
55
+ throw new Error(`fake ${vendor}: select a connection of this connector`);
56
+ let entry = accounts.get(ref.$connection);
57
+ if (!entry) {
58
+ entry = { fake: unused ?? fresh() };
59
+ unused = undefined;
60
+ accounts.set(ref.$connection, entry);
61
+ }
62
+ return entry.fake;
63
+ };
64
+ const ops: Record<string, DriverFn> = {};
65
+ for (const key of Object.keys(first.driver.ops)) {
66
+ ops[key] = async (input, call) => {
67
+ const selected = call.connection;
68
+ if (!selected || selected.vendor !== vendor)
69
+ throw new DriverError(`fake ${vendor}: select a named connection`, { retryable: false });
70
+ const ref = { vendor, $connection: selected.id };
71
+ account(ref);
72
+ let entry = accounts.get(selected.id)!;
73
+ // Rebinding to another vendor account gets fresh state. Rotation within an account does not.
74
+ if (entry.identity !== undefined && entry.identity !== selected.account) {
75
+ entry = { fake: fresh() };
76
+ accounts.set(selected.id, entry);
77
+ }
78
+ entry.identity = selected.account;
79
+ calls.push({
80
+ op: `${vendor}.${key}`,
81
+ input: structuredClone(input),
82
+ at: new Date().toISOString(),
83
+ idempotencyKey: call.idempotencyKey,
84
+ attempt: call.attempt,
85
+ connection: selected.id,
86
+ });
87
+ return entry.fake.driver.ops[key]!(input, call);
88
+ };
89
+ }
90
+ return {
91
+ driver: { vendor, ops } satisfies Driver,
92
+ account,
93
+ calls,
94
+ reset() {
95
+ for (const entry of accounts.values()) entry.fake.reset();
96
+ calls.length = 0;
97
+ },
98
+ };
28
99
  }
29
100
 
30
101
  export interface FakeOptions {
package/src/index.ts CHANGED
@@ -62,7 +62,7 @@ export {
62
62
  type SanomaConfig,
63
63
  } from "./config.ts";
64
64
  // An app's `app.ts` (`defineApp`), and where the launcher finds an app's workflows and data file.
65
- export { type AppEntry, readApps } from "./apps.ts";
65
+ export { type AppEntry, appSourceOf, readApps } from "./apps.ts";
66
66
  export { type AppDefinition, defineApp } from "./definitions.ts";
67
67
  export { appFolders, APPS_DIR, filesUnder, kindDirs } from "./paths.ts";
68
68
  // `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
@@ -214,6 +214,7 @@ export {
214
214
  type ChangesFilter,
215
215
  type ChangeView,
216
216
  type ClientOptions,
217
+ type ConnectionReview,
217
218
  SanomaClient,
218
219
  type RunsFilter,
219
220
  type RunSummary,
@@ -285,7 +286,14 @@ export {
285
286
  checkAuthenticationStore,
286
287
  encodeCredentials,
287
288
  decodeCredentials,
289
+ authenticationCredentials,
290
+ verifyAuthentication,
291
+ AuthenticationError,
292
+ type AuthenticationProblem,
288
293
  type Authentication,
294
+ type AuthenticationIdentity,
295
+ type AuthenticationContext,
296
+ type CallAuthentication,
289
297
  } from "./authentication.ts";
290
298
  export { checkSecretStore, type SecretStoreCheck } from "./secret-store-check.ts";
291
299
 
@@ -301,4 +309,8 @@ export {
301
309
  type ConnectionDefinition,
302
310
  type ConnectionRule,
303
311
  type ConnectionSnapshot,
312
+ type ConnectionCard,
313
+ type ConnectionHealth,
314
+ type ConnectionTarget,
315
+ type ConnectionSetup,
304
316
  } from "./connection.ts";
@@ -0,0 +1,271 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
2
+ import type { protos } from "@google-cloud/kms";
3
+ import crc32c from "crc-32/crc32c.js";
4
+ import type { Db } from "./db.ts";
5
+ import { SecretStoreError, type SecretAddress, type SecretStore, type SecretValue } from "./secret-store.ts";
6
+
7
+ /** Install with a privileged connection; runtime needs only SELECT/INSERT/UPDATE/DELETE on its company database. */
8
+ export const KMS_SECRETS_SQL = `
9
+ CREATE SCHEMA IF NOT EXISTS sanoma_kms;
10
+ REVOKE ALL ON SCHEMA sanoma_kms FROM PUBLIC;
11
+ CREATE TABLE IF NOT EXISTS sanoma_kms.secrets (
12
+ company text NOT NULL, namespace text NOT NULL, path text NOT NULL,
13
+ version uuid NOT NULL, envelope jsonb NOT NULL,
14
+ updated_by text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(),
15
+ PRIMARY KEY (company, namespace, path)
16
+ );
17
+ REVOKE ALL ON sanoma_kms.secrets FROM PUBLIC;
18
+ DO $revoke$
19
+ DECLARE api_role text;
20
+ BEGIN
21
+ FOREACH api_role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] LOOP
22
+ IF EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = api_role) THEN
23
+ EXECUTE format('REVOKE ALL ON SCHEMA sanoma_kms FROM %I', api_role);
24
+ EXECUTE format('REVOKE ALL ON sanoma_kms.secrets FROM %I', api_role);
25
+ END IF;
26
+ END LOOP;
27
+ END;
28
+ $revoke$;
29
+ `;
30
+
31
+ /** Promise interface implemented by Google's KeyManagementServiceClient. */
32
+ export interface GcpKmsClient {
33
+ encrypt(
34
+ request: protos.google.cloud.kms.v1.IEncryptRequest,
35
+ ): Promise<[protos.google.cloud.kms.v1.IEncryptResponse, ...unknown[]]>;
36
+ decrypt(
37
+ request: protos.google.cloud.kms.v1.IDecryptRequest,
38
+ ): Promise<[protos.google.cloud.kms.v1.IDecryptResponse, ...unknown[]]>;
39
+ }
40
+
41
+ export interface KmsPostgresOptions {
42
+ /** Fixed by trusted deployment bootstrap. IAM must independently restrict the runtime to this company’s CryptoKeys. */
43
+ company: string;
44
+ /** Full GCP CryptoKey resource name for new writes, using its primary version. */
45
+ keyName: string;
46
+ /** Retained CryptoKey names allowed for reads during key replacement/backup recovery. */
47
+ decryptKeyNames?: readonly string[];
48
+ /** Explicit client under a company-scoped IAM identity, never fleet credentials. Caller owns its lifecycle. */
49
+ kms: GcpKmsClient;
50
+ /** Company-scoped database access. Caller owns connection lifecycle; never shared admin authority. */
51
+ database: () => Promise<Db>;
52
+ }
53
+
54
+ interface Envelope {
55
+ format: "sanoma-gcp-kms-v1";
56
+ key: string;
57
+ keyVersion: string;
58
+ wrapped: string;
59
+ nonce: string;
60
+ ciphertext: string;
61
+ tag: string;
62
+ }
63
+ interface Row {
64
+ version: string;
65
+ envelope: Envelope;
66
+ updated_by: string;
67
+ updated_at: Date;
68
+ }
69
+ const FORMAT = "sanoma-gcp-kms-v1";
70
+ const MAX_BYTES = 65_536;
71
+ const keyName =
72
+ /^projects\/[a-zA-Z0-9-]+\/locations\/[a-z0-9-]+\/keyRings\/[a-zA-Z0-9_-]+\/cryptoKeys\/[a-zA-Z0-9_-]+$/;
73
+ const nonempty = (v: string) =>
74
+ typeof v === "string" && v.length > 0 && Buffer.byteLength(v, "utf8") <= 1024 && !v.includes("\0");
75
+ const failed = (error: unknown): never => {
76
+ if (error instanceof SecretStoreError) throw error;
77
+ const code = (error as { code?: string | number } | null)?.code;
78
+ throw new SecretStoreError(
79
+ code === 7 || code === 16 || code === "42501"
80
+ ? "denied"
81
+ : code === 3
82
+ ? "invalid_value"
83
+ : code === 5 || code === 9 || code === "42P01"
84
+ ? "invalid_configuration"
85
+ : "unavailable",
86
+ );
87
+ };
88
+ const safe = async <T>(run: () => Promise<T>): Promise<T> => {
89
+ try {
90
+ return await run();
91
+ } catch (e) {
92
+ return failed(e);
93
+ }
94
+ };
95
+ const bytes = (v: string, length?: number, max = MAX_BYTES): Buffer => {
96
+ if (
97
+ typeof v !== "string" ||
98
+ v.length > Math.ceil((length ?? max) / 3) * 4 ||
99
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(v)
100
+ )
101
+ throw new SecretStoreError("invalid_value");
102
+ const b = Buffer.from(v, "base64");
103
+ if (length !== undefined && b.length !== length) throw new SecretStoreError("invalid_value");
104
+ return b;
105
+ };
106
+
107
+ const versionOf = (name: unknown, key: string): name is string =>
108
+ typeof name === "string" &&
109
+ name.startsWith(`${key}/cryptoKeyVersions/`) &&
110
+ /^[1-9][0-9]{0,19}$/.test(name.slice(`${key}/cryptoKeyVersions/`.length));
111
+ const checksum = (value: Uint8Array) => crc32c.buf(value) >>> 0;
112
+
113
+ /** Versioned envelope encryption; no plaintext/data-key cache, no SANOMA_SECRETS_KEY and no Secrets Manager. */
114
+ export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore {
115
+ const { company, keyName: writeKey, kms, database } = options;
116
+ const keys = new Set([writeKey, ...(options.decryptKeyNames ?? [])]);
117
+ if (!nonempty(company) || [...keys].some((k) => !nonempty(k) || !keyName.test(k)))
118
+ throw new SecretStoreError("invalid_configuration");
119
+ const context = (address: SecretAddress, version: string) => {
120
+ if (!nonempty(address.namespace) || !nonempty(address.path)) throw new SecretStoreError("invalid_configuration");
121
+ return { company, namespace: address.namespace, path: address.path, version, format: FORMAT };
122
+ };
123
+ const seal = async (address: SecretAddress, value: string, version: string): Promise<Envelope> => {
124
+ const bound = context(address, version);
125
+ const aad = Buffer.from(JSON.stringify(bound));
126
+ const dataKey = randomBytes(32);
127
+ try {
128
+ const [encrypted] = await kms.encrypt({
129
+ name: writeKey,
130
+ plaintext: dataKey,
131
+ plaintextCrc32c: { value: checksum(dataKey) },
132
+ additionalAuthenticatedData: aad,
133
+ additionalAuthenticatedDataCrc32c: { value: checksum(aad) },
134
+ });
135
+ const wrapped =
136
+ typeof encrypted.ciphertext === "string" ? bytes(encrypted.ciphertext, undefined, 6144) : encrypted.ciphertext;
137
+ if (
138
+ !versionOf(encrypted.name, writeKey) ||
139
+ !wrapped?.length ||
140
+ wrapped.length > 6144 ||
141
+ !encrypted.verifiedPlaintextCrc32c ||
142
+ !encrypted.verifiedAdditionalAuthenticatedDataCrc32c ||
143
+ Number(encrypted.ciphertextCrc32c?.value ?? NaN) !== checksum(wrapped)
144
+ )
145
+ throw new SecretStoreError("invalid_value");
146
+ const nonce = randomBytes(12);
147
+ const cipher = createCipheriv("aes-256-gcm", dataKey, nonce).setAAD(
148
+ Buffer.from(JSON.stringify({ ...bound, keyVersion: encrypted.name })),
149
+ );
150
+ const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
151
+ return {
152
+ format: FORMAT,
153
+ key: writeKey,
154
+ keyVersion: encrypted.name,
155
+ wrapped: Buffer.from(wrapped).toString("base64"),
156
+ nonce: nonce.toString("base64"),
157
+ ciphertext: ciphertext.toString("base64"),
158
+ tag: cipher.getAuthTag().toString("base64"),
159
+ };
160
+ } finally {
161
+ dataKey.fill(0);
162
+ }
163
+ };
164
+ const open = async (address: SecretAddress, row: Row): Promise<SecretValue> => {
165
+ const e = row.envelope;
166
+ if (!e || e.format !== FORMAT || !keys.has(e.key) || !versionOf(e.keyVersion, e.key))
167
+ throw new SecretStoreError("invalid_value");
168
+ const nonce = bytes(e.nonce, 12),
169
+ tag = bytes(e.tag, 16),
170
+ ciphertext = bytes(e.ciphertext),
171
+ wrapped = bytes(e.wrapped, undefined, 6144);
172
+ if (ciphertext.length > MAX_BYTES || !wrapped.length || wrapped.length > 6144)
173
+ throw new SecretStoreError("invalid_value");
174
+ const bound = context(address, row.version);
175
+ const aad = Buffer.from(JSON.stringify(bound));
176
+ const [decrypted] = await kms.decrypt({
177
+ name: e.key,
178
+ ciphertext: wrapped,
179
+ ciphertextCrc32c: { value: checksum(wrapped) },
180
+ additionalAuthenticatedData: aad,
181
+ additionalAuthenticatedDataCrc32c: { value: checksum(aad) },
182
+ });
183
+ const dataKey = typeof decrypted.plaintext === "string" ? bytes(decrypted.plaintext, 32) : decrypted.plaintext;
184
+ try {
185
+ if (dataKey?.length !== 32 || Number(decrypted.plaintextCrc32c?.value ?? NaN) !== checksum(dataKey))
186
+ throw new SecretStoreError("invalid_value");
187
+ const decipher = createDecipheriv("aes-256-gcm", dataKey, nonce)
188
+ .setAAD(Buffer.from(JSON.stringify({ ...bound, keyVersion: e.keyVersion })))
189
+ .setAuthTag(tag);
190
+ let plaintext: Buffer;
191
+ try {
192
+ plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
193
+ } catch {
194
+ throw new SecretStoreError("invalid_value");
195
+ }
196
+ try {
197
+ return {
198
+ value: plaintext.toString("utf8"),
199
+ version: row.version,
200
+ updatedBy: row.updated_by,
201
+ updatedAt: row.updated_at.toISOString(),
202
+ };
203
+ } finally {
204
+ plaintext.fill(0);
205
+ }
206
+ } finally {
207
+ dataKey?.fill(0);
208
+ }
209
+ };
210
+ return {
211
+ id: "kms-postgres",
212
+ capabilities: Object.freeze({
213
+ writable: true,
214
+ maxBytes: MAX_BYTES,
215
+ conditionalWrites: "atomic",
216
+ deletion: "destroy",
217
+ changeDetection: "read",
218
+ }),
219
+ validate: (namespace) =>
220
+ safe(async () => {
221
+ const address = { namespace, path: "__validation__" },
222
+ version = randomUUID();
223
+ const envelope = await seal(address, "validation", version);
224
+ await open(address, { envelope, version, updated_by: "validation", updated_at: new Date() });
225
+ await (await database()).query("SELECT version FROM sanoma_kms.secrets WHERE company=$1 LIMIT 0", [company]);
226
+ // Own-key usability only. Cross-company authorization requires live IAM proof with another existing key.
227
+ }),
228
+ read: (address) =>
229
+ safe(async () => {
230
+ context(address, "read");
231
+ const { rows } = await (
232
+ await database()
233
+ ).query<Row>(
234
+ "SELECT version,envelope,updated_by,updated_at FROM sanoma_kms.secrets WHERE company=$1 AND namespace=$2 AND path=$3",
235
+ [company, address.namespace, address.path],
236
+ );
237
+ return rows[0] && open(address, rows[0]);
238
+ }),
239
+ write: (address, value, { expectedVersion, by }) =>
240
+ safe(async () => {
241
+ if (Buffer.byteLength(value, "utf8") > MAX_BYTES) throw new SecretStoreError("too_large");
242
+ if (!nonempty(by)) throw new SecretStoreError("invalid_value");
243
+ const version = randomUUID(),
244
+ envelope = await seal(address, value, version);
245
+ const args = [company, address.namespace, address.path, version, JSON.stringify(envelope), by];
246
+ const sql =
247
+ expectedVersion === null
248
+ ? "INSERT INTO sanoma_kms.secrets(company,namespace,path,version,envelope,updated_by) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(company,namespace,path) DO NOTHING RETURNING version,envelope,updated_by,updated_at"
249
+ : "UPDATE sanoma_kms.secrets SET version=$4,envelope=$5,updated_by=$6,updated_at=now() WHERE company=$1 AND namespace=$2 AND path=$3 AND version::text=$7 RETURNING version,envelope,updated_by,updated_at";
250
+ const { rows } = await (
251
+ await database()
252
+ ).query<Row>(sql, expectedVersion === null ? args : [...args, expectedVersion]);
253
+ if (!rows[0]) throw new SecretStoreError("conflict");
254
+ // Return the caller's value, never make a second decrypt after a successful commit.
255
+ return { value, version, updatedBy: by, updatedAt: rows[0].updated_at.toISOString() };
256
+ }),
257
+ delete: (address, { expectedVersion }) =>
258
+ safe(async () => {
259
+ context(address, expectedVersion);
260
+ const { rowCount } = await (
261
+ await database()
262
+ ).query("DELETE FROM sanoma_kms.secrets WHERE company=$1 AND namespace=$2 AND path=$3 AND version::text=$4", [
263
+ company,
264
+ address.namespace,
265
+ address.path,
266
+ expectedVersion,
267
+ ]);
268
+ if (!rowCount) throw new SecretStoreError("conflict");
269
+ }),
270
+ };
271
+ }
package/src/lint.ts CHANGED
@@ -28,7 +28,7 @@ const ALLOWED_PACKAGE = new RegExp(`^(${[...AUTHOR_PACKAGES, "@sanoma-ai/connect
28
28
  const REFUSED_PACKAGES: [RegExp, string][] = [
29
29
  [/^@sanoma-ai\/testing(\/|$)/, `fakes and test helpers belong in tests; ${THROUGH_CTX}`],
30
30
  [/^@sanoma-ai\/app(\/|$)/, "the app starts runs and decides approvals, so a workflow could approve itself"],
31
- [/^@sanoma-ai\/connector-[^/]+\/(fake|driver|remote)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
31
+ [/^@sanoma-ai\/connector-[^/]+\/(fake|driver|remote|vault)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
32
32
  [/^sanoma\//, "`sanoma` has no entry points for a workflow: import its names from `sanoma` itself"],
33
33
  ];
34
34
 
package/src/load.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, realpathSync, statSync } from "node:fs";
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { createRequire, registerHooks } from "node:module";
3
3
  import { dirname, isAbsolute, join, relative, sep } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -254,20 +254,47 @@ function placeOf(gen: Generation, error: unknown): { file: string; line?: number
254
254
  return undefined;
255
255
  }
256
256
 
257
+ /**
258
+ * Each company file under `root` a load has read, and each one's folder, with its stamp
259
+ * (`stampOf`) as it was before the file was read: the latest generation's for a file several
260
+ * read. Taken after a load, it still says what the load read, so a file changed while it read is
261
+ * found changed after.
262
+ */
263
+ export function stampsOf(root: string): Map<string, string> {
264
+ const stamps = new Map<string, string>();
265
+ if (!existsSync(root)) return stamps;
266
+ const real = realpathSync(root);
267
+ // In the order made: a later generation's stamp is the newer.
268
+ for (const gen of registry.generations.values()) {
269
+ if (gen.root === real) for (const [path, stamp] of gen.stamps) stamps.set(path, stamp);
270
+ }
271
+ return stamps;
272
+ }
273
+
257
274
  const byOrder = <T extends string | number>(a: T, b: T) => (a < b ? -1 : a > b ? 1 : 0);
258
275
 
259
- /** A file's size and modification time, or that it is gone. */
260
- function stampOf(path: string): string {
276
+ /** A file's size and modification time, or that it is gone: what a generation keeps of each file it read. */
277
+ export function stampOf(path: string): string {
261
278
  const stat = statSync(path, { bigint: true, throwIfNoEntry: false });
262
279
  return stat ? `${stat.size} ${stat.mtimeNs}` : "gone";
263
280
  }
264
281
 
282
+ /** True while each path's stamp (`stampOf`) is still the one kept. */
283
+ export function fresh(stamps: ReadonlyMap<string, string>): boolean {
284
+ for (const [path, stamp] of stamps) if (stampOf(path) !== stamp) return false;
285
+ return true;
286
+ }
287
+
265
288
  /** Records a file of the generation, and its directory, as they are now: before it is read, so an edit after is seen. */
266
289
  function touch(gen: Generation, file: string) {
267
290
  for (const path of [file, dirname(file)]) if (!gen.stamps.has(path)) gen.stamps.set(path, stampOf(path));
268
291
  }
269
292
 
270
- /** The generation of `root` and these constructors: the current one while nothing it stamped changed, else a new one. */
293
+ /**
294
+ * The generation of `root` and these constructors: the current one while nothing it stamped
295
+ * changed, else a new one. A generation of `root` whose files changed goes, whatever its
296
+ * constructors, so `stampsOf` never keeps the stamps of one no load will make again.
297
+ */
271
298
  function generationOf(
272
299
  root: string,
273
300
  connectors: readonly Connector<any, any>[],
@@ -278,11 +305,13 @@ function generationOf(
278
305
  return info?.package ? [`${info.package}/resources`, id] : [];
279
306
  });
280
307
  const typesKey = [...packages, ...[...types.values()].map(idOf)].join(" ");
308
+ let current: Generation | undefined;
281
309
  for (const gen of registry.generations.values()) {
282
- if (gen.root !== root || gen.typesKey !== typesKey) continue;
283
- if ([...gen.stamps].every(([path, stamp]) => stampOf(path) === stamp)) return gen;
284
- registry.generations.delete(gen.id);
310
+ if (gen.root !== root) continue;
311
+ if (!fresh(gen.stamps)) registry.generations.delete(gen.id);
312
+ else if (gen.typesKey === typesKey) current = gen;
285
313
  }
314
+ if (current !== undefined) return current;
286
315
  const gen: Generation = {
287
316
  id: String(++registry.seq),
288
317
  root,
package/src/op.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Principal } from "./define.ts";
2
- import type { Authentication } from "./authentication.ts";
2
+ import type { Authentication, CallAuthentication } from "./authentication.ts";
3
3
  import type { ConnectionSnapshot } from "./connection.ts";
4
4
  import type { ProviderV4 } from "@ai-sdk/provider";
5
5
  import type { z } from "zod";
@@ -41,6 +41,8 @@ export interface OpSpec<I extends z.ZodType = z.ZodType, O extends z.ZodType = z
41
41
  * (a resource type's `handle`): the ledger records each as `"<name>"`, never its value.
42
42
  */
43
43
  opaque?: readonly string[];
44
+ /** Logical destinations this operation may create with Secrets.ensure, derived from its authorized input. */
45
+ createsSecrets?: (input: any) => readonly string[];
44
46
  }
45
47
 
46
48
  /** One vendor operation, such as `ghost.post.publish`. Declares the contract only; drivers implement it. */
@@ -59,6 +61,7 @@ export interface Op<V extends string = string, R extends string = string, N exte
59
61
  readonly phrases?: { readonly given?: string; readonly expect?: string };
60
62
  /** From the spec: fields the ledger records by name only. */
61
63
  readonly opaque?: readonly string[];
64
+ readonly createsSecrets?: (input: any) => readonly string[];
62
65
  readonly input: z.ZodType<any, I>;
63
66
  readonly output: z.ZodType<O>;
64
67
  }
@@ -111,7 +114,7 @@ export type Connector<V extends string, S extends Specs> = {
111
114
  * and `resolveConfig` reads only a record of its own shape (`checkConnector`): change it with
112
115
  * the record, so a build refuses a connector another build made in another shape.
113
116
  */
114
- export const CONNECTOR_FORMAT = 2;
117
+ export const CONNECTOR_FORMAT = 3;
115
118
 
116
119
  /**
117
120
  * A connector's vendor: its id, who it is (`defineConnector`'s third argument, for a UI), its
@@ -212,6 +215,7 @@ export function defineConnector<const V extends string, const S extends Specs>(
212
215
  target: spec.target,
213
216
  phrases: spec.phrases,
214
217
  ...(spec.opaque?.length ? { opaque: spec.opaque } : {}),
218
+ ...(spec.createsSecrets && { createsSecrets: spec.createsSecrets }),
215
219
  input: spec.input,
216
220
  output: spec.output,
217
221
  } satisfies Op);
@@ -243,6 +247,8 @@ export interface CallContext {
243
247
  principal?: Principal;
244
248
  /** Authorized account metadata. Credential material stays inside the driver boundary. */
245
249
  connection?: ConnectionSnapshot;
250
+ /** Resolved after policy, for this call alone. Never return it from a driver. */
251
+ authentication?: CallAuthentication;
246
252
  idempotencyKey: string;
247
253
  runId: string;
248
254
  opId: string;
@@ -302,13 +308,15 @@ export function retryableStatus(status: number): boolean {
302
308
  export type DriverEnv = z.ZodObject<Record<string, z.ZodType<string | undefined, string | undefined>>>;
303
309
 
304
310
  /**
305
- * Implements a vendor's operations, keyed `"resource.name"`. A driver reads its credentials
306
- * from the environment when it is called, never from the config file; `env` declares them.
311
+ * Implements a vendor's operations, keyed `"resource.name"`. Named authentication is supplied
312
+ * in the call context. `env` describes a driver that has not added named-account support.
307
313
  */
308
314
  export interface Driver {
309
315
  readonly vendor: string;
310
316
  readonly ops: Readonly<Record<string, DriverFn>>;
311
317
  readonly env?: DriverEnv;
318
+ /** Methods this driver consumes from CallContext.authentication. Others fail closed. */
319
+ readonly authentication?: readonly string[];
312
320
  }
313
321
 
314
322
  /**
@@ -322,7 +330,7 @@ export interface Driver {
322
330
  export interface ConnectorSetup {
323
331
  /** Auth methods available to named connections; login identity is separate. */
324
332
  authentication?: Readonly<Record<string, Authentication>>;
325
- /** Makes the live driver, which reads its credentials from the environment when called: once per resolved config. */
333
+ /** Makes the live driver once per resolved config; credentials belong to each invocation. */
326
334
  driver?: () => Driver;
327
335
  /** Makes a fake of the vendor (`defineFake`): once per sandbox run, its own. */
328
336
  fake?: () => Fake<any, any>;
@@ -466,8 +474,8 @@ export type OpIdOf<V extends string, S extends Specs> = {
466
474
  */
467
475
  export function defineDriver<V extends string, S extends Specs>(
468
476
  connector: Connector<V, S>,
469
- impl: DriverImpl<S>,
470
- options: { env?: DriverEnv } = {},
477
+ impl: DriverImpl<S> | ((call: CallContext) => DriverImpl<S>),
478
+ options: { env?: DriverEnv; authentication?: readonly string[] } = {},
471
479
  ): Driver {
472
480
  const declared = new Map<string, Op>();
473
481
  for (const resource of Object.values(connector as Record<string, Record<string, unknown>>)) {
@@ -475,6 +483,18 @@ export function defineDriver<V extends string, S extends Specs>(
475
483
  }
476
484
  const vendor = connector[VENDOR].id;
477
485
  const ops: Record<string, DriverFn> = {};
486
+ if (typeof impl === "function") {
487
+ // Build clients only once the runtime has authorized and authenticated this invocation.
488
+ for (const key of declared.keys()) {
489
+ ops[key] = async (input, call) => defineDriver(connector, impl(call), options).ops[key]!(input, call);
490
+ }
491
+ return Object.freeze({
492
+ vendor,
493
+ ops: Object.freeze(ops),
494
+ ...(options.env && { env: options.env }),
495
+ ...(options.authentication && { authentication: Object.freeze([...options.authentication]) }),
496
+ });
497
+ }
478
498
  const extra: string[] = [];
479
499
  for (const [resource, fns] of Object.entries(impl as Record<string, Record<string, unknown>>)) {
480
500
  for (const [name, fn] of Object.entries(fns ?? {})) {
@@ -489,5 +509,10 @@ export function defineDriver<V extends string, S extends Specs>(
489
509
  ...(extra.length ? [`it implements ${extra.join(", ")}, which the connector does not declare`] : []),
490
510
  ];
491
511
  if (problems.length) throw new Error(`defineDriver("${vendor}"): ${problems.join("; ")}`);
492
- return Object.freeze({ vendor, ops: Object.freeze(ops), ...(options.env && { env: options.env }) });
512
+ return Object.freeze({
513
+ vendor,
514
+ ops: Object.freeze(ops),
515
+ ...(options.env && { env: options.env }),
516
+ ...(options.authentication && { authentication: Object.freeze([...options.authentication]) }),
517
+ });
493
518
  }
package/src/outline.ts CHANGED
@@ -44,7 +44,7 @@ export type OutlineNode = { path: string; span: Span } &
44
44
  */
45
45
  /** `name`: the one the call gives (`ctx.x.y.z(input, { name: "…" })`), else the id when no segment is computed. */
46
46
  (
47
- | { kind: "op"; id: string; ops?: string[]; name?: string }
47
+ | { kind: "op"; id: string; ops?: string[]; name?: string; connection?: { id: string } | { dynamic: true } }
48
48
  /**
49
49
  * `ctx.approval(...)`, with its title, and the title as its `name`, when the first argument is
50
50
  * a string literal or a top-level `const` holding one.
@@ -417,6 +417,31 @@ export function outlineBody(
417
417
  return undefined;
418
418
  };
419
419
 
420
+ // Dependency information only. Execution still resolves and authorizes the actual reference.
421
+ // An imported/computed reference stays unknown rather than being guessed from its variable name.
422
+ const connectionIn = (options: Node | undefined): { id: string } | { dynamic: true } | undefined => {
423
+ if (!options) return undefined;
424
+ const object = options && unwrap(options);
425
+ if (object?.type !== "ObjectExpression") return { dynamic: true };
426
+ if (object.properties.some((p: Node) => p.type !== "Property" || p.computed)) return { dynamic: true };
427
+ const property =
428
+ object?.type === "ObjectExpression" &&
429
+ object.properties.findLast(
430
+ (p: Node) => p.type === "Property" && !p.computed && (p.key.name ?? p.key.value) === "connection",
431
+ );
432
+ if (!property) return undefined;
433
+ const ref = unwrap(property.value);
434
+ if (ref?.type !== "ObjectExpression") return { dynamic: true };
435
+ if (ref.properties.some((p: Node) => p.type !== "Property" || p.computed)) return { dynamic: true };
436
+ const key =
437
+ ref?.type === "ObjectExpression" &&
438
+ ref.properties.findLast(
439
+ (p: Node) => p.type === "Property" && !p.computed && (p.key.name ?? p.key.value) === "$connection",
440
+ );
441
+ const id = key && literalOf(key.value, consts);
442
+ return typeof id === "string" ? { id } : { dynamic: true };
443
+ };
444
+
420
445
  // The arguments are evaluated before the call is made, so their calls come first.
421
446
  const call = (node: Node, at: Where): OutlineNode[] => {
422
447
  const args: Node[] = node.arguments;
@@ -442,9 +467,17 @@ export function outlineBody(
442
467
  if (isOpPath(members)) {
443
468
  // A computed id's own name is the operation it calls, known only in a run.
444
469
  const name = nameIn(args[1], true) ?? (id.includes("*") ? undefined : id);
470
+ const connection = connectionIn(args[1]);
445
471
  return [
446
472
  ...walk(args, at),
447
- { kind: "op", path, id, span: span(node), ...(name === undefined ? {} : { name }) },
473
+ {
474
+ kind: "op",
475
+ path,
476
+ id,
477
+ span: span(node),
478
+ ...(name === undefined ? {} : { name }),
479
+ ...(connection && { connection }),
480
+ },
448
481
  ];
449
482
  }
450
483
  return READS.has(id) ? walk(args, at) : [...walk(args, at), ...problem(node, CTX_CALL(id))];