@samebase/alchemy-convex 0.18.0 → 0.19.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 +96 -69
- package/lib/{HubClient-BZKweNfD.js → HubClient-C0XxgkK3.js} +42 -7
- package/lib/HubClient-C0XxgkK3.js.map +1 -0
- package/lib/cli.js +2 -2
- package/lib/cli.js.map +1 -1
- 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/Project.ts +1 -1
- package/src/Push.ts +1 -1
- 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
|
@@ -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
|
+
});
|
|
@@ -8,14 +8,65 @@
|
|
|
8
8
|
// These names and arguments are the wire contract of STORE_VERSION. Samebase
|
|
9
9
|
// reads `stacks`, `getOutput`, and `readStack` by name: a renamed function is
|
|
10
10
|
// a version bump of the store and of Samebase.
|
|
11
|
+
//
|
|
12
|
+
// The rows hold their secrets sealed (see sealing.ts): `set` and `setOutput`
|
|
13
|
+
// are actions that seal and then write, and `get`, `replaced`, and `getOutput`
|
|
14
|
+
// open them for the run. `readStack` gives the rows as they are stored.
|
|
15
|
+
import type { FunctionReturnType } from "convex/server";
|
|
11
16
|
import { v } from "convex/values";
|
|
12
17
|
import { runRecord } from "../component/runRecord.ts";
|
|
13
18
|
import { stackReply } from "../component/state.ts";
|
|
14
|
-
import { components } from "./_generated/api.js";
|
|
15
|
-
import {
|
|
19
|
+
import { components, internal } from "./_generated/api.js";
|
|
20
|
+
import {
|
|
21
|
+
type ActionCtx,
|
|
22
|
+
internalAction,
|
|
23
|
+
internalMutation,
|
|
24
|
+
internalQuery,
|
|
25
|
+
type MutationCtx,
|
|
26
|
+
} from "./_generated/server.js";
|
|
27
|
+
import {
|
|
28
|
+
hasSecret,
|
|
29
|
+
openText,
|
|
30
|
+
outputAad,
|
|
31
|
+
requireKey,
|
|
32
|
+
resealText,
|
|
33
|
+
resourceAad,
|
|
34
|
+
sealText,
|
|
35
|
+
} from "./sealing.ts";
|
|
16
36
|
import { STORE_VERSION } from "./version.ts";
|
|
17
37
|
|
|
18
38
|
const state = components.alchemyState.state;
|
|
39
|
+
const keys = components.alchemyState.keys;
|
|
40
|
+
|
|
41
|
+
const NO_KEY = "The store has no key yet.";
|
|
42
|
+
|
|
43
|
+
const currentKey = async (ctx: ActionCtx) => {
|
|
44
|
+
const { current } = await ctx.runQuery(keys.sealing, {});
|
|
45
|
+
if (current === null) throw new Error(NO_KEY);
|
|
46
|
+
return current;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Seals a text and writes it. A write that sealed under a key that stopped being the current key
|
|
51
|
+
* does not commit, so it cannot land after the reseal of a rotation: it seals again.
|
|
52
|
+
*/
|
|
53
|
+
const sealedWrite = async (
|
|
54
|
+
ctx: ActionCtx,
|
|
55
|
+
text: string,
|
|
56
|
+
aad: string,
|
|
57
|
+
write: (value: string, keyId: string | null) => Promise<boolean>,
|
|
58
|
+
): Promise<null> => {
|
|
59
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
60
|
+
const keyId = hasSecret(text) ? await currentKey(ctx) : null;
|
|
61
|
+
const value = keyId === null ? text : await sealText(text, aad, keyId);
|
|
62
|
+
if (await write(value, keyId)) return null;
|
|
63
|
+
}
|
|
64
|
+
throw new Error("The current key of the store changed during three writes in a row.");
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** True when a value sealed under `keyId` must not commit. A text with no secret has no key. */
|
|
68
|
+
const isStale = async (ctx: MutationCtx, keyId: string | null) =>
|
|
69
|
+
keyId !== null && (await ctx.runQuery(keys.sealing, {})).current !== keyId;
|
|
19
70
|
|
|
20
71
|
/** The wire version of this store, compared with the client's STORE_VERSION. */
|
|
21
72
|
export const version = internalQuery({
|
|
@@ -47,34 +98,59 @@ export const list = internalQuery({
|
|
|
47
98
|
export const get = internalQuery({
|
|
48
99
|
args: { stack: v.string(), stage: v.string(), fqn: v.string() },
|
|
49
100
|
returns: v.union(v.string(), v.null()),
|
|
50
|
-
handler: (ctx, args) =>
|
|
51
|
-
|
|
101
|
+
handler: async (ctx, args) => {
|
|
102
|
+
const row = { stack: args.stack, stage: args.stage, fqn: args.fqn };
|
|
103
|
+
const value = await ctx.runQuery(state.get, row);
|
|
104
|
+
return value === null ? null : openText(value, resourceAad(row));
|
|
105
|
+
},
|
|
52
106
|
});
|
|
53
107
|
|
|
54
108
|
/** Resources whose state has `status: "replaced"`. */
|
|
55
109
|
export const replaced = internalQuery({
|
|
56
110
|
args: { stack: v.string(), stage: v.string() },
|
|
57
111
|
returns: v.array(v.string()),
|
|
58
|
-
handler: (ctx, args) =>
|
|
112
|
+
handler: async (ctx, args) => {
|
|
113
|
+
const rows = await ctx.runQuery(state.replaced, { stack: args.stack, stage: args.stage });
|
|
114
|
+
return Promise.all(
|
|
115
|
+
rows.map((row) =>
|
|
116
|
+
openText(row.value, resourceAad({ stack: args.stack, stage: args.stage, fqn: row.fqn })),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
},
|
|
59
120
|
});
|
|
60
121
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
122
|
+
const setArgs = {
|
|
123
|
+
stack: v.string(),
|
|
124
|
+
stage: v.string(),
|
|
125
|
+
fqn: v.string(),
|
|
126
|
+
value: v.string(),
|
|
127
|
+
run: v.optional(runRecord),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const set = internalAction({
|
|
131
|
+
args: setArgs,
|
|
69
132
|
returns: v.null(),
|
|
70
|
-
handler: (ctx, args) =>
|
|
71
|
-
ctx.
|
|
133
|
+
handler: (ctx, args): Promise<null> =>
|
|
134
|
+
sealedWrite(ctx, args.value, resourceAad(args), (value, keyId) =>
|
|
135
|
+
ctx.runMutation(internal.state.writeResource, { ...args, value, keyId }),
|
|
136
|
+
),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** The write of `set`. It answers false when `keyId` is no longer the current key. */
|
|
140
|
+
export const writeResource = internalMutation({
|
|
141
|
+
args: { ...setArgs, keyId: v.union(v.string(), v.null()) },
|
|
142
|
+
returns: v.boolean(),
|
|
143
|
+
handler: async (ctx, args) => {
|
|
144
|
+
if (await isStale(ctx, args.keyId)) return false;
|
|
145
|
+
await ctx.runMutation(state.set, {
|
|
72
146
|
stack: args.stack,
|
|
73
147
|
stage: args.stage,
|
|
74
148
|
fqn: args.fqn,
|
|
75
149
|
value: args.value,
|
|
76
150
|
run: args.run ?? null,
|
|
77
|
-
})
|
|
151
|
+
});
|
|
152
|
+
return true;
|
|
153
|
+
},
|
|
78
154
|
});
|
|
79
155
|
|
|
80
156
|
export const remove = internalMutation({
|
|
@@ -100,19 +176,43 @@ export const removeStack = internalMutation({
|
|
|
100
176
|
export const getOutput = internalQuery({
|
|
101
177
|
args: { stack: v.string(), stage: v.string() },
|
|
102
178
|
returns: v.union(v.string(), v.null()),
|
|
103
|
-
handler: (ctx, args) =>
|
|
179
|
+
handler: async (ctx, args) => {
|
|
180
|
+
const row = { stack: args.stack, stage: args.stage };
|
|
181
|
+
const value = await ctx.runQuery(state.getOutput, row);
|
|
182
|
+
return value === null ? null : openText(value, outputAad(row));
|
|
183
|
+
},
|
|
104
184
|
});
|
|
105
185
|
|
|
106
|
-
|
|
107
|
-
|
|
186
|
+
const setOutputArgs = {
|
|
187
|
+
stack: v.string(),
|
|
188
|
+
stage: v.string(),
|
|
189
|
+
value: v.string(),
|
|
190
|
+
run: v.optional(runRecord),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const setOutput = internalAction({
|
|
194
|
+
args: setOutputArgs,
|
|
108
195
|
returns: v.null(),
|
|
109
|
-
handler: (ctx, args) =>
|
|
110
|
-
ctx.
|
|
196
|
+
handler: (ctx, args): Promise<null> =>
|
|
197
|
+
sealedWrite(ctx, args.value, outputAad(args), (value, keyId) =>
|
|
198
|
+
ctx.runMutation(internal.state.writeOutput, { ...args, value, keyId }),
|
|
199
|
+
),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/** The write of `setOutput`. It answers false when `keyId` is no longer the current key. */
|
|
203
|
+
export const writeOutput = internalMutation({
|
|
204
|
+
args: { ...setOutputArgs, keyId: v.union(v.string(), v.null()) },
|
|
205
|
+
returns: v.boolean(),
|
|
206
|
+
handler: async (ctx, args) => {
|
|
207
|
+
if (await isStale(ctx, args.keyId)) return false;
|
|
208
|
+
await ctx.runMutation(state.setOutput, {
|
|
111
209
|
stack: args.stack,
|
|
112
210
|
stage: args.stage,
|
|
113
211
|
value: args.value,
|
|
114
212
|
run: args.run ?? null,
|
|
115
|
-
})
|
|
213
|
+
});
|
|
214
|
+
return true;
|
|
215
|
+
},
|
|
116
216
|
});
|
|
117
217
|
|
|
118
218
|
/** Every stage of a stack with its resources, output, and current record, in one call. Since store version 2. */
|
|
@@ -121,3 +221,129 @@ export const readStack = internalQuery({
|
|
|
121
221
|
returns: stackReply,
|
|
122
222
|
handler: (ctx, args) => ctx.runQuery(state.readStack, { stack: args.stack }),
|
|
123
223
|
});
|
|
224
|
+
|
|
225
|
+
/** The current key, and true when every secret is sealed under it. A run reads this each time. */
|
|
226
|
+
export const sealing = internalQuery({
|
|
227
|
+
args: {},
|
|
228
|
+
returns: v.object({
|
|
229
|
+
current: v.union(v.string(), v.null()),
|
|
230
|
+
generation: v.number(),
|
|
231
|
+
sealed: v.boolean(),
|
|
232
|
+
}),
|
|
233
|
+
handler: (ctx) => ctx.runQuery(keys.sealing, {}),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Makes `keyId` the current key of a store with no key, and answers the current key. Two first runs
|
|
238
|
+
* can both call it: the first one wins, and the variable of the other stays unused.
|
|
239
|
+
*/
|
|
240
|
+
export const chooseKey = internalMutation({
|
|
241
|
+
args: { keyId: v.string() },
|
|
242
|
+
returns: v.string(),
|
|
243
|
+
handler: (ctx, args) => {
|
|
244
|
+
requireKey(args.keyId);
|
|
245
|
+
return ctx.runMutation(keys.choose, args);
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/** Makes `keyId`, a key that the deployment holds, the current key. See `reseal`. */
|
|
250
|
+
export const rotateKey = internalMutation({
|
|
251
|
+
args: { keyId: v.string() },
|
|
252
|
+
returns: v.null(),
|
|
253
|
+
handler: (ctx, args) => {
|
|
254
|
+
requireKey(args.keyId);
|
|
255
|
+
return ctx.runMutation(keys.rotate, args);
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Seals every secret of the store under the current key, one row at a time, through bounded pages.
|
|
261
|
+
* A run calls it until one finishes, so a run that stops only leaves work for the next one, and the
|
|
262
|
+
* rows that it sealed stay sealed. A row that fails, because a key is gone or a write limit was hit,
|
|
263
|
+
* is named in `failed`, and the others go on. A row that changed during the sweep is left: the write
|
|
264
|
+
* that changed it sealed it. A sweep
|
|
265
|
+
* stops when the current key changes: the sweep of the new key does the work, and this one must not
|
|
266
|
+
* put the older key back.
|
|
267
|
+
*/
|
|
268
|
+
export const reseal = internalAction({
|
|
269
|
+
args: {},
|
|
270
|
+
returns: v.object({
|
|
271
|
+
resealed: v.number(),
|
|
272
|
+
skippedChanged: v.number(),
|
|
273
|
+
failed: v.array(v.string()),
|
|
274
|
+
}),
|
|
275
|
+
handler: async (ctx) => {
|
|
276
|
+
const { current: keyId, generation } = await ctx.runQuery(keys.sealing, {});
|
|
277
|
+
if (keyId === null) throw new Error(NO_KEY);
|
|
278
|
+
let resealed = 0;
|
|
279
|
+
let skippedChanged = 0;
|
|
280
|
+
const failed: string[] = [];
|
|
281
|
+
/** Reseals one row, and answers true when the sweep must stop. */
|
|
282
|
+
const sweep = async (
|
|
283
|
+
name: string,
|
|
284
|
+
aad: string,
|
|
285
|
+
expected: string,
|
|
286
|
+
replace: (value: string) => Promise<"replaced" | "changed" | "stale">,
|
|
287
|
+
) => {
|
|
288
|
+
try {
|
|
289
|
+
const value = await resealText(expected, aad, keyId);
|
|
290
|
+
if (value === expected) return false;
|
|
291
|
+
const result = await replace(value);
|
|
292
|
+
if (result === "replaced") resealed += 1;
|
|
293
|
+
if (result === "changed") skippedChanged += 1;
|
|
294
|
+
return result === "stale";
|
|
295
|
+
} catch {
|
|
296
|
+
// The row name is enough: an error can name a key id, and it adds nothing to find the row.
|
|
297
|
+
failed.push(name);
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const done = () => ({ resealed, skippedChanged, failed });
|
|
302
|
+
|
|
303
|
+
let cursor: string | null = null;
|
|
304
|
+
for (;;) {
|
|
305
|
+
const { page, continueCursor, isDone }: FunctionReturnType<typeof state.resourcePage> =
|
|
306
|
+
await ctx.runQuery(state.resourcePage, { cursor });
|
|
307
|
+
for (const { stack, stage, fqn, value: expected } of page) {
|
|
308
|
+
const stale = await sweep(
|
|
309
|
+
`${stack}/${stage}/${fqn}`,
|
|
310
|
+
resourceAad({ stack, stage, fqn }),
|
|
311
|
+
expected,
|
|
312
|
+
(value) =>
|
|
313
|
+
ctx.runMutation(state.replaceResource, {
|
|
314
|
+
stack,
|
|
315
|
+
stage,
|
|
316
|
+
fqn,
|
|
317
|
+
expected,
|
|
318
|
+
value,
|
|
319
|
+
generation,
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
if (stale) return done();
|
|
323
|
+
}
|
|
324
|
+
if (isDone) break;
|
|
325
|
+
cursor = continueCursor;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
cursor = null;
|
|
329
|
+
for (;;) {
|
|
330
|
+
const { page, continueCursor, isDone }: FunctionReturnType<typeof state.outputPage> =
|
|
331
|
+
await ctx.runQuery(state.outputPage, { cursor });
|
|
332
|
+
for (const { stack, stage, value: expected } of page) {
|
|
333
|
+
const stale = await sweep(
|
|
334
|
+
`${stack}/${stage} output`,
|
|
335
|
+
outputAad({ stack, stage }),
|
|
336
|
+
expected,
|
|
337
|
+
(value) =>
|
|
338
|
+
ctx.runMutation(state.replaceOutput, { stack, stage, expected, value, generation }),
|
|
339
|
+
);
|
|
340
|
+
if (stale) return done();
|
|
341
|
+
}
|
|
342
|
+
if (isDone) break;
|
|
343
|
+
cursor = continueCursor;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (failed.length === 0) await ctx.runMutation(keys.markSealed, { generation });
|
|
347
|
+
return done();
|
|
348
|
+
},
|
|
349
|
+
});
|
|
@@ -10,4 +10,6 @@
|
|
|
10
10
|
// 2: `readStack` (0.7.0).
|
|
11
11
|
// 3: authenticated subscriptions and current stage run records (0.9.0).
|
|
12
12
|
// 4: the store is a component (same functions).
|
|
13
|
-
|
|
13
|
+
// 5: rows hold their secrets sealed: `set` and `setOutput` are actions, with `sealing`, `chooseKey`,
|
|
14
|
+
// `rotateKey`, and `reseal` (0.19.0).
|
|
15
|
+
export const STORE_VERSION = 5;
|