@sanoma-ai/workflows 0.9.1 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -50
- package/SECRET-SUPPORT.md +33 -0
- 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 +13 -24
- package/dist/apps.js +69 -68
- 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 +54 -0
- package/dist/authentication.js +138 -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 +36 -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 +62 -10
- package/dist/client.js +272 -47
- 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 +16 -4
- package/dist/config.js +89 -21
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +389 -0
- package/dist/connection.js +657 -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 +34 -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 +15 -175
- package/dist/describe.js +122 -35
- 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 +7 -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/fake.d.ts +13 -1
- package/dist/fake.js +70 -0
- package/dist/fake.js.map +1 -1
- package/dist/index.d.ts +11 -3
- 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 +2 -17
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +85 -0
- package/dist/load.js +391 -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 +22 -5
- package/dist/op.js +20 -2
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +5 -0
- package/dist/outline.js +46 -5
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +18 -2
- package/dist/paths.js +19 -7
- 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 +4 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +5 -2
- package/dist/sandbox.js +56 -7
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +5 -0
- package/dist/scenario.js +3 -2
- package/dist/scenario.js.map +1 -1
- package/dist/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/secret.d.ts +2 -0
- package/dist/secret.js +32 -0
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +21 -11
- package/dist/shared.js +41 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +4 -93
- package/dist/sites.js +76 -84
- package/dist/sites.js.map +1 -1
- package/dist/steps.d.ts +3 -0
- package/dist/steps.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 +1 -5
- package/package.json +6 -1
- package/src/apply-steps.ts +5 -2
- package/src/apply.ts +54 -21
- package/src/approvals.ts +7 -1
- package/src/apps.ts +71 -86
- package/src/ast.ts +6 -6
- package/src/authentication.ts +191 -0
- package/src/call-workflow.ts +5 -4
- package/src/call.ts +52 -9
- package/src/cedar.ts +14 -1
- package/src/change-steps.ts +7 -4
- package/src/change.ts +10 -0
- package/src/client.ts +346 -45
- package/src/collection.ts +10 -2
- package/src/config.ts +107 -19
- package/src/connection.ts +912 -0
- package/src/credentials.ts +94 -281
- package/src/db.ts +34 -2
- package/src/define.ts +7 -2
- package/src/definitions.ts +31 -0
- package/src/deployment-key.ts +23 -0
- package/src/describe.ts +150 -227
- package/src/description.ts +426 -0
- package/src/direct.ts +7 -0
- package/src/drift.ts +10 -2
- package/src/env.ts +4 -2
- package/src/fake.ts +71 -0
- package/src/index.ts +53 -1
- package/src/ledger.ts +3 -1
- package/src/lint.ts +2 -17
- package/src/load.ts +466 -0
- package/src/lockfile.ts +10 -3
- package/src/op.ts +41 -7
- package/src/outline.ts +53 -7
- package/src/paths.ts +21 -6
- 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 +4 -0
- package/src/sandbox.ts +61 -7
- package/src/scenario.ts +24 -6
- 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/secret.ts +29 -0
- package/src/shared.ts +44 -10
- package/src/sites.ts +85 -179
- package/src/steps.ts +3 -2
- 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 { ConnectionRef, ConnectionRegistry, ConnectionSnapshot } 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. */
|
|
@@ -96,6 +98,8 @@ export interface Run {
|
|
|
96
98
|
fakeDrivers?: Map<string, DriverFn>;
|
|
97
99
|
/** A sandbox run's own secrets, in memory, from `seedSandbox`; unset for a live run. */
|
|
98
100
|
fakeSecrets?: Secrets;
|
|
101
|
+
/** Synthetic, run-local identities; this resolver never reads production connection records. */
|
|
102
|
+
fakeConnection?: (vendor: string, ref?: ConnectionRef) => ConnectionSnapshot | undefined;
|
|
99
103
|
approvals: ApprovalState[];
|
|
100
104
|
/** The next ledger `seq`. Advanced only outside steps, so a replay counts the same way. */
|
|
101
105
|
seq: number;
|
package/src/sandbox.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
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
|
-
import type
|
|
6
|
+
import { connectionFakes, type Fake, type ResourceFake } from "./fake.ts";
|
|
7
|
+
import type { ConnectionCatalog, ConnectionRef, ConnectionSnapshot } from "./connection.ts";
|
|
6
8
|
import { entry, type LedgerRecord, write } from "./ledger.ts";
|
|
7
9
|
import type { DriverFn } from "./op.ts";
|
|
8
10
|
import type { Run } from "./run.ts";
|
|
@@ -19,6 +21,30 @@ import { errorMessage } from "./shared.ts";
|
|
|
19
21
|
const holdsResources = (fake: Fake<unknown>): fake is Fake<unknown> & ResourceFake =>
|
|
20
22
|
"override" in fake && "put" in fake && "remove" in fake;
|
|
21
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
|
+
|
|
22
48
|
/**
|
|
23
49
|
* The error a sandbox run of the scenario fails with when it seeds a resource of a vendor whose
|
|
24
50
|
* fake (one of `fakes`, by vendor) holds none, or whose connector attaches no fake; undefined
|
|
@@ -88,7 +114,7 @@ export function seedResource(
|
|
|
88
114
|
* run whose seeding is replayed from it has none, and fails, since a sandbox run does not
|
|
89
115
|
* survive a worker restart.
|
|
90
116
|
*/
|
|
91
|
-
export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
117
|
+
export async function seedSandbox(run: Run, name: string, input: unknown): Promise<void> {
|
|
92
118
|
const { state } = run;
|
|
93
119
|
let copies: Map<string, DriverFn> | undefined;
|
|
94
120
|
// The run's secrets, in memory as its fakes are: seeding and its calls share them.
|
|
@@ -109,12 +135,23 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
109
135
|
});
|
|
110
136
|
}
|
|
111
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;
|
|
112
145
|
// The built-ins first, so a connector's fake that implements one of their operations wins.
|
|
113
146
|
const drivers = indexDrivers(
|
|
114
|
-
[...sandboxDrivers(state.ops), ...Array.from(fakes
|
|
147
|
+
[...sandboxDrivers(state.ops), ...Array.from(fakes, ([vendor, f]) => named.get(vendor)?.driver ?? f.driver)],
|
|
115
148
|
state.ops,
|
|
149
|
+
{ sandbox: true },
|
|
116
150
|
);
|
|
117
|
-
|
|
151
|
+
// The built-in call workflow selects exactly one operation. Other direct operations
|
|
152
|
+
// (including live connection mutations) need no fake unless this scenario calls them.
|
|
153
|
+
const uses =
|
|
154
|
+
run.workflow === CALL_WORKFLOW ? [CallInput.parse(input).op] : opIdsOf(state.workflows.get(run.workflow)!);
|
|
118
155
|
const seeding = scenario.given.flatMap((g) => ("op" in g ? [g.op] : []));
|
|
119
156
|
const missing = [...new Set([...uses, ...seeding])].filter((id) => !drivers.has(id));
|
|
120
157
|
if (missing.length) {
|
|
@@ -130,16 +167,33 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
130
167
|
for (const [i, given] of scenario.given.entries()) {
|
|
131
168
|
if ("fault" in given) continue;
|
|
132
169
|
if ("op" in given) {
|
|
133
|
-
const
|
|
170
|
+
const connection = select(state.ops.get(given.op)!.vendor, given.connection);
|
|
171
|
+
const call = {
|
|
172
|
+
idempotencyKey: `${run.id}:seed:${i}`,
|
|
173
|
+
runId: run.id,
|
|
174
|
+
opId: given.op,
|
|
175
|
+
attempt: 1,
|
|
176
|
+
secrets,
|
|
177
|
+
connection,
|
|
178
|
+
};
|
|
134
179
|
const output = await drivers.get(given.op)!(given.input, call);
|
|
135
180
|
seeded.push({ op: given.op, input: given.input, output });
|
|
136
181
|
continue;
|
|
137
182
|
}
|
|
138
183
|
// Checked above: the vendor's fake holds resources.
|
|
139
|
-
|
|
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));
|
|
140
189
|
}
|
|
141
190
|
for (const given of scenario.given) {
|
|
142
|
-
if ("fault" in given)
|
|
191
|
+
if ("fault" in given) {
|
|
192
|
+
const vendor = state.ops.get(given.op)!.vendor;
|
|
193
|
+
const selected = select(vendor, given.connection);
|
|
194
|
+
const fake = selected ? named.get(vendor)!.account({ vendor, $connection: selected.id }) : fakes.get(vendor)!;
|
|
195
|
+
fake[given.fault](given.op);
|
|
196
|
+
}
|
|
143
197
|
}
|
|
144
198
|
copies = drivers;
|
|
145
199
|
return { seeds: seeded };
|
package/src/scenario.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { errorCode, errorInfo, SanomaError } from "./errors.ts";
|
|
30
30
|
import { type CallRecord, contains, type CoreExpectation, detailOf, isCall } from "./expectations.ts";
|
|
31
31
|
import type { Fake } from "./fake.ts";
|
|
32
|
+
import type { ConnectionRef } from "./connection.ts";
|
|
32
33
|
import { fill, isBug, objectUnder, restOf, seedFrom, shapeOf } from "./fill.ts";
|
|
33
34
|
import type { LedgerRecord } from "./ledger.ts";
|
|
34
35
|
import type { Driver, Op } from "./op.ts";
|
|
@@ -92,10 +93,26 @@ export interface ScenarioStep {
|
|
|
92
93
|
* its import id, with `fields` to set on it (put, when the vendor has none) or `gone` to remove it.
|
|
93
94
|
*/
|
|
94
95
|
export type Given =
|
|
95
|
-
| { step: string; op: string; input: unknown; as?: string }
|
|
96
|
-
| { step: string; op: string; fault: Fault }
|
|
97
|
-
| {
|
|
98
|
-
|
|
96
|
+
| { step: string; op: string; input: unknown; as?: string; connection?: ConnectionRef }
|
|
97
|
+
| { step: string; op: string; fault: Fault; connection?: ConnectionRef }
|
|
98
|
+
| {
|
|
99
|
+
step: string;
|
|
100
|
+
resource: string;
|
|
101
|
+
vendor: string;
|
|
102
|
+
type: string;
|
|
103
|
+
name: string;
|
|
104
|
+
fields: Record<string, unknown>;
|
|
105
|
+
connection?: ConnectionRef;
|
|
106
|
+
}
|
|
107
|
+
| {
|
|
108
|
+
step: string;
|
|
109
|
+
resource: string;
|
|
110
|
+
vendor: string;
|
|
111
|
+
type: string;
|
|
112
|
+
name: string;
|
|
113
|
+
gone: true;
|
|
114
|
+
connection?: ConnectionRef;
|
|
115
|
+
};
|
|
99
116
|
|
|
100
117
|
/** How one approval is decided; `approval` is its title or its id. */
|
|
101
118
|
export interface Decision {
|
|
@@ -671,7 +688,8 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
671
688
|
read: (values) => {
|
|
672
689
|
const seed = def.fn(...values);
|
|
673
690
|
const op = opNamed(seed.op);
|
|
674
|
-
|
|
691
|
+
const connection = seed.connection && { connection: seed.connection };
|
|
692
|
+
if ("fault" in seed) return [{ op, fault: seed.fault, ...connection }];
|
|
675
693
|
let input: unknown;
|
|
676
694
|
try {
|
|
677
695
|
input = fill(opOf(op).input, seed.input);
|
|
@@ -681,7 +699,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
681
699
|
cause: err,
|
|
682
700
|
});
|
|
683
701
|
}
|
|
684
|
-
return [{ op, input, ...(seed.as === undefined ? {} : { as: seed.as }) }];
|
|
702
|
+
return [{ op, input, ...(seed.as === undefined ? {} : { as: seed.as }), ...connection }];
|
|
685
703
|
},
|
|
686
704
|
});
|
|
687
705
|
continue;
|
package/src/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/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
|
|