@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.
Files changed (270) hide show
  1. package/dist/access/access.d.ts +172 -0
  2. package/dist/access/access.d.ts.map +1 -0
  3. package/dist/access/access.js +147 -0
  4. package/dist/access/acting.d.ts +49 -0
  5. package/dist/access/acting.d.ts.map +1 -0
  6. package/dist/access/acting.js +21 -0
  7. package/dist/access/policyCheck.d.ts +33 -0
  8. package/dist/access/policyCheck.d.ts.map +1 -0
  9. package/dist/access/policyCheck.js +48 -0
  10. package/dist/access/usage.d.ts +83 -0
  11. package/dist/access/usage.d.ts.map +1 -0
  12. package/dist/access/usage.js +104 -0
  13. package/dist/access/visibility.d.ts +66 -0
  14. package/dist/access/visibility.d.ts.map +1 -0
  15. package/dist/access/visibility.js +86 -0
  16. package/dist/audit/auditEvent.d.ts +5 -0
  17. package/dist/audit/auditEvent.d.ts.map +1 -1
  18. package/dist/audit/auditEvent.js +2 -0
  19. package/dist/audit/recorder.d.ts +10 -2
  20. package/dist/audit/recorder.d.ts.map +1 -1
  21. package/dist/capability/bindings.d.ts +47 -2
  22. package/dist/capability/bindings.d.ts.map +1 -1
  23. package/dist/capability/bindings.js +117 -7
  24. package/dist/capability/capability.d.ts +110 -28
  25. package/dist/capability/capability.d.ts.map +1 -1
  26. package/dist/capability/capability.js +2 -0
  27. package/dist/capability/manifest.d.ts +2 -1
  28. package/dist/capability/manifest.d.ts.map +1 -1
  29. package/dist/capability/manifest.js +1 -1
  30. package/dist/capability/purge.d.ts +80 -0
  31. package/dist/capability/purge.d.ts.map +1 -0
  32. package/dist/capability/purge.js +76 -0
  33. package/dist/capability/settings.d.ts +86 -2
  34. package/dist/capability/settings.d.ts.map +1 -1
  35. package/dist/capability/settings.js +80 -1
  36. package/dist/cli/commandCatalog.generated.d.ts +238 -24
  37. package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
  38. package/dist/cli/commandCatalog.generated.js +251 -22
  39. package/dist/cli/commandFacts.d.ts +2 -1
  40. package/dist/cli/commandFacts.d.ts.map +1 -1
  41. package/dist/cli/commandFacts.js +2 -0
  42. package/dist/controlPlane/capability.d.ts.map +1 -1
  43. package/dist/controlPlane/capability.js +19 -2
  44. package/dist/controlPlane/context.d.ts +5 -0
  45. package/dist/controlPlane/context.d.ts.map +1 -1
  46. package/dist/controlPlane/context.js +3 -1
  47. package/dist/controlPlane/data/connection.d.ts +49 -12
  48. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  49. package/dist/controlPlane/data/connection.js +56 -2
  50. package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
  51. package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
  52. package/dist/controlPlane/discovery/adminRoute.js +3 -0
  53. package/dist/controlPlane/error/errors.d.ts +4 -3
  54. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  55. package/dist/controlPlane/http/cors.d.ts +4 -4
  56. package/dist/controlPlane/http/cors.js +4 -4
  57. package/dist/controlPlane/http/guard.d.ts +11 -0
  58. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  59. package/dist/controlPlane/http/guard.js +17 -1
  60. package/dist/controlPlane/http/handlers.d.ts +139 -0
  61. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  62. package/dist/controlPlane/http/handlers.js +67 -4
  63. package/dist/controlPlane/http/readiness.d.ts +33 -0
  64. package/dist/controlPlane/http/readiness.d.ts.map +1 -0
  65. package/dist/controlPlane/http/readiness.js +39 -0
  66. package/dist/controlPlane/http/responses.d.ts +245 -0
  67. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  68. package/dist/controlPlane/http/responses.js +101 -4
  69. package/dist/controlPlane/http/routes.d.ts +2 -0
  70. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  71. package/dist/controlPlane/http/routes.js +31 -3
  72. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  73. package/dist/controlPlane/http/verify.js +2 -1
  74. package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
  75. package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
  76. package/dist/controlPlane/migrationState/migrationState.js +142 -0
  77. package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
  78. package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
  79. package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
  80. package/dist/controlPlane/scope/scope.d.ts +23 -2
  81. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  82. package/dist/controlPlane/scope/scope.js +24 -3
  83. package/dist/createBackend.d.ts.map +1 -1
  84. package/dist/createBackend.js +9 -2
  85. package/dist/createEntrypoint.d.ts.map +1 -1
  86. package/dist/createEntrypoint.js +5 -3
  87. package/dist/crypto/envelope.d.ts +86 -0
  88. package/dist/crypto/envelope.d.ts.map +1 -0
  89. package/dist/crypto/envelope.js +129 -0
  90. package/dist/crypto/fieldKeys.d.ts +41 -0
  91. package/dist/crypto/fieldKeys.d.ts.map +1 -0
  92. package/dist/crypto/fieldKeys.js +56 -0
  93. package/dist/data/codecs.d.ts +23 -10
  94. package/dist/data/codecs.d.ts.map +1 -1
  95. package/dist/data/codecs.js +50 -36
  96. package/dist/entitlement/allowance.d.ts +25 -0
  97. package/dist/entitlement/allowance.d.ts.map +1 -0
  98. package/dist/entitlement/allowance.js +80 -0
  99. package/dist/entitlement/entitlement.d.ts +41 -0
  100. package/dist/entitlement/entitlement.d.ts.map +1 -1
  101. package/dist/entitlement/meter.d.ts +57 -0
  102. package/dist/entitlement/meter.d.ts.map +1 -0
  103. package/dist/entitlement/meter.js +13 -0
  104. package/dist/error/extend.d.ts +6 -5
  105. package/dist/error/extend.d.ts.map +1 -1
  106. package/dist/error/http.d.ts +168 -0
  107. package/dist/error/http.d.ts.map +1 -1
  108. package/dist/error/payload.d.ts +336 -0
  109. package/dist/error/payload.d.ts.map +1 -1
  110. package/dist/error/payload.js +116 -4
  111. package/dist/error/pithyError.d.ts +18 -7
  112. package/dist/error/pithyError.d.ts.map +1 -1
  113. package/dist/error/pithyError.js +6 -0
  114. package/dist/http/authContext.d.ts +7 -0
  115. package/dist/http/authContext.d.ts.map +1 -1
  116. package/dist/http/authContext.js +8 -1
  117. package/dist/http/scopeGates.d.ts +20 -0
  118. package/dist/http/scopeGates.d.ts.map +1 -0
  119. package/dist/http/scopeGates.js +36 -0
  120. package/dist/index.d.ts +1 -1
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +2 -2
  123. package/dist/logger/logger.d.ts +12 -5
  124. package/dist/logger/logger.d.ts.map +1 -1
  125. package/dist/logger/record.d.ts +98 -0
  126. package/dist/logger/record.d.ts.map +1 -1
  127. package/dist/migrations/capabilitySets.d.ts +23 -0
  128. package/dist/migrations/capabilitySets.d.ts.map +1 -0
  129. package/dist/migrations/capabilitySets.js +74 -0
  130. package/dist/migrations/registry.d.ts +26 -0
  131. package/dist/migrations/registry.d.ts.map +1 -1
  132. package/dist/migrations/registry.js +38 -7
  133. package/dist/migrations/state.d.ts +60 -0
  134. package/dist/migrations/state.d.ts.map +1 -0
  135. package/dist/migrations/state.js +46 -0
  136. package/dist/naming/feature.d.ts +27 -5
  137. package/dist/naming/feature.d.ts.map +1 -1
  138. package/dist/naming/feature.js +36 -6
  139. package/dist/naming/limits.d.ts +47 -3
  140. package/dist/naming/limits.d.ts.map +1 -1
  141. package/dist/naming/limits.js +47 -3
  142. package/dist/naming/provisionScope.d.ts +6 -0
  143. package/dist/naming/provisionScope.d.ts.map +1 -1
  144. package/dist/naming/provisionScope.js +8 -3
  145. package/dist/naming/resourceNames.d.ts +21 -2
  146. package/dist/naming/resourceNames.d.ts.map +1 -1
  147. package/dist/naming/resourceNames.js +21 -3
  148. package/dist/privacy/blindIndex.d.ts +27 -0
  149. package/dist/privacy/blindIndex.d.ts.map +1 -0
  150. package/dist/privacy/blindIndex.js +57 -0
  151. package/dist/privacy/classes.d.ts +142 -0
  152. package/dist/privacy/classes.d.ts.map +1 -0
  153. package/dist/privacy/classes.js +209 -0
  154. package/dist/privacy/classify.d.ts +336 -0
  155. package/dist/privacy/classify.d.ts.map +1 -0
  156. package/dist/privacy/classify.js +1024 -0
  157. package/dist/privacy/encrypted.d.ts +210 -0
  158. package/dist/privacy/encrypted.d.ts.map +1 -0
  159. package/dist/privacy/encrypted.js +350 -0
  160. package/dist/privacy/encryptedColumns.d.ts +64 -0
  161. package/dist/privacy/encryptedColumns.d.ts.map +1 -0
  162. package/dist/privacy/encryptedColumns.js +134 -0
  163. package/dist/privacy/fieldRotation.d.ts +34 -0
  164. package/dist/privacy/fieldRotation.d.ts.map +1 -0
  165. package/dist/privacy/fieldRotation.js +186 -0
  166. package/dist/privacy/inventory.d.ts +12 -0
  167. package/dist/privacy/inventory.d.ts.map +1 -0
  168. package/dist/privacy/inventory.js +154 -0
  169. package/dist/privacy/posture.d.ts +114 -0
  170. package/dist/privacy/posture.d.ts.map +1 -0
  171. package/dist/privacy/posture.js +136 -0
  172. package/dist/privacy/redact.d.ts +7 -0
  173. package/dist/privacy/redact.d.ts.map +1 -0
  174. package/dist/privacy/redact.js +404 -0
  175. package/dist/privacy/report.d.ts +58 -0
  176. package/dist/privacy/report.d.ts.map +1 -0
  177. package/dist/privacy/report.js +15 -0
  178. package/dist/privacy/retirement.d.ts +68 -0
  179. package/dist/privacy/retirement.d.ts.map +1 -0
  180. package/dist/privacy/retirement.js +72 -0
  181. package/dist/privacy/sealed.d.ts +87 -0
  182. package/dist/privacy/sealed.d.ts.map +1 -0
  183. package/dist/privacy/sealed.js +221 -0
  184. package/dist/privacy/types.d.ts +559 -0
  185. package/dist/privacy/types.d.ts.map +1 -0
  186. package/dist/privacy/types.js +2 -0
  187. package/dist/seed/writeD1.d.ts +7 -1
  188. package/dist/seed/writeD1.d.ts.map +1 -1
  189. package/dist/seed/writeD1.js +9 -3
  190. package/dist/seed/writeKv.d.ts +2 -0
  191. package/dist/seed/writeKv.d.ts.map +1 -1
  192. package/dist/seed/writeKv.js +6 -2
  193. package/dist/version.generated.d.ts +1 -1
  194. package/dist/version.generated.js +1 -1
  195. package/dist/worker/serviceLoopback.d.ts +36 -0
  196. package/dist/worker/serviceLoopback.d.ts.map +1 -0
  197. package/dist/worker/serviceLoopback.js +74 -0
  198. package/dist/workflow/faults.d.ts +12 -1
  199. package/dist/workflow/faults.d.ts.map +1 -1
  200. package/dist/workflow/faults.js +22 -15
  201. package/package.json +2 -2
  202. package/src/access/access.ts +298 -0
  203. package/src/access/acting.ts +59 -0
  204. package/src/access/policyCheck.ts +77 -0
  205. package/src/access/usage.ts +141 -0
  206. package/src/access/visibility.ts +109 -0
  207. package/src/audit/auditEvent.ts +4 -0
  208. package/src/audit/recorder.ts +10 -2
  209. package/src/capability/bindings.ts +159 -13
  210. package/src/capability/capability.ts +114 -29
  211. package/src/capability/purge.ts +124 -0
  212. package/src/capability/settings.ts +146 -2
  213. package/src/cli/commandCatalog.generated.ts +271 -22
  214. package/src/cli/commandFacts.ts +2 -0
  215. package/src/controlPlane/capability.ts +36 -1
  216. package/src/controlPlane/context.ts +4 -0
  217. package/src/controlPlane/data/connection.ts +90 -0
  218. package/src/controlPlane/discovery/adminRoute.ts +11 -0
  219. package/src/controlPlane/error/errors.ts +4 -3
  220. package/src/controlPlane/http/cors.ts +4 -4
  221. package/src/controlPlane/http/guard.ts +21 -0
  222. package/src/controlPlane/http/handlers.ts +98 -4
  223. package/src/controlPlane/http/readiness.ts +50 -0
  224. package/src/controlPlane/http/responses.ts +240 -2
  225. package/src/controlPlane/http/routes.ts +55 -1
  226. package/src/controlPlane/http/verify.ts +2 -0
  227. package/src/controlPlane/migrationState/migrationState.ts +200 -0
  228. package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
  229. package/src/controlPlane/scope/scope.ts +25 -2
  230. package/src/controlPlane/token/base64url.ts +1 -1
  231. package/src/createBackend.ts +28 -6
  232. package/src/createEntrypoint.ts +9 -3
  233. package/src/crypto/envelope.ts +196 -0
  234. package/src/crypto/fieldKeys.ts +76 -0
  235. package/src/data/codecs.ts +69 -37
  236. package/src/entitlement/allowance.ts +85 -0
  237. package/src/entitlement/entitlement.ts +38 -0
  238. package/src/entitlement/meter.ts +69 -0
  239. package/src/error/extend.ts +6 -5
  240. package/src/error/payload.ts +253 -4
  241. package/src/error/pithyError.ts +21 -7
  242. package/src/http/authContext.ts +13 -1
  243. package/src/http/scopeGates.ts +34 -0
  244. package/src/index.ts +2 -0
  245. package/src/logger/logger.ts +12 -5
  246. package/src/migrations/capabilitySets.ts +87 -0
  247. package/src/migrations/registry.ts +46 -2
  248. package/src/migrations/state.ts +94 -0
  249. package/src/naming/feature.ts +46 -7
  250. package/src/naming/limits.ts +35 -3
  251. package/src/naming/provisionScope.ts +19 -1
  252. package/src/naming/resourceNames.ts +52 -5
  253. package/src/privacy/blindIndex.ts +81 -0
  254. package/src/privacy/classes.ts +277 -0
  255. package/src/privacy/classify.ts +1447 -0
  256. package/src/privacy/encrypted.ts +513 -0
  257. package/src/privacy/encryptedColumns.ts +185 -0
  258. package/src/privacy/fieldRotation.ts +234 -0
  259. package/src/privacy/inventory.ts +147 -0
  260. package/src/privacy/posture.ts +205 -0
  261. package/src/privacy/redact.ts +423 -0
  262. package/src/privacy/report.ts +64 -0
  263. package/src/privacy/retirement.ts +102 -0
  264. package/src/privacy/sealed.ts +323 -0
  265. package/src/privacy/types.ts +804 -0
  266. package/src/seed/writeD1.ts +13 -4
  267. package/src/seed/writeKv.ts +6 -2
  268. package/src/version.generated.ts +1 -1
  269. package/src/worker/serviceLoopback.ts +105 -0
  270. package/src/workflow/faults.ts +51 -24
