@pithy-sh/cli 0.1.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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +72 -0
  3. package/scripts/templateManifest.ts +49 -0
  4. package/scripts/tsconfig.json +26 -0
  5. package/scripts/vendorTemplate.ts +84 -0
  6. package/scripts/verifyPack.ts +88 -0
  7. package/src/audit/cliAudit.ts +406 -0
  8. package/src/bin.ts +111 -0
  9. package/src/capabilities/add.ts +288 -0
  10. package/src/capabilities/addBootstrap.ts +275 -0
  11. package/src/capabilities/catalog.ts +175 -0
  12. package/src/capabilities/compose.ts +39 -0
  13. package/src/capabilities/configConstants.ts +74 -0
  14. package/src/capabilities/configImports.ts +397 -0
  15. package/src/capabilities/eject.ts +331 -0
  16. package/src/capabilities/emailProvisioner.ts +346 -0
  17. package/src/capabilities/entitlementGap.ts +70 -0
  18. package/src/capabilities/entryExports.ts +162 -0
  19. package/src/capabilities/flow.ts +550 -0
  20. package/src/capabilities/hostRegistry.ts +368 -0
  21. package/src/capabilities/loadFailure.ts +208 -0
  22. package/src/capabilities/manifests.ts +238 -0
  23. package/src/capabilities/mediaProvisioner.ts +471 -0
  24. package/src/capabilities/mintSecrets.ts +306 -0
  25. package/src/capabilities/paymentsProvisioner.ts +207 -0
  26. package/src/capabilities/prerequisites.ts +168 -0
  27. package/src/capabilities/r2Bucket.ts +113 -0
  28. package/src/capabilities/reconcile.ts +1483 -0
  29. package/src/capabilities/remove.ts +597 -0
  30. package/src/capabilities/requiredOptions.ts +92 -0
  31. package/src/capabilities/rotateSecrets.ts +305 -0
  32. package/src/capabilities/secrets.ts +178 -0
  33. package/src/capabilities/secretsDispatcher.ts +29 -0
  34. package/src/capabilities/secretsProvisioner.ts +389 -0
  35. package/src/capabilities/storageProvisioner.ts +414 -0
  36. package/src/capabilities/supportProvisioner.ts +515 -0
  37. package/src/capabilities/testersLoader.ts +52 -0
  38. package/src/capabilities/testersProvisioner.ts +236 -0
  39. package/src/capabilities/turnstileProvisioner.ts +347 -0
  40. package/src/capabilities/vectorProvisioner.ts +260 -0
  41. package/src/ci/fileModes.ts +223 -0
  42. package/src/ci/sourceFiles.ts +200 -0
  43. package/src/ci/workflowDrivers.ts +524 -0
  44. package/src/cloudflare/accountAnswer.ts +110 -0
  45. package/src/cloudflare/config.ts +685 -0
  46. package/src/cloudflare/storeId.ts +129 -0
  47. package/src/commands/add.ts +372 -0
  48. package/src/commands/alias.ts +205 -0
  49. package/src/commands/dashboard.ts +651 -0
  50. package/src/commands/deploy.ts +150 -0
  51. package/src/commands/dev.ts +37 -0
  52. package/src/commands/doctor.ts +2059 -0
  53. package/src/commands/email.ts +425 -0
  54. package/src/commands/env.ts +155 -0
  55. package/src/commands/feature.ts +359 -0
  56. package/src/commands/init.ts +538 -0
  57. package/src/commands/media.ts +303 -0
  58. package/src/commands/migrate.ts +129 -0
  59. package/src/commands/payments.ts +336 -0
  60. package/src/commands/provision.ts +368 -0
  61. package/src/commands/remove.ts +151 -0
  62. package/src/commands/secrets.ts +652 -0
  63. package/src/commands/seed.ts +229 -0
  64. package/src/commands/storage.ts +309 -0
  65. package/src/commands/support.ts +331 -0
  66. package/src/commands/testers.ts +1020 -0
  67. package/src/commands/token.ts +364 -0
  68. package/src/commands/turnstile.ts +271 -0
  69. package/src/commands/ui.ts +222 -0
  70. package/src/commands/upgrade.ts +517 -0
  71. package/src/commands/vector.ts +390 -0
  72. package/src/commands/worker.ts +295 -0
  73. package/src/dashboard/api.ts +323 -0
  74. package/src/dashboard/connect.ts +758 -0
  75. package/src/dashboard/contract.ts +289 -0
  76. package/src/dashboard/grant.ts +124 -0
  77. package/src/dashboard/registry.ts +519 -0
  78. package/src/dashboard/resolveTarget.ts +119 -0
  79. package/src/dev/delivery.ts +174 -0
  80. package/src/dev/devLogin.ts +155 -0
  81. package/src/dev/devLoginTargets.ts +91 -0
  82. package/src/dev/env.ts +206 -0
  83. package/src/dev/hostWorkers.ts +290 -0
  84. package/src/dev/keys.ts +111 -0
  85. package/src/dev/logging.ts +87 -0
  86. package/src/dev/openUrl.ts +75 -0
  87. package/src/dev/orchestrator.ts +1014 -0
  88. package/src/dev/ports.ts +220 -0
  89. package/src/dev/readyWatch.ts +142 -0
  90. package/src/dev/state.ts +90 -0
  91. package/src/devSecrets/bootstrapVars.ts +265 -0
  92. package/src/devSecrets/devVars.ts +240 -0
  93. package/src/devSecrets/edit.ts +256 -0
  94. package/src/devSecrets/file.ts +277 -0
  95. package/src/devSecrets/generate.ts +428 -0
  96. package/src/devSecrets/location.ts +80 -0
  97. package/src/devSecrets/mode.ts +71 -0
  98. package/src/devSecrets/records.ts +30 -0
  99. package/src/devSecrets/report.ts +99 -0
  100. package/src/devSecrets/seed.ts +344 -0
  101. package/src/devSecrets/store.ts +262 -0
  102. package/src/devSecrets/targets.ts +204 -0
  103. package/src/dispatch.ts +147 -0
  104. package/src/docs/catalog.ts +246 -0
  105. package/src/docs/writeCatalog.ts +45 -0
  106. package/src/doctor/cloudflare.ts +287 -0
  107. package/src/doctor/devPreferences.ts +155 -0
  108. package/src/doctor/devSecrets.ts +464 -0
  109. package/src/doctor/devVars.ts +414 -0
  110. package/src/doctor/devVarsLocal.ts +138 -0
  111. package/src/doctor/environments.ts +155 -0
  112. package/src/doctor/health.ts +354 -0
  113. package/src/doctor/localDelivery.ts +91 -0
  114. package/src/doctor/portsRegistry.ts +252 -0
  115. package/src/doctor/projectName.ts +584 -0
  116. package/src/doctor/secretBindings.ts +166 -0
  117. package/src/doctor/settings.ts +274 -0
  118. package/src/doctor/settingsSources.ts +202 -0
  119. package/src/doctor/workerName.ts +174 -0
  120. package/src/doctor/wranglerVars.ts +33 -0
  121. package/src/feature/bindings.ts +93 -0
  122. package/src/feature/create.ts +179 -0
  123. package/src/feature/destroy.ts +160 -0
  124. package/src/feature/devConfig.ts +201 -0
  125. package/src/feature/identity.ts +100 -0
  126. package/src/feature/manifest.ts +132 -0
  127. package/src/feature/ports.ts +615 -0
  128. package/src/feature/provision.ts +362 -0
  129. package/src/feature/sync.ts +148 -0
  130. package/src/feature/worktree.ts +282 -0
  131. package/src/help/groups.ts +47 -0
  132. package/src/help/rootUsage.ts +135 -0
  133. package/src/main.ts +73 -0
  134. package/src/migrations/ledger.ts +129 -0
  135. package/src/migrations/registry.ts +47 -0
  136. package/src/migrations/run.ts +1066 -0
  137. package/src/notifier/check.ts +129 -0
  138. package/src/notifier/installer.ts +48 -0
  139. package/src/notifier/notify.ts +152 -0
  140. package/src/notifier/state.ts +248 -0
  141. package/src/notifier/version.ts +59 -0
  142. package/src/platform/editor.ts +333 -0
  143. package/src/platform/rc.ts +118 -0
  144. package/src/platform/shell.ts +83 -0
  145. package/src/project/appBindings.ts +184 -0
  146. package/src/project/appWorkflows.ts +266 -0
  147. package/src/project/applyDomains.ts +166 -0
  148. package/src/project/askDomains.ts +220 -0
  149. package/src/project/atomic.ts +466 -0
  150. package/src/project/bindingEntries.ts +425 -0
  151. package/src/project/config.ts +701 -0
  152. package/src/project/dashboard.ts +118 -0
  153. package/src/project/deploy.ts +364 -0
  154. package/src/project/devVars.ts +113 -0
  155. package/src/project/domainPrompt.ts +191 -0
  156. package/src/project/domains.ts +386 -0
  157. package/src/project/envInventory.ts +356 -0
  158. package/src/project/environment.ts +125 -0
  159. package/src/project/extensions.ts +69 -0
  160. package/src/project/jsonc.ts +289 -0
  161. package/src/project/packageManager.ts +238 -0
  162. package/src/project/readOptionalFile.ts +342 -0
  163. package/src/project/rollback.ts +145 -0
  164. package/src/project/scaffold.ts +1088 -0
  165. package/src/project/templateFiles.ts +53 -0
  166. package/src/project/verifyDeploy.ts +230 -0
  167. package/src/project/versionMetadata.ts +77 -0
  168. package/src/project/workerAddress.ts +176 -0
  169. package/src/project/workerCommand.ts +564 -0
  170. package/src/project/workerIdentity.ts +50 -0
  171. package/src/project/workerManifest.ts +135 -0
  172. package/src/project/workerScaffold.ts +289 -0
  173. package/src/project/workerScope.ts +394 -0
  174. package/src/project/workers.ts +86 -0
  175. package/src/project/workflows.ts +281 -0
  176. package/src/project/wrangler.ts +168 -0
  177. package/src/provision/confirm.ts +86 -0
  178. package/src/provision/environment.ts +407 -0
  179. package/src/provision/featureConfig.ts +98 -0
  180. package/src/provision/mode.ts +62 -0
  181. package/src/provision/pendingSecrets.ts +96 -0
  182. package/src/provision/resources.ts +126 -0
  183. package/src/provision/secretBindings.ts +149 -0
  184. package/src/provision/store.ts +33 -0
  185. package/src/provision/unprovisioned.ts +114 -0
  186. package/src/provision/wranglerEnv.ts +220 -0
  187. package/src/rootFlags.ts +48 -0
  188. package/src/seed/drivers.ts +423 -0
  189. package/src/seed/media.ts +187 -0
  190. package/src/seed/plan.ts +137 -0
  191. package/src/seed/prepare.ts +224 -0
  192. package/src/seed/registry.ts +25 -0
  193. package/src/seed/run.ts +793 -0
  194. package/src/seed/safety.ts +206 -0
  195. package/src/terminal/logger.ts +42 -0
  196. package/src/terminal/output.ts +64 -0
  197. package/src/terminal/style.ts +132 -0
  198. package/src/test-utils/doctorHarness.ts +190 -0
  199. package/src/test-utils/migrateHarness.ts +126 -0
  200. package/src/test-utils/seedHarness.ts +173 -0
  201. package/src/test-utils/tempRepo.ts +45 -0
  202. package/src/tokens/config.ts +16 -0
  203. package/src/tokens/engine.ts +345 -0
  204. package/src/tokens/mintedTokens.ts +233 -0
  205. package/src/tokens/sinks.ts +84 -0
  206. package/src/ui/flow.ts +451 -0
  207. package/src/ui/react.ts +112 -0
  208. package/src/ui/routeAllowlist.ts +208 -0
  209. package/src/ui/scaffold.ts +113 -0
  210. package/src/ui/screenStyles.ts +127 -0
  211. package/src/ui/stubs.ts +135 -0
  212. package/src/ui/templates.ts +52 -0
  213. package/src/ui/wire.ts +311 -0
  214. package/src/ui/workerUi.ts +172 -0
  215. package/templates/starter/.dev.secrets.example.jsonc +43 -0
  216. package/templates/starter/.dev.vars.example +30 -0
  217. package/templates/starter/apps/api/package.json +22 -0
  218. package/templates/starter/apps/api/pithy.config.ts +65 -0
  219. package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
  220. package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
  221. package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
  222. package/templates/starter/apps/api/src/index.ts +8 -0
  223. package/templates/starter/apps/api/tsconfig.json +26 -0
  224. package/templates/starter/apps/api/wrangler.jsonc +68 -0
  225. package/templates/starter/biome.template.jsonc +75 -0
  226. package/templates/starter/gitignore +37 -0
  227. package/templates/starter/package.json +28 -0
  228. package/templates/starter/pithy.config.ts +67 -0
  229. package/templates/starter/plugins/no-console.grit +25 -0
  230. package/templates/starter/plugins/no-process-io.grit +25 -0
  231. package/templates/starter/tsconfig.json +14 -0
  232. package/templates/starter/tsconfig.tools.json +30 -0
  233. package/templates/starter/vitest.config.ts +124 -0
  234. package/templates/starter/vitest.workers.config.ts +26 -0
