@pithy-sh/core 0.9.0 → 0.11.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 (298) 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/asyncapi/document.d.ts +46 -0
  17. package/dist/asyncapi/document.d.ts.map +1 -0
  18. package/dist/asyncapi/document.js +115 -0
  19. package/dist/audit/auditEvent.d.ts +5 -0
  20. package/dist/audit/auditEvent.d.ts.map +1 -1
  21. package/dist/audit/auditEvent.js +2 -0
  22. package/dist/capability/bindings.d.ts +47 -2
  23. package/dist/capability/bindings.d.ts.map +1 -1
  24. package/dist/capability/bindings.js +117 -7
  25. package/dist/capability/capability.d.ts +105 -28
  26. package/dist/capability/capability.d.ts.map +1 -1
  27. package/dist/capability/composition.d.ts.map +1 -1
  28. package/dist/capability/composition.js +5 -1
  29. package/dist/capability/manifest.d.ts +2 -1
  30. package/dist/capability/manifest.d.ts.map +1 -1
  31. package/dist/capability/manifest.js +1 -1
  32. package/dist/capability/settings.d.ts +96 -4
  33. package/dist/capability/settings.d.ts.map +1 -1
  34. package/dist/capability/settings.js +85 -4
  35. package/dist/cli/command.d.ts +364 -0
  36. package/dist/cli/command.d.ts.map +1 -0
  37. package/dist/cli/command.js +418 -0
  38. package/dist/cli/commandCatalog.generated.d.ts +2437 -0
  39. package/dist/cli/commandCatalog.generated.d.ts.map +1 -0
  40. package/dist/cli/commandCatalog.generated.js +2564 -0
  41. package/dist/cli/commandFacts.d.ts +164 -0
  42. package/dist/cli/commandFacts.d.ts.map +1 -0
  43. package/dist/cli/commandFacts.js +216 -0
  44. package/dist/controlPlane/capability.d.ts.map +1 -1
  45. package/dist/controlPlane/capability.js +12 -2
  46. package/dist/controlPlane/context.d.ts +5 -0
  47. package/dist/controlPlane/context.d.ts.map +1 -1
  48. package/dist/controlPlane/context.js +3 -1
  49. package/dist/controlPlane/data/connection.d.ts +47 -0
  50. package/dist/controlPlane/data/connection.d.ts.map +1 -1
  51. package/dist/controlPlane/data/connection.js +55 -1
  52. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  53. package/dist/controlPlane/error/errors.js +4 -3
  54. package/dist/controlPlane/http/cors.d.ts +4 -4
  55. package/dist/controlPlane/http/cors.js +4 -4
  56. package/dist/controlPlane/http/guard.d.ts +11 -0
  57. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  58. package/dist/controlPlane/http/guard.js +28 -2
  59. package/dist/controlPlane/http/handlers.d.ts +60 -0
  60. package/dist/controlPlane/http/handlers.d.ts.map +1 -1
  61. package/dist/controlPlane/http/handlers.js +31 -2
  62. package/dist/controlPlane/http/readiness.d.ts +33 -0
  63. package/dist/controlPlane/http/readiness.d.ts.map +1 -0
  64. package/dist/controlPlane/http/readiness.js +39 -0
  65. package/dist/controlPlane/http/responses.d.ts +147 -0
  66. package/dist/controlPlane/http/responses.d.ts.map +1 -1
  67. package/dist/controlPlane/http/responses.js +61 -4
  68. package/dist/controlPlane/http/routes.d.ts +2 -1
  69. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  70. package/dist/controlPlane/http/routes.js +50 -9
  71. package/dist/controlPlane/http/verify.d.ts.map +1 -1
  72. package/dist/controlPlane/http/verify.js +2 -1
  73. package/dist/controlPlane/migrationState/migrationState.d.ts +102 -0
  74. package/dist/controlPlane/migrationState/migrationState.d.ts.map +1 -0
  75. package/dist/controlPlane/migrationState/migrationState.js +142 -0
  76. package/dist/controlPlane/migrations/0003_connection_principal.d.ts +21 -0
  77. package/dist/controlPlane/migrations/0003_connection_principal.d.ts.map +1 -0
  78. package/dist/controlPlane/migrations/0003_connection_principal.js +33 -0
  79. package/dist/controlPlane/scope/scope.d.ts +13 -2
  80. package/dist/controlPlane/scope/scope.d.ts.map +1 -1
  81. package/dist/controlPlane/scope/scope.js +14 -3
  82. package/dist/createBackend.d.ts +7 -0
  83. package/dist/createBackend.d.ts.map +1 -1
  84. package/dist/createBackend.js +41 -6
  85. package/dist/createEntrypoint.d.ts.map +1 -1
  86. package/dist/createEntrypoint.js +5 -3
  87. package/dist/entitlement/allowance.d.ts +25 -0
  88. package/dist/entitlement/allowance.d.ts.map +1 -0
  89. package/dist/entitlement/allowance.js +80 -0
  90. package/dist/entitlement/entitlement.d.ts +41 -0
  91. package/dist/entitlement/entitlement.d.ts.map +1 -1
  92. package/dist/entitlement/meter.d.ts +57 -0
  93. package/dist/entitlement/meter.d.ts.map +1 -0
  94. package/dist/entitlement/meter.js +13 -0
  95. package/dist/entitlement/require.js +1 -1
  96. package/dist/error/cause.d.ts +16 -116
  97. package/dist/error/cause.d.ts.map +1 -1
  98. package/dist/error/cause.js +19 -153
  99. package/dist/error/http.d.ts +184 -0
  100. package/dist/error/http.d.ts.map +1 -1
  101. package/dist/error/payload.d.ts +368 -0
  102. package/dist/error/payload.d.ts.map +1 -1
  103. package/dist/error/payload.js +126 -4
  104. package/dist/error/pithyError.d.ts +44 -0
  105. package/dist/error/pithyError.d.ts.map +1 -1
  106. package/dist/error/pithyError.js +56 -1
  107. package/dist/error/unresolved.d.ts +265 -0
  108. package/dist/error/unresolved.d.ts.map +1 -0
  109. package/dist/error/unresolved.js +599 -0
  110. package/dist/error/wire.d.ts +20 -0
  111. package/dist/error/wire.d.ts.map +1 -0
  112. package/dist/error/wire.js +19 -0
  113. package/dist/http/authContext.d.ts +7 -0
  114. package/dist/http/authContext.d.ts.map +1 -1
  115. package/dist/http/authContext.js +8 -1
  116. package/dist/http/oidcWebhook.d.ts +3 -3
  117. package/dist/http/oidcWebhook.js +3 -3
  118. package/dist/http/routeContract.js +1 -1
  119. package/dist/http/routeSpec.d.ts +66 -0
  120. package/dist/http/routeSpec.d.ts.map +1 -0
  121. package/dist/http/routeSpec.js +40 -0
  122. package/dist/http/sameOrigin.d.ts +1 -1
  123. package/dist/http/sameOrigin.js +1 -1
  124. package/dist/http/scopeGates.d.ts +20 -0
  125. package/dist/http/scopeGates.d.ts.map +1 -0
  126. package/dist/http/scopeGates.js +36 -0
  127. package/dist/http/signedWebhook.d.ts +3 -3
  128. package/dist/http/signedWebhook.js +3 -3
  129. package/dist/http/socket.d.ts +52 -0
  130. package/dist/http/socket.d.ts.map +1 -0
  131. package/dist/http/socket.js +2 -0
  132. package/dist/http/subprotocol.d.ts +29 -0
  133. package/dist/http/subprotocol.d.ts.map +1 -0
  134. package/dist/http/subprotocol.js +37 -0
  135. package/dist/http/validation.d.ts +40 -8
  136. package/dist/http/validation.d.ts.map +1 -1
  137. package/dist/http/validation.js +25 -1
  138. package/dist/index.d.ts +1 -1
  139. package/dist/index.d.ts.map +1 -1
  140. package/dist/index.js +4 -4
  141. package/dist/logger/record.d.ts +107 -0
  142. package/dist/logger/record.d.ts.map +1 -1
  143. package/dist/migrations/bookkeeping.d.ts +21 -0
  144. package/dist/migrations/bookkeeping.d.ts.map +1 -1
  145. package/dist/migrations/bookkeeping.js +32 -3
  146. package/dist/migrations/capabilitySets.d.ts +23 -0
  147. package/dist/migrations/capabilitySets.d.ts.map +1 -0
  148. package/dist/migrations/capabilitySets.js +74 -0
  149. package/dist/migrations/groups.d.ts +140 -0
  150. package/dist/migrations/groups.d.ts.map +1 -0
  151. package/dist/migrations/groups.js +191 -0
  152. package/dist/migrations/migrateStep.d.ts +13 -0
  153. package/dist/migrations/migrateStep.d.ts.map +1 -0
  154. package/dist/migrations/migrateStep.js +20 -0
  155. package/dist/migrations/owner.d.ts +7 -0
  156. package/dist/migrations/owner.d.ts.map +1 -1
  157. package/dist/migrations/owner.js +11 -7
  158. package/dist/migrations/registry.d.ts +26 -0
  159. package/dist/migrations/registry.d.ts.map +1 -1
  160. package/dist/migrations/registry.js +38 -7
  161. package/dist/migrations/retained.js +2 -2
  162. package/dist/migrations/runner.d.ts +76 -5
  163. package/dist/migrations/runner.d.ts.map +1 -1
  164. package/dist/migrations/runner.js +130 -19
  165. package/dist/migrations/state.d.ts +60 -0
  166. package/dist/migrations/state.d.ts.map +1 -0
  167. package/dist/migrations/state.js +46 -0
  168. package/dist/naming/domains.d.ts +17 -0
  169. package/dist/naming/domains.d.ts.map +1 -1
  170. package/dist/naming/domains.js +62 -4
  171. package/dist/naming/environment.d.ts.map +1 -1
  172. package/dist/naming/environment.js +2 -1
  173. package/dist/naming/feature.d.ts +27 -5
  174. package/dist/naming/feature.d.ts.map +1 -1
  175. package/dist/naming/feature.js +36 -6
  176. package/dist/naming/limits.d.ts +47 -3
  177. package/dist/naming/limits.d.ts.map +1 -1
  178. package/dist/naming/limits.js +47 -3
  179. package/dist/naming/packageName.d.ts +17 -0
  180. package/dist/naming/packageName.d.ts.map +1 -0
  181. package/dist/naming/packageName.js +22 -0
  182. package/dist/naming/provisionScope.d.ts +6 -0
  183. package/dist/naming/provisionScope.d.ts.map +1 -1
  184. package/dist/naming/provisionScope.js +10 -4
  185. package/dist/naming/resourceNames.d.ts +21 -2
  186. package/dist/naming/resourceNames.d.ts.map +1 -1
  187. package/dist/naming/resourceNames.js +21 -3
  188. package/dist/openapi/document.d.ts +106 -0
  189. package/dist/openapi/document.d.ts.map +1 -0
  190. package/dist/openapi/document.js +314 -0
  191. package/dist/openapi/routeGroups.d.ts +8 -0
  192. package/dist/openapi/routeGroups.d.ts.map +1 -0
  193. package/dist/openapi/routeGroups.js +23 -0
  194. package/dist/seed/writeD1.d.ts +7 -1
  195. package/dist/seed/writeD1.d.ts.map +1 -1
  196. package/dist/seed/writeD1.js +9 -3
  197. package/dist/seed/writeKv.d.ts +2 -0
  198. package/dist/seed/writeKv.d.ts.map +1 -1
  199. package/dist/seed/writeKv.js +6 -2
  200. package/dist/text/comments.js +66 -4
  201. package/dist/text/literals.d.ts +43 -0
  202. package/dist/text/literals.d.ts.map +1 -0
  203. package/dist/text/literals.js +293 -0
  204. package/dist/version.generated.d.ts +1 -1
  205. package/dist/version.generated.d.ts.map +1 -1
  206. package/dist/version.generated.js +1 -1
  207. package/dist/worker/serviceLoopback.d.ts +36 -0
  208. package/dist/worker/serviceLoopback.d.ts.map +1 -0
  209. package/dist/worker/serviceLoopback.js +74 -0
  210. package/dist/workflow/dispatchRoute.d.ts.map +1 -1
  211. package/dist/workflow/dispatchRoute.js +12 -6
  212. package/dist/workflow/faults.d.ts +12 -1
  213. package/dist/workflow/faults.d.ts.map +1 -1
  214. package/dist/workflow/faults.js +24 -18
  215. package/dist/workflow/hostEnv.d.ts +1 -0
  216. package/dist/workflow/hostEnv.d.ts.map +1 -1
  217. package/dist/workflow/hostEnv.js +4 -2
  218. package/dist/workflow/loopback.d.ts.map +1 -1
  219. package/dist/workflow/loopback.js +2 -1
  220. package/package.json +4 -4
  221. package/src/access/access.ts +298 -0
  222. package/src/access/acting.ts +59 -0
  223. package/src/access/policyCheck.ts +77 -0
  224. package/src/access/usage.ts +141 -0
  225. package/src/access/visibility.ts +109 -0
  226. package/src/asyncapi/document.ts +164 -0
  227. package/src/audit/auditEvent.ts +4 -0
  228. package/src/capability/bindings.ts +159 -13
  229. package/src/capability/capability.ts +106 -29
  230. package/src/capability/composition.ts +2 -1
  231. package/src/capability/settings.ts +165 -4
  232. package/src/cli/command.ts +865 -0
  233. package/src/cli/commandCatalog.generated.ts +2723 -0
  234. package/src/cli/commandFacts.ts +220 -0
  235. package/src/controlPlane/capability.ts +25 -1
  236. package/src/controlPlane/context.ts +4 -0
  237. package/src/controlPlane/data/connection.ts +90 -0
  238. package/src/controlPlane/error/errors.ts +10 -3
  239. package/src/controlPlane/http/cors.ts +4 -4
  240. package/src/controlPlane/http/guard.ts +35 -2
  241. package/src/controlPlane/http/handlers.ts +41 -0
  242. package/src/controlPlane/http/readiness.ts +50 -0
  243. package/src/controlPlane/http/responses.ts +140 -2
  244. package/src/controlPlane/http/routes.ts +82 -10
  245. package/src/controlPlane/http/verify.ts +2 -0
  246. package/src/controlPlane/migrationState/migrationState.ts +200 -0
  247. package/src/controlPlane/migrations/0003_connection_principal.ts +37 -0
  248. package/src/controlPlane/scope/scope.ts +14 -2
  249. package/src/createBackend.ts +59 -8
  250. package/src/createEntrypoint.ts +9 -3
  251. package/src/entitlement/allowance.ts +85 -0
  252. package/src/entitlement/entitlement.ts +38 -0
  253. package/src/entitlement/meter.ts +69 -0
  254. package/src/entitlement/require.ts +1 -1
  255. package/src/error/cause.ts +18 -181
  256. package/src/error/payload.ts +270 -4
  257. package/src/error/pithyError.ts +76 -0
  258. package/src/error/unresolved.ts +853 -0
  259. package/src/error/wire.ts +40 -0
  260. package/src/http/authContext.ts +13 -1
  261. package/src/http/oidcWebhook.ts +3 -3
  262. package/src/http/routeContract.ts +1 -1
  263. package/src/http/routeSpec.ts +81 -0
  264. package/src/http/sameOrigin.ts +1 -1
  265. package/src/http/scopeGates.ts +34 -0
  266. package/src/http/signedWebhook.ts +3 -3
  267. package/src/http/socket.ts +55 -0
  268. package/src/http/subprotocol.ts +47 -0
  269. package/src/http/validation.ts +50 -8
  270. package/src/index.ts +1 -0
  271. package/src/migrations/bookkeeping.ts +39 -2
  272. package/src/migrations/capabilitySets.ts +87 -0
  273. package/src/migrations/groups.ts +295 -0
  274. package/src/migrations/migrateStep.ts +19 -0
  275. package/src/migrations/owner.ts +10 -6
  276. package/src/migrations/registry.ts +46 -2
  277. package/src/migrations/retained.ts +2 -2
  278. package/src/migrations/runner.ts +214 -22
  279. package/src/migrations/state.ts +94 -0
  280. package/src/naming/domains.ts +75 -16
  281. package/src/naming/environment.ts +2 -1
  282. package/src/naming/feature.ts +46 -7
  283. package/src/naming/limits.ts +35 -3
  284. package/src/naming/packageName.ts +20 -0
  285. package/src/naming/provisionScope.ts +21 -2
  286. package/src/naming/resourceNames.ts +52 -5
  287. package/src/openapi/document.ts +412 -0
  288. package/src/openapi/routeGroups.ts +25 -0
  289. package/src/seed/writeD1.ts +13 -4
  290. package/src/seed/writeKv.ts +6 -2
  291. package/src/text/comments.ts +56 -4
  292. package/src/text/literals.ts +294 -0
  293. package/src/version.generated.ts +1 -1
  294. package/src/worker/serviceLoopback.ts +105 -0
  295. package/src/workflow/dispatchRoute.ts +13 -6
  296. package/src/workflow/faults.ts +56 -27
  297. package/src/workflow/hostEnv.ts +9 -1
  298. package/src/workflow/loopback.ts +2 -1