@@ -0,0 +1,87 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { MigrationProvider } from "kysely/migration";
5
+ import type { Capability } from "../capability/capability";
6
+ import type { DatabaseSpec } from "../data/databases";
7
+ import { InternalError } from "../error/pithyError";
8
+ import { createMigrationRegistry, type NamespacedMigrations } from "./registry";
9
+
10
+ /**
11
+ * **Where a capability's migrations become a registry's sets — one function, composed twice (#729).**
12
+ *
13
+ * `pithy migrate` and `pithy doctor` compose these sets from the project on disk; the Worker composes them
14
+ * from the capabilities it was built with, to answer `GET {basePath}/migrations`. It lived in the CLI until
15
+ * the Worker needed it too. It reads nothing but `Capability.databases`, so it belongs in core — and if
16
+ * the two sides composed with two functions, `pithy doctor` and the route could disagree about one
17
+ * environment with nothing to say which was wrong.
18
+ */
19
+
20
+ /**
21
+ * A retained declaration must name a table this slice declares, beside the migrations that create it.
22
+ *
23
+ * A misspelled key would count a table that does not exist, find no rows, and let the `down` through —
24
+ * the refusal quietly off for the one table it was written for. And retention on a slice with no
25
+ * migrations has no `down` to be recorded on, so nothing would ever enforce it. Both are an author's mistake,
26
+ * caught where the declaration is read rather than discovered when a vault is empty (#588).
27
+ */
28
+ function assertRetainedDeclared(capability: string, database: string, spec: DatabaseSpec): void {
29
+ const retained = spec.retained ?? [];
30
+ if (retained.length === 0) return;
31
+ const unknown = retained.filter((table) => !(table in spec.tables));
32
+ if (unknown.length > 0) {
33
+ throw new InternalError({
34
+ message: `Capability "${capability}" retains ${unknown.map((table) => `"${table}"`).join(", ")} in database "${database}", which its tables do not declare.`,
35
+ action: "Name tables from that database spec's tables in retained.",
36
+ });
37
+ }
38
+ if (!spec.migrations || Object.keys(spec.migrations).length === 0) {
39
+ throw new InternalError({
40
+ message: `Capability "${capability}" retains tables in database "${database}" but ships no migrations for it.`,
41
+ action: "Declare retained beside the migrations that create those tables.",
42
+ });
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Every capability's per-database migration set (`DatabaseSpec.migrations`, namespaced by capability
48
+ * name), in composition order — the raw material {@link buildRegistryFromCapabilities}, the CLI's
49
+ * fan-out runner and the control-plane seam all build on. The runner needs the sets rather than a
50
+ * finished provider because two Workers can be bound to one physical D1: their sets merge into a single
51
+ * ordered provider so that database migrates once, with every Worker's capabilities in it. A Worker
52
+ * composes only its own, which is why the seam's `undeclared` is relative to that Worker. Migrations
53
+ * without a `migrationOrder` fail here, attributed to their capability.
54
+ */
55
+ export function collectMigrationSets(capabilities: Capability[]): NamespacedMigrations[] {
56
+ const sets: NamespacedMigrations[] = [];
57
+ for (const capability of capabilities) {
58
+ for (const [database, spec] of Object.entries(capability.databases ?? {})) {
59
+ assertRetainedDeclared(capability.name, database, spec);
60
+ if (!spec.migrations) continue;
61
+ if (spec.migrationOrder === undefined) {
62
+ throw new InternalError({
63
+ message: `Capability "${capability.name}" has migrations for database "${database}" but no migrationOrder.`,
64
+ action: "Set migrationOrder on that database spec (core low, app high).",
65
+ });
66
+ }
67
+ sets.push({
68
+ database,
69
+ namespace: capability.name,
70
+ order: spec.migrationOrder,
71
+ migrations: spec.migrations,
72
+ ...(spec.retained && spec.retained.length > 0 ? { retained: spec.retained } : {}),
73
+ });
74
+ }
75
+ }
76
+ return sets;
77
+ }
78
+
79
+ /**
80
+ * Compose every capability's per-database migration sets (`DatabaseSpec.migrations`,
81
+ * namespaced by capability name) into one ordered provider per database. A database
82
+ * nobody migrates gets no provider; migrations without a `migrationOrder` fail here,
83
+ * attributed to their capability.
84
+ */
85
+ export function buildRegistryFromCapabilities(capabilities: Capability[]): Record<string, MigrationProvider> {
86
+ return createMigrationRegistry(collectMigrationSets(capabilities));
87
+ }
@@ -62,6 +62,51 @@ function assertValidLocalKey(localKey: string, namespace: string): void {
62
62
  }
63
63
  }
