@sanoma-ai/workflows 0.10.7 → 0.10.11

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 (54) hide show
  1. package/CLOUD-IDENTITY.md +25 -5
  2. package/KMS-SECRETS.md +75 -11
  3. package/README.md +18 -11
  4. package/dist/client.js +3 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/collection.d.ts +1 -0
  7. package/dist/collection.js +25 -8
  8. package/dist/collection.js.map +1 -1
  9. package/dist/config.d.ts +3 -2
  10. package/dist/config.js +4 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/connection.d.ts +2 -0
  13. package/dist/connection.js +4 -0
  14. package/dist/connection.js.map +1 -1
  15. package/dist/credentials.d.ts +1 -1
  16. package/dist/credentials.js +21 -2
  17. package/dist/credentials.js.map +1 -1
  18. package/dist/identity-provisioning.d.ts +11 -1
  19. package/dist/identity-provisioning.js +59 -0
  20. package/dist/identity-provisioning.js.map +1 -1
  21. package/dist/identity-service.js +1 -1
  22. package/dist/identity-service.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/kms-secrets.d.ts +19 -4
  26. package/dist/kms-secrets.js +70 -11
  27. package/dist/kms-secrets.js.map +1 -1
  28. package/dist/runtime-ops.d.ts +31 -1
  29. package/dist/runtime-ops.js +30 -1
  30. package/dist/runtime-ops.js.map +1 -1
  31. package/dist/secret-store.d.ts +21 -2
  32. package/dist/secret-store.js +18 -3
  33. package/dist/secret-store.js.map +1 -1
  34. package/dist/shared.d.ts +1 -0
  35. package/dist/storage-provisioning.d.ts +25 -0
  36. package/dist/storage-provisioning.js +221 -0
  37. package/dist/storage-provisioning.js.map +1 -0
  38. package/dist/worker.js +9 -2
  39. package/dist/worker.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/client.ts +3 -0
  42. package/src/collection.ts +28 -8
  43. package/src/config.ts +10 -3
  44. package/src/connection.ts +5 -0
  45. package/src/credentials.ts +29 -2
  46. package/src/identity-provisioning.ts +64 -1
  47. package/src/identity-service.ts +1 -1
  48. package/src/index.ts +1 -0
  49. package/src/kms-secrets.ts +90 -13
  50. package/src/runtime-ops.ts +37 -0
  51. package/src/secret-store.ts +34 -3
  52. package/src/shared.ts +1 -1
  53. package/src/storage-provisioning.ts +257 -0
  54. package/src/worker.ts +9 -2
package/src/config.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { configuredSecrets } from "./credentials.ts";
2
- import type { IdentityProvisioning } from "./identity-provisioning.ts";
2
+ import {
3
+ configuredIdentityProvisioning,
4
+ type IdentityProvisioning,
5
+ type IdentityProvisioningDatabase,
6
+ } from "./identity-provisioning.ts";
3
7
  import { secretsForInput } from "./secret.ts";
4
8
  import { isRunControlError } from "./errors.ts";
5
9
  import { currentRun } from "./run.ts";
