@sanoma-ai/workflows 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CLOUD-IDENTITY.md +140 -0
  2. package/KMS-SECRETS.md +99 -0
  3. package/README.md +39 -7
  4. package/SECRET-SUPPORT.md +33 -0
  5. package/dist/apps.d.ts +6 -0
  6. package/dist/apps.js +18 -1
  7. package/dist/apps.js.map +1 -1
  8. package/dist/authentication.d.ts +35 -0
  9. package/dist/authentication.js +71 -1
  10. package/dist/authentication.js.map +1 -1
  11. package/dist/call.js +7 -5
  12. package/dist/call.js.map +1 -1
  13. package/dist/client.d.ts +54 -6
  14. package/dist/client.js +177 -5
  15. package/dist/client.js.map +1 -1
  16. package/dist/cloud-identity.d.ts +52 -0
  17. package/dist/cloud-identity.js +222 -0
  18. package/dist/cloud-identity.js.map +1 -0
  19. package/dist/config.d.ts +7 -4
  20. package/dist/config.js +44 -12
  21. package/dist/config.js.map +1 -1
  22. package/dist/connection.d.ts +223 -2
  23. package/dist/connection.js +340 -4
  24. package/dist/connection.js.map +1 -1
  25. package/dist/db.js +15 -2
  26. package/dist/db.js.map +1 -1
  27. package/dist/describe.d.ts +8 -0
  28. package/dist/describe.js +51 -1
  29. package/dist/describe.js.map +1 -1
  30. package/dist/direct.js +4 -0
  31. package/dist/direct.js.map +1 -1
  32. package/dist/fake.d.ts +13 -1
  33. package/dist/fake.js +70 -0
  34. package/dist/fake.js.map +1 -1
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.js +2 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/kms-secrets.d.ts +24 -0
  39. package/dist/kms-secrets.js +209 -0
  40. package/dist/kms-secrets.js.map +1 -0
  41. package/dist/lint.js +1 -1
  42. package/dist/lint.js.map +1 -1
  43. package/dist/load.d.ts +11 -0
  44. package/dist/load.js +42 -8
  45. package/dist/load.js.map +1 -1
  46. package/dist/op.d.ts +14 -6
  47. package/dist/op.js +20 -2
  48. package/dist/op.js.map +1 -1
  49. package/dist/outline.d.ts +5 -0
  50. package/dist/outline.js +33 -1
  51. package/dist/outline.js.map +1 -1
  52. package/dist/paths.d.ts +2 -4
  53. package/dist/paths.js +2 -8
  54. package/dist/paths.js.map +1 -1
  55. package/dist/run.d.ts +3 -1
  56. package/dist/run.js.map +1 -1
  57. package/dist/sandbox.d.ts +4 -1
  58. package/dist/sandbox.js +51 -5
  59. package/dist/sandbox.js.map +1 -1
  60. package/dist/scenario.d.ts +5 -0
  61. package/dist/scenario.js +3 -2
  62. package/dist/scenario.js.map +1 -1
  63. package/dist/secret.d.ts +2 -0
  64. package/dist/secret.js +32 -0
  65. package/dist/secret.js.map +1 -1
  66. package/dist/shared.d.ts +4 -0
  67. package/dist/shared.js +7 -0
  68. package/dist/shared.js.map +1 -1
  69. package/dist/sites.js +18 -3
  70. package/dist/sites.js.map +1 -1
  71. package/dist/steps.d.ts +3 -0
  72. package/dist/steps.js.map +1 -1
  73. package/oxlint.json +3 -0
  74. package/package.json +16 -2
  75. package/src/apps.ts +17 -1
  76. package/src/authentication.ts +126 -2
  77. package/src/call.ts +4 -5
  78. package/src/client.ts +242 -6
  79. package/src/cloud-identity.ts +266 -0
  80. package/src/config.ts +45 -11
  81. package/src/connection.ts +478 -6
  82. package/src/db.ts +15 -2
  83. package/src/describe.ts +52 -1
  84. package/src/direct.ts +4 -0
  85. package/src/fake.ts +71 -0
  86. package/src/index.ts +13 -1
  87. package/src/kms-secrets.ts +271 -0
  88. package/src/lint.ts +1 -1
  89. package/src/load.ts +36 -7
  90. package/src/op.ts +33 -8
  91. package/src/outline.ts +35 -2
  92. package/src/paths.ts +2 -9
  93. package/src/run.ts +3 -1
  94. package/src/sandbox.ts +55 -5
  95. package/src/scenario.ts +24 -6
  96. package/src/secret.ts +29 -0
  97. package/src/shared.ts +9 -0
  98. package/src/sites.ts +19 -3
  99. package/src/steps.ts +3 -2