64
64
 
65
+ /**
66
+ * The composed key for one of a set's migrations: `NNNN_<namespace>_<localKey>`. The one place the shape is
67
+ * written, so the registry and anything naming a declared migration's namespace build the same string.
68
+ */
69
+ export function composedMigrationKey(set: Pick<NamespacedMigrations, "order" | "namespace">, localKey: string): string {
70
+ return `${String(set.order).padStart(ORDER_DIGITS, "0")}_${set.namespace}_${localKey}`;
71
+ }
72
+
73
+ /** A composed key taken back apart: the three parts {@link createMigrationRegistry} joined. */
74
+ export interface ComposedMigrationKey {
75
+ /** The capability's `migrationOrder` — the zero-padded prefix, as a number. */
76
+ order: number;
77
+ /** The capability namespace — its `name`, which the manifest reports beside every capability. */
78
+ namespace: string;
79
+ /** The capability's own stable key, e.g. `0001_init`. */
80
+ localKey: string;
81
+ }
82
+
83
+ /**
84
+ * The composed key shape, anchored at both ends: the order prefix, a namespace (which admits no separator,
85
+ * so the first underscore after it is the boundary), then a local key, which may hold any number.
86
+ */
87
+ const COMPOSED_KEY_PATTERN = new RegExp(
88
+ `^(\\d{${ORDER_DIGITS}})_(${NAMESPACE_PATTERN.source.slice(1, -1)})_(${LOCAL_KEY_PATTERN.source.slice(1, -1)})$`,
89
+ );
90
+
91
+ /**
92
+ * Take a composed key back apart — the inverse of the composition below.
93
+ *
94
+ * A declared migration's namespace is known from the set it came from. An **undeclared** one — applied in
95
+ * the database, carried by no provider this build composes — has nothing but its key, so this is the only
96
+ * way to say whose it was. **Null for a key no registry could have composed**, a migration written by hand
97
+ * or by another tool: guessing a namespace for it would be inventing one.
98
+ *
99
+ * The patterns are the same constants the composition enforces, so the two cannot disagree about what a
100
+ * key looks like.
101
+ */
102
+ export function decomposeMigrationKey(key: string): ComposedMigrationKey | null {
103
+ const match = COMPOSED_KEY_PATTERN.exec(key);
104
+ if (!match) return null;
105
+ const [, order, namespace, localKey] = match;
106
+ if (order === undefined || namespace === undefined || localKey === undefined) return null;
107
+ return { order: Number(order), namespace, localKey };
108
+ }
109
+
65
110
  /** Merge one database's sets into a provider, enforcing the per-database invariants. */
