@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,174 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { renderTerminal } from "@pithy-sh/core/src/error/terminal";
|
|
6
|
+
import { resolveCloudflare } from "../cloudflare/config";
|
|
7
|
+
import { projectCloudflareAccount } from "../project/config";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* **Real mail from a laptop, and what happens when it cannot be.**
|
|
11
|
+
*
|
|
12
|
+
* `pithy dev` runs each composed capability's host Worker, and email's host carries the one binding in
|
|
13
|
+
* the kit that puts a message on the wire. `remote: true` on that binding runs the Worker locally and
|
|
14
|
+
* delivers through Cloudflare Email Service for real — the same pipeline, the same DKIM, the same
|
|
15
|
+
* delivery logs as production — which is what makes a magic link triggered from localhost actually
|
|
16
|
+
* arrive (pithy-sh/pithy#410).
|
|
17
|
+
*
|
|
18
|
+
* That depends on two things the kit does not own: a Cloudflare login `wrangler dev` can use, and a
|
|
19
|
+
* sending domain already onboarded onto Email Service. The requirement is not that both always hold.
|
|
20
|
+
* It is that **no path from here ends in silence** — because silence is the defect this whole issue is
|
|
21
|
+
* about, and a developer waiting on an inbox is the worst possible place to discover a missing login.
|
|
22
|
+
*
|
|
23
|
+
* So there are two checks, and they are deliberately different in kind:
|
|
24
|
+
*
|
|
25
|
+
* - {@link deliveryPreflight} runs **before anything spawns**, costs nothing, and *decides*. A check
|
|
26
|
+
* that runs first is worth more than a message that arrives second: where it can already see that
|
|
27
|
+
* real delivery is impossible, the host is resolved with the simulator instead and the banner says
|
|
28
|
+
* so once. The session is correct either way.
|
|
29
|
+
* - {@link deliveryFailureNote} runs over the host's own output and *reports*. The preflight is cheap,
|
|
30
|
+
* which is another way of saying it is not the guarantee: a remote binding is established when the
|
|
31
|
+
* Worker starts, and a domain that is not onboarded most likely fails there — or at the first send.
|
|
32
|
+
* Either shape is caught where it appears and rendered as a `PithyError` with the action that fixes
|
|
33
|
+
* it, rather than scrolling past as somebody else's stack trace.
|
|
34
|
+
*
|
|
35
|
+
* Neither path kills the session. `pithy dev` supervises Workers; a message that did not send is a
|
|
36
|
+
* reason for a sentence, not for tearing down every process a developer is working in.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether Cloudflare credentials resolve at all — the cheap half of the delivery preflight.
|
|
41
|
+
*
|
|
42
|
+
* No network call and no account probe: it reads the file this project's own account selection points
|
|
43
|
+
* at, overlaid with the environment, exactly as every other command does. That is enough to catch the
|
|
44
|
+
* state a developer most often starts in, at none of the cost of asking Cloudflare.
|
|
45
|
+
*
|
|
46
|
+
* Here rather than in the dev command because `pithy dev` and `pithy doctor` both ask it, and they must
|
|
47
|
+
* not come to two answers about one machine.
|
|
48
|
+
*/
|
|
49
|
+
export async function hasCloudflareLogin(projectDir: string, env: NodeJS.ProcessEnv): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
const vars = resolveCloudflare({ account: await projectCloudflareAccount(projectDir), env }).vars;
|
|
52
|
+
return Boolean(vars.CLOUDFLARE_ACCOUNT_ID && vars.CLOUDFLARE_API_TOKEN);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What the preflight was asked to check, and what it had to check with. */
|
|
59
|
+
export interface DeliveryPreflightOptions {
|
|
60
|
+
/** Whether the project composes email at all. Nothing to check when it does not. */
|
|
61
|
+
composed: boolean;
|
|
62
|
+
/** The delivery mode the adopter's config selected — `simulator` is a deliberate choice, not a failure. */
|
|
63
|
+
requested: "remote" | "simulator";
|
|
64
|
+
/** The from address the capability sends as; its domain is what must be onboarded. */
|
|
65
|
+
fromAddress?: string;
|
|
66
|
+
/** Whether Cloudflare credentials resolved at all. `false` means `wrangler dev` has no login to use. */
|
|
67
|
+
hasCloudflareLogin: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The preflight's answer: what this session will do about delivery, and the lines that say so. */
|
|
71
|
+
export interface DeliveryPreflight {
|
|
72
|
+
/** Whether the email host is resolved for real delivery. `false` selects the local simulator. */
|
|
73
|
+
live: boolean;
|
|
74
|
+
/** Terminal lines — a problem and its action, or the one line that states a deliberate choice. */
|
|
75
|
+
lines: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Domains that cannot be onboarded onto Email Service, so a from address on one can never deliver. */
|
|
79
|
+
const UNDELIVERABLE_DOMAINS = new Set(["example.com", "example.org", "example.net", "localhost", "test", "invalid"]);
|
|
80
|
+
|
|
81
|
+
/** The domain half of an address, lowercased, or `undefined` when the address has no usable one. */
|
|
82
|
+
function domainOf(address: string | undefined): string | undefined {
|
|
83
|
+
const at = address?.lastIndexOf("@") ?? -1;
|
|
84
|
+
if (at < 0 || address === undefined) return undefined;
|
|
85
|
+
const domain = address
|
|
86
|
+
.slice(at + 1)
|
|
87
|
+
.trim()
|
|
88
|
+
.toLowerCase();
|
|
89
|
+
return domain === "" ? undefined : domain;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Decide, before anything spawns, whether this session sends real mail — and say why when it does not.
|
|
94
|
+
*
|
|
95
|
+
* Cheap by construction: no network call, no account lookup. It reads the credentials that already
|
|
96
|
+
* resolved and the address the capability is configured to send as. That catches the two states a
|
|
97
|
+
* developer most often starts a session in — no Cloudflare login at all, and a placeholder from
|
|
98
|
+
* address nobody has replaced — which is most of the value, at none of the cost of asking Cloudflare.
|
|
99
|
+
*/
|
|
100
|
+
export function deliveryPreflight(options: DeliveryPreflightOptions): DeliveryPreflight {
|
|
101
|
+
if (!options.composed) return { live: false, lines: [] };
|
|
102
|
+
|
|
103
|
+
if (options.requested === "simulator") {
|
|
104
|
+
return {
|
|
105
|
+
live: false,
|
|
106
|
+
lines: ["Email: the simulator, by config. Messages are logged and written to disk, never sent."],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!options.hasCloudflareLogin) {
|
|
111
|
+
return {
|
|
112
|
+
live: false,
|
|
113
|
+
lines: [
|
|
114
|
+
"Email: no Cloudflare credentials, so real delivery is not possible here — using the simulator.",
|
|
115
|
+
" run: pithy init, or set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN",
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const domain = domainOf(options.fromAddress);
|
|
121
|
+
if (domain === undefined || UNDELIVERABLE_DOMAINS.has(domain)) {
|
|
122
|
+
return {
|
|
123
|
+
live: false,
|
|
124
|
+
lines: [
|
|
125
|
+
`Email: ${options.fromAddress ?? "no from address"} cannot be onboarded onto Email Service — using the simulator.`,
|
|
126
|
+
" set email({ fromAddress }) to an address on a domain you have onboarded.",
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { live: true, lines: [`Email: sending for real from ${options.fromAddress}.`] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What the host says when a remote `send_email` binding will not stand up, or a send is rejected.
|
|
136
|
+
*
|
|
137
|
+
* Matched narrowly and on purpose. These are wrangler's and Cloudflare's words, not ours, so this is
|
|
138
|
+
* pattern matching over somebody else's output and the failure mode of being too clever is a false
|
|
139
|
+
* accusation on an unrelated line. Two shapes only — a binding that could not be established, and a
|
|
140
|
+
* send refused for an address or domain that is not onboarded — and anything else falls through to
|
|
141
|
+
* the ordinary tee'd output, where it is at least visible.
|
|
142
|
+
*/
|
|
143
|
+
const DELIVERY_FAILURES: readonly { pattern: RegExp; message: string; action: string }[] = [
|
|
144
|
+
{
|
|
145
|
+
pattern: /remote binding.*(send_email|EMAIL)|(send_email|EMAIL).*remote binding/i,
|
|
146
|
+
message: "The email host's send binding could not be established, so nothing will be delivered.",
|
|
147
|
+
action: 'Check the Cloudflare login wrangler dev uses, or set email({ devDelivery: "simulator" }).',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
pattern:
|
|
151
|
+
/(sender|from address|domain).*(not (a )?verified|unverified|not onboarded)|(not (a )?verified|unverified|not onboarded).*(sender|address|domain)/i,
|
|
152
|
+
message: "Cloudflare Email Service refused the sending address — its domain is not onboarded.",
|
|
153
|
+
action: 'Onboard the domain onto Email Service, or set email({ devDelivery: "simulator" }).',
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A rendered problem + action block for a host output line that reports a delivery failure, or
|
|
159
|
+
* `undefined` for every other line. Rendered through `renderTerminal` so it reads exactly like every
|
|
160
|
+
* other operator-facing failure, rather than being a second error format nobody recognizes.
|
|
161
|
+
*/
|
|
162
|
+
export function deliveryFailureNote(line: string): string | undefined {
|
|
163
|
+
const match = DELIVERY_FAILURES.find((failure) => failure.pattern.test(line));
|
|
164
|
+
if (!match) return undefined;
|
|
165
|
+
return renderTerminal(
|
|
166
|
+
new PithyError({
|
|
167
|
+
code: "core/upstream_failed",
|
|
168
|
+
status: 502,
|
|
169
|
+
message: match.message,
|
|
170
|
+
action: match.action,
|
|
171
|
+
detail: line,
|
|
172
|
+
}).payload,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { DEV_LOGIN_PATH, DEV_LOGIN_ROUTE, DevLogin } from "@pithy-sh/core/src/seed/devLogin";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `pithy dev` end of the dev login: say that there is one, and how to use it — **without ever
|
|
10
|
+
* saying what it is**.
|
|
11
|
+
*
|
|
12
|
+
* The banner is still where this belongs, because it is the only place a developer reliably looks and a
|
|
13
|
+
* seeded session nobody discovers has removed no friction. What changed is what the banner is allowed to
|
|
14
|
+
* carry. It used to hand over the credential itself:
|
|
15
|
+
*
|
|
16
|
+
* ```
|
|
17
|
+
* document.cookie = "better-auth.session_token=…; path=/; max-age=31535918"
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* A working session cookie, rendered as text, on every `pithy dev`. Terminals scroll back, output gets
|
|
21
|
+
* tee'd and piped, screenshots get pasted into issues — a value printed once is a value at rest in
|
|
22
|
+
* places nobody chose. `core/src/seed/seed.ts` already treats the login artifact as sensitive for
|
|
23
|
+
* exactly this reason, so printing it was the one place the rule was suspended by design.
|
|
24
|
+
*
|
|
25
|
+
* Now the credential travels over HTTP, from the Worker to the browser, and the terminal carries a
|
|
26
|
+
* keypress and a URL. Neither is a secret: {@link DEV_LOGIN_ROUTE} is registered only in a `dev`
|
|
27
|
+
* composition that is not CI, and it hands out only what `pithy seed` already minted on this machine.
|
|
28
|
+
*
|
|
29
|
+
* **A cookie value must never appear in a string this module returns.** That is asserted directly, over
|
|
30
|
+
* every line every function here can produce, rather than trusted to review.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** A started worker the dev-login route can be opened on — one that composes auth. */
|
|
34
|
+
export interface DevLoginTarget {
|
|
35
|
+
/** The worker's name, as `pithy dev` labels it. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Its localhost origin, from the pinned port. */
|
|
38
|
+
origin: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the banner needs to know about the session it is describing. */
|
|
42
|
+
export interface DevLoginBanner {
|
|
43
|
+
/** Whether a keypress can be offered — a TTY that is not being piped, and not `--json`. */
|
|
44
|
+
interactive: boolean;
|
|
45
|
+
/** The started workers that carry the route. Empty is a real answer, and gets its own sentence. */
|
|
46
|
+
targets: readonly DevLoginTarget[];
|
|
47
|
+
/**
|
|
48
|
+
* Whether this run is under CI, where the capability refuses to register the route at all.
|
|
49
|
+
*
|
|
50
|
+
* The keypress follows the route. Offering `l` here would be offering a 404, and it is the *only*
|
|
51
|
+
* refusal `pithy dev` can see coming: every other one is about what is running.
|
|
52
|
+
*/
|
|
53
|
+
ci: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The one sentence for a run under CI. Stated once, because the banner and the keypress both say it. */
|
|
57
|
+
const CI_REFUSAL = "the dev-login route is not registered under CI.";
|
|
58
|
+
|
|
59
|
+
/** What pressing `l` should do: open this URL, and say these lines. Either half may be empty. */
|
|
60
|
+
export interface DevLoginKeyAction {
|
|
61
|
+
/** The URL to open, or `undefined` when there is nothing to open — never a URL that would 404. */
|
|
62
|
+
url?: string;
|
|
63
|
+
/** What to print. A refusal always says what to do about it; an open says what it is opening. */
|
|
64
|
+
lines: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Read the seeded dev login, or `undefined` when there is none. Validated — an unreadable file is no login. */
|
|
68
|
+
export async function readDevLogin(projectDir: string): Promise<DevLogin | undefined> {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = DevLogin.safeParse(JSON.parse(await readFile(join(projectDir, DEV_LOGIN_PATH), "utf8")));
|
|
71
|
+
return parsed.success ? parsed.data : undefined;
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Where a worker serves the dev login. The route is core's, so both ends spell it the same way once. */
|
|
78
|
+
export function devLoginUrl(origin: string): string {
|
|
79
|
+
return `${origin}${DEV_LOGIN_ROUTE}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A usable login, or `undefined`.
|
|
84
|
+
*
|
|
85
|
+
* An expired cookie is worse than no cookie: it looks like a way in, fails silently in the browser, and
|
|
86
|
+
* sends someone hunting for a bug in auth. The banner therefore treats expiry as absence; the keypress
|
|
87
|
+
* does not, because someone who pressed `l` asked a question and deserves the reason.
|
|
88
|
+
*/
|
|
89
|
+
function usable(login: DevLogin | undefined, now: Date): DevLogin | undefined {
|
|
90
|
+
if (!login) return undefined;
|
|
91
|
+
return login.expiresAt.getTime() > now.getTime() ? login : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One indented `name: url` line per target — the shape both the banner and the keypress list choices in. */
|
|
95
|
+
function choices(targets: readonly DevLoginTarget[]): string[] {
|
|
96
|
+
return targets.map((target) => ` ${target.name}: ${devLoginUrl(target.origin)}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The banner lines for a seeded dev login — empty when there is none, and empty when it has expired.
|
|
101
|
+
*
|
|
102
|
+
* Four shapes, because the honest sentence differs: a keypress where there is a keypress and one target,
|
|
103
|
+
* a URL where there is not, the choices where more than one worker composes auth, and a plain statement
|
|
104
|
+
* where nothing running carries the route at all. Nothing here guesses.
|
|
105
|
+
*/
|
|
106
|
+
export function devLoginLines(login: DevLogin | undefined, now: Date, banner: DevLoginBanner): string[] {
|
|
107
|
+
const live = usable(login, now);
|
|
108
|
+
if (!live) return [];
|
|
109
|
+
if (banner.ci) return [`Dev login: ${live.email} — ${CI_REFUSAL}`];
|
|
110
|
+
const targets = banner.targets;
|
|
111
|
+
|
|
112
|
+
if (targets.length === 0) {
|
|
113
|
+
return [`Dev login: ${live.email} — no running worker composes auth, so there is nothing to open.`];
|
|
114
|
+
}
|
|
115
|
+
if (banner.interactive) {
|
|
116
|
+
const verb = targets.length === 1 ? "open a signed-in browser" : "choose a worker and open a signed-in browser";
|
|
117
|
+
return [`Dev login: ${live.email} — press l to ${verb}.`];
|
|
118
|
+
}
|
|
119
|
+
const first = targets[0];
|
|
120
|
+
if (targets.length === 1 && first) {
|
|
121
|
+
return [`Dev login: ${live.email} — open ${devLoginUrl(first.origin)} to sign in.`];
|
|
122
|
+
}
|
|
123
|
+
return [`Dev login: ${live.email} — open one of these to sign in.`, ...choices(targets)];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What `l` does, decided without touching the terminal or the network so it can be tested as a value.
|
|
128
|
+
*
|
|
129
|
+
* Every refusal names the command that fixes it and opens nothing. A browser sent to a route that 404s
|
|
130
|
+
* is worse than a sentence: it looks like the feature is broken rather than like the session is missing.
|
|
131
|
+
*/
|
|
132
|
+
export function devLoginKeyAction(
|
|
133
|
+
login: DevLogin | undefined,
|
|
134
|
+
now: Date,
|
|
135
|
+
targets: readonly DevLoginTarget[],
|
|
136
|
+
ci = false,
|
|
137
|
+
): DevLoginKeyAction {
|
|
138
|
+
if (!login) return { lines: ["No dev login is seeded. Run pithy seed, then press l again."] };
|
|
139
|
+
if (!usable(login, now)) {
|
|
140
|
+
return { lines: ["The seeded dev login has expired. Run pithy seed to mint a fresh one."] };
|
|
141
|
+
}
|
|
142
|
+
// Before the targets, because this refusal is about the route rather than about what is running: the
|
|
143
|
+
// workers below all compose auth and none of them mounted it.
|
|
144
|
+
if (ci) return { lines: [`Not opening — ${CI_REFUSAL}`] };
|
|
145
|
+
if (targets.length === 0) return { lines: ["No running worker composes auth, so there is nothing to open."] };
|
|
146
|
+
|
|
147
|
+
const first = targets[0];
|
|
148
|
+
if (targets.length === 1 && first) {
|
|
149
|
+
const url = devLoginUrl(first.origin);
|
|
150
|
+
return { url, lines: [`Opening ${url} as ${login.email}.`] };
|
|
151
|
+
}
|
|
152
|
+
// More than one worker carries the route, and they are separate origins — a cookie set on one signs
|
|
153
|
+
// nobody into the other. There is no defensible guess, so the choice is printed.
|
|
154
|
+
return { lines: ["More than one worker composes auth. Open the one you want:", ...choices(targets)] };
|
|
155
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { allCapabilities, loadWorkerConfig } from "../project/config";
|
|
5
|
+
import { parseWorkerManifest } from "../project/workerManifest";
|
|
6
|
+
import type { DevLoginTarget } from "./devLogin";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Which running worker `l` opens, in a project that runs more than one.
|
|
10
|
+
*
|
|
11
|
+
* **The rule is "the worker that carries the route", not "the worker with the UI"** — and the reason is
|
|
12
|
+
* the cookie, not preference. `Set-Cookie` is scoped to the origin that sent it, and every worker in a
|
|
13
|
+
* dev session is a different `localhost:<port>`. Opening a worker that does not compose auth would 404
|
|
14
|
+
* on {@link DEV_LOGIN_ROUTE}; opening one that does, and then browsing to a *different* worker, leaves
|
|
15
|
+
* the browser signed out on the second origin. So the candidate set is exactly the workers that compose
|
|
16
|
+
* auth, which is a fact read off each worker's own `pithy.config.ts` rather than guessed.
|
|
17
|
+
*
|
|
18
|
+
* **The UI block breaks a tie, and only a tie.** `pithy ui add` records `ui` in `pithy.worker.jsonc`,
|
|
19
|
+
* and in the ordinary Pithy project that block sits on the same worker that serves the API — one origin,
|
|
20
|
+
* one cookie, one thing to look at. Where two workers both compose auth and one of them is the one with
|
|
21
|
+
* a front end, that is the one a developer meant. Where the signal does not decide — neither carries a
|
|
22
|
+
* UI, or both do — nothing is narrowed and the caller prints the choices. A silent guess between two
|
|
23
|
+
* origins is the failure this avoids: it looks like the feature worked, and the browser is signed in to
|
|
24
|
+
* the wrong one.
|
|
25
|
+
*
|
|
26
|
+
* Both probes are non-fatal. A worker whose `pithy.config.ts` will not import composes nothing as far as
|
|
27
|
+
* this is concerned; wrangler reports that file's failure, loudly, and inventing a second complaint here
|
|
28
|
+
* would only bury it.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** A started worker, as this module needs it: its label, its directory, and where it answers. */
|
|
32
|
+
export interface StartedWorkerDir {
|
|
33
|
+
name: string;
|
|
34
|
+
/** The `apps/<name>/` directory holding its `pithy.config.ts` and `pithy.worker.jsonc`. */
|
|
35
|
+
dir: string;
|
|
36
|
+
origin: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Everything {@link devLoginTargets} needs. Both probes default to reading the worker's own files. */
|
|
40
|
+
export interface DevLoginTargetOptions {
|
|
41
|
+
started: readonly StartedWorkerDir[];
|
|
42
|
+
/** Does this worker compose the auth capability, and therefore carry the route? */
|
|
43
|
+
composesAuth?: (dir: string) => Promise<boolean>;
|
|
44
|
+
/** Does this worker carry a front end (`ui` in its `pithy.worker.jsonc`)? Breaks a tie, nothing more. */
|
|
45
|
+
hasUi?: (dir: string) => Promise<boolean>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether a worker's own config composes auth. A config that will not load composes nothing. */
|
|
49
|
+
async function composesAuthDefault(dir: string): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
return allCapabilities(await loadWorkerConfig(dir)).some((capability) => capability.name === "auth");
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether a worker carries a front end. The `ui` block's presence *is* the signal (see `ui/workerUi.ts`). */
|
|
58
|
+
async function hasUiDefault(dir: string): Promise<boolean> {
|
|
59
|
+
try {
|
|
60
|
+
return (await parseWorkerManifest(dir))?.ui !== undefined;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Run a predicate over every started worker, keeping the started order. */
|
|
67
|
+
async function keep(
|
|
68
|
+
workers: readonly StartedWorkerDir[],
|
|
69
|
+
predicate: (dir: string) => Promise<boolean>,
|
|
70
|
+
): Promise<StartedWorkerDir[]> {
|
|
71
|
+
const verdicts = await Promise.all(
|
|
72
|
+
workers.map(async (worker) => {
|
|
73
|
+
try {
|
|
74
|
+
return await predicate(worker.dir);
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
return workers.filter((_worker, index) => verdicts[index] === true);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The workers `l` may open, in started order — one to open, several to choose from, or none. */
|
|
84
|
+
export async function devLoginTargets(options: DevLoginTargetOptions): Promise<DevLoginTarget[]> {
|
|
85
|
+
const carriers = await keep(options.started, options.composesAuth ?? composesAuthDefault);
|
|
86
|
+
if (carriers.length <= 1) return carriers.map(({ name, origin }) => ({ name, origin }));
|
|
87
|
+
|
|
88
|
+
const withUi = await keep(carriers, options.hasUi ?? hasUiDefault);
|
|
89
|
+
const chosen = withUi.length === 1 ? withUi : carriers;
|
|
90
|
+
return chosen.map(({ name, origin }) => ({ name, origin }));
|
|
91
|
+
}
|
package/src/dev/env.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { CI_ENV, isContinuousIntegration } from "@pithy-sh/core/src/env/ci";
|
|
5
|
+
import { envStem } from "@pithy-sh/core/src/env/stem";
|
|
6
|
+
import { WORKER_ORIGIN_VAR } from "@pithy-sh/core/src/worker/identity";
|
|
7
|
+
import type { DevConfig } from "../feature/devConfig";
|
|
8
|
+
import { DEV_PORT_TOKEN } from "../project/workerManifest";
|
|
9
|
+
import type { WorkerTarget } from "../project/workers";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the environment every child inherits: the parent env plus, for **every** worker in the feature's
|
|
13
|
+
* dev config, `<STEM>_PORT` and `<STEM>_ORIGIN`. That is how workers reach each other — each sibling's
|
|
14
|
+
* localhost address is known ahead of time and injected, so a worker calls its peers directly instead of
|
|
15
|
+
* relying on wrangler's flaky cross-`wrangler dev` service registry.
|
|
16
|
+
*/
|
|
17
|
+
export function buildWorkerEnv(config: DevConfig, base: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
18
|
+
const env: Record<string, string> = {};
|
|
19
|
+
for (const [key, value] of Object.entries(base)) {
|
|
20
|
+
if (value !== undefined) env[key] = value;
|
|
21
|
+
}
|
|
22
|
+
for (const [name, worker] of Object.entries(config.workers)) {
|
|
23
|
+
const stem = envStem(name);
|
|
24
|
+
env[`${stem}_PORT`] = String(worker.port);
|
|
25
|
+
env[`${stem}_ORIGIN`] = worker.origin;
|
|
26
|
+
}
|
|
27
|
+
return env;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The origin to hand one worker as its own, or `null` when somebody else owns it.
|
|
32
|
+
*
|
|
33
|
+
* `null` is a capability host: its `BASE_URL` is the *app's* origin, because it holds no public route
|
|
34
|
+
* and a verification link it mails has to arrive back at the app. `materializeHostConfigs` writes that
|
|
35
|
+
* into the host's generated config, so a value from here could only be a second producer of one
|
|
36
|
+
* setting — and the one it would produce is the wrong one.
|
|
37
|
+
*
|
|
38
|
+
* One function rather than a condition repeated at each carrier, so a host cannot be exempt from the
|
|
39
|
+
* argv path and not from the environment path.
|
|
40
|
+
*/
|
|
41
|
+
export function ownOriginFor(workerName: string, origin: string, hostNames: ReadonlySet<string>): string | null {
|
|
42
|
+
return hostNames.has(workerName) ? null : origin;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One child's environment: everything {@link buildWorkerEnv} publishes, plus that child's own origin.
|
|
47
|
+
*
|
|
48
|
+
* Per child, and that is the point. `buildWorkerEnv` is built once and shared, because `<STEM>_ORIGIN`
|
|
49
|
+
* is the same table of *other people's* addresses for everybody. "Where do I answer" is the one fact
|
|
50
|
+
* that differs per child, so it cannot live in the shared object.
|
|
51
|
+
*/
|
|
52
|
+
export function childEnvFor(shared: Record<string, string>, ownOrigin: string | null): Record<string, string> {
|
|
53
|
+
if (ownOrigin === null) return shared;
|
|
54
|
+
return { ...shared, [WORKER_ORIGIN_VAR]: ownOrigin };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Turn `["dev", "--port", "8787"]` into a spawnable `{ command, args }` via the project's package manager. */
|
|
58
|
+
export type WranglerLauncher = (args: string[]) => { command: string; args: string[] };
|
|
59
|
+
|
|
60
|
+
/** A resolved start command for one worker. */
|
|
61
|
+
export interface StartCommand {
|
|
62
|
+
command: string;
|
|
63
|
+
args: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The command that starts one worker at its pinned `port`.
|
|
68
|
+
*
|
|
69
|
+
* A worker with an explicit `dev.command` (e.g. a Vite frontend with no `wrangler.jsonc`) runs that verbatim
|
|
70
|
+
* — the port is **not** appended; it reaches the process through `<STEM>_PORT` in the env, or through the
|
|
71
|
+
* `{port}` token wherever the manifest placed it on the argv. That token is the one substitution, and it
|
|
72
|
+
* exists because the orchestrator spawns with no shell: `$WEB_PORT` in an argv array is a literal, never an
|
|
73
|
+
* expansion, and a dev server that takes its port as a flag (`vite dev --port {port}`) has nowhere else to
|
|
74
|
+
* read it. A command with no token runs byte-identically. A plain Worker runs `wrangler dev --port <port>
|
|
75
|
+
* --inspector-port 0` (inspector `0` auto-assigns, so multiple workers never collide on the inspector port),
|
|
76
|
+
* resolved through the project's package manager rather than a hardcoded global wrangler.
|
|
77
|
+
*
|
|
78
|
+
* **`--persist-to` is what makes local sharing real.** Every Worker lives in its own `apps/<name>/` and
|
|
79
|
+
* wrangler defaults its local state to the *cwd* it runs in, so each Worker would get its own `.wrangler/`
|
|
80
|
+
* store — and two Workers that deliberately declare the same binding (the way Workers share a database)
|
|
81
|
+
* would silently read and write two different local D1s, with the divergence showing up only as
|
|
82
|
+
* inexplicably missing rows. Pointing every Worker at one project-level store makes local behave the way
|
|
83
|
+
* the deployed environment does.
|
|
84
|
+
*/
|
|
85
|
+
export function startCommand(
|
|
86
|
+
worker: WorkerTarget,
|
|
87
|
+
port: number,
|
|
88
|
+
origin: string | null,
|
|
89
|
+
launchWrangler: WranglerLauncher,
|
|
90
|
+
persistTo: string,
|
|
91
|
+
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
92
|
+
hostPorts: Readonly<Record<string, number>> = {},
|
|
93
|
+
): StartCommand {
|
|
94
|
+
const custom = worker.dev?.command;
|
|
95
|
+
if (custom && custom.length > 0) {
|
|
96
|
+
// A custom command is run verbatim: it is not necessarily wrangler, so no flag is appended. The only
|
|
97
|
+
// edit is `{port}` → the pinned port, in every element that carries it. It also gets no `--var`: the
|
|
98
|
+
// process already inherits the real environment through `buildWorkerEnv`, which is exactly what the
|
|
99
|
+
// forwarding below exists to work around for workerd.
|
|
100
|
+
const resolved = custom.map((part) => part.replaceAll(DEV_PORT_TOKEN, String(port)));
|
|
101
|
+
return { command: resolved[0] as string, args: resolved.slice(1) };
|
|
102
|
+
}
|
|
103
|
+
return launchWrangler([
|
|
104
|
+
"dev",
|
|
105
|
+
"--port",
|
|
106
|
+
String(port),
|
|
107
|
+
"--inspector-port",
|
|
108
|
+
"0",
|
|
109
|
+
"--persist-to",
|
|
110
|
+
persistTo,
|
|
111
|
+
...ownOriginVarArgs(origin),
|
|
112
|
+
...ciVarArgs(baseEnv),
|
|
113
|
+
...hostVarArgs(worker.name, hostPorts),
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Tell the Worker where it itself answers, as `BASE_URL`.
|
|
119
|
+
*
|
|
120
|
+
* **The one address a Worker cannot work out and cannot be made to write down.** It cannot derive it
|
|
121
|
+
* from a request: `Host` is caller-controlled, so a Worker that did would take its own identity from
|
|
122
|
+
* whoever called it. And it cannot state it in `wrangler.jsonc`, because a dev port is *allocated* —
|
|
123
|
+
* every checkout reserves its own block, so a literal there is right in the first checkout on a machine
|
|
124
|
+
* and wrong in every other one. `pithy dev` is the only party that knows, and this is where it says so.
|
|
125
|
+
*
|
|
126
|
+
* `--var` beats a `vars` entry in the config, so a project that already wrote a dev `BASE_URL` down is
|
|
127
|
+
* corrected rather than asked to edit anything. Deployed environments never reach here: `applyDomains`
|
|
128
|
+
* generates theirs from the `domains` declaration, and this runs only under `pithy dev`.
|
|
129
|
+
*
|
|
130
|
+
* **Note what this is not.** {@link hostVarArgs} deliberately withholds a host's own `<STEM>_ORIGIN`,
|
|
131
|
+
* because that var names a *dispatch target* and a Worker posting to itself is a request that answers
|
|
132
|
+
* itself forever. `BASE_URL` is the opposite kind of fact — it is an identity, the `iss` a
|
|
133
|
+
* control-plane token carries and the origin a callback link is built against — and withholding it is
|
|
134
|
+
* what made `pithy-sh/dashboard#95`: a second checkout signed tokens as the first one and its own seam
|
|
135
|
+
* denied every call. The two vars look alike and mean opposite things.
|
|
136
|
+
*
|
|
137
|
+
* The origin travels **verbatim** from `.dev.config.json` rather than being rebuilt as
|
|
138
|
+
* `http://localhost:${port}`. The config pins both, and recomposing one from the other is a second
|
|
139
|
+
* producer of a value that is already written down — the same rule `SeedPrepareContext.origin` states.
|
|
140
|
+
*
|
|
141
|
+
* **`null` means somebody else owns this Worker's `BASE_URL`, and today that is a capability host.**
|
|
142
|
+
* A host's is the *app's* origin, not its own: it holds no public route, and a callback link it mails
|
|
143
|
+
* has to arrive back at the app. `materializeHostConfigs` writes that value into the host's generated
|
|
144
|
+
* `wrangler.jsonc`, from the same allocation, so a `--var` here could only be a second producer of one
|
|
145
|
+
* value — and the one it would produce is the wrong one. Overriding it would point every verification
|
|
146
|
+
* link at the email host.
|
|
147
|
+
*/
|
|
148
|
+
function ownOriginVarArgs(origin: string | null): string[] {
|
|
149
|
+
if (origin === null) return [];
|
|
150
|
+
// wrangler splits a `--var` at its first colon, so the `http://` in the value survives intact.
|
|
151
|
+
return ["--var", `BASE_URL:${origin}`];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Forward each capability host's address into the Worker as vars.
|
|
156
|
+
*
|
|
157
|
+
* Same reason as {@link ciVarArgs}, and the same mechanism: `buildWorkerEnv` publishes
|
|
158
|
+
* `<STEM>_ORIGIN` and `<STEM>_PORT` into every child *process*, and the host environment does not
|
|
159
|
+
* cross into workerd. So an app Worker asking for `EMAIL_ORIGIN` — the address core's loopback
|
|
160
|
+
* dispatcher posts a Workflow dispatch to, in place of the cross-script binding a deployed
|
|
161
|
+
* environment has — would read nothing at all. One `--var` per host is what makes the read truthful.
|
|
162
|
+
*
|
|
163
|
+
* Only the hosts, not every sibling. An `apps/*` Worker reaches another over `<STEM>_ORIGIN` in the
|
|
164
|
+
* *process* env today, and widening this to all of them is a change to what every Worker sees rather
|
|
165
|
+
* than the one wire this issue is about (pithy-sh/pithy#410).
|
|
166
|
+
*
|
|
167
|
+
* A host is never handed its own address: it *is* the thing at that origin, and a self-dispatch
|
|
168
|
+
* loop is a request that answers itself forever.
|
|
169
|
+
*
|
|
170
|
+
* **That is a rule about dispatch targets, not about self-knowledge.** A Worker does learn where it
|
|
171
|
+
* itself answers — see {@link ownOriginVarArgs}, which hands it exactly that as `BASE_URL`. The two
|
|
172
|
+
* vars are adjacent, look alike, and mean opposite things: `<STEM>_ORIGIN` is somewhere to send a
|
|
173
|
+
* request, `BASE_URL` is who you are. Reading the paragraph above as "a Worker never learns its own
|
|
174
|
+
* origin" is what left every checkout but the first signing tokens as another one (#462).
|
|
175
|
+
*/
|
|
176
|
+
function hostVarArgs(workerName: string, hostPorts: Readonly<Record<string, number>>): string[] {
|
|
177
|
+
const args: string[] = [];
|
|
178
|
+
for (const [name, port] of Object.entries(hostPorts)) {
|
|
179
|
+
if (name === workerName) continue;
|
|
180
|
+
const stem = envStem(name);
|
|
181
|
+
// wrangler splits a `--var` at its first colon, so the `http://` in the value survives intact.
|
|
182
|
+
args.push("--var", `${stem}_ORIGIN:http://localhost:${port}`, "--var", `${stem}_PORT:${port}`);
|
|
183
|
+
}
|
|
184
|
+
return args;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Forward `CI` into the Worker as a var, when this process is running under one.
|
|
189
|
+
*
|
|
190
|
+
* **The host's environment does not cross into workerd.** With `nodejs_compat`, `process.env` inside a
|
|
191
|
+
* Worker is populated from that script's own `vars` and secrets and nothing else — verified against a
|
|
192
|
+
* real `wrangler dev`, where `Object.keys(process.env)` at module scope is exactly the declared vars. So
|
|
193
|
+
* a capability that refuses to register itself under CI (`@pithy-sh/auth`'s dev-login route is the first)
|
|
194
|
+
* cannot see the `CI=true` that GitHub Actions set in the shell that ran this command. One `--var` is
|
|
195
|
+
* what makes that read truthful for every Worker Pithy starts.
|
|
196
|
+
*
|
|
197
|
+
* Nothing is forwarded off CI, so an ordinary `pithy dev` writes no var and the Worker's `process.env` is
|
|
198
|
+
* byte-identical to what it was. And the forwarding is a convenience, never the security boundary: the
|
|
199
|
+
* capability's environment gate refuses in `staging` and `prod` with no cooperation from anything here.
|
|
200
|
+
*/
|
|
201
|
+
function ciVarArgs(env: NodeJS.ProcessEnv): string[] {
|
|
202
|
+
if (!isContinuousIntegration(env)) return [];
|
|
203
|
+
// The value travels verbatim rather than normalized to `true`: "any non-blank value" is the rule at
|
|
204
|
+
// both ends (#218), and rewriting it here would be this file inventing a second one.
|
|
205
|
+
return ["--var", `${CI_ENV}:${env[CI_ENV] ?? ""}`];
|
|
206
|
+
}
|