@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/src/fake.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { fail, type Instance, UNREGISTERED, type WakeupApi, wakeupCalls } from "./wakeup-driver.ts";
|
|
12
12
|
import { INSTANCE_TOKEN, instanceUrl } from "./wakeup-service.ts";
|
|
13
13
|
import { wakeupOps } from "./wakeup-type.ts";
|
|
14
|
+
import type { ConnectionRef } from "./connection.ts";
|
|
14
15
|
|
|
15
16
|
/*
|
|
16
17
|
* Fake vendors for tests, at `@sanoma-ai/workflows/fake`. Kept off the main entry so the
|
|
@@ -25,6 +26,76 @@ export interface FakeCall {
|
|
|
25
26
|
at: string;
|
|
26
27
|
idempotencyKey: string;
|
|
27
28
|
attempt: number;
|
|
29
|
+
connection?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A factory per named account: state, faults and idempotent replies never cross connections. */
|
|
33
|
+
export function connectionFakes<F extends Fake<unknown>>(make: () => F) {
|
|
34
|
+
const seen = new WeakSet<object>();
|
|
35
|
+
const fresh = () => {
|
|
36
|
+
const fake = make();
|
|
37
|
+
const parts = [
|
|
38
|
+
fake,
|
|
39
|
+
fake.driver,
|
|
40
|
+
fake.driver.ops,
|
|
41
|
+
...(fake.state && typeof fake.state === "object" ? [fake.state] : []),
|
|
42
|
+
];
|
|
43
|
+
if (parts.some((part) => seen.has(part)))
|
|
44
|
+
throw new Error("A named fake factory must create separate state for each connection");
|
|
45
|
+
for (const part of parts) seen.add(part);
|
|
46
|
+
return fake;
|
|
47
|
+
};
|
|
48
|
+
const first = fresh();
|
|
49
|
+
const { vendor } = first.driver;
|
|
50
|
+
let unused: F | undefined = first;
|
|
51
|
+
const accounts = new Map<string, { fake: F; identity?: string }>();
|
|
52
|
+
const calls: FakeCall[] = [];
|
|
53
|
+
const account = (ref: ConnectionRef): F => {
|
|
54
|
+
if (ref.vendor !== vendor || !ref.$connection.startsWith(`${vendor}/`))
|
|
55
|
+
throw new Error(`fake ${vendor}: select a connection of this connector`);
|
|
56
|
+
let entry = accounts.get(ref.$connection);
|
|
57
|
+
if (!entry) {
|
|
58
|
+
entry = { fake: unused ?? fresh() };
|
|
59
|
+
unused = undefined;
|
|
60
|
+
accounts.set(ref.$connection, entry);
|
|
61
|
+
}
|
|
62
|
+
return entry.fake;
|
|
63
|
+
};
|
|
64
|
+
const ops: Record<string, DriverFn> = {};
|
|
65
|
+
for (const key of Object.keys(first.driver.ops)) {
|
|
66
|
+
ops[key] = async (input, call) => {
|
|
67
|
+
const selected = call.connection;
|
|
68
|
+
if (!selected || selected.vendor !== vendor)
|
|
69
|
+
throw new DriverError(`fake ${vendor}: select a named connection`, { retryable: false });
|
|
70
|
+
const ref = { vendor, $connection: selected.id };
|
|
71
|
+
account(ref);
|
|
72
|
+
let entry = accounts.get(selected.id)!;
|
|
73
|
+
// Rebinding to another vendor account gets fresh state. Rotation within an account does not.
|
|
74
|
+
if (entry.identity !== undefined && entry.identity !== selected.account) {
|
|
75
|
+
entry = { fake: fresh() };
|
|
76
|
+
accounts.set(selected.id, entry);
|
|
77
|
+
}
|
|
78
|
+
entry.identity = selected.account;
|
|
79
|
+
calls.push({
|
|
80
|
+
op: `${vendor}.${key}`,
|
|
81
|
+
input: structuredClone(input),
|
|
82
|
+
at: new Date().toISOString(),
|
|
83
|
+
idempotencyKey: call.idempotencyKey,
|
|
84
|
+
attempt: call.attempt,
|
|
85
|
+
connection: selected.id,
|
|
86
|
+
});
|
|
87
|
+
return entry.fake.driver.ops[key]!(input, call);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
driver: { vendor, ops } satisfies Driver,
|
|
92
|
+
account,
|
|
93
|
+
calls,
|
|
94
|
+
reset() {
|
|
95
|
+
for (const entry of accounts.values()) entry.fake.reset();
|
|
96
|
+
calls.length = 0;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
28
99
|
}
|
|
29
100
|
|
|
30
101
|
export interface FakeOptions {
|
package/src/index.ts
CHANGED
|
@@ -62,7 +62,7 @@ export {
|
|
|
62
62
|
type SanomaConfig,
|
|
63
63
|
} from "./config.ts";
|
|
64
64
|
// An app's `app.ts` (`defineApp`), and where the launcher finds an app's workflows and data file.
|
|
65
|
-
export { type AppEntry, readApps } from "./apps.ts";
|
|
65
|
+
export { type AppEntry, appSourceOf, readApps } from "./apps.ts";
|
|
66
66
|
export { type AppDefinition, defineApp } from "./definitions.ts";
|
|
67
67
|
export { appFolders, APPS_DIR, filesUnder, kindDirs } from "./paths.ts";
|
|
68
68
|
// `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
|
|
@@ -214,6 +214,7 @@ export {
|
|
|
214
214
|
type ChangesFilter,
|
|
215
215
|
type ChangeView,
|
|
216
216
|
type ClientOptions,
|
|
217
|
+
type ConnectionReview,
|
|
217
218
|
SanomaClient,
|
|
218
219
|
type RunsFilter,
|
|
219
220
|
type RunSummary,
|
|
@@ -285,7 +286,13 @@ export {
|
|
|
285
286
|
checkAuthenticationStore,
|
|
286
287
|
encodeCredentials,
|
|
287
288
|
decodeCredentials,
|
|
289
|
+
authenticationCredentials,
|
|
290
|
+
verifyAuthentication,
|
|
291
|
+
AuthenticationError,
|
|
292
|
+
type AuthenticationProblem,
|
|
288
293
|
type Authentication,
|
|
294
|
+
type AuthenticationIdentity,
|
|
295
|
+
type CallAuthentication,
|
|
289
296
|
} from "./authentication.ts";
|
|
290
297
|
export { checkSecretStore, type SecretStoreCheck } from "./secret-store-check.ts";
|
|
291
298
|
|
|
@@ -301,4 +308,8 @@ export {
|
|
|
301
308
|
type ConnectionDefinition,
|
|
302
309
|
type ConnectionRule,
|
|
303
310
|
type ConnectionSnapshot,
|
|
311
|
+
type ConnectionCard,
|
|
312
|
+
type ConnectionHealth,
|
|
313
|
+
type ConnectionTarget,
|
|
314
|
+
type ConnectionSetup,
|
|
304
315
|
} from "./connection.ts";
|
package/src/lint.ts
CHANGED
|
@@ -28,7 +28,7 @@ const ALLOWED_PACKAGE = new RegExp(`^(${[...AUTHOR_PACKAGES, "@sanoma-ai/connect
|
|
|
28
28
|
const REFUSED_PACKAGES: [RegExp, string][] = [
|
|
29
29
|
[/^@sanoma-ai\/testing(\/|$)/, `fakes and test helpers belong in tests; ${THROUGH_CTX}`],
|
|
30
30
|
[/^@sanoma-ai\/app(\/|$)/, "the app starts runs and decides approvals, so a workflow could approve itself"],
|
|
31
|
-
[/^@sanoma-ai\/connector-[^/]+\/(fake|driver|remote)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
|
|
31
|
+
[/^@sanoma-ai\/connector-[^/]+\/(fake|driver|remote|vault)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
|
|
32
32
|
[/^sanoma\//, "`sanoma` has no entry points for a workflow: import its names from `sanoma` itself"],
|
|
33
33
|
];
|
|
34
34
|
|
package/src/load.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { createRequire, registerHooks } from "node:module";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -254,20 +254,47 @@ function placeOf(gen: Generation, error: unknown): { file: string; line?: number
|
|
|
254
254
|
return undefined;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Each company file under `root` a load has read, and each one's folder, with its stamp
|
|
259
|
+
* (`stampOf`) as it was before the file was read: the latest generation's for a file several
|
|
260
|
+
* read. Taken after a load, it still says what the load read, so a file changed while it read is
|
|
261
|
+
* found changed after.
|
|
262
|
+
*/
|
|
263
|
+
export function stampsOf(root: string): Map<string, string> {
|
|
264
|
+
const stamps = new Map<string, string>();
|
|
265
|
+
if (!existsSync(root)) return stamps;
|
|
266
|
+
const real = realpathSync(root);
|
|
267
|
+
// In the order made: a later generation's stamp is the newer.
|
|
268
|
+
for (const gen of registry.generations.values()) {
|
|
269
|
+
if (gen.root === real) for (const [path, stamp] of gen.stamps) stamps.set(path, stamp);
|
|
270
|
+
}
|
|
271
|
+
return stamps;
|
|
272
|
+
}
|
|
273
|
+
|
|
257
274
|
const byOrder = <T extends string | number>(a: T, b: T) => (a < b ? -1 : a > b ? 1 : 0);
|
|
258
275
|
|
|
259
|
-
/** A file's size and modification time, or that it is gone. */
|
|
260
|
-
function stampOf(path: string): string {
|
|
276
|
+
/** A file's size and modification time, or that it is gone: what a generation keeps of each file it read. */
|
|
277
|
+
export function stampOf(path: string): string {
|
|
261
278
|
const stat = statSync(path, { bigint: true, throwIfNoEntry: false });
|
|
262
279
|
return stat ? `${stat.size} ${stat.mtimeNs}` : "gone";
|
|
263
280
|
}
|
|
264
281
|
|
|
282
|
+
/** True while each path's stamp (`stampOf`) is still the one kept. */
|
|
283
|
+
export function fresh(stamps: ReadonlyMap<string, string>): boolean {
|
|
284
|
+
for (const [path, stamp] of stamps) if (stampOf(path) !== stamp) return false;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
265
288
|
/** Records a file of the generation, and its directory, as they are now: before it is read, so an edit after is seen. */
|
|
266
289
|
function touch(gen: Generation, file: string) {
|
|
267
290
|
for (const path of [file, dirname(file)]) if (!gen.stamps.has(path)) gen.stamps.set(path, stampOf(path));
|
|
268
291
|
}
|
|
269
292
|
|
|
270
|
-
/**
|
|
293
|
+
/**
|
|
294
|
+
* The generation of `root` and these constructors: the current one while nothing it stamped
|
|
295
|
+
* changed, else a new one. A generation of `root` whose files changed goes, whatever its
|
|
296
|
+
* constructors, so `stampsOf` never keeps the stamps of one no load will make again.
|
|
297
|
+
*/
|
|
271
298
|
function generationOf(
|
|
272
299
|
root: string,
|
|
273
300
|
connectors: readonly Connector<any, any>[],
|
|
@@ -278,11 +305,13 @@ function generationOf(
|
|
|
278
305
|
return info?.package ? [`${info.package}/resources`, id] : [];
|
|
279
306
|
});
|
|
280
307
|
const typesKey = [...packages, ...[...types.values()].map(idOf)].join(" ");
|
|
308
|
+
let current: Generation | undefined;
|
|
281
309
|
for (const gen of registry.generations.values()) {
|
|
282
|
-
if (gen.root !== root
|
|
283
|
-
if (
|
|
284
|
-
|
|
310
|
+
if (gen.root !== root) continue;
|
|
311
|
+
if (!fresh(gen.stamps)) registry.generations.delete(gen.id);
|
|
312
|
+
else if (gen.typesKey === typesKey) current = gen;
|
|
285
313
|
}
|
|
314
|
+
if (current !== undefined) return current;
|
|
286
315
|
const gen: Generation = {
|
|
287
316
|
id: String(++registry.seq),
|
|
288
317
|
root,
|
package/src/op.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Principal } from "./define.ts";
|
|
2
|
-
import type { Authentication } from "./authentication.ts";
|
|
2
|
+
import type { Authentication, CallAuthentication } from "./authentication.ts";
|
|
3
3
|
import type { ConnectionSnapshot } from "./connection.ts";
|
|
4
4
|
import type { ProviderV4 } from "@ai-sdk/provider";
|
|
5
5
|
import type { z } from "zod";
|
|
@@ -41,6 +41,8 @@ export interface OpSpec<I extends z.ZodType = z.ZodType, O extends z.ZodType = z
|
|
|
41
41
|
* (a resource type's `handle`): the ledger records each as `"<name>"`, never its value.
|
|
42
42
|
*/
|
|
43
43
|
opaque?: readonly string[];
|
|
44
|
+
/** Logical destinations this operation may create with Secrets.ensure, derived from its authorized input. */
|
|
45
|
+
createsSecrets?: (input: any) => readonly string[];
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/** One vendor operation, such as `ghost.post.publish`. Declares the contract only; drivers implement it. */
|
|
@@ -59,6 +61,7 @@ export interface Op<V extends string = string, R extends string = string, N exte
|
|
|
59
61
|
readonly phrases?: { readonly given?: string; readonly expect?: string };
|
|
60
62
|
/** From the spec: fields the ledger records by name only. */
|
|
61
63
|
readonly opaque?: readonly string[];
|
|
64
|
+
readonly createsSecrets?: (input: any) => readonly string[];
|
|
62
65
|
readonly input: z.ZodType<any, I>;
|
|
63
66
|
readonly output: z.ZodType<O>;
|
|
64
67
|
}
|
|
@@ -111,7 +114,7 @@ export type Connector<V extends string, S extends Specs> = {
|
|
|
111
114
|
* and `resolveConfig` reads only a record of its own shape (`checkConnector`): change it with
|
|
112
115
|
* the record, so a build refuses a connector another build made in another shape.
|
|
113
116
|
*/
|
|
114
|
-
export const CONNECTOR_FORMAT =
|
|
117
|
+
export const CONNECTOR_FORMAT = 3;
|
|
115
118
|
|
|
116
119
|
/**
|
|
117
120
|
* A connector's vendor: its id, who it is (`defineConnector`'s third argument, for a UI), its
|
|
@@ -212,6 +215,7 @@ export function defineConnector<const V extends string, const S extends Specs>(
|
|
|
212
215
|
target: spec.target,
|
|
213
216
|
phrases: spec.phrases,
|
|
214
217
|
...(spec.opaque?.length ? { opaque: spec.opaque } : {}),
|
|
218
|
+
...(spec.createsSecrets && { createsSecrets: spec.createsSecrets }),
|
|
215
219
|
input: spec.input,
|
|
216
220
|
output: spec.output,
|
|
217
221
|
} satisfies Op);
|
|
@@ -243,6 +247,8 @@ export interface CallContext {
|
|
|
243
247
|
principal?: Principal;
|
|
244
248
|
/** Authorized account metadata. Credential material stays inside the driver boundary. */
|
|
245
249
|
connection?: ConnectionSnapshot;
|
|
250
|
+
/** Resolved after policy, for this call alone. Never return it from a driver. */
|
|
251
|
+
authentication?: CallAuthentication;
|
|
246
252
|
idempotencyKey: string;
|
|
247
253
|
runId: string;
|
|
248
254
|
opId: string;
|
|
@@ -302,13 +308,15 @@ export function retryableStatus(status: number): boolean {
|
|
|
302
308
|
export type DriverEnv = z.ZodObject<Record<string, z.ZodType<string | undefined, string | undefined>>>;
|
|
303
309
|
|
|
304
310
|
/**
|
|
305
|
-
* Implements a vendor's operations, keyed `"resource.name"`.
|
|
306
|
-
*
|
|
311
|
+
* Implements a vendor's operations, keyed `"resource.name"`. Named authentication is supplied
|
|
312
|
+
* in the call context. `env` describes a driver that has not added named-account support.
|
|
307
313
|
*/
|
|
308
314
|
export interface Driver {
|
|
309
315
|
readonly vendor: string;
|
|
310
316
|
readonly ops: Readonly<Record<string, DriverFn>>;
|
|
311
317
|
readonly env?: DriverEnv;
|
|
318
|
+
/** Methods this driver consumes from CallContext.authentication. Others fail closed. */
|
|
319
|
+
readonly authentication?: readonly string[];
|
|
312
320
|
}
|
|
313
321
|
|
|
314
322
|
/**
|
|
@@ -322,7 +330,7 @@ export interface Driver {
|
|
|
322
330
|
export interface ConnectorSetup {
|
|
323
331
|
/** Auth methods available to named connections; login identity is separate. */
|
|
324
332
|
authentication?: Readonly<Record<string, Authentication>>;
|
|
325
|
-
/** Makes the live driver
|
|
333
|
+
/** Makes the live driver once per resolved config; credentials belong to each invocation. */
|
|
326
334
|
driver?: () => Driver;
|
|
327
335
|
/** Makes a fake of the vendor (`defineFake`): once per sandbox run, its own. */
|
|
328
336
|
fake?: () => Fake<any, any>;
|
|
@@ -466,8 +474,8 @@ export type OpIdOf<V extends string, S extends Specs> = {
|
|
|
466
474
|
*/
|
|
467
475
|
export function defineDriver<V extends string, S extends Specs>(
|
|
468
476
|
connector: Connector<V, S>,
|
|
469
|
-
impl: DriverImpl<S
|
|
470
|
-
options: { env?: DriverEnv } = {},
|
|
477
|
+
impl: DriverImpl<S> | ((call: CallContext) => DriverImpl<S>),
|
|
478
|
+
options: { env?: DriverEnv; authentication?: readonly string[] } = {},
|
|
471
479
|
): Driver {
|
|
472
480
|
const declared = new Map<string, Op>();
|
|
473
481
|
for (const resource of Object.values(connector as Record<string, Record<string, unknown>>)) {
|
|
@@ -475,6 +483,18 @@ export function defineDriver<V extends string, S extends Specs>(
|
|
|
475
483
|
}
|
|
476
484
|
const vendor = connector[VENDOR].id;
|
|
477
485
|
const ops: Record<string, DriverFn> = {};
|
|
486
|
+
if (typeof impl === "function") {
|
|
487
|
+
// Build clients only once the runtime has authorized and authenticated this invocation.
|
|
488
|
+
for (const key of declared.keys()) {
|
|
489
|
+
ops[key] = async (input, call) => defineDriver(connector, impl(call), options).ops[key]!(input, call);
|
|
490
|
+
}
|
|
491
|
+
return Object.freeze({
|
|
492
|
+
vendor,
|
|
493
|
+
ops: Object.freeze(ops),
|
|
494
|
+
...(options.env && { env: options.env }),
|
|
495
|
+
...(options.authentication && { authentication: Object.freeze([...options.authentication]) }),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
478
498
|
const extra: string[] = [];
|
|
479
499
|
for (const [resource, fns] of Object.entries(impl as Record<string, Record<string, unknown>>)) {
|
|
480
500
|
for (const [name, fn] of Object.entries(fns ?? {})) {
|
|
@@ -489,5 +509,10 @@ export function defineDriver<V extends string, S extends Specs>(
|
|
|
489
509
|
...(extra.length ? [`it implements ${extra.join(", ")}, which the connector does not declare`] : []),
|
|
490
510
|
];
|
|
491
511
|
if (problems.length) throw new Error(`defineDriver("${vendor}"): ${problems.join("; ")}`);
|
|
492
|
-
return Object.freeze({
|
|
512
|
+
return Object.freeze({
|
|
513
|
+
vendor,
|
|
514
|
+
ops: Object.freeze(ops),
|
|
515
|
+
...(options.env && { env: options.env }),
|
|
516
|
+
...(options.authentication && { authentication: Object.freeze([...options.authentication]) }),
|
|
517
|
+
});
|
|
493
518
|
}
|
package/src/outline.ts
CHANGED
|
@@ -44,7 +44,7 @@ export type OutlineNode = { path: string; span: Span } &
|
|
|
44
44
|
*/
|
|
45
45
|
/** `name`: the one the call gives (`ctx.x.y.z(input, { name: "…" })`), else the id when no segment is computed. */
|
|
46
46
|
(
|
|
47
|
-
| { kind: "op"; id: string; ops?: string[]; name?: string }
|
|
47
|
+
| { kind: "op"; id: string; ops?: string[]; name?: string; connection?: { id: string } | { dynamic: true } }
|
|
48
48
|
/**
|
|
49
49
|
* `ctx.approval(...)`, with its title, and the title as its `name`, when the first argument is
|
|
50
50
|
* a string literal or a top-level `const` holding one.
|
|
@@ -417,6 +417,31 @@ export function outlineBody(
|
|
|
417
417
|
return undefined;
|
|
418
418
|
};
|
|
419
419
|
|
|
420
|
+
// Dependency information only. Execution still resolves and authorizes the actual reference.
|
|
421
|
+
// An imported/computed reference stays unknown rather than being guessed from its variable name.
|
|
422
|
+
const connectionIn = (options: Node | undefined): { id: string } | { dynamic: true } | undefined => {
|
|
423
|
+
if (!options) return undefined;
|
|
424
|
+
const object = options && unwrap(options);
|
|
425
|
+
if (object?.type !== "ObjectExpression") return { dynamic: true };
|
|
426
|
+
if (object.properties.some((p: Node) => p.type !== "Property" || p.computed)) return { dynamic: true };
|
|
427
|
+
const property =
|
|
428
|
+
object?.type === "ObjectExpression" &&
|
|
429
|
+
object.properties.findLast(
|
|
430
|
+
(p: Node) => p.type === "Property" && !p.computed && (p.key.name ?? p.key.value) === "connection",
|
|
431
|
+
);
|
|
432
|
+
if (!property) return undefined;
|
|
433
|
+
const ref = unwrap(property.value);
|
|
434
|
+
if (ref?.type !== "ObjectExpression") return { dynamic: true };
|
|
435
|
+
if (ref.properties.some((p: Node) => p.type !== "Property" || p.computed)) return { dynamic: true };
|
|
436
|
+
const key =
|
|
437
|
+
ref?.type === "ObjectExpression" &&
|
|
438
|
+
ref.properties.findLast(
|
|
439
|
+
(p: Node) => p.type === "Property" && !p.computed && (p.key.name ?? p.key.value) === "$connection",
|
|
440
|
+
);
|
|
441
|
+
const id = key && literalOf(key.value, consts);
|
|
442
|
+
return typeof id === "string" ? { id } : { dynamic: true };
|
|
443
|
+
};
|
|
444
|
+
|
|
420
445
|
// The arguments are evaluated before the call is made, so their calls come first.
|
|
421
446
|
const call = (node: Node, at: Where): OutlineNode[] => {
|
|
422
447
|
const args: Node[] = node.arguments;
|
|
@@ -442,9 +467,17 @@ export function outlineBody(
|
|
|
442
467
|
if (isOpPath(members)) {
|
|
443
468
|
// A computed id's own name is the operation it calls, known only in a run.
|
|
444
469
|
const name = nameIn(args[1], true) ?? (id.includes("*") ? undefined : id);
|
|
470
|
+
const connection = connectionIn(args[1]);
|
|
445
471
|
return [
|
|
446
472
|
...walk(args, at),
|
|
447
|
-
{
|
|
473
|
+
{
|
|
474
|
+
kind: "op",
|
|
475
|
+
path,
|
|
476
|
+
id,
|
|
477
|
+
span: span(node),
|
|
478
|
+
...(name === undefined ? {} : { name }),
|
|
479
|
+
...(connection && { connection }),
|
|
480
|
+
},
|
|
448
481
|
];
|
|
449
482
|
}
|
|
450
483
|
return READS.has(id) ? walk(args, at) : [...walk(args, at), ...problem(node, CTX_CALL(id))];
|
package/src/paths.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
-
import { APP_NAME } from "./shared.ts";
|
|
3
|
+
import { APP_NAME, APPS_DIR, appOfPath } from "./shared.ts";
|
|
4
4
|
|
|
5
5
|
// Where an import may reach, as the lints and the data-file reader all decide it, the one walk up
|
|
6
6
|
// the directories and the one walk down (`filesUnder`). Node's `path` and `fs` only, no parser:
|
|
@@ -65,8 +65,7 @@ export function filesUnder(dir: string, keep: (name: string) => boolean): string
|
|
|
65
65
|
.toSorted();
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
export const APPS_DIR = "apps";
|
|
68
|
+
export { APPS_DIR, appOfPath };
|
|
70
69
|
|
|
71
70
|
/** Each directory's company root (the nearest one with a `sanoma.config.ts`, short of a `node_modules`), once found. */
|
|
72
71
|
const roots = new Map<string, string>();
|
|
@@ -98,12 +97,6 @@ export function appOfFile(file: string | undefined): string | undefined {
|
|
|
98
97
|
return root === null ? undefined : appOfPath(relPath(root, file));
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
/** The app a path relative to a company's root is in: `<app>` under `apps/<app>/`; undefined for any other. */
|
|
102
|
-
export function appOfPath(rel: string): string | undefined {
|
|
103
|
-
const [top, app, ...rest] = rel.split("/");
|
|
104
|
-
return top === APPS_DIR && app !== undefined && rest.length && APP_NAME.test(app) ? app : undefined;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
100
|
/** The apps' folders under `root` whose names are an `APP_NAME`, sorted: `apps/<app>/`, absolute. */
|
|
108
101
|
export function appFolders(root: string): string[] {
|
|
109
102
|
const dir = join(root, APPS_DIR);
|
package/src/run.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConnectionRegistry } from "./connection.ts";
|
|
1
|
+
import type { ConnectionRef, ConnectionRegistry, ConnectionSnapshot } from "./connection.ts";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
3
|
import type { Collection } from "./collection.ts";
|
|
4
4
|
import type { DeadlineRegistry } from "./deadlines.ts";
|
|
@@ -98,6 +98,8 @@ export interface Run {
|
|
|
98
98
|
fakeDrivers?: Map<string, DriverFn>;
|
|
99
99
|
/** A sandbox run's own secrets, in memory, from `seedSandbox`; unset for a live run. */
|
|
100
100
|
fakeSecrets?: Secrets;
|
|
101
|
+
/** Synthetic, run-local identities; this resolver never reads production connection records. */
|
|
102
|
+
fakeConnection?: (vendor: string, ref?: ConnectionRef) => ConnectionSnapshot | undefined;
|
|
101
103
|
approvals: ApprovalState[];
|
|
102
104
|
/** The next ledger `seq`. Advanced only outside steps, so a replay counts the same way. */
|
|
103
105
|
seq: number;
|
package/src/sandbox.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { indexDrivers, sandboxDrivers } from "./config.ts";
|
|
|
3
3
|
import { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
|
|
4
4
|
import { opIdsOf } from "./define.ts";
|
|
5
5
|
import { SanomaError } from "./errors.ts";
|
|
6
|
-
import type
|
|
6
|
+
import { connectionFakes, type Fake, type ResourceFake } from "./fake.ts";
|
|
7
|
+
import type { ConnectionCatalog, ConnectionRef, ConnectionSnapshot } from "./connection.ts";
|
|
7
8
|
import { entry, type LedgerRecord, write } from "./ledger.ts";
|
|
8
9
|
import type { DriverFn } from "./op.ts";
|
|
9
10
|
import type { Run } from "./run.ts";
|
|
@@ -20,6 +21,30 @@ import { errorMessage } from "./shared.ts";
|
|
|
20
21
|
const holdsResources = (fake: Fake<unknown>): fake is Fake<unknown> & ResourceFake =>
|
|
21
22
|
"override" in fake && "put" in fake && "remove" in fake;
|
|
22
23
|
|
|
24
|
+
/** Declared accounts get synthetic identities in each run; no production metadata or secrets are read. */
|
|
25
|
+
export function sandboxConnection(catalog: ConnectionCatalog, runId: string) {
|
|
26
|
+
return (vendor: string, ref?: ConnectionRef): ConnectionSnapshot | undefined => {
|
|
27
|
+
const definitions = [...catalog.definitions.values()].filter((d) => d.vendor === vendor);
|
|
28
|
+
const selected = ref ?? definitions.find((d) => d.default);
|
|
29
|
+
if (!selected && !Object.keys(catalog.methods.get(vendor) ?? {}).length) return undefined;
|
|
30
|
+
const definition = selected && catalog.definitions.get(selected.$connection);
|
|
31
|
+
if (!definition || selected?.vendor !== vendor || definition.vendor !== vendor)
|
|
32
|
+
throw new SanomaError("invalid_input", "Select an explicitly declared sandbox connection");
|
|
33
|
+
const revision = `sandbox:${runId}:${definition.$connection}`;
|
|
34
|
+
if (selected.revision !== undefined && selected.revision !== revision)
|
|
35
|
+
throw new SanomaError("invalid_input", "A sandbox cannot use a production connection revision");
|
|
36
|
+
return {
|
|
37
|
+
id: definition.$connection,
|
|
38
|
+
vendor,
|
|
39
|
+
method: definition.method,
|
|
40
|
+
account: definition.account ?? `fake:${definition.$connection}`,
|
|
41
|
+
scopes: [...definition.scopes],
|
|
42
|
+
revision,
|
|
43
|
+
definition: revision,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
/**
|
|
24
49
|
* The error a sandbox run of the scenario fails with when it seeds a resource of a vendor whose
|
|
25
50
|
* fake (one of `fakes`, by vendor) holds none, or whose connector attaches no fake; undefined
|
|
@@ -110,10 +135,18 @@ export async function seedSandbox(run: Run, name: string, input: unknown): Promi
|
|
|
110
135
|
});
|
|
111
136
|
}
|
|
112
137
|
const fakes = new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
|
|
138
|
+
const named = new Map(
|
|
139
|
+
[...state.fakes]
|
|
140
|
+
.filter(([vendor]) => Object.keys(state.connections!.catalog.methods.get(vendor) ?? {}).length)
|
|
141
|
+
.map(([vendor, make]) => [vendor, connectionFakes(make)]),
|
|
142
|
+
);
|
|
143
|
+
const select = sandboxConnection(state.connections!.catalog, run.id);
|
|
144
|
+
run.fakeConnection = select;
|
|
113
145
|
// The built-ins first, so a connector's fake that implements one of their operations wins.
|
|
114
146
|
const drivers = indexDrivers(
|
|
115
|
-
[...sandboxDrivers(state.ops), ...Array.from(fakes
|
|
147
|
+
[...sandboxDrivers(state.ops), ...Array.from(fakes, ([vendor, f]) => named.get(vendor)?.driver ?? f.driver)],
|
|
116
148
|
state.ops,
|
|
149
|
+
{ sandbox: true },
|
|
117
150
|
);
|
|
118
151
|
// The built-in call workflow selects exactly one operation. Other direct operations
|
|
119
152
|
// (including live connection mutations) need no fake unless this scenario calls them.
|
|
@@ -134,16 +167,33 @@ export async function seedSandbox(run: Run, name: string, input: unknown): Promi
|
|
|
134
167
|
for (const [i, given] of scenario.given.entries()) {
|
|
135
168
|
if ("fault" in given) continue;
|
|
136
169
|
if ("op" in given) {
|
|
137
|
-
const
|
|
170
|
+
const connection = select(state.ops.get(given.op)!.vendor, given.connection);
|
|
171
|
+
const call = {
|
|
172
|
+
idempotencyKey: `${run.id}:seed:${i}`,
|
|
173
|
+
runId: run.id,
|
|
174
|
+
opId: given.op,
|
|
175
|
+
attempt: 1,
|
|
176
|
+
secrets,
|
|
177
|
+
connection,
|
|
178
|
+
};
|
|
138
179
|
const output = await drivers.get(given.op)!(given.input, call);
|
|
139
180
|
seeded.push({ op: given.op, input: given.input, output });
|
|
140
181
|
continue;
|
|
141
182
|
}
|
|
142
183
|
// Checked above: the vendor's fake holds resources.
|
|
143
|
-
|
|
184
|
+
const selected = select(given.vendor, given.connection);
|
|
185
|
+
const fake = selected
|
|
186
|
+
? named.get(given.vendor)!.account({ vendor: given.vendor, $connection: selected.id })
|
|
187
|
+
: fakes.get(given.vendor)!;
|
|
188
|
+
seeded.push(seedResource(name, fake as Fake<unknown> & ResourceFake, given));
|
|
144
189
|
}
|
|
145
190
|
for (const given of scenario.given) {
|
|
146
|
-
if ("fault" in given)
|
|
191
|
+
if ("fault" in given) {
|
|
192
|
+
const vendor = state.ops.get(given.op)!.vendor;
|
|
193
|
+
const selected = select(vendor, given.connection);
|
|
194
|
+
const fake = selected ? named.get(vendor)!.account({ vendor, $connection: selected.id }) : fakes.get(vendor)!;
|
|
195
|
+
fake[given.fault](given.op);
|
|
196
|
+
}
|
|
147
197
|
}
|
|
148
198
|
copies = drivers;
|
|
149
199
|
return { seeds: seeded };
|
package/src/scenario.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { errorCode, errorInfo, SanomaError } from "./errors.ts";
|
|
30
30
|
import { type CallRecord, contains, type CoreExpectation, detailOf, isCall } from "./expectations.ts";
|
|
31
31
|
import type { Fake } from "./fake.ts";
|
|
32
|
+
import type { ConnectionRef } from "./connection.ts";
|
|
32
33
|
import { fill, isBug, objectUnder, restOf, seedFrom, shapeOf } from "./fill.ts";
|
|
33
34
|
import type { LedgerRecord } from "./ledger.ts";
|
|
34
35
|
import type { Driver, Op } from "./op.ts";
|
|
@@ -92,10 +93,26 @@ export interface ScenarioStep {
|
|
|
92
93
|
* its import id, with `fields` to set on it (put, when the vendor has none) or `gone` to remove it.
|
|
93
94
|
*/
|
|
94
95
|
export type Given =
|
|
95
|
-
| { step: string; op: string; input: unknown; as?: string }
|
|
96
|
-
| { step: string; op: string; fault: Fault }
|
|
97
|
-
| {
|
|
98
|
-
|
|
96
|
+
| { step: string; op: string; input: unknown; as?: string; connection?: ConnectionRef }
|
|
97
|
+
| { step: string; op: string; fault: Fault; connection?: ConnectionRef }
|
|
98
|
+
| {
|
|
99
|
+
step: string;
|
|
100
|
+
resource: string;
|
|
101
|
+
vendor: string;
|
|
102
|
+
type: string;
|
|
103
|
+
name: string;
|
|
104
|
+
fields: Record<string, unknown>;
|
|
105
|
+
connection?: ConnectionRef;
|
|
106
|
+
}
|
|
107
|
+
| {
|
|
108
|
+
step: string;
|
|
109
|
+
resource: string;
|
|
110
|
+
vendor: string;
|
|
111
|
+
type: string;
|
|
112
|
+
name: string;
|
|
113
|
+
gone: true;
|
|
114
|
+
connection?: ConnectionRef;
|
|
115
|
+
};
|
|
99
116
|
|
|
100
117
|
/** How one approval is decided; `approval` is its title or its id. */
|
|
101
118
|
export interface Decision {
|
|
@@ -671,7 +688,8 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
671
688
|
read: (values) => {
|
|
672
689
|
const seed = def.fn(...values);
|
|
673
690
|
const op = opNamed(seed.op);
|
|
674
|
-
|
|
691
|
+
const connection = seed.connection && { connection: seed.connection };
|
|
692
|
+
if ("fault" in seed) return [{ op, fault: seed.fault, ...connection }];
|
|
675
693
|
let input: unknown;
|
|
676
694
|
try {
|
|
677
695
|
input = fill(opOf(op).input, seed.input);
|
|
@@ -681,7 +699,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
681
699
|
cause: err,
|
|
682
700
|
});
|
|
683
701
|
}
|
|
684
|
-
return [{ op, input, ...(seed.as === undefined ? {} : { as: seed.as }) }];
|
|
702
|
+
return [{ op, input, ...(seed.as === undefined ? {} : { as: seed.as }), ...connection }];
|
|
685
703
|
},
|
|
686
704
|
});
|
|
687
705
|
continue;
|
package/src/secret.ts
CHANGED
|
@@ -46,6 +46,35 @@ export interface Secrets {
|
|
|
46
46
|
ensure(name: string, make: () => string): Promise<string>;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** A named call can resolve only the auxiliary secret markers present in its authorized input. */
|
|
50
|
+
export function secretsForInput(input: unknown, secrets: Secrets, creates: readonly string[] = []): Secrets {
|
|
51
|
+
const allowed = new Map<string, boolean>();
|
|
52
|
+
for (const name of creates) {
|
|
53
|
+
if (!SECRET_NAME.test(name)) throw new DriverError("Invalid operation secret destination", { retryable: false });
|
|
54
|
+
allowed.set(name, true);
|
|
55
|
+
}
|
|
56
|
+
const visit = (value: unknown) => {
|
|
57
|
+
if (isSecret(value)) allowed.set(value.$secret, allowed.get(value.$secret) === true || value.generate === true);
|
|
58
|
+
else if (Array.isArray(value)) value.forEach(visit);
|
|
59
|
+
else if (value && typeof value === "object") Object.values(value).forEach(visit);
|
|
60
|
+
};
|
|
61
|
+
visit(input);
|
|
62
|
+
const check = (name: string, generate: boolean) => {
|
|
63
|
+
if (!allowed.has(name) || (generate && !allowed.get(name)))
|
|
64
|
+
throw new DriverError("The call does not authorize this secret access", { retryable: false });
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
async get(name) {
|
|
68
|
+
check(name, false);
|
|
69
|
+
return secrets.get(name);
|
|
70
|
+
},
|
|
71
|
+
async ensure(name, make) {
|
|
72
|
+
check(name, true);
|
|
73
|
+
return secrets.ensure(name, make);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
/** A value for a secret with `generate`: 32 random bytes, base64url. */
|
|
50
79
|
export const generateSecret = (): string => randomBytes(32).toString("base64url");
|
|
51
80
|
|
package/src/shared.ts
CHANGED
|
@@ -89,6 +89,15 @@ export const SITE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
|
89
89
|
*/
|
|
90
90
|
export const APP_NAME = /^[a-z][a-z0-9]*$/;
|
|
91
91
|
|
|
92
|
+
/** Where a company's apps are, under its root: `apps/<app>/`. */
|
|
93
|
+
export const APPS_DIR = "apps";
|
|
94
|
+
|
|
95
|
+
/** The app a path relative to a company's root is in: `<app>` under `apps/<app>/`; undefined for any other. */
|
|
96
|
+
export function appOfPath(rel: string): string | undefined {
|
|
97
|
+
const [top, app, ...rest] = rel.split("/");
|
|
98
|
+
return top === APPS_DIR && app !== undefined && rest.length && APP_NAME.test(app) ? app : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
/** A name declared under `apps/<app>/` as the runtime has it, `<app>.<name>`; one declared at the root as it is. */
|
|
93
102
|
export const qualified = (app: string | undefined, name: string): string =>
|
|
94
103
|
app === undefined ? name : `${app}.${name}`;
|