66
111
  function composeDatabase(database: string, sets: NamespacedMigrations[]): MigrationProvider {
67
112
  const seenOrders = new Set<number>();
@@ -85,10 +130,9 @@ function composeDatabase(database: string, sets: NamespacedMigrations[]): Migrat
85
130
 
86
131
  const composed: Record<string, Migration> = {};
87
132
  for (const set of [...sets].sort((a, b) => a.order - b.order)) {
88
- const prefix = String(set.order).padStart(ORDER_DIGITS, "0");
89
133
  for (const localKey of Object.keys(set.migrations).sort()) {
90
134
  const migration = set.migrations[localKey];
91
- if (migration) composed[`${prefix}_${set.namespace}_${localKey}`] = migration;
135
+ if (migration) composed[composedMigrationKey(set, localKey)] = migration;
92
136
  }
93
137
  }
94
138
 
@@ -0,0 +1,94 @@
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 { appliedMigrationChain, migrationKysely } from "./bookkeeping";
7
+ import { readGroupPosition, readMigrationGroups } from "./groups";
8
+ import { readMigrationLedger } from "./runner";
9
+
10
+ /**
11
+ * **Where one database stands, read in one place (#729).**
12
+ *
13
+ * The control-plane seam answers `GET {basePath}/migrations` from this: what is applied, what this build
14
+ * declares and the database lacks, what the database holds and this build no longer declares, and which
15
+ * run put it there. Each half already had a reader — {@link readMigrationLedger} for both directions,
16
+ * {@link readMigrationGroups} and {@link readGroupPosition} for the group (#694) — and this composes them
17
+ * rather than reading either table a second way.
18
+ *
19
+ * Read-only. It applies nothing, creates nothing, and reverses nothing: a screen somebody opened to *ask*
20
+ * must not be the thing that changes the schema.
21
+ */
22
+
23
+ /** Whether the last group can still be taken back by name — {@link readGroupPosition}, minus `absent`. */
24
+ export type MigrationGroupStanding =
25
+ | {
26
+ /** The group's migrations are the contiguous tip of this ledger. The CLI's rollback has refusals of its own. */
27
+ state: "top";
28
+ }
29
+ | {
30
+ /** Something sits over the group or inside it, so reversing it by name is refused. */
31
+ state: "buried";
32
+ /** The groups in the way, newest first. */
33
+ above: string[];
34
+ /** The migrations in the way that no group claims, newest first. */
35
+ ungrouped: string[];
36
+ };
37
+
38
+ /** The newest group with anything still applied here, and whether it is still reversible. */
39
+ export interface MigrationLastGroup {
40
+ /** The group's value — what `--rollback --group` takes. */
41
+ group: string;
42
+ /** When it last applied something here. */
43
+ appliedAt: Date;
44
+ /** What it applied here and is still applied, in application order. */
45
+ migrations: string[];
46
+ /** Whether it is the tip of the chain, or what is in its way. */
47
+ position: MigrationGroupStanding;
48
+ }
49
+
50
+ /** One database's migration state. */
51
+ export interface MigrationState {
52
+ /** Every applied migration, in the order the ledger applied them. */
53
+ applied: string[];
54
+ /** Declared and not yet applied, in the order they would run. */
55
+ pending: string[];
56
+ /** Applied and not declared by this build, sorted. Kysely refuses the whole chain on any one of these. */
57
+ undeclared: string[];
58
+ /** The newest group with anything applied here, or null when nothing applied here was ever grouped. */
59
+ lastGroup: MigrationLastGroup | null;
60
+ }
61
+
62
+ /**
63
+ * Read one database's migration state against the provider this build composes for it.
64
+ *
65
+ * **The last group is the newest one with something still applied.** Group rows are forgotten as their
66
+ * migrations come down, so this is ordinarily the newest group recorded. A group whose rows outlived their
67
+ * ledger rows reads as `absent` and is passed over: naming it as where the environment stands would name a
68
+ * release that is no longer in the database.
69
+ */
70
+ export async function readMigrationState(database: D1Database, provider: MigrationProvider): Promise<MigrationState> {
71
+ const { pending, undeclared } = await readMigrationLedger(database, provider);
72
+ const applied = await appliedMigrationChain(migrationKysely(database));
73
+ return { applied, pending, undeclared, lastGroup: await lastGroupOf(database, applied) };
74
+ }
75
+
76
+ /** Walk the groups newest first and stop at the first one this database still holds any of. */
77
+ async function lastGroupOf(database: D1Database, applied: readonly string[]): Promise<MigrationLastGroup | null> {
78
+ const held = new Set(applied);
79
+ const groups = await readMigrationGroups(database);
80
+ for (const group of [...groups].reverse()) {
81
+ const position = await readGroupPosition(database, group.group);
82
+ if (position.state === "absent") continue;
83
+ return {
84
+ group: group.group,
85
+ appliedAt: group.appliedAt,
86
+ migrations: group.migrations.filter((name) => held.has(name)),
87
+ position:
88
+ position.state === "top"
89
+ ? { state: "top" }
90
+ : { state: "buried", above: position.above, ungrouped: position.ungrouped },
91
+ };
92
+ }
93
+ return null;
94
+ }
@@ -34,11 +34,26 @@ import { assertValidProjectName, kebab } from "./resource";
34
34
  * implementation rather than two that drift.
35
35
  */
36
36
 
37
- /** A Cloudflare resource kind a feature can provision. All two characters — `MAX_FEATURE_KIND` in `./limits`. */
38
- export type FeatureResourceKind = "d1" | "kv" | "r2";
37
+ /** A Cloudflare resource kind Pithy names and creates — in a feature, and in every declared environment. */
38
+ export type FeatureResourceKind = "d1" | "kv" | "r2" | "queue";
39
39
 
40
40
  /** Every feature resource kind, for tests and for callers that enumerate. */
41
- export const FEATURE_RESOURCE_KINDS: readonly FeatureResourceKind[] = ["d1", "kv", "r2"];
41
+ export const FEATURE_RESOURCE_KINDS: readonly FeatureResourceKind[] = ["d1", "kv", "r2", "queue"];
42
+
43
+ /**
44
+ * **The segment each kind ends a feature resource's name with — never more than `MAX_FEATURE_KIND` (#662).**
45
+ *
46
+ * `d1`, `kv` and `r2` spell themselves. A queue is `q`, and that is load-bearing: `MAX_FEATURE_KIND` is a term in
47
+ * `FEATURE_DERIVED_PROJECT_NAME`, so spelling it `queue` would take the project budget from 26 to 23 — and a
48
+ * project name is stable forever once anything is provisioned, so a smaller budget orphans every resource of a
49
+ * project already named against the old one. `feature.test.ts` fails on a segment longer than the budget.
50
+ */
51
+ export const FEATURE_KIND_SEGMENT: Readonly<Record<FeatureResourceKind, string>> = {
52
+ d1: "d1",
53
+ kv: "kv",
54
+ r2: "r2",
55
+ queue: "q",
56
+ };
42
57
 
43
58
  /** The identity of a feature — everything a feature name is derived from. */
44
59
  export interface FeatureIdentity {
@@ -292,10 +307,11 @@ export function assertFeatureSlugFits(identity: FeatureIdentity, shapes: readonl
292
307
  }
293
308
 
294
309
  /**
295
- * The full Cloudflare resource name for a feature's binding — `<project>-f<issue>-<slug>--<binding>-<kind>`.
310
+ * The full Cloudflare resource name for a feature's binding — `<project>-f<issue>-<slug>--<binding>-<kind>`, the
311
+ * kind spelled by {@link FEATURE_KIND_SEGMENT}.
296
312
  *
297
- * Held to **R2's 63**, the strictest of the three kinds a feature provisions, so one shape is legal for
298
- * all of them: lowercase, hyphenated, alphanumeric at both ends. A D1 or KV name could be longer, but a
313
+ * Held to **R2's 63**, the strictest of the kinds a feature provisions and a queue's number too, so one shape is
314
+ * legal for all of them: lowercase, hyphenated, alphanumeric at both ends. A D1 or KV name could be longer, but a
299
315
  * feature that provisions a bucket and a database wants them recognizably the same name.
300
316
  */
301
317
  export function featureResourceName(identity: FeatureIdentity, binding: string, kind: FeatureResourceKind): string {
@@ -303,7 +319,7 @@ export function featureResourceName(identity: FeatureIdentity, binding: string,
303
319
  identity,
304
320
  kebab(identity.slug),
305
321
  kebab(binding),
306
- `-${kind}`,
322
+ `-${FEATURE_KIND_SEGMENT[kind]}`,
307
323
  NAMESPACE_LIMITS.r2.maxLength,
308
324
  NAMESPACE_LIMITS.r2.label,
309
325
  );
@@ -364,6 +380,29 @@ export function featureWorkerName(identity: FeatureIdentity, app: string): strin
364
380
  );
365
381
  }
366
382
 
383
+ /** The `<thing>` a feature's Artifacts namespace ends with, after {@link FEATURE_TAIL_SEPARATOR} (#673). */
384
+ export const FEATURE_ARTIFACTS_THING = "artifacts";
385
+
386
+ /**
387
+ * A feature's **Artifacts namespace** — `<project>-f<issue>-<slug>--artifacts` (#673).
388
+ *
389
+ * A declared environment's namespace is `<project>-<env>` and has no `<thing>`. A feature's needs one, because the
390
+ * separator and what follows it are what make a name a feature name: {@link isFeatureOwnedName} reads this one back
391
+ * as this feature's, so `pithy feature destroy` reaps the repos of exactly this branch and of no sibling. Held to the
392
+ * namespace's 63, the same number the rest of a feature's names are derived against, and `artifacts` is shorter than
393
+ * the shortest binding tail, so it moves no slug budget.
394
+ */
395
+ export function featureArtifactsNamespace(identity: FeatureIdentity): string {
396
+ return composeFeatureName(
397
+ identity,
398
+ kebab(identity.slug),
399
+ FEATURE_ARTIFACTS_THING,
400
+ "",
401
+ NAMESPACE_LIMITS.artifactsNamespace.maxLength,
402
+ NAMESPACE_LIMITS.artifactsNamespace.label,
403
+ );
404
+ }
405
+
367
406
  /**
368
407
  * The deployed name of one of a feature's **Workflows** — `<project>-f<issue>-<slug>--<capability>-<job>` (#643).
369
408
  *
@@ -107,6 +107,35 @@ export const NAMESPACE_LIMITS = {
107
107
  * no longer hashes one at 63.
108
108
  */
109
109
  kv: { label: "a KV namespace title", maxLength: 512, minLength: 1, policy: "truncate", source: "cloudflare" },
110
+ /**
111
+ * A Queue name: **1 to 63 characters**, lowercase letters, digits and dashes, starting and ending with a letter
112
+ * or number ([Queues · Get started](https://developers.cloudflare.com/queues/get-started/), verified
113
+ * 2026-10-18). Truncated rather than refused: Pithy recomputes a queue's name from its binding on every run,
114
+ * as it does a bucket's, so a deterministic hash is safe (#662). The same 63 as R2, so a feature's queue fits
115
+ * the shape {@link FEATURE_DERIVED_PROJECT_NAME} is derived against and moves no budget.
116
+ */
117
+ queue: { label: "a Queue name", maxLength: 63, minLength: 1, policy: "truncate", source: "cloudflare" },
118
+ /**
119
+ * An Artifacts namespace: **2 to 63 characters**, starting with a letter or digit, the rest letters, digits, `.`,
120
+ * `_` and `-` ([Artifacts · Limits](https://developers.cloudflare.com/artifacts/platform/limits/), verified
121
+ * 2026-10-18). Truncated rather than refused: the kit derives it from the project and the environment on every
122
+ * run, like an R2 bucket name, and the longest project and environment compose 34 characters, so it never is (#673).
123
+ */
124
+ artifactsNamespace: {
125
+ label: "an Artifacts namespace name",
126
+ maxLength: 63,
127
+ minLength: 2,
128
+ policy: "truncate",
129
+ source: "cloudflare",
130
+ },
131
+ /**
132
+ * An Artifacts repo name. **The charset is Cloudflare's** — a letter or digit, then letters, digits, `.`, `_` and
133
+ * `-` ([Artifacts · Limits](https://developers.cloudflare.com/artifacts/platform/limits/), verified 2026-10-18) —
134
+ * and **the length is ours**: that page states one for a namespace and none for a repo, so 2 to 63 mirrors the
135
+ * namespace rather than inventing a second number. Refused, never truncated: a repo name is either a UUID or a
136
+ * name somebody chose, and a name somebody chose is never silently shortened (#673).
137
+ */
138
+ artifactsRepo: { label: "an Artifacts repo name", maxLength: 63, minLength: 2, policy: "refuse", source: "pithy" },
110
139
  /**
111
140
  * A D1 database name: `^[a-zA-Z0-9][a-zA-Z0-9_-]*$` in the OpenAPI schema, and **no documented
112
141
  * length cap** — the limits page has no row for it. So the ceiling here is {@link MAX_PITHY_NAME}.
@@ -196,7 +225,10 @@ export const FEATURE_TAIL_SEPARATOR = "--";
196
225
  */
197
226
  export const MIN_VERBATIM_BINDING = 12;
198
227
 
199
- /** The longest feature resource kind — `d1`, `kv`, `r2` are all two characters. `feature.test.ts` pins it. */
228
+ /**
229
+ * The longest feature resource kind segment — `d1`, `kv`, `r2`, and a queue's `q` (#662). `feature.test.ts` pins
230
+ * every kind's `FEATURE_KIND_SEGMENT` to it, so a longer segment fails rather than shrinking every project's budget.
231
+ */
200
232
  export const MAX_FEATURE_KIND = 2;
201
233
 
202
234
  /**
@@ -217,7 +249,7 @@ export const WORKFLOW_DERIVED_PROJECT_NAME =
217
249
 
218
250
  /**
219
251
  * What the longest feature resource name leaves a project — `<project>-f<issue>-<slug>--<binding>-<kind>`,
220
- * against R2's 63 (the strictest of the three kinds a feature provisions, and the same number a
252
+ * against R2's 63 (the strictest of the kinds a feature provisions, a queue's number too, and the same number a
221
253
  * feature Worker script gets).
222
254
  *
223
255
  * ```
@@ -226,7 +258,7 @@ export const WORKFLOW_DERIVED_PROJECT_NAME =
226
258
  * - 6 the issue number, six digits — `f999999` (MAX_ISSUE_DIGITS)
227
259
  * - 12 `-` + a legible slug (MIN_LEGIBLE_SLUG)
228
260
  * - 14 `--` + a binding kept whole (FEATURE_TAIL_SEPARATOR, MIN_VERBATIM_BINDING)
229
- * - 3 `-` + the kind, `d1` | `kv` | `r2`
261
+ * - 3 `-` + the kind, `d1` | `kv` | `r2` | `q`
230
262
  * ----
231
263
  * 26
232
264
  * ```
@@ -8,6 +8,7 @@ import { assertValidEnvironment, FEATURE_ENVIRONMENT, GLOBAL_SCOPE, isFeatureMar
8
8
  import {
9
9
  type FeatureIdentity,
10
10
  type FeatureResourceKind,
11
+ featureArtifactsNamespace,
11
12
  featureResourceName,
12
13
  featureSecretEntryName,
13
14
  featureWorkerName,
@@ -189,10 +190,21 @@ export interface ProvisionScope {
189
190
  * `<project>-feature-email-send` would be one Workflow every open branch deployed over every other's.
190
191
  */
191
192
  readonly workflowHost: Omit<WorkflowHostNameParts, "capability">;
193
+ /**
194
+ * The Artifacts namespace an `artifacts` binding names in this scope (#673): `<project>-<env>` in a declared
195
+ * environment, `<project>-f<issue>-<slug>--artifacts` in a feature. On the scope for the reason every other name
196
+ * is: the stanza a run writes and the namespace it writes into it are one decision.
197
+ */
198
+ readonly artifactsNamespace: string;
192
199
  }
