@sanoma-ai/workflows 0.10.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -7
- package/SECRET-SUPPORT.md +33 -0
- package/dist/apps.d.ts +6 -0
- package/dist/apps.js +18 -1
- package/dist/apps.js.map +1 -1
- package/dist/authentication.d.ts +30 -0
- package/dist/authentication.js +71 -1
- package/dist/authentication.js.map +1 -1
- package/dist/call.js +7 -5
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +54 -6
- package/dist/client.js +177 -5
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +7 -4
- package/dist/config.js +44 -12
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +223 -2
- package/dist/connection.js +334 -4
- package/dist/connection.js.map +1 -1
- package/dist/db.js +15 -2
- package/dist/db.js.map +1 -1
- package/dist/describe.d.ts +8 -0
- package/dist/describe.js +51 -1
- package/dist/describe.js.map +1 -1
- package/dist/direct.js +4 -0
- package/dist/direct.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 +4 -4
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lint.js +1 -1
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +11 -0
- package/dist/load.js +42 -8
- package/dist/load.js.map +1 -1
- package/dist/op.d.ts +14 -6
- package/dist/op.js +20 -2
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +5 -0
- package/dist/outline.js +33 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +2 -4
- package/dist/paths.js +2 -8
- package/dist/paths.js.map +1 -1
- package/dist/run.d.ts +3 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +4 -1
- package/dist/sandbox.js +51 -5
- 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/secret.d.ts +2 -0
- package/dist/secret.js +32 -0
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +4 -0
- package/dist/shared.js +7 -0
- package/dist/shared.js.map +1 -1
- package/dist/sites.js +18 -3
- package/dist/sites.js.map +1 -1
- package/dist/steps.d.ts +3 -0
- package/dist/steps.js.map +1 -1
- package/oxlint.json +1 -0
- package/package.json +2 -1
- package/src/apps.ts +17 -1
- package/src/authentication.ts +119 -2
- package/src/call.ts +4 -5
- package/src/client.ts +240 -6
- package/src/config.ts +45 -11
- package/src/connection.ts +472 -6
- package/src/db.ts +15 -2
- package/src/describe.ts +52 -1
- package/src/direct.ts +4 -0
- package/src/fake.ts +71 -0
- package/src/index.ts +12 -1
- package/src/lint.ts +1 -1
- package/src/load.ts +36 -7
- package/src/op.ts +33 -8
- package/src/outline.ts +35 -2
- package/src/paths.ts +2 -9
- package/src/run.ts +3 -1
- package/src/sandbox.ts +55 -5
- package/src/scenario.ts +24 -6
- package/src/secret.ts +29 -0
- package/src/shared.ts +9 -0
- package/src/sites.ts +19 -3
- package/src/steps.ts +3 -2
package/dist/steps.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LedgerRecord } from "./ledger.ts";
|
|
2
2
|
import type { Op } from "./op.ts";
|
|
3
|
+
import type { ConnectionRef } from "./connection.ts";
|
|
3
4
|
/** Any operation, whatever its input and output. */
|
|
4
5
|
type AnyOp<I = any, O = any> = Op<string, string, string, I, O>;
|
|
5
6
|
/**
|
|
@@ -14,9 +15,11 @@ export type Seed = {
|
|
|
14
15
|
op: AnyOp;
|
|
15
16
|
input: Record<string, unknown>;
|
|
16
17
|
as?: string;
|
|
18
|
+
connection?: ConnectionRef;
|
|
17
19
|
} | {
|
|
18
20
|
op: AnyOp;
|
|
19
21
|
fault: "failNext" | "rateLimit" | "loseReply";
|
|
22
|
+
connection?: ConnectionRef;
|
|
20
23
|
};
|
|
21
24
|
/** A call through an operation: what it was given, and what it returned. */
|
|
22
25
|
export interface Call<I, O> {
|
package/dist/steps.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"steps.js","sourceRoot":"","sources":["../src/steps.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"steps.js","sourceRoot":"","sources":["../src/steps.ts"],"names":[],"mappings":"AAiEA;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,WAAW,CACzB,KAAa,EACb,MAGU;IAEV,MAAM,KAAK,GAAqB,EAAE,CAAC;IACnC,MAAM,CAAC;QACL,KAAK,EAAE,CAAC,UAAU,EAAE,EAAE,EAAE,EAAE,CAAC,KAAK,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC;QAC7E,wFAAwF;QACxF,+CAA+C;QAC/C,IAAI,EAAE,CAAC,UAAU,EAAE,EAAE,EAAE,EAAE,CAAC,KAAK,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC;KAC5E,CAAC,CAAC;IACH,OAAO,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;AAC/D,CAAC"}
|
package/oxlint.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanoma-ai/workflows",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Business processes as TypeScript functions, run durably on DBOS",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"approvals",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"!src/AGENTS.md",
|
|
25
25
|
"oxlint.json",
|
|
26
26
|
"README.md",
|
|
27
|
+
"SECRET-SUPPORT.md",
|
|
27
28
|
"LICENSE"
|
|
28
29
|
],
|
|
29
30
|
"type": "module",
|
package/src/apps.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { exportsOf, type Loaded, loadModules } from "./load.ts";
|
|
|
7
7
|
import { stringValue } from "./outline.ts";
|
|
8
8
|
import { APPS_DIR, filesUnder, inside, isDirectory, RELATIVE, relPath } from "./paths.ts";
|
|
9
9
|
import type { ResourceProblem } from "./resources.ts";
|
|
10
|
-
import { APP_NAME } from "./shared.ts";
|
|
10
|
+
import { APP_NAME, problemLine } from "./shared.ts";
|
|
11
11
|
|
|
12
12
|
// A company's apps: each folder of `apps/` holds the kinds the root does (workflows, resources,
|
|
13
13
|
// policies, scenarios, sites), merged from a source repository whose URL its `app.ts` names, or
|
|
@@ -65,6 +65,22 @@ export function appsAt(root: string | undefined): { apps: AppEntry[]; problems:
|
|
|
65
65
|
return { apps, problems };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The source URL the app `folder` under `root` is taken from, as its `app.ts` names it; an error
|
|
70
|
+
* saying why there is none: no such app, its `app.ts` does not load or is not `defineApp({ source })`
|
|
71
|
+
* (what is wrong, where), or it names none (the company's own app).
|
|
72
|
+
*/
|
|
73
|
+
export function appSourceOf(root: string, folder: string): string {
|
|
74
|
+
if (!APP_NAME.test(folder) || !isDirectory(join(root, APPS_DIR, folder)))
|
|
75
|
+
throw new Error(`There is no app ${APPS_DIR}/${folder}/`);
|
|
76
|
+
const file = `${APPS_DIR}/${folder}/app.ts`;
|
|
77
|
+
const path = join(root, file);
|
|
78
|
+
const read = existsSync(path) ? appDefinitionOf(file, loadModules(root, [path], [])) : { problems: [] };
|
|
79
|
+
if (read.problems.length) throw new Error(read.problems.map(problemLine).join("; "));
|
|
80
|
+
if (read.source === undefined) throw new Error(`${file} names no source: the app is the company's own`);
|
|
81
|
+
return read.source;
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
/** What `app.ts`'s default export, `defineApp({ … })`, may say. */
|
|
69
85
|
const AppDefinitionSchema = z.strictObject({ source: z.string().optional() });
|
|
70
86
|
|
package/src/authentication.ts
CHANGED
|
@@ -1,6 +1,95 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
+
import type { CallContext } from "./op.ts";
|
|
2
3
|
import { SecretStoreError, type SecretStoreCapabilities } from "./secret-store.ts";
|
|
3
4
|
|
|
5
|
+
/** Facts verified with the provider, not account claims supplied by the caller. */
|
|
6
|
+
export interface AuthenticationIdentity {
|
|
7
|
+
account: string;
|
|
8
|
+
scopes: readonly string[];
|
|
9
|
+
/** Unix time in milliseconds. Missing means the provider gives no expiry. */
|
|
10
|
+
expiresAt?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AuthenticationProblem =
|
|
14
|
+
| "missing"
|
|
15
|
+
| "expired"
|
|
16
|
+
| "revoked"
|
|
17
|
+
| "rejected"
|
|
18
|
+
| "wrong_account"
|
|
19
|
+
| "scope_changed"
|
|
20
|
+
| "uncertain"
|
|
21
|
+
| "unavailable"
|
|
22
|
+
| "unsupported";
|
|
23
|
+
|
|
24
|
+
/** Safe lifecycle information only: never a provider response, cause or credential. */
|
|
25
|
+
export class AuthenticationError extends Error {
|
|
26
|
+
readonly code = "authentication_failed";
|
|
27
|
+
readonly retryable: boolean;
|
|
28
|
+
readonly problem: AuthenticationProblem;
|
|
29
|
+
constructor(problem: AuthenticationProblem) {
|
|
30
|
+
super(`Authentication: ${problem}`);
|
|
31
|
+
this.problem = problem;
|
|
32
|
+
this.name = "AuthenticationError";
|
|
33
|
+
this.retryable = problem === "unavailable";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const authenticationProblems = new Set<AuthenticationProblem>([
|
|
38
|
+
"missing",
|
|
39
|
+
"expired",
|
|
40
|
+
"revoked",
|
|
41
|
+
"rejected",
|
|
42
|
+
"wrong_account",
|
|
43
|
+
"scope_changed",
|
|
44
|
+
"uncertain",
|
|
45
|
+
"unavailable",
|
|
46
|
+
"unsupported",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
export function authenticationProblem(error: unknown): AuthenticationProblem | undefined {
|
|
50
|
+
const failure = error as Partial<AuthenticationError> | null;
|
|
51
|
+
return failure?.name === "AuthenticationError" && authenticationProblems.has(failure.problem!)
|
|
52
|
+
? failure.problem
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shared by credential intake and execution; unknown provider failures never imply revocation. */
|
|
57
|
+
export async function verifyAuthentication(
|
|
58
|
+
method: Authentication,
|
|
59
|
+
credentials?: unknown,
|
|
60
|
+
): Promise<AuthenticationIdentity> {
|
|
61
|
+
if (!method.verify) throw new AuthenticationError("unsupported");
|
|
62
|
+
try {
|
|
63
|
+
const identity = await method.verify(credentials);
|
|
64
|
+
if (
|
|
65
|
+
typeof identity?.account !== "string" ||
|
|
66
|
+
!identity.account.trim() ||
|
|
67
|
+
!Array.isArray(identity.scopes) ||
|
|
68
|
+
identity.scopes.some((scope) => typeof scope !== "string") ||
|
|
69
|
+
(identity.expiresAt !== undefined && !Number.isFinite(identity.expiresAt))
|
|
70
|
+
)
|
|
71
|
+
throw new AuthenticationError("unavailable");
|
|
72
|
+
if (identity.expiresAt !== undefined && identity.expiresAt <= Date.now()) throw new AuthenticationError("expired");
|
|
73
|
+
return {
|
|
74
|
+
account: identity.account,
|
|
75
|
+
scopes: [...new Set(identity.scopes)].toSorted(),
|
|
76
|
+
...(identity.expiresAt !== undefined && { expiresAt: identity.expiresAt }),
|
|
77
|
+
};
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Structural recognition works across installed copies. Rebuild even recognized errors so
|
|
80
|
+
// an SDK message, stack or cause attached by a verifier cannot cross this boundary.
|
|
81
|
+
throw new AuthenticationError(authenticationProblem(error) ?? "unavailable");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Ephemeral material for one authorized driver invocation; never checkpoint this object. */
|
|
86
|
+
export interface CallAuthentication {
|
|
87
|
+
method: string;
|
|
88
|
+
kind: Authentication["kind"];
|
|
89
|
+
credentialVersion?: string;
|
|
90
|
+
credentials?: unknown;
|
|
91
|
+
}
|
|
92
|
+
|
|
4
93
|
/** Credential schemas and lifecycle are owned by authentication, never by the store provider. */
|
|
5
94
|
export type Authentication<S extends z.ZodType = z.ZodType> =
|
|
6
95
|
| {
|
|
@@ -8,14 +97,23 @@ export type Authentication<S extends z.ZodType = z.ZodType> =
|
|
|
8
97
|
version: number;
|
|
9
98
|
schema: S;
|
|
10
99
|
storage: { writable: boolean; maxBytes: number; conditionalWrites: boolean };
|
|
100
|
+
verify?(credentials: z.output<S>): Promise<AuthenticationIdentity>;
|
|
11
101
|
}
|
|
12
|
-
| {
|
|
102
|
+
| {
|
|
103
|
+
kind: "workload-identity";
|
|
104
|
+
storage?: never;
|
|
105
|
+
version?: never;
|
|
106
|
+
schema?: never;
|
|
107
|
+
verify?(): Promise<AuthenticationIdentity>;
|
|
108
|
+
};
|
|
13
109
|
|
|
14
110
|
export function defineAuthentication<S extends z.ZodType>(definition: Authentication<S>): Authentication<S> {
|
|
111
|
+
if (definition.verify !== undefined && typeof definition.verify !== "function")
|
|
112
|
+
throw new SecretStoreError("invalid_configuration");
|
|
15
113
|
if (definition.kind === "workload-identity") {
|
|
16
114
|
if (definition.storage !== undefined || definition.schema !== undefined || definition.version !== undefined)
|
|
17
115
|
throw new SecretStoreError("invalid_configuration");
|
|
18
|
-
return Object.freeze({
|
|
116
|
+
return Object.freeze({ ...definition });
|
|
19
117
|
}
|
|
20
118
|
if (
|
|
21
119
|
!["api-key", "fields", "oauth2", "signed"].includes(definition.kind) ||
|
|
@@ -33,6 +131,25 @@ export function defineAuthentication<S extends z.ZodType>(definition: Authentica
|
|
|
33
131
|
return Object.freeze({ ...definition, storage: Object.freeze({ ...definition.storage }) });
|
|
34
132
|
}
|
|
35
133
|
|
|
134
|
+
/** Typed access inside a driver. A missing or different method never falls back to the environment. */
|
|
135
|
+
export function authenticationCredentials<S extends z.ZodType>(
|
|
136
|
+
method: string,
|
|
137
|
+
definition: Authentication<S>,
|
|
138
|
+
call: CallContext,
|
|
139
|
+
): z.output<S> {
|
|
140
|
+
if (
|
|
141
|
+
definition.kind === "workload-identity" ||
|
|
142
|
+
call.authentication?.method !== method ||
|
|
143
|
+
call.authentication.kind !== definition.kind
|
|
144
|
+
)
|
|
145
|
+
throw new SecretStoreError("unsupported");
|
|
146
|
+
try {
|
|
147
|
+
return definition.schema.parse(call.authentication.credentials);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new SecretStoreError("invalid_value");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
36
153
|
/** Compatibility check before consent, including externally renewed (read-only) OAuth bundles. */
|
|
37
154
|
export function checkAuthenticationStore(
|
|
38
155
|
auth: Authentication,
|
package/src/call.ts
CHANGED
|
@@ -458,9 +458,8 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
|
|
|
458
458
|
const connection = await DBOS.runStep(
|
|
459
459
|
async () => {
|
|
460
460
|
if (run.sandbox !== undefined) {
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
return undefined;
|
|
461
|
+
if (!run.fakeConnection) throw new SanomaError("invalid_input", "Sandbox account state is unavailable");
|
|
462
|
+
return run.fakeConnection(op.vendor, ref);
|
|
464
463
|
}
|
|
465
464
|
return run.state.connections?.select(op.vendor, ref, run.actor.id);
|
|
466
465
|
},
|
|
@@ -469,7 +468,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
|
|
|
469
468
|
const decideStep = () =>
|
|
470
469
|
DBOS.runStep(
|
|
471
470
|
async () => {
|
|
472
|
-
await run.state.connections?.assertCurrent(connection, run.actor.id);
|
|
471
|
+
if (run.sandbox === undefined) await run.state.connections?.assertCurrent(connection, run.actor.id);
|
|
473
472
|
return decide(run, op, parsed, connection);
|
|
474
473
|
},
|
|
475
474
|
{ name: `policy:${op.id}` },
|
|
@@ -527,7 +526,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?:
|
|
|
527
526
|
async () => {
|
|
528
527
|
const at = Date.now();
|
|
529
528
|
attempt = DBOS.stepStatus?.currentAttempt ?? 1;
|
|
530
|
-
await run.state.connections?.assertCurrent(connection, run.actor.id);
|
|
529
|
+
if (run.sandbox === undefined) await run.state.connections?.assertCurrent(connection, run.actor.id);
|
|
531
530
|
const secrets = secretsOf(run);
|
|
532
531
|
const context: CallContext = {
|
|
533
532
|
idempotencyKey: `${run.id}:${seq}`,
|
package/src/client.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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";
|
|
3
14
|
import { isDeepStrictEqual } from "node:util";
|
|
4
15
|
import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
|
|
5
16
|
import type { Pool } from "pg";
|
|
@@ -53,7 +64,7 @@ import type { CallContext, Driver, Op } from "./op.ts";
|
|
|
53
64
|
import { allowAll, type RecordedDecision } from "./policy.ts";
|
|
54
65
|
import { admit, readPeople } from "./resources.ts";
|
|
55
66
|
import type { RunArgs, StartHold } from "./run.ts";
|
|
56
|
-
import type
|
|
67
|
+
import { SecretStoreError, type SecretAccess, type SecretLocation } from "./secret-store.ts";
|
|
57
68
|
import {
|
|
58
69
|
BUILT_IN_WORKFLOWS,
|
|
59
70
|
CALL_WORKFLOW,
|
|
@@ -220,6 +231,15 @@ export interface CallOptions {
|
|
|
220
231
|
id?: string;
|
|
221
232
|
}
|
|
222
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
|
+
}
|
|
223
243
|
/** What `SanomaClient.connect` is given besides the config. */
|
|
224
244
|
export interface ClientOptions {
|
|
225
245
|
/**
|
|
@@ -1148,10 +1168,224 @@ export class SanomaClient {
|
|
|
1148
1168
|
if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by });
|
|
1149
1169
|
}
|
|
1150
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;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1151
1386
|
/**
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
* 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.
|
|
1155
1389
|
*/
|
|
1156
1390
|
async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
|
|
1157
1391
|
const found = new Map<string, StoredSecret>();
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { configuredSecrets } from "./credentials.ts";
|
|
2
|
+
import { secretsForInput } from "./secret.ts";
|
|
3
|
+
import { isRunControlError } from "./errors.ts";
|
|
2
4
|
import { currentRun } from "./run.ts";
|
|
3
5
|
import {
|
|
4
6
|
connectionCatalog,
|
|
@@ -331,6 +333,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
331
333
|
...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
|
|
332
334
|
],
|
|
333
335
|
ops,
|
|
336
|
+
{ connections },
|
|
334
337
|
);
|
|
335
338
|
const fakes = makersOf(running, "fake");
|
|
336
339
|
if (dataStore) fakes.set(DATA_VENDOR, () => collectionsFake(declared));
|
|
@@ -617,11 +620,15 @@ function indexConnectors(list: Connector<any, any>[]) {
|
|
|
617
620
|
}
|
|
618
621
|
|
|
619
622
|
/**
|
|
620
|
-
* The drivers' functions by operation id, refusing one no connector declares.
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
+
* The drivers' functions by operation id, refusing one no connector declares. Named live
|
|
624
|
+
* calls resolve authentication here, after policy, inside the driver's execution boundary.
|
|
625
|
+
* Drivers without named authentication retain their declared environment guard.
|
|
623
626
|
*/
|
|
624
|
-
export function indexDrivers(
|
|
627
|
+
export function indexDrivers(
|
|
628
|
+
list: Driver[],
|
|
629
|
+
ops: Map<string, Op>,
|
|
630
|
+
options: { connections?: ConnectionRegistry; sandbox?: boolean } = {},
|
|
631
|
+
) {
|
|
625
632
|
const map = new Map<string, DriverFn>();
|
|
626
633
|
for (const d of list) {
|
|
627
634
|
for (const [key, fn] of Object.entries(d.ops)) {
|
|
@@ -629,7 +636,36 @@ export function indexDrivers(list: Driver[], ops: Map<string, Op>) {
|
|
|
629
636
|
if (!ops.has(id)) {
|
|
630
637
|
throw new Error(`Driver "${d.vendor}" implements ${id}, which no connector in \`connectors\` declares`);
|
|
631
638
|
}
|
|
632
|
-
|
|
639
|
+
const unselected = d.env ? guarded(d.vendor, d.env, fn) : fn;
|
|
640
|
+
map.set(id, async (input, call) => {
|
|
641
|
+
if (options.sandbox) return fn(input, call);
|
|
642
|
+
if (!call.connection) {
|
|
643
|
+
if (d.authentication?.length)
|
|
644
|
+
throw new DriverError("Select a named connection for this driver", { retryable: false });
|
|
645
|
+
return unselected(input, call);
|
|
646
|
+
}
|
|
647
|
+
if (!options.connections || !call.principal || !d.authentication?.includes(call.connection.method))
|
|
648
|
+
throw new DriverError("This driver does not support the selected connection", { retryable: false });
|
|
649
|
+
const authentication = await options.connections.authenticate(call.connection, call.principal.id);
|
|
650
|
+
try {
|
|
651
|
+
return await fn(input, {
|
|
652
|
+
...call,
|
|
653
|
+
connection: structuredClone(call.connection),
|
|
654
|
+
authentication,
|
|
655
|
+
secrets: secretsForInput(input, call.secrets, ops.get(id)!.createsSecrets?.(input)),
|
|
656
|
+
});
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (isRunControlError(error)) throw error;
|
|
659
|
+
// SDK exceptions can contain the complete request, including authorization headers.
|
|
660
|
+
// Drivers may come from another installed copy of this package.
|
|
661
|
+
const failure = error as { code?: unknown; retryable?: unknown; status?: unknown } | null;
|
|
662
|
+
const status = failure?.code === "driver_failed" ? failure.status : undefined;
|
|
663
|
+
throw new DriverError(`${d.vendor} operation failed`, {
|
|
664
|
+
retryable: failure?.code === "driver_failed" && failure.retryable === true,
|
|
665
|
+
...(typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599 && { status }),
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
});
|
|
633
669
|
}
|
|
634
670
|
}
|
|
635
671
|
return map;
|
|
@@ -637,12 +673,10 @@ export function indexDrivers(list: Driver[], ops: Map<string, Op>) {
|
|
|
637
673
|
|
|
638
674
|
/**
|
|
639
675
|
* `fn`, refused while the environment leaves a variable `env` declares missing or invalid. The
|
|
640
|
-
* worker started without it (`startWorker` only warns)
|
|
641
|
-
* environment
|
|
642
|
-
* driver step
|
|
643
|
-
*
|
|
644
|
-
* used when the approver approves. The refusal is the call's own failure, final: recorded as
|
|
645
|
-
* the call's, and never retried.
|
|
676
|
+
* worker started without it (`startWorker` only warns). This checks only explicitly supplied
|
|
677
|
+
* process environment; stored credentials are never copied there. The guard runs inside the
|
|
678
|
+
* driver step after policy and approval. Named connections never use this path. The refusal
|
|
679
|
+
* is the call's own failure, final: recorded as the call's, and never retried.
|
|
646
680
|
*/
|
|
647
681
|
function guarded(vendor: string, env: DriverEnv, fn: DriverFn): DriverFn {
|
|
648
682
|
return async (input, call) => {
|