@sanoma-ai/workflows 0.9.1 → 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.
- package/README.md +167 -50
- package/dist/apply-steps.js +5 -2
- package/dist/apply-steps.js.map +1 -1
- package/dist/apply.d.ts +3 -0
- package/dist/apply.js +27 -13
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +1 -0
- package/dist/approvals.js +6 -1
- package/dist/approvals.js.map +1 -1
- package/dist/apps.d.ts +7 -24
- package/dist/apps.js +51 -67
- package/dist/apps.js.map +1 -1
- package/dist/ast.d.ts +9 -7
- package/dist/ast.js +2 -0
- package/dist/ast.js.map +1 -1
- package/dist/authentication.d.ts +24 -0
- package/dist/authentication.js +68 -0
- package/dist/authentication.js.map +1 -0
- package/dist/call-workflow.d.ts +5 -0
- package/dist/call-workflow.js +4 -3
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +34 -7
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +14 -1
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.js +7 -5
- package/dist/change-steps.js.map +1 -1
- package/dist/change.d.ts +7 -0
- package/dist/change.js +7 -0
- package/dist/change.js.map +1 -1
- package/dist/client.d.ts +10 -6
- package/dist/client.js +96 -43
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +7 -0
- package/dist/collection.js +5 -2
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.js +45 -9
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +168 -0
- package/dist/connection.js +327 -0
- package/dist/connection.js.map +1 -0
- package/dist/credentials.d.ts +7 -53
- package/dist/credentials.js +84 -247
- package/dist/credentials.js.map +1 -1
- package/dist/db.js +21 -2
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +9 -2
- package/dist/define.js.map +1 -1
- package/dist/definitions.d.ts +20 -0
- package/dist/definitions.js +16 -0
- package/dist/definitions.js.map +1 -0
- package/dist/deployment-key.d.ts +2 -0
- package/dist/deployment-key.js +22 -0
- package/dist/deployment-key.js.map +1 -0
- package/dist/describe.d.ts +9 -177
- package/dist/describe.js +71 -34
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +532 -0
- package/dist/description.js +381 -0
- package/dist/description.js.map +1 -0
- package/dist/direct.js +3 -0
- package/dist/direct.js.map +1 -1
- package/dist/drift.d.ts +1 -1
- package/dist/drift.js +1 -1
- package/dist/drift.js.map +1 -1
- package/dist/env.d.ts +2 -0
- package/dist/env.js +2 -2
- package/dist/env.js.map +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +2 -0
- package/dist/ledger.js +1 -1
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +0 -9
- package/dist/lint.js +1 -16
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +74 -0
- package/dist/load.js +357 -0
- package/dist/load.js.map +1 -0
- package/dist/lockfile.d.ts +2 -0
- package/dist/lockfile.js +8 -3
- package/dist/lockfile.js.map +1 -1
- package/dist/op.d.ts +10 -1
- package/dist/op.js +1 -1
- package/dist/op.js.map +1 -1
- package/dist/outline.js +13 -4
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +21 -3
- package/dist/paths.js.map +1 -1
- package/dist/plugin.d.ts +0 -11
- package/dist/plugin.js +3 -18
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +10 -4
- package/dist/resource.js +11 -4
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +33 -76
- package/dist/resources.js +219 -227
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +2 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +1 -1
- package/dist/sandbox.js +5 -2
- package/dist/sandbox.js.map +1 -1
- package/dist/scenarios.js +7 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.js +8 -0
- package/dist/schema.js.map +1 -1
- package/dist/secret-store-check.d.ts +11 -0
- package/dist/secret-store-check.js +76 -0
- package/dist/secret-store-check.js.map +1 -0
- package/dist/secret-store.d.ts +106 -0
- package/dist/secret-store.js +228 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/shared.d.ts +17 -11
- package/dist/shared.js +34 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +4 -93
- package/dist/sites.js +61 -84
- package/dist/sites.js.map +1 -1
- package/dist/version.d.ts +2 -1
- package/dist/version.js +2 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.js +15 -6
- package/dist/wakeup-driver.js.map +1 -1
- package/dist/worker.js +12 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +53 -14
- package/dist/writer.js +113 -43
- package/dist/writer.js.map +1 -1
- package/oxlint.json +0 -5
- package/package.json +5 -1
- package/src/apply-steps.ts +5 -2
- package/src/apply.ts +54 -21
- package/src/approvals.ts +7 -1
- package/src/apps.ts +54 -85
- package/src/ast.ts +6 -6
- package/src/authentication.ts +74 -0
- package/src/call-workflow.ts +5 -4
- package/src/call.ts +53 -9
- package/src/cedar.ts +14 -1
- package/src/change-steps.ts +7 -4
- package/src/change.ts +10 -0
- package/src/client.ts +108 -41
- package/src/collection.ts +10 -2
- package/src/config.ts +62 -8
- package/src/connection.ts +446 -0
- package/src/credentials.ts +94 -281
- package/src/db.ts +21 -2
- package/src/define.ts +7 -2
- package/src/definitions.ts +31 -0
- package/src/deployment-key.ts +23 -0
- package/src/describe.ts +102 -230
- package/src/description.ts +426 -0
- package/src/direct.ts +3 -0
- package/src/drift.ts +10 -2
- package/src/env.ts +4 -2
- package/src/index.ts +42 -1
- package/src/ledger.ts +3 -1
- package/src/lint.ts +1 -16
- package/src/load.ts +437 -0
- package/src/lockfile.ts +10 -3
- package/src/op.ts +10 -1
- package/src/outline.ts +18 -5
- package/src/paths.ts +24 -2
- package/src/plugin.ts +3 -24
- package/src/policy.ts +2 -0
- package/src/resource.ts +24 -6
- package/src/resources.ts +246 -301
- package/src/run.ts +2 -0
- package/src/sandbox.ts +6 -2
- package/src/scenarios.ts +7 -1
- package/src/schema.ts +10 -0
- package/src/secret-store-check.ts +93 -0
- package/src/secret-store.ts +314 -0
- package/src/shared.ts +35 -10
- package/src/sites.ts +70 -180
- package/src/version.ts +2 -1
- package/src/wakeup-driver.ts +22 -6
- package/src/worker.ts +12 -8
- package/src/writer.ts +145 -56
- package/dist/datafile.d.ts +0 -97
- package/dist/datafile.js +0 -479
- package/dist/datafile.js.map +0 -1
- package/src/datafile.ts +0 -601
package/src/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.
|
|
@@ -114,7 +115,10 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
114
115
|
[...sandboxDrivers(state.ops), ...Array.from(fakes.values(), (f) => f.driver)],
|
|
115
116
|
state.ops,
|
|
116
117
|
);
|
|
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)!);
|
|
118
122
|
const seeding = scenario.given.flatMap((g) => ("op" in g ? [g.op] : []));
|
|
119
123
|
const missing = [...new Set([...uses, ...seeding])].filter((id) => !drivers.has(id));
|
|
120
124
|
if (missing.length) {
|
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 = {
|
|
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 "./
|
|
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 "./
|
|
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
|
-
/**
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
*
|
|
499
|
-
* the
|
|
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
|
|
502
|
-
|
|
503
|
-
|
|
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. */
|