package/src/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
- /** Where a company's apps are, under its root: `apps/<app>/`. */
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 { Fake, ResourceFake } from "./fake.ts";
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.values(), (f) => f.driver)],
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 call = { idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId: given.op, attempt: 1, secrets };
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
- seeded.push(seedResource(name, fakes.get(given.vendor) as Fake<unknown> & ResourceFake, given));
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) fakes.get(state.ops.get(given.op)!.vendor)![given.fault](given.op);
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
- | { step: string; resource: string; vendor: string; type: string; name: string; fields: Record<string, unknown> }
98
- | { step: string; resource: string; vendor: string; type: string; name: string; gone: true };
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
- if ("fault" in seed) return [{ op, fault: seed.fault }];
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}`;
package/src/sites.ts CHANGED
@@ -7,6 +7,7 @@ import type { ConfigDescription, SiteCall, SiteEntry, SitePage } from "./descrip
7
7
  import { stringValue, unwrap } from "./outline.ts";
8
8
  import { exportsOf, loadModules } from "./load.ts";
9
9
  import { APPS_DIR, appFolders, filesUnder, isDirectory, relPath, SITE_ENTRIES } from "./paths.ts";
10
+ import { isObject } from "./resource.ts";
10
11
  import type { ResourceProblem } from "./resources.ts";
11
12
  import { START } from "./schema.ts";
12
13
  import {
@@ -428,6 +429,9 @@ const SiteDefinitionSchema = z.strictObject({
428
429
  locales: z.array(z.string()).optional(),
429
430
  });
430
431
 
432
+ /** What `site.ts` says of who may see a site: read together or not at all. */
433
+ const ACCESS = ["public", "login", "locales"];
434
+
431
435
  /**
432
436
  * What the site in `dir` (relative to `root`) says of itself in its `site.ts`, loaded
433
437
  * (`loadModules`, `defineSite` served from `sanoma/site`), and what is wrong there: its default
@@ -452,13 +456,25 @@ function siteDefinitionOf(
452
456
  const value = loaded.exports.find(([name]) => name === "default")?.[1];
453
457
  if (value === undefined) return { problems: [] };
454
458
  const problem = (message: string) => ({ file, export: "default", message });
455
- const parsed = SiteDefinitionSchema.safeParse(value);
459
+ const problems: (ResourceProblem & { file: string })[] = [];
460
+ let parsed = SiteDefinitionSchema.safeParse(value);
456
461
  if (!parsed.success) {
457
462
  const why = z.prettifyError(parsed.error).replaceAll(/\s*\n\s*/g, " ");
458
- return { problems: [problem(`its default export is \`defineSite({ … })\` from "sanoma/site": ${why}`)] };
463
+ problems.push(problem(`its default export is \`defineSite({ … })\` from "sanoma/site": ${why}`));
464
+ // The fields it names wrongly (and the keys it does not know) left out, the rest still read:
465
+ // a bad icon keeps a site's `public`, `login` and `locales`. Those three say who may see it,
466
+ // so a wrong one leaves out all three: never a site read as public without its login.
467
+ const wrong = new Set(
468
+ parsed.error.issues.flatMap((i) => (i.code === "unrecognized_keys" ? i.keys : i.path.slice(0, 1))),
469
+ );
470
+ if (ACCESS.some((key) => wrong.has(key))) for (const key of ACCESS) wrong.add(key);
471
+ if (!isObject(value) || !wrong.size) return { problems };
472
+ parsed = SiteDefinitionSchema.safeParse(
473
+ Object.fromEntries(Object.entries(value).filter(([key]) => !wrong.has(key))),
474
+ );
475
+ if (!parsed.success) return { problems };
459
476
  }
460
477
  const { title, icon, public: isPublic, login, locales } = parsed.data;
461
- const problems: (ResourceProblem & { file: string })[] = [];
462
478
  const loginProblem = (): string | undefined => {
463
479
  if (!isPublic) return "a login is for a public site: add `public: true`, or remove `login`";
464
480
  if (!connectors.vendors.has(login!)) return `login names "${login}", which is not a connector in sanoma.config.ts`;
package/src/steps.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
 
4
5
  /*
5
6
  * Steps a connector (or a company) ships for its Gherkin tests, as plain data: expressions and
@@ -19,8 +20,8 @@ type AnyOp<I = any, O = any> = Op<string, string, string, I, O>;
19
20
  * seed's name (else the type's) as the export.
20
21
  */
21
22
  export type Seed =
22
- | { op: AnyOp; input: Record<string, unknown>; as?: string }
23
- | { op: AnyOp; fault: "failNext" | "rateLimit" | "loseReply" };
23
+ | { op: AnyOp; input: Record<string, unknown>; as?: string; connection?: ConnectionRef }
24
+ | { op: AnyOp; fault: "failNext" | "rateLimit" | "loseReply"; connection?: ConnectionRef };
24
25
 
25
26
  /** A call through an operation: what it was given, and what it returned. */
26
27
  export interface Call<I, O> {