193
200
 
194
201
  /** The wrangler binding array a resource kind's name is composed for. */
195
- const KIND_NAMER: Record<FeatureResourceKind, "d1" | "kv" | "r2"> = { d1: "d1", kv: "kv", r2: "r2" };
202
+ const KIND_NAMER: Record<FeatureResourceKind, "d1" | "kv" | "r2" | "queue"> = {
203
+ d1: "d1",
204
+ kv: "kv",
205
+ r2: "r2",
206
+ queue: "queue",
207
+ };
196
208
 
197
209
  /**
198
210
  * The one branch every scope shares: a `global` secret resolves to the project's single
@@ -345,6 +357,7 @@ export function environmentScope(project: string, environment: string): Provisio
345
357
  secretEntry: (secret, secretScope) =>
346
358
  secretEntryName(project, secret, secretScope, () => names.secretEntry(secret)),
347
359
  workflowHost: { project, env: environment },
360
+ artifactsNamespace: names.artifactsNamespace(),
348
361
  };
349
362
  }
350
363
 
@@ -392,6 +405,11 @@ export function featureScope(identity: FeatureIdentity): ProvisionScope {
392
405
  secretEntry: (secret) => featureSecretEntryName(identity, secret),
393
406
  // `env` is the stanza — what the host's `ENVIRONMENT` var says — and `feature` is what its names take.
394
407
  workflowHost: { project: identity.project, env: FEATURE_ENVIRONMENT, feature: identity },
408
+ // The feature's own, read back as its own by `isFeatureOwnedName`, so teardown reaps no sibling's repos. A
409
+ // getter, so a slug too long for it refuses where the namespace is asked for, never where the scope is made.
410
+ get artifactsNamespace() {
411
+ return featureArtifactsNamespace(identity);
412
+ },
395
413
  };
396
414
  }
397
415
 
@@ -4,7 +4,13 @@
4
4
  import { InternalError, ValidationError } from "../error/pithyError";
5
5
  import { workflowHostName, workflowScriptName } from "../workflow/naming";
6
6
  import { assertValidEnvironment, GLOBAL_SCOPE } from "./environment";
7
- import { type FeatureIdentity, type FeatureResourceKind, featureResourceName, featureWorkerName } from "./feature";
7
+ import {
8
+ type FeatureIdentity,
9
+ type FeatureResourceKind,
10
+ featureArtifactsNamespace,
11
+ featureResourceName,
12
+ featureWorkerName,
13
+ } from "./feature";
8
14
  import { NAMESPACE_LIMITS, type NamespaceLimit } from "./limits";
9
15
  import { assertValidProjectName, kebab, resourceName } from "./resource";
10
16
 
@@ -57,6 +63,8 @@ export interface ScopedNames {
57
63
  kv(binding: string): string;
58
64
  /** An R2 bucket. Cloudflare's 63 — the one namespace where that number was always right. */
