@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,289 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { Ed25519PublicJwk } from "@pithy-sh/core/src/controlPlane/data/connection";
5
+ import { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
6
+ import { z } from "zod";
7
+
8
+ /**
9
+ * The management client's side of the control-plane seam, as a contract the CLI holds it to.
10
+ *
11
+ * **The dashboard lives in another repo, so this file defines what it must implement**, not what it
12
+ * happens to do. Six calls, six response shapes, every one Zod-validated on arrival — because a
13
+ * management client is a network boundary like any other, and the thing it hands back gets written into
14
+ * the adopter's own D1 as an authorization record. A malformed `publicKeyJwk` that reached the row would
15
+ * be a connection that can never verify a call and never explain why.
16
+ *
17
+ * It is an **interface first and an HTTP client second** for the same reason: the seam is MIT and
18
+ * ungated (docs/CONTROL-PLANE.md §5), so "write your own management client" has to be true. Anything
19
+ * satisfying {@link DashboardClient} drives `pithy dashboard` — the hosted dashboard, a self-hosted one,
20
+ * or a fake in a test. `httpDashboardClient` (`./api`) is one implementation, not the definition.
21
+ *
22
+ * **This module is the contract, and it is a module so that the client it specifies can import it.**
23
+ * Everything here is schemas and types over `zod` and `@pithy-sh/core` — no `fetch`, no timers, nothing
24
+ * from node. The HTTP client is a separate module because it needs all three, and a `setTimeout` handle
25
+ * is a `number` under `@cloudflare/workers-types` and a `Timeout` under node's: with the two in one file,
26
+ * the module failed to typecheck in every Workers-typed program, which is every program that could
27
+ * implement this contract. The dashboard hand-copied the field sets into a conformance test instead, and
28
+ * a hand-copied schema drifts silently — the precise thing exporting these was meant to prevent.
29
+ * `tsconfig.contract.json` compiles this file alone against the Workers types, so the split stays real.
30
+ *
31
+ * **Nothing secret of the adopter's crosses this wire.** The dashboard mints the Ed25519 keypair and
32
+ * keeps the private half; only the public JWK comes back. What the CLI sends up is a project name, an
33
+ * environment, a Worker URL, and the scopes a human chose.
34
+ */
35
+
36
+ /** Where the hosted dashboard lives. `--origin` re-points every call at a self-hosted one. */
37
+ export const DEFAULT_DASHBOARD_ORIGIN = "https://app.pithy.sh";
38
+
39
+ /**
40
+ * What the CLI shows a human so they can approve the connection in a browser. The device-code flow
41
+ * is the one leg of this design with genuine user delegation, which is why a browser belongs here
42
+ * and nowhere near the machine-to-machine leg (docs/CONTROL-PLANE.md §5).
43
+ */
44
+ export const DeviceAuthorization = z
45
+ .object({
46
+ deviceCode: z
47
+ .string()
48
+ .min(1)
49
+ .describe("The opaque code the CLI polls with. Never shown to the human — the user code is."),
50
+ userCode: z
51
+ .string()
52
+ .min(1)
53
+ .describe("The short code the human types into the browser. Short enough to read off a screen and retype."),
54
+ verificationUri: z.url().describe("Where the human approves — the page the CLI prints and offers to open."),
55
+ expiresInSeconds: z
56
+ .number()
57
+ .int()
58
+ .positive()
59
+ .describe("How long this request stays approvable. The CLI stops polling at this bound rather than forever."),
60
+ intervalSeconds: z
61
+ .number()
62
+ .int()
63
+ .positive()
64
+ .describe("How long to wait between polls. The dashboard's rate limit, honored rather than guessed."),
65
+ })
66
+ .describe("A started device-authorization request: what to show the human, and how to poll for the result.");
67
+ export type DeviceAuthorization = z.infer<typeof DeviceAuthorization>;
68
+
69
+ /** The short-lived credential a completed device flow yields — enough to create or rotate a connection, and nothing more. */
70
+ export const ConnectToken = z
71
+ .object({
72
+ connectToken: z
73
+ .string()
74
+ .min(1)
75
+ .describe("The bearer credential for the connection-management calls. Short-lived, and never stored on disk."),
76
+ expiresInSeconds: z
77
+ .number()
78
+ .int()
79
+ .positive()
80
+ .describe("How long the token stays usable. A connect run finishes well inside it or starts over."),
81
+ })
82
+ .describe("The credential a completed device authorization yields, used for the rest of one `pithy dashboard` run.");
83
+ export type ConnectToken = z.infer<typeof ConnectToken>;
84
+
85
+ /** What the CLI asks for when registering a new connection: the identity of the thing being connected. */
86
+ export const CreateConnectionRequest = z
87
+ .object({
88
+ project: z
89
+ .string()
90
+ .min(1)
91
+ .describe("The project's stable name, from the root pithy.config.ts. What the connection is labeled with."),
92
+ environment: z
93
+ .string()
94
+ .min(1)
95
+ .describe(
96
+ "Which environment this connection is valid in. Bound into every token, so a staging credential cannot reach production.",
97
+ ),
98
+ isProduction: z
99
+ .boolean()
100
+ .describe(
101
+ "Whether this environment holds live data — the CLI's answer, not the client's guess. A management client's production treatment is load-bearing UI: it is what stands between an operator and an unguarded destructive action against real users. The name cannot answer it, because a project may call production `live` or `prod-eu`, and a client inferring from `prod` alone would give every one of them the safe-looking treatment. The CLI already knows: the built-in `prod`/`production`, plus whatever the project declared in `seed.productionEnvironments`, which is the same list that gates a destructive seed.",
102
+ ),
103
+ workerUrl: z
104
+ .url()
105
+ .describe(
106
+ "This environment's deployed Worker URL — the address the management client calls. Setup, not an afterthought: a client cannot reach a Worker it cannot address.",
107
+ ),
108
+ basePath: z
109
+ .string()
110
+ .min(1)
111
+ .describe(
112
+ "Where the control-plane seam is mounted on that Worker, from its resolved config — `/control-plane` unless the adopter moved it. **The one address a client cannot discover from the manifest, because it is the manifest's own address.** Everything else is discoverable: `AdminRoute.path` carries the fully mounted path so no client hardcodes a capability's mount point. Without this, a client has to assume the default, and an adopter who set `basePath: \"/admin\"` registers successfully, passes the ping, and then 404s on every call — the ping is called at the same assumed path, so a wrong base path fails identically to an unreachable Worker and the operator diagnoses the wrong problem.",
113
+ ),
114
+ scopes: z
115
+ .array(ControlPlaneScope)
116
+ .min(1)
117
+ .describe("The operations being granted. Enforced on the adopter's side; sent so the client knows its ceiling."),
118
+ })
119
+ .describe("A request to register one management-client connection for a project and environment.");
120
+ export type CreateConnectionRequest = z.infer<typeof CreateConnectionRequest>;
121
+
122
+ /**
123
+ * A newly registered connection. **The private key is not here and never will be** — the dashboard
124
+ * generates the keypair and keeps the private half, so key material never crosses the wire.
125
+ */
126
+ export const IssuedConnection = z
127
+ .object({
128
+ connectionId: z
129
+ .uuid()
130
+ .describe("The connection's id — the token `aud`, and the row's primary key in the adopter's D1."),
131
+ keyId: z.string().min(1).max(64).describe("The first key's id, named by the `kid` header of every token it signs."),
132
+ publicKeyJwk: Ed25519PublicJwk.describe("The public half of the generated keypair — the only half that travels."),
133
+ issuer: z
134
+ .url()
135
+ .describe("The `iss` every token from this client will carry, verified on every call and effectively permanent."),
136
+ scopes: z
137
+ .array(ControlPlaneScope)
138
+ .describe("The scopes the dashboard recorded, echoed back so the row stores what was actually granted."),
139
+ })
140
+ .describe("A registered connection and its first public key — everything the adopter's D1 row is built from.");
141
+ export type IssuedConnection = z.infer<typeof IssuedConnection>;
142
+
143
+ /**
144
+ * Where the seam answers on the adopter's Worker — the address a registration has to be sent to.
145
+ *
146
+ * Sent from the adopter's own row rather than left to the client's memory of it. The row is the
147
+ * authority on where their Worker is; a client holding a stale address would otherwise register a key
148
+ * against whatever now answers there.
149
+ */
150
+ export const SeamAddress = z
151
+ .object({
152
+ workerUrl: z.url().describe("This environment's Worker URL, as the adopter's own registration records it."),
153
+ basePath: z
154
+ .string()
155
+ .min(1)
156
+ .describe("Where the seam is mounted on it — `/control-plane` unless the adopter moved the mount."),
157
+ })
158
+ .describe("The address of one Worker's control-plane seam, taken from the adopter's own connection row.");
159
+ export type SeamAddress = z.infer<typeof SeamAddress>;
160
+
161
+ /**
162
+ * A rotation's new key, **after the adopter's Worker has recorded it**.
163
+ *
164
+ * The public JWK is deliberately absent. It used to come back because the CLI wrote the key into the
165
+ * adopter's D1 itself; the registration now happens at `POST {basePath}/keys`, which writes that row,
166
+ * so the CLI has nothing to do with the key material and asking for it would be asking for something
167
+ * to go wrong with.
168
+ */
169
+ export const RotatedKey = z
170
+ .object({
171
+ keyId: z
172
+ .string()
173
+ .min(1)
174
+ .max(64)
175
+ .describe(
176
+ "The successor key's id, as the adopter's Worker registered it. Named by the `kid` of every token it signs.",
177
+ ),
178
+ validFrom: z.iso
179
+ .datetime()
180
+ .describe(
181
+ "When the Worker opened the new key's window, ISO-8601 — its clock, since its clock is what judges a token.",
182
+ ),
183
+ })
184
+ .describe("A successor key the management client generated and registered through the adopter's own seam.");
185
+ export type RotatedKey = z.infer<typeof RotatedKey>;
186
+
187
+ /**
188
+ * The result of a signed round-trip against the adopter's Worker. **The CLI cannot make this call
189
+ * itself** — it holds no private key — so it asks the management client to sign a `ping` and report.
190
+ * That is the whole reason this method exists on the contract.
191
+ */
192
+ export const ConnectionHealth = z
193
+ .object({
194
+ status: z
195
+ .enum(["connected", "needs_reconnect"])
196
+ .describe(
197
+ "Whether a signed ping reached the Worker and verified. `needs_reconnect` is a live but unusable connection — a moved URL, an unregistered key — never a silent dead link.",
198
+ ),
199
+ keyId: z
200
+ .string()
201
+ .nullable()
202
+ .describe(
203
+ "Which registered key answered, read off the `ping` response's own `keyId`, or null when nothing did. **This is what proves a rotation.** The seam echoes the key that verified the call precisely so a client can tell which one answered rather than infer it from a 200, and the second step of a rotation — prove the successor before expiring what it replaces — is exactly that question. Without it a rotation is reported on the client's account of its own work.",
204
+ ),
205
+ detail: z
206
+ .string()
207
+ .optional()
208
+ .describe("What went wrong, when something did. Operator-facing context, shown under the status line."),
209
+ })
210
+ .describe("A connection's health, as proven by a real signed call rather than by the row existing.");
211
+ export type ConnectionHealth = z.infer<typeof ConnectionHealth>;
212
+
213
+ /**
214
+ * What `--update` re-registers: the address, and nothing else.
215
+ *
216
+ * Scopes are deliberately absent. The adopter's own row is the authority on what a connection may do —
217
+ * `assertNoScopeEscalation` refuses a client that returns more than was asked for — so telling the client
218
+ * about a scope change is neither necessary nor safe to trust. The address is the opposite case: the
219
+ * client is the one that has to *reach* the Worker, so it is the one that has to be told where it moved.
220
+ *
221
+ * `isProduction` is absent for a different reason: an environment does not stop being production. It is
222
+ * a fact about the environment, settled once, at the connect that created the record.
223
+ */
224
+ export const UpdateConnectionRequest = z
225
+ .object({
226
+ workerUrl: z
227
+ .url()
228
+ .describe(
229
+ "Where this environment's Worker now answers. A custom domain, a rename, or a moved environment all change it, and a client still calling the old address fails in a way that looks like an outage.",
230
+ ),
231
+ basePath: z
232
+ .string()
233
+ .min(1)
234
+ .describe(
235
+ "Where the seam is now mounted. Together with `workerUrl` this fully determines the manifest address — and it is the half a client cannot discover, because it is the manifest's own address.",
236
+ ),
237
+ })
238
+ .describe("Re-point an existing connection at the address its Worker now answers on.");
239
+ export type UpdateConnectionRequest = z.infer<typeof UpdateConnectionRequest>;
240
+
241
+ /**
242
+ * The management client, as the CLI needs it. Six calls: authorize a human, create a connection,
243
+ * rotate its key, prove it works, and revoke it.
244
+ *
245
+ * Note what is **not** here. There is no "expire the old key": expiry is the management client's own
246
+ * call once it has proven the successor, and a CLI that did it would recreate exactly the lockout the
247
+ * seam is built to prevent (docs/CONTROL-PLANE.md §6).
248
+ */
249
+ export interface DashboardClient {
250
+ /** Begin a device-code authorization. Returns what to show the human and how to poll. */
251
+ startDeviceAuthorization(): Promise<DeviceAuthorization>;
252
+ /** Poll once. `"pending"` while the human has not approved; throws once the request is gone. */
253
+ pollForConnectToken(deviceCode: string): Promise<ConnectToken | "pending">;
254
+ /** Register a connection for this project and environment, and get its first keypair's public half. */
255
+ createConnection(token: string, request: CreateConnectionRequest): Promise<IssuedConnection>;
256
+ /**
257
+ * Generate a successor keypair and **register it through the adopter's own seam**.
258
+ *
259
+ * Two steps, and the second is the one that matters: the client mints the keypair, then calls
260
+ * `POST {basePath}/keys` on the Worker at `address`, signing that request with the key it is
261
+ * replacing. Appends only — nothing is expired, and both keys are live when this returns.
262
+ *
263
+ * **The CLI cannot make that call itself**, holding no private key, which is the same reason
264
+ * {@link DashboardClient.verifyConnection} exists. Routing it through the client rather than writing
265
+ * the key straight into the adopter's D1 is what puts the rotation behind their `keys:rotate` scope
266
+ * check and into their own audit trail — the safety property at a boundary rather than in a function
267
+ * a caller has to remember to use (docs/CONTROL-PLANE.md §6).
268
+ *
269
+ * A Worker that cannot be reached must fail this call rather than report a key it did not register.
270
+ * The CLI cannot audit that claim by reading the adopter's row — locally the row is behind a second
271
+ * runtime's cache, and a check that is only sometimes right is worse than none. What it does instead
272
+ * is insist on evidence: the `ping` that follows must come back naming *this* key
273
+ * ({@link ConnectionHealth.keyId}), which no amount of reporting can fake.
274
+ */
275
+ rotateKey(token: string, connectionId: string, address: SeamAddress): Promise<RotatedKey>;
276
+ /**
277
+ * Re-point an existing connection at a new address.
278
+ *
279
+ * **Without this, `--update` is half an update.** It would rewrite the adopter's own enforcement row
280
+ * while the management client kept calling wherever it was told at connect — so the CLI would report
281
+ * success and every subsequent management call would fail against a dead address, which reads as an
282
+ * outage rather than as a stale registration.
283
+ */
284
+ updateConnection(token: string, connectionId: string, request: UpdateConnectionRequest): Promise<void>;
285
+ /** Ask the client to sign a `ping` at `workerUrl` and report what happened. */
286
+ verifyConnection(token: string, connectionId: string, workerUrl: string): Promise<ConnectionHealth>;
287
+ /** Forget a connection on the client's side. The adopter's own revocation is deleting their row. */
288
+ deleteConnection(token: string, connectionId: string): Promise<void>;
289
+ }
@@ -0,0 +1,124 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
6
+ import { type ControlPlaneScope, SEAM_SCOPES } from "@pithy-sh/core/src/controlPlane/scope/scope";
7
+
8
+ /**
9
+ * What `pithy dashboard connect` grants when nobody narrowed it — derived from the Worker's own
10
+ * declared admin surface, never from a list kept here.
11
+ *
12
+ * ## The problem this fixes
13
+ *
14
+ * `connect` granted `SEAM_SCOPES` and nothing else, so a freshly connected project opened to a
15
+ * dashboard of refusals: every pane that reads a customer's data needs a scope the default did not
16
+ * include. The refusal itself is well built — it names the missing scope and sells nothing — but a
17
+ * successful connect that leaves the product unusable is not a refusal doing its job, it is a default
18
+ * doing damage. `pithy-sh/dashboard`'s own self-connection held `manifest:read` and `keys:rotate`, read
19
+ * `blocked` off the manifest for all six panes, and never made a single call. It looked like a broken
20
+ * product.
21
+ *
22
+ * ## Why the default is derived rather than listed
23
+ *
24
+ * A list of scope names in the CLI is a second copy of a taxonomy that is deliberately federated: a
25
+ * capability declares its own scopes and its own routes, and `GET /control-plane/manifest` reports the
26
+ * union. A hard-coded default would be stale the day a capability lands or moves — and capabilities are
27
+ * still landing. So the default is read from the same declaration the manifest is built from: the
28
+ * composed `Capability.adminRoutes`. The CLI already resolves the Worker's composed set to find the
29
+ * seam's mount point (`resolveTarget.ts`), so this needs no credential, no network call, and no
30
+ * manifest — which matters, because reading the manifest requires the very grant being decided.
31
+ *
32
+ * The consequence worth stating: a capability that adds a read route is granted by default on the next
33
+ * `connect`, with no change here and no coordination. That is the same property the manifest gives a
34
+ * management client, applied to the grant.
35
+ *
36
+ * ## What counts as a read, and why the method decides it
37
+ *
38
+ * **A scope is a read scope only when every route that requires it is a `GET`.** Not "its name ends in
39
+ * `:read`" — a name is a convention and this is an authorization decision. `scopeCovers` matches
40
+ * exactly, with no prefix or wildcard rule, so holding a scope confers *every* route that requires it
41
+ * anywhere in the composed tree; if one of those mutates, the scope is not a read however it is spelled.
42
+ *
43
+ * The seam's own `keys:rotate` is exactly that case and is the reason the rule is stated over routes
44
+ * rather than over scopes: it gates `GET {base}/keys` **and** two `POST`s that register and expire keys.
45
+ * A rule keyed on the listing route alone would have put a credential-lifecycle write into a read
46
+ * default. It does not.
47
+ */
48
+
49
+ /** One scope an adopter may grant, and what the composed Worker says holding it would allow. */
50
+ export interface GrantableScope {
51
+ /** The scope, as a route requires it and as `--scope` names it. */
52
+ scope: ControlPlaneScope;
53
+ /** True when every declared route requiring this scope is a `GET`. */
54
+ read: boolean;
55
+ /** The capability that first declared it — what an adopter recognizes the grant by. */
56
+ capability: string;
57
+ /** The first declared route's summary: one line on what holding it lets a client do. */
58
+ summary: string;
59
+ /** How many declared routes this scope opens. */
60
+ routes: number;
61
+ }
62
+
63
+ /** Every admin route the composed set declares, in composition order, paired with its capability. */
64
+ function declaredRoutes(capabilities: readonly Capability[]): { capability: string; route: AdminRoute }[] {
65
+ return capabilities.flatMap((capability) =>
66
+ (capability.adminRoutes ?? []).map((route) => ({ capability: capability.name, route })),
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Every scope the composed Worker's admin surface declares, in composition order, classified.
72
+ *
73
+ * A route with a null scope is skipped rather than reported: `ping` needs a verified caller and no
74
+ * authorization, so granting it would change nothing and withholding it would change nothing. It is not
75
+ * grantable, and offering it would be a lie about what a decision does.
76
+ */
77
+ export function grantableScopes(capabilities: readonly Capability[]): GrantableScope[] {
78
+ const found = new Map<string, GrantableScope>();
79
+ for (const { capability, route } of declaredRoutes(capabilities)) {
80
+ if (route.scope === null) continue;
81
+ const existing = found.get(route.scope);
82
+ if (existing) {
83
+ // One non-GET anywhere in the tree settles it. The union is what a grant actually confers.
84
+ found.set(route.scope, {
85
+ ...existing,
86
+ read: existing.read && route.method === "GET",
87
+ routes: existing.routes + 1,
88
+ });
89
+ continue;
90
+ }
91
+ found.set(route.scope, {
92
+ scope: route.scope,
93
+ read: route.method === "GET",
94
+ capability,
95
+ summary: route.summary,
96
+ routes: 1,
97
+ });
98
+ }
99
+ return [...found.values()];
100
+ }
101
+
102
+ /**
103
+ * The grant a `connect` makes when the operator named no `--scope`.
104
+ *
105
+ * **The seam's own scopes, plus every read the composed capabilities declare.** The seam's pair is the
106
+ * base rather than a derivation because `keys:rotate` is not a read and would otherwise drop out — and
107
+ * dropping it would break `pithy dashboard rotate` on every new connection, which is a working command
108
+ * regressed to fix a different problem. It was already the default before this change; nothing here
109
+ * widens a write. Everything this function *adds* to that base is provably read-only, and that is the
110
+ * property `grant.test.ts` gates.
111
+ *
112
+ * An adopter who wants less says so: `--scope` narrows to exactly what is passed, and a narrowed grant
113
+ * refuses every call it left out with `controlplane/insufficient_scope`. The scope that was missing is
114
+ * in the refusal's `detail`, which the HTTP codec strips — so what names it to a client is the manifest,
115
+ * where each route carries its own `scope` beside the connection's `grantedScopes`. That is the right
116
+ * place for it: a client grays the operation out before trying, rather than learning from a 403.
117
+ */
118
+ export function defaultGrant(capabilities: readonly Capability[]): ControlPlaneScope[] {
119
+ const grant = [...SEAM_SCOPES];
120
+ for (const grantable of grantableScopes(capabilities)) {
121
+ if (grantable.read && !grant.includes(grantable.scope)) grant.push(grantable.scope);
122
+ }
123
+ return grant;
124
+ }