@sanoma-ai/workflows 0.9.0 → 0.10.0

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 (205) hide show
  1. package/README.md +169 -52
  2. package/dist/apply-steps.js +5 -2
  3. package/dist/apply-steps.js.map +1 -1
  4. package/dist/apply.d.ts +3 -0
  5. package/dist/apply.js +35 -21
  6. package/dist/apply.js.map +1 -1
  7. package/dist/approvals.d.ts +1 -0
  8. package/dist/approvals.js +6 -1
  9. package/dist/approvals.js.map +1 -1
  10. package/dist/apps.d.ts +7 -24
  11. package/dist/apps.js +51 -67
  12. package/dist/apps.js.map +1 -1
  13. package/dist/ast.d.ts +9 -7
  14. package/dist/ast.js +2 -0
  15. package/dist/ast.js.map +1 -1
  16. package/dist/authentication.d.ts +24 -0
  17. package/dist/authentication.js +68 -0
  18. package/dist/authentication.js.map +1 -0
  19. package/dist/call-workflow.d.ts +5 -0
  20. package/dist/call-workflow.js +4 -3
  21. package/dist/call-workflow.js.map +1 -1
  22. package/dist/call.d.ts +3 -2
  23. package/dist/call.js +34 -7
  24. package/dist/call.js.map +1 -1
  25. package/dist/cedar.js +14 -1
  26. package/dist/cedar.js.map +1 -1
  27. package/dist/change-steps.js +7 -5
  28. package/dist/change-steps.js.map +1 -1
  29. package/dist/change.d.ts +7 -0
  30. package/dist/change.js +7 -0
  31. package/dist/change.js.map +1 -1
  32. package/dist/client.d.ts +10 -6
  33. package/dist/client.js +96 -43
  34. package/dist/client.js.map +1 -1
  35. package/dist/collection.d.ts +7 -0
  36. package/dist/collection.js +5 -2
  37. package/dist/collection.js.map +1 -1
  38. package/dist/config.d.ts +14 -3
  39. package/dist/config.js +55 -14
  40. package/dist/config.js.map +1 -1
  41. package/dist/connection.d.ts +168 -0
  42. package/dist/connection.js +327 -0
  43. package/dist/connection.js.map +1 -0
  44. package/dist/credentials.d.ts +7 -53
  45. package/dist/credentials.js +84 -247
  46. package/dist/credentials.js.map +1 -1
  47. package/dist/cucumber.d.ts +4 -2
  48. package/dist/cucumber.js +8 -3
  49. package/dist/cucumber.js.map +1 -1
  50. package/dist/db.js +21 -2
  51. package/dist/db.js.map +1 -1
  52. package/dist/define.d.ts +9 -2
  53. package/dist/define.js.map +1 -1
  54. package/dist/definitions.d.ts +20 -0
  55. package/dist/definitions.js +16 -0
  56. package/dist/definitions.js.map +1 -0
  57. package/dist/deployment-key.d.ts +2 -0
  58. package/dist/deployment-key.js +22 -0
  59. package/dist/deployment-key.js.map +1 -0
  60. package/dist/describe.d.ts +9 -177
  61. package/dist/describe.js +71 -34
  62. package/dist/describe.js.map +1 -1
  63. package/dist/description.d.ts +532 -0
  64. package/dist/description.js +381 -0
  65. package/dist/description.js.map +1 -0
  66. package/dist/direct.js +3 -0
  67. package/dist/direct.js.map +1 -1
  68. package/dist/drift.d.ts +1 -1
  69. package/dist/drift.js +1 -1
  70. package/dist/drift.js.map +1 -1
  71. package/dist/env.d.ts +2 -0
  72. package/dist/env.js +2 -2
  73. package/dist/env.js.map +1 -1
  74. package/dist/index.d.ts +10 -2
  75. package/dist/index.js +10 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/ledger.d.ts +2 -0
  78. package/dist/ledger.js +1 -1
  79. package/dist/ledger.js.map +1 -1
  80. package/dist/lint.d.ts +0 -9
  81. package/dist/lint.js +1 -16
  82. package/dist/lint.js.map +1 -1
  83. package/dist/load.d.ts +74 -0
  84. package/dist/load.js +357 -0
  85. package/dist/load.js.map +1 -0
  86. package/dist/lockfile.d.ts +2 -0
  87. package/dist/lockfile.js +8 -3
  88. package/dist/lockfile.js.map +1 -1
  89. package/dist/op.d.ts +10 -1
  90. package/dist/op.js +1 -1
  91. package/dist/op.js.map +1 -1
  92. package/dist/outline.js +13 -4
  93. package/dist/outline.js.map +1 -1
  94. package/dist/paths.d.ts +18 -0
  95. package/dist/paths.js +21 -3
  96. package/dist/paths.js.map +1 -1
  97. package/dist/plugin.d.ts +0 -11
  98. package/dist/plugin.js +3 -18
  99. package/dist/plugin.js.map +1 -1
  100. package/dist/policy.d.ts +2 -0
  101. package/dist/policy.js.map +1 -1
  102. package/dist/resource.d.ts +27 -4
  103. package/dist/resource.js +49 -4
  104. package/dist/resource.js.map +1 -1
  105. package/dist/resources.d.ts +33 -76
  106. package/dist/resources.js +219 -227
  107. package/dist/resources.js.map +1 -1
  108. package/dist/run.d.ts +2 -0
  109. package/dist/run.js.map +1 -1
  110. package/dist/sandbox.d.ts +1 -1
  111. package/dist/sandbox.js +7 -3
  112. package/dist/sandbox.js.map +1 -1
  113. package/dist/scenario.d.ts +7 -0
  114. package/dist/scenario.js +33 -3
  115. package/dist/scenario.js.map +1 -1
  116. package/dist/scenarios.js +7 -1
  117. package/dist/scenarios.js.map +1 -1
  118. package/dist/schema.js +8 -0
  119. package/dist/schema.js.map +1 -1
  120. package/dist/secret-store-check.d.ts +11 -0
  121. package/dist/secret-store-check.js +76 -0
  122. package/dist/secret-store-check.js.map +1 -0
  123. package/dist/secret-store.d.ts +106 -0
  124. package/dist/secret-store.js +228 -0
  125. package/dist/secret-store.js.map +1 -0
  126. package/dist/shared.d.ts +17 -11
  127. package/dist/shared.js +34 -7
  128. package/dist/shared.js.map +1 -1
  129. package/dist/sites.d.ts +4 -93
  130. package/dist/sites.js +61 -84
  131. package/dist/sites.js.map +1 -1
  132. package/dist/source.d.ts +6 -0
  133. package/dist/source.js +13 -0
  134. package/dist/source.js.map +1 -1
  135. package/dist/steps.d.ts +3 -1
  136. package/dist/steps.js.map +1 -1
  137. package/dist/version.d.ts +2 -1
  138. package/dist/version.js +2 -1
  139. package/dist/version.js.map +1 -1
  140. package/dist/wakeup-driver.js +15 -6
  141. package/dist/wakeup-driver.js.map +1 -1
  142. package/dist/worker.js +12 -8
  143. package/dist/worker.js.map +1 -1
  144. package/dist/writer.d.ts +53 -14
  145. package/dist/writer.js +113 -43
  146. package/dist/writer.js.map +1 -1
  147. package/oxlint.json +0 -5
  148. package/package.json +6 -2
  149. package/src/apply-steps.ts +5 -2
  150. package/src/apply.ts +70 -29
  151. package/src/approvals.ts +7 -1
  152. package/src/apps.ts +54 -85
  153. package/src/ast.ts +6 -6
  154. package/src/authentication.ts +74 -0
  155. package/src/call-workflow.ts +5 -4
  156. package/src/call.ts +53 -9
  157. package/src/cedar.ts +14 -1
  158. package/src/change-steps.ts +7 -4
  159. package/src/change.ts +10 -0
  160. package/src/client.ts +108 -41
  161. package/src/collection.ts +10 -2
  162. package/src/config.ts +72 -13
  163. package/src/connection.ts +446 -0
  164. package/src/credentials.ts +94 -281
  165. package/src/cucumber.ts +16 -3
  166. package/src/db.ts +21 -2
  167. package/src/define.ts +7 -2
  168. package/src/definitions.ts +31 -0
  169. package/src/deployment-key.ts +23 -0
  170. package/src/describe.ts +102 -230
  171. package/src/description.ts +426 -0
  172. package/src/direct.ts +3 -0
  173. package/src/drift.ts +10 -2
  174. package/src/env.ts +4 -2
  175. package/src/index.ts +42 -1
  176. package/src/ledger.ts +3 -1
  177. package/src/lint.ts +1 -16
  178. package/src/load.ts +437 -0
  179. package/src/lockfile.ts +10 -3
  180. package/src/op.ts +10 -1
  181. package/src/outline.ts +18 -5
  182. package/src/paths.ts +24 -2
  183. package/src/plugin.ts +3 -24
  184. package/src/policy.ts +2 -0
  185. package/src/resource.ts +67 -6
  186. package/src/resources.ts +246 -301
  187. package/src/run.ts +2 -0
  188. package/src/sandbox.ts +11 -3
  189. package/src/scenario.ts +45 -3
  190. package/src/scenarios.ts +7 -1
  191. package/src/schema.ts +10 -0
  192. package/src/secret-store-check.ts +93 -0
  193. package/src/secret-store.ts +314 -0
  194. package/src/shared.ts +35 -10
  195. package/src/sites.ts +70 -180
  196. package/src/source.ts +14 -0
  197. package/src/steps.ts +3 -1
  198. package/src/version.ts +2 -1
  199. package/src/wakeup-driver.ts +22 -6
  200. package/src/worker.ts +12 -8
  201. package/src/writer.ts +145 -56
  202. package/dist/datafile.d.ts +0 -97
  203. package/dist/datafile.js +0 -479
  204. package/dist/datafile.js.map +0 -1
  205. package/src/datafile.ts +0 -601