59
65
  r2(binding: string): string;
66
+ /** A Queue. Cloudflare's 63, truncated with a stable hash like a bucket (#662). */
67
+ queue(binding: string): string;
60
68
  /** A Vectorize index. 64 bytes, refused rather than truncated: a renamed index has no vectors. */
61
69
  vectorizeIndex(index: string): string;
62
70
  /** A Secrets Store entry. No Cloudflare cap; Pithy's ceiling. */
@@ -65,15 +73,33 @@ export interface ScopedNames {
65
73
  apiToken(profile: string): string;
66
74
  }
67
75
 
76
+ /**
77
+ * The names in one declared environment: everything a scope has, and the one name that exists per environment
78
+ * and never at `global`.
79
+ */
80
+ export interface EnvironmentNames extends ScopedNames {
81
+ /**
82
+ * The Artifacts namespace — `<project>-<env>`, with no `<thing>` (#673). One per environment, so a feature's
83
+ * repos never sit beside production's and the per-namespace rate limit splits per environment. There is no
84
+ * global one, because a namespace shared across environments is exactly that mix.
85
+ */
86
+ artifactsNamespace(): string;
87
+ }
88
+
68
89
  /** The names a feature environment provisions under, with the project already bound. */
69
90
  export interface FeatureNames {
70
- /** A feature's D1, KV, or R2 resource — `<project>-f<issue>-<slug>-<binding>-<kind>`. */
91
+ /** A feature's D1, KV, R2 or Queue resource — `<project>-f<issue>-<slug>--<binding>-<kind>`. */
71
92
  resource(binding: string, kind: FeatureResourceKind): string;
72
93
  /**
73
94
  * A feature's Worker script — `<project>-f<issue>-<slug>-<app>`, from the `apps/<app>` directory and
74
95
  * never the deploy name, which already leads with the project (#587).
75
96
  */
76
97
  worker(app: string): string;
98
+ /**
99
+ * A feature's Artifacts namespace — `<project>-f<issue>-<slug>--artifacts` (#673). The tail is what makes it a
100
+ * feature name `isFeatureOwnedName` reads back, so teardown reaps this feature's repos and no sibling's.
101
+ */
102
+ artifactsNamespace(): string;
77
103
  }
