@gaganref/confect-test 10.3.1-fork.91 → 10.3.1-fork.93
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/dist/TestConfect.d.ts +26 -0
- package/dist/TestConfect.d.ts.map +1 -1
- package/dist/TestConfect.js +9 -1
- package/dist/TestConfect.js.map +1 -1
- package/dist/internal/Attempt.d.ts +14 -1
- package/dist/internal/Attempt.d.ts.map +1 -1
- package/dist/internal/Attempt.js +18 -1
- package/dist/internal/Attempt.js.map +1 -1
- package/dist/internal/ConvexTest.d.ts +26 -1
- package/dist/internal/ConvexTest.d.ts.map +1 -1
- package/dist/internal/ConvexTest.js +55 -27
- package/dist/internal/ConvexTest.js.map +1 -1
- package/dist/internal/Execution.d.ts +151 -1
- package/dist/internal/Execution.d.ts.map +1 -1
- package/dist/internal/Execution.js +731 -214
- package/dist/internal/Execution.js.map +1 -1
- package/dist/internal/KernelQuery.d.ts +8 -3
- package/dist/internal/KernelQuery.d.ts.map +1 -1
- package/dist/internal/KernelQuery.js +10 -3
- package/dist/internal/KernelQuery.js.map +1 -1
- package/dist/internal/KernelRefusal.d.ts +134 -0
- package/dist/internal/KernelRefusal.d.ts.map +1 -1
- package/dist/internal/KernelRefusal.js +134 -1
- package/dist/internal/KernelRefusal.js.map +1 -1
- package/dist/internal/Metering.d.ts +186 -0
- package/dist/internal/Metering.d.ts.map +1 -0
- package/dist/internal/Metering.js +401 -0
- package/dist/internal/Metering.js.map +1 -0
- package/dist/internal/Redaction.d.ts +142 -0
- package/dist/internal/Redaction.d.ts.map +1 -0
- package/dist/internal/Redaction.js +448 -0
- package/dist/internal/Redaction.js.map +1 -0
- package/dist/internal/Snapshot.d.ts +298 -0
- package/dist/internal/Snapshot.d.ts.map +1 -0
- package/dist/internal/Snapshot.js +532 -0
- package/dist/internal/Snapshot.js.map +1 -0
- package/dist/internal/Subscriptions.d.ts +13 -0
- package/dist/internal/Subscriptions.d.ts.map +1 -1
- package/dist/internal/Subscriptions.js +27 -3
- package/dist/internal/Subscriptions.js.map +1 -1
- package/package.json +3 -3
- package/src/TestConfect.ts +63 -4
- package/src/internal/Attempt.ts +24 -0
- package/src/internal/ConvexTest.ts +135 -38
- package/src/internal/Execution.ts +1141 -324
- package/src/internal/KernelQuery.ts +17 -6
- package/src/internal/KernelRefusal.ts +160 -0
- package/src/internal/Metering.ts +520 -0
- package/src/internal/Redaction.ts +730 -0
- package/src/internal/Snapshot.ts +945 -0
- package/src/internal/Subscriptions.ts +34 -4
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import * as DatabaseSchema from "@confect/server/DatabaseSchema";
|
|
2
|
+
import {
|
|
3
|
+
convexToJson,
|
|
4
|
+
jsonToConvex,
|
|
5
|
+
type JSONValue,
|
|
6
|
+
type Value,
|
|
7
|
+
} from "convex/values";
|
|
8
|
+
import * as Result from "effect/Result";
|
|
9
|
+
import type * as Schema from "effect/Schema";
|
|
10
|
+
import * as SchemaAST from "effect/SchemaAST";
|
|
11
|
+
import * as KernelRefusal from "./KernelRefusal.js";
|
|
12
|
+
import type { FunctionPath } from "./KernelValues.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a marker writes in place of a secret in text and in values: a failure
|
|
16
|
+
* report, a diff, a read record.
|
|
17
|
+
*/
|
|
18
|
+
export const marker = "[redacted]";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The key of a snapshot's marker object, `{ "$redacted": <ref> }`. Convex's
|
|
22
|
+
* encoded JSON allows no object key that starts with `$`, so no stored value
|
|
23
|
+
* reads as a marker.
|
|
24
|
+
*/
|
|
25
|
+
export const markerKey = "$redacted";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Free text has no schema, so a failure report's message and stack are searched
|
|
29
|
+
* for every kept secret string. A string shorter than this is not searched for,
|
|
30
|
+
* or text that merely shares a few letters would be marked. Values are marked
|
|
31
|
+
* by their schema at any length.
|
|
32
|
+
*/
|
|
33
|
+
const searchableLength = 4;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where a value may hold secrets: the type side of a schema's AST. A walk
|
|
37
|
+
* follows it through the encoded value, so the value bounds the walk.
|
|
38
|
+
*/
|
|
39
|
+
export type Shape = SchemaAST.AST;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A shape placed at a path inside a value: a table's schema at a document's
|
|
43
|
+
* root, a job's target arguments at its row's `args.0`.
|
|
44
|
+
*/
|
|
45
|
+
export interface Placement {
|
|
46
|
+
readonly at: ReadonlyArray<string>;
|
|
47
|
+
readonly shape: Shape;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isSecretNode = (ast: SchemaAST.AST): boolean =>
|
|
51
|
+
ast._tag === "Declaration" &&
|
|
52
|
+
(SchemaAST.resolve(ast)?.["representation"] as { id?: unknown } | undefined)
|
|
53
|
+
?.id === "effect/schema/Redacted";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether a secret node is reachable from `ast`. A suspension is followed once
|
|
57
|
+
* per ancestry, so a recursive schema ends, and a suspension reused at another
|
|
58
|
+
* path is followed again there.
|
|
59
|
+
*/
|
|
60
|
+
const reachesSecret = (
|
|
61
|
+
ast: SchemaAST.AST,
|
|
62
|
+
ancestry: Set<SchemaAST.AST>,
|
|
63
|
+
): boolean => {
|
|
64
|
+
if (isSecretNode(ast)) return true;
|
|
65
|
+
switch (ast._tag) {
|
|
66
|
+
case "Objects":
|
|
67
|
+
return (
|
|
68
|
+
ast.propertySignatures.some((p) => reachesSecret(p.type, ancestry)) ||
|
|
69
|
+
ast.indexSignatures.some((i) => reachesSecret(i.type, ancestry))
|
|
70
|
+
);
|
|
71
|
+
case "Arrays":
|
|
72
|
+
return [...ast.elements, ...ast.rest].some((e) =>
|
|
73
|
+
reachesSecret(e, ancestry),
|
|
74
|
+
);
|
|
75
|
+
case "Union":
|
|
76
|
+
return ast.types.some((t) => reachesSecret(t, ancestry));
|
|
77
|
+
case "Declaration":
|
|
78
|
+
return ast.typeParameters.some((t) => reachesSecret(t, ancestry));
|
|
79
|
+
case "Suspend": {
|
|
80
|
+
if (ancestry.has(ast)) return false;
|
|
81
|
+
ancestry.add(ast);
|
|
82
|
+
const found = reachesSecret(ast.thunk(), ancestry);
|
|
83
|
+
ancestry.delete(ast);
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const reaches = new WeakMap<SchemaAST.AST, boolean>();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether `shape` holds a secret anywhere beneath it.
|
|
95
|
+
*/
|
|
96
|
+
export const hasSecret = (shape: Shape): boolean => {
|
|
97
|
+
let found = reaches.get(shape);
|
|
98
|
+
if (found === undefined) {
|
|
99
|
+
found = reachesSecret(shape, new Set());
|
|
100
|
+
reaches.set(shape, found);
|
|
101
|
+
}
|
|
102
|
+
return found;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const isObject = (json: JSONValue): json is { [key: string]: JSONValue } =>
|
|
106
|
+
typeof json === "object" && json !== null && !Array.isArray(json);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A Convex encoded leaf, such as `{"$integer": …}` or `{"$bytes": …}`, or a
|
|
110
|
+
* marker: one key that starts with `$`.
|
|
111
|
+
*/
|
|
112
|
+
const isWrapped = (json: JSONValue): boolean => {
|
|
113
|
+
if (!isObject(json)) return false;
|
|
114
|
+
const keys = Object.keys(json);
|
|
115
|
+
return keys.length === 1 && keys[0]!.startsWith("$");
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const isMarker = (json: JSONValue): boolean =>
|
|
119
|
+
isObject(json) && Object.keys(json).length === 1 && markerKey in json;
|
|
120
|
+
|
|
121
|
+
const isRecord = (json: JSONValue): json is { [key: string]: JSONValue } =>
|
|
122
|
+
isObject(json) && !isWrapped(json);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether `json` can be a value of `ast`, for choosing a union's members. Nodes
|
|
126
|
+
* the walk does not look into fit anything.
|
|
127
|
+
*/
|
|
128
|
+
const fits = (
|
|
129
|
+
ast: SchemaAST.AST,
|
|
130
|
+
json: JSONValue,
|
|
131
|
+
ancestry: Set<SchemaAST.AST> = new Set(),
|
|
132
|
+
): boolean => {
|
|
133
|
+
switch (ast._tag) {
|
|
134
|
+
case "Objects":
|
|
135
|
+
return isRecord(json);
|
|
136
|
+
case "Arrays":
|
|
137
|
+
return Array.isArray(json);
|
|
138
|
+
case "String":
|
|
139
|
+
case "TemplateLiteral":
|
|
140
|
+
return typeof json === "string";
|
|
141
|
+
case "Number":
|
|
142
|
+
return typeof json === "number" || (isObject(json) && "$float" in json);
|
|
143
|
+
case "BigInt":
|
|
144
|
+
return isObject(json) && "$integer" in json;
|
|
145
|
+
case "Boolean":
|
|
146
|
+
return typeof json === "boolean";
|
|
147
|
+
case "Null":
|
|
148
|
+
return json === null;
|
|
149
|
+
case "Literal":
|
|
150
|
+
return typeof ast.literal === "bigint"
|
|
151
|
+
? isObject(json) && "$integer" in json
|
|
152
|
+
: json === ast.literal;
|
|
153
|
+
case "Union":
|
|
154
|
+
return ast.types.some((t) => fits(t, json, ancestry));
|
|
155
|
+
case "Suspend": {
|
|
156
|
+
if (ancestry.has(ast)) return true;
|
|
157
|
+
ancestry.add(ast);
|
|
158
|
+
const fit = fits(ast.thunk(), json, ancestry);
|
|
159
|
+
ancestry.delete(ast);
|
|
160
|
+
return fit;
|
|
161
|
+
}
|
|
162
|
+
case "Declaration":
|
|
163
|
+
// A secret is stored as what its inner type encodes to, so a union
|
|
164
|
+
// member that is a secret fits only those values.
|
|
165
|
+
return (
|
|
166
|
+
!isSecretNode(ast) ||
|
|
167
|
+
ast.typeParameters.some((t) => fits(t, json, ancestry))
|
|
168
|
+
);
|
|
169
|
+
default:
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `json` with `onSecret` applied at every position where `shape` puts a secret.
|
|
176
|
+
* A value whose structure the shape cannot follow, beneath a node that holds a
|
|
177
|
+
* secret, is treated as secret whole: the walk fails closed. A marker is never
|
|
178
|
+
* walked again, so marking is idempotent and overlapping members of a union
|
|
179
|
+
* mark a shared field once.
|
|
180
|
+
*/
|
|
181
|
+
const walk = (
|
|
182
|
+
shape: SchemaAST.AST,
|
|
183
|
+
json: JSONValue,
|
|
184
|
+
onSecret: (value: JSONValue) => JSONValue,
|
|
185
|
+
unconsumed: Set<SchemaAST.AST> = new Set(),
|
|
186
|
+
): JSONValue => {
|
|
187
|
+
if (isMarker(json)) return json;
|
|
188
|
+
if (isSecretNode(shape)) return onSecret(json);
|
|
189
|
+
if (!hasSecret(shape)) return json;
|
|
190
|
+
switch (shape._tag) {
|
|
191
|
+
case "Objects": {
|
|
192
|
+
if (!isRecord(json)) return onSecret(json);
|
|
193
|
+
const named = new Map(
|
|
194
|
+
shape.propertySignatures.map((p) => [String(p.name), p.type]),
|
|
195
|
+
);
|
|
196
|
+
return Object.fromEntries(
|
|
197
|
+
Object.entries(json).map(([key, item]) => {
|
|
198
|
+
const type = named.get(key);
|
|
199
|
+
return [
|
|
200
|
+
key,
|
|
201
|
+
type !== undefined
|
|
202
|
+
? walk(type, item, onSecret)
|
|
203
|
+
: shape.indexSignatures.reduce(
|
|
204
|
+
(value, index) => walk(index.type, value, onSecret),
|
|
205
|
+
item,
|
|
206
|
+
),
|
|
207
|
+
];
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
case "Arrays": {
|
|
212
|
+
if (!Array.isArray(json)) return onSecret(json);
|
|
213
|
+
const [rest, ...post] = shape.rest;
|
|
214
|
+
return json.map((item, i) => {
|
|
215
|
+
if (i < shape.elements.length)
|
|
216
|
+
return walk(shape.elements[i]!, item, onSecret);
|
|
217
|
+
const fromEnd = json.length - i;
|
|
218
|
+
if (fromEnd <= post.length)
|
|
219
|
+
return walk(post[post.length - fromEnd]!, item, onSecret);
|
|
220
|
+
return rest === undefined ? item : walk(rest, item, onSecret);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
case "Union": {
|
|
224
|
+
const members = shape.types.filter((t) => fits(t, json));
|
|
225
|
+
if (members.length === 0) return onSecret(json);
|
|
226
|
+
return members.reduce(
|
|
227
|
+
(value, member) => walk(member, value, onSecret, unconsumed),
|
|
228
|
+
json,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
case "Suspend": {
|
|
232
|
+
if (unconsumed.has(shape)) return json;
|
|
233
|
+
unconsumed.add(shape);
|
|
234
|
+
const walked = walk(shape.thunk(), json, onSecret, unconsumed);
|
|
235
|
+
unconsumed.delete(shape);
|
|
236
|
+
return walked;
|
|
237
|
+
}
|
|
238
|
+
case "Declaration":
|
|
239
|
+
return shape.typeParameters.reduce(
|
|
240
|
+
(value, parameter) => walk(parameter, value, onSecret, unconsumed),
|
|
241
|
+
json,
|
|
242
|
+
);
|
|
243
|
+
default:
|
|
244
|
+
return onSecret(json);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const replaceAt = (
|
|
249
|
+
json: JSONValue,
|
|
250
|
+
path: ReadonlyArray<string>,
|
|
251
|
+
replace: (value: JSONValue) => JSONValue,
|
|
252
|
+
): JSONValue => {
|
|
253
|
+
if (path.length === 0) return replace(json);
|
|
254
|
+
const [head, ...rest] = path as [string, ...Array<string>];
|
|
255
|
+
if (Array.isArray(json)) {
|
|
256
|
+
return json.map((item, i) =>
|
|
257
|
+
`${i}` === head ? replaceAt(item, rest, replace) : item,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (isRecord(json)) {
|
|
261
|
+
return Object.fromEntries(
|
|
262
|
+
Object.entries(json).map(([key, item]) => [
|
|
263
|
+
key,
|
|
264
|
+
key === head ? replaceAt(item, rest, replace) : item,
|
|
265
|
+
]),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return json;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const walkPlaced = (
|
|
272
|
+
json: JSONValue,
|
|
273
|
+
placements: ReadonlyArray<Placement>,
|
|
274
|
+
onSecret: (value: JSONValue) => JSONValue,
|
|
275
|
+
): JSONValue =>
|
|
276
|
+
placements.reduce(
|
|
277
|
+
(value, { at, shape }) =>
|
|
278
|
+
replaceAt(value, at, (inner) => walk(shape, inner, onSecret)),
|
|
279
|
+
json,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Whether the field at `fieldPath` of a document of `shape` holds a secret, or
|
|
284
|
+
* sits under one.
|
|
285
|
+
*/
|
|
286
|
+
export const sensitiveAt = (
|
|
287
|
+
shape: Shape,
|
|
288
|
+
fieldPath: ReadonlyArray<string>,
|
|
289
|
+
): boolean => {
|
|
290
|
+
const at = (
|
|
291
|
+
ast: SchemaAST.AST,
|
|
292
|
+
path: ReadonlyArray<string>,
|
|
293
|
+
ancestry: Set<SchemaAST.AST>,
|
|
294
|
+
): boolean => {
|
|
295
|
+
if (isSecretNode(ast)) return true;
|
|
296
|
+
if (path.length === 0) return hasSecret(ast);
|
|
297
|
+
const [head, ...rest] = path as [string, ...Array<string>];
|
|
298
|
+
switch (ast._tag) {
|
|
299
|
+
case "Objects": {
|
|
300
|
+
const property = ast.propertySignatures.find(
|
|
301
|
+
(p) => String(p.name) === head,
|
|
302
|
+
);
|
|
303
|
+
// A segment is consumed here, so a suspension met again further down
|
|
304
|
+
// the path is a new descent, not a cycle.
|
|
305
|
+
return property !== undefined
|
|
306
|
+
? at(property.type, rest, new Set())
|
|
307
|
+
: ast.indexSignatures.some((i) => at(i.type, rest, new Set()));
|
|
308
|
+
}
|
|
309
|
+
case "Union":
|
|
310
|
+
return ast.types.some((t) => at(t, path, ancestry));
|
|
311
|
+
case "Declaration":
|
|
312
|
+
return ast.typeParameters.some((t) => at(t, path, ancestry));
|
|
313
|
+
case "Suspend": {
|
|
314
|
+
if (ancestry.has(ast)) return false;
|
|
315
|
+
ancestry.add(ast);
|
|
316
|
+
const found = at(ast.thunk(), path, ancestry);
|
|
317
|
+
ancestry.delete(ast);
|
|
318
|
+
return found;
|
|
319
|
+
}
|
|
320
|
+
default:
|
|
321
|
+
return hasSecret(ast);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
return at(shape, fieldPath, new Set());
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Which values the kernel treats as secret: the shapes of a table's documents,
|
|
329
|
+
* of a function's arguments and of its return value.
|
|
330
|
+
*/
|
|
331
|
+
export interface Policy {
|
|
332
|
+
readonly table: (componentPath: string, table: string) => Shape | undefined;
|
|
333
|
+
readonly args: (functionPath: FunctionPath) => Shape | undefined;
|
|
334
|
+
readonly returns: (functionPath: FunctionPath) => Shape | undefined;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A schema the policy builds the first time it needs it, as Confect's specs and
|
|
339
|
+
* tables keep theirs.
|
|
340
|
+
*/
|
|
341
|
+
export type Lazy = () => Schema.Top;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The schemas a policy reads its secret nodes from, by component path, then
|
|
345
|
+
* table name or `module:export`. Row 8's codegen fills this from the Confect
|
|
346
|
+
* specs. A test that declares it by hand writes the same record.
|
|
347
|
+
*/
|
|
348
|
+
export interface Declared {
|
|
349
|
+
readonly tables?: Readonly<Record<string, Readonly<Record<string, Lazy>>>>;
|
|
350
|
+
readonly functions?: Readonly<
|
|
351
|
+
Record<
|
|
352
|
+
string,
|
|
353
|
+
Readonly<
|
|
354
|
+
Record<string, { readonly args?: Lazy; readonly returns?: Lazy }>
|
|
355
|
+
>
|
|
356
|
+
>
|
|
357
|
+
>;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const memo = <A>(compute: (key: string) => A) => {
|
|
361
|
+
const cache = new Map<string, A>();
|
|
362
|
+
return (key: string): A => {
|
|
363
|
+
if (!cache.has(key)) cache.set(key, compute(key));
|
|
364
|
+
return cache.get(key)!;
|
|
365
|
+
};
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* A policy from `declared`'s schemas. Each schema is built the first time its
|
|
370
|
+
* shape is asked for.
|
|
371
|
+
*/
|
|
372
|
+
export const policy = (declared: Declared): Policy => {
|
|
373
|
+
const split = (key: string) => key.split("\u0000") as [string, string];
|
|
374
|
+
const table = memo((key) => {
|
|
375
|
+
const [componentPath, name] = split(key);
|
|
376
|
+
return declared.tables?.[componentPath]?.[name]?.().ast;
|
|
377
|
+
});
|
|
378
|
+
const fn = (part: "args" | "returns") =>
|
|
379
|
+
memo((key) => {
|
|
380
|
+
const [componentPath, udfPath] = split(key);
|
|
381
|
+
return declared.functions?.[componentPath]?.[udfPath]?.[part]?.().ast;
|
|
382
|
+
});
|
|
383
|
+
const args = fn("args");
|
|
384
|
+
const returns = fn("returns");
|
|
385
|
+
return {
|
|
386
|
+
table: (componentPath, name) => table(`${componentPath}\u0000${name}`),
|
|
387
|
+
args: ({ componentPath, udfPath }) =>
|
|
388
|
+
args(`${componentPath}\u0000${udfPath}`),
|
|
389
|
+
returns: ({ componentPath, udfPath }) =>
|
|
390
|
+
returns(`${componentPath}\u0000${udfPath}`),
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
export const none: Policy = policy({});
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Two declarations as one: `second`'s entries win where both name a table or
|
|
398
|
+
* function.
|
|
399
|
+
*/
|
|
400
|
+
export const merge = (first: Declared, second: Declared): Declared => {
|
|
401
|
+
const each = <A>(
|
|
402
|
+
a: Readonly<Record<string, Readonly<Record<string, A>>>> | undefined,
|
|
403
|
+
b: Readonly<Record<string, Readonly<Record<string, A>>>> | undefined,
|
|
404
|
+
) =>
|
|
405
|
+
Object.fromEntries(
|
|
406
|
+
[...new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})])].map(
|
|
407
|
+
(componentPath) => [
|
|
408
|
+
componentPath,
|
|
409
|
+
{ ...a?.[componentPath], ...b?.[componentPath] },
|
|
410
|
+
],
|
|
411
|
+
),
|
|
412
|
+
);
|
|
413
|
+
return {
|
|
414
|
+
tables: each(first.tables, second.tables),
|
|
415
|
+
functions: each(first.functions, second.functions),
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* A Confect database schema's tables, as a policy's `tables` entry for one
|
|
421
|
+
* component. No table's schema is built until the kernel asks for its shape.
|
|
422
|
+
*/
|
|
423
|
+
export const tablesOf = (
|
|
424
|
+
databaseSchema: DatabaseSchema.AnyWithProps,
|
|
425
|
+
): Readonly<Record<string, Lazy>> =>
|
|
426
|
+
Object.fromEntries(
|
|
427
|
+
Object.values(DatabaseSchema.tables(databaseSchema)).map(
|
|
428
|
+
(table: { readonly tableName: string; readonly Fields: Schema.Top }) => [
|
|
429
|
+
table.tableName,
|
|
430
|
+
() => table.Fields,
|
|
431
|
+
],
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The text a secret leaf can show in free text: a string itself, an int64 as
|
|
437
|
+
* its decimal digits.
|
|
438
|
+
*/
|
|
439
|
+
const leafTexts = (json: JSONValue): Array<string> => {
|
|
440
|
+
if (typeof json === "string") return [json];
|
|
441
|
+
if (Array.isArray(json)) return json.flatMap(leafTexts);
|
|
442
|
+
if (isObject(json)) {
|
|
443
|
+
if ("$integer" in json) {
|
|
444
|
+
try {
|
|
445
|
+
const integer = jsonToConvex(json);
|
|
446
|
+
return typeof integer === "bigint" ? [integer.toString()] : [];
|
|
447
|
+
} catch {
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (isWrapped(json)) return [];
|
|
452
|
+
return Object.values(json).flatMap(leafTexts);
|
|
453
|
+
}
|
|
454
|
+
return [];
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The raw secrets the kernel has seen under a secret node, in the order it
|
|
459
|
+
* first saw each, with every string a compound secret holds beneath it. This
|
|
460
|
+
* store is replay state: it lives in memory beside the world and is never
|
|
461
|
+
* written to an artifact. A snapshot's markers name entries here by position.
|
|
462
|
+
*/
|
|
463
|
+
export class Secrets {
|
|
464
|
+
private readonly values: Array<JSONValue> = [];
|
|
465
|
+
private readonly refs = new Map<string, number>();
|
|
466
|
+
private readonly texts = new Set<string>();
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Keep `value` and answer its position. A value kept before answers its first
|
|
470
|
+
* position.
|
|
471
|
+
*/
|
|
472
|
+
keep(value: JSONValue): number {
|
|
473
|
+
const key = JSON.stringify(value);
|
|
474
|
+
const known = this.refs.get(key);
|
|
475
|
+
if (known !== undefined) return known;
|
|
476
|
+
this.values.push(value);
|
|
477
|
+
this.refs.set(key, this.values.length - 1);
|
|
478
|
+
for (const text of leafTexts(value)) this.texts.add(text);
|
|
479
|
+
return this.values.length - 1;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
raw(ref: number): JSONValue | undefined {
|
|
483
|
+
return this.values[ref];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Every kept secret's text long enough to search free text for, longest
|
|
488
|
+
* first, so a secret inside another is never marked half.
|
|
489
|
+
*/
|
|
490
|
+
searchable(): ReadonlyArray<string> {
|
|
491
|
+
return [...this.texts]
|
|
492
|
+
.filter((text) => text.length >= searchableLength)
|
|
493
|
+
.sort((a, b) => b.length - a.length);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
copy(): Secrets {
|
|
497
|
+
const copy = new Secrets();
|
|
498
|
+
for (const value of this.values) copy.keep(value);
|
|
499
|
+
return copy;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Keep every secret of `value` that `placements` put there, before it is
|
|
505
|
+
* validated, so a validation error can already mark it.
|
|
506
|
+
*/
|
|
507
|
+
export const keep = (
|
|
508
|
+
value: unknown,
|
|
509
|
+
placements: ReadonlyArray<Placement>,
|
|
510
|
+
secrets: Secrets,
|
|
511
|
+
): void => {
|
|
512
|
+
if (placements.length === 0) return;
|
|
513
|
+
let json: JSONValue;
|
|
514
|
+
try {
|
|
515
|
+
json = convexToJson(value as Value);
|
|
516
|
+
} catch {
|
|
517
|
+
// A value Convex cannot encode is never stored or passed, so it reaches no
|
|
518
|
+
// artifact as a value.
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
walkPlaced(json, placements, (secret) => {
|
|
522
|
+
secrets.keep(secret);
|
|
523
|
+
return secret;
|
|
524
|
+
});
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const containsSecret = (text: string, searchable: ReadonlyArray<string>) =>
|
|
528
|
+
searchable.some((secret) => text.includes(secret));
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The backstop beneath every schema walk: each string, outside a marker, that
|
|
532
|
+
* holds a kept secret, replaced by `replace`.
|
|
533
|
+
*/
|
|
534
|
+
const backstop = (
|
|
535
|
+
json: JSONValue,
|
|
536
|
+
searchable: ReadonlyArray<string>,
|
|
537
|
+
replace: (value: JSONValue) => JSONValue,
|
|
538
|
+
): JSONValue => {
|
|
539
|
+
if (searchable.length === 0) return json;
|
|
540
|
+
const go = (value: JSONValue): JSONValue => {
|
|
541
|
+
if (typeof value === "string")
|
|
542
|
+
return containsSecret(value, searchable) ? replace(value) : value;
|
|
543
|
+
if (Array.isArray(value)) return value.map(go);
|
|
544
|
+
if (isObject(value)) {
|
|
545
|
+
if (isWrapped(value)) return value;
|
|
546
|
+
// A document's ID and creation time are its identity, never a secret
|
|
547
|
+
// value, even when a secret field holds the same ID. Convex reserves
|
|
548
|
+
// these names for system fields, so no user value is skipped.
|
|
549
|
+
return Object.fromEntries(
|
|
550
|
+
Object.entries(value).map(([key, item]) => [
|
|
551
|
+
key,
|
|
552
|
+
systemFields.has(key) ? item : go(item),
|
|
553
|
+
]),
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
return value;
|
|
557
|
+
};
|
|
558
|
+
return go(json);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const systemFields = new Set(["_id", "_creationTime"]);
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* `json`, a document bound for a snapshot, with every secret its placements put
|
|
565
|
+
* there, and every string holding a kept secret, replaced by a marker that
|
|
566
|
+
* names the raw value's position in `secrets`. The walk never enters a marker,
|
|
567
|
+
* so marking a marked value changes nothing.
|
|
568
|
+
*/
|
|
569
|
+
export const mark = (
|
|
570
|
+
json: JSONValue,
|
|
571
|
+
placements: ReadonlyArray<Placement>,
|
|
572
|
+
secrets: Secrets,
|
|
573
|
+
): JSONValue => {
|
|
574
|
+
const toMarker = (value: JSONValue): JSONValue => ({
|
|
575
|
+
[markerKey]: secrets.keep(value),
|
|
576
|
+
});
|
|
577
|
+
return backstop(
|
|
578
|
+
walkPlaced(json, placements, toMarker),
|
|
579
|
+
secrets.searchable(),
|
|
580
|
+
toMarker,
|
|
581
|
+
);
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* `value` as an artifact other than a snapshot writes it: every secret its
|
|
586
|
+
* placements put there, and every string holding a kept secret, replaced by the
|
|
587
|
+
* marker text.
|
|
588
|
+
*/
|
|
589
|
+
export const markValue = (
|
|
590
|
+
value: Value,
|
|
591
|
+
placements: ReadonlyArray<Placement>,
|
|
592
|
+
secrets: Secrets,
|
|
593
|
+
): Value => {
|
|
594
|
+
let json: JSONValue;
|
|
595
|
+
try {
|
|
596
|
+
json = convexToJson(value);
|
|
597
|
+
} catch {
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
const marked = backstop(
|
|
601
|
+
walkPlaced(json, placements, () => marker),
|
|
602
|
+
secrets.searchable(),
|
|
603
|
+
() => marker,
|
|
604
|
+
);
|
|
605
|
+
return marked === json ? value : jsonToConvex(marked);
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* `json` with each marker replaced by the raw value it names in `secrets`, or
|
|
610
|
+
* `SnapshotSecretMissing` for the first marker the store cannot answer.
|
|
611
|
+
*/
|
|
612
|
+
export const unmark = (
|
|
613
|
+
json: JSONValue,
|
|
614
|
+
secrets: Secrets,
|
|
615
|
+
): Result.Result<JSONValue, KernelRefusal.SnapshotSecretMissing> => {
|
|
616
|
+
let missing: unknown = undefined;
|
|
617
|
+
const go = (value: JSONValue): JSONValue => {
|
|
618
|
+
if (Array.isArray(value)) return value.map(go);
|
|
619
|
+
if (!isObject(value)) return value;
|
|
620
|
+
if (isMarker(value)) {
|
|
621
|
+
const ref = value[markerKey];
|
|
622
|
+
const raw = typeof ref === "number" ? secrets.raw(ref) : undefined;
|
|
623
|
+
if (raw === undefined) missing ??= ref;
|
|
624
|
+
return raw ?? null;
|
|
625
|
+
}
|
|
626
|
+
return Object.fromEntries(
|
|
627
|
+
Object.entries(value).map(([key, item]) => [key, go(item)]),
|
|
628
|
+
);
|
|
629
|
+
};
|
|
630
|
+
const unmarked = go(json);
|
|
631
|
+
return missing === undefined
|
|
632
|
+
? Result.succeed(unmarked)
|
|
633
|
+
: Result.fail(
|
|
634
|
+
new KernelRefusal.SnapshotSecretMissing({
|
|
635
|
+
ref: JSON.stringify(missing),
|
|
636
|
+
message: `The snapshot marks a secret at ${JSON.stringify(missing)}, which the secrets passed to restore do not hold. Restore with the secrets of the backend that exported it.`,
|
|
637
|
+
}),
|
|
638
|
+
);
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* `text` with every kept secret, raw or as it appears inside a JSON string,
|
|
643
|
+
* replaced by the marker.
|
|
644
|
+
*/
|
|
645
|
+
export const scrubText = (text: string, secrets: Secrets): string =>
|
|
646
|
+
secrets.searchable().reduce((scrubbed, secret) => {
|
|
647
|
+
const escaped = JSON.stringify(secret).slice(1, -1);
|
|
648
|
+
return scrubbed.split(secret).join(marker).split(escaped).join(marker);
|
|
649
|
+
}, text);
|
|
650
|
+
|
|
651
|
+
const isPlainObject = (value: object): boolean =>
|
|
652
|
+
Object.getPrototypeOf(value) === Object.prototype ||
|
|
653
|
+
Object.getPrototypeOf(value) === null;
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* A failure report for `thrown`, whatever was thrown: every kept secret in its
|
|
657
|
+
* text, its data and its causes marked. When nothing needs marking it is
|
|
658
|
+
* `thrown` itself. Otherwise it is a copy with the same prototype and fields,
|
|
659
|
+
* so its class and tag still read the same. `thrown` is never changed, so a
|
|
660
|
+
* subscription that keeps the raw error compares it as it was.
|
|
661
|
+
*/
|
|
662
|
+
export const failure = (thrown: unknown, secrets: Secrets): unknown => {
|
|
663
|
+
if (secrets.searchable().length === 0) return thrown;
|
|
664
|
+
const copyable = (value: object) =>
|
|
665
|
+
Array.isArray(value) || value instanceof Error || isPlainObject(value);
|
|
666
|
+
const field = (value: object, key: string | symbol) => {
|
|
667
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
|
|
668
|
+
return {
|
|
669
|
+
raw: "value" in descriptor ? descriptor.value : Reflect.get(value, key),
|
|
670
|
+
enumerable: descriptor.enumerable ?? false,
|
|
671
|
+
};
|
|
672
|
+
};
|
|
673
|
+
// First find whether anything reachable holds a kept secret. Every object is
|
|
674
|
+
// visited once, so a cycle ends.
|
|
675
|
+
const visited = new Set<object>();
|
|
676
|
+
const holdsSecret = (value: unknown): boolean => {
|
|
677
|
+
if (typeof value === "string") return scrubText(value, secrets) !== value;
|
|
678
|
+
if (typeof value !== "object" || value === null) return false;
|
|
679
|
+
if (visited.has(value) || !copyable(value)) return false;
|
|
680
|
+
visited.add(value);
|
|
681
|
+
return Reflect.ownKeys(value).some((key) =>
|
|
682
|
+
holdsSecret(field(value, key).raw),
|
|
683
|
+
);
|
|
684
|
+
};
|
|
685
|
+
if (!holdsSecret(thrown)) return thrown;
|
|
686
|
+
// Then copy every reachable error, array and plain object. Each copy is
|
|
687
|
+
// registered before its fields are walked, so a cycle that leads back to it
|
|
688
|
+
// reaches the copy, never the raw value.
|
|
689
|
+
const copies = new Map<object, unknown>();
|
|
690
|
+
const copy = (value: unknown): unknown => {
|
|
691
|
+
if (typeof value === "string") return scrubText(value, secrets);
|
|
692
|
+
if (typeof value !== "object" || value === null) return value;
|
|
693
|
+
if (copies.has(value)) return copies.get(value);
|
|
694
|
+
if (!copyable(value)) return value;
|
|
695
|
+
if (Array.isArray(value)) {
|
|
696
|
+
// The copy is registered first, so a cycle through it reaches the copy.
|
|
697
|
+
// Its entries and its own other properties, such as a `code`, are copied.
|
|
698
|
+
const items: Array<unknown> = [];
|
|
699
|
+
copies.set(value, items);
|
|
700
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
701
|
+
if (key === "length") continue;
|
|
702
|
+
const { raw, enumerable } = field(value, key);
|
|
703
|
+
if (typeof key === "string" && /^(0|[1-9][0-9]*)$/.test(key))
|
|
704
|
+
items[Number(key)] = copy(raw);
|
|
705
|
+
else
|
|
706
|
+
Object.defineProperty(items, key, {
|
|
707
|
+
value: copy(raw),
|
|
708
|
+
writable: true,
|
|
709
|
+
enumerable,
|
|
710
|
+
configurable: true,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
items.length = value.length;
|
|
714
|
+
return items;
|
|
715
|
+
}
|
|
716
|
+
const target: object = Object.create(Object.getPrototypeOf(value));
|
|
717
|
+
copies.set(value, target);
|
|
718
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
719
|
+
const { raw, enumerable } = field(value, key);
|
|
720
|
+
Object.defineProperty(target, key, {
|
|
721
|
+
value: copy(raw),
|
|
722
|
+
writable: true,
|
|
723
|
+
enumerable,
|
|
724
|
+
configurable: true,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
return target;
|
|
728
|
+
};
|
|
729
|
+
return copy(thrown);
|
|
730
|
+
};
|