@sanoma-ai/workflows 0.9.1 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -50
- package/SECRET-SUPPORT.md +33 -0
- package/dist/apply-steps.js +5 -2
- package/dist/apply-steps.js.map +1 -1
- package/dist/apply.d.ts +3 -0
- package/dist/apply.js +27 -13
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +1 -0
- package/dist/approvals.js +6 -1
- package/dist/approvals.js.map +1 -1
- package/dist/apps.d.ts +13 -24
- package/dist/apps.js +69 -68
- package/dist/apps.js.map +1 -1
- package/dist/ast.d.ts +9 -7
- package/dist/ast.js +2 -0
- package/dist/ast.js.map +1 -1
- package/dist/authentication.d.ts +54 -0
- package/dist/authentication.js +138 -0
- package/dist/authentication.js.map +1 -0
- package/dist/call-workflow.d.ts +5 -0
- package/dist/call-workflow.js +4 -3
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +36 -7
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +14 -1
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.js +7 -5
- package/dist/change-steps.js.map +1 -1
- package/dist/change.d.ts +7 -0
- package/dist/change.js +7 -0
- package/dist/change.js.map +1 -1
- package/dist/client.d.ts +62 -10
- package/dist/client.js +272 -47
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +7 -0
- package/dist/collection.js +5 -2
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +16 -4
- package/dist/config.js +89 -21
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +389 -0
- package/dist/connection.js +657 -0
- package/dist/connection.js.map +1 -0
- package/dist/credentials.d.ts +7 -53
- package/dist/credentials.js +84 -247
- package/dist/credentials.js.map +1 -1
- package/dist/db.js +34 -2
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +9 -2
- package/dist/define.js.map +1 -1
- package/dist/definitions.d.ts +20 -0
- package/dist/definitions.js +16 -0
- package/dist/definitions.js.map +1 -0
- package/dist/deployment-key.d.ts +2 -0
- package/dist/deployment-key.js +22 -0
- package/dist/deployment-key.js.map +1 -0
- package/dist/describe.d.ts +15 -175
- package/dist/describe.js +122 -35
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +532 -0
- package/dist/description.js +381 -0
- package/dist/description.js.map +1 -0
- package/dist/direct.js +7 -0
- package/dist/direct.js.map +1 -1
- package/dist/drift.d.ts +1 -1
- package/dist/drift.js +1 -1
- package/dist/drift.js.map +1 -1
- package/dist/env.d.ts +2 -0
- package/dist/env.js +2 -2
- package/dist/env.js.map +1 -1
- package/dist/fake.d.ts +13 -1
- package/dist/fake.js +70 -0
- package/dist/fake.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +2 -0
- package/dist/ledger.js +1 -1
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +0 -9
- package/dist/lint.js +2 -17
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +85 -0
- package/dist/load.js +391 -0
- package/dist/load.js.map +1 -0
- package/dist/lockfile.d.ts +2 -0
- package/dist/lockfile.js +8 -3
- package/dist/lockfile.js.map +1 -1
- package/dist/op.d.ts +22 -5
- package/dist/op.js +20 -2
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +5 -0
- package/dist/outline.js +46 -5
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +18 -2
- package/dist/paths.js +19 -7
- package/dist/paths.js.map +1 -1
- package/dist/plugin.d.ts +0 -11
- package/dist/plugin.js +3 -18
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +10 -4
- package/dist/resource.js +11 -4
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +33 -76
- package/dist/resources.js +219 -227
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +4 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +5 -2
- package/dist/sandbox.js +56 -7
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +5 -0
- package/dist/scenario.js +3 -2
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.js +7 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.js +8 -0
- package/dist/schema.js.map +1 -1
- package/dist/secret-store-check.d.ts +11 -0
- package/dist/secret-store-check.js +76 -0
- package/dist/secret-store-check.js.map +1 -0
- package/dist/secret-store.d.ts +106 -0
- package/dist/secret-store.js +228 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/secret.d.ts +2 -0
- package/dist/secret.js +32 -0
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +21 -11
- package/dist/shared.js +41 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +4 -93
- package/dist/sites.js +76 -84
- package/dist/sites.js.map +1 -1
- package/dist/steps.d.ts +3 -0
- package/dist/steps.js.map +1 -1
- package/dist/version.d.ts +2 -1
- package/dist/version.js +2 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.js +15 -6
- package/dist/wakeup-driver.js.map +1 -1
- package/dist/worker.js +12 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +53 -14
- package/dist/writer.js +113 -43
- package/dist/writer.js.map +1 -1
- package/oxlint.json +1 -5
- package/package.json +6 -1
- package/src/apply-steps.ts +5 -2
- package/src/apply.ts +54 -21
- package/src/approvals.ts +7 -1
- package/src/apps.ts +71 -86
- package/src/ast.ts +6 -6
- package/src/authentication.ts +191 -0
- package/src/call-workflow.ts +5 -4
- package/src/call.ts +52 -9
- package/src/cedar.ts +14 -1
- package/src/change-steps.ts +7 -4
- package/src/change.ts +10 -0
- package/src/client.ts +346 -45
- package/src/collection.ts +10 -2
- package/src/config.ts +107 -19
- package/src/connection.ts +912 -0
- package/src/credentials.ts +94 -281
- package/src/db.ts +34 -2
- package/src/define.ts +7 -2
- package/src/definitions.ts +31 -0
- package/src/deployment-key.ts +23 -0
- package/src/describe.ts +150 -227
- package/src/description.ts +426 -0
- package/src/direct.ts +7 -0
- package/src/drift.ts +10 -2
- package/src/env.ts +4 -2
- package/src/fake.ts +71 -0
- package/src/index.ts +53 -1
- package/src/ledger.ts +3 -1
- package/src/lint.ts +2 -17
- package/src/load.ts +466 -0
- package/src/lockfile.ts +10 -3
- package/src/op.ts +41 -7
- package/src/outline.ts +53 -7
- package/src/paths.ts +21 -6
- package/src/plugin.ts +3 -24
- package/src/policy.ts +2 -0
- package/src/resource.ts +24 -6
- package/src/resources.ts +246 -301
- package/src/run.ts +4 -0
- package/src/sandbox.ts +61 -7
- package/src/scenario.ts +24 -6
- package/src/scenarios.ts +7 -1
- package/src/schema.ts +10 -0
- package/src/secret-store-check.ts +93 -0
- package/src/secret-store.ts +314 -0
- package/src/secret.ts +29 -0
- package/src/shared.ts +44 -10
- package/src/sites.ts +85 -179
- package/src/steps.ts +3 -2
- package/src/version.ts +2 -1
- package/src/wakeup-driver.ts +22 -6
- package/src/worker.ts +12 -8
- package/src/writer.ts +145 -56
- package/dist/datafile.d.ts +0 -97
- package/dist/datafile.js +0 -479
- package/dist/datafile.js.map +0 -1
- package/src/datafile.ts +0 -601
package/src/change.ts
CHANGED
|
@@ -320,6 +320,16 @@ export const ADMIN = "admin";
|
|
|
320
320
|
*/
|
|
321
321
|
export const isRules = (path: string) =>
|
|
322
322
|
path === "policies/" || path === "resources/people.ts" || /^apps\/[^/]+\/policies\/$/.test(path);
|
|
323
|
+
/**
|
|
324
|
+
* Whether a data file's text imports the runtime's constructors, `sanoma` (by any local name, or
|
|
325
|
+
* the whole entry, `* as`, from which it can reach them): a file that may declare a person, agent
|
|
326
|
+
* or group, which `readPeople` loads and a change to which is a change to the rules. Read from the
|
|
327
|
+
* text, so a draft's file is told without running it.
|
|
328
|
+
*/
|
|
329
|
+
export const mayDeclarePeople = (text: string): boolean =>
|
|
330
|
+
/(?:\{[^}]*\bsanoma\b[^}]*\}|\*(?:\s*as\s+[\w$]+)?)\s*from\s*["'](?:sanoma|@sanoma-ai\/workflows)\/resources["']/.test(
|
|
331
|
+
text,
|
|
332
|
+
);
|
|
323
333
|
/** The runtime rule's id, as a hold's `reasons` name it. */
|
|
324
334
|
const RULES_WAIT_FOR_ADMIN = "sanoma:rules-wait-for-admin";
|
|
325
335
|
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
connectionDriver,
|
|
3
|
+
connectionTargetSchema,
|
|
4
|
+
type ConnectionRef,
|
|
5
|
+
type ConnectionSnapshot,
|
|
6
|
+
type ConnectionTarget,
|
|
7
|
+
type ConnectionSetup,
|
|
8
|
+
type ConnectionCard,
|
|
9
|
+
type ConnectionRule,
|
|
10
|
+
} from "./connection.ts";
|
|
11
|
+
import { encodeCredentials, decodeCredentials, verifyAuthentication, AuthenticationError } from "./authentication.ts";
|
|
12
|
+
|
|
13
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
14
|
import { isDeepStrictEqual } from "node:util";
|
|
3
15
|
import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
|
|
4
16
|
import type { Pool } from "pg";
|
|
@@ -16,7 +28,7 @@ import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call
|
|
|
16
28
|
import { type Chats, chatStore } from "./chats.ts";
|
|
17
29
|
import { collectionsDriver } from "./collection.ts";
|
|
18
30
|
import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
|
|
19
|
-
import {
|
|
31
|
+
import { configuredSecrets } from "./credentials.ts";
|
|
20
32
|
import { dbosClient, lazyPool, type LazyPool } from "./db.ts";
|
|
21
33
|
import { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
22
34
|
import { type ApprovalState, Principal, type WorkflowDefinition } from "./define.ts";
|
|
@@ -52,7 +64,7 @@ import type { CallContext, Driver, Op } from "./op.ts";
|
|
|
52
64
|
import { allowAll, type RecordedDecision } from "./policy.ts";
|
|
53
65
|
import { admit, readPeople } from "./resources.ts";
|
|
54
66
|
import type { RunArgs, StartHold } from "./run.ts";
|
|
55
|
-
import type
|
|
67
|
+
import { SecretStoreError, type SecretAccess, type SecretLocation } from "./secret-store.ts";
|
|
56
68
|
import {
|
|
57
69
|
BUILT_IN_WORKFLOWS,
|
|
58
70
|
CALL_WORKFLOW,
|
|
@@ -206,6 +218,7 @@ export interface ChangesFilter {
|
|
|
206
218
|
|
|
207
219
|
/** What `SanomaClient.call` takes besides the operation's id and its input. */
|
|
208
220
|
export interface CallOptions {
|
|
221
|
+
connection?: ConnectionRef;
|
|
209
222
|
/**
|
|
210
223
|
* Who calls, by id, admitted as `startedBy` is: the policy decides as them, a write's record
|
|
211
224
|
* names them, and a save's `updatedBy` is their id.
|
|
@@ -218,6 +231,15 @@ export interface CallOptions {
|
|
|
218
231
|
id?: string;
|
|
219
232
|
}
|
|
220
233
|
|
|
234
|
+
/** A credential-free review shown before the user saves a connection. */
|
|
235
|
+
export interface ConnectionReview extends ConnectionSetup {
|
|
236
|
+
authorization: string;
|
|
237
|
+
requestId: string;
|
|
238
|
+
account: string;
|
|
239
|
+
scopes: string[];
|
|
240
|
+
credentialVersion: string | null;
|
|
241
|
+
expiresAt?: number;
|
|
242
|
+
}
|
|
221
243
|
/** What `SanomaClient.connect` is given besides the config. */
|
|
222
244
|
export interface ClientOptions {
|
|
223
245
|
/**
|
|
@@ -243,7 +265,7 @@ export class SanomaClient {
|
|
|
243
265
|
/** On the config's database, for the runtime's tables (the credentials, the chats): opened on first use. */
|
|
244
266
|
private readonly pool: LazyPool;
|
|
245
267
|
/** What a direct call's driver reads secrets from, as a worker's live driver does. */
|
|
246
|
-
private readonly storedSecrets:
|
|
268
|
+
private readonly storedSecrets: SecretAccess;
|
|
247
269
|
/** The operator agent's chats: their rows and their transcripts, in the config's database. */
|
|
248
270
|
readonly chats: Chats;
|
|
249
271
|
private readonly options: ClientOptions;
|
|
@@ -260,7 +282,7 @@ export class SanomaClient {
|
|
|
260
282
|
this.options = options;
|
|
261
283
|
this.audit = ownedLedger(config);
|
|
262
284
|
this.pool = lazyPool(config.databaseUrl, "client");
|
|
263
|
-
this.storedSecrets =
|
|
285
|
+
this.storedSecrets = configuredSecrets(config.secrets, () => this.db());
|
|
264
286
|
this.chats = chatStore(() => this.db());
|
|
265
287
|
}
|
|
266
288
|
|
|
@@ -272,7 +294,14 @@ export class SanomaClient {
|
|
|
272
294
|
static async connect(config: SanomaConfig, options: ClientOptions = {}): Promise<SanomaClient> {
|
|
273
295
|
const resolved = resolveConfig(config);
|
|
274
296
|
const dbos = await dbosClient(resolved.databaseUrl, resolved.appName);
|
|
275
|
-
|
|
297
|
+
const client = new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
|
|
298
|
+
try {
|
|
299
|
+
await client.storedSecrets.validate();
|
|
300
|
+
return client;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
await client.close();
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
276
305
|
}
|
|
277
306
|
|
|
278
307
|
/**
|
|
@@ -414,14 +443,21 @@ export class SanomaClient {
|
|
|
414
443
|
const fn = drivers.get(op.id);
|
|
415
444
|
if (!fn) throw new SanomaError("invalid_input", `${op.id} has no driver: the connector package must attach one`);
|
|
416
445
|
const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
|
|
446
|
+
const connection = await this.config.connections.select(op.vendor, options.connection, actor.id);
|
|
447
|
+
const selected = connection && {
|
|
448
|
+
$connection: connection.id,
|
|
449
|
+
vendor: connection.vendor,
|
|
450
|
+
revision: connection.revision,
|
|
451
|
+
};
|
|
417
452
|
const runId = `direct:${actor.id}:${id}`;
|
|
418
453
|
const heldId = `call:${actor.id}:${id}`;
|
|
419
|
-
const held: RunArgs = { input: { op: op.id, input }, startedBy: actor };
|
|
454
|
+
const held: RunArgs = { input: { op: op.id, input, ...(selected && { connection: selected }) }, startedBy: actor };
|
|
420
455
|
const logged = recorded(op, parsed);
|
|
421
456
|
const write = op.effect !== "read";
|
|
422
457
|
// Only an id the caller gave can have been used: a write made, or held, with it settles it.
|
|
423
458
|
if (write && options.id !== undefined) {
|
|
424
|
-
const first =
|
|
459
|
+
const first =
|
|
460
|
+
(await this.recordedCall(runId, op.id, logged, connection)) ?? (await this.heldCall(op.id, heldId, held));
|
|
425
461
|
if (first) return first.output;
|
|
426
462
|
}
|
|
427
463
|
const before = await beforeOf(policy, op, parsed, this.config.collections, (get, key) =>
|
|
@@ -431,7 +467,7 @@ export class SanomaClient {
|
|
|
431
467
|
),
|
|
432
468
|
);
|
|
433
469
|
const run = { id: runId, workflow: CALL_WORKFLOW, approvals: [] };
|
|
434
|
-
const decision = decideCall(policy, op, parsed, actor, run, before);
|
|
470
|
+
const decision = decideCall(policy, op, parsed, actor, run, { ...before, ...(connection && { connection }) });
|
|
435
471
|
if (decision.kind === "deny") throw new PolicyDeniedError(op.id, decision.reason);
|
|
436
472
|
if (decision.kind === "approve") {
|
|
437
473
|
await this.start(CALL_WORKFLOW, held.input, { startedBy: actor, runId: heldId });
|
|
@@ -445,7 +481,10 @@ export class SanomaClient {
|
|
|
445
481
|
attempt: 1,
|
|
446
482
|
secrets: this.storedSecrets,
|
|
447
483
|
by: actor.id,
|
|
484
|
+
principal: actor,
|
|
485
|
+
...(connection && { connection }),
|
|
448
486
|
};
|
|
487
|
+
await this.config.connections.assertCurrent(connection, actor.id);
|
|
449
488
|
if (!write) return outputOf(op, () => fn(parsed, context));
|
|
450
489
|
return this.directWrite({ op, fn, parsed, logged, actor, decision, context });
|
|
451
490
|
}
|
|
@@ -469,13 +508,22 @@ export class SanomaClient {
|
|
|
469
508
|
const { op, parsed, logged, actor, decision, context } = call;
|
|
470
509
|
const { runId } = context;
|
|
471
510
|
const { dataStore, collections, ledger } = this.config;
|
|
472
|
-
const
|
|
511
|
+
const connectionMutation = op.id.startsWith("sanoma.connection.");
|
|
512
|
+
const transactionStore = connectionMutation
|
|
513
|
+
? this.config.connectionPool
|
|
514
|
+
: op.vendor === DATA_VENDOR
|
|
515
|
+
? dataStore
|
|
516
|
+
: undefined;
|
|
517
|
+
const conn = !ledger && transactionStore ? await (await transactionStore.db()).connect() : undefined;
|
|
473
518
|
let broken: Error | undefined;
|
|
474
519
|
try {
|
|
475
520
|
await conn?.query("BEGIN");
|
|
476
|
-
//
|
|
521
|
+
// The metadata or collection driver shares the ledger transaction; neither has an env guard.
|
|
477
522
|
const fn = conn
|
|
478
|
-
?
|
|
523
|
+
? (connectionMutation
|
|
524
|
+
? connectionDriver(this.config.connections.onDatabase(conn))
|
|
525
|
+
: collectionsDriver([collections.get(op.resource)!], { db: async () => conn })
|
|
526
|
+
).ops[`${op.resource}.${op.name}`]!
|
|
479
527
|
: call.fn;
|
|
480
528
|
const at = Date.now();
|
|
481
529
|
const record = (outcome: { output: unknown } | { error: ErrorInfo }): LedgerRecord => ({
|
|
@@ -492,6 +540,7 @@ export class SanomaClient {
|
|
|
492
540
|
effect: op.effect,
|
|
493
541
|
input: logged,
|
|
494
542
|
decision,
|
|
543
|
+
...(context.connection && { connection: context.connection }),
|
|
495
544
|
...outcome,
|
|
496
545
|
durationMs: Date.now() - at,
|
|
497
546
|
});
|
|
@@ -513,7 +562,7 @@ export class SanomaClient {
|
|
|
513
562
|
return output;
|
|
514
563
|
}
|
|
515
564
|
await conn.query("ROLLBACK");
|
|
516
|
-
return (await this.recordedCall(runId, op.id, logged))!.output;
|
|
565
|
+
return (await this.recordedCall(runId, op.id, logged, context.connection))!.output;
|
|
517
566
|
} catch (err) {
|
|
518
567
|
await conn?.query("ROLLBACK").catch((e: Error) => (broken = e));
|
|
519
568
|
throw err;
|
|
@@ -526,10 +575,19 @@ export class SanomaClient {
|
|
|
526
575
|
* What the direct write recorded as `runId` returned, when one did: refused (`invalid_input`)
|
|
527
576
|
* when it was of another operation or input, and its failure again (`driver_failed`) when it failed.
|
|
528
577
|
*/
|
|
529
|
-
private async recordedCall(
|
|
578
|
+
private async recordedCall(
|
|
579
|
+
runId: string,
|
|
580
|
+
opId: string,
|
|
581
|
+
input: unknown,
|
|
582
|
+
connection?: ConnectionSnapshot,
|
|
583
|
+
): Promise<{ output: unknown } | undefined> {
|
|
530
584
|
const first = (await this.audit.store.read(runId)).find((r) => r.type === "op.called");
|
|
531
585
|
if (!first) return undefined;
|
|
532
|
-
const differs = [
|
|
586
|
+
const differs = [
|
|
587
|
+
...(first.op === opId ? [] : ["op"]),
|
|
588
|
+
...(sameJson(first.input, input) ? [] : ["input"]),
|
|
589
|
+
...(sameJson(first.connection ?? null, connection ?? null) ? [] : ["connection"]),
|
|
590
|
+
];
|
|
533
591
|
if (differs.length) {
|
|
534
592
|
throw new SanomaError(
|
|
535
593
|
"invalid_input",
|
|
@@ -1061,7 +1119,8 @@ export class SanomaClient {
|
|
|
1061
1119
|
issues: [{ path: ["value"], message: problem, code: "custom" }],
|
|
1062
1120
|
});
|
|
1063
1121
|
}
|
|
1064
|
-
|
|
1122
|
+
const current = await this.storedSecrets.read(name);
|
|
1123
|
+
await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
|
|
1065
1124
|
}
|
|
1066
1125
|
|
|
1067
1126
|
/**
|
|
@@ -1072,7 +1131,8 @@ export class SanomaClient {
|
|
|
1072
1131
|
async clearCredential(name: string, options: { by: string }): Promise<void> {
|
|
1073
1132
|
await this.admittedBy(options);
|
|
1074
1133
|
this.declared(name, () => undefined);
|
|
1075
|
-
|
|
1134
|
+
const current = await this.storedSecrets.read(name);
|
|
1135
|
+
if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by: options.by });
|
|
1076
1136
|
}
|
|
1077
1137
|
|
|
1078
1138
|
/**
|
|
@@ -1091,49 +1151,288 @@ export class SanomaClient {
|
|
|
1091
1151
|
const path = VARIABLE.test(name) ? "value" : "name";
|
|
1092
1152
|
throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
|
|
1093
1153
|
}
|
|
1094
|
-
|
|
1154
|
+
const current = await this.storedSecrets.read(name);
|
|
1155
|
+
await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Removes a logical secret from the selected store; external bindings remain read-only. */
|
|
1159
|
+
async clearSecret(name: string, options: { by: string }): Promise<void> {
|
|
1160
|
+
const by = await this.admittedBy(options);
|
|
1161
|
+
if (!VARIABLE.test(name)) {
|
|
1162
|
+
const message = "That is not a variable name";
|
|
1163
|
+
throw new SanomaError("invalid_input", message, {
|
|
1164
|
+
issues: [{ path: ["name"], message, code: "custom" }],
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
const current = await this.storedSecrets.read(name);
|
|
1168
|
+
if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by });
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
private connectionAuthorization(): string {
|
|
1172
|
+
return createHash("sha256")
|
|
1173
|
+
.update(
|
|
1174
|
+
JSON.stringify({
|
|
1175
|
+
version: this.config.version,
|
|
1176
|
+
policies: this.config.policies,
|
|
1177
|
+
problems: this.config.policyProblems,
|
|
1178
|
+
}),
|
|
1179
|
+
)
|
|
1180
|
+
.digest("hex");
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** Safe account cards, scoped to the admitted principal. No provider or secret reads. */
|
|
1184
|
+
async connections(options: { actor: Principal | string }): Promise<ConnectionCard[]> {
|
|
1185
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
1186
|
+
return this.config.connections.cards(actor.id);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** Creation choices are scoped by code to the admitted person's current groups. */
|
|
1190
|
+
async connectionRules(options: { actor: Principal | string }): Promise<{ id: string; rule: ConnectionRule }[]> {
|
|
1191
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
1192
|
+
return [...this.config.connections.catalog.rules]
|
|
1193
|
+
.filter(([, rule]) =>
|
|
1194
|
+
rule.createBy.some((by) => (typeof by === "string" ? by === actor.id : actor.groups?.includes(by.group))),
|
|
1195
|
+
)
|
|
1196
|
+
.map(([id, rule]) => ({ id, rule }));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** Readiness only, without reading or listing any credential. The adapter's bootstrap
|
|
1200
|
+
* authority is checked by validate; provider details never escape this boundary. */
|
|
1201
|
+
async secretStoreStatus(options: { actor: Principal | string }) {
|
|
1202
|
+
await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
1203
|
+
const { store, namespace } = this.storedSecrets;
|
|
1204
|
+
let problem: string | undefined;
|
|
1205
|
+
try {
|
|
1206
|
+
await this.storedSecrets.validate();
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
problem = (error as SecretStoreError).code;
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
store: store.id,
|
|
1212
|
+
namespace,
|
|
1213
|
+
capabilities: { ...store.capabilities },
|
|
1214
|
+
ready: !problem,
|
|
1215
|
+
...(problem && { problem }),
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Secure intake endpoint, never a workflow operation. Only its metadata-only prepare gate
|
|
1220
|
+
* enters policy/approval history; callers keep the value in a password form while reviewing. */
|
|
1221
|
+
async previewConnection(
|
|
1222
|
+
target: ConnectionTarget,
|
|
1223
|
+
credentials: unknown,
|
|
1224
|
+
options: { actor: Principal | string; id: string },
|
|
1225
|
+
): Promise<ConnectionReview> {
|
|
1226
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
1227
|
+
const id = parseOrThrow(z.string().min(1).max(200), options.id, "Name the setup request");
|
|
1228
|
+
target = parseOrThrow(connectionTargetSchema, target, "Select a declared connection or creation rule");
|
|
1229
|
+
const setup = await this.config.connections.setup(target, actor, id);
|
|
1230
|
+
const authorized = (await this.call(
|
|
1231
|
+
"sanoma.connection.prepare",
|
|
1232
|
+
{
|
|
1233
|
+
target,
|
|
1234
|
+
requestId: id,
|
|
1235
|
+
definition: setup.definition,
|
|
1236
|
+
authorization: this.connectionAuthorization(),
|
|
1237
|
+
},
|
|
1238
|
+
{ actor, id: `connection-prepare:${id}` },
|
|
1239
|
+
)) as ConnectionSetup;
|
|
1240
|
+
if (!isDeepStrictEqual(authorized, setup))
|
|
1241
|
+
throw new SanomaError("invalid_input", "The connection changed; start a new review");
|
|
1242
|
+
const method = this.config.connections.catalog.methods.get(setup.vendor)![setup.method]!;
|
|
1243
|
+
const external =
|
|
1244
|
+
method.kind !== "workload-identity" &&
|
|
1245
|
+
(setup.location?.management === "external" || !this.storedSecrets.store.capabilities.writable);
|
|
1246
|
+
if (
|
|
1247
|
+
!external &&
|
|
1248
|
+
method.kind !== "workload-identity" &&
|
|
1249
|
+
this.storedSecrets.store.capabilities.conditionalWrites === "none"
|
|
1250
|
+
)
|
|
1251
|
+
throw new SecretStoreError("unsupported");
|
|
1252
|
+
if (credentials !== undefined && (external || method.kind === "workload-identity"))
|
|
1253
|
+
throw new SecretStoreError("read_only");
|
|
1254
|
+
const existing = setup.credential ? await this.storedSecrets.read(setup.credential) : undefined;
|
|
1255
|
+
if (external && !existing) throw new SecretStoreError("unavailable");
|
|
1256
|
+
const encoded =
|
|
1257
|
+
method.kind === "workload-identity"
|
|
1258
|
+
? undefined
|
|
1259
|
+
: external
|
|
1260
|
+
? existing!.value
|
|
1261
|
+
: encodeCredentials(method, credentials);
|
|
1262
|
+
const identity = await verifyAuthentication(
|
|
1263
|
+
method,
|
|
1264
|
+
encoded === undefined ? undefined : decodeCredentials(method, encoded),
|
|
1265
|
+
);
|
|
1266
|
+
if (setup.expectedAccount && setup.expectedAccount !== identity.account)
|
|
1267
|
+
throw new AuthenticationError("wrong_account");
|
|
1268
|
+
if (setup.requiredScopes.some((scope) => !identity.scopes.includes(scope)))
|
|
1269
|
+
throw new AuthenticationError("scope_changed");
|
|
1270
|
+
return {
|
|
1271
|
+
...setup,
|
|
1272
|
+
requestId: id,
|
|
1273
|
+
authorization: this.connectionAuthorization(),
|
|
1274
|
+
account: identity.account,
|
|
1275
|
+
scopes: [...identity.scopes],
|
|
1276
|
+
credentialVersion: existing?.version ?? null,
|
|
1277
|
+
...(identity.expiresAt !== undefined && { expiresAt: identity.expiresAt }),
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/** Reverify the reviewed authority, reserve its metadata under policy, then write the bundle
|
|
1282
|
+
* outside durable execution. Activation has its own policy gate and verifies what was saved. */
|
|
1283
|
+
async connectConnection(
|
|
1284
|
+
review: ConnectionReview,
|
|
1285
|
+
credentials: unknown,
|
|
1286
|
+
options: { actor: Principal | string },
|
|
1287
|
+
): Promise<ConnectionSnapshot> {
|
|
1288
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
1289
|
+
if (review.authorization !== this.connectionAuthorization())
|
|
1290
|
+
throw new SanomaError("invalid_input", "The policy changed; start a new connection review");
|
|
1291
|
+
const setup = await this.config.connections.setup(review.target, actor, review.requestId);
|
|
1292
|
+
for (const field of ["id", "vendor", "method", "definition", "credential", "location"] as const) {
|
|
1293
|
+
if (!isDeepStrictEqual(setup[field], review[field]))
|
|
1294
|
+
throw new SanomaError("invalid_input", "The destination changed; review it again before saving");
|
|
1295
|
+
}
|
|
1296
|
+
// Reuse only the metadata gate, never a cached credential or verification result.
|
|
1297
|
+
await this.call(
|
|
1298
|
+
"sanoma.connection.prepare",
|
|
1299
|
+
{
|
|
1300
|
+
target: review.target,
|
|
1301
|
+
requestId: review.requestId,
|
|
1302
|
+
definition: setup.definition,
|
|
1303
|
+
authorization: review.authorization,
|
|
1304
|
+
},
|
|
1305
|
+
{ actor, id: `connection-prepare:${review.requestId}` },
|
|
1306
|
+
);
|
|
1307
|
+
const method = this.config.connections.catalog.methods.get(setup.vendor)![setup.method]!;
|
|
1308
|
+
const external =
|
|
1309
|
+
method.kind !== "workload-identity" &&
|
|
1310
|
+
(setup.location?.management === "external" || !this.storedSecrets.store.capabilities.writable);
|
|
1311
|
+
if (
|
|
1312
|
+
!external &&
|
|
1313
|
+
method.kind !== "workload-identity" &&
|
|
1314
|
+
this.storedSecrets.store.capabilities.conditionalWrites === "none"
|
|
1315
|
+
)
|
|
1316
|
+
throw new SecretStoreError("unsupported");
|
|
1317
|
+
if (credentials !== undefined && (external || method.kind === "workload-identity"))
|
|
1318
|
+
throw new SecretStoreError("read_only");
|
|
1319
|
+
const existing = setup.credential ? await this.storedSecrets.read(setup.credential) : undefined;
|
|
1320
|
+
if (external && (!existing || existing.version !== review.credentialVersion))
|
|
1321
|
+
throw new SecretStoreError("conflict");
|
|
1322
|
+
if (setup.expectedRevision === review.expectedRevision && (existing?.version ?? null) !== review.credentialVersion)
|
|
1323
|
+
throw new SecretStoreError("conflict");
|
|
1324
|
+
const encoded =
|
|
1325
|
+
method.kind === "workload-identity"
|
|
1326
|
+
? undefined
|
|
1327
|
+
: external
|
|
1328
|
+
? existing!.value
|
|
1329
|
+
: encodeCredentials(method, credentials);
|
|
1330
|
+
const identity = await verifyAuthentication(
|
|
1331
|
+
method,
|
|
1332
|
+
encoded === undefined ? undefined : decodeCredentials(method, encoded),
|
|
1333
|
+
);
|
|
1334
|
+
if (identity.account !== review.account) throw new AuthenticationError("wrong_account");
|
|
1335
|
+
if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(review.scopes)].toSorted()))
|
|
1336
|
+
throw new AuthenticationError("scope_changed");
|
|
1337
|
+
const selected = (await this.call(
|
|
1338
|
+
"sanoma.connection.configure",
|
|
1339
|
+
{
|
|
1340
|
+
target: review.target,
|
|
1341
|
+
requestId: review.requestId,
|
|
1342
|
+
expectedRevision: review.expectedRevision,
|
|
1343
|
+
definition: review.definition,
|
|
1344
|
+
account: identity.account,
|
|
1345
|
+
scopes: [...identity.scopes],
|
|
1346
|
+
},
|
|
1347
|
+
{ actor, id: `connection-configure:${review.requestId}` },
|
|
1348
|
+
)) as ConnectionSnapshot;
|
|
1349
|
+
const ref = { vendor: selected.vendor, $connection: selected.id };
|
|
1350
|
+
if (
|
|
1351
|
+
(await this.config.connections.setupState(selected, actor)) === "pending" &&
|
|
1352
|
+
!external &&
|
|
1353
|
+
encoded !== undefined
|
|
1354
|
+
) {
|
|
1355
|
+
let written = false;
|
|
1356
|
+
try {
|
|
1357
|
+
const receipt = await this.config.connections.storedVersion(selected.id, selected.revision);
|
|
1358
|
+
if (receipt) {
|
|
1359
|
+
if (existing?.version !== receipt || existing.value !== encoded) throw new SecretStoreError("conflict");
|
|
1360
|
+
} else {
|
|
1361
|
+
const stored = await this.storedSecrets.write(setup.credential!, encoded, {
|
|
1362
|
+
expectedVersion: review.credentialVersion,
|
|
1363
|
+
by: actor.id,
|
|
1364
|
+
});
|
|
1365
|
+
written = true;
|
|
1366
|
+
await this.config.connections.recordStoredVersion(selected.id, selected.revision, stored.version);
|
|
1367
|
+
}
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
if (written || (error as { code?: string })?.code === "unavailable")
|
|
1370
|
+
await this.config.connections.uncertain(ref, selected.revision, actor.id);
|
|
1371
|
+
throw error;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
const connected = (await this.call(
|
|
1375
|
+
"sanoma.connection.activate",
|
|
1376
|
+
{
|
|
1377
|
+
connection: ref,
|
|
1378
|
+
expectedRevision: selected.revision,
|
|
1379
|
+
},
|
|
1380
|
+
{ actor, id: `connection-activate:${review.requestId}` },
|
|
1381
|
+
)) as ConnectionSnapshot;
|
|
1382
|
+
await this.config.connections.assertCurrent(connected, actor.id);
|
|
1383
|
+
return connected;
|
|
1095
1384
|
}
|
|
1096
1385
|
|
|
1097
1386
|
/**
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
* is left out. For server code: a page is told whether one is set, never its value.
|
|
1387
|
+
* Values resolved through the selected store and explicit bindings, with version and change
|
|
1388
|
+
* metadata. Missing names are left out. Server code only; pages receive status, never values.
|
|
1101
1389
|
*/
|
|
1102
1390
|
async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
|
|
1103
|
-
const rows = await storedCredentials(await this.db(), names);
|
|
1104
1391
|
const found = new Map<string, StoredSecret>();
|
|
1105
1392
|
for (const name of names) {
|
|
1106
|
-
const
|
|
1107
|
-
const
|
|
1108
|
-
if (
|
|
1109
|
-
|
|
1393
|
+
const row = await this.storedSecrets.read(name);
|
|
1394
|
+
const location = this.storedSecrets.location(name);
|
|
1395
|
+
if (row)
|
|
1396
|
+
found.set(name, {
|
|
1397
|
+
value: row.value,
|
|
1398
|
+
source: location.store === "environment" ? "environment" : "stored",
|
|
1399
|
+
version: row.version,
|
|
1400
|
+
location,
|
|
1401
|
+
...(row.updatedBy && { setBy: row.updatedBy }),
|
|
1402
|
+
...(row.updatedAt && { setAt: row.updatedAt }),
|
|
1403
|
+
});
|
|
1110
1404
|
}
|
|
1111
1405
|
return found;
|
|
1112
1406
|
}
|
|
1113
1407
|
|
|
1114
|
-
/**
|
|
1115
|
-
* Each vendor's declared variables and their statuses now: from this process's environment,
|
|
1116
|
-
* which wins, else from the stored credentials, with `source` saying which (a value equal to
|
|
1117
|
-
* the stored one, as a worker in this process loads it, is `stored`), and who set a stored one
|
|
1118
|
-
* and when. Never a value.
|
|
1119
|
-
*/
|
|
1408
|
+
/** Non-secret statuses for the selected store and declared bindings. */
|
|
1120
1409
|
async credentials(): Promise<Map<string, CredentialStatus[]>> {
|
|
1121
1410
|
const names = [...credentialsOf(this.drivers, () => undefined).values()].flat().map((c) => c.name);
|
|
1122
|
-
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1411
|
+
const rows = await this.secrets(names);
|
|
1412
|
+
const statuses = credentialsOf(this.drivers, (name) => rows.get(name)?.value);
|
|
1413
|
+
return new Map(
|
|
1414
|
+
[...statuses].map(([vendor, list]) => [
|
|
1415
|
+
vendor,
|
|
1416
|
+
list.map((c) => {
|
|
1417
|
+
const row = rows.get(c.name);
|
|
1418
|
+
return {
|
|
1419
|
+
...c,
|
|
1420
|
+
location: this.storedSecrets.location(c.name),
|
|
1421
|
+
...(row && { source: row.source, setBy: row.setBy, setAt: row.setAt }),
|
|
1422
|
+
};
|
|
1423
|
+
}),
|
|
1424
|
+
]),
|
|
1425
|
+
);
|
|
1133
1426
|
}
|
|
1134
1427
|
|
|
1135
1428
|
async close() {
|
|
1136
|
-
await Promise.all([
|
|
1429
|
+
await Promise.all([
|
|
1430
|
+
this.dbos.destroy(),
|
|
1431
|
+
this.pool.close(),
|
|
1432
|
+
this.audit.close(),
|
|
1433
|
+
this.config.dataStore?.close(),
|
|
1434
|
+
this.config.connectionPool.close(),
|
|
1435
|
+
]);
|
|
1137
1436
|
}
|
|
1138
1437
|
|
|
1139
1438
|
/**
|
|
@@ -1285,6 +1584,8 @@ const sameJson = (a: unknown, b: unknown): boolean => isDeepStrictEqual(asJson(a
|
|
|
1285
1584
|
|
|
1286
1585
|
/** A secret's value as `SanomaClient.secrets` reads it, and where from. */
|
|
1287
1586
|
export interface StoredSecret {
|
|
1587
|
+
version?: string;
|
|
1588
|
+
location?: SecretLocation;
|
|
1288
1589
|
value: string;
|
|
1289
1590
|
source: "environment" | "stored";
|
|
1290
1591
|
/** Who stored it (`setSecret`'s `by`), and when, as an ISO 8601 time. */
|
|
@@ -1293,7 +1594,7 @@ export interface StoredSecret {
|
|
|
1293
1594
|
}
|
|
1294
1595
|
|
|
1295
1596
|
/** What an environment variable's name looks like. */
|
|
1296
|
-
const VARIABLE = /^[A-
|
|
1597
|
+
const VARIABLE = /^[A-Z][A-Z0-9_]*$/;
|
|
1297
1598
|
|
|
1298
1599
|
const alreadyDecided = (runId: string, a: ApprovalState) =>
|
|
1299
1600
|
new SanomaError(
|
package/src/collection.ts
CHANGED
|
@@ -99,10 +99,18 @@ export function collection<
|
|
|
99
99
|
const F extends Record<string, Field>,
|
|
100
100
|
const K extends RequiredTextKeys<F>,
|
|
101
101
|
>(declaration: { name: N; key: K; fields: F; title?: string }): Collection<N, F, K> {
|
|
102
|
+
// Frame 2 of `callerFile` is this function's caller: the data file.
|
|
103
|
+
return collectionIn(appOfFile(callerFile()), declaration);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `collection()` in the app `app` names (none for the root's): what the loader's `collection` calls, knowing the file itself. */
|
|
107
|
+
export function collectionIn<
|
|
108
|
+
const N extends string,
|
|
109
|
+
const F extends Record<string, Field>,
|
|
110
|
+
const K extends RequiredTextKeys<F>,
|
|
111
|
+
>(app: string | undefined, declaration: { name: N; key: K; fields: F; title?: string }): Collection<N, F, K> {
|
|
102
112
|
checkCollection(declaration);
|
|
103
113
|
const { key, fields, title } = declaration;
|
|
104
|
-
// Frame 2 of `callerFile` is this function's caller: the data file.
|
|
105
|
-
const app = appOfFile(callerFile());
|
|
106
114
|
const name = qualified(app, declaration.name);
|
|
107
115
|
const record = z.strictObject(
|
|
108
116
|
Object.fromEntries(
|