78
104
 
79
105
  /** Everything a project can be asked to name. Created once, from the project, by {@link resourceNames}. */
@@ -81,7 +107,7 @@ export interface ProjectNames {
81
107
  /** The kebabbed project name every name below leads with. */
82
108
  readonly project: string;
83
109
  /** The names in one environment. Throws if the environment is not one this scheme accepts. */
84
- env(environment: string): ScopedNames;
110
+ env(environment: string): EnvironmentNames;
85
111
  /** The names shared by every environment — the `global` scope, without typing the word. */
86
112
  readonly global: ScopedNames;
87
113
  /** The names a feature environment provisions under. */
@@ -134,6 +160,22 @@ function scopedName(project: string, scope: string, thing: string, limit: Namesp
134
160
  return name;
135
161
  }
136
162
 
163
+ /**
164
+ * `<project>-<env>`, under the Artifacts namespace rule. The project and environment are both validated by now, and
165
+ * the longest of each compose 34 characters, so the cap is a check rather than a truncation that ever happens.
166
+ */
167
+ function artifactsNamespaceName(project: string, environment: string): string {
168
+ const limit = NAMESPACE_LIMITS.artifactsNamespace;
169
+ const name = `${project}-${environment}`;
170
+ if (name.length > limit.maxLength || name.length < limit.minLength) {
171
+ throw new InternalError({
172
+ message: `"${name}" does not fit ${limit.label}, which takes ${limit.minLength} to ${limit.maxLength} characters.`,
173
+ action: "Shorten the project or environment name.",
174
+ });
175
+ }
176
+ return name;
177
+ }
178
+
137
179
  /** The getters for one scope. `global` and every environment share this, so nothing special-cases either. */
138
180
  function scopedNames(project: string, scope: string): ScopedNames {
139
181
  return {
@@ -142,6 +184,7 @@ function scopedNames(project: string, scope: string): ScopedNames {
142
184
  d1: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.d1),
143
185
  kv: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.kv),
144
186
  r2: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.r2),
187
+ queue: (binding) => scopedName(project, scope, binding, NAMESPACE_LIMITS.queue),
145
188
  vectorizeIndex: (index) => scopedName(project, scope, index, NAMESPACE_LIMITS.vectorizeIndex),
146
189
  secretEntry: (secret) => scopedName(project, scope, secret, NAMESPACE_LIMITS.secretEntry),
147
190
  apiToken: (profile) => scopedName(project, scope, profile, NAMESPACE_LIMITS.apiToken),
@@ -161,9 +204,12 @@ export function resourceNames(project: string): ProjectNames {
161
204
 
162
205
  return {
163
206
  project: normalized,
164
- env(environment: string): ScopedNames {
207
+ env(environment: string): EnvironmentNames {
165
208
  assertValidEnvironment(environment);
166
- return scopedNames(normalized, environment);
209
+ return {
210
+ ...scopedNames(normalized, environment),
211
+ artifactsNamespace: () => artifactsNamespaceName(normalized, environment),
212
+ };
167
213
  },
168
214
  global: scopedNames(normalized, GLOBAL_SCOPE),
169
215
  feature(identity): FeatureNames {
@@ -171,6 +217,7 @@ export function resourceNames(project: string): ProjectNames {
171
217
  return {
172
218
  resource: (binding, kind) => featureResourceName(full, binding, kind),
173
219
  worker: (app) => featureWorkerName(full, app),
220
+ artifactsNamespace: () => featureArtifactsNamespace(full),
174
221
  };
175
222
  },
176
223
  };
@@ -0,0 +1,81 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type EncryptionConfig, keyBytes } from "../crypto/envelope";
5
+ import { FIELD_KEYS_BINDING } from "../crypto/fieldKeys";
6
+ import { type BlindIndexNormalizer, normalizeForIndex } from "./encrypted";
7
+
8
+ /**
9
+ * **A blind index: a keyed hash an encrypted column can be found by** (#390).
10
+ *
11
+ * An encrypted column cannot answer `WHERE email = ?`. A sibling column holding `HMAC(key, normalize(value))` can,
12
+ * for equality and nothing else. One rule here is shared by the app Worker that seals, `sealed().match` that looks
13
+ * up, and the rotator that re-indexes inside the manager, so the three cannot hash one value two ways.
14
+ *
15
+ * - **The key is derived, on the field key's own version axis.** Each version's index key is HKDF-SHA256 over that
16
+ * version's field key, with the label `pithy/blind-index/<normalizer>`. One secret, one rotation.
17
+ * - **The value carries its version**: `<version>:<hmac>`, so the stale count reaches an index exactly as it reaches
18
+ * an envelope, by reading the prefix.
19
+ * - **The normalizer is in the label**, so changing a field's normalizer cannot silently miss against old hashes: they
20
+ * are hashes under a different key, and the change reads as a re-index.
21
+ */
22
+
23
+ /** The HKDF label one normalizer's index keys are derived under. */
24
+ export function blindIndexLabel(normalizer: BlindIndexNormalizer): string {
25
+ return `pithy/blind-index/${normalizer}`;
26
+ }
27
+
28
+ /** Unpadded base64url, the alphabet a blind index is written in. */
29
+ function base64Url(bytes: Uint8Array): string {
30
+ let binary = "";
31
+ for (const byte of bytes) binary += String.fromCharCode(byte);
32
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
33
+ }
34
+
35
+ /** The HMAC key for one version and normalizer: HKDF-SHA256 over that version's field key. */
36
+ export async function blindIndexKey(
37
+ config: EncryptionConfig,
38
+ version: number,
39
+ normalizer: BlindIndexNormalizer,
40
+ ): Promise<CryptoKey> {
41
+ const material = await crypto.subtle.importKey(
42
+ "raw",
43
+ keyBytes(config, version, { keys: FIELD_KEYS_BINDING }),
44
+ "HKDF",
45
+ false,
46
+ ["deriveBits"],
47
+ );
48
+ const bits = await crypto.subtle.deriveBits(
49
+ {
50
+ name: "HKDF",
51
+ hash: "SHA-256",
52
+ salt: new Uint8Array(0),
53
+ info: Uint8Array.from(new TextEncoder().encode(blindIndexLabel(normalizer))),
54
+ },
55
+ material,
56
+ 256,
57
+ );
58
+ return crypto.subtle.importKey("raw", bits, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
59
+ }
60
+
61
+ /** The blind index of `value` under `version`: `<version>:<hmac>`. The value is normalized first. */
62
+ export async function blindIndexValue(
63
+ config: EncryptionConfig,
64
+ version: number,
65
+ normalizer: BlindIndexNormalizer,
66
+ value: string,
67
+ ): Promise<string> {
68
+ const key = await blindIndexKey(config, version, normalizer);
69
+ const mac = await crypto.subtle.sign(
70
+ "HMAC",
71
+ key,
72
+ Uint8Array.from(new TextEncoder().encode(normalizeForIndex(normalizer, value))),
73
+ );
74
+ return `${version}:${base64Url(new Uint8Array(mac))}`;
75
+ }
76
+
77
+ /** The version a blind-index value was written under, or `null` for a value that is not one. */
78
+ export function blindIndexVersion(value: string): number | null {
79
+ const match = /^(\d+):/.exec(value);
80
+ return match ? Number(match[1]) : null;
81
+ }