@@ -0,0 +1,519 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { join } from "node:path";
5
+ import type { D1Database } from "@cloudflare/workers-types";
6
+ import { type ControlPlaneAuditAction, ControlPlaneAuditActions } from "@pithy-sh/core/src/controlPlane/audit/actions";
7
+ import { ControlPlaneConnection, type RegisteredKey } from "@pithy-sh/core/src/controlPlane/data/connection";
8
+ import { activeKeys, appendKey as appendRegisteredKey } from "@pithy-sh/core/src/controlPlane/data/keyLifecycle";
9
+ import {
10
+ CONTROL_PLANE_CONNECTIONS_TABLE,
11
+ type ControlPlaneDatabase,
12
+ controlPlaneDatabase,
13
+ } from "@pithy-sh/core/src/controlPlane/data/tables";
14
+ import {
15
+ ControlPlaneKeyConflictError,
16
+ ControlPlaneNotConnectedError,
17
+ } from "@pithy-sh/core/src/controlPlane/error/errors";
18
+ import { messageOf, ValidationError } from "@pithy-sh/core/src/error/pithyError";
19
+ import type { CliAuditEmit, CliAuditEvent } from "../audit/cliAudit";
20
+ import { type CloudflareAccountSelection, cloudflareCredentials } from "../cloudflare/config";
21
+ import { discoverWorkers } from "../project/workers";
22
+ import { openSeedDriver, type SeedDriver } from "../seed/drivers";
23
+ import { createCliLogger } from "../terminal/logger";
24
+
25
+ /**
26
+ * The adopter's side of the control-plane registration: the `pithy_controlplane_connections` row, read
27
+ * and written from the CLI.
28
+ *
29
+ * **Connection is project-wide, per environment — never per Worker** (docs/CONTROL-PLANE.md §15).
30
+ * Workers share a resource by declaring the same binding name, so one user record lives in one D1 that
31
+ * several Workers touch; a per-Worker credential would produce a dashboard where a user is visible in
32
+ * one pane and absent from another. So the row is keyed on `environment`, and a Worker is only how the
33
+ * CLI *finds* the database.
34
+ *
35
+ * Two rules this module does not get to bend. Every read is `ControlPlaneConnection.parse` and every
36
+ * write is `.encode` — the row holds dates as ms-epoch and scopes and keys as JSON, and the codecs are
37
+ * the only thing that knows that. And a key is appended through core's `appendKey`, never by touching
38
+ * the array here: the ordering that keeps a failed rotation from locking the adopter out is a property
39
+ * of that function, and a second implementation of it is a second thing to get wrong.
40
+ *
41
+ * ## The invariant (#287)
42
+ *
43
+ * **The CLI adds a key to a connection only when no live key exists to sign for one through the seam.**
44
+ *
45
+ * `POST {basePath}/keys` is the adopter's own boundary for registering a key: it checks the connection's
46
+ * `keys:rotate` grant, it records the registration in their audit trail, and it is signed with the key
47
+ * being replaced. A CLI writing the same column direct to D1 is a second authority over it — not a
48
+ * second implementation, since both paths call core's `appendKey`, but a second *place the decision is
49
+ * made*, which means the safety property holds for callers who remember and for nobody else.
50
+ *
51
+ * So the direct write is narrowed to the one case the seam cannot serve: a connection with nothing live
52
+ * to sign with. That is first connect — no key exists, and the Worker may not even be deployed, so
53
+ * requiring a running Worker to register the key that lets anyone talk to it would be a chicken-and-egg
54
+ * with no exit. It is also the recovery case, a connection whose every key was revoked, and the same
55
+ * sentence covers both because it is the same fact: nothing can sign.
56
+ *
57
+ * Stated as a property rather than a list of blessed callers, and enforced here rather than upstream,
58
+ * because a rule kept at the call sites regrows the moment someone adds one. {@link connectionRegistry}
59
+ * is the CLI's only door to that column, and `registry.test.ts` asserts both halves — that nothing else
60
+ * in the CLI opens the table, and that neither write gets through while a key is live.
61
+ *
62
+ * **Revocation is deliberately outside it.** {@link ConnectionRegistry.revokeKey} changes the key set of
63
+ * a connection with live keys, on purpose: it *removes* trust, and revocation that needed the Worker's
64
+ * cooperation would not be revocation (docs/CONTROL-PLANE.md §7). The invariant is about granting.
65
+ *
66
+ * ## The second invariant (#294)
67
+ *
68
+ * **A write to this table records itself in the adopter's own audit trail.**
69
+ *
70
+ * `ControlPlaneAuditActions` declared `connectionRegistered`, `connectionUpdated` and
71
+ * `connectionRemoved` from the day the seam shipped, and nothing emitted any of them. Not an oversight
72
+ * in a handler: those are the writes the CLI performs by opening the adopter's D1 directly, so no
73
+ * request reaches their Worker and no route is in a position to record one. An adopter could read a
74
+ * *key* rotation in their trail but not the connection being created or destroyed — the larger event
75
+ * was the invisible one.
76
+ *
77
+ * The recording is here, in the same functions as the writes, rather than at the call sites, for the
78
+ * same reason the key invariant is: a rule kept at the call sites regrows the moment someone adds one.
79
+ * Together with the tripwire asserting nothing else in the CLI opens this table, "every CLI write to an
80
+ * adopter's connection row is recorded" holds by construction rather than by discipline.
81
+ *
82
+ * Three things this settles, each of which is a way to get it wrong.
83
+ *
84
+ * **Where.** The event goes to the emitter this registry was built with, which
85
+ * {@link openConnectionRegistry} builds over the *same* `DB` handle the row is written through. Not a
86
+ * separately resolved one: on `dev` the registry's handle is a local Miniflare store and a resolved
87
+ * database id names the real remote database, so a second lookup would record the change in a database
88
+ * that never saw it.
89
+ *
90
+ * **When.** The row is written first and the event follows. A write that throws records nothing, and a
91
+ * record that fails cannot unwind the write and does not try — the emitter is non-fatal by contract, so
92
+ * a connect never fails because the trail was unavailable. The record is evidence of the change, never
93
+ * part of making it. The reverse order would let a `connect` announce a registration that did not land.
94
+ *
95
+ * **Whether.** An adopter need not compose `audit`. The emitter is then a no-op and every write here is
96
+ * unchanged, which is why this takes an always-callable function rather than an optional one: a call
97
+ * site that cannot tell whether auditing is on cannot forget to check.
98
+ */
99
+
100
+ /**
101
+ * One connection-lifecycle event, in the fields the seam's own routes already use.
102
+ *
103
+ * `resourceType`, `resourceId` and the two metadata keys match `keyRegistered` and `keyExpired` exactly,
104
+ * so an adopter filtering their trail to one connection gets the CLI-side and Worker-side halves of its
105
+ * history together rather than having to know that two writers named the same thing two ways.
106
+ *
107
+ * `severity` is `warning` for the same reason those are: a management client gaining, moving, or losing
108
+ * reach into an environment is the notable kind of event, not the routine kind.
109
+ *
110
+ * The actor is deliberately absent — {@link CliAuditEvent} has no `actorType`, because the CLI does not
111
+ * get to claim one. A route records `control-plane`, meaning the management client called in and proved
112
+ * it; the CLI is the adopter's own operator at a terminal, named from their Cloudflare token where there
113
+ * is one and unattributed where there is not. Letting this file set it would let a CLI-side write
114
+ * present itself as a verified caller.
115
+ */
116
+ function lifecycleEvent(
117
+ action: ControlPlaneAuditAction,
118
+ connection: ControlPlaneConnection,
119
+ metadata: Record<string, unknown>,
120
+ ): CliAuditEvent {
121
+ return {
122
+ action,
123
+ outcome: "success",
124
+ severity: "warning",
125
+ resourceType: "controlplane_connection",
126
+ resourceId: connection.id,
127
+ environment: connection.environment,
128
+ metadata: { connectionId: connection.id, connectionEnvironment: connection.environment, ...metadata },
129
+ };
130
+ }
131
+
132
+ /** Key **ids**, never key material — the trail is queryable and long-lived. */
133
+ function keyIds(connection: ControlPlaneConnection): string[] {
134
+ return connection.keys.map((registered) => registered.keyId);
135
+ }
136
+
137
+ /**
138
+ * What a re-point moved, field by field, so the trail answers "what changed" without a diff of two rows.
139
+ *
140
+ * Scopes are compared as a set rendered in order: a grant is what it contains, and reporting a
141
+ * reordering as a change would bury the one that matters.
142
+ */
143
+ function addressAndGrantChanges(
144
+ stored: ControlPlaneConnection,
145
+ connection: ControlPlaneConnection,
146
+ ): Record<string, unknown> {
147
+ const before = [...stored.scopes].sort();
148
+ const after = [...connection.scopes].sort();
149
+ return {
150
+ ...(stored.workerUrl === connection.workerUrl
151
+ ? {}
152
+ : { workerUrl: { from: stored.workerUrl, to: connection.workerUrl } }),
153
+ ...(stored.basePath === connection.basePath
154
+ ? {}
155
+ : { basePath: { from: stored.basePath, to: connection.basePath } }),
156
+ ...(before.join(" ") === after.join(" ") ? {} : { scopes: { from: before, to: after } }),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Refuse a key write while something live could have signed for it at the seam.
162
+ *
163
+ * The gate #287's invariant is stated as. It reads the connection as stored — never as offered — so a
164
+ * caller cannot talk its way past by describing the keys it wishes were there.
165
+ */
166
+ function refuseWhileSomethingCanSign(connection: ControlPlaneConnection, now: Date, detail: string): void {
167
+ const live = activeKeys(connection.keys, now);
168
+ if (live.length === 0) return;
169
+ throw new ControlPlaneKeyConflictError({
170
+ message: "That connection already has a live key.",
171
+ action: `Register the successor through the worker: POST ${connection.workerUrl}${connection.basePath}/keys, signed with ${live[0]?.keyId}. That is what pithy dashboard rotate does.`,
172
+ detail,
173
+ });
174
+ }
175
+
176
+ /** Read, write, and revoke one environment's registration. */
177
+ export interface ConnectionRegistry {
178
+ /** The environment's connection, or null when nothing is registered — the default, denying state. */
179
+ read(): Promise<ControlPlaneConnection | null>;
180
+ /** Write the environment's connection, replacing whatever was registered for it. */
181
+ save(connection: ControlPlaneConnection): Promise<void>;
182
+ /**
183
+ * Append a public key through core's lifecycle rules, and return the updated connection.
184
+ *
185
+ * **Refused while the connection has a live key** — see the invariant above. This is the bootstrap
186
+ * path only: nothing live means nothing can sign a registration at the seam, so the CLI is the only
187
+ * thing that can put a key there.
188
+ */
189
+ appendKey(key: RegisteredKey, now: Date): Promise<ControlPlaneConnection>;
190
+ /**
191
+ * Revoke one registered key, immediately. Returns the updated connection, or null when no key answers
192
+ * to that id.
193
+ *
194
+ * This is the adopter's side of revocation at key granularity: pulling one leaked key without tearing
195
+ * down the connection and re-running `connect`. It is a write to their own D1, needs no route, and
196
+ * needs nothing from the management client — which is what "immediate and unilateral" has to mean.
197
+ */
198
+ revokeKey(keyId: string, now: Date): Promise<ControlPlaneConnection | null>;
199
+ /** Delete the registration. `false` when there was nothing to delete — re-running is not an error. */
200
+ remove(): Promise<boolean>;
201
+ /** Release whatever the registry opened (the local Miniflare instance; nothing remotely). */
202
+ dispose(): Promise<void>;
203
+ }
204
+
205
+ /**
206
+ * The registry over an already-open database. Pure enough to test against a real D1 with no project on
207
+ * disk, which is the whole reason it is separate from {@link openConnectionRegistry}.
208
+ */
209
+ export function connectionRegistry(
210
+ db: ControlPlaneDatabase,
211
+ env: string,
212
+ dispose: () => Promise<void> = async () => {},
213
+ /**
214
+ * Where a write records itself (#294). Defaults to dropping the event, which is what a project not
215
+ * composing `audit` gets — and what a test constructing a bare registry gets, so the recording is
216
+ * opt-in for a caller that has a trail to write to and invisible to every caller that has not.
217
+ */
218
+ emit: CliAuditEmit = async () => {},
219
+ ): ConnectionRegistry {
220
+ /**
221
+ * Record an event, and let no failure of it reach the write it was recording.
222
+ *
223
+ * {@link createCliAudit}'s emitter is non-fatal by contract and logs its own drops, so this is belt
224
+ * and braces — but the writes below are the adopter's security boundary and do not get to assume
225
+ * their collaborators behave. The same argument core's `safeEmit` makes, for the same reason: a
226
+ * `disconnect` that failed because the trail was unreachable would leave a credential live on the
227
+ * strength of an audit problem, which is precisely backwards. It is not silent, though; a broken
228
+ * collaborator is worse hidden than seen.
229
+ */
230
+ async function record(event: CliAuditEvent): Promise<void> {
231
+ try {
232
+ await emit(event);
233
+ } catch (error) {
234
+ createCliLogger().child("dashboard").warn("connection lifecycle event dropped", { action: event.action, error });
235
+ }
236
+ }
237
+
238
+ async function read(): Promise<ControlPlaneConnection | null> {
239
+ const row = await db
240
+ .selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE)
241
+ .selectAll()
242
+ .where("environment", "=", env)
243
+ .executeTakeFirst();
244
+ return row ? ControlPlaneConnection.parse(row) : null;
245
+ }
246
+
247
+ return {
248
+ read,
249
+
250
+ async save(connection) {
251
+ // `save` creates or replaces a whole connection, and replacing one is how an adopter starts over:
252
+ // a new id, a new keypair, nothing carried forward. Nothing live can sign for a key on a
253
+ // connection that does not exist yet, so a replacement is first connect and passes.
254
+ //
255
+ // Keeping the same connection and rewriting its keys is the other thing entirely — that is a
256
+ // rotation wearing a create's clothes, and it goes through the seam like every other one.
257
+ const stored = await read();
258
+ if (stored && stored.id === connection.id) {
259
+ const before = ControlPlaneConnection.shape.keys.encode([...stored.keys]);
260
+ const after = ControlPlaneConnection.shape.keys.encode([...connection.keys]);
261
+ if (before !== after) {
262
+ refuseWhileSomethingCanSign(stored, connection.updatedAt, `save rewrote the keys of connection ${stored.id}`);
263
+ }
264
+ }
265
+
266
+ // One connection per environment. SQLite carries no unique index on `environment` — the column is
267
+ // indexed for reads, not constrained — so the invariant is enforced here, by replacing.
268
+ await db.deleteFrom(CONTROL_PLANE_CONNECTIONS_TABLE).where("environment", "=", env).execute();
269
+ await db.insertInto(CONTROL_PLANE_CONNECTIONS_TABLE).values(ControlPlaneConnection.encode(connection)).execute();
270
+
271
+ // The row landed, so the event can be written. A new id — or none stored at all — is a management
272
+ // client that did not have reach into this environment a moment ago and now does; the same id is
273
+ // the same client re-pointed. Those are different facts to an adopter reading their trail, and the
274
+ // id is the only thing that tells them apart, so it is what decides the code.
275
+ if (stored !== null && stored.id === connection.id) {
276
+ const changes = addressAndGrantChanges(stored, connection);
277
+ await record(
278
+ lifecycleEvent(ControlPlaneAuditActions.connectionUpdated, connection, {
279
+ changed: Object.keys(changes),
280
+ ...changes,
281
+ }),
282
+ );
283
+ return;
284
+ }
285
+ await record(
286
+ lifecycleEvent(ControlPlaneAuditActions.connectionRegistered, connection, {
287
+ issuer: connection.issuer,
288
+ workerUrl: connection.workerUrl,
289
+ basePath: connection.basePath,
290
+ scopes: [...connection.scopes],
291
+ registeredKeyIds: keyIds(connection),
292
+ // Starting over replaces a connection rather than editing one, and the id that stopped
293
+ // working is what an adopter needs to recognize their own older rows by.
294
+ ...(stored === null ? {} : { replacedConnectionId: stored.id }),
295
+ }),
296
+ );
297
+ },
298
+
299
+ async appendKey(key, now) {
300
+ const connection = await read();
301
+ if (!connection) {
302
+ throw new ControlPlaneNotConnectedError({
303
+ detail: `no connection registered for environment ${env}`,
304
+ });
305
+ }
306
+ refuseWhileSomethingCanSign(connection, now, `appendKey ${key.keyId} on connection ${connection.id}`);
307
+ // Core owns the rule. It refuses a duplicate id and a key that is already retired, and it never
308
+ // moves an existing key's window — which is what leaves the old credential working until the new
309
+ // one has been proven.
310
+ const keys = appendRegisteredKey(connection.keys, key, now);
311
+ const updated: ControlPlaneConnection = { ...connection, keys, updatedAt: now };
312
+ await db
313
+ .updateTable(CONTROL_PLANE_CONNECTIONS_TABLE)
314
+ .set(ControlPlaneConnection.encode(updated))
315
+ .where("id", "=", connection.id)
316
+ .execute();
317
+ // The connection existed and now trusts a key it did not, so this is a change to it rather than a
318
+ // registration of one. The seam's own `keyRegistered` is not the code for it: that one means the
319
+ // Worker accepted a signed registration through the route, and nothing here went near the Worker.
320
+ await record(
321
+ lifecycleEvent(ControlPlaneAuditActions.connectionUpdated, updated, {
322
+ changed: ["keys"],
323
+ registeredKeyId: key.keyId,
324
+ keyIds: keyIds(updated),
325
+ }),
326
+ );
327
+ return updated;
328
+ },
329
+
330
+ async revokeKey(keyId, now) {
331
+ const connection = await read();
332
+ if (!connection) {
333
+ throw new ControlPlaneNotConnectedError({
334
+ detail: `no connection registered for environment ${env}`,
335
+ });
336
+ }
337
+ if (!connection.keys.some((key) => key.keyId === keyId)) return null;
338
+
339
+ // `revokedAt`, not `validUntil`. Expiry is the orderly end of a rotation and core refuses it while
340
+ // it would leave no live key; revocation is the disorderly one, and it must not be refusable on
341
+ // those grounds — a leaked key has to come out even if it is the only one. That leaves the
342
+ // connection denying every call, which is the correct state for a credential you no longer trust,
343
+ // and `pithy dashboard connect` is the way back.
344
+ const keys = connection.keys.map((key) => (key.keyId === keyId ? { ...key, revokedAt: now } : key));
345
+ const updated: ControlPlaneConnection = { ...connection, keys, updatedAt: now };
346
+ await db
347
+ .updateTable(CONTROL_PLANE_CONNECTIONS_TABLE)
348
+ .set(ControlPlaneConnection.encode(updated))
349
+ .where("id", "=", connection.id)
350
+ .execute();
351
+ // Recorded after the revocation, never before it, and it cannot fail one. Revocation is immediate
352
+ // and unilateral (§7); a leaked key that stayed live because the trail was unreachable would be the
353
+ // audit tail wagging the security dog. The count of what is still live is on the row because
354
+ // revoking the last one leaves the connection denying everything, and that is the fact a reader
355
+ // asking "when did this stop working" is looking for.
356
+ await record(
357
+ lifecycleEvent(ControlPlaneAuditActions.connectionUpdated, updated, {
358
+ changed: ["keys"],
359
+ revokedKeyId: keyId,
360
+ liveKeyIdsAfter: activeKeys(updated.keys, now).map((key) => key.keyId),
361
+ }),
362
+ );
363
+ return updated;
364
+ },
365
+
366
+ async remove() {
367
+ const existing = await read();
368
+ if (!existing) return false;
369
+ await db.deleteFrom(CONTROL_PLANE_CONNECTIONS_TABLE).where("environment", "=", env).execute();
370
+ // The widest-blast-radius write there is: every credential for this environment stopped working at
371
+ // once. Recorded from the connection as it was, because after the delete there is nothing left to
372
+ // describe it — which is exactly why an adopter needs the row.
373
+ await record(
374
+ lifecycleEvent(ControlPlaneAuditActions.connectionRemoved, existing, {
375
+ issuer: existing.issuer,
376
+ workerUrl: existing.workerUrl,
377
+ basePath: existing.basePath,
378
+ scopes: [...existing.scopes],
379
+ removedKeyIds: keyIds(existing),
380
+ }),
381
+ );
382
+ return true;
383
+ },
384
+
385
+ dispose,
386
+ };
387
+ }
388
+
389
+ /**
390
+ * Test seam: open the backend driver for a Worker and environment.
391
+ *
392
+ * **It carries the account (#234).** The seam is shaped by what {@link openSeedDriver} needs, and that
393
+ * function's `account` was optional until then — so this type could not name one, so `openConnectionRegistry`
394
+ * could not pass one even once it wanted to. A registry lookup on a non-`dev` environment opens a real D1
395
+ * over REST, and it was opening it against whichever account `<config>/cloudflare.json` held.
396
+ */
397
+ export type OpenDriver = (options: {
398
+ workerDir: string;
399
+ persistRoot: string;
400
+ env: string;
401
+ account: CloudflareAccountSelection | null;
402
+ }) => Promise<SeedDriver>;
403
+
404
+ /** Options for {@link openConnectionRegistry}. */
405
+ export interface OpenConnectionRegistryOptions {
406
+ /** The project root — the parent of `apps/`, and the owner of the local `.wrangler/state` stores. */
407
+ projectDir: string;
408
+ /** The environment whose registration is being read or written. */
409
+ env: string;
410
+ /**
411
+ * The Cloudflare account this project belongs to, from `projectCloudflareAccount(projectDir)`, or
412
+ * `null` when it names none. Required (#234): every non-`dev` environment resolves the app database
413
+ * over REST, and the credentials that reads it with belong to exactly one account.
414
+ */
415
+ account: CloudflareAccountSelection | null;
416
+ /** Narrow the database lookup to one Worker (`--worker`). Optional; the first with a `DB` wins. */
417
+ worker?: string;
418
+ /** Driver seam (default: {@link openSeedDriver}), so a test needs no Miniflare of the CLI's making. */
419
+ openDriver?: OpenDriver;
420
+ /**
421
+ * Build the recorder a write records itself through (#294), over **the database this registry resolved**
422
+ * — which is why it is a factory taking that handle rather than an emitter passed in ready-made.
423
+ *
424
+ * The `DB` a connection row lives in is decided here, by walking the project's Workers, and on `dev` it
425
+ * is a local Miniflare store that no database id names. An emitter built anywhere else would be writing
426
+ * the record of a change into a database the change never reached. Passing the handle out is what makes
427
+ * "the event goes where the row went" a property of the wiring instead of a thing two call sites have to
428
+ * agree about.
429
+ *
430
+ * Omitted, nothing is recorded — the shape a test and a project without `audit` both get.
431
+ */
432
+ openAudit?: (database: D1Database) => Promise<CliAuditEmit>;
433
+ }
434
+
435
+ /**
436
+ * Settle the account before a single Worker is considered (#236).
437
+ *
438
+ * **A per-item refusal collector will swallow a whole-run refusal**, because the whole-run failure arrives
439
+ * wearing a per-item costume. The loop below gathers "why each Worker was skipped" so one unresolvable
440
+ * Worker does not stop the rest — a summary that is exactly the wrong shape for a fact settled before the
441
+ * loop began. An account mismatch went in as a per-Worker reason and came out as "No worker resolves the
442
+ * DB binding for this environment", pointing an adopter at a `wrangler.jsonc` that was fine. #199 was the
443
+ * same shape: a config that would not import, reported as "this Worker declares no secrets".
444
+ *
445
+ * So the two whole-project facts are asked here, once, and thrown rather than collected — the way
446
+ * `migrate`, `deploy`, `env`, `seed` and `upgrade` already refuse. {@link cloudflareCredentials} owns both
447
+ * sentences, so the mismatch reads exactly as `pithy doctor` reads it out.
448
+ *
449
+ * **`dev` is exempt because it consults no account.** The local driver resolves D1 from the project root's
450
+ * Miniflare stores and never builds a client, so no credential decides anything; refusing there would
451
+ * block work that never leaves the machine over a disagreement it cannot reach. Every other environment
452
+ * resolves the database over REST, which means credentials, which means exactly one account.
453
+ */
454
+ function requireAccountSettled(env: string, account: CloudflareAccountSelection | null): void {
455
+ if (env === "dev") return;
456
+ cloudflareCredentials({ account });
457
+ }
458
+
459
+ /**
460
+ * Open the registry for a project and environment.
461
+ *
462
+ * The database comes from a Worker's `wrangler.jsonc`, because that is where bindings are declared —
463
+ * but **the state it persists to is the project root's**, exactly as `pithy dev`, `migrate`, and `seed`
464
+ * do. A per-Worker persist directory would give two Workers that both declare `DB` separate copies of
465
+ * one database, and the registration would be visible to whichever Worker happened to write it.
466
+ *
467
+ * Which Worker? `--worker` names one; otherwise the first discovered Worker declaring a `DB` binding.
468
+ * Deliberately not an ambiguity error, for the same reason the audit lookup is not: Workers share a
469
+ * resource by declaring the same binding name, so every Worker with a `DB` points at the one app
470
+ * database.
471
+ */
472
+ export async function openConnectionRegistry(options: OpenConnectionRegistryOptions): Promise<ConnectionRegistry> {
473
+ const open = options.openDriver ?? openSeedDriver;
474
+ requireAccountSettled(options.env, options.account);
475
+ const workers = await discoverWorkers(options.projectDir);
476
+ const candidates =
477
+ options.worker === undefined
478
+ ? workers
479
+ : workers.filter(
480
+ (worker) =>
481
+ worker.name === options.worker || worker.dir === join(options.projectDir, "apps", options.worker ?? ""),
482
+ );
483
+
484
+ const refusals: string[] = [];
485
+ for (const candidate of candidates) {
486
+ const driver = await open({
487
+ workerDir: candidate.dir,
488
+ persistRoot: options.projectDir,
489
+ env: options.env,
490
+ account: options.account,
491
+ });
492
+ try {
493
+ const d1 = driver.d1("DB");
494
+ // Resolved before the registry is built, so a registry never exists without the recorder that
495
+ // belongs to its own database. The factory is not allowed to fail the command it is auditing: a
496
+ // project whose audit wiring cannot be built still connects, silently, and records nothing.
497
+ const record = await options.openAudit?.(d1).catch(() => undefined);
498
+ return connectionRegistry(controlPlaneDatabase(d1), options.env, () => driver.dispose(), record);
499
+ } catch (error) {
500
+ // This Worker cannot answer "where does the app database live" — usually because it declares no
501
+ // `DB` at all (a UI-only Worker in `apps/` is normal), sometimes because this env's stanza has no
502
+ // id. Both are collected rather than thrown: another Worker may still resolve it, and if none
503
+ // does, the reasons are what makes the final error debuggable.
504
+ refusals.push(`${candidate.name}: ${messageOf(error)}`);
505
+ await driver.dispose();
506
+ }
507
+ }
508
+
509
+ // Every reason is per-Worker by now — the whole-project ones were refused before the loop — so the
510
+ // summary is honest, and the reasons themselves belong on the `action` line rather than only in
511
+ // `detail`. `detail` is stripped at the display boundary and always will be; an operator staring at
512
+ // "add the DB binding" on a project that has one needs to be told which Worker said what.
513
+ const why = refusals.length === 0 ? "No workers under apps/." : `Skipped: ${refusals.join("; ")}.`;
514
+ throw new ValidationError({
515
+ message: "No worker resolves the DB binding for this environment.",
516
+ action: `${why} Add the DB d1_databases binding to a worker's wrangler.jsonc, or pass --worker to name one.`,
517
+ detail: `env ${options.env}; ${refusals.length === 0 ? "no workers under apps/" : refusals.join("; ")}`,
518
+ });
519
+ }
@@ -0,0 +1,119 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { isControlPlaneCapability } from "@pithy-sh/core/src/controlPlane/capability";
5
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { loadWorkerDomains } from "../project/config";
7
+ import { type AddressStanza, describeAddressSource, resolveWorkerAddress } from "../project/workerAddress";
8
+ import { type ResolvedWorker, resolveSingleWorker } from "../project/workerScope";
9
+ import { readWranglerConfig } from "../project/wrangler";
10
+
11
+ /**
12
+ * What `pithy dashboard connect` is registering: which Worker, at what address, with the seam mounted
13
+ * where.
14
+ *
15
+ * ## Why the Worker has to be named
16
+ *
17
+ * The administrative surface is composed on **one Worker per project**, and a connection targets it. In
18
+ * a multi-Worker project, "connect this project" is ambiguous, and guessing means registering an address
19
+ * that answers nothing. Every other per-Worker command already refuses that ambiguity through
20
+ * `resolveSingleWorker` — this reuses it rather than inventing a second rule.
21
+ *
22
+ * Sibling Workers are not separately addressable, which is right: the data being administered is shared
23
+ * through binding names, not owned per Worker.
24
+ *
25
+ * ## Why the address is resolved rather than demanded
26
+ *
27
+ * `connect` used to require `--worker-url` with no fallback at all, and softened it only with an
28
+ * interactive free-text prompt — so the agent and CI path, which is the one that matters for
29
+ * automation, simply threw. The project already knows where its Workers answer; asking again invites a
30
+ * value that disagrees with the routes beside it.
31
+ *
32
+ * ## Why the base path comes from the composed config
33
+ *
34
+ * It is the one address a client cannot discover, because it *is* the manifest's own address. Reading it
35
+ * off the composed capability means an adopter who moved the mount has their real one registered, rather
36
+ * than the default being assumed on their behalf — which registers cleanly, passes the ping at that same
37
+ * assumed path, and then 404s on every call.
38
+ */
39
+
40
+ /** The Worker a connection targets, and how to reach its seam. */
41
+ export interface ConnectTarget {
42
+ /** The resolved Worker. */
43
+ worker: ResolvedWorker;
44
+ /** Its base URL for this environment. */
45
+ workerUrl: string;
46
+ /** Where its control-plane seam is mounted, from the composed capability's resolved config. */
47
+ basePath: string;
48
+ /** A one-line account of where the address came from, for the confirmation line. */
49
+ source: string;
50
+ }
51
+
52
+ /** The seam's mount point on this Worker, or null when it composes no seam. */
53
+ function composedBasePath(worker: ResolvedWorker): string | null {
54
+ const seam = worker.capabilities.find(isControlPlaneCapability);
55
+ return seam ? seam.controlPlaneConfig.basePath : null;
56
+ }
57
+
58
+ /**
59
+ * Resolve the Worker, address, and base path a connect targets.
60
+ *
61
+ * `workerUrl` still overrides everything — an adopter fronting their Worker with a proxy has an address
62
+ * no config knows. It remains the escape hatch, not the requirement.
63
+ */
64
+ export async function resolveConnectTarget(options: {
65
+ projectDir: string;
66
+ environment: string;
67
+ /** `--worker`, when given. Absent in a single-Worker project; required when there are several. */
68
+ worker?: string | undefined;
69
+ /** `--worker-url`, when given. Overrides the resolver. */
70
+ workerUrl?: string | undefined;
71
+ }): Promise<ConnectTarget> {
72
+ const worker = await resolveSingleWorker({
73
+ projectDir: options.projectDir,
74
+ ...(options.worker === undefined ? {} : { worker: options.worker }),
75
+ });
76
+
77
+ const basePath = composedBasePath(worker);
78
+ if (basePath === null) {
79
+ throw new ValidationError({
80
+ message: `${worker.name} does not compose the control-plane seam, so there is nothing to connect.`,
81
+ action: "Add `controlplane()` to that Worker's pithy.config.ts, deploy, then run connect again.",
82
+ detail: `no controlplane capability in ${worker.dir}'s composed set`,
83
+ });
84
+ }
85
+
86
+ if (options.workerUrl) {
87
+ return { worker, workerUrl: options.workerUrl, basePath, source: "from --worker-url" };
88
+ }
89
+
90
+ let stanza: AddressStanza | undefined;
91
+ try {
92
+ stanza = ((await readWranglerConfig(worker.dir)) as { env?: Record<string, AddressStanza | undefined> }).env?.[
93
+ options.environment
94
+ ];
95
+ } catch {
96
+ stanza = undefined;
97
+ }
98
+
99
+ const address = resolveWorkerAddress({
100
+ environment: options.environment,
101
+ domains: loadWorkerDomains(worker.config),
102
+ stanza,
103
+ });
104
+ if (!address) {
105
+ throw new ValidationError({
106
+ message: `${worker.name} has no ${options.environment} address to register.`,
107
+ action:
108
+ 'Declare it in the Worker\'s pithy.config.ts — `domains: { prod: { pattern: "api.example.com", zone: "example.com" } }` — or pass --worker-url.',
109
+ detail: `no domains declaration, route, or vars.BASE_URL resolved for env.${options.environment} in ${worker.dir}`,
110
+ });
111
+ }
112
+
113
+ return { worker, workerUrl: address.url, basePath, source: describeAddressSource(address.source) };
114
+ }
115
+
116
+ /** The line shown before registering, so an operator sees the address and where it came from. */
117
+ export function describeConnectTarget(target: ConnectTarget): string {
118
+ return `${target.worker.name} → ${target.workerUrl}${target.basePath} (${target.source})`;
119
+ }