@pithy-sh/core 0.10.0 → 0.12.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/dist/access/access.d.ts +172 -0
- package/dist/access/access.d.ts.map +1 -0
- package/dist/access/access.js +147 -0
- package/dist/access/acting.d.ts +49 -0
- package/dist/access/acting.d.ts.map +1 -0
- package/dist/access/acting.js +21 -0
- package/dist/access/policyCheck.d.ts +33 -0
- package/dist/access/policyCheck.d.ts.map +1 -0
- package/dist/access/policyCheck.js +48 -0
- package/dist/access/usage.d.ts +83 -0
- package/dist/access/usage.d.ts.map +1 -0
- package/dist/access/usage.js +104 -0
- package/dist/access/visibility.d.ts +66 -0
- package/dist/access/visibility.d.ts.map +1 -0
- package/dist/access/visibility.js +86 -0
- package/dist/audit/auditEvent.d.ts +5 -0
- package/dist/audit/auditEvent.d.ts.map +1 -1
- package/dist/audit/auditEvent.js +2 -0
- package/dist/audit/recorder.d.ts +10 -2
- package/dist/audit/recorder.d.ts.map +1 -1
- package/dist/capability/bindings.d.ts +47 -2
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +117 -7
- package/dist/capability/capability.d.ts +110 -28
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- package/dist/capability/manifest.d.ts +2 -1
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/manifest.js +1 -1
- package/dist/capability/purge.d.ts +80 -0
- package/dist/capability/purge.d.ts.map +1 -0
- package/dist/capability/purge.js +76 -0
- package/dist/capability/settings.d.ts +86 -2
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/settings.js +80 -1
- package/dist/cli/commandCatalog.generated.d.ts +238 -24
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +251 -22
- package/dist/cli/commandFacts.d.ts +2 -1
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +2 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +19 -2
- package/dist/controlPlane/context.d.ts +5 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -1
- package/dist/controlPlane/data/connection.d.ts +49 -12
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +56 -2
- package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +3 -0
- package/dist/controlPlane/error/errors.d.ts +4 -3
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/http/cors.d.ts +4 -4
- package/dist/controlPlane/http/cors.js +4 -4
- package/dist/controlPlane/http/guard.d.ts +11 -0
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +17 -1
- package/dist/controlPlane/http/handlers.d.ts +139 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +67 -4
- package/dist/controlPlane/http/readiness.d.ts +33 -0
- package/dist/controlPlane/http/readiness.d.ts.map +1 -0
- package/dist/controlPlane/http/readiness.js +39 -0
- package/dist/controlPlane/http/responses.d.ts +245 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +101 -4
- package/dist/controlPlane/http/routes.d.ts +2 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +31 -3
- package/dist/controlPlane/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +2 -1
- package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
- package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
- package/dist/controlPlane/migrationState/migrationState.js +142 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
- package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
- package/dist/controlPlane/scope/scope.d.ts +23 -2
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +24 -3
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +9 -2
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +5 -3
- package/dist/crypto/envelope.d.ts +86 -0
- package/dist/crypto/envelope.d.ts.map +1 -0
- package/dist/crypto/envelope.js +129 -0
- package/dist/crypto/fieldKeys.d.ts +41 -0
- package/dist/crypto/fieldKeys.d.ts.map +1 -0
- package/dist/crypto/fieldKeys.js +56 -0
- package/dist/data/codecs.d.ts +23 -10
- package/dist/data/codecs.d.ts.map +1 -1
- package/dist/data/codecs.js +50 -36
- package/dist/entitlement/allowance.d.ts +25 -0
- package/dist/entitlement/allowance.d.ts.map +1 -0
- package/dist/entitlement/allowance.js +80 -0
- package/dist/entitlement/entitlement.d.ts +41 -0
- package/dist/entitlement/entitlement.d.ts.map +1 -1
- package/dist/entitlement/meter.d.ts +57 -0
- package/dist/entitlement/meter.d.ts.map +1 -0
- package/dist/entitlement/meter.js +13 -0
- package/dist/error/extend.d.ts +6 -5
- package/dist/error/extend.d.ts.map +1 -1
- package/dist/error/http.d.ts +168 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +336 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +116 -4
- package/dist/error/pithyError.d.ts +18 -7
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +6 -0
- package/dist/http/authContext.d.ts +7 -0
- package/dist/http/authContext.d.ts.map +1 -1
- package/dist/http/authContext.js +8 -1
- package/dist/http/scopeGates.d.ts +20 -0
- package/dist/http/scopeGates.d.ts.map +1 -0
- package/dist/http/scopeGates.js +36 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/logger/logger.d.ts +12 -5
- package/dist/logger/logger.d.ts.map +1 -1
- package/dist/logger/record.d.ts +98 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/migrations/capabilitySets.d.ts +23 -0
- package/dist/migrations/capabilitySets.d.ts.map +1 -0
- package/dist/migrations/capabilitySets.js +74 -0
- package/dist/migrations/registry.d.ts +26 -0
- package/dist/migrations/registry.d.ts.map +1 -1
- package/dist/migrations/registry.js +38 -7
- package/dist/migrations/state.d.ts +60 -0
- package/dist/migrations/state.d.ts.map +1 -0
- package/dist/migrations/state.js +46 -0
- package/dist/naming/feature.d.ts +27 -5
- package/dist/naming/feature.d.ts.map +1 -1
- package/dist/naming/feature.js +36 -6
- package/dist/naming/limits.d.ts +47 -3
- package/dist/naming/limits.d.ts.map +1 -1
- package/dist/naming/limits.js +47 -3
- package/dist/naming/provisionScope.d.ts +6 -0
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +8 -3
- package/dist/naming/resourceNames.d.ts +21 -2
- package/dist/naming/resourceNames.d.ts.map +1 -1
- package/dist/naming/resourceNames.js +21 -3
- package/dist/privacy/blindIndex.d.ts +27 -0
- package/dist/privacy/blindIndex.d.ts.map +1 -0
- package/dist/privacy/blindIndex.js +57 -0
- package/dist/privacy/classes.d.ts +142 -0
- package/dist/privacy/classes.d.ts.map +1 -0
- package/dist/privacy/classes.js +209 -0
- package/dist/privacy/classify.d.ts +336 -0
- package/dist/privacy/classify.d.ts.map +1 -0
- package/dist/privacy/classify.js +1024 -0
- package/dist/privacy/encrypted.d.ts +210 -0
- package/dist/privacy/encrypted.d.ts.map +1 -0
- package/dist/privacy/encrypted.js +350 -0
- package/dist/privacy/encryptedColumns.d.ts +64 -0
- package/dist/privacy/encryptedColumns.d.ts.map +1 -0
- package/dist/privacy/encryptedColumns.js +134 -0
- package/dist/privacy/fieldRotation.d.ts +34 -0
- package/dist/privacy/fieldRotation.d.ts.map +1 -0
- package/dist/privacy/fieldRotation.js +186 -0
- package/dist/privacy/inventory.d.ts +12 -0
- package/dist/privacy/inventory.d.ts.map +1 -0
- package/dist/privacy/inventory.js +154 -0
- package/dist/privacy/posture.d.ts +114 -0
- package/dist/privacy/posture.d.ts.map +1 -0
- package/dist/privacy/posture.js +136 -0
- package/dist/privacy/redact.d.ts +7 -0
- package/dist/privacy/redact.d.ts.map +1 -0
- package/dist/privacy/redact.js +404 -0
- package/dist/privacy/report.d.ts +58 -0
- package/dist/privacy/report.d.ts.map +1 -0
- package/dist/privacy/report.js +15 -0
- package/dist/privacy/retirement.d.ts +68 -0
- package/dist/privacy/retirement.d.ts.map +1 -0
- package/dist/privacy/retirement.js +72 -0
- package/dist/privacy/sealed.d.ts +87 -0
- package/dist/privacy/sealed.d.ts.map +1 -0
- package/dist/privacy/sealed.js +221 -0
- package/dist/privacy/types.d.ts +559 -0
- package/dist/privacy/types.d.ts.map +1 -0
- package/dist/privacy/types.js +2 -0
- package/dist/seed/writeD1.d.ts +7 -1
- package/dist/seed/writeD1.d.ts.map +1 -1
- package/dist/seed/writeD1.js +9 -3
- package/dist/seed/writeKv.d.ts +2 -0
- package/dist/seed/writeKv.d.ts.map +1 -1
- package/dist/seed/writeKv.js +6 -2
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/worker/serviceLoopback.d.ts +36 -0
- package/dist/worker/serviceLoopback.d.ts.map +1 -0
- package/dist/worker/serviceLoopback.js +74 -0
- package/dist/workflow/faults.d.ts +12 -1
- package/dist/workflow/faults.d.ts.map +1 -1
- package/dist/workflow/faults.js +22 -15
- package/package.json +2 -2
- package/src/access/access.ts +298 -0
- package/src/access/acting.ts +59 -0
- package/src/access/policyCheck.ts +77 -0
- package/src/access/usage.ts +141 -0
- package/src/access/visibility.ts +109 -0
- package/src/audit/auditEvent.ts +4 -0
- package/src/audit/recorder.ts +10 -2
- package/src/capability/bindings.ts +159 -13
- package/src/capability/capability.ts +114 -29
- package/src/capability/purge.ts +124 -0
- package/src/capability/settings.ts +146 -2
- package/src/cli/commandCatalog.generated.ts +271 -22
- package/src/cli/commandFacts.ts +2 -0
- package/src/controlPlane/capability.ts +36 -1
- package/src/controlPlane/context.ts +4 -0
- package/src/controlPlane/data/connection.ts +90 -0
- package/src/controlPlane/discovery/adminRoute.ts +11 -0
- package/src/controlPlane/error/errors.ts +4 -3
- package/src/controlPlane/http/cors.ts +4 -4
- package/src/controlPlane/http/guard.ts +21 -0
- package/src/controlPlane/http/handlers.ts +98 -4
- package/src/controlPlane/http/readiness.ts +50 -0
- package/src/controlPlane/http/responses.ts +240 -2
- package/src/controlPlane/http/routes.ts +55 -1
- package/src/controlPlane/http/verify.ts +2 -0
- package/src/controlPlane/migrationState/migrationState.ts +200 -0
- package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
- package/src/controlPlane/scope/scope.ts +25 -2
- package/src/controlPlane/token/base64url.ts +1 -1
- package/src/createBackend.ts +28 -6
- package/src/createEntrypoint.ts +9 -3
- package/src/crypto/envelope.ts +196 -0
- package/src/crypto/fieldKeys.ts +76 -0
- package/src/data/codecs.ts +69 -37
- package/src/entitlement/allowance.ts +85 -0
- package/src/entitlement/entitlement.ts +38 -0
- package/src/entitlement/meter.ts +69 -0
- package/src/error/extend.ts +6 -5
- package/src/error/payload.ts +253 -4
- package/src/error/pithyError.ts +21 -7
- package/src/http/authContext.ts +13 -1
- package/src/http/scopeGates.ts +34 -0
- package/src/index.ts +2 -0
- package/src/logger/logger.ts +12 -5
- package/src/migrations/capabilitySets.ts +87 -0
- package/src/migrations/registry.ts +46 -2
- package/src/migrations/state.ts +94 -0
- package/src/naming/feature.ts +46 -7
- package/src/naming/limits.ts +35 -3
- package/src/naming/provisionScope.ts +19 -1
- package/src/naming/resourceNames.ts +52 -5
- package/src/privacy/blindIndex.ts +81 -0
- package/src/privacy/classes.ts +277 -0
- package/src/privacy/classify.ts +1447 -0
- package/src/privacy/encrypted.ts +513 -0
- package/src/privacy/encryptedColumns.ts +185 -0
- package/src/privacy/fieldRotation.ts +234 -0
- package/src/privacy/inventory.ts +147 -0
- package/src/privacy/posture.ts +205 -0
- package/src/privacy/redact.ts +423 -0
- package/src/privacy/report.ts +64 -0
- package/src/privacy/retirement.ts +102 -0
- package/src/privacy/sealed.ts +323 -0
- package/src/privacy/types.ts +804 -0
- package/src/seed/writeD1.ts +13 -4
- package/src/seed/writeKv.ts +6 -2
- package/src/version.generated.ts +1 -1
- package/src/worker/serviceLoopback.ts +105 -0
- package/src/workflow/faults.ts +51 -24
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import type { MigrationProvider } from "kysely/migration";
|
|
6
|
+
import type { Capability } from "../../capability/capability";
|
|
7
|
+
import { composeDatabases } from "../../data/databases";
|
|
8
|
+
import { InternalError } from "../../error/pithyError";
|
|
9
|
+
import { collectMigrationSets } from "../../migrations/capabilitySets";
|
|
10
|
+
import { composedMigrationKey, createMigrationRegistry, decomposeMigrationKey } from "../../migrations/registry";
|
|
11
|
+
import { readMigrationLedger } from "../../migrations/runner";
|
|
12
|
+
import { type MigrationLastGroup, readMigrationState } from "../../migrations/state";
|
|
13
|
+
import type { CapabilityHealthInput } from "../discovery/health";
|
|
14
|
+
import type { HealthSummary } from "../discovery/healthSummary";
|
|
15
|
+
import type { MigrationDatabaseView, MigrationGroupView, MigrationView } from "../http/responses";
|
|
16
|
+
import { MIGRATIONS_READ_SCOPE } from "../scope/scope";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* **Migration state over the control plane (#729).**
|
|
20
|
+
*
|
|
21
|
+
* A management client could not answer what is applied where, or whether a deploy left something behind:
|
|
22
|
+
* the seam registered a ping, the manifest and key rotation, and not one field of it was about a database.
|
|
23
|
+
* The read already existed — {@link readMigrationLedger} answers both directions — and it needs a D1
|
|
24
|
+
* binding and the composed provider in one process. **The Worker is the only thing holding both**, and it
|
|
25
|
+
* is the deployed build, so asked from its side the answer cannot be stale the way the project on disk is.
|
|
26
|
+
*
|
|
27
|
+
* Two halves. At assembly, {@link composeMigrationDatabases} builds what this Worker declares per bound
|
|
28
|
+
* database, through `collectMigrationSets` — the function `pithy migrate` composes with, so the route and
|
|
29
|
+
* `pithy doctor` compose the same way. Not always from the same inputs: on a D1 two Workers share, the CLI
|
|
30
|
+
* merges both Workers' sets and the route holds only its own, so `pending` and `undeclared` can differ
|
|
31
|
+
* there. On a database one Worker binds alone they agree. Per request, {@link readDatabaseMigrations} reads
|
|
32
|
+
* one database for the route and {@link readMigrationHealth} totals all of them for the manifest.
|
|
33
|
+
*
|
|
34
|
+
* **Read-only.** Nothing here runs, reverses or resets a migration. A screen somebody opened to ask must
|
|
35
|
+
* not hand them the thing that changes the schema.
|
|
36
|
+
*
|
|
37
|
+
* **`undeclared` is relative to this Worker.** Two Workers can be bound to one physical D1, and the CLI
|
|
38
|
+
* composes across all of them. A Worker composes only its own capabilities and cannot learn a co-bound
|
|
39
|
+
* Worker's, so from inside one, every migration the other shipped to the shared database reads as
|
|
40
|
+
* undeclared. That is why `undeclaredMigrations` grades nothing.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** One database this Worker binds, with what this build declares for it. Built once, at assembly. */
|
|
44
|
+
export interface ComposedMigrationDatabase {
|
|
45
|
+
/** The database name, as capabilities declare it in `databases`. */
|
|
46
|
+
database: string;
|
|
47
|
+
/** The D1 binding it is read through. */
|
|
48
|
+
binding: string;
|
|
49
|
+
/** Every migration this build declares for it — empty for a database bound with none. */
|
|
50
|
+
provider: MigrationProvider;
|
|
51
|
+
/** Each declared migration's namespace, known from the set it came from. */
|
|
52
|
+
namespaces: ReadonlyMap<string, string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A provider declaring nothing, for a database bound with no migrations: everything applied is undeclared. */
|
|
56
|
+
const NOTHING_DECLARED: MigrationProvider = { getMigrations: async () => ({}) };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every database this Worker binds, with the provider this build composes for it, sorted by name.
|
|
60
|
+
*
|
|
61
|
+
* One entry per database `composeDatabases` maps to a binding — the same map the request registry is built
|
|
62
|
+
* from — so a database bound with no migrations is still reported. Leaving it out would read as not bound.
|
|
63
|
+
*/
|
|
64
|
+
export function composeMigrationDatabases(capabilities: readonly Capability[]): ComposedMigrationDatabase[] {
|
|
65
|
+
const sets = collectMigrationSets([...capabilities]);
|
|
66
|
+
const registry = createMigrationRegistry(sets);
|
|
67
|
+
const bound = composeDatabases([...capabilities]);
|
|
68
|
+
return Object.entries(bound)
|
|
69
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
70
|
+
.map(([database, group]) => {
|
|
71
|
+
const namespaces = new Map<string, string>();
|
|
72
|
+
for (const set of sets) {
|
|
73
|
+
if (set.database !== database) continue;
|
|
74
|
+
for (const localKey of Object.keys(set.migrations))
|
|
75
|
+
namespaces.set(composedMigrationKey(set, localKey), set.namespace);
|
|
76
|
+
}
|
|
77
|
+
return { database, binding: group.binding, provider: registry[database] ?? NOTHING_DECLARED, namespaces };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One migration as the route reports it.
|
|
83
|
+
*
|
|
84
|
+
* A declared migration's namespace comes from the set it was declared in. An undeclared one has only its
|
|
85
|
+
* key, so it is decomposed — and a key no registry composed answers null rather than a guess.
|
|
86
|
+
*/
|
|
87
|
+
export function migrationView(name: string, namespaces: ReadonlyMap<string, string>): MigrationView {
|
|
88
|
+
return { name, namespace: namespaces.get(name) ?? decomposeMigrationKey(name)?.namespace ?? null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The D1 binding for `entry`, or a refusal naming it — a Worker missing the binding cannot read it. */
|
|
92
|
+
function bindingOf(env: Record<string, unknown>, entry: ComposedMigrationDatabase): D1Database {
|
|
93
|
+
const database = env[entry.binding];
|
|
94
|
+
if (database == null) {
|
|
95
|
+
throw new InternalError({
|
|
96
|
+
message: `The ${entry.binding} binding is missing, so database "${entry.database}" cannot be read.`,
|
|
97
|
+
action: `Declare ${entry.binding} in this Worker's wrangler.jsonc, then deploy again.`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return database as D1Database;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function groupView(group: MigrationLastGroup, namespaces: ReadonlyMap<string, string>): MigrationGroupView {
|
|
104
|
+
return {
|
|
105
|
+
group: group.group,
|
|
106
|
+
appliedAt: group.appliedAt.toISOString(),
|
|
107
|
+
migrations: group.migrations.map((name) => migrationView(name, namespaces)),
|
|
108
|
+
position:
|
|
109
|
+
group.position.state === "top"
|
|
110
|
+
? { state: "top" }
|
|
111
|
+
: {
|
|
112
|
+
state: "buried",
|
|
113
|
+
above: group.position.above,
|
|
114
|
+
ungrouped: group.position.ungrouped.map((name) => migrationView(name, namespaces)),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read one database's migration state for the route.
|
|
121
|
+
*
|
|
122
|
+
* **A database that cannot be read is `unreadable`, and costs only itself.** The cause goes to
|
|
123
|
+
* `onUnreadable` — the route logs it, where the operator's context stays the operator's — and never into
|
|
124
|
+
* the view: a D1 error can name a table or a query, and the view crosses to a management client.
|
|
125
|
+
*/
|
|
126
|
+
export async function readDatabaseMigrations(
|
|
127
|
+
env: Record<string, unknown>,
|
|
128
|
+
entry: ComposedMigrationDatabase,
|
|
129
|
+
onUnreadable?: (entry: ComposedMigrationDatabase, cause: unknown) => void,
|
|
130
|
+
): Promise<MigrationDatabaseView> {
|
|
131
|
+
try {
|
|
132
|
+
const state = await readMigrationState(bindingOf(env, entry), entry.provider);
|
|
133
|
+
const view = (name: string) => migrationView(name, entry.namespaces);
|
|
134
|
+
return {
|
|
135
|
+
state: "readable",
|
|
136
|
+
database: entry.database,
|
|
137
|
+
binding: entry.binding,
|
|
138
|
+
applied: state.applied.map(view),
|
|
139
|
+
pending: state.pending.map(view),
|
|
140
|
+
undeclared: state.undeclared.map(view),
|
|
141
|
+
lastGroup: state.lastGroup ? groupView(state.lastGroup, entry.namespaces) : null,
|
|
142
|
+
};
|
|
143
|
+
} catch (cause) {
|
|
144
|
+
onUnreadable?.(entry, cause);
|
|
145
|
+
return { state: "unreadable", database: entry.database, binding: entry.binding };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The two health keys the seam contributes to its own manifest entry.
|
|
151
|
+
*
|
|
152
|
+
* Both `indexed`: one small select per bound database, bounded by the declared migration count rather
|
|
153
|
+
* than by the adopter's data. Both behind {@link MIGRATIONS_READ_SCOPE}, which the seam's own route
|
|
154
|
+
* requires, so a connection without it gets no value rather than a zero.
|
|
155
|
+
*
|
|
156
|
+
* **`undeclaredMigrations` declares no nominal, on purpose.** From inside one Worker, a database shared
|
|
157
|
+
* with another lists the other's migrations as undeclared, so `atMost: 0` would report a corrupted chain
|
|
158
|
+
* on a database that is entirely fine. Null is what a client reads as unknowable, never as healthy (#471).
|
|
159
|
+
*/
|
|
160
|
+
export const MIGRATION_HEALTH_KEYS = [
|
|
161
|
+
{
|
|
162
|
+
key: "pendingMigrations",
|
|
163
|
+
kind: "count",
|
|
164
|
+
states: null,
|
|
165
|
+
scope: MIGRATIONS_READ_SCOPE,
|
|
166
|
+
cost: "indexed",
|
|
167
|
+
summary: "Migrations this build declares that a bound database has not applied.",
|
|
168
|
+
nominal: { atMost: 0 },
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
key: "undeclaredMigrations",
|
|
172
|
+
kind: "count",
|
|
173
|
+
states: null,
|
|
174
|
+
scope: MIGRATIONS_READ_SCOPE,
|
|
175
|
+
cost: "indexed",
|
|
176
|
+
summary:
|
|
177
|
+
"Migrations a bound database holds that this Worker does not declare. Relative to this Worker: a database shared with another lists that Worker's migrations here.",
|
|
178
|
+
nominal: null,
|
|
179
|
+
},
|
|
180
|
+
] as const satisfies CapabilityHealthInput["keys"];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Total pending and undeclared across every bound database, for the manifest.
|
|
184
|
+
*
|
|
185
|
+
* **Throws rather than total a database it could not read.** A partial total is a zero for the database
|
|
186
|
+
* nobody read. The seam catches the throw and reports the summary `unavailable`, which a client reads as
|
|
187
|
+
* unknown — null is never zero.
|
|
188
|
+
*/
|
|
189
|
+
export async function readMigrationHealth(
|
|
190
|
+
env: Record<string, unknown>,
|
|
191
|
+
databases: readonly ComposedMigrationDatabase[],
|
|
192
|
+
): Promise<HealthSummary> {
|
|
193
|
+
const ledgers = await Promise.all(
|
|
194
|
+
databases.map((entry) => readMigrationLedger(bindingOf(env, entry), entry.provider)),
|
|
195
|
+
);
|
|
196
|
+
return {
|
|
197
|
+
pendingMigrations: ledgers.reduce((total, ledger) => total + ledger.pending.length, 0),
|
|
198
|
+
undeclaredMigrations: ledgers.reduce((total, ledger) => total + ledger.undeclared.length, 0),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Kysely } from "kysely";
|
|
5
|
+
import type { Migration } from "kysely/migration";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Who sanctioned a connection, what kind of client it is, and what it is for — `#721`.
|
|
9
|
+
*
|
|
10
|
+
* **Appended, not folded into `0001`.** The seam has shipped and adopters' databases hold rows written by
|
|
11
|
+
* it, so the three columns arrive in a migration of their own, as `0002`'s origin did.
|
|
12
|
+
*
|
|
13
|
+
* **Nullable, with no default, and nothing backfilled.** A row registered before this carries no
|
|
14
|
+
* principal, and none can be recovered: most of the trail that might be mined for one names a token
|
|
15
|
+
* rather than a person, and a principal read off a token name is the self-assertion this issue exists to
|
|
16
|
+
* end. So null means *registered before principals were recorded*, the connection keeps working exactly
|
|
17
|
+
* as it did, its purpose may be set with `pithy dashboard connect --update --purpose`, and its principal
|
|
18
|
+
* is set only by registering again, under a signed-in session.
|
|
19
|
+
*
|
|
20
|
+
* **The Worker reads only `actorKind`**, to stamp it onto the audit rows a connection's calls write. The
|
|
21
|
+
* other two exist for the adopter's own review, on their own side.
|
|
22
|
+
*/
|
|
23
|
+
export const controlplane_0003_connection_principal: Migration = {
|
|
24
|
+
up: async (db: Kysely<unknown>): Promise<void> => {
|
|
25
|
+
await db.schema.alterTable("pithyControlplaneConnections").addColumn("authorizedBy", "text").execute();
|
|
26
|
+
await db.schema.alterTable("pithyControlplaneConnections").addColumn("actorKind", "text").execute();
|
|
27
|
+
await db.schema.alterTable("pithyControlplaneConnections").addColumn("purpose", "text").execute();
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
down: async (db: Kysely<unknown>): Promise<void> => {
|
|
31
|
+
// Dropped column by column, newest first, for the reason `0002` gives: D1's engine drops a column in
|
|
32
|
+
// place, and a twelve-step rebuild would rewrite every row of a table holding live credentials.
|
|
33
|
+
await db.schema.alterTable("pithyControlplaneConnections").dropColumn("purpose").execute();
|
|
34
|
+
await db.schema.alterTable("pithyControlplaneConnections").dropColumn("actorKind").execute();
|
|
35
|
+
await db.schema.alterTable("pithyControlplaneConnections").dropColumn("authorizedBy").execute();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -78,8 +78,31 @@ export const MANIFEST_READ_SCOPE: ControlPlaneScope = "manifest:read";
|
|
|
78
78
|
export const KEYS_ROTATE_SCOPE: ControlPlaneScope = "keys:rotate";
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
81
|
+
* Read where each database this Worker binds stands: what is applied, what is pending, what is undeclared,
|
|
82
|
+
* and which run put it there (#729). Read-only by construction — every route behind it is a `GET`, and
|
|
83
|
+
* nothing over the seam runs, reverses or resets a migration. The two migration health keys on the seam's
|
|
84
|
+
* manifest entry sit behind it too, so a connection without it gets no number rather than a zero.
|
|
85
|
+
*/
|
|
86
|
+
export const MIGRATIONS_READ_SCOPE: ControlPlaneScope = "migrations:read";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read this Worker's full privacy inventory: every storage schema holding a marked field, each marked field's
|
|
90
|
+
* path, its class, and where it is stored (#132). Read-only — the one route behind it is a `GET`.
|
|
91
|
+
*
|
|
92
|
+
* **Its own scope rather than `manifest:read`**, because field names are the kind of thing an adopter may
|
|
93
|
+
* reasonably withhold from a client that has no business seeing them. The manifest's privacy block carries
|
|
94
|
+
* classes and counts only, so a connection without this one still sees the posture headline.
|
|
95
|
+
*/
|
|
96
|
+
export const PRIVACY_READ_SCOPE: ControlPlaneScope = "privacy:read";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The seam's base grant — what `pithy dashboard connect` grants whatever the Worker composes. Ping is absent
|
|
100
|
+
* because it is not grantable; see {@link ANY_VERIFIED_CALLER}.
|
|
101
|
+
*
|
|
102
|
+
* **{@link MIGRATIONS_READ_SCOPE} and {@link PRIVACY_READ_SCOPE} are absent on purpose.** Each is a read —
|
|
103
|
+
* every route requiring it is a `GET` — so `connect` derives it from the seam's `adminRoutes` the way it
|
|
104
|
+
* derives every capability's reads. The base exists for `keys:rotate`, which is not a read and would
|
|
105
|
+
* otherwise drop out.
|
|
83
106
|
*/
|
|
84
107
|
export const SEAM_SCOPES: readonly ControlPlaneScope[] = [MANIFEST_READ_SCOPE, KEYS_ROTATE_SCOPE];
|
|
85
108
|
|
|
@@ -27,7 +27,7 @@ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
|
|
|
27
27
|
export function base64UrlEncode(bytes: Uint8Array): string {
|
|
28
28
|
// Built one character at a time. `String.fromCharCode(...bytes)` overflows the argument stack on
|
|
29
29
|
// inputs this seam genuinely sees — a request body digest, an exported key — so the loop is the
|
|
30
|
-
// implementation, not a style choice. (Same reason as `
|
|
30
|
+
// implementation, not a style choice. (Same reason as `core/src/crypto/envelope.ts`.)
|
|
31
31
|
let binary = "";
|
|
32
32
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
33
33
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
package/src/createBackend.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { Hono } from "hono";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { noActingOrganization } from "./access/acting";
|
|
6
7
|
import { noopEmit } from "./audit/recorder";
|
|
7
8
|
import { BindingSpec } from "./capability/bindings";
|
|
8
9
|
import type {
|
|
@@ -18,6 +19,7 @@ import { validateBindings } from "./capability/validateBindings";
|
|
|
18
19
|
import { pithyCommandOf } from "./cli/command";
|
|
19
20
|
import { isControlPlaneCapability } from "./controlPlane/capability";
|
|
20
21
|
import { allowedOriginSet, corsSurface, registerControlPlaneCors } from "./controlPlane/http/cors";
|
|
22
|
+
import { registerReadinessGuards } from "./controlPlane/http/readiness";
|
|
21
23
|
import { buildDbRegistry, composeDatabases, type DbRegistry } from "./data/databases";
|
|
22
24
|
import { noEntitlementProvider } from "./entitlement/entitlement";
|
|
23
25
|
import { ValidationError } from "./error/pithyError";
|
|
@@ -30,6 +32,7 @@ import type { Logger } from "./logger/logger";
|
|
|
30
32
|
import { bindRequestContext, createWorkerLogger } from "./logger/worker";
|
|
31
33
|
import type { RouteGroup } from "./openapi/document";
|
|
32
34
|
import { recordRouteGroups } from "./openapi/routeGroups";
|
|
35
|
+
import { refuseEncryptedKvStores } from "./privacy/encryptedColumns";
|
|
33
36
|
import { HEALTH_PATH } from "./worker/health";
|
|
34
37
|
import { workerVersion } from "./worker/identity";
|
|
35
38
|
import { registeredWorkflowBinding } from "./workflow/bindings";
|
|
@@ -176,6 +179,9 @@ export function createBackend<
|
|
|
176
179
|
// `@pithy-sh/i18n`'s middleware replaces it per request; nothing else ever does.
|
|
177
180
|
const defaultTranslator = bakedTranslator(catalogFor(messages, DEFAULT_LOCALE));
|
|
178
181
|
const namespaces = composeKv(all);
|
|
182
|
+
// An encrypted field in a KV store is refused here, at boot (#390): a KV envelope cannot be found by key version,
|
|
183
|
+
// so it could never be rotated. The refusal names the store, the field and D1.
|
|
184
|
+
refuseEncryptedKvStores(all);
|
|
179
185
|
const workflows = composeWorkflows(all);
|
|
180
186
|
|
|
181
187
|
// Each named database implies its D1 binding; each KV namespace implies its KV binding; each
|
|
@@ -205,15 +211,19 @@ export function createBackend<
|
|
|
205
211
|
// not here). Defaults to the CF-native Mode 2 logger — structured records to Workers Logs, zero-config.
|
|
206
212
|
const baseLogger = options.logger ?? createWorkerLogger();
|
|
207
213
|
|
|
214
|
+
// Every composed capability's `adminRoutes`, folded per path: the surface CORS answers for, and the
|
|
215
|
+
// surface no readiness guard may refuse. One computation, so the two cannot disagree.
|
|
216
|
+
const adminSurface = corsSurface(all);
|
|
217
|
+
|
|
208
218
|
// **CORS first — before this Worker's own base middleware and before every capability's.**
|
|
209
219
|
//
|
|
210
220
|
// Hono runs middleware in registration order, so whatever registers first is the only thing that can
|
|
211
221
|
// put a header on a response somebody else short-circuits. Capabilities short-circuit for real
|
|
212
|
-
// reasons: `@pithy-sh/vector`'s `provisionGuard
|
|
213
|
-
// when the provision record disagrees with config.
|
|
214
|
-
//
|
|
215
|
-
// opaque `fetch` TypeError this exists to remove
|
|
216
|
-
//
|
|
222
|
+
// reasons: a rate limit, or a readiness guard like `@pithy-sh/vector`'s `provisionGuard`, which
|
|
223
|
+
// throws on every request off the admin surface when the provision record disagrees with config.
|
|
224
|
+
// Register CORS after either and the refusal reaches a browser with no `Access-Control-Allow-Origin`,
|
|
225
|
+
// so the dashboard sees the opaque `fetch` TypeError this exists to remove instead of the reason.
|
|
226
|
+
// Ahead of `validateBindings` for the same reason.
|
|
217
227
|
//
|
|
218
228
|
// It lives here rather than in the capability's `middleware` hook because that hook runs inside the
|
|
219
229
|
// loop below, in the adopter's composition order, and this must not depend on where they listed
|
|
@@ -222,7 +232,7 @@ export function createBackend<
|
|
|
222
232
|
const controlPlane = all.find(isControlPlaneCapability);
|
|
223
233
|
if (controlPlane) {
|
|
224
234
|
const cors = controlPlane.controlPlaneConfig;
|
|
225
|
-
registerControlPlaneCors(app,
|
|
235
|
+
registerControlPlaneCors(app, adminSurface, allowedOriginSet(cors), cors.corsMaxAgeSeconds);
|
|
226
236
|
}
|
|
227
237
|
|
|
228
238
|
let validated = false;
|
|
@@ -233,6 +243,9 @@ export function createBackend<
|
|
|
233
243
|
validated = true;
|
|
234
244
|
}
|
|
235
245
|
if (c.get("auth") === undefined) c.set("auth", null);
|
|
246
|
+
// Nothing in force until `@pithy-sh/organization`'s middleware publishes a resolver. Media and storage
|
|
247
|
+
// read it to scope every record, so a Worker with no tenancy answers null rather than throwing.
|
|
248
|
+
if (c.get("actingOrganization") === undefined) c.set("actingOrganization", noActingOrganization);
|
|
236
249
|
// Null means nothing negotiated, which is not the same fact as "the default was chosen" — only
|
|
237
250
|
// `@pithy-sh/i18n` ever sets it, and `c.var.t` works either way.
|
|
238
251
|
if (c.get("locale") === undefined) c.set("locale", null);
|
|
@@ -316,6 +329,15 @@ export function createBackend<
|
|
|
316
329
|
for (const cap of all) {
|
|
317
330
|
for (const middleware of cap.middleware ?? []) middleware(app);
|
|
318
331
|
}
|
|
332
|
+
// Readiness guards after every capability's middleware, so the verifier, `emit` and every rate limit
|
|
333
|
+
// are in place, and before any capability's routes, so a terminal handler cannot outrun them. Each is
|
|
334
|
+
// skipped on the admin surface: the control plane answers whatever else is wrong with the Worker,
|
|
335
|
+
// because it is how a management client finds out what that is (#763).
|
|
336
|
+
registerReadinessGuards(
|
|
337
|
+
app,
|
|
338
|
+
adminSurface,
|
|
339
|
+
all.flatMap((cap) => cap.readiness ?? []),
|
|
340
|
+
);
|
|
319
341
|
// Each hook's slice of the route table is noted as it mounts, so the OpenAPI contract can tag every
|
|
320
342
|
// operation with the capability that owns it (`openapi/routeGroups.ts`).
|
|
321
343
|
for (const cap of all) {
|
package/src/createEntrypoint.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { ExecutionContext, ForwardableEmailMessage } from "@cloudflare/workers-types";
|
|
5
5
|
import type { Capability, CapabilityEmailHandler } from "./capability/capability";
|
|
6
6
|
import { type CreateBackendOptions, createBackend } from "./createBackend";
|
|
7
|
+
import { withLocalServices } from "./worker/serviceLoopback";
|
|
7
8
|
import { triggerWorkflow } from "./workflow/dispatch";
|
|
8
9
|
import { composeWorkflows, scheduledWorkflows } from "./workflow/register";
|
|
9
10
|
|
|
@@ -88,7 +89,11 @@ export function createEntrypoint<
|
|
|
88
89
|
const registry = composeWorkflows(all);
|
|
89
90
|
const scheduled = scheduledWorkflows(registry);
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
// Under `pithy dev`, a service binding's local stand-in goes into the env before anything reads it — the
|
|
93
|
+
// binding check included (#617). Anywhere else this hands back the env it was given.
|
|
94
|
+
const entrypoint: PithyEntrypoint = {
|
|
95
|
+
fetch: (request, env, ctx) => app.fetch(request, withLocalServices(env), ctx),
|
|
96
|
+
};
|
|
92
97
|
|
|
93
98
|
// Cron-carrying jobs get the Worker's one `scheduled` entry. Each is started independently so one
|
|
94
99
|
// job whose binding is missing cannot stop the rest of the schedule from running; the failure is
|
|
@@ -102,7 +107,7 @@ export function createEntrypoint<
|
|
|
102
107
|
// can never receive on a schedule therefore fails loudly here, which is the author's bug and
|
|
103
108
|
// is exactly what should surface.
|
|
104
109
|
try {
|
|
105
|
-
await triggerWorkflow(env, registry, entry.key, {});
|
|
110
|
+
await triggerWorkflow(withLocalServices(env), registry, entry.key, {});
|
|
106
111
|
} catch (error) {
|
|
107
112
|
failures.push(error);
|
|
108
113
|
}
|
|
@@ -114,7 +119,8 @@ export function createEntrypoint<
|
|
|
114
119
|
// Only expose an `email` handler when something actually handles inbound mail — an inert one would
|
|
115
120
|
// silently drop any message Email Routing delivered to this Worker.
|
|
116
121
|
if (handlers.length > 0) {
|
|
117
|
-
entrypoint.email = async (message,
|
|
122
|
+
entrypoint.email = async (message, given, ctx) => {
|
|
123
|
+
const env = withLocalServices(given);
|
|
118
124
|
if (handlers.length === 1) {
|
|
119
125
|
await handlers[0]?.(message, env, ctx);
|
|
120
126
|
return;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { InternalError } from "../error/pithyError";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* **The AES-256-GCM envelope: one primitive, two callers** (#390).
|
|
9
|
+
*
|
|
10
|
+
* It was `@pithy-sh/secrets`' own, ported from the CMS `secretsCrypto`, and it sealed that capability's rows and
|
|
11
|
+
* nothing else. Field encryption needs the same primitive inside an adopter's own Workers, and a capability may not
|
|
12
|
+
* depend on another's internals (principle 4), so it lives in core now and both callers import it from here. The
|
|
13
|
+
* format did not change: a row secrets sealed before the move opens after it, byte for byte.
|
|
14
|
+
*
|
|
15
|
+
* ## Two version axes
|
|
16
|
+
*
|
|
17
|
+
* `currentVersion` says which key seals new writes, and the `keyVersion` persisted on every envelope says which key
|
|
18
|
+
* opens it. A rotation adds a version, moves the pointer and keeps the old key until nothing is sealed under it, so
|
|
19
|
+
* a row opens under whichever version sealed it, for as long as that version is in the set. This is the
|
|
20
|
+
* *encryption-key* version. A secret's *value* version is a separate concern (`secrets/src/crypto/versionedValue.ts`).
|
|
21
|
+
*
|
|
22
|
+
* The config is the uniform `{ currentVersion, versions }` shape every versioned value uses, plus `lastRotatedAt`
|
|
23
|
+
* beside it as rotation metadata. Version keys are stringified integers, so reading the keys is a full-set read:
|
|
24
|
+
* every still-valid version, never the current one alone.
|
|
25
|
+
*
|
|
26
|
+
* ## The bound context is the caller's, and it is authenticated
|
|
27
|
+
*
|
|
28
|
+
* Every seal binds a **context** string as AES-GCM additional authenticated data. It is not encrypted; it is bound.
|
|
29
|
+
* Without it a ciphertext is valid under the key alone, so one lifted from one row and written into another opens
|
|
30
|
+
* cleanly, and every decision about which value a caller gets lives in the query above. With it, a moved ciphertext
|
|
31
|
+
* fails inside the primitive, however that query is later rewritten.
|
|
32
|
+
*
|
|
33
|
+
* The primitive takes the context whole, because what to bind is the caller's fact, not this module's:
|
|
34
|
+
*
|
|
35
|
+
* - `@pithy-sh/secrets` binds `pithy.secrets.v1:<stored name>` — exactly what it always bound.
|
|
36
|
+
* - A field marked `encrypted()` binds `pithy/enc/1:<declared name>:<field path>:<primary key>` (`../privacy/sealed`).
|
|
37
|
+
*
|
|
38
|
+
* Each prefix is versioned, so a later change to what is bound is a deliberate, visible break, and the two prefixes
|
|
39
|
+
* are distinct, so a secret's ciphertext never opens as a field and a field's never opens as a secret. **What is bound
|
|
40
|
+
* must be stable for the life of the ciphertext**: each caller states why its own context is.
|
|
41
|
+
*
|
|
42
|
+
* An empty context is refused on both halves. It binds nothing, and it would fail open rather than loudly.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The key set a seal draws from: which version is current, every still-valid key, and when it last rotated. */
|
|
46
|
+
export const EncryptionConfig = z
|
|
47
|
+
.object({
|
|
48
|
+
currentVersion: z
|
|
49
|
+
.string()
|
|
50
|
+
.meta({ multiline: false })
|
|
51
|
+
.describe("The version key (a stringified integer) whose key seals new writes — the active key."),
|
|
52
|
+
versions: z
|
|
53
|
+
// Every string leaf of a `json` secret says whether it fits on a terminal line, and a base64 key
|
|
54
|
+
// does. See `cli/promptPlan.ts`; held repo-wide by `packages/cli/src/ci/secretFieldLines.test.ts`.
|
|
55
|
+
.record(z.string(), z.string().meta({ multiline: false }))
|
|
56
|
+
.describe(
|
|
57
|
+
"Every still-valid key: version key (stringified integer) → base64-encoded AES-256 key. Holds the current key plus any prior versions still needed to open values not yet re-sealed.",
|
|
58
|
+
),
|
|
59
|
+
lastRotatedAt: z.iso
|
|
60
|
+
.datetime()
|
|
61
|
+
.meta({ multiline: false })
|
|
62
|
+
.describe(
|
|
63
|
+
"ISO-8601 timestamp of the last rotation; the rotation cron compares against it to decide when to rotate.",
|
|
64
|
+
),
|
|
65
|
+
})
|
|
66
|
+
.describe(
|
|
67
|
+
"A versioned AES-256 key set: the uniform versioned-value shape (currentVersion + versions) plus rotation metadata. Secrets' master key (`SECRETS_ENCRYPTION_KEYS`) and the field key (`PITHY_FIELD_KEYS`) both take it.",
|
|
68
|
+
);
|
|
69
|
+
export type EncryptionConfig = z.output<typeof EncryptionConfig>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One AES-256-GCM envelope: base64 ciphertext, base64 IV, and the key version that sealed it.
|
|
73
|
+
*
|
|
74
|
+
* The bound context is deliberately not in here. It is supplied by the caller on the way back in, from the row's own
|
|
75
|
+
* identity, so a row that has been moved cannot carry its old context with it.
|
|
76
|
+
*/
|
|
77
|
+
export const EncryptedEnvelope = z
|
|
78
|
+
.object({
|
|
79
|
+
encryptedValue: z.string().min(1).describe("The AES-256-GCM ciphertext and tag, base64."),
|
|
80
|
+
iv: z.string().min(1).describe("The 12-byte initialization vector, base64. Fresh for every seal."),
|
|
81
|
+
keyVersion: z
|
|
82
|
+
.number()
|
|
83
|
+
.int()
|
|
84
|
+
.describe("The version of the key that sealed this value: the key set entry that opens it."),
|
|
85
|
+
})
|
|
86
|
+
.describe("An AES-256-GCM envelope: the ciphertext, its IV, and the key version that sealed it.");
|
|
87
|
+
export type EncryptedEnvelope = z.output<typeof EncryptedEnvelope>;
|
|
88
|
+
|
|
89
|
+
/** What the refusals name the key set by — its binding, so an operator knows which entry to inspect. */
|
|
90
|
+
export interface EnvelopeOptions {
|
|
91
|
+
/** The binding the key set was read from, e.g. `SECRETS_ENCRYPTION_KEYS`. Defaults to `the key set`. */
|
|
92
|
+
keys?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Base64-encode raw bytes without spreading into `fromCharCode` (stack-safe, lint-clean). */
|
|
96
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
97
|
+
let binary = "";
|
|
98
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
99
|
+
return btoa(binary);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Decode base64 to raw bytes. */
|
|
103
|
+
export function fromBase64(b64: string): Uint8Array<ArrayBuffer> {
|
|
104
|
+
return Uint8Array.from(atob(b64), (char) => char.charCodeAt(0));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The authenticated data for one seal. An empty context is refused: it would bind nothing. */
|
|
108
|
+
function additionalData(context: string): Uint8Array<ArrayBuffer> {
|
|
109
|
+
if (context.length === 0) {
|
|
110
|
+
throw new InternalError({ detail: "the envelope needs a context to bind; an empty context binds nothing" });
|
|
111
|
+
}
|
|
112
|
+
// Copied through `from` so the view is backed by an `ArrayBuffer` rather than an `ArrayBufferLike`:
|
|
113
|
+
// workers-types types `encode` as the wider one, which no longer satisfies `BufferSource`.
|
|
114
|
+
return Uint8Array.from(new TextEncoder().encode(context));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The raw key bytes for `version`, or a refusal naming the key set. Never the key itself. */
|
|
118
|
+
export function keyBytes(
|
|
119
|
+
config: EncryptionConfig,
|
|
120
|
+
version: number,
|
|
121
|
+
options: EnvelopeOptions = {},
|
|
122
|
+
): Uint8Array<ArrayBuffer> {
|
|
123
|
+
const b64 = config.versions[String(version)];
|
|
124
|
+
if (!b64) {
|
|
125
|
+
throw new InternalError({
|
|
126
|
+
detail: `encryption key version ${version} not present in ${options.keys ?? "the key set"}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return fromBase64(b64);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Import the AES-GCM key for `version` from the config, or throw if that version is absent. */
|
|
133
|
+
async function importKey(
|
|
134
|
+
config: EncryptionConfig,
|
|
135
|
+
version: number,
|
|
136
|
+
usage: ("encrypt" | "decrypt")[],
|
|
137
|
+
options: EnvelopeOptions,
|
|
138
|
+
): Promise<CryptoKey> {
|
|
139
|
+
return crypto.subtle.importKey("raw", keyBytes(config, version, options), "AES-GCM", false, usage);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Seal `plaintext` under the config's current key version, binding `context`. The IV is 12 fresh random bytes, so
|
|
144
|
+
* two seals of one value never share a ciphertext.
|
|
145
|
+
*/
|
|
146
|
+
export async function encryptValue(
|
|
147
|
+
config: EncryptionConfig,
|
|
148
|
+
context: string,
|
|
149
|
+
plaintext: string,
|
|
150
|
+
options: EnvelopeOptions = {},
|
|
151
|
+
): Promise<EncryptedEnvelope> {
|
|
152
|
+
const aad = additionalData(context);
|
|
153
|
+
const keyVersion = Number(config.currentVersion);
|
|
154
|
+
const key = await importKey(config, keyVersion, ["encrypt"], options);
|
|
155
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
156
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
157
|
+
{ name: "AES-GCM", iv, additionalData: aad },
|
|
158
|
+
key,
|
|
159
|
+
new TextEncoder().encode(plaintext),
|
|
160
|
+
);
|
|
161
|
+
return { encryptedValue: toBase64(new Uint8Array(ciphertext)), iv: toBase64(iv), keyVersion };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Open an envelope sealed under `context`. The key version may differ from `currentVersion` during a rotation window;
|
|
166
|
+
* the matching key must still be in the config.
|
|
167
|
+
*
|
|
168
|
+
* A missing key version, a tampered ciphertext, the wrong key, or a ciphertext sealed under another context all throw
|
|
169
|
+
* — never the plaintext or key material. GCM cannot tell those apart, and separating them for the caller would be an
|
|
170
|
+
* oracle: the `detail` names the context the open was *attempted under*, which is what an operator needs. Each caller
|
|
171
|
+
* binds a context composed from validated identifiers, so it is safe in a log; neither binds a value.
|
|
172
|
+
*/
|
|
173
|
+
export async function decryptValue(
|
|
174
|
+
config: EncryptionConfig,
|
|
175
|
+
context: string,
|
|
176
|
+
envelope: EncryptedEnvelope,
|
|
177
|
+
options: EnvelopeOptions = {},
|
|
178
|
+
): Promise<string> {
|
|
179
|
+
const aad = additionalData(context);
|
|
180
|
+
const key = await importKey(config, envelope.keyVersion, ["decrypt"], options);
|
|
181
|
+
try {
|
|
182
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
183
|
+
{ name: "AES-GCM", iv: fromBase64(envelope.iv), additionalData: aad },
|
|
184
|
+
key,
|
|
185
|
+
fromBase64(envelope.encryptedValue),
|
|
186
|
+
);
|
|
187
|
+
return new TextDecoder().decode(plaintext);
|
|
188
|
+
} catch (cause) {
|
|
189
|
+
throw new InternalError(
|
|
190
|
+
{
|
|
191
|
+
detail: `AES-GCM decrypt failed for '${context}' under key version ${envelope.keyVersion}: wrong key, tampered ciphertext, or a ciphertext sealed under a different context`,
|
|
192
|
+
},
|
|
193
|
+
{ cause },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|