@samebase/alchemy-convex 0.18.0 → 0.19.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 +96 -69
- package/lib/{HubClient-BZKweNfD.js → HubClient-NUvPkolM.js} +53 -10
- package/lib/HubClient-NUvPkolM.js.map +1 -0
- package/lib/cli.js +2 -2
- package/lib/cli.js.map +1 -1
- package/lib/index.d.ts +7 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -1
- package/lib/sealedSecret-Bf8AeIp_.js +86 -0
- package/lib/sealedSecret-Bf8AeIp_.js.map +1 -0
- package/lib/sealedSecret.d.ts +37 -0
- package/lib/sealedSecret.d.ts.map +1 -0
- package/lib/sealedSecret.js +1 -0
- package/package.json +20 -19
- package/src/ManagementApi.ts +22 -1
- package/src/Project.ts +1 -1
- package/src/Push.ts +11 -7
- package/src/RunRecord.ts +1 -0
- package/src/StackComment.ts +2 -1
- package/src/State.ts +54 -1
- package/src/store/component/_generated/api.ts +2 -0
- package/src/store/component/_generated/component.ts +85 -1
- package/src/store/component/keys.ts +73 -0
- package/src/store/component/reader.ts +1 -0
- package/src/store/component/runRecord.ts +1 -0
- package/src/store/component/schema.ts +7 -0
- package/src/store/component/state.ts +86 -4
- package/src/store/convex/_generated/api.d.ts +2 -0
- package/src/store/convex/reader.ts +1 -0
- package/src/store/convex/sealing.ts +102 -0
- package/src/store/convex/state.ts +248 -22
- package/src/store/convex/version.ts +3 -1
- package/src/store/sealedSecret.ts +129 -0
- package/lib/HubClient-BZKweNfD.js.map +0 -1
|
@@ -23,6 +23,36 @@ import type { FunctionReference } from "convex/server";
|
|
|
23
23
|
*/
|
|
24
24
|
export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
25
25
|
{
|
|
26
|
+
keys: {
|
|
27
|
+
choose: FunctionReference<
|
|
28
|
+
"mutation",
|
|
29
|
+
"internal",
|
|
30
|
+
{ keyId: string },
|
|
31
|
+
string,
|
|
32
|
+
Name
|
|
33
|
+
>;
|
|
34
|
+
markSealed: FunctionReference<
|
|
35
|
+
"mutation",
|
|
36
|
+
"internal",
|
|
37
|
+
{ generation: number },
|
|
38
|
+
null,
|
|
39
|
+
Name
|
|
40
|
+
>;
|
|
41
|
+
rotate: FunctionReference<
|
|
42
|
+
"mutation",
|
|
43
|
+
"internal",
|
|
44
|
+
{ keyId: string },
|
|
45
|
+
null,
|
|
46
|
+
Name
|
|
47
|
+
>;
|
|
48
|
+
sealing: FunctionReference<
|
|
49
|
+
"query",
|
|
50
|
+
"internal",
|
|
51
|
+
{},
|
|
52
|
+
{ current: string | null; generation: number; sealed: boolean },
|
|
53
|
+
Name
|
|
54
|
+
>;
|
|
55
|
+
};
|
|
26
56
|
reader: {
|
|
27
57
|
repository: FunctionReference<
|
|
28
58
|
"query",
|
|
@@ -76,6 +106,17 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
76
106
|
Array<string>,
|
|
77
107
|
Name
|
|
78
108
|
>;
|
|
109
|
+
outputPage: FunctionReference<
|
|
110
|
+
"query",
|
|
111
|
+
"internal",
|
|
112
|
+
{ cursor: string | null },
|
|
113
|
+
{
|
|
114
|
+
continueCursor: string;
|
|
115
|
+
isDone: boolean;
|
|
116
|
+
page: Array<{ stack: string; stage: string; value: string }>;
|
|
117
|
+
},
|
|
118
|
+
Name
|
|
119
|
+
>;
|
|
79
120
|
readStack: FunctionReference<
|
|
80
121
|
"query",
|
|
81
122
|
"internal",
|
|
@@ -133,7 +174,50 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
133
174
|
"query",
|
|
134
175
|
"internal",
|
|
135
176
|
{ stack: string; stage: string },
|
|
136
|
-
Array<string>,
|
|
177
|
+
Array<{ fqn: string; value: string }>,
|
|
178
|
+
Name
|
|
179
|
+
>;
|
|
180
|
+
replaceOutput: FunctionReference<
|
|
181
|
+
"mutation",
|
|
182
|
+
"internal",
|
|
183
|
+
{
|
|
184
|
+
expected: string;
|
|
185
|
+
generation: number;
|
|
186
|
+
stack: string;
|
|
187
|
+
stage: string;
|
|
188
|
+
value: string;
|
|
189
|
+
},
|
|
190
|
+
"replaced" | "changed" | "stale",
|
|
191
|
+
Name
|
|
192
|
+
>;
|
|
193
|
+
replaceResource: FunctionReference<
|
|
194
|
+
"mutation",
|
|
195
|
+
"internal",
|
|
196
|
+
{
|
|
197
|
+
expected: string;
|
|
198
|
+
fqn: string;
|
|
199
|
+
generation: number;
|
|
200
|
+
stack: string;
|
|
201
|
+
stage: string;
|
|
202
|
+
value: string;
|
|
203
|
+
},
|
|
204
|
+
"replaced" | "changed" | "stale",
|
|
205
|
+
Name
|
|
206
|
+
>;
|
|
207
|
+
resourcePage: FunctionReference<
|
|
208
|
+
"query",
|
|
209
|
+
"internal",
|
|
210
|
+
{ cursor: string | null },
|
|
211
|
+
{
|
|
212
|
+
continueCursor: string;
|
|
213
|
+
isDone: boolean;
|
|
214
|
+
page: Array<{
|
|
215
|
+
fqn: string;
|
|
216
|
+
stack: string;
|
|
217
|
+
stage: string;
|
|
218
|
+
value: string;
|
|
219
|
+
}>;
|
|
220
|
+
},
|
|
137
221
|
Name
|
|
138
222
|
>;
|
|
139
223
|
set: FunctionReference<
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The keys of the store: one row that names the current key, and the key under which every secret
|
|
2
|
+
// is sealed. The keys themselves are variables of the deployment, and a component cannot read them.
|
|
3
|
+
import { v } from "convex/values";
|
|
4
|
+
import type { QueryCtx } from "./_generated/server.js";
|
|
5
|
+
// oxlint-disable-next-line samebase/no-raw-public-convex-function-builders -- Store entrypoints have no app wrapper; the deploy key enforces access.
|
|
6
|
+
import { mutation, query } from "./_generated/server.js";
|
|
7
|
+
|
|
8
|
+
const rowOf = (ctx: QueryCtx) => ctx.db.query("keys").first();
|
|
9
|
+
|
|
10
|
+
/** The generation of the current key, read in the transaction of the caller. */
|
|
11
|
+
export const generationOf = async (ctx: QueryCtx) => (await rowOf(ctx))?.generation ?? null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The current key, its generation, and true when a reseal of that generation finished. Each
|
|
15
|
+
* rotation is a new generation, also when a key comes back, so a sweep that a rotation overtook can
|
|
16
|
+
* never pass for the current one.
|
|
17
|
+
*/
|
|
18
|
+
export const sealing = query({
|
|
19
|
+
args: {},
|
|
20
|
+
returns: v.object({
|
|
21
|
+
current: v.union(v.string(), v.null()),
|
|
22
|
+
generation: v.number(),
|
|
23
|
+
sealed: v.boolean(),
|
|
24
|
+
}),
|
|
25
|
+
handler: async (ctx) => {
|
|
26
|
+
const row = await rowOf(ctx);
|
|
27
|
+
if (row === null) return { current: null, generation: 0, sealed: false };
|
|
28
|
+
const { current, generation, sealedGeneration } = row;
|
|
29
|
+
return { current, generation, sealed: sealedGeneration === generation };
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Makes `keyId` the current key of a store with no key, and answers the current key. */
|
|
34
|
+
export const choose = mutation({
|
|
35
|
+
args: { keyId: v.string() },
|
|
36
|
+
returns: v.string(),
|
|
37
|
+
handler: async (ctx, args) => {
|
|
38
|
+
const row = await rowOf(ctx);
|
|
39
|
+
if (row !== null) return row.current;
|
|
40
|
+
await ctx.db.insert("keys", { current: args.keyId, generation: 1 });
|
|
41
|
+
return args.keyId;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Makes `keyId` the current key, in a new generation. The next reseal moves the secrets to it. */
|
|
46
|
+
export const rotate = mutation({
|
|
47
|
+
args: { keyId: v.string() },
|
|
48
|
+
returns: v.null(),
|
|
49
|
+
handler: async (ctx, args) => {
|
|
50
|
+
const row = await rowOf(ctx);
|
|
51
|
+
if (row === null) await ctx.db.insert("keys", { current: args.keyId, generation: 1 });
|
|
52
|
+
else {
|
|
53
|
+
await ctx.db.patch("keys", row._id, {
|
|
54
|
+
current: args.keyId,
|
|
55
|
+
generation: row.generation + 1,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** Records a finished reseal. It does nothing when a rotation came after the sweep started. */
|
|
63
|
+
export const markSealed = mutation({
|
|
64
|
+
args: { generation: v.number() },
|
|
65
|
+
returns: v.null(),
|
|
66
|
+
handler: async (ctx, args) => {
|
|
67
|
+
const row = await rowOf(ctx);
|
|
68
|
+
if (row?.generation === args.generation) {
|
|
69
|
+
await ctx.db.patch("keys", row._id, { sealedGeneration: args.generation });
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// token and passes the repository that the token names: a component has no
|
|
3
3
|
// `ctx.auth`.
|
|
4
4
|
import { v, type Infer } from "convex/values";
|
|
5
|
+
// oxlint-disable-next-line samebase/no-raw-public-convex-function-builders -- Store entrypoints have no app wrapper; the reader and the deploy key enforce access.
|
|
5
6
|
import { query } from "./_generated/server.js";
|
|
6
7
|
import { runRecord, validate } from "./runRecord.ts";
|
|
7
8
|
|
|
@@ -16,6 +16,7 @@ export const runRecord = v.object({
|
|
|
16
16
|
export const validate = <V extends GenericValidator>(
|
|
17
17
|
validator: V,
|
|
18
18
|
value: unknown,
|
|
19
|
+
// eslint-disable-next-line samebase/no-hand-written-type-predicate -- A component imports no app package, and the switch walks the validator itself.
|
|
19
20
|
): value is Infer<V> => {
|
|
20
21
|
switch (validator.kind) {
|
|
21
22
|
case "object": {
|
|
@@ -18,4 +18,11 @@ export default defineSchema({
|
|
|
18
18
|
value: v.optional(v.string()),
|
|
19
19
|
currentRun: v.optional(runRecord),
|
|
20
20
|
}).index("by_stage", ["stack", "stage"]),
|
|
21
|
+
// One row: the key that seals new secrets, the count of its rotations, and the last count for
|
|
22
|
+
// which a reseal finished.
|
|
23
|
+
keys: defineTable({
|
|
24
|
+
current: v.string(),
|
|
25
|
+
generation: v.number(),
|
|
26
|
+
sealedGeneration: v.optional(v.number()),
|
|
27
|
+
}),
|
|
21
28
|
});
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
//
|
|
6
6
|
// A stage holds one row for each resource of its stack, so the reads of one
|
|
7
7
|
// stage collect all rows.
|
|
8
|
-
import { v, type Infer } from "convex/values";
|
|
8
|
+
import { v, type Infer, type Validator } from "convex/values";
|
|
9
9
|
import type { MutationCtx, QueryCtx } from "./_generated/server.js";
|
|
10
|
+
// oxlint-disable-next-line samebase/no-raw-public-convex-function-builders -- Store entrypoints have no app wrapper; the reader and the deploy key enforce access.
|
|
10
11
|
import { mutation, query } from "./_generated/server.js";
|
|
12
|
+
import { generationOf } from "./keys.ts";
|
|
11
13
|
import { runRecord } from "./runRecord.ts";
|
|
12
14
|
|
|
13
15
|
const stage = { stack: v.string(), stage: v.string() };
|
|
@@ -147,11 +149,11 @@ export const get = query({
|
|
|
147
149
|
/** Resources whose state has `status: "replaced"`. */
|
|
148
150
|
export const replaced = query({
|
|
149
151
|
args: stage,
|
|
150
|
-
returns: v.array(v.string()),
|
|
152
|
+
returns: v.array(v.object({ fqn: v.string(), value: v.string() })),
|
|
151
153
|
handler: async (ctx, args) =>
|
|
152
154
|
(await rowsOf(ctx, args))
|
|
153
|
-
.map((row) => row.value)
|
|
154
|
-
.filter((value) => {
|
|
155
|
+
.map((row) => ({ fqn: row.fqn, value: row.value }))
|
|
156
|
+
.filter(({ value }) => {
|
|
155
157
|
const parsed: unknown = JSON.parse(value);
|
|
156
158
|
return (
|
|
157
159
|
typeof parsed === "object" &&
|
|
@@ -234,6 +236,86 @@ export const setOutput = mutation({
|
|
|
234
236
|
},
|
|
235
237
|
});
|
|
236
238
|
|
|
239
|
+
/** A document holds at most 1 MiB, so one page reads less than the 16 MiB of one function. */
|
|
240
|
+
const RESEAL_PAGE = 10;
|
|
241
|
+
|
|
242
|
+
const pageOf = <Row extends Validator<unknown, "required", string>>(row: Row) =>
|
|
243
|
+
v.object({ page: v.array(row), continueCursor: v.string(), isDone: v.boolean() });
|
|
244
|
+
|
|
245
|
+
const resealRow = v.object({ ...resource, value: v.string() });
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* One bounded page of the resources, in table order, for a reseal. `list` and `readStack` read a
|
|
249
|
+
* whole stage or stack, which can pass the read limit of one function.
|
|
250
|
+
*/
|
|
251
|
+
export const resourcePage = query({
|
|
252
|
+
args: { cursor: v.union(v.string(), v.null()) },
|
|
253
|
+
returns: pageOf(resealRow),
|
|
254
|
+
handler: async (ctx, args) => {
|
|
255
|
+
const { page, continueCursor, isDone } = await ctx.db
|
|
256
|
+
.query("resources")
|
|
257
|
+
.paginate({ cursor: args.cursor, numItems: RESEAL_PAGE });
|
|
258
|
+
return {
|
|
259
|
+
page: page.map(({ stack, stage, fqn, value }) => ({ stack, stage, fqn, value })),
|
|
260
|
+
continueCursor,
|
|
261
|
+
isDone,
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
/** One bounded page of the outputs. A row with only a run record has no value and is left out. */
|
|
267
|
+
export const outputPage = query({
|
|
268
|
+
args: { cursor: v.union(v.string(), v.null()) },
|
|
269
|
+
returns: pageOf(v.object({ ...stage, value: v.string() })),
|
|
270
|
+
handler: async (ctx, args) => {
|
|
271
|
+
const { page, continueCursor, isDone } = await ctx.db
|
|
272
|
+
.query("outputs")
|
|
273
|
+
.paginate({ cursor: args.cursor, numItems: RESEAL_PAGE });
|
|
274
|
+
return {
|
|
275
|
+
page: page.flatMap(({ stack, stage, value }) =>
|
|
276
|
+
value === undefined ? [] : [{ stack, stage, value }],
|
|
277
|
+
),
|
|
278
|
+
continueCursor,
|
|
279
|
+
isDone,
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* What a reseal write did. `stale` means that a rotation came after the sweep started: that sweep
|
|
286
|
+
* must not put an older key back over the work of a newer one.
|
|
287
|
+
*/
|
|
288
|
+
const replaceResult = v.union(v.literal("replaced"), v.literal("changed"), v.literal("stale"));
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Replaces the value of a row that still holds `expected`, for a reseal of `generation`: the row
|
|
292
|
+
* keeps its run record.
|
|
293
|
+
*/
|
|
294
|
+
export const replaceResource = mutation({
|
|
295
|
+
args: { ...resource, expected: v.string(), value: v.string(), generation: v.number() },
|
|
296
|
+
returns: replaceResult,
|
|
297
|
+
handler: async (ctx, args) => {
|
|
298
|
+
if ((await generationOf(ctx)) !== args.generation) return "stale";
|
|
299
|
+
const row = await rowOf(ctx, args);
|
|
300
|
+
if (row === null || row.value !== args.expected) return "changed";
|
|
301
|
+
await ctx.db.patch("resources", row._id, { value: args.value });
|
|
302
|
+
return "replaced";
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
/** Like {@link replaceResource}, for an output. */
|
|
307
|
+
export const replaceOutput = mutation({
|
|
308
|
+
args: { ...stage, expected: v.string(), value: v.string(), generation: v.number() },
|
|
309
|
+
returns: replaceResult,
|
|
310
|
+
handler: async (ctx, args) => {
|
|
311
|
+
if ((await generationOf(ctx)) !== args.generation) return "stale";
|
|
312
|
+
const row = await outputOf(ctx, args);
|
|
313
|
+
if (row === null || row.value !== args.expected) return "changed";
|
|
314
|
+
await ctx.db.patch("outputs", row._id, { value: args.value });
|
|
315
|
+
return "replaced";
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
237
319
|
/** The reply of {@link readStack}. */
|
|
238
320
|
export const stackReply = v.object({
|
|
239
321
|
stages: v.array(
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type * as reader from "../reader.js";
|
|
12
|
+
import type * as sealing from "../sealing.js";
|
|
12
13
|
import type * as state from "../state.js";
|
|
13
14
|
import type * as version from "../version.js";
|
|
14
15
|
|
|
@@ -20,6 +21,7 @@ import type {
|
|
|
20
21
|
|
|
21
22
|
declare const fullApi: ApiFromModules<{
|
|
22
23
|
reader: typeof reader;
|
|
24
|
+
sealing: typeof sealing;
|
|
23
25
|
state: typeof state;
|
|
24
26
|
version: typeof version;
|
|
25
27
|
}>;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { v } from "convex/values";
|
|
5
5
|
import { repositoryReply } from "../component/reader.ts";
|
|
6
6
|
import { components } from "./_generated/api.js";
|
|
7
|
+
// oxlint-disable-next-line samebase/no-raw-public-convex-function-builders -- Store entrypoints have no app wrapper; the reader and the deploy key enforce access.
|
|
7
8
|
import { env, internalMutation, query } from "./_generated/server.js";
|
|
8
9
|
|
|
9
10
|
const repositoryName = /^[^/]+\/[^/]+$/;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The secrets of the state rows. Alchemy's `encodeState` writes a `Redacted` value in clear text as
|
|
2
|
+
// `{ "__redacted__": <value> }`. A row holds `{ "__redacted__": { "__sealed__": <sealed> } }`, so a
|
|
3
|
+
// table view, a snapshot, or a reader that forgets to remove secrets shows no value.
|
|
4
|
+
//
|
|
5
|
+
// Each key is one variable `STATE_KEY_<keyId>` of the store deployment, and a sealed value names its
|
|
6
|
+
// key. So two runs that make a key at the same time add two variables, and neither can replace a
|
|
7
|
+
// key that a row needs. The table `keys` of the component names the current key: that choice is a
|
|
8
|
+
// transaction, so the first run wins, and a write that sealed under an older key cannot commit.
|
|
9
|
+
//
|
|
10
|
+
// `state:set` and `state:setOutput` seal in an action, and the reads of a run open in a query.
|
|
11
|
+
// `state:readStack` and `reader:repository` give the rows as they are stored, without their
|
|
12
|
+
// secrets. A value without `__sealed__` is one that `state:reseal` has not reached yet.
|
|
13
|
+
import { openSecret, parseKeyRing, sealSecret, type SealedSecret } from "../sealedSecret.ts";
|
|
14
|
+
|
|
15
|
+
const REDACTED = "__redacted__";
|
|
16
|
+
const SEALED = "__sealed__";
|
|
17
|
+
|
|
18
|
+
/** The variable of the store deployment that holds the key `keyId`. */
|
|
19
|
+
export const stateKeyName = (keyId: string) => `STATE_KEY_${keyId}`;
|
|
20
|
+
|
|
21
|
+
/** The one key `keyId` as a key ring. It fails when the variable is missing or malformed. */
|
|
22
|
+
const keyRingOf = (keyId: string) => {
|
|
23
|
+
const name = stateKeyName(keyId);
|
|
24
|
+
return parseKeyRing(
|
|
25
|
+
name,
|
|
26
|
+
JSON.stringify({ current: keyId, keys: { [keyId]: process.env[name] } }),
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Fails unless the deployment holds the key `keyId`, before a function makes it the current key. */
|
|
31
|
+
export const requireKey = (keyId: string) => {
|
|
32
|
+
keyRingOf(keyId);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// eslint-disable-next-line samebase/no-hand-written-type-predicate -- The store imports no app package, and this check is exactly the JSON object that the predicate names.
|
|
36
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
37
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
|
|
39
|
+
/** The sealed object of a `__redacted__` value, or undefined for a value in clear text. */
|
|
40
|
+
const sealedIn = (value: unknown): SealedSecret | undefined => {
|
|
41
|
+
if (!isRecord(value) || !isRecord(value[SEALED])) return undefined;
|
|
42
|
+
const { keyId, iv, ciphertext } = value[SEALED];
|
|
43
|
+
return typeof keyId === "string" && typeof iv === "string" && typeof ciphertext === "string"
|
|
44
|
+
? { keyId, iv, ciphertext }
|
|
45
|
+
: undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** `value` with each `__redacted__` value replaced by the result of `change`. */
|
|
49
|
+
const mapSecrets = async (
|
|
50
|
+
value: unknown,
|
|
51
|
+
change: (secret: unknown) => Promise<unknown>,
|
|
52
|
+
): Promise<unknown> => {
|
|
53
|
+
if (Array.isArray(value)) return Promise.all(value.map((item) => mapSecrets(item, change)));
|
|
54
|
+
if (!isRecord(value)) return value;
|
|
55
|
+
if (REDACTED in value) return { [REDACTED]: await change(value[REDACTED]) };
|
|
56
|
+
const entries = await Promise.all(
|
|
57
|
+
Object.entries(value).map(async ([key, item]) => [key, await mapSecrets(item, change)]),
|
|
58
|
+
);
|
|
59
|
+
return Object.fromEntries(entries);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** True when a JSON text of `encodeState` can hold a secret. */
|
|
63
|
+
export const hasSecret = (text: string) => text.includes(REDACTED);
|
|
64
|
+
|
|
65
|
+
/** The JSON text with each secret changed. A text without a secret stays the same text. */
|
|
66
|
+
const mapText = async (text: string, change: (secret: unknown) => Promise<unknown>) =>
|
|
67
|
+
hasSecret(text) ? JSON.stringify(await mapSecrets(JSON.parse(text), change)) : text;
|
|
68
|
+
|
|
69
|
+
const sealValue = async (value: unknown, aad: string, keyId: string) => ({
|
|
70
|
+
[SEALED]: await sealSecret(keyRingOf(keyId), { plaintext: JSON.stringify(value), aad }),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const openValue = async (sealed: SealedSecret, aad: string): Promise<unknown> =>
|
|
74
|
+
JSON.parse(await openSecret(keyRingOf(sealed.keyId), { sealed, aad }));
|
|
75
|
+
|
|
76
|
+
/** The owner of a resource row: a sealed value opens only in the row that sealed it. */
|
|
77
|
+
export const resourceAad = (row: { stack: string; stage: string; fqn: string }) =>
|
|
78
|
+
JSON.stringify(["resource", row.stack, row.stage, row.fqn]);
|
|
79
|
+
|
|
80
|
+
/** The owner of an output row. */
|
|
81
|
+
export const outputAad = (row: { stack: string; stage: string }) =>
|
|
82
|
+
JSON.stringify(["output", row.stack, row.stage]);
|
|
83
|
+
|
|
84
|
+
/** Seals each secret of a JSON text of `encodeState`. Only an action can call it. */
|
|
85
|
+
export const sealText = (text: string, aad: string, keyId: string) =>
|
|
86
|
+
mapText(text, (secret) => sealValue(secret, aad, keyId));
|
|
87
|
+
|
|
88
|
+
/** Opens each secret of a row, for the reads of a run. */
|
|
89
|
+
export const openText = (text: string, aad: string) =>
|
|
90
|
+
mapText(text, async (secret) => {
|
|
91
|
+
const sealed = sealedIn(secret);
|
|
92
|
+
return sealed === undefined ? secret : openValue(sealed, aad);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** The row with every secret sealed under `keyId`. Only an action can call it. */
|
|
96
|
+
export const resealText = (text: string, aad: string, keyId: string) =>
|
|
97
|
+
mapText(text, async (secret) => {
|
|
98
|
+
const sealed = sealedIn(secret);
|
|
99
|
+
if (sealed === undefined) return sealValue(secret, aad, keyId);
|
|
100
|
+
if (sealed.keyId === keyId) return secret;
|
|
101
|
+
return sealValue(await openValue(sealed, aad), aad, keyId);
|
|
102
|
+
});
|