@@ -109,7 +113,7 @@ export interface SanomaConfig {
109
113
  /** Credential storage and explicit bindings. Omitted: encrypted Postgres, no environment fallback. */
110
114
  secrets?: SecretConfiguration;
111
115
  /** Trusted control plane only: separate broker enrollment writer. Absent in company runtimes. */
112
- identityProvisioning?: IdentityProvisioning;
116
+ identityProvisioning?: IdentityProvisioning | IdentityProvisioningDatabase;
113
117
  connections?: readonly ConnectionDefinition[];
114
118
  connectionRules?: readonly ConnectionRule[];
115
119
  /** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then `DEFAULT_DATABASE_URL`. */
@@ -159,6 +163,7 @@ export interface ResolvedConfig {
159
163
  secrets: SecretConfiguration;
160
164
  connections: ConnectionRegistry;
161
165
  connectionPool: LazyPool;
166
+ identityProvisioning: ReturnType<typeof configuredIdentityProvisioning>;
162
167
  /**
163
168
  * Scopes workflows, queues and versions in the system database: the `name` of
164
169
  * `<root>/package.json` when it names one, else "sanoma".
@@ -331,6 +336,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
331
336
  // Built-in: the collections' operations, `data.<name>.<op>`, on the config's database.
332
337
  const databaseUrl = resolveDatabaseUrl(config);
333
338
  const connectionPool = lazyPool(databaseUrl, "connections");
339
+ const identityProvisioning = configuredIdentityProvisioning(config.identityProvisioning);
334
340
  const connections = new ConnectionRegistry(
335
341
  connectionCatalog(config.connectors, config.connections, config.connectionRules),
336
342
  () => connectionPool.db(),
@@ -361,7 +367,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
361
367
  changeDriver(remotes),
362
368
  connectionDriver(connections),
363
369
  appsListDriver,
364
- runtimeDriver({ tokens: tokenStore(() => connectionPool.db()), identities: config.identityProvisioning }),
370
+ runtimeDriver({ tokens: tokenStore(() => connectionPool.db()), identities: identityProvisioning.store }),
365
371
  ...(hostTyped ? [sourceDriver] : []),
366
372
  ...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
367
373
  ],
@@ -413,6 +419,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
413
419
  secrets: config.secrets ?? {},
414
420
  connections,
415
421
  connectionPool,
422
+ identityProvisioning,
416
423
  appName,
417
424
  databaseUrl,
418
425
  // The built-in's code and operations are part of the app's version, like the config's own.
package/src/connection.ts CHANGED
@@ -300,6 +300,11 @@ export class ConnectionRegistry {
300
300
  this.secrets = secrets;
301
301
  }
302
302
 
303
+ /** Release runtime-owned storage clients; the caller owns the database connection. */
304
+ async close(): Promise<void> {
305
+ await this.secrets?.close();
306
+ }
307
+
303
308
  /** A direct metadata write uses the same transaction as its default ledger record. */
304
309
  onDatabase(db: Db): ConnectionRegistry {
305
310
  return new ConnectionRegistry(this.catalog, async () => db, this.secrets);
@@ -3,6 +3,8 @@ import { CREDENTIALS_TABLE as TABLE, type Db } from "./db.ts";
3
3
  import {
4
4
  SecretAccess,
5
5
  SecretStoreError,
6
+ checkSecretConfiguration,
7
+ isSecretStoreFactory,
6
8
  type SecretAddress,
7
9
  type SecretConfiguration,
8
10
  type SecretStore,
@@ -105,5 +107,30 @@ export function postgresSecretStore(db: () => Promise<Db>): SecretStore {
105
107
  };
106
108
  }
107
109
 
108
- export const configuredSecrets = (config: SecretConfiguration, db: () => Promise<Db>): SecretAccess =>
109
- new SecretAccess(config, postgresSecretStore(db));
110
+ export function configuredSecrets(config: SecretConfiguration, db: () => Promise<Db>): SecretAccess {
111
+ checkSecretConfiguration(config);
112
+ const selected = config.store;
113
+ if (!selected || !isSecretStoreFactory(selected)) return new SecretAccess(config, postgresSecretStore(db));
114
+ try {
115
+ const store = selected.create({ database: db });
116
+ checkSecretConfiguration({ store });
117
+ if (
118
+ isSecretStoreFactory(store) ||
119
+ (store.close !== undefined && typeof store.close !== "function") ||
120
+ store.id !== selected.id ||
121
+ (Object.keys(selected.capabilities) as (keyof typeof selected.capabilities)[]).some(
122
+ (key) => store.capabilities[key] !== selected.capabilities[key],
123
+ )
124
+ )
125
+ throw new SecretStoreError("invalid_configuration");
126
+ return new SecretAccess(
127
+ { ...config, store },
128
+ postgresSecretStore(db),
129
+ undefined,
130
+ () => store.close?.() ?? Promise.resolve(),
131
+ );
132
+ } catch {
133
+ // Config construction may otherwise leak a provider's bootstrap/database diagnostics.
134
+ throw new SecretStoreError("invalid_configuration");
135
+ }
136
+ }
@@ -1,4 +1,4 @@
1
- import type { Pool, PoolClient } from "pg";
1
+ import { Pool, type PoolClient } from "pg";
2
2
  import type { IdentityEnrollment } from "./cloud-identity.ts";
3
3
 
4
4
  export type DeploymentIdentity = Omit<IdentityEnrollment, "generation">;
@@ -14,6 +14,69 @@ export interface IdentityProvisioning {
14
14
  revoke(input: DeploymentIdentity): Promise<void>;
15
15
  }
16
16
 
17
+ /** Trusted control-plane configuration; the runtime owns this separate writer connection. */
18
+ export interface IdentityProvisioningDatabase {
19
+ databaseUrl: string;
20
+ maxValidityMs: number;
21
+ }
22
+
23
+ /** Resolve without I/O. Explicit adapters remain caller-owned; database configuration is runtime-owned. */
24
+ export function configuredIdentityProvisioning(
25
+ config: IdentityProvisioning | IdentityProvisioningDatabase | undefined,
26
+ ): { store?: IdentityProvisioning; close(): Promise<void> } {
27
+ if (!config) return { close: async () => {} };
28
+ if (!("databaseUrl" in config)) return { store: config, close: async () => {} };
29
+ let url: URL;
30
+ try {
31
+ url = new URL(config.databaseUrl);
32
+ if (
33
+ !["postgres:", "postgresql:"].includes(url.protocol) ||
34
+ !url.hostname ||
35
+ !url.username ||
36
+ !url.password ||
37
+ !/^\/[^/]+$/.test(url.pathname) ||
38
+ url.hash ||
39
+ [...url.searchParams.keys()].some((key) => !["sslmode", "channel_binding"].includes(key))
40
+ )
41
+ fail("invalid");
42
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) url.searchParams.set("sslmode", "verify-full");
43
+ } catch {
44
+ throw new IdentityProvisioningError("invalid");
45
+ }
46
+ const connectionString = url.href;
47
+ let pool: Pool | undefined;
48
+ let closed = false;
49
+ let closing: Promise<void> | undefined;
50
+ const store = postgresIdentityProvisioning(async () => {
51
+ if (closed) throw new IdentityProvisioningError("unavailable");
52
+ if (!pool) {
53
+ pool = new Pool({ connectionString, max: 5, connectionTimeoutMillis: 10_000, query_timeout: 15_000 });
54
+ pool.on("error", () => {});
55
+ }
56
+ // The control plane needs enrollment writes, never schema or database administration.
57
+ const { rows } = await pool.query<{ allowed: boolean }>(`SELECT
58
+ NOT EXISTS (SELECT FROM pg_roles WHERE rolname=current_user AND (rolsuper OR rolcreatedb OR rolcreaterole OR rolbypassrls))
59
+ AND has_schema_privilege('sanoma_identity','USAGE') AND NOT has_schema_privilege('sanoma_identity','CREATE')
60
+ AND has_table_privilege('sanoma_identity.deployments','SELECT')
61
+ AND has_table_privilege('sanoma_identity.deployments','INSERT')
62
+ AND has_table_privilege('sanoma_identity.deployments','UPDATE')
63
+ AND has_table_privilege('sanoma_identity.credentials','SELECT')
64
+ AND has_table_privilege('sanoma_identity.credentials','INSERT')
65
+ AND has_table_privilege('sanoma_identity.credentials','UPDATE')
66
+ AND NOT has_table_privilege('sanoma_identity.deployments','DELETE,TRUNCATE,REFERENCES,TRIGGER')
67
+ AND NOT has_table_privilege('sanoma_identity.credentials','DELETE,TRUNCATE,REFERENCES,TRIGGER') AS allowed`);
68
+ if (!rows[0]?.allowed) fail("invalid");
69
+ return pool;
70
+ }, config);
71
+ return {
72
+ store,
73
+ close() {
74
+ closed = true;
75
+ return (closing ??= pool?.end() ?? Promise.resolve());
76
+ },
77
+ };
78
+ }
79
+
17
80
  export class IdentityProvisioningError extends Error {
18
81
  readonly retryable: boolean;
19
82
  readonly code: "invalid" | "conflict" | "unavailable";
@@ -139,7 +139,7 @@ export async function startIdentityService(options: IdentityServiceOptions): Pro
139
139
  res.end('{"error":"invalid_request"}');
140
140
  return;
141
141
  }
142
- if (req.url === "/healthz" && (req.method === "GET" || req.method === "HEAD")) {
142
+ if (req.url === "/health" && (req.method === "GET" || req.method === "HEAD")) {
143
143
  res.writeHead(200, { "Content-Type": "text/plain", "Cache-Control": "no-store" });
144
144
  res.end("ok\n");
145
145
  return;
package/src/index.ts CHANGED
@@ -296,6 +296,7 @@ export {
296
296
  type SecretConfiguration,
297
297
  type SecretLocation,
298
298
  type SecretStore,
299
+ type SecretStoreFactory,
299
300
  type SecretStoreCapabilities,
300
301
  type SecretStoreErrorCode,
301
302
  type SecretValue,
@@ -2,7 +2,14 @@ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:
2
2
  import type { protos } from "@google-cloud/kms";
3
3
  import crc32c from "crc-32/crc32c.js";
4
4
  import type { Db } from "./db.ts";
5
- import { SecretStoreError, type SecretAddress, type SecretStore, type SecretValue } from "./secret-store.ts";
5
+ import { cloudIdentityAuth } from "./cloud-identity.ts";
6
+ import {
7
+ SecretStoreError,
8
+ type SecretAddress,
9
+ type SecretStore,
10
+ type SecretStoreFactory,
11
+ type SecretValue,
12
+ } from "./secret-store.ts";
6
13
 
7
14
  /** Install with a privileged connection; runtime needs only SELECT/INSERT/UPDATE/DELETE on its company database. */
8
15
  export const KMS_SECRETS_SQL = `
@@ -47,8 +54,8 @@ export interface KmsPostgresOptions {
47
54
  decryptKeyNames?: readonly string[];
48
55
  /** Explicit client under a company-scoped IAM identity, never fleet credentials. Caller owns its lifecycle. */
49
56
  kms: GcpKmsClient;
50
- /** Company-scoped database access. Caller owns connection lifecycle; never shared admin authority. */
51
- database: () => Promise<Db>;
57
+ /** Omit in config to use the runtime's database. An explicit connection remains caller-owned. */
58
+ database?: () => Promise<Db>;
52
59
  }
53
60
 
54
61
  interface Envelope {
@@ -68,6 +75,13 @@ interface Row {
68
75
  }
69
76
  const FORMAT = "sanoma-gcp-kms-v1";
70
77
  const MAX_BYTES = 65_536;
78
+ const capabilities = Object.freeze({
79
+ writable: true,
80
+ maxBytes: MAX_BYTES,
81
+ conditionalWrites: "atomic" as const,
82
+ deletion: "destroy" as const,
83
+ changeDetection: "read" as const,
84
+ });
71
85
  const keyName =
72
86
  /^projects\/[a-zA-Z0-9-]+\/locations\/[a-z0-9-]+\/keyRings\/[a-zA-Z0-9_-]+\/cryptoKeys\/[a-zA-Z0-9_-]+$/;
73
87
  const nonempty = (v: string) =>
@@ -110,12 +124,81 @@ const versionOf = (name: unknown, key: string): name is string =>
110
124
  /^[1-9][0-9]{0,19}$/.test(name.slice(`${key}/cryptoKeyVersions/`.length));
111
125
  const checksum = (value: Uint8Array) => crc32c.buf(value) >>> 0;
112
126
 
127
+ function checkedKeys(options: Pick<KmsPostgresOptions, "company" | "keyName" | "decryptKeyNames">): Set<string> {
128
+ const keys = new Set([options.keyName, ...(options.decryptKeyNames ?? [])]);
129
+ if (!nonempty(options.company) || [...keys].some((key) => !nonempty(key) || !keyName.test(key)))
130
+ throw new SecretStoreError("invalid_configuration");
131
+ return keys;
132
+ }
133
+
134
+ export type ManagedKmsOptions = Pick<KmsPostgresOptions, "company" | "keyName" | "decryptKeyNames"> & {
135
+ /** Fixed deployment broker; the callback reads protected bootstrap again on renewal. */
136
+ identity: { endpoint: string; credential(): Promise<string> };
137
+ };
138
+
139
+ /** Config composition for managed Cloud. Each runtime owns its lazily opened SDK client and database binding. */
140
+ export function managedKmsStore(options: ManagedKmsOptions): SecretStoreFactory {
141
+ const keys = checkedKeys(options);
142
+ const { company, keyName: writeKey } = options;
143
+ const identity = { ...options.identity };
144
+ if (typeof identity.credential !== "function") throw new SecretStoreError("invalid_configuration");
145
+ return Object.freeze({
146
+ kind: "runtime",
147
+ id: "kms-postgres",
148
+ capabilities,
149
+ create({ database }: { database: () => Promise<Db> }) {
150
+ const authClient = cloudIdentityAuth(identity);
151
+ let closed = false;
152
+ let pending: Promise<GcpKmsClient & { close(): Promise<void> }> | undefined;
153
+ const client = () => {
154
+ if (closed) throw new SecretStoreError("unavailable");
155
+ return (pending ??= import("@google-cloud/kms").then(
156
+ ({ KeyManagementServiceClient }) => new KeyManagementServiceClient({ authClient }),
157
+ ));
158
+ };
159
+ const kms: GcpKmsClient = {
160
+ encrypt: async (request) => (await client()).encrypt(request),
161
+ decrypt: async (request) => (await client()).decrypt(request),
162
+ };
163
+ return {
164
+ ...kmsPostgresStore({ company, keyName: writeKey, decryptKeyNames: [...keys], database, kms }),
165
+ async close() {
166
+ if (closed) return;
167
+ closed = true;
168
+ if (pending) await (await pending).close();
169
+ },
170
+ };
171
+ },
172
+ });
173
+ }
174
+
113
175
  /** Versioned envelope encryption; no plaintext/data-key cache, no SANOMA_SECRETS_KEY and no Secrets Manager. */
114
- export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore {
176
+ export function kmsPostgresStore(options: KmsPostgresOptions & { database: () => Promise<Db> }): SecretStore;
177
+ export function kmsPostgresStore(options: KmsPostgresOptions & { database?: undefined }): SecretStoreFactory;
178
+ export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore | SecretStoreFactory;
179
+ export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore | SecretStoreFactory {
115
180
  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)))
181
+ const keys = checkedKeys(options);
182
+ if (
183
+ typeof kms?.encrypt !== "function" ||
184
+ typeof kms?.decrypt !== "function" ||
185
+ (database !== undefined && typeof database !== "function")
186
+ )
118
187
  throw new SecretStoreError("invalid_configuration");
188
+ if (!database)
189
+ return Object.freeze({
190
+ kind: "runtime",
191
+ id: "kms-postgres",
192
+ capabilities,
193
+ create: ({ database: runtimeDatabase }: { database: () => Promise<Db> }) =>
194
+ kmsPostgresStore({
195
+ company,
196
+ keyName: writeKey,
197
+ decryptKeyNames: [...keys],
198
+ kms,
199
+ database: runtimeDatabase,
200
+ }),
201
+ });
119
202
  const context = (address: SecretAddress, version: string) => {
120
203
  if (!nonempty(address.namespace) || !nonempty(address.path)) throw new SecretStoreError("invalid_configuration");
121
204
  return { company, namespace: address.namespace, path: address.path, version, format: FORMAT };
@@ -209,13 +292,7 @@ export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore {
209
292
  };
210
293
  return {
211
294
  id: "kms-postgres",
212
- capabilities: Object.freeze({
213
- writable: true,
214
- maxBytes: MAX_BYTES,
215
- conditionalWrites: "atomic",
216
- deletion: "destroy",
217
- changeDetection: "read",
218
- }),
295
+ capabilities,
219
296
  validate: (namespace) =>
220
297
  safe(async () => {
221
298
  const address = { namespace, path: "__validation__" },
@@ -12,6 +12,12 @@ import {
12
12
  memoryIdentityProvisioning,
13
13
  type IdentityProvisioning,
14
14
  } from "./identity-provisioning.ts";
15
+ import {
16
+ prepareSecretStore,
17
+ memoryStorageProvisioning,
18
+ storageProvisioningCode,
19
+ type PrepareStorage,
20
+ } from "./storage-provisioning.ts";
15
21
 
16
22
  // What a workflow cannot do itself and the runtime does for it, as operations of the vendor
17
23
  // `sanoma` (`sanoma.secret.*`, `sanoma.invite.mint`, `sanoma.token.*`): named in `uses`, decided by
@@ -53,6 +59,24 @@ async function identityWrite(write: () => Promise<void>): Promise<void> {
53
59
  * `uses: [sanoma.secret.generate]` and `ctx.sanoma.secret.generate({ name, kind })`.
54
60
  */
55
61
  export const sanoma = defineConnector("sanoma", {
62
+ secretStore: {
63
+ prepare: {
64
+ effect: "access",
65
+ idempotent: true,
66
+ description:
67
+ "Prepare a company/environment KMS Postgres schema and restricted runtime login; store its database URL without returning credentials",
68
+ input: z.object({
69
+ company: z.string().min(1).max(256),
70
+ environment: z.string().min(1).max(256),
71
+ database: z.strictObject({ $secret: z.string().regex(SECRET_NAME) }),
72
+ store: SecretName,
73
+ }),
74
+ output: z.object({ company: z.string(), environment: z.string(), role: z.string(), store: z.string() }),
75
+ target: ({ company, environment }: { company: string; environment: string }) =>
76
+ JSON.stringify([company, environment]),
77
+ createsSecrets: ({ store }: { store: string }) => [store],
78
+ },
79
+ },
56
80
  identity: {
57
81
  enroll: {
58
82
  effect: "access",
@@ -211,13 +235,25 @@ export async function postInvite(host: string, request: string): Promise<{ url:
211
235
  export function runtimeDriver({
212
236
  tokens,
213
237
  identities,
238
+ storage = prepareSecretStore,
214
239
  invite = postInvite,
215
240
  }: {
216
241
  tokens: Tokens;
217
242
  identities?: IdentityProvisioning;
243
+ storage?: PrepareStorage;
218
244
  invite?: (host: string, request: string, call: CallContext) => Promise<{ url: string }>;
219
245
  }) {
220
246
  return defineDriver(sanoma, {
247
+ secretStore: {
248
+ async prepare(input, call) {
249
+ try {
250
+ return await storage(input, call.secrets);
251
+ } catch (error) {
252
+ const code = storageProvisioningCode(error) ?? "unavailable";
253
+ throw new DriverError(`Storage provisioning: ${code}`, { retryable: code === "unavailable" });
254
+ }
255
+ },
256
+ },
221
257
  identity: {
222
258
  async enroll({ store, ...input }, call) {
223
259
  if (!identities) throw refuse("identity provisioning is not configured");
@@ -309,5 +345,6 @@ export const sandboxRuntimeDriver = () =>
309
345
  runtimeDriver({
310
346
  tokens: memoryTokens(),
311
347
  identities: memoryIdentityProvisioning(),
348
+ storage: memoryStorageProvisioning(),
312
349
  invite: async (host, _request, call) => ({ url: new URL(`/auth/invite/sandbox-${tokenIdOf(call)}`, host).href }),
313
350
  });
@@ -1,4 +1,5 @@
1
1
  import { DEPLOYMENT_KEYS, type DeploymentKeyName, deploymentKey } from "./deployment-key.ts";
2
+ import type { Db } from "./db.ts";
2
3
  /** Opaque values belong only in trusted runtime code, never workflow history or descriptions. */
3
4
  export interface SecretValue {
4
5
  value: string;
@@ -62,6 +63,21 @@ export interface SecretStore {
62
63
  delete(address: SecretAddress, options: { expectedVersion: string; by: string }): Promise<void>;
63
64
  }
64
65
 
66
+ /** Bind a fresh adapter to each runtime's existing database; create must perform no I/O or allocate owned clients. */
67
+ export interface SecretStoreFactory {
68
+ readonly kind: "runtime";
69
+ readonly id: string;
70
+ readonly capabilities: Readonly<SecretStoreCapabilities>;
71
+ /** The runtime owns this connection. Never close it or retain it outside the returned adapter. */
72
+ create(context: { database: () => Promise<Db> }): SecretStore & {
73
+ /** Close only clients this adapter opens lazily; never the supplied database. */
74
+ close?(): Promise<void>;
75
+ };
76
+ }
77
+
78
+ export const isSecretStoreFactory = (store: SecretStore | SecretStoreFactory): store is SecretStoreFactory =>
79
+ "kind" in store && store.kind === "runtime";
80
+
65
81
  export type SecretBinding =
66
82
  | { path: string; management: "sanoma" | "external"; env?: never; bootstrap?: never }
67
83
  | { env: string; management?: "external"; path?: never; bootstrap?: never }
@@ -69,7 +85,7 @@ export type SecretBinding =
69
85
 
70
86
  export interface SecretConfiguration {
71
87
  /** Omitted: built-in encrypted Postgres, supplied by the runtime. */
72
- store?: SecretStore;
88
+ store?: SecretStore | SecretStoreFactory;
73
89
  /** Defaults to `default`. Choose a distinct namespace and identity for each deployment. */
74
90
  namespace?: string;
75
91
  /** Names without a binding resolve to the same name in this namespace. */
@@ -134,7 +150,9 @@ export function checkSecretConfiguration(config: SecretConfiguration): void {
134
150
  !["atomic", "coordinated", "none"].includes(c.conditionalWrites) ||
135
151
  !["destroy", "soft-delete", "unsupported"].includes(c.deletion) ||
136
152
  !["read", "watch"].includes(c.changeDetection) ||
137
- [s.validate, s.read, s.write, s.delete].some((fn) => typeof fn !== "function")
153
+ (isSecretStoreFactory(s)
154
+ ? typeof s.create !== "function"
155
+ : [s.validate, s.read, s.write, s.delete].some((fn) => typeof fn !== "function"))
138
156
  )
139
157
  invalid();
140
158
  }
@@ -173,15 +191,21 @@ export class SecretAccess {
173
191
  private readonly bindings: Readonly<Record<string, SecretBinding>>;
174
192
 
175
193
  private readonly env: (name: string) => string | undefined;
194
+ private closing?: Promise<void>;
195
+ private readonly onClose?: () => Promise<void>;
176
196
 
177
197
  constructor(
178
198
  config: SecretConfiguration,
179
199
  defaultStore: SecretStore,
180
200
  env: (name: string) => string | undefined = (name) => process.env[name],
201
+ onClose?: () => Promise<void>,
181
202
  ) {
182
203
  this.env = env;
204
+ this.onClose = onClose;
183
205
  checkSecretConfiguration(config);
184
- this.store = config.store ?? defaultStore;
206
+ const store = config.store ?? defaultStore;
207
+ if (isSecretStoreFactory(store)) throw new SecretStoreError("invalid_configuration");
208
+ this.store = store;
185
209
  checkSecretConfiguration({ store: this.store });
186
210
  this.namespace = config.namespace ?? "default";
187
211
  this.bindings = Object.fromEntries(
@@ -189,6 +213,13 @@ export class SecretAccess {
189
213
  );
190
214
  }
191
215
 
216
+ /** Only runtime-factory adapters are owned here; configured standalone stores remain caller-owned. */
217
+ close(): Promise<void> {
218
+ return (this.closing ??= secretStoreCall(async () => {
219
+ await this.onClose?.();
220
+ }));
221
+ }
222
+
192
223
  location(name: string): SecretLocation {
193
224
  if (!namePattern.test(name)) throw new SecretStoreError("invalid_configuration");
194
225
  const binding = Object.hasOwn(this.bindings, name) ? this.bindings[name] : undefined;
package/src/shared.ts CHANGED
@@ -613,6 +613,6 @@ export type OpFn<I, O> = (input: I) => Promise<O>;
613
613
  export interface CollectionApi<R> {
614
614
  get: OpFn<{ key: string }, Saved<R> | null>;
615
615
  list(query?: ListQuery<R>): Promise<Saved<R>[]>;
616
- save: OpFn<{ record: R }, Saved<R>>;
616
+ save: OpFn<{ record: R; ifAbsent?: boolean }, Saved<R>>;
617
617
  delete: OpFn<{ key: string }, { deleted: boolean }>;
618
618
  }