@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.
- package/LICENSE +21 -0
- package/package.json +72 -0
- package/scripts/templateManifest.ts +49 -0
- package/scripts/tsconfig.json +26 -0
- package/scripts/vendorTemplate.ts +84 -0
- package/scripts/verifyPack.ts +88 -0
- package/src/audit/cliAudit.ts +406 -0
- package/src/bin.ts +111 -0
- package/src/capabilities/add.ts +288 -0
- package/src/capabilities/addBootstrap.ts +275 -0
- package/src/capabilities/catalog.ts +175 -0
- package/src/capabilities/compose.ts +39 -0
- package/src/capabilities/configConstants.ts +74 -0
- package/src/capabilities/configImports.ts +397 -0
- package/src/capabilities/eject.ts +331 -0
- package/src/capabilities/emailProvisioner.ts +346 -0
- package/src/capabilities/entitlementGap.ts +70 -0
- package/src/capabilities/entryExports.ts +162 -0
- package/src/capabilities/flow.ts +550 -0
- package/src/capabilities/hostRegistry.ts +368 -0
- package/src/capabilities/loadFailure.ts +208 -0
- package/src/capabilities/manifests.ts +238 -0
- package/src/capabilities/mediaProvisioner.ts +471 -0
- package/src/capabilities/mintSecrets.ts +306 -0
- package/src/capabilities/paymentsProvisioner.ts +207 -0
- package/src/capabilities/prerequisites.ts +168 -0
- package/src/capabilities/r2Bucket.ts +113 -0
- package/src/capabilities/reconcile.ts +1483 -0
- package/src/capabilities/remove.ts +597 -0
- package/src/capabilities/requiredOptions.ts +92 -0
- package/src/capabilities/rotateSecrets.ts +305 -0
- package/src/capabilities/secrets.ts +178 -0
- package/src/capabilities/secretsDispatcher.ts +29 -0
- package/src/capabilities/secretsProvisioner.ts +389 -0
- package/src/capabilities/storageProvisioner.ts +414 -0
- package/src/capabilities/supportProvisioner.ts +515 -0
- package/src/capabilities/testersLoader.ts +52 -0
- package/src/capabilities/testersProvisioner.ts +236 -0
- package/src/capabilities/turnstileProvisioner.ts +347 -0
- package/src/capabilities/vectorProvisioner.ts +260 -0
- package/src/ci/fileModes.ts +223 -0
- package/src/ci/sourceFiles.ts +200 -0
- package/src/ci/workflowDrivers.ts +524 -0
- package/src/cloudflare/accountAnswer.ts +110 -0
- package/src/cloudflare/config.ts +685 -0
- package/src/cloudflare/storeId.ts +129 -0
- package/src/commands/add.ts +372 -0
- package/src/commands/alias.ts +205 -0
- package/src/commands/dashboard.ts +651 -0
- package/src/commands/deploy.ts +150 -0
- package/src/commands/dev.ts +37 -0
- package/src/commands/doctor.ts +2059 -0
- package/src/commands/email.ts +425 -0
- package/src/commands/env.ts +155 -0
- package/src/commands/feature.ts +359 -0
- package/src/commands/init.ts +538 -0
- package/src/commands/media.ts +303 -0
- package/src/commands/migrate.ts +129 -0
- package/src/commands/payments.ts +336 -0
- package/src/commands/provision.ts +368 -0
- package/src/commands/remove.ts +151 -0
- package/src/commands/secrets.ts +652 -0
- package/src/commands/seed.ts +229 -0
- package/src/commands/storage.ts +309 -0
- package/src/commands/support.ts +331 -0
- package/src/commands/testers.ts +1020 -0
- package/src/commands/token.ts +364 -0
- package/src/commands/turnstile.ts +271 -0
- package/src/commands/ui.ts +222 -0
- package/src/commands/upgrade.ts +517 -0
- package/src/commands/vector.ts +390 -0
- package/src/commands/worker.ts +295 -0
- package/src/dashboard/api.ts +323 -0
- package/src/dashboard/connect.ts +758 -0
- package/src/dashboard/contract.ts +289 -0
- package/src/dashboard/grant.ts +124 -0
- package/src/dashboard/registry.ts +519 -0
- package/src/dashboard/resolveTarget.ts +119 -0
- package/src/dev/delivery.ts +174 -0
- package/src/dev/devLogin.ts +155 -0
- package/src/dev/devLoginTargets.ts +91 -0
- package/src/dev/env.ts +206 -0
- package/src/dev/hostWorkers.ts +290 -0
- package/src/dev/keys.ts +111 -0
- package/src/dev/logging.ts +87 -0
- package/src/dev/openUrl.ts +75 -0
- package/src/dev/orchestrator.ts +1014 -0
- package/src/dev/ports.ts +220 -0
- package/src/dev/readyWatch.ts +142 -0
- package/src/dev/state.ts +90 -0
- package/src/devSecrets/bootstrapVars.ts +265 -0
- package/src/devSecrets/devVars.ts +240 -0
- package/src/devSecrets/edit.ts +256 -0
- package/src/devSecrets/file.ts +277 -0
- package/src/devSecrets/generate.ts +428 -0
- package/src/devSecrets/location.ts +80 -0
- package/src/devSecrets/mode.ts +71 -0
- package/src/devSecrets/records.ts +30 -0
- package/src/devSecrets/report.ts +99 -0
- package/src/devSecrets/seed.ts +344 -0
- package/src/devSecrets/store.ts +262 -0
- package/src/devSecrets/targets.ts +204 -0
- package/src/dispatch.ts +147 -0
- package/src/docs/catalog.ts +246 -0
- package/src/docs/writeCatalog.ts +45 -0
- package/src/doctor/cloudflare.ts +287 -0
- package/src/doctor/devPreferences.ts +155 -0
- package/src/doctor/devSecrets.ts +464 -0
- package/src/doctor/devVars.ts +414 -0
- package/src/doctor/devVarsLocal.ts +138 -0
- package/src/doctor/environments.ts +155 -0
- package/src/doctor/health.ts +354 -0
- package/src/doctor/localDelivery.ts +91 -0
- package/src/doctor/portsRegistry.ts +252 -0
- package/src/doctor/projectName.ts +584 -0
- package/src/doctor/secretBindings.ts +166 -0
- package/src/doctor/settings.ts +274 -0
- package/src/doctor/settingsSources.ts +202 -0
- package/src/doctor/workerName.ts +174 -0
- package/src/doctor/wranglerVars.ts +33 -0
- package/src/feature/bindings.ts +93 -0
- package/src/feature/create.ts +179 -0
- package/src/feature/destroy.ts +160 -0
- package/src/feature/devConfig.ts +201 -0
- package/src/feature/identity.ts +100 -0
- package/src/feature/manifest.ts +132 -0
- package/src/feature/ports.ts +615 -0
- package/src/feature/provision.ts +362 -0
- package/src/feature/sync.ts +148 -0
- package/src/feature/worktree.ts +282 -0
- package/src/help/groups.ts +47 -0
- package/src/help/rootUsage.ts +135 -0
- package/src/main.ts +73 -0
- package/src/migrations/ledger.ts +129 -0
- package/src/migrations/registry.ts +47 -0
- package/src/migrations/run.ts +1066 -0
- package/src/notifier/check.ts +129 -0
- package/src/notifier/installer.ts +48 -0
- package/src/notifier/notify.ts +152 -0
- package/src/notifier/state.ts +248 -0
- package/src/notifier/version.ts +59 -0
- package/src/platform/editor.ts +333 -0
- package/src/platform/rc.ts +118 -0
- package/src/platform/shell.ts +83 -0
- package/src/project/appBindings.ts +184 -0
- package/src/project/appWorkflows.ts +266 -0
- package/src/project/applyDomains.ts +166 -0
- package/src/project/askDomains.ts +220 -0
- package/src/project/atomic.ts +466 -0
- package/src/project/bindingEntries.ts +425 -0
- package/src/project/config.ts +701 -0
- package/src/project/dashboard.ts +118 -0
- package/src/project/deploy.ts +364 -0
- package/src/project/devVars.ts +113 -0
- package/src/project/domainPrompt.ts +191 -0
- package/src/project/domains.ts +386 -0
- package/src/project/envInventory.ts +356 -0
- package/src/project/environment.ts +125 -0
- package/src/project/extensions.ts +69 -0
- package/src/project/jsonc.ts +289 -0
- package/src/project/packageManager.ts +238 -0
- package/src/project/readOptionalFile.ts +342 -0
- package/src/project/rollback.ts +145 -0
- package/src/project/scaffold.ts +1088 -0
- package/src/project/templateFiles.ts +53 -0
- package/src/project/verifyDeploy.ts +230 -0
- package/src/project/versionMetadata.ts +77 -0
- package/src/project/workerAddress.ts +176 -0
- package/src/project/workerCommand.ts +564 -0
- package/src/project/workerIdentity.ts +50 -0
- package/src/project/workerManifest.ts +135 -0
- package/src/project/workerScaffold.ts +289 -0
- package/src/project/workerScope.ts +394 -0
- package/src/project/workers.ts +86 -0
- package/src/project/workflows.ts +281 -0
- package/src/project/wrangler.ts +168 -0
- package/src/provision/confirm.ts +86 -0
- package/src/provision/environment.ts +407 -0
- package/src/provision/featureConfig.ts +98 -0
- package/src/provision/mode.ts +62 -0
- package/src/provision/pendingSecrets.ts +96 -0
- package/src/provision/resources.ts +126 -0
- package/src/provision/secretBindings.ts +149 -0
- package/src/provision/store.ts +33 -0
- package/src/provision/unprovisioned.ts +114 -0
- package/src/provision/wranglerEnv.ts +220 -0
- package/src/rootFlags.ts +48 -0
- package/src/seed/drivers.ts +423 -0
- package/src/seed/media.ts +187 -0
- package/src/seed/plan.ts +137 -0
- package/src/seed/prepare.ts +224 -0
- package/src/seed/registry.ts +25 -0
- package/src/seed/run.ts +793 -0
- package/src/seed/safety.ts +206 -0
- package/src/terminal/logger.ts +42 -0
- package/src/terminal/output.ts +64 -0
- package/src/terminal/style.ts +132 -0
- package/src/test-utils/doctorHarness.ts +190 -0
- package/src/test-utils/migrateHarness.ts +126 -0
- package/src/test-utils/seedHarness.ts +173 -0
- package/src/test-utils/tempRepo.ts +45 -0
- package/src/tokens/config.ts +16 -0
- package/src/tokens/engine.ts +345 -0
- package/src/tokens/mintedTokens.ts +233 -0
- package/src/tokens/sinks.ts +84 -0
- package/src/ui/flow.ts +451 -0
- package/src/ui/react.ts +112 -0
- package/src/ui/routeAllowlist.ts +208 -0
- package/src/ui/scaffold.ts +113 -0
- package/src/ui/screenStyles.ts +127 -0
- package/src/ui/stubs.ts +135 -0
- package/src/ui/templates.ts +52 -0
- package/src/ui/wire.ts +311 -0
- package/src/ui/workerUi.ts +172 -0
- package/templates/starter/.dev.secrets.example.jsonc +43 -0
- package/templates/starter/.dev.vars.example +30 -0
- package/templates/starter/apps/api/package.json +22 -0
- package/templates/starter/apps/api/pithy.config.ts +65 -0
- package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
- package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
- package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
- package/templates/starter/apps/api/src/index.ts +8 -0
- package/templates/starter/apps/api/tsconfig.json +26 -0
- package/templates/starter/apps/api/wrangler.jsonc +68 -0
- package/templates/starter/biome.template.jsonc +75 -0
- package/templates/starter/gitignore +37 -0
- package/templates/starter/package.json +28 -0
- package/templates/starter/pithy.config.ts +67 -0
- package/templates/starter/plugins/no-console.grit +25 -0
- package/templates/starter/plugins/no-process-io.grit +25 -0
- package/templates/starter/tsconfig.json +14 -0
- package/templates/starter/tsconfig.tools.json +30 -0
- package/templates/starter/vitest.config.ts +124 -0
- package/templates/starter/vitest.workers.config.ts +26 -0
|
@@ -0,0 +1,758 @@
|
|
|
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 { ControlPlaneConnection, Ed25519PublicJwk } from "@pithy-sh/core/src/controlPlane/data/connection";
|
|
6
|
+
import { activeKeys } from "@pithy-sh/core/src/controlPlane/data/keyLifecycle";
|
|
7
|
+
import { ControlPlaneNotConnectedError } from "@pithy-sh/core/src/controlPlane/error/errors";
|
|
8
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
9
|
+
import { ConflictError, messageOf, PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
10
|
+
import type { ConnectionHealth, DashboardClient, DeviceAuthorization } from "./contract";
|
|
11
|
+
import { defaultGrant } from "./grant";
|
|
12
|
+
import type { ConnectionRegistry } from "./registry";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The mount point assumed only when a caller could not resolve a composed seam.
|
|
16
|
+
*
|
|
17
|
+
* Mirrors `ControlPlaneConfig.basePath`'s own default, and is deliberately the *fallback* rather than
|
|
18
|
+
* the value: an adopter who moved the mount must have their real one sent, because a wrong base path
|
|
19
|
+
* registers cleanly, passes the ping, and then 404s every call.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_CONTROL_PLANE_BASE_PATH = "/control-plane";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `pithy dashboard`'s orchestration — connect, rotate, disconnect, status — with every I/O boundary
|
|
25
|
+
* injected. The registry is the adopter's D1, the client is the management client, and nothing here
|
|
26
|
+
* touches either directly.
|
|
27
|
+
*
|
|
28
|
+
* Four rules are load-bearing, and each of them is a failure mode this seam exists to prevent.
|
|
29
|
+
*
|
|
30
|
+
* **A key is added through the seam whenever anything live could sign for one** (#287). The CLI writes
|
|
31
|
+
* the `keys` column directly only at first connect and in recovery, which are the same case said twice:
|
|
32
|
+
* no live key, so nothing can sign a `POST {basePath}/keys`, so the route cannot serve it. Everywhere
|
|
33
|
+
* else the route is the authority — it checks `keys:rotate`, it records the registration in the
|
|
34
|
+
* adopter's own trail, and it means the CLI needs no D1 write access to rotate. The invariant is
|
|
35
|
+
* enforced in `registry.ts` rather than trusted to the callers here.
|
|
36
|
+
*
|
|
37
|
+
* **Nothing reports connected until a signed round-trip has succeeded.** A registration that was
|
|
38
|
+
* written but cannot be reached is a dead link, and reporting it as connected is how an operator finds
|
|
39
|
+
* out weeks later. The CLI holds no private key, so it cannot sign the ping itself — it asks the
|
|
40
|
+
* management client to, which is why `verifyConnection` is on the contract at all.
|
|
41
|
+
*
|
|
42
|
+
* **The CLI never expires a key.** Rotation is append, prove, then expire (docs/CONTROL-PLANE.md §6),
|
|
43
|
+
* and the third step belongs to the management client once *it* has proven the successor works from
|
|
44
|
+
* *its* infrastructure. A CLI that expired the old key on the strength of its own ping would recreate
|
|
45
|
+
* exactly the lockout the ordering exists to prevent — and the lockout is the one failure with no
|
|
46
|
+
* authenticated path back.
|
|
47
|
+
*
|
|
48
|
+
* **Revocation is local and unilateral.** `disconnect` deletes the adopter's row first and tells the
|
|
49
|
+
* dashboard afterwards, as a courtesy. A dashboard that is down, unreachable, or uncooperative must
|
|
50
|
+
* not be able to keep a connection alive; that property is the reason granting this access is
|
|
51
|
+
* defensible at all (§7).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** How a connect run ended. `registered` is the offline path: written, but not proven by a ping. */
|
|
55
|
+
export type ConnectStatus = "connected" | "needs_reconnect" | "registered";
|
|
56
|
+
|
|
57
|
+
/** What `pithy dashboard connect` did, in the shape `--json` emits and the human formatter renders. */
|
|
58
|
+
export interface ConnectReport {
|
|
59
|
+
/** The environment the connection is bound to. */
|
|
60
|
+
environment: string;
|
|
61
|
+
/** The connection's id — the token `aud`, and the row's primary key. */
|
|
62
|
+
connectionId: string;
|
|
63
|
+
/** The management-client origin every token from this connection must carry. */
|
|
64
|
+
issuer: string;
|
|
65
|
+
/** The Worker URL the management client will call. */
|
|
66
|
+
workerUrl: string;
|
|
67
|
+
/** The operations granted, as stored on the row. */
|
|
68
|
+
scopes: readonly string[];
|
|
69
|
+
/** The key registered by this run, or null when `--update` only re-pointed. */
|
|
70
|
+
keyId: string | null;
|
|
71
|
+
/** Whether a signed ping proved the connection, or why it could not. */
|
|
72
|
+
status: ConnectStatus;
|
|
73
|
+
/** Operator-facing context for a status that is not `connected`. */
|
|
74
|
+
detail?: string;
|
|
75
|
+
/** True when an existing connection was re-pointed rather than a new one created. */
|
|
76
|
+
updated: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A key the operator generated themselves — the offline registration path, with no dashboard at all. */
|
|
80
|
+
export interface OfflinePublicKey {
|
|
81
|
+
/** The key's id, which the operator's own client will put in every token's `kid` header. */
|
|
82
|
+
keyId: string;
|
|
83
|
+
/** The Ed25519 public key to trust. */
|
|
84
|
+
publicKey: Ed25519PublicJwk;
|
|
85
|
+
/** The `iss` the operator's client will present. Required: verification compares it on every call. */
|
|
86
|
+
issuer: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Turn a started device authorization into a connect token. Injected so tests never sleep or poll. */
|
|
90
|
+
export type Authorize = (client: DashboardClient) => Promise<string>;
|
|
91
|
+
|
|
92
|
+
/** Options for {@link authorizeDashboard}. */
|
|
93
|
+
export interface AuthorizeOptions {
|
|
94
|
+
/** Where to show the user code and verification URI — stderr in the CLI, a spy in a test. */
|
|
95
|
+
announce?: (authorization: DeviceAuthorization) => void;
|
|
96
|
+
/** The wait between polls. Injected so a test does not spend the interval. */
|
|
97
|
+
sleep?: (ms: number) => Promise<void>;
|
|
98
|
+
/** The clock, in milliseconds. */
|
|
99
|
+
now?: () => number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run the device-code flow and return the connect token.
|
|
104
|
+
*
|
|
105
|
+
* This leg is genuine user delegation — a human approving in a browser — which is why a browser
|
|
106
|
+
* authorization flow belongs here and nowhere near the machine-to-machine leg. Polling stops at the
|
|
107
|
+
* authorization's own expiry rather than running forever, and a `410` from the client is not retried:
|
|
108
|
+
* the request is gone, and the answer is to start a new one.
|
|
109
|
+
*/
|
|
110
|
+
export async function authorizeDashboard(client: DashboardClient, options: AuthorizeOptions = {}): Promise<string> {
|
|
111
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
112
|
+
const now = options.now ?? (() => Date.now());
|
|
113
|
+
|
|
114
|
+
const authorization = await client.startDeviceAuthorization();
|
|
115
|
+
options.announce?.(authorization);
|
|
116
|
+
|
|
117
|
+
const deadline = now() + authorization.expiresInSeconds * 1000;
|
|
118
|
+
while (now() < deadline) {
|
|
119
|
+
const result = await client.pollForConnectToken(authorization.deviceCode);
|
|
120
|
+
if (result !== "pending") return result.connectToken;
|
|
121
|
+
await sleep(authorization.intervalSeconds * 1000);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new ValidationError({
|
|
125
|
+
message: "That sign-in request expired.",
|
|
126
|
+
action: "Run the command again and approve it in the browser.",
|
|
127
|
+
detail: `device authorization ${authorization.deviceCode} was never approved`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Options for {@link connectDashboard}. */
|
|
132
|
+
export interface ConnectDashboardOptions {
|
|
133
|
+
/** The adopter's registration store for this environment. */
|
|
134
|
+
registry: ConnectionRegistry;
|
|
135
|
+
/** The project's stable name, from the root `pithy.config.ts`. */
|
|
136
|
+
project: string;
|
|
137
|
+
/** The environment being connected. */
|
|
138
|
+
environment: string;
|
|
139
|
+
/**
|
|
140
|
+
* Whether that environment holds live data — resolved by the caller, never inferred here.
|
|
141
|
+
*
|
|
142
|
+
* A management client's production treatment is the thing standing between an operator and an
|
|
143
|
+
* unguarded destructive action against real users, and the environment *name* cannot answer the
|
|
144
|
+
* question: a project may call production `live` or `prod-eu`, and a client reading `prod` alone gives
|
|
145
|
+
* every one of those the safe-looking treatment. So the CLI answers it, from the same list that gates a
|
|
146
|
+
* destructive seed (`seed.productionEnvironments`, plus the built-in `prod`/`production`), and sends it
|
|
147
|
+
* at the one moment the connection is being created.
|
|
148
|
+
*
|
|
149
|
+
* Defaults to false only on the offline path and the update path, neither of which creates a record on
|
|
150
|
+
* the client's side. A create sends what the caller resolved.
|
|
151
|
+
*/
|
|
152
|
+
isProduction?: boolean;
|
|
153
|
+
/** This environment's Worker URL. Required to create a connection; optional when only re-pointing. */
|
|
154
|
+
workerUrl?: string;
|
|
155
|
+
/**
|
|
156
|
+
* Where this Worker mounts the control-plane seam, from its **resolved** config rather than a flag.
|
|
157
|
+
*
|
|
158
|
+
* Sent at connect and stored beside `workerUrl`, because the two together fully determine the manifest
|
|
159
|
+
* address. Defaults to `/control-plane` only when the caller could not resolve a composed seam — an
|
|
160
|
+
* adopter who moved the mount must never have the default assumed on their behalf, since a wrong base
|
|
161
|
+
* path fails identically to an unreachable Worker.
|
|
162
|
+
*/
|
|
163
|
+
basePath?: string;
|
|
164
|
+
/**
|
|
165
|
+
* The scopes to grant. Left alone on an update; on a create, absent means {@link defaultGrant} over
|
|
166
|
+
* {@link ConnectDashboardOptions.capabilities}.
|
|
167
|
+
*
|
|
168
|
+
* **Absent and empty are different, deliberately.** Absent is "I did not decide, use the default";
|
|
169
|
+
* empty is "I decided, and the answer is nothing". Collapsing them would hand the full default set to
|
|
170
|
+
* the one operator who just deselected every scope at the prompt.
|
|
171
|
+
*/
|
|
172
|
+
scopes?: readonly ControlPlaneScope[];
|
|
173
|
+
/**
|
|
174
|
+
* The Worker's composed capabilities — what the default grant is derived from.
|
|
175
|
+
*
|
|
176
|
+
* The connection is being made so that a management client can read this Worker, and this Worker is
|
|
177
|
+
* the only thing that knows what there is to read. Passing the composed set means the default is the
|
|
178
|
+
* reads it actually declares, rather than a list kept in the CLI that goes stale the day a capability
|
|
179
|
+
* lands. Absent falls back to the seam's own scopes, which is a connection that works and reads
|
|
180
|
+
* nothing — honest for a caller that could not resolve a Worker, and never the CLI's own path.
|
|
181
|
+
*/
|
|
182
|
+
capabilities?: readonly Capability[];
|
|
183
|
+
/** Re-point an existing connection's URL and scopes instead of creating one. */
|
|
184
|
+
update?: boolean;
|
|
185
|
+
/** Register a key the operator generated. When set, no dashboard is contacted at all. */
|
|
186
|
+
publicKey?: OfflinePublicKey;
|
|
187
|
+
/** The management client. Absent only on the offline path. */
|
|
188
|
+
client?: DashboardClient;
|
|
189
|
+
/** The device-code flow (default: {@link authorizeDashboard}). */
|
|
190
|
+
authorize?: Authorize;
|
|
191
|
+
/** The clock. */
|
|
192
|
+
now?: () => Date;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Connect this environment to a management client, or re-point the connection it already has.
|
|
197
|
+
*
|
|
198
|
+
* Order matters. The row is written before the ping, because the ping only means something once the
|
|
199
|
+
* Worker can actually load the key; and the report's status comes from the ping, not from the write
|
|
200
|
+
* having succeeded.
|
|
201
|
+
*/
|
|
202
|
+
/**
|
|
203
|
+
* The scopes to store, given what the operator asked for and what the client says it recorded.
|
|
204
|
+
*
|
|
205
|
+
* The answer is always the operator's list. The client's response is checked, not trusted: it may
|
|
206
|
+
* decline a scope (returning fewer is its business), but a scope it returns that nobody requested is a
|
|
207
|
+
* management client trying to widen its own grant, and the only safe response is to refuse the
|
|
208
|
+
* connection outright rather than quietly store the narrower set and leave the two sides disagreeing.
|
|
209
|
+
*/
|
|
210
|
+
function assertNoScopeEscalation(requested: readonly string[], issued: readonly string[]): string[] {
|
|
211
|
+
const escalated = issued.filter((scope) => !requested.includes(scope));
|
|
212
|
+
if (escalated.length > 0) {
|
|
213
|
+
throw new ValidationError({
|
|
214
|
+
message: "The management client asked for more access than you granted.",
|
|
215
|
+
action: "Nothing was registered. Re-run the connect, and report this to whoever operates that client.",
|
|
216
|
+
detail: `client returned unrequested scopes [${escalated.join(", ")}]; requested [${requested.join(", ")}]`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return [...requested];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function connectDashboard(options: ConnectDashboardOptions): Promise<ConnectReport> {
|
|
223
|
+
const now = options.now?.() ?? new Date();
|
|
224
|
+
const existing = await options.registry.read();
|
|
225
|
+
|
|
226
|
+
if (options.publicKey) return connectOffline(options, existing, now);
|
|
227
|
+
|
|
228
|
+
const client = options.client;
|
|
229
|
+
if (!client) {
|
|
230
|
+
throw new ValidationError({
|
|
231
|
+
message: "Nothing to connect with.",
|
|
232
|
+
action:
|
|
233
|
+
"Run pithy dashboard connect to use the dashboard, or pass --public-key and --issuer to register your own key.",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const authorize = options.authorize ?? ((target: DashboardClient) => authorizeDashboard(target));
|
|
238
|
+
const token = await authorize(client);
|
|
239
|
+
|
|
240
|
+
if (options.update) {
|
|
241
|
+
if (!existing) {
|
|
242
|
+
throw new ControlPlaneNotConnectedError({
|
|
243
|
+
action: "Connect this environment first: pithy dashboard connect --env <environment>.",
|
|
244
|
+
detail: `--update with no connection registered for ${options.environment}`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const connection: ControlPlaneConnection = {
|
|
248
|
+
...existing,
|
|
249
|
+
workerUrl: options.workerUrl ?? existing.workerUrl,
|
|
250
|
+
// Re-pointed with the URL, because the two together are the address. An adopter who moved the
|
|
251
|
+
// seam to `/admin` and ran `--update` would otherwise keep a stale mount in their own enforcement
|
|
252
|
+
// row — the exact failure this field exists to prevent, and one that exits 0 saying `Done.`
|
|
253
|
+
basePath: options.basePath ?? existing.basePath,
|
|
254
|
+
scopes: options.scopes ? [...options.scopes] : existing.scopes,
|
|
255
|
+
updatedAt: now,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// **The client is told first, and the row is saved only if that succeeded.**
|
|
259
|
+
//
|
|
260
|
+
// An update writes the address in two places — the adopter's own enforcement row, and the management
|
|
261
|
+
// client's record of where to call. Saving locally first and telling the client second leaves a
|
|
262
|
+
// window where the two disagree, and if the second call fails the CLI exits reporting an update that
|
|
263
|
+
// half happened: the row says `/admin`, the client keeps calling `/control-plane`, and every
|
|
264
|
+
// management call 404s in a way that reads as an outage rather than as a stale registration.
|
|
265
|
+
//
|
|
266
|
+
// Doing it in this order means a failure changes nothing. It also makes an unreachable client a loud
|
|
267
|
+
// failure rather than a silent divergence, which is the same discipline `saveKeys` applies to a
|
|
268
|
+
// rotation: one write, or none.
|
|
269
|
+
//
|
|
270
|
+
// Only the address is sent. Scopes stay local, because the adopter's row is the authority on what a
|
|
271
|
+
// connection may do — `assertNoScopeEscalation` refuses a client that claims more than was asked for,
|
|
272
|
+
// so there is nothing to gain by telling it and something to lose by trusting it.
|
|
273
|
+
//
|
|
274
|
+
// **And this write stays local while rotation does not** (#287), for a reason worth stating rather
|
|
275
|
+
// than assuming. An update touches no key: the seam has no route that could serve it, and it should
|
|
276
|
+
// not. The address is one the Worker never reads — it exists so a *client* can find the Worker — and
|
|
277
|
+
// the scopes are the grant that Worker enforces, so a route letting a management client change them
|
|
278
|
+
// would let it widen its own. Both are the adopter's to write, on the adopter's side. A key is the
|
|
279
|
+
// opposite: the seam owns registering one, and the CLI stopped doing it.
|
|
280
|
+
if (connection.workerUrl !== existing.workerUrl || connection.basePath !== existing.basePath) {
|
|
281
|
+
await client.updateConnection(token, connection.id, {
|
|
282
|
+
workerUrl: connection.workerUrl,
|
|
283
|
+
basePath: connection.basePath,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await options.registry.save(connection);
|
|
288
|
+
// Re-pointing is not a rotation. The keys are the client's, and they still work.
|
|
289
|
+
const health = await probe(client, token, connection);
|
|
290
|
+
return report(connection, { keyId: null, updated: true, health });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const workerUrl = requireWorkerUrl(options.workerUrl);
|
|
294
|
+
// The seam's resolved mount point, sent so a client never has to assume `/control-plane`. It is the
|
|
295
|
+
// one address the manifest cannot describe, because it *is* the manifest's address.
|
|
296
|
+
const basePath = options.basePath ?? DEFAULT_CONTROL_PLANE_BASE_PATH;
|
|
297
|
+
// The grant, and the one place a create decides it. `defaultGrant` reads the composed Worker's own
|
|
298
|
+
// declared admin surface and adds every scope whose entire surface is a read — so connecting produces
|
|
299
|
+
// panes that read, without a second command, and without this file holding a list of scope names.
|
|
300
|
+
const scopes = options.scopes ? [...options.scopes] : defaultGrant(options.capabilities ?? []);
|
|
301
|
+
const issued = await client.createConnection(token, {
|
|
302
|
+
project: options.project,
|
|
303
|
+
environment: options.environment,
|
|
304
|
+
isProduction: options.isProduction ?? false,
|
|
305
|
+
workerUrl,
|
|
306
|
+
basePath,
|
|
307
|
+
scopes,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const connection: ControlPlaneConnection = {
|
|
311
|
+
id: issued.connectionId,
|
|
312
|
+
environment: options.environment,
|
|
313
|
+
issuer: issued.issuer,
|
|
314
|
+
workerUrl,
|
|
315
|
+
basePath,
|
|
316
|
+
// **What the operator asked for, never what the client echoed back.** This row is the adopter's
|
|
317
|
+
// enforcement copy, and it is the only thing their Worker consults — so writing the client's own
|
|
318
|
+
// account of its grant would let the client decide what it may do, which is precisely the property
|
|
319
|
+
// storing scopes on this side exists to prevent. A client that returns MORE than was asked for is
|
|
320
|
+
// not a client to negotiate with, so `assertNoScopeEscalation` refuses the whole connection.
|
|
321
|
+
scopes: assertNoScopeEscalation(scopes, issued.scopes),
|
|
322
|
+
keys: [{ keyId: issued.keyId, publicKey: issued.publicKeyJwk, validFrom: now, validUntil: null, revokedAt: null }],
|
|
323
|
+
createdAt: existing?.createdAt ?? now,
|
|
324
|
+
updatedAt: now,
|
|
325
|
+
};
|
|
326
|
+
await options.registry.save(connection);
|
|
327
|
+
|
|
328
|
+
const health = await probe(client, token, connection);
|
|
329
|
+
return report(connection, { keyId: issued.keyId, updated: false, health });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The offline path: register a key the operator generated, with no dashboard in the loop.
|
|
334
|
+
*
|
|
335
|
+
* **This has to genuinely work end to end.** The seam is MIT and ungated, and "build your own
|
|
336
|
+
* management client against your own Worker" is only true if the CLI can register a key without us.
|
|
337
|
+
*
|
|
338
|
+
* It cannot report `connected`, and does not pretend to: proving a key needs the private half, which
|
|
339
|
+
* lives with whoever generated it. The operator proves it with their own signed `ping`.
|
|
340
|
+
*/
|
|
341
|
+
async function connectOffline(
|
|
342
|
+
options: ConnectDashboardOptions,
|
|
343
|
+
existing: ControlPlaneConnection | null,
|
|
344
|
+
now: Date,
|
|
345
|
+
): Promise<ConnectReport> {
|
|
346
|
+
const offline = options.publicKey as OfflinePublicKey;
|
|
347
|
+
|
|
348
|
+
if (!existing) {
|
|
349
|
+
const connection: ControlPlaneConnection = {
|
|
350
|
+
id: crypto.randomUUID(),
|
|
351
|
+
environment: options.environment,
|
|
352
|
+
issuer: offline.issuer,
|
|
353
|
+
workerUrl: requireWorkerUrl(options.workerUrl),
|
|
354
|
+
basePath: options.basePath ?? DEFAULT_CONTROL_PLANE_BASE_PATH,
|
|
355
|
+
// The same default as the dashboard path. An operator registering their own key against their own
|
|
356
|
+
// Worker is building their own client against their own data; there is no reason their first run
|
|
357
|
+
// should read less than ours does.
|
|
358
|
+
scopes: options.scopes ? [...options.scopes] : defaultGrant(options.capabilities ?? []),
|
|
359
|
+
keys: [{ keyId: offline.keyId, publicKey: offline.publicKey, validFrom: now, validUntil: null, revokedAt: null }],
|
|
360
|
+
createdAt: now,
|
|
361
|
+
updatedAt: now,
|
|
362
|
+
};
|
|
363
|
+
await options.registry.save(connection);
|
|
364
|
+
return report(connection, { keyId: offline.keyId, updated: false, health: null });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The issuer is verified on every call and is effectively permanent — changing it is a migration
|
|
368
|
+
// across every registered connection, not a config edit. Silently overwriting it would strand a
|
|
369
|
+
// client that still holds a working key.
|
|
370
|
+
if (existing.issuer !== offline.issuer) {
|
|
371
|
+
throw new ConflictError({
|
|
372
|
+
message: "This environment is already connected to a different issuer.",
|
|
373
|
+
action: `Run pithy dashboard disconnect --env ${options.environment} first, then connect again.`,
|
|
374
|
+
detail: `registered issuer ${existing.issuer}, offered ${offline.issuer}`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Re-offering a key that is already registered adds nothing, and that is what makes it useful: it is
|
|
379
|
+
// how an operator with no dashboard re-points an address, since `--update` on this path needs a key
|
|
380
|
+
// file to identify itself with. Same id, same material, no change to what the connection trusts.
|
|
381
|
+
const registered = existing.keys.find((key) => key.keyId === offline.keyId);
|
|
382
|
+
if (registered && registered.publicKey.x !== offline.publicKey.x) {
|
|
383
|
+
// Same id, different key. Honoring it would swap the material under a name the Worker already
|
|
384
|
+
// trusts — a trust change disguised as a re-registration, and the loudest possible one to refuse.
|
|
385
|
+
throw new ConflictError({
|
|
386
|
+
message: "That key id is already registered with different key material.",
|
|
387
|
+
action: "Register the new key under a new id, or revoke this one first: pithy dashboard revoke-key.",
|
|
388
|
+
detail: `keyId ${offline.keyId} is registered on connection ${existing.id} with a different public key`,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Adding a key while something live could sign for one is the seam's job, not the CLI's (#287). The
|
|
393
|
+
// registry refuses it either way; refusing here means an `--update` that would have gone first does
|
|
394
|
+
// not land before the refusal, so the command changes nothing at all.
|
|
395
|
+
//
|
|
396
|
+
// There is no offline equivalent of `pithy dashboard rotate`, and there cannot be one: registering
|
|
397
|
+
// through the seam means signing with the current key, and on this path the private half belongs to
|
|
398
|
+
// the operator's own client. So the action line hands them the call to make.
|
|
399
|
+
if (!registered) {
|
|
400
|
+
const live = activeKeys(existing.keys, now);
|
|
401
|
+
if (live.length > 0) {
|
|
402
|
+
throw new ConflictError({
|
|
403
|
+
message: "That environment already has a live key.",
|
|
404
|
+
action: `Register the successor through your worker: POST ${existing.workerUrl}${existing.basePath}/keys, signed with ${live[0]?.keyId}.`,
|
|
405
|
+
detail: `offline registration of ${offline.keyId} refused; connection ${existing.id} has ${live.length} live key(s)`,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const updated = options.update
|
|
411
|
+
? {
|
|
412
|
+
...existing,
|
|
413
|
+
workerUrl: options.workerUrl ?? existing.workerUrl,
|
|
414
|
+
// Same as the dashboard update path above — the mount point moves with the URL.
|
|
415
|
+
basePath: options.basePath ?? existing.basePath,
|
|
416
|
+
scopes: options.scopes ? [...options.scopes] : existing.scopes,
|
|
417
|
+
updatedAt: now,
|
|
418
|
+
}
|
|
419
|
+
: existing;
|
|
420
|
+
if (options.update) await options.registry.save(updated);
|
|
421
|
+
|
|
422
|
+
// Nothing live, so nothing could have signed a registration: this is the recovery case, and the CLI
|
|
423
|
+
// is the only thing that can put a key back. The same exemption as first connect, for the same reason.
|
|
424
|
+
const connection = registered
|
|
425
|
+
? updated
|
|
426
|
+
: await options.registry.appendKey(
|
|
427
|
+
{ keyId: offline.keyId, publicKey: offline.publicKey, validFrom: now, validUntil: null, revokedAt: null },
|
|
428
|
+
now,
|
|
429
|
+
);
|
|
430
|
+
return report(connection, { keyId: offline.keyId, updated: options.update === true, health: null });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Options for {@link rotateDashboardKey}. */
|
|
434
|
+
export interface RotateDashboardKeyOptions {
|
|
435
|
+
/** The adopter's registration store for this environment. */
|
|
436
|
+
registry: ConnectionRegistry;
|
|
437
|
+
/** The management client that generates and keeps the successor keypair. */
|
|
438
|
+
client: DashboardClient;
|
|
439
|
+
/** The environment being rotated. */
|
|
440
|
+
environment: string;
|
|
441
|
+
/** The device-code flow (default: {@link authorizeDashboard}). */
|
|
442
|
+
authorize?: Authorize;
|
|
443
|
+
/** The clock. */
|
|
444
|
+
now?: () => Date;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** What `pithy dashboard rotate` did. */
|
|
448
|
+
export interface RotateReport {
|
|
449
|
+
/** The environment rotated. */
|
|
450
|
+
environment: string;
|
|
451
|
+
/** The connection the key was appended to. */
|
|
452
|
+
connectionId: string;
|
|
453
|
+
/** The newly registered key. */
|
|
454
|
+
keyId: string;
|
|
455
|
+
/** The keys that were already live, and that stay live. Two live keys is the normal state. */
|
|
456
|
+
previousKeyIds: readonly string[];
|
|
457
|
+
/** Whether a signed ping proved the new key. */
|
|
458
|
+
status: "connected" | "needs_reconnect";
|
|
459
|
+
/** Operator-facing context when the ping did not prove it. */
|
|
460
|
+
detail?: string;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Rotate through the adopter's own seam, and prove the result.
|
|
465
|
+
*
|
|
466
|
+
* **The CLI does not write the key.** It asks the management client to call
|
|
467
|
+
* `POST {basePath}/keys` on the Worker, signed with the key being replaced, and that route writes the
|
|
468
|
+
* adopter's row (#287). Three things follow only from going that way: the connection's `keys:rotate`
|
|
469
|
+
* grant is actually checked, the adopter's audit trail records a key registered on their own side, and
|
|
470
|
+
* the CLI needs no D1 write access to rotate. A direct write reached the same column with none of them.
|
|
471
|
+
*
|
|
472
|
+
* **A Worker that cannot be reached fails the rotation and changes nothing.** That is the difference
|
|
473
|
+
* the ordering buys: the registration either lands in their D1 or does not happen, where writing
|
|
474
|
+
* locally and pinging afterwards left the two sides disagreeing whenever the ping failed.
|
|
475
|
+
*
|
|
476
|
+
* **The rotation is reported proven only when the ping came back naming the new key.** The client
|
|
477
|
+
* reports a key id; the Worker's own `ping` says which key actually verified a call, and those are
|
|
478
|
+
* different claims. Anything less is the CLI repeating a management client's account of its own work.
|
|
479
|
+
*
|
|
480
|
+
* That check was first written as a re-read of the adopter's row, and the end-to-end run is what
|
|
481
|
+
* refuted it: locally the CLI's D1 handle and the Worker's are two runtimes, so a registration the
|
|
482
|
+
* Worker had just committed was invisible to a reader that had already opened the file — a correct
|
|
483
|
+
* rotation failing on a stale read, in the one environment everybody tries first. The row is still the
|
|
484
|
+
* authority; the CLI's view of it, mid-command, is not evidence.
|
|
485
|
+
*
|
|
486
|
+
* **The old key is deliberately left live.** Expiry is `POST /control-plane/keys/:keyId/expire`, and it
|
|
487
|
+
* is the management client's call to make once it has proven the successor from its own
|
|
488
|
+
* infrastructure — the CLI proving it once, from a developer's laptop, is not the same evidence. A
|
|
489
|
+
* stale key costs nothing; expiring one that turns out to be the only working credential costs the
|
|
490
|
+
* connection, with no authenticated path back.
|
|
491
|
+
*/
|
|
492
|
+
export async function rotateDashboardKey(options: RotateDashboardKeyOptions): Promise<RotateReport> {
|
|
493
|
+
const now = options.now?.() ?? new Date();
|
|
494
|
+
const connection = await options.registry.read();
|
|
495
|
+
if (!connection) {
|
|
496
|
+
throw new ControlPlaneNotConnectedError({
|
|
497
|
+
action: `Connect this environment first: pithy dashboard connect --env ${options.environment}.`,
|
|
498
|
+
detail: `rotate with no connection registered for ${options.environment}`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Nothing live is not a rotation problem, it is a lockout already happened. The seam would refuse the
|
|
503
|
+
// registration — there is no key to sign it with — so say the true thing here rather than surface a
|
|
504
|
+
// 401 from a route the operator did not know was being called.
|
|
505
|
+
const previousKeyIds = activeKeys(connection.keys, now).map((key) => key.keyId);
|
|
506
|
+
if (previousKeyIds.length === 0) {
|
|
507
|
+
throw new ConflictError({
|
|
508
|
+
message: "This connection has no live key, so nothing can sign a rotation.",
|
|
509
|
+
action: `Register a new one: pithy dashboard connect --env ${options.environment}.`,
|
|
510
|
+
detail: `connection ${connection.id} has no key live at ${now.toISOString()}`,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const authorize = options.authorize ?? ((target: DashboardClient) => authorizeDashboard(target));
|
|
515
|
+
const token = await authorize(options.client);
|
|
516
|
+
|
|
517
|
+
const rotated = await options.client.rotateKey(token, connection.id, {
|
|
518
|
+
// From the adopter's row, not from the client's memory of it. The row is the authority on where
|
|
519
|
+
// their Worker is, and a client registering a key at a stale address registers it with a stranger.
|
|
520
|
+
workerUrl: connection.workerUrl,
|
|
521
|
+
basePath: connection.basePath,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Step two of the rotation: prove the successor. A ping that answered with the key it replaces
|
|
525
|
+
// proves the connection, not the key — and it is the key the next step would expire the old one on
|
|
526
|
+
// the strength of, so it is reported unproven rather than connected.
|
|
527
|
+
const health = await probe(options.client, token, connection);
|
|
528
|
+
const answered = health.status === "connected" && health.keyId === rotated.keyId;
|
|
529
|
+
const detail = answered
|
|
530
|
+
? health.detail
|
|
531
|
+
: health.status === "connected"
|
|
532
|
+
? `The ping was answered by ${health.keyId ?? "no key"}, not by ${rotated.keyId}.`
|
|
533
|
+
: health.detail;
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
environment: options.environment,
|
|
537
|
+
connectionId: connection.id,
|
|
538
|
+
keyId: rotated.keyId,
|
|
539
|
+
previousKeyIds,
|
|
540
|
+
status: answered ? "connected" : "needs_reconnect",
|
|
541
|
+
...(detail === undefined ? {} : { detail }),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Options for {@link disconnectDashboard}. */
|
|
546
|
+
export interface DisconnectDashboardOptions {
|
|
547
|
+
/** The adopter's registration store for this environment. */
|
|
548
|
+
registry: ConnectionRegistry;
|
|
549
|
+
/** The management client, told about the revocation as a courtesy. Optional. */
|
|
550
|
+
client?: DashboardClient;
|
|
551
|
+
/** The environment being revoked. */
|
|
552
|
+
environment: string;
|
|
553
|
+
/** The device-code flow (default: {@link authorizeDashboard}). */
|
|
554
|
+
authorize?: Authorize;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** What `pithy dashboard disconnect` did. */
|
|
558
|
+
export interface DisconnectReport {
|
|
559
|
+
/** The environment revoked. */
|
|
560
|
+
environment: string;
|
|
561
|
+
/** The connection that was removed, or null when there was nothing registered. */
|
|
562
|
+
connectionId: string | null;
|
|
563
|
+
/** Whether a row was deleted. `false` on a re-run, which is not an error. */
|
|
564
|
+
removed: boolean;
|
|
565
|
+
/** Whether the management client was successfully told. Never gates the revocation. */
|
|
566
|
+
dashboardNotified: boolean;
|
|
567
|
+
/** Why the management client was not told, when it was not. */
|
|
568
|
+
detail?: string;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Revoke this environment's connection.
|
|
573
|
+
*
|
|
574
|
+
* **The row goes first.** Revocation is immediate, unilateral, and requires nothing from the
|
|
575
|
+
* management client — that is the property that makes granting this access defensible. Telling the
|
|
576
|
+
* dashboard is a courtesy that runs afterwards and cannot fail the command; a dashboard that is down
|
|
577
|
+
* must not be able to keep a credential alive.
|
|
578
|
+
*/
|
|
579
|
+
export async function disconnectDashboard(options: DisconnectDashboardOptions): Promise<DisconnectReport> {
|
|
580
|
+
const connection = await options.registry.read();
|
|
581
|
+
if (!connection) {
|
|
582
|
+
return { environment: options.environment, connectionId: null, removed: false, dashboardNotified: false };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
await options.registry.remove();
|
|
586
|
+
|
|
587
|
+
if (!options.client) {
|
|
588
|
+
return { environment: options.environment, connectionId: connection.id, removed: true, dashboardNotified: false };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
const authorize = options.authorize ?? ((target: DashboardClient) => authorizeDashboard(target));
|
|
593
|
+
const token = await authorize(options.client);
|
|
594
|
+
await options.client.deleteConnection(token, connection.id);
|
|
595
|
+
return { environment: options.environment, connectionId: connection.id, removed: true, dashboardNotified: true };
|
|
596
|
+
} catch (error) {
|
|
597
|
+
return {
|
|
598
|
+
environment: options.environment,
|
|
599
|
+
connectionId: connection.id,
|
|
600
|
+
removed: true,
|
|
601
|
+
dashboardNotified: false,
|
|
602
|
+
detail: messageOf(error),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** One registered key, as `pithy dashboard status` reports it. */
|
|
608
|
+
export interface KeyReport {
|
|
609
|
+
/** The key's id, as a token's `kid` names it. */
|
|
610
|
+
keyId: string;
|
|
611
|
+
/** Whether this key may verify a call right now. */
|
|
612
|
+
live: boolean;
|
|
613
|
+
/** How many whole days since the key became valid — the number a rotation policy is read against. */
|
|
614
|
+
ageDays: number;
|
|
615
|
+
/** When the key became valid, ISO-8601. */
|
|
616
|
+
validFrom: string;
|
|
617
|
+
/** When the key stops being accepted, or null while open-ended. */
|
|
618
|
+
validUntil: string | null;
|
|
619
|
+
/** When the key was revoked outright, or null. */
|
|
620
|
+
revokedAt: string | null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** What `pithy dashboard status` found. */
|
|
624
|
+
export interface StatusReport {
|
|
625
|
+
/** The environment inspected. */
|
|
626
|
+
environment: string;
|
|
627
|
+
/** Whether anything is registered at all. `false` is the shipped, denying state. */
|
|
628
|
+
connected: boolean;
|
|
629
|
+
/** The connection's id, or null when nothing is registered. */
|
|
630
|
+
connectionId: string | null;
|
|
631
|
+
/** The management-client origin this connection trusts. */
|
|
632
|
+
issuer: string | null;
|
|
633
|
+
/** The Worker URL the management client calls. */
|
|
634
|
+
workerUrl: string | null;
|
|
635
|
+
/** The operations granted. */
|
|
636
|
+
scopes: readonly string[];
|
|
637
|
+
/** Every registered key, live and superseded, newest window first as stored. */
|
|
638
|
+
keys: readonly KeyReport[];
|
|
639
|
+
/** `unverified` unless a probe was asked for — status never claims a round-trip it did not make. */
|
|
640
|
+
status: "connected" | "needs_reconnect" | "unverified";
|
|
641
|
+
/** Operator-facing context from the probe. */
|
|
642
|
+
detail?: string;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Options for {@link dashboardStatus}. */
|
|
646
|
+
export interface DashboardStatusOptions {
|
|
647
|
+
/** The adopter's registration store for this environment. */
|
|
648
|
+
registry: ConnectionRegistry;
|
|
649
|
+
/** The environment inspected. */
|
|
650
|
+
environment: string;
|
|
651
|
+
/**
|
|
652
|
+
* Ask the management client for a signed round-trip. Optional, and off by default: `status` is the
|
|
653
|
+
* command an operator runs to look, and making it require a browser sign-in every time would make
|
|
654
|
+
* looking expensive. `--verify` wires it.
|
|
655
|
+
*/
|
|
656
|
+
verify?: (connection: ControlPlaneConnection) => Promise<ConnectionHealth>;
|
|
657
|
+
/** The clock. */
|
|
658
|
+
now?: () => Date;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Report what is registered for this environment, and — when asked — whether it still answers. */
|
|
662
|
+
export async function dashboardStatus(options: DashboardStatusOptions): Promise<StatusReport> {
|
|
663
|
+
const now = options.now?.() ?? new Date();
|
|
664
|
+
const connection = await options.registry.read();
|
|
665
|
+
if (!connection) {
|
|
666
|
+
return {
|
|
667
|
+
environment: options.environment,
|
|
668
|
+
connected: false,
|
|
669
|
+
connectionId: null,
|
|
670
|
+
issuer: null,
|
|
671
|
+
workerUrl: null,
|
|
672
|
+
scopes: [],
|
|
673
|
+
keys: [],
|
|
674
|
+
status: "unverified",
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const live = new Set(activeKeys(connection.keys, now).map((key) => key.keyId));
|
|
679
|
+
const keys = connection.keys.map((key) => ({
|
|
680
|
+
keyId: key.keyId,
|
|
681
|
+
live: live.has(key.keyId),
|
|
682
|
+
ageDays: Math.floor((now.getTime() - key.validFrom.getTime()) / 86_400_000),
|
|
683
|
+
validFrom: key.validFrom.toISOString(),
|
|
684
|
+
validUntil: key.validUntil?.toISOString() ?? null,
|
|
685
|
+
revokedAt: key.revokedAt?.toISOString() ?? null,
|
|
686
|
+
}));
|
|
687
|
+
|
|
688
|
+
const verify = options.verify;
|
|
689
|
+
const health = verify ? await settle(() => verify(connection)) : null;
|
|
690
|
+
return {
|
|
691
|
+
environment: options.environment,
|
|
692
|
+
connected: true,
|
|
693
|
+
connectionId: connection.id,
|
|
694
|
+
issuer: connection.issuer,
|
|
695
|
+
workerUrl: connection.workerUrl,
|
|
696
|
+
scopes: connection.scopes,
|
|
697
|
+
keys,
|
|
698
|
+
status: health?.status ?? "unverified",
|
|
699
|
+
...(health?.detail === undefined ? {} : { detail: health.detail }),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** The Worker URL, or the error that names the flag. A client cannot reach a Worker it cannot address. */
|
|
704
|
+
function requireWorkerUrl(workerUrl: string | undefined): string {
|
|
705
|
+
if (workerUrl) return workerUrl;
|
|
706
|
+
throw new ValidationError({
|
|
707
|
+
message: "No worker URL for this environment.",
|
|
708
|
+
action: "Pass --worker-url https://<your-worker> — it is the address the management client calls.",
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Ask the management client to sign a `ping`, and turn any failure into an honest health report. */
|
|
713
|
+
async function probe(
|
|
714
|
+
client: DashboardClient,
|
|
715
|
+
token: string,
|
|
716
|
+
connection: ControlPlaneConnection,
|
|
717
|
+
): Promise<ConnectionHealth> {
|
|
718
|
+
return settle(() => client.verifyConnection(token, connection.id, connection.workerUrl));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Run a health check and never throw. A probe that fails is information, not an error: the
|
|
723
|
+
* registration is real and the report has to say so *and* say the round-trip did not work. Throwing
|
|
724
|
+
* here would lose the first half.
|
|
725
|
+
*/
|
|
726
|
+
async function settle(check: () => Promise<ConnectionHealth>): Promise<ConnectionHealth> {
|
|
727
|
+
try {
|
|
728
|
+
return await check();
|
|
729
|
+
} catch (error) {
|
|
730
|
+
// A non-Pithy throw is a bug in the client implementation, and its message is still the most
|
|
731
|
+
// useful thing to show — but it is never swallowed silently: it lands in `detail`.
|
|
732
|
+
return {
|
|
733
|
+
status: "needs_reconnect",
|
|
734
|
+
// Nothing answered, so no key did.
|
|
735
|
+
keyId: null,
|
|
736
|
+
detail: error instanceof PithyError ? error.payload.message : messageOf(error),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Assemble the connect report from a saved connection and what the probe (if any) found. */
|
|
742
|
+
function report(
|
|
743
|
+
connection: ControlPlaneConnection,
|
|
744
|
+
outcome: { keyId: string | null; updated: boolean; health: ConnectionHealth | null },
|
|
745
|
+
): ConnectReport {
|
|
746
|
+
const status: ConnectStatus = outcome.health === null ? "registered" : outcome.health.status;
|
|
747
|
+
return {
|
|
748
|
+
environment: connection.environment,
|
|
749
|
+
connectionId: connection.id,
|
|
750
|
+
issuer: connection.issuer,
|
|
751
|
+
workerUrl: connection.workerUrl,
|
|
752
|
+
scopes: connection.scopes,
|
|
753
|
+
keyId: outcome.keyId,
|
|
754
|
+
status,
|
|
755
|
+
...(outcome.health?.detail === undefined ? {} : { detail: outcome.health.detail }),
|
|
756
|
+
updated: outcome.updated,
|
|
757
|
+
};
|
|
758
|
+
}
|