@@ -0,0 +1,220 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * **What the catalog cannot say about a `pithy` command, written down once (#711).**
6
+ *
7
+ * `commandCatalog.generated.ts` is the parser's own account of every command: its arguments, its flags, which
8
+ * are required, which have defaults, which go together. Four facts are not in any parser, and each was the
9
+ * fault behind a line that did not run as written:
10
+ *
11
+ * - **Which commands act on one Worker**, and are refused `input/required` without `--worker` where a project
12
+ * has several. `packages/cli/src/ci/runnableCommands.test.ts` proves every entry by running it in a
13
+ * two-Worker project, and fails on a command that takes `--worker` and is not classified here.
14
+ * - **Which flag's default changes what the command acts on.** `pithy vector provision` with no `--env`
15
+ * provisions `dev`, so a line about staging that leaves it off sends the reader to the wrong environment.
16
+ * - **Which commands destroy something**, and are never a remedy for a smaller fault. `pithy feature destroy`
17
+ * was offered as the fix for one mis-shaped vector index.
18
+ * - **Which flag needs another beside it** that the parser does not enforce: `pithy provision --env` is refused
19
+ * without `--yes`, at run time.
20
+ *
21
+ * `command.ts` builds every command a line names from these and the catalog, and refuses one that breaks them.
22
+ * Nothing here imports anything, so a Worker that names a command pays for two tables and nothing more.
23
+ */
24
+
25
+ /**
26
+ * The commands that act on exactly one Worker, and refuse `input/required` without `--worker` when a project
27
+ * has several and the run cannot ask. Every line that names one names the Worker, by its `apps/` directory.
28
+ */
29
+ export const WORKER_SCOPED = [
30
+ "add",
31
+ "dashboard connect",
32
+ "email provision",
33
+ "media provision",
34
+ "payments provision",
35
+ "storage provision",
36
+ "support provision",
37
+ "testers provision",
38
+ "turnstile deprovision",
39
+ "turnstile provision",
40
+ "ui add",
41
+ "ui sync",
42
+ "vector provision",
43
+ "vector reprocess",
44
+ "vector reset",
45
+ "worker sync",
46
+ ] as const;
47
+
48
+ /** A command that acts on one Worker. */
49
+ export type WorkerScopedPath = (typeof WORKER_SCOPED)[number];
50
+
51
+ /**
52
+ * The commands that take `--worker` and do not need it: each fans out over every Worker, or falls back to the
53
+ * first that answers, and the flag narrows.
54
+ */
55
+ export const WORKER_NARROWS = [
56
+ "contract",
57
+ "dashboard disconnect",
58
+ "dashboard revoke-key",
59
+ "dashboard rotate",
60
+ "dashboard status",
61
+ "doctor",
62
+ "env",
63
+ "init",
64
+ "migrate",
65
+ "secrets tables",
66
+ "seed",
67
+ "support deprovision",
68
+ "upgrade",
69
+ ] as const;
70
+
71
+ /**
72
+ * A command only a person runs, at a terminal, where the Worker is asked for. `pithy remove` refuses every run
73
+ * that cannot ask (`core/person_required`).
74
+ */
75
+ export const PERSON_ONLY = ["remove"] as const;
76
+
77
+ /** A flag that turns a Worker-scoped command into one that acts on no Worker: `pithy add --list` reads the catalog. */
78
+ export const NO_WORKER_MODES: Readonly<Record<string, readonly string[]>> = { add: ["--list"] };
79
+
80
+ /**
81
+ * **A flag whose default decides what the command acts on.** Left off a line, the command still runs — on the
82
+ * default, which is rarely the environment the line was about. Every `--env` with a default is one: `dev` for
83
+ * `vector provision`, `migrate` and `testers list`, `staging` for `payments reconcile`. A line names it, always.
84
+ */
85
+ export const MEANING_FLAGS = ["--env"] as const;
86
+
87
+ /**
88
+ * The commands whose `--env` decides nothing until another flag asks it to: `pithy remove` touches no database
89
+ * without `--drop`, and `pithy upgrade` migrates none without `--migrate`. Without that flag a line need not
90
+ * name the environment; with it, it must.
91
+ */
92
+ export const MEANING_ONLY_WITH = { remove: "--drop", upgrade: "--migrate" } as const;
93
+
94
+ /**
95
+ * **The commands that destroy something, by what they destroy.** None is ever built as a remedy: a line that
96
+ * names one says it is a person's decision and what it deletes (`command.ts`'s `personDecision`), and carries
97
+ * no `fix`. `packages/core/src/cli/command.test.ts` holds every command the catalog has to this list or to
98
+ * {@link NOT_DESTRUCTIVE}, so a new one cannot arrive unexamined.
99
+ */
100
+ export const DESTRUCTIVE_COMMANDS = {
101
+ "dashboard disconnect": "the environment's dashboard connection and the key it signs with",
102
+ "dashboard revoke-key": "a signing key the dashboard holds",
103
+ "email deprovision": "the email Workers, and with --suppression the suppression database",
104
+ "feature destroy": "the feature's worktree, its branch and every resource it provisioned",
105
+ "media deprovision": "the media Worker, and with --storage its bucket",
106
+ remove: "a capability's wiring",
107
+ "secrets deprovision": "the secrets manager, and with --keys the store's keys",
108
+ "secrets rm": "a stored secret",
109
+ "storage deprovision": "the storage Worker, and with --storage its bucket",
110
+ "support deprovision": "the support Workers, and with --storage its attachments",
111
+ "testers deprovision": "the testers Worker",
112
+ "token revoke": "an API token",
113
+ "turnstile deprovision": "the Turnstile widget, whose sitekey every client embeds",
114
+ "vector reset": "every vector in the index",
115
+ "worker remove": "a Worker's directory",
116
+ } as const;
117
+
118
+ /** A command that destroys something. */
119
+ export type DestructivePath = keyof typeof DESTRUCTIVE_COMMANDS;
120
+
121
+ /**
122
+ * **The flags that make a command destroy something**, on any command that declares them. Each is refused by
123
+ * the builder; a line that needs one is a person's decision.
124
+ */
125
+ export const DESTRUCTIVE_FLAGS = {
126
+ "--destroy-retained": "a resource kept for recovery",
127
+ "--drop": "a capability's tables",
128
+ "--redo": "the data a seed wrote",
129
+ "--rollback": "the schema a migration group applied",
130
+ } as const;
131
+
132
+ /** A flag that makes a command destroy something. */
133
+ export type DestructiveFlag = keyof typeof DESTRUCTIVE_FLAGS;
134
+
135
+ /**
136
+ * The commands whose name reads as destructive and which are not, and why. A command named here is built like
137
+ * any other.
138
+ */
139
+ export const NOT_DESTRUCTIVE: Readonly<Record<string, string>> = {
140
+ "cloudflare disconnect": "revokes a sign-in and deletes no data, which `cloudflare connect` gives back",
141
+ "docs disconnect": "removes the docs server from an editor's config, which `docs connect` restores",
142
+ "feature prune": "forgets worktrees that are already gone",
143
+ "testers close": "stops a cohort accruing, and keeps its history",
144
+ "testers remove": "takes one tester off a roster, which `testers invite` reverses",
145
+ };
146
+
147
+ /**
148
+ * **A flag whose value is a person's confirmation**, never written into a line an agent reads: a phrase spelled
149
+ * in a remedy is a confirmation nobody gave. A person's decision names the flag and leaves the phrase to them.
150
+ */
151
+ export const CONFIRM_FLAGS = ["--confirm", "--confirm-production", "--confirm-reset", "--confirm-rollback"] as const;
152
+
153
+ /**
154
+ * When a command asks for a confirmation: always, when a flag is passed (`when`), or in every environment but the
155
+ * ones it trusts (`unlessEnv`) — `vector reset` asks nothing of `dev`, whose index is a laptop's.
156
+ */
157
+ export const CONFIRMATIONS: readonly { path: string; when?: string; unlessEnv?: readonly string[]; flag: string }[] = [
158
+ { path: "vector reset", unlessEnv: ["dev"], flag: "--confirm-reset" },
159
+ { path: "migrate", when: "--rollback", flag: "--confirm-rollback" },
160
+ { path: "seed", when: "--redo", flag: "--confirm-reset" },
161
+ ];
162
+
163
+ /**
164
+ * **A flag whose value is a credential**, never written into a line: an error's action reaches logs, a terminal
165
+ * scrollback and a `--json` consumer, none of which should hold a key. A run named again carries the flag's name
166
+ * and leaves its value to whoever passed it.
167
+ */
168
+ export const CREDENTIAL_FLAGS = [
169
+ "--api-token",
170
+ "--r2-access-key-id",
171
+ "--r2-api-token",
172
+ "--r2-secret-access-key",
173
+ ] as const;
174
+
175
+ /**
176
+ * The environments every project treats as production. A project can name more in `seed.productionEnvironments`;
177
+ * a builder that has them passes them.
178
+ */
179
+ export const PRODUCTION_ENVIRONMENTS = ["prod", "production"] as const;
180
+
181
+ /**
182
+ * **What a production run of a command needs from a person**, by command: the confirmation it asks for, or `null`
183
+ * for one that asks none. A production provision or seed is never a remedy — a line names it as a person's
184
+ * decision either way, and names a confirmation only when the command declares it: `testers provision` and
185
+ * `vector provision` take no `--confirm`, and a line that told a person to pass one would be refused.
186
+ */
187
+ export const PRODUCTION_CONFIRMS = {
188
+ provision: "--confirm",
189
+ seed: "--confirm-production",
190
+ "secrets provision": null,
191
+ "testers provision": null,
192
+ "vector provision": null,
193
+ } as const satisfies Readonly<Record<string, string | null>>;
194
+
195
+ /** A command whose production run is a person's decision. */
196
+ export type ProductionPath = keyof typeof PRODUCTION_CONFIRMS;
197
+
198
+ /**
199
+ * **A command that acts on every environment when its `--env` is left off (#711)**, production among them. `pithy
200
+ * testers provision` with no `--env` provisions every declared environment, so the bare line touches production as
201
+ * surely as `--env prod` does, and is a person's decision for the same reason. Each declares `--env` with no
202
+ * default; `command.test.ts` holds that against the catalog.
203
+ */
204
+ export const EVERY_ENVIRONMENT_WITHOUT_ENV = ["testers provision"] as const satisfies readonly ProductionPath[];
205
+
206
+ /**
207
+ * **A command that acts on every environment on every run (#711)**, production among them, with no `--env` to narrow
208
+ * it. `pithy secrets provision` deploys each declared environment's manager and mints its store entries, so every line
209
+ * naming it touches production and is a person's decision — never the fix for one binding, one database or one
210
+ * setting. None declares `--env`; `command.test.ts` holds that against the catalog.
211
+ */
212
+ export const EVERY_ENVIRONMENT_ALWAYS = ["secrets provision"] as const satisfies readonly ProductionPath[];
213
+
214
+ /**
215
+ * A flag a command refuses to run without, beside another, that the parser does not enforce. `pithy provision
216
+ * --env <env>` with no `--yes` is refused before it does anything; a dry run needs neither.
217
+ */
218
+ export const REQUIRED_BESIDE: readonly { path: string; with: string; needs: string; unless: string }[] = [
219
+ { path: "provision", with: "--env", needs: "--yes", unless: "--dry-run" },
220
+ ];
@@ -13,11 +13,18 @@ import { ControlPlaneConfig, type ControlPlaneConfigInput } from "./config/confi
13
13
  import { ControlPlaneConnection } from "./data/connection";
14
14
  import { CONTROL_PLANE_CONNECTIONS_TABLE, type ControlPlaneDatabase, controlPlaneTables } from "./data/tables";
15
15
  import type { CapabilityDeclaration } from "./discovery/adminRoute";
16
- import { type CapabilityHealthSource, capabilityHealthSources } from "./discovery/health";
16
+ import { type CapabilityHealthSource, capabilityHealthSources, defineCapabilityHealth } from "./discovery/health";
17
17
  import { createControlPlaneVerifier } from "./http/guard";
18
18
  import { controlPlaneRouteDescriptors, registerControlPlaneRoutes } from "./http/routes";
19
+ import {
20
+ type ComposedMigrationDatabase,
21
+ composeMigrationDatabases,
22
+ MIGRATION_HEALTH_KEYS,
23
+ readMigrationHealth,
24
+ } from "./migrationState/migrationState";
19
25
  import { controlplane_0001_init } from "./migrations/0001_init";
20
26
  import { controlplane_0002_management_origin } from "./migrations/0002_management_origin";
27
+ import { controlplane_0003_connection_principal } from "./migrations/0003_connection_principal";
21
28
  import { d1ReplayGuard } from "./replay/d1Guard";
22
29
  import type { ReplayGuard } from "./replay/guard";
23
30
  import {
@@ -79,6 +86,7 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
79
86
  const migrations: Record<string, Migration> = {
80
87
  "0001_init": controlplane_0001_init,
81
88
  "0002_management_origin": controlplane_0002_management_origin,
89
+ "0003_connection_principal": controlplane_0003_connection_principal,
82
90
  };
83
91
  const requiredBindings: BindingSpecInput[] = [
84
92
  // The app database — the connections table lives here, beside auth's and audit's. Not the secrets
@@ -132,6 +140,12 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
132
140
  // assembly, because an adopter is never offered such a scope and the number could never be granted.
133
141
  let health: ReadonlyMap<string, CapabilityHealthSource> = new Map();
134
142
 
143
+ // Every database this Worker binds and what this build declares for it (#729), composed by the same hook
144
+ // through `collectMigrationSets` — the function `pithy migrate` uses, so the route and `pithy doctor`
145
+ // compose the same way. The inputs differ on a D1 two Workers share: the CLI merges every co-bound
146
+ // Worker's sets and this Worker holds only its own, so `pending` and `undeclared` can differ there.
147
+ let migrationDatabases: readonly ComposedMigrationDatabase[] = [];
148
+
135
149
  const capability = defineCapability({
136
150
  name: "controlplane",
137
151
  // `@pithy-sh/core`'s version, stamped by `scripts/stampVersions.ts` — a Worker cannot read its own
@@ -164,6 +178,14 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
164
178
  // The seam describes itself too. Built from the same resolved `basePath` the routes mount on, so a
165
179
  // moved mount point is reported rather than becoming a lie a client believes.
166
180
  adminRoutes: controlPlaneRouteDescriptors(config.basePath),
181
+ // Two counts on the seam's own manifest entry, so a client sees *this environment is behind* on the read
182
+ // it already makes and calls the route only to see what. Behind `migrations:read`, which the route above
183
+ // requires — a connection without it gets no number rather than a zero. A database that cannot be read
184
+ // fails the producer, and the seam reports the summary unavailable rather than a partial total.
185
+ health: defineCapabilityHealth({
186
+ keys: MIGRATION_HEALTH_KEYS,
187
+ read: (c) => readMigrationHealth(c.env as Record<string, unknown>, migrationDatabases),
188
+ }),
167
189
  compose: ({ capabilities }) => {
168
190
  // Every capability, including those exposing nothing. A client rendering a capability it cannot
169
191
  // act on is useful; a client that cannot tell "no admin surface" from "not installed" is not.
@@ -186,6 +208,7 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
186
208
  config: { ...(cap.manifestConfig?.values ?? {}) },
187
209
  }));
188
210
  health = capabilityHealthSources(capabilities);
211
+ migrationDatabases = composeMigrationDatabases(capabilities);
189
212
  },
190
213
  middleware: [
191
214
  (app) => {
@@ -228,6 +251,7 @@ export function controlplane(options: ControlPlaneOptions = {}): ControlPlaneCap
228
251
  database,
229
252
  composedCapabilities: () => composed,
230
253
  composedHealth: () => health,
254
+ composedMigrations: () => migrationDatabases,
231
255
  now,
232
256
  });
233
257
  },
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
+ import { ControlPlaneActorKind } from "./data/connection";
5
6
  import { ControlPlaneScope } from "./scope/scope";
6
7
 
7
8
  /**
@@ -56,6 +57,9 @@ export const ControlPlaneContext = z
56
57
  tokenId: z
57
58
  .string()
58
59
  .describe("The token's `jti`, already claimed in the replay set. Recorded to tie one audit event to one call."),
60
+ actorKind: ControlPlaneActorKind.nullable().describe(
61
+ "What kind of client the connection is for — `agent`, `dashboard` or `script` — read from the adopter's own row, never from the token. The gate stamps it onto every control-plane audit row this call writes. Null for a connection registered before the kind was recorded (#721).",
62
+ ),
59
63
  })
60
64
  .describe(
61
65
  "The verified control-plane caller for one request. Never an AuthContext: a management client is not a user of the adopter's app.",
@@ -66,6 +66,81 @@ export const RegisteredKey = z
66
66
  .describe("One registered public key with its validity window. Two live at once during a rotation overlap.");
67
67
  export type RegisteredKey = z.output<typeof RegisteredKey>;
68
68
 
69
+ /**
70
+ * What kind of client a connection is for (#721): `agent`, `dashboard` or `script`.
71
+ *
72
+ * The audit trail's `actorType: "control-plane"` names all three identically, and the questions asked
73
+ * after an incident are exactly the ones that tell them apart — was it a person at the dashboard, a
74
+ * script somebody wrote, or an agent running unattended. So the kind is recorded on the connection when
75
+ * it is registered and stamped from there onto every row its calls write.
76
+ *
77
+ * It follows the path, never a guess: the device-code flow is a dashboard, always, and a key the
78
+ * operator registered themselves is whichever of the other two they say, out loud, with `--kind`.
79
+ */
80
+ export const ControlPlaneActorKind = z
81
+ .enum(["agent", "dashboard", "script"])
82
+ .describe(
83
+ "What kind of client a connection is for: `agent` (an automated client acting on its own judgment, often unattended), `dashboard` (a management dashboard a person drives, registered through the device-code flow), or `script` (an operator's own automation, doing one fixed thing).",
84
+ );
85
+ export type ControlPlaneActorKind = z.output<typeof ControlPlaneActorKind>;
86
+
87
+ /**
88
+ * Who sanctioned a connection: the email address of the signed-in Cloudflare session that registered it.
89
+ *
90
+ * **A plain string, deliberately not a reference.** An adopter's `pithy_auth_users` are their customers,
91
+ * and the operator who registers a connection is their staff, so a join into the organization model would
92
+ * be empty for most of them. A string is readable by anybody, and matchable against a directory where the
93
+ * operator is in one.
94
+ *
95
+ * **Derived, never asserted.** No flag supplies it; the CLI reads it from `GET /user` under the session.
96
+ */
97
+ export const ConnectionPrincipal = z
98
+ .email()
99
+ .max(254)
100
+ .describe(
101
+ "The email address of the person who sanctioned this connection, read from their signed-in Cloudflare session at registration. Never typed, so never asserted.",
102
+ );
103
+ export type ConnectionPrincipal = z.output<typeof ConnectionPrincipal>;
104
+
105
+ /** The longest purpose a connection may carry. One line on a review screen, with room to say what it does. */
106
+ export const MAX_CONNECTION_PURPOSE = 200;
107
+
108
+ /**
109
+ * Characters a purpose may not carry: every C0 and C1 control (so no terminal escape, no newline and no
110
+ * carriage return), the Unicode line and paragraph separators, every `Bidi_Control` character — the
111
+ * embeddings, overrides and isolates, and the LRM, RLM and ALM marks — that lets text render in a
112
+ * different order from the one it is stored in, and the invisible zero-width space, word joiner and
113
+ * byte-order mark that split a word without showing it. The zero-width joiner and non-joiner stay: Persian
114
+ * and the Indic scripts cannot be written without them.
115
+ *
116
+ * Each of those is a way for a sentence to look like something else once it reaches a renderer: a line
117
+ * in `pithy dashboard status` forged beneath the real one, a screen cleared, a scope name read backwards.
118
+ * Escaping them at every renderer is a promise each renderer has to keep. Refusing them at the boundary
119
+ * is one promise, kept here.
120
+ */
121
+ const UNRENDERABLE = /[\p{Cc}\p{Bidi_Control}\u2028\u2029\u200B\u2060\uFEFF]/u;
122
+
123
+ /**
124
+ * What a connection is for, in the words of whoever registered it (#721).
125
+ *
126
+ * **Required at registration, and refused when absent**, because a review listing credentials by key id
127
+ * cannot support the decision it exists for. **Untrusted input**: a customer writes it and something else
128
+ * renders it, so it is validated on the way in, and again on every read of the row.
129
+ */
130
+ export const ConnectionPurpose = z
131
+ .string()
132
+ .trim()
133
+ .min(1, "Say what this client is for.")
134
+ .max(MAX_CONNECTION_PURPOSE, `A purpose is ${MAX_CONNECTION_PURPOSE} characters at most.`)
135
+ .refine(
136
+ (purpose) => !UNRENDERABLE.test(purpose),
137
+ "A purpose is one line of plain text: no control, bidi or invisible characters.",
138
+ )
139
+ .describe(
140
+ `What this connection's client is for, in the registering operator's words — one line of plain text, ${MAX_CONNECTION_PURPOSE} characters at most, with no control, bidi or invisible characters. Written by a customer and rendered elsewhere, so validated at the boundary.`,
141
+ );
142
+ export type ConnectionPurpose = z.output<typeof ConnectionPurpose>;
143
+
69
144
  /**
70
145
  * One management-client connection — the row in `pithy_controlplane_connections`.
71
146
  *
@@ -112,6 +187,21 @@ export const ControlPlaneConnection = z
112
187
  .describe(
113
188
  "Where the management client that registered this connection answers — the origin the CLI calls for a verify, a rotation or a disconnect. Stored because it is not derivable: `issuer` is what that client *signs as*, and nothing says a dashboard's API answers at its own `iss`. Null for a connection registered before this column, which reads as the issuer rather than as the hosted dashboard — an older row is still a row about somebody's own deployment. Nothing on the Worker's side reads it; verification cares about `issuer` alone.",
114
189
  ),
190
+ authorizedBy: ConnectionPrincipal.nullable()
191
+ .default(null)
192
+ .describe(
193
+ "Who sanctioned this connection: the email of the signed-in Cloudflare session that registered it, never a flag. Null for a connection registered before principals were recorded (#721), which keeps working and reads as exactly that; registering again is the only way to set it.",
194
+ ),
195
+ actorKind: ControlPlaneActorKind.nullable()
196
+ .default(null)
197
+ .describe(
198
+ "What kind of client this connection is for — `agent`, `dashboard` or `script` — stamped onto every audit row its calls write so the trail can tell them apart. Null for a connection registered before it was recorded.",
199
+ ),
200
+ purpose: ConnectionPurpose.nullable()
201
+ .default(null)
202
+ .describe(
203
+ "What this connection's client is for, in the registering operator's words. Required at registration and editable with `pithy dashboard connect --update --purpose`; null only for a connection registered before it was recorded.",
204
+ ),
115
205
  scopes: sqliteJson(z.array(ControlPlaneScope)).describe(
116
206
  "The operations this connection was granted, stored and enforced on the adopter's side. Enforced only by the caller, a scope would not be a limit at all.",
117
207
  ),
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { commandName } from "../../cli/command";
4
5
  import { PithyError } from "../../error/pithyError";
5
6
  import type { MessageParams } from "../../i18n/catalog";
6
7
 
@@ -40,7 +41,9 @@ export class ControlPlaneNotConnectedError extends PithyError {
40
41
  code: "controlplane/not_connected",
41
42
  status: 403,
42
43
  message: args.message ?? "No management client is connected to this environment.",
43
- action: args.action ?? "Run pithy dashboard connect --env <environment> to register one.",
44
+ action:
45
+ args.action ??
46
+ `Register one for this environment with ${commandName("dashboard connect")}, naming it with --env.`,
44
47
  detail: args.detail,
45
48
  params: args.params,
46
49
  },
@@ -60,7 +63,9 @@ export class ControlPlaneInvalidCredentialError extends PithyError {
60
63
  code: "controlplane/invalid_credential",
61
64
  status: 401,
62
65
  message: args.message ?? "That credential is not valid here.",
63
- action: args.action ?? "Rotate the connection's key, or re-run pithy dashboard connect --update.",
66
+ action:
67
+ args.action ??
68
+ `Rotate the connection's key, or update this environment's connection with ${commandName("dashboard connect")} and its --update flag.`,
64
69
  detail: args.detail,
65
70
  params: args.params,
66
71
  },
@@ -77,7 +82,9 @@ export class ControlPlaneInsufficientScopeError extends PithyError {
77
82
  code: "controlplane/insufficient_scope",
78
83
  status: 403,
79
84
  message: args.message ?? "This connection is not scoped for that operation.",
80
- action: args.action ?? "Re-run pithy dashboard connect --update and grant the scope this call needs.",
85
+ action:
86
+ args.action ??
87
+ `Update this environment's connection with ${commandName("dashboard connect")} and its --update flag, granting the scope this call needs.`,
81
88
  detail: args.detail,
82
89
  params: args.params,
83
90
  },
@@ -217,10 +217,10 @@ export function corsMiddleware(
217
217
  /**
218
218
  * Mount one `app.use(path, …)` per entry.
219
219
  *
220
- * **Called from the capability's `middleware` hook, never its `routes` hook, and that is load-bearing.**
221
- * `createBackend` runs every capability's `middleware` hook before **any** capability's `routes` hook
222
- * (`createBackend.ts:262-267`), which is what makes this independent of the order an adopter happens to
223
- * list their capabilities in.
220
+ * **Called from `createBackend` ahead of every capability's `middleware`, never from a `routes` hook,
221
+ * and that is load-bearing.** Registered there, it is independent of the order an adopter happens to
222
+ * list their capabilities in, and it can put a header on whatever a capability's middleware or a
223
+ * readiness guard short-circuits.
224
224
  *
225
225
  * From `routes` it looks like it works and does not. The preflight still answers — an `OPTIONS` has no
226
226
  * route handler to lose to, so this `use` is the only thing that matches it — but a capability composed
@@ -2,11 +2,15 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import type { MiddlewareHandler } from "hono";
5
+ import type { AuditEmit } from "../../audit/recorder";
5
6
  import type { PithyHonoEnv } from "../../capability/capability";
7
+ import { commandName, remedy } from "../../cli/command";
8
+ import { type AmbientEnv, compositionEnvironment } from "../../env/ambient";
6
9
  import { PithyError } from "../../error/pithyError";
7
10
  import { workerVersionMetadata } from "../../worker/identity";
8
11
  import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
9
12
  import type { ControlPlaneContext } from "../context";
13
+ import type { ControlPlaneActorKind } from "../data/connection";
10
14
  import { ControlPlaneNotConnectedError } from "../error/errors";
11
15
  import type { ControlPlaneRequirement } from "../scope/scope";
12
16
  import { CONTROL_PLANE_HEADER, CONTROL_PLANE_VERSION_CREATED_HEADER, CONTROL_PLANE_VERSION_HEADER } from "../wire";
@@ -55,7 +59,7 @@ export type ControlPlaneVerifier = (
55
59
  */
56
60
  export function createControlPlaneVerifier(deps: ControlPlaneVerifyDeps): ControlPlaneVerifier {
57
61
  return async (request, requirement) => {
58
- // Clone before reading. The handler still has to parse this body through `zValidator`, and a stream
62
+ // Clone before reading. The handler still has to parse this body through `validate`, and a stream
59
63
  // consumed here would reach it empty — the digest check would pass and the request would then fail
60
64
  // validation for no visible reason.
61
65
  const body = new Uint8Array(await request.clone().arrayBuffer());
@@ -138,7 +142,7 @@ export function requireControlPlane(requirement: ControlPlaneRequirement): Middl
138
142
  if (!verify) {
139
143
  const denial = new ControlPlaneNotConnectedError({
140
144
  message: "This deployment does not accept management-client calls.",
141
- action: "Add `controlplane()` to this Worker's pithy.config.ts, then run pithy dashboard connect.",
145
+ action: `Add \`controlplane()\` to this Worker's pithy.config.ts, then ${connectStep(compositionEnvironment(c.env as unknown as AmbientEnv))}.`,
142
146
  detail: "no control-plane verifier on the request — the controlplane capability is not composed",
143
147
  });
144
148
  await auditDenial(c, requirement, denial);
@@ -154,6 +158,11 @@ export function requireControlPlane(requirement: ControlPlaneRequirement): Middl
154
158
  }
155
159
 
156
160
  c.set("controlPlane", context);
161
+ // Every control-plane row this call writes says which kind of client wrote it (#721). Stamped here,
162
+ // once, rather than at each of the dozens of emit sites across the capabilities, because a rule kept
163
+ // at the call sites regrows the moment someone adds one — and `actorType` alone names an agent, the
164
+ // dashboard and a script identically.
165
+ c.set("emit", stampActorKind(c.var.emit, context.actorKind));
157
166
  await safeEmit(
158
167
  c.var.emit,
159
168
  {
@@ -183,6 +192,20 @@ export function requireControlPlane(requirement: ControlPlaneRequirement): Middl
183
192
  return guard;
184
193
  }
185
194
 
195
+ /**
196
+ * The recorder a verified call's handlers write through: the request's own, with the connection's actor
197
+ * kind added to every `control-plane` event that does not already carry one.
198
+ *
199
+ * Only `control-plane` events, because the kind describes a management client and nothing else — a
200
+ * handler recording what happened to a user is recording a user. An event that states a kind keeps it.
201
+ * A connection with no recorded kind adds nothing, so its rows read exactly as they did before.
202
+ */
203
+ export function stampActorKind(emit: AuditEmit, actorKind: ControlPlaneActorKind | null): AuditEmit {
204
+ if (actorKind === null) return emit;
205
+ return (event) =>
206
+ emit(event.actorType === "control-plane" && event.actorKind == null ? { ...event, actorKind } : event);
207
+ }
208
+
186
209
  /**
187
210
  * Record a refused call.
188
211
  *
@@ -221,3 +244,13 @@ async function auditDenial(
221
244
  c.var.log,
222
245
  );
223
246
  }
247
+
248
+ /**
249
+ * The step that connects a management client, as this deployed Worker can name it: with its own environment when
250
+ * it knows it, and never with a Worker — it cannot know the `apps/` directory that provisioned it (#711).
251
+ */
252
+ function connectStep(env: string | undefined): string {
253
+ if (env === undefined)
254
+ return `connect one with ${commandName("dashboard connect")}, naming this environment with --env`;
255
+ return remedy("dashboard connect", { flags: { "--env": env } }, { runtime: true }).say((command) => `run ${command}`);
256
+ }
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { Context } from "hono";
5
5
  import type { PithyHonoEnv } from "../../capability/capability";
6
+ import { causeMessage } from "../../error/cause";
6
7
  import { workerVersion } from "../../worker/identity";
7
8
  import { ControlPlaneAuditActions, safeEmit } from "../audit/actions";
8
9
  import type { ControlPlaneConfig } from "../config/config";
@@ -14,8 +15,10 @@ import type { CapabilityDeclaration, ControlPlaneManifestWire } from "../discove
14
15
  import { type CapabilityHealthSource, readCapabilityHealth } from "../discovery/health";
15
16
  import { healthWire } from "../discovery/healthSummary";
16
17
  import { ControlPlaneInvalidCredentialError, ControlPlaneKeyConflictError } from "../error/errors";
18
+ import { type ComposedMigrationDatabase, readDatabaseMigrations } from "../migrationState/migrationState";
17
19
  import type {
18
20
  ControlPlaneKeysResponse,
21
+ ControlPlaneMigrationsResponse,
19
22
  ControlPlanePingResponse,
20
23
  ExpireKeyResponse,
21
24
  PublicKeyView,
@@ -68,6 +71,11 @@ export interface ControlPlaneHandlerDeps {
68
71
  * value is not: the numbers are resolved per request, behind the scope each one is declared under.
69
72
  */
70
73
  composedHealth: () => ReadonlyMap<string, CapabilityHealthSource>;
74
+ /**
75
+ * Every database this Worker binds, with the provider this build composes for it — captured by the same
76
+ * `compose` hook through `collectMigrationSets`, the function `pithy migrate` composes with (#729).
77
+ */
78
+ composedMigrations: () => readonly ComposedMigrationDatabase[];
71
79
  /** The clock, injected so a test can stand at any instant. */
72
80
  now: () => Date;
73
81
  }
@@ -203,6 +211,34 @@ export function manifestHandler(deps: ControlPlaneHandlerDeps) {
203
211
  };
204
212
  }
205
213
 
214
+ /**
215
+ * `GET /control-plane/migrations` — where each database this Worker binds stands (#729).
216
+ *
217
+ * Read-only: it reports, and nothing over the seam runs, reverses or resets a migration.
218
+ *
219
+ * **One unreadable ledger costs one database its answer.** Each read catches its own failure and lands on
220
+ * `unreadable`, so `Promise.all` still resolves and the route answers 200 with what it has. The cause is
221
+ * logged here, for the operator, and never projected: a D1 error can name a table or a query.
222
+ */
223
+ export function migrationsHandler(deps: ControlPlaneHandlerDeps) {
224
+ return async (c: Context<PithyHonoEnv>) => {
225
+ const context = caller(c);
226
+ const env = c.env as Record<string, unknown>;
227
+ const databases = await Promise.all(
228
+ deps.composedMigrations().map((entry) =>
229
+ readDatabaseMigrations(env, entry, (failed, cause) =>
230
+ c.var.log.warn("migration ledger unreadable", {
231
+ database: failed.database,
232
+ binding: failed.binding,
233
+ reason: causeMessage(cause) ?? String(cause),
234
+ }),
235
+ ),
236
+ ),
237
+ );
238
+ return c.json({ environment: context.environment, databases } satisfies ControlPlaneMigrationsResponse);
239
+ };
240
+ }
241
+
206
242
  /** `GET /control-plane/keys` — the registration state, so a client can surface a stale key. */
207
243
  export function listKeysHandler(deps: ControlPlaneHandlerDeps) {
208
244
  return async (c: Context<PithyHonoEnv>) => {
@@ -212,6 +248,11 @@ export function listKeysHandler(deps: ControlPlaneHandlerDeps) {
212
248
  connectionId: connection.id,
213
249
  environment: connection.environment,
214
250
  keys: connection.keys.map(publicKeyView),
251
+ // The review a credential listing exists for needs these, and it is the adopter's to perform —
252
+ // on their own side, from their own row (#721).
253
+ authorizedBy: connection.authorizedBy,
254
+ actorKind: connection.actorKind,
255
+ purpose: connection.purpose,
215
256
  } satisfies ControlPlaneKeysResponse);
216
257
  };
217
258
  }