package/src/run.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ConnectionRegistry } from "./connection.ts";
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import type { Collection } from "./collection.ts";
3
4
  import type { DeadlineRegistry } from "./deadlines.ts";
@@ -38,6 +39,7 @@ export interface StartHold {
38
39
  * from a stopped worker never reads a newer worker's drivers or policy.
39
40
  */
40
41
  export interface WorkerState {
42
+ connections?: ConnectionRegistry;
41
43
  /** The config's `appName`, recorded on every ledger record. */
42
44
  app: string;
43
45
  /** The operations as the config's connectors declare them, by id. */
package/src/sandbox.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { DBOS } from "@dbos-inc/dbos-sdk";
2
2
  import { indexDrivers, sandboxDrivers } from "./config.ts";
3
+ import { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
3
4
  import { opIdsOf } from "./define.ts";
4
5
  import { SanomaError } from "./errors.ts";
5
6
  import type { Fake, ResourceFake } from "./fake.ts";
@@ -88,7 +89,7 @@ export function seedResource(
88
89
  * run whose seeding is replayed from it has none, and fails, since a sandbox run does not
89
90
  * survive a worker restart.
90
91
  */
91
- export async function seedSandbox(run: Run, name: string): Promise<void> {
92
+ export async function seedSandbox(run: Run, name: string, input: unknown): Promise<void> {
92
93
  const { state } = run;
93
94
  let copies: Map<string, DriverFn> | undefined;
94
95
  // The run's secrets, in memory as its fakes are: seeding and its calls share them.
@@ -109,8 +110,15 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
109
110
  });
110
111
  }
111
112
  const fakes = new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
112
- const drivers = indexDrivers([...Array.from(fakes.values(), (f) => f.driver), ...sandboxDrivers], state.ops);
113
- const uses = opIdsOf(state.workflows.get(run.workflow)!);
113
+ // The built-ins first, so a connector's fake that implements one of their operations wins.
114
+ const drivers = indexDrivers(
115
+ [...sandboxDrivers(state.ops), ...Array.from(fakes.values(), (f) => f.driver)],
116
+ state.ops,
117
+ );
118
+ // The built-in call workflow selects exactly one operation. Other direct operations
119
+ // (including live connection mutations) need no fake unless this scenario calls them.
120
+ const uses =
121
+ run.workflow === CALL_WORKFLOW ? [CallInput.parse(input).op] : opIdsOf(state.workflows.get(run.workflow)!);
114
122
  const seeding = scenario.given.flatMap((g) => ("op" in g ? [g.op] : []));
115
123
  const missing = [...new Set([...uses, ...seeding])].filter((id) => !drivers.has(id));
116
124
  if (missing.length) {
package/src/scenario.ts CHANGED
@@ -34,7 +34,7 @@ import type { LedgerRecord } from "./ledger.ts";
34
34
  import type { Driver, Op } from "./op.ts";
35
35
  import { type OutlineNode, outlineWorkflow } from "./outline.ts";
36
36
  import { isDirectory, kindDirs, SCENARIOS_DIR } from "./paths.ts";
37
- import { isObject } from "./resource.ts";
37
+ import { AFTER_CREATE_MS, callAfterCreate, isObject } from "./resource.ts";
38
38
  import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
39
39
  import { memorySecrets } from "./secret.ts";
40
40
  import {
@@ -903,11 +903,19 @@ function viewOf(records: readonly LedgerRecord[]): RunView {
903
903
  return { records, calls, seeded, made: (op) => [...seeded(op), ...calls(op)] };
904
904
  }
905
905
 
906
+ /** How long a step of an operation's own test may take live: a `Given` may seed an object as apply makes it, its `afterCreate` polled. */
907
+ export const FEATURE_STEP_MS = AFTER_CREATE_MS + 60_000;
908
+
906
909
  /** Where an operation's own test makes its calls: an operation's driver, live, replayed or a fake's, and the fake. */
907
910
  export interface FeatureTarget {
908
911
  driver: Driver;
909
912
  /** The fake whose driver it is: a scenario's faults need one. */
910
913
  fake?: Fake<any, any>;
914
+ /**
915
+ * The driver calls the vendor: a seed's `afterCreate` waits between its calls as apply's do,
916
+ * and a step may take `FEATURE_STEP_MS`. Replayed or on a fake, the calls answer at once.
917
+ */
918
+ live?: boolean;
911
919
  }
912
920
 
913
921
  /** Why no step, or two, match a step's text. */
@@ -1030,12 +1038,13 @@ function checkWorld(built: Built, workflow: string, view: RunView, verdicts: Map
1030
1038
  * A World for an operation's own test: `Given` steps seed through the target, `When` steps call
1031
1039
  * its operations directly (no worker, no policy), and `Then` steps fail when they do not hold.
1032
1040
  */
1033
- function testWorld(built: Built, { driver, fake }: FeatureTarget) {
1041
+ function testWorld(built: Built, { driver, fake, live }: FeatureTarget) {
1034
1042
  return (report: Report): SanomaWorld => {
1035
1043
  // What the steps named (`as`), and the calls made, as a run's ledger would hold them.
1036
1044
  const values = new Map<string, unknown>();
1037
1045
  const records: LedgerRecord[] = [];
1038
1046
  let runId = "";
1047
+ let calls = 0;
1039
1048
  // The test's own secrets, in memory: a driver resolves `secret()` markers against them.
1040
1049
  const secrets = memorySecrets();
1041
1050
  /** A record of the test, as a run's ledger would hold it. */
@@ -1051,7 +1060,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
1051
1060
  const input = op.input.parse(resolve(given, values));
1052
1061
  const fn = driver.ops[`${op.resource}.${op.name}`];
1053
1062
  if (!fn) throw new Error(`the driver under test does not implement ${id}`);
1054
- const context = { idempotencyKey: `${runId}:${records.length}`, runId, opId: id, attempt: 1, secrets };
1063
+ // A key of its own per call: a fake answers a repeated key with its remembered reply.
1064
+ const context = { idempotencyKey: `${runId}:${++calls}`, runId, opId: id, attempt: 1, secrets };
1055
1065
  return { op, input, output: () => fn(input, context) };
1056
1066
  };
1057
1067
  return {
@@ -1076,6 +1086,37 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
1076
1086
  const output = await made.output();
1077
1087
  if (item.as !== undefined) values.set(item.as, output);
1078
1088
  seeds.push({ op: item.op, input: made.input, output });
1089
+ // A resource type's create seeds the object as apply makes it: the type's `afterCreate`
1090
+ // calls follow, each polled as apply polls it, the seed's name (else the type's) its export.
1091
+ const type =
1092
+ made.op.name === "create" ? built.scope.types.get(`${made.op.vendor}.${made.op.resource}`) : undefined;
1093
+ const id = (output as { id?: unknown } | undefined)?.id;
1094
+ if (type?.afterCreate?.length && typeof id !== "string") {
1095
+ throw new Error(`"${text}": ${item.op} returned no id for its afterCreate`);
1096
+ }
1097
+ for (const hook of type?.afterCreate ?? []) {
1098
+ // One the target cannot call fails at once: polled, its error would read as not passing yet.
1099
+ const polled = built.scope.ops.get(hook.op);
1100
+ if (!polled || !driver.ops[`${polled.resource}.${polled.name}`]) {
1101
+ throw new Error(
1102
+ `"${text}": the driver under test does not implement ${hook.op}, an afterCreate of ${item.op}`,
1103
+ );
1104
+ }
1105
+ const stuck = await callAfterCreate(
1106
+ hook,
1107
+ { id: id as string, export: item.as ?? made.op.resource },
1108
+ async (op, input) => {
1109
+ const after = call(op, input);
1110
+ const out = await after.output();
1111
+ seeds.push({ op, input: after.input, output: out });
1112
+ return out;
1113
+ },
1114
+ async (seconds) => {
1115
+ if (live) await delay(seconds * 1000);
1116
+ },
1117
+ );
1118
+ if (stuck !== undefined) throw new Error(`"${text}": ${stuck}`);
1119
+ }
1079
1120
  }
1080
1121
  if (seeds.length) record({ type: "scenario.seeded", scenario: "", seeds });
1081
1122
  return;
@@ -1442,6 +1483,7 @@ export async function testFeature(scope: Scope, file: string, line: number, targ
1442
1483
  files: [name],
1443
1484
  line,
1444
1485
  isBug,
1486
+ timeoutMs: target.live ? FEATURE_STEP_MS : undefined,
1445
1487
  });
1446
1488
  if (report.parseErrors.length) throw new Error(report.parseErrors.map(parseErrorOf).join("\n"));
1447
1489
  const results = stepResults(report);
package/src/scenarios.ts CHANGED
@@ -5,6 +5,7 @@ import { type LedgerRecord, memoryLedger } from "./ledger.ts";
5
5
  import { type Check, check, drive, loadScenarios, type Scenario, type Scope } from "./scenario.ts";
6
6
  import { errorMessage } from "./shared.ts";
7
7
  import { startWorker } from "./worker.ts";
8
+ import { memorySecretStore } from "./secret-store.ts";
8
9
 
9
10
  /*
10
11
  * `@sanoma-ai/workflows/scenarios`: runs a config's scenarios as sandbox runs, with no test
@@ -77,7 +78,12 @@ export async function runScenarios(
77
78
  config: SanomaConfig,
78
79
  { databaseUrl, startedBy, timeoutMs, log }: RunScenariosOptions,
79
80
  ): Promise<{ passed: string[]; failed: ScenarioFailure[] }> {
80
- const shared: SanomaConfig = { ...config, databaseUrl, ledger: memoryLedger() };
81
+ const shared: SanomaConfig = {
82
+ ...config,
83
+ databaseUrl,
84
+ ledger: memoryLedger(),
85
+ secrets: { store: memorySecretStore(), namespace: "scenarios" },
86
+ };
81
87
  const resolved = resolveConfig(shared);
82
88
  const { scenarios, errors } = await loadScenarios(resolved);
83
89
  const failed: ScenarioFailure[] = errors.map(({ file, message }) => ({
package/src/schema.ts CHANGED
@@ -104,6 +104,16 @@ function buildSchema(
104
104
  memberOf: [{ id: `effect:${op.effect}` }],
105
105
  appliesTo: applies(op.id === CHANGE_MERGE ? CHANGE : "Target", {
106
106
  ...input(op.input),
107
+ connection: optional(
108
+ record({
109
+ id: STRING,
110
+ vendor: STRING,
111
+ method: STRING,
112
+ account: STRING,
113
+ revision: STRING,
114
+ scopes: { type: "Set", element: STRING },
115
+ }),
116
+ ),
107
117
  ...(before && { before: optional(before) }),
108
118
  }),
109
119
  };
@@ -0,0 +1,93 @@
1
+ import { SecretAccess, SecretStoreError, type SecretStore } from "./secret-store.ts";
2
+
3
+ const assert = (condition: unknown) => {
4
+ if (!condition) throw new Error("Conformance failure");
5
+ };
6
+
7
+ const rejects = async (run: () => Promise<unknown>, code: string) => {
8
+ try {
9
+ await run();
10
+ } catch (error) {
11
+ assert(error instanceof SecretStoreError && error.code === code);
12
+ return;
13
+ }
14
+ throw new Error("Expected refusal");
15
+ };
16
+
17
+ export interface SecretStoreCheck {
18
+ name: string;
19
+ ok: boolean;
20
+ }
21
+
22
+ /**
23
+ * Destructive conformance checks on a fresh, disposable namespace. Never use a production
24
+ * namespace. The caller supplies a store scoped to test credentials and cleans up retained
25
+ * versions/namespaces according to the provider's retention rules. No result contains values.
26
+ */
27
+ export async function checkSecretStore(store: SecretStore, namespace: string): Promise<SecretStoreCheck[]> {
28
+ const access = new SecretAccess({ store, namespace }, store);
29
+ const other = new SecretAccess({ store, namespace: `${namespace}-other` }, store);
30
+ const results: SecretStoreCheck[] = [];
31
+ const check = async (name: string, run: () => Promise<void>) => {
32
+ try {
33
+ await run();
34
+ results.push({ name, ok: true });
35
+ } catch {
36
+ results.push({ name, ok: false });
37
+ }
38
+ };
39
+ await check("validate and missing read", async () => {
40
+ await access.validate();
41
+ assert((await access.read("ABSENT")) === undefined);
42
+ });
43
+ await check("external ownership", async () => {
44
+ const external = new SecretAccess(
45
+ { store, namespace, bindings: { EXISTING: { path: "existing", management: "external" } } },
46
+ store,
47
+ );
48
+ await rejects(() => external.write("EXISTING", "value", { expectedVersion: null, by: "check" }), "read_only");
49
+ await rejects(() => external.delete("EXISTING", { expectedVersion: "v1", by: "check" }), "read_only");
50
+ });
51
+ if (!store.capabilities.writable) {
52
+ await check("read-only provider", () =>
53
+ rejects(() => access.write("VALUE", "value", { expectedVersion: null, by: "check" }), "read_only"),
54
+ );
55
+ return results;
56
+ }
57
+ if (store.capabilities.conditionalWrites === "none") {
58
+ await check("unsupported conditional writes", () =>
59
+ rejects(() => access.ensure("VALUE", () => "value"), "unsupported"),
60
+ );
61
+ return results;
62
+ }
63
+ await check("concurrent create and namespace separation", async () => {
64
+ const values = await Promise.all(Array.from({ length: 8 }, (_, i) => access.ensure("RACE", () => String(i))));
65
+ assert(new Set(values).size === 1);
66
+ assert((await other.get("RACE")) === undefined);
67
+ });
68
+ await check("replace rejects stale versions", async () => {
69
+ const one = await access.write("REPLACE", "one", { expectedVersion: null, by: "check" });
70
+ const two = await access.write("REPLACE", "two", { expectedVersion: one.version, by: "check" });
71
+ assert(one.version !== two.version);
72
+ await rejects(() => access.write("REPLACE", "stale", { expectedVersion: one.version, by: "check" }), "conflict");
73
+ assert((await access.get("REPLACE")) === "two");
74
+ });
75
+ await check("UTF-8 size rejection", () =>
76
+ rejects(
77
+ () =>
78
+ access.write("OVERSIZE", "x".repeat(store.capabilities.maxBytes + 1), { expectedVersion: null, by: "check" }),
79
+ "too_large",
80
+ ),
81
+ );
82
+ await check("deletion semantics", async () => {
83
+ const one = await access.write("DELETE", "one", { expectedVersion: null, by: "check" });
84
+ if (store.capabilities.deletion === "unsupported") {
85
+ await rejects(() => access.delete("DELETE", { expectedVersion: one.version, by: "check" }), "unsupported");
86
+ return;
87
+ }
88
+ await rejects(() => access.delete("DELETE", { expectedVersion: "incorrect", by: "check" }), "conflict");
89
+ await access.delete("DELETE", { expectedVersion: one.version, by: "check" });
90
+ assert((await access.read("DELETE")) === undefined);
91
+ });
92
+ return results;
93
+ }
@@ -0,0 +1,314 @@
1
+ import { deploymentKey } from "./deployment-key.ts";
2
+ /** Opaque values belong only in trusted runtime code, never workflow history or descriptions. */
3
+ export interface SecretValue {
4
+ value: string;
5
+ version: string;
6
+ updatedBy?: string;
7
+ updatedAt?: string;
8
+ }
9
+
10
+ /** A store instance already authenticates as one deployment. A namespace is not tenant authorization. */
11
+ export interface SecretAddress {
12
+ namespace: string;
13
+ path: string;
14
+ }
15
+
16
+ export interface SecretStoreCapabilities {
17
+ writable: boolean;
18
+ /** UTF-8 bytes, including any serialized credential bundle. */
19
+ maxBytes: number;
20
+ /** `coordinated` covers cooperating Sanoma writers only; `none` cannot ensure or replace safely. */
21
+ conditionalWrites: "atomic" | "coordinated" | "none";
22
+ deletion: "destroy" | "soft-delete" | "unsupported";
23
+ changeDetection: "read" | "watch";
24
+ }
25
+
26
+ export type SecretStoreErrorCode =
27
+ | "invalid_configuration"
28
+ | "unavailable"
29
+ | "denied"
30
+ | "conflict"
31
+ | "read_only"
32
+ | "unsupported"
33
+ | "too_large"
34
+ | "invalid_value";
35
+
36
+ /** Intentionally carries no provider message, cause, path or value. */
37
+ export class SecretStoreError extends Error {
38
+ readonly code: SecretStoreErrorCode;
39
+ readonly retryable: boolean;
40
+ constructor(code: SecretStoreErrorCode) {
41
+ super(`Secret store: ${code}`);
42
+ this.name = "SecretStoreError";
43
+ this.code = code;
44
+ this.retryable = code === "unavailable";
45
+ }
46
+ }
47
+
48
+ export interface SecretStore {
49
+ /** Non-secret adapter identifier, e.g. `postgres` or `aws-secrets-manager`. */
50
+ readonly id: string;
51
+ readonly capabilities: Readonly<SecretStoreCapabilities>;
52
+ /** Check scoped access without listing an account or returning secret material. */
53
+ validate(namespace: string): Promise<void>;
54
+ read(address: SecretAddress): Promise<SecretValue | undefined>;
55
+ /** null means create only. A string means replace only that exact current version. */
56
+ write(
57
+ address: SecretAddress,
58
+ value: string,
59
+ options: { expectedVersion: string | null; by: string },
60
+ ): Promise<SecretValue>;
61
+ /** Delete only the exact version; report the retention behavior advertised in capabilities. */
62
+ delete(address: SecretAddress, options: { expectedVersion: string; by: string }): Promise<void>;
63
+ }
64
+
65
+ export type SecretBinding =
66
+ | { path: string; management: "sanoma" | "external"; env?: never; bootstrap?: never }
67
+ | { env: string; management?: "external"; path?: never; bootstrap?: never }
68
+ | { bootstrap: "SANOMA_SIGNING_KEY" | "SANOMA_SECRETS_KEY"; management?: "external"; env?: never; path?: never };
69
+
70
+ export interface SecretConfiguration {
71
+ /** Omitted: built-in encrypted Postgres, supplied by the runtime. */
72
+ store?: SecretStore;
73
+ /** Defaults to `default`. Choose a distinct namespace and identity for each deployment. */
74
+ namespace?: string;
75
+ /** Names without a binding resolve to the same name in this namespace. */
76
+ bindings?: Readonly<Record<string, SecretBinding>>;
77
+ }
78
+
79
+ export interface SecretLocation {
80
+ store: string;
81
+ namespace: string;
82
+ path: string;
83
+ management: "sanoma" | "external";
84
+ }
85
+
86
+ const errorCodes = new Set<string>([
87
+ "invalid_configuration",
88
+ "unavailable",
89
+ "denied",
90
+ "conflict",
91
+ "read_only",
92
+ "unsupported",
93
+ "too_large",
94
+ "invalid_value",
95
+ ]);
96
+ const storeError = (error: unknown): error is SecretStoreError =>
97
+ typeof error === "object" &&
98
+ error !== null &&
99
+ (error as SecretStoreError).name === "SecretStoreError" &&
100
+ errorCodes.has((error as SecretStoreError).code);
101
+
102
+ /** Provider failures are sanitized even when an SDK throws a credential-bearing response. */
103
+ export async function secretStoreCall<T>(run: () => Promise<T>): Promise<T> {
104
+ try {
105
+ return await run();
106
+ } catch (error) {
107
+ if (storeError(error)) throw new SecretStoreError(error.code);
108
+ throw new SecretStoreError("unavailable");
109
+ }
110
+ }
111
+
112
+ const namePattern = /^[A-Z][A-Z0-9_]*$/;
113
+ const environmentPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
114
+ const nonempty = (value: unknown): value is string =>
115
+ typeof value === "string" && value.trim().length > 0 && !value.includes("\0");
116
+
117
+ /** Structural validation only: no secret reads, provider calls or environment access. */
118
+ const invalid = () => {
119
+ throw new SecretStoreError("invalid_configuration");
120
+ };
121
+ export function checkSecretConfiguration(config: SecretConfiguration): void {
122
+ if (!config || typeof config !== "object" || Array.isArray(config)) invalid();
123
+ if (config.namespace !== undefined && !nonempty(config.namespace)) invalid();
124
+ if (config.store !== undefined) {
125
+ const s = config.store;
126
+ const c = s?.capabilities;
127
+ if (
128
+ !nonempty(s?.id) ||
129
+ !/^[a-z][a-z0-9-]{0,63}$/.test(s.id) ||
130
+ !c ||
131
+ typeof c.writable !== "boolean" ||
132
+ !Number.isSafeInteger(c.maxBytes) ||
133
+ c.maxBytes < 1 ||
134
+ !["atomic", "coordinated", "none"].includes(c.conditionalWrites) ||
135
+ !["destroy", "soft-delete", "unsupported"].includes(c.deletion) ||
136
+ !["read", "watch"].includes(c.changeDetection) ||
137
+ [s.validate, s.read, s.write, s.delete].some((fn) => typeof fn !== "function")
138
+ )
139
+ invalid();
140
+ }
141
+ if (
142
+ config.bindings !== undefined &&
143
+ (!config.bindings || typeof config.bindings !== "object" || Array.isArray(config.bindings))
144
+ )
145
+ invalid();
146
+ for (const [name, b] of Object.entries(config.bindings ?? {})) {
147
+ if (!namePattern.test(name) || !b || typeof b !== "object") invalid();
148
+ if (["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"].includes(name) && !("env" in b) && !("bootstrap" in b)) invalid();
149
+ if ("bootstrap" in b) {
150
+ if (
151
+ !["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"].includes(b.bootstrap!) ||
152
+ b.path !== undefined ||
153
+ b.env !== undefined ||
154
+ (b.management !== undefined && b.management !== "external")
155
+ )
156
+ invalid();
157
+ } else if ("env" in b) {
158
+ if (
159
+ typeof b.env !== "string" ||
160
+ !environmentPattern.test(b.env) ||
161
+ b.path !== undefined ||
162
+ (b.management !== undefined && b.management !== "external")
163
+ )
164
+ invalid();
165
+ } else if (!nonempty(b.path) || !["sanoma", "external"].includes(b.management)) invalid();
166
+ }
167
+ }
168
+
169
+ /** Enforces binding ownership before calling a provider; preserves the drivers' get/ensure interface. */
170
+ export class SecretAccess {
171
+ readonly store: SecretStore;
172
+ readonly namespace: string;
173
+ private readonly bindings: Readonly<Record<string, SecretBinding>>;
174
+
175
+ private readonly env: (name: string) => string | undefined;
176
+
177
+ constructor(
178
+ config: SecretConfiguration,
179
+ defaultStore: SecretStore,
180
+ env: (name: string) => string | undefined = (name) => process.env[name],
181
+ ) {
182
+ this.env = env;
183
+ checkSecretConfiguration(config);
184
+ this.store = config.store ?? defaultStore;
185
+ checkSecretConfiguration({ store: this.store });
186
+ this.namespace = config.namespace ?? "default";
187
+ this.bindings = Object.fromEntries(
188
+ Object.entries(config.bindings ?? {}).map(([name, binding]) => [name, { ...binding }]),
189
+ );
190
+ }
191
+
192
+ location(name: string): SecretLocation {
193
+ if (!namePattern.test(name)) throw new SecretStoreError("invalid_configuration");
194
+ const binding = Object.hasOwn(this.bindings, name) ? this.bindings[name] : undefined;
195
+ if (binding?.bootstrap !== undefined)
196
+ return { store: "bootstrap", namespace: "", path: binding.bootstrap, management: "external" };
197
+ if (["SANOMA_SIGNING_KEY", "SANOMA_SECRETS_KEY"].includes(name) && !binding)
198
+ throw new SecretStoreError("invalid_configuration");
199
+ if (binding?.env !== undefined)
200
+ return { store: "environment", namespace: "", path: binding.env, management: "external" };
201
+ return {
202
+ store: this.store.id,
203
+ namespace: this.namespace,
204
+ path: binding?.path ?? name,
205
+ management: binding?.management ?? "sanoma",
206
+ };
207
+ }
208
+
209
+ async validate(): Promise<void> {
210
+ await secretStoreCall(() => this.store.validate(this.namespace));
211
+ }
212
+
213
+ async read(name: string): Promise<SecretValue | undefined> {
214
+ const location = this.location(name);
215
+ if (location.store === "bootstrap" && location.namespace === "") {
216
+ const value = deploymentKey(location.path as "SANOMA_SIGNING_KEY" | "SANOMA_SECRETS_KEY").toString("base64");
217
+ return { value, version: "bootstrap" };
218
+ }
219
+ if (location.store === "environment" && location.namespace === "") {
220
+ const value = this.env(location.path);
221
+ // No content-derived version (which would expose a hash of a low-entropy password).
222
+ return value === undefined ? undefined : { value, version: "environment" };
223
+ }
224
+ return secretStoreCall(() => this.store.read(location));
225
+ }
226
+
227
+ async get(name: string): Promise<string | undefined> {
228
+ return (await this.read(name))?.value;
229
+ }
230
+
231
+ async write(
232
+ name: string,
233
+ value: string,
234
+ options: { expectedVersion: string | null; by: string },
235
+ ): Promise<SecretValue> {
236
+ const location = this.writable(name);
237
+ if (Buffer.byteLength(value, "utf8") > this.store.capabilities.maxBytes) throw new SecretStoreError("too_large");
238
+ return secretStoreCall(() => this.store.write(location, value, options));
239
+ }
240
+
241
+ async delete(name: string, options: { expectedVersion: string; by: string }): Promise<void> {
242
+ const location = this.writable(name);
243
+ if (this.store.capabilities.deletion === "unsupported") throw new SecretStoreError("unsupported");
244
+ await secretStoreCall(() => this.store.delete(location, options));
245
+ }
246
+
247
+ async ensure(name: string, make: () => string): Promise<string> {
248
+ const stored = await this.read(name);
249
+ if (stored) return stored.value;
250
+ this.writable(name);
251
+ try {
252
+ return (await this.write(name, make(), { expectedVersion: null, by: "sanoma" })).value;
253
+ } catch (error) {
254
+ if (!storeError(error) || error.code !== "conflict") throw error;
255
+ const winner = await this.read(name);
256
+ if (winner) return winner.value;
257
+ throw error;
258
+ }
259
+ }
260
+
261
+ private writable(name: string): SecretLocation {
262
+ const location = this.location(name);
263
+ if (location.management === "external" || !this.store.capabilities.writable)
264
+ throw new SecretStoreError("read_only");
265
+ if (this.store.capabilities.conditionalWrites === "none") throw new SecretStoreError("unsupported");
266
+ return location;
267
+ }
268
+ }
269
+
270
+ /** Reference provider for tests. `atomic` is scoped to this shared in-process instance only. */
271
+ const key = (a: SecretAddress) => JSON.stringify([a.namespace, a.path]);
272
+ export function memorySecretStore(
273
+ options: { maxBytes?: number; writable?: boolean; deletion?: "destroy" | "soft-delete" } = {},
274
+ ): SecretStore {
275
+ const values = new Map<string, SecretValue>();
276
+ const retained = new Map<string, SecretValue>();
277
+ let generation = 0;
278
+ const capabilities: SecretStoreCapabilities = {
279
+ writable: options.writable ?? true,
280
+ maxBytes: options.maxBytes ?? 65_536,
281
+ conditionalWrites: "atomic",
282
+ deletion: options.deletion ?? "destroy",
283
+ changeDetection: "read",
284
+ };
285
+ const mutable = () => {
286
+ if (!capabilities.writable) throw new SecretStoreError("read_only");
287
+ };
288
+ return {
289
+ id: "memory",
290
+ capabilities: Object.freeze(capabilities),
291
+ async validate(namespace) {
292
+ if (!nonempty(namespace)) throw new SecretStoreError("invalid_configuration");
293
+ },
294
+ async read(address) {
295
+ const value = values.get(key(address));
296
+ return value && { ...value };
297
+ },
298
+ async write(address, value, { expectedVersion, by }) {
299
+ mutable();
300
+ if (Buffer.byteLength(value, "utf8") > capabilities.maxBytes) throw new SecretStoreError("too_large");
301
+ const current = values.get(key(address));
302
+ if ((current?.version ?? null) !== expectedVersion) throw new SecretStoreError("conflict");
303
+ const stored = { value, version: String(++generation), updatedBy: by, updatedAt: new Date().toISOString() };
304
+ values.set(key(address), stored);
305
+ return { ...stored };
306
+ },
307
+ async delete(address, { expectedVersion }) {
308
+ mutable();
309
+ if (values.get(key(address))?.version !== expectedVersion) throw new SecretStoreError("conflict");
310
+ if (capabilities.deletion === "soft-delete") retained.set(expectedVersion, values.get(key(address))!);
311
+ values.delete(key(address));
312
+ },
313
+ };
314
+ }
package/src/shared.ts CHANGED
@@ -5,7 +5,7 @@ import type { OpEntry, WorkflowEntry } from "./describe.ts";
5
5
  import type { LedgerRecord } from "./ledger.ts";
6
6
  import type { Effect } from "./op.ts";
7
7
  import type { PreviewRequest } from "./policy.ts";
8
- import type { DeclaredResource, ResourceProblem } from "./resources.ts";
8
+ import type { DeclaredResource, ResourceProblem } from "./description.ts";
9
9
  import type { OutlineNode } from "./outline.ts";
10
10
 
11
11
  // Types only: resource types as a UI reads them. Erased from the bundle.
@@ -18,7 +18,7 @@ export type {
18
18
  ResourceFields,
19
19
  ResourceSpec,
20
20
  } from "./resource.ts";
21
- export type { DeclaredResource, ResourceProblem } from "./resources.ts";
21
+ export type { At, DeclaredResource, ResourceProblem } from "./description.ts";
22
22
  export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
23
23
  export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
24
24
  export type { CredentialStatus } from "./env.ts";
@@ -31,6 +31,8 @@ export type { AvailableApp, AvailableApps } from "./apps-list.ts";
31
31
 
32
32
  // Sanoma's claims, data (`sanoma/claims` re-exports them).
33
33
  export * from "./claims.ts";
34
+ // A site's `site.ts` declares itself with it (`sanoma/site` calls it, typing its icon).
35
+ export { defineSite, type SiteDefinition } from "./definitions.ts";
34
36
 
35
37
  /**
36
38
  * What a `ctx.sleep` without a `{ name }` is called, in its outline node and its ledger record
@@ -490,17 +492,40 @@ export function flatten(nodes: readonly OutlineNode[]): OutlineNode[] {
490
492
  return nodes.flatMap((node) => [node, ...flatten(childLists(node).flat())]);
491
493
  }
492
494
 
493
- /** Where a data-file problem is, `file:line:column`; undefined for one that is the config's, with no file. */
494
- export const problemAt = (p: ResourceProblem): string | undefined =>
495
- p.file === undefined ? undefined : `${p.file}:${p.line}:${p.column}`;
495
+ /**
496
+ * Where a problem is, `file:line:column`, `file#export` or `file`; undefined for one that is the
497
+ * config's, with no file.
498
+ */
499
+ export const problemAt = (p: ResourceProblem): string | undefined => {
500
+ if (p.file === undefined) return undefined;
501
+ if (p.line !== undefined) return `${p.file}:${p.line}:${p.column ?? 1}`;
502
+ return p.export === undefined ? p.file : `${p.file}#${p.export}`;
503
+ };
504
+
505
+ /**
506
+ * A problem as one line: `file:line:column: message`, `file#export: message` or `file: message`
507
+ * without a position, or the message alone without a file.
508
+ */
509
+ export function problemLine(p: ResourceProblem): string {
510
+ const at = problemAt(p);
511
+ return at === undefined ? p.message : `${at}: ${p.message}`;
512
+ }
496
513
 
497
514
  /**
498
- * A problem as one line: `file:line:column: message`, or `file: message` without a position, or
499
- * the message alone without a file.
515
+ * Where the export `name` (`default` for the default export) is in a file's text, as UTF-16
516
+ * offsets: from its `export` to the next `export`, `import`, `const` or comment at the start of a
517
+ * line, or the end, less trailing blank space. Read from the text alone, for highlighting it; undefined
518
+ * when no line starts it.
500
519
  */
501
- export function problemLine(p: { file?: string; line?: number; column?: number; message: string }): string {
502
- if (p.file === undefined) return p.message;
503
- return p.line === undefined ? `${p.file}: ${p.message}` : `${p.file}:${p.line}:${p.column ?? 1}: ${p.message}`;
520
+ export function exportSpan(text: string, name: string): [number, number] | undefined {
521
+ const declared = name === "default" ? "default" : `const\\s+${name.replaceAll("$", "\\$")}`;
522
+ const start = new RegExp(`^export\\s+${declared}(?![\\w$])`, "m").exec(text);
523
+ if (!start) return undefined;
524
+ const after = start.index + start[0].length;
525
+ // A comment at the start of a line is the next declaration's.
526
+ const next = /^(?:(?:export|import|const)\b|\/[/*])/m.exec(text.slice(after));
527
+ const end = next ? after + next.index : text.length;
528
+ return [start.index, start.index + text.slice(start.index, end).trimEnd().length];
504
529
  }
505
530
 
506
531
  /** A warning as one line, as `problemLine` with `warning: ` before its message: what is worth fixing but not wrong. */