@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.
@@ -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
+ });