@kedge/cli 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cf.ts CHANGED
@@ -26,6 +26,8 @@ export interface Deployment {
26
26
  export interface WorkerSettings {
27
27
  logpush: boolean;
28
28
  invocationLogs: boolean | null;
29
+ /** `observability.redact_query_string`; null when the read doesn't say (p0.11 row 18). */
30
+ redactQueryString: boolean | null;
29
31
  }
30
32
 
31
33
  /** A call whose answer never came: the caller records it as an unknown outcome (§3.1). */
@@ -107,8 +109,18 @@ export interface Cloudflare {
107
109
  executeD1File(input: { database: string; databaseId: string; sql: string }): Promise<void>;
108
110
  /** The account's workers.dev subdomain; none is the person's choice to make (`NeedsPerson`). */
109
111
  workersSubdomain(): Promise<string>;
110
- /** One list of each resource type a store uses: null where it answered, else the refusing status (0: no answer). */
111
- readable(): Promise<Record<ResourceKind, number | null>>;
112
+ /**
113
+ * The Worker's workers.dev URL when it's served there, else null: a platform
114
+ * may answer an account subdomain and serve the store elsewhere (salve
115
+ * hosting's own domain; p0.11 row 2).
116
+ */
117
+ workersDevUrl(worker: string): Promise<string | null>;
118
+ /**
119
+ * One list of each resource type a store uses: null where it answered,
120
+ * "not served" where the platform refuses the route itself (10400, p0.11
121
+ * row 5), else the refusing status (0: no answer).
122
+ */
123
+ readable(): Promise<Record<ResourceKind, number | "not served" | null>>;
112
124
  /** The account's Logpush jobs, or null when the token can't read them. */
113
125
  logpushJobs(): Promise<{ dataset: string; enabled: boolean; fields: string[] | null }[] | null>;
114
126
  /** `wrangler versions secret put|delete`: a new version with one secret changed, not deployed. */
@@ -196,12 +208,18 @@ export function noSubdomain(account: string): NeedsPerson {
196
208
  * another. `ops` is that database's name; `preview apply` from a plan names
197
209
  * its store only once the plan is read, and `openOps` checks the same there.
198
210
  */
199
- export async function preflight(cf: Cloudflare, ops?: string, choice?: string): Promise<void> {
211
+ export async function preflight(
212
+ cf: Cloudflare,
213
+ ops?: string,
214
+ choice?: string,
215
+ /** Whether the store is served at workers.dev; a store at its own domain alone needs no subdomain (codex code r3). */
216
+ workersDev = true,
217
+ ): Promise<void> {
200
218
  if (choice !== undefined && !isJurisdictionChoice(choice))
201
219
  throw new NeedsPerson(
202
220
  `--jurisdiction takes ${JURISDICTIONS.join(", ")} or none, not "${choice}": ${CHOOSE_JURISDICTION}`,
203
221
  );
204
- await cf.workersSubdomain();
222
+ if (workersDev) await cf.workersSubdomain();
205
223
  if (ops === undefined) return;
206
224
  const id = await cf.findD1(ops);
207
225
  if (!id && choice === undefined) throw new NeedsPerson(CHOOSE_JURISDICTION);
@@ -237,16 +255,26 @@ export async function resolveCloudflare(
237
255
  ): Promise<CloudflareAuth> {
238
256
  if (env.CLOUDFLARE_API_TOKEN) {
239
257
  const token = env.CLOUDFLARE_API_TOKEN;
240
- if (!env.CLOUDFLARE_ACCOUNT_ID) throw new NeedsPerson("set CLOUDFLARE_ACCOUNT_ID beside CLOUDFLARE_API_TOKEN");
241
- sameAccount(env.CLOUDFLARE_ACCOUNT_ID, configIn(cwd));
242
- return { source: "environment", account: env.CLOUDFLARE_ACCOUNT_ID, token: () => token };
258
+ // A platform that gives a token alone (p0.11 row 3): the one account it reaches.
259
+ const account =
260
+ env.CLOUDFLARE_ACCOUNT_ID ??
261
+ (await tokenAccount(env, token, fetchImpl, "set CLOUDFLARE_ACCOUNT_ID beside CLOUDFLARE_API_TOKEN"));
262
+ sameAccount(account, configIn(cwd));
263
+ return { source: "environment", account, token: () => token };
243
264
  }
244
265
  const file = join(cwd, ".env.local");
245
266
  const local = existsSync(file) ? parseEnv(readFileSync(file, "utf8")) : {};
246
267
  if (local.CLOUDFLARE_API_TOKEN) {
247
268
  const token = local.CLOUDFLARE_API_TOKEN;
248
- const account = env.CLOUDFLARE_ACCOUNT_ID ?? local.CLOUDFLARE_ACCOUNT_ID;
249
- if (!account) throw new NeedsPerson("add CLOUDFLARE_ACCOUNT_ID to .env.local beside CLOUDFLARE_API_TOKEN");
269
+ const account =
270
+ env.CLOUDFLARE_ACCOUNT_ID ??
271
+ local.CLOUDFLARE_ACCOUNT_ID ??
272
+ (await tokenAccount(
273
+ env,
274
+ token,
275
+ fetchImpl,
276
+ "add CLOUDFLARE_ACCOUNT_ID to .env.local beside CLOUDFLARE_API_TOKEN",
277
+ ));
250
278
  sameAccount(account, configIn(cwd));
251
279
  return { source: ".env.local", account, token: () => token };
252
280
  }
@@ -320,6 +348,25 @@ function wranglerLoginToken(env: NodeJS.ProcessEnv, wranglerEntry: string): stri
320
348
  }
321
349
  }
322
350
 
351
+ /**
352
+ * The one account an API token reaches (`GET /accounts`, which a platform like
353
+ * salve answers with its stand-in): none or several is the person's to say.
354
+ */
355
+ async function tokenAccount(
356
+ env: NodeJS.ProcessEnv,
357
+ token: string,
358
+ fetchImpl: typeof fetch,
359
+ ask: string,
360
+ ): Promise<string> {
361
+ const res = await fetchImpl(`${cloudflareBase(env)}/accounts?per_page=50`, {
362
+ headers: { authorization: `Bearer ${token}` },
363
+ }).catch(() => null);
364
+ const data = (await res?.json().catch(() => ({}))) as { result?: { id?: string }[] } | undefined;
365
+ const ids = (data?.result ?? []).map((a) => a.id).filter((id): id is string => !!id);
366
+ if (ids.length === 1) return ids[0] as string;
367
+ throw new NeedsPerson(ask);
368
+ }
369
+
323
370
  /** The one account a token can reach; more than one is the person's choice. */
324
371
  async function onlyAccount(env: NodeJS.ProcessEnv, token: string, fetchImpl: typeof fetch): Promise<string> {
325
372
  const res = await fetchImpl(`${cloudflareBase(env)}/memberships?per_page=50`, {
@@ -357,6 +404,11 @@ export async function cloudflareFor(
357
404
  return restCloudflare(env, fetchImpl, wranglerEntry, await resolveCloudflare(env, cwd, fetchImpl, wranglerEntry));
358
405
  }
359
406
 
407
+ /** A platform's refusal of a route or key, as salve's broker answers it (p0.11 §6): never "not found". */
408
+ export const PLATFORM_REFUSED = 10400;
409
+ /** Attempts of one call refused with proof it ran nothing, before the refusal is the answer. */
410
+ const RETRIES = 5;
411
+
360
412
  export function restCloudflare(
361
413
  env: NodeJS.ProcessEnv,
362
414
  fetchImpl: typeof fetch = fetch,
@@ -364,6 +416,8 @@ export function restCloudflare(
364
416
  wranglerEntry: string = wranglerBin(),
365
417
  /** Resolved credentials; without them, the environment's two variables. */
366
418
  auth?: CloudflareAuth,
419
+ /** The wait before a retry; tests make it instant. */
420
+ sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
367
421
  ): Cloudflare {
368
422
  const resolved =
369
423
  auth ??
@@ -377,33 +431,71 @@ export function restCloudflare(
377
431
  const apiBase = cloudflareBase(env);
378
432
  const base = `${apiBase}/accounts/${account}`;
379
433
 
380
- async function call<T>(what: string, method: string, path: string, body?: unknown, ok404 = false): Promise<T | null> {
381
- // Before the network's catch: a credential that's gone is a handoff, not an unknown outcome.
382
- const bearer = resolved.token();
434
+ async function call<T>(
435
+ what: string,
436
+ method: string,
437
+ path: string,
438
+ body?: unknown,
439
+ ok404 = false,
440
+ /** One upstream call: Cloudflare's 429 blocks it whole. Activation and a Worker's deletion are several (p0.11 row 16). */
441
+ oneCall = true,
442
+ ): Promise<T | null> {
383
443
  let res: Response;
384
- try {
385
- res = await fetchImpl(`${base}${path}`, {
386
- method,
387
- headers: {
388
- authorization: `Bearer ${bearer}`,
389
- ...(body === undefined ? {} : { "content-type": "application/json" }),
390
- },
391
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
392
- });
393
- } catch {
394
- throw new UnknownOutcome(what);
444
+ let notRun = false;
445
+ for (let attempt = 1; ; attempt++) {
446
+ // Before the network's catch: a credential that's gone is a handoff, not an unknown outcome.
447
+ const bearer = resolved.token();
448
+ try {
449
+ res = await fetchImpl(`${base}${path}`, {
450
+ method,
451
+ headers: {
452
+ authorization: `Bearer ${bearer}`,
453
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
454
+ },
455
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
456
+ });
457
+ } catch {
458
+ throw new UnknownOutcome(what);
459
+ }
460
+ // A refusal proven to have run nothing is sent again after the wait the platform names
461
+ // (p0.11 row 16): salve marks its own `x-salve-not-run: 1`; Cloudflare's 429 blocks a
462
+ // one-call request whole; a 503 proves nothing for a write, which can commit and then fail.
463
+ notRun =
464
+ res.headers.get("x-salve-not-run") === "1" ||
465
+ (res.status === 429 && oneCall) ||
466
+ (res.status === 503 && method === "GET");
467
+ if (!((res.status === 429 || res.status === 503) && notRun) || attempt >= RETRIES) break;
468
+ const after = Number(res.headers.get("retry-after"));
469
+ await sleep(Math.min(Number.isFinite(after) && after > 0 ? after : 5, 60) * 1000);
470
+ }
471
+ if (ok404 && res.status === 404) {
472
+ // A platform's refusal of the route (10400) isn't absence: reading it so would lose a live resource.
473
+ const refused = (await res.json().catch(() => null)) as Envelope<T> | null;
474
+ // Absence is a readable envelope saying so; an HTML or plain-text 404 from something in front of the
475
+ // API says nothing, and reading it as absent would let a teardown skip a live Worker (codex code r4).
476
+ if (refused?.success !== false || !Array.isArray(refused.errors))
477
+ throw new CloudflareError(`${what} (answered 404, unreadable)`, 404, []);
478
+ if (!refused.errors.some((e) => e.code === PLATFORM_REFUSED)) return null;
479
+ throw new CloudflareError(
480
+ what,
481
+ 404,
482
+ refused.errors.map((e) => e.code),
483
+ refused.errors.map((e) => e.message ?? "").filter(Boolean),
484
+ );
395
485
  }
396
- if (ok404 && res.status === 404) return null;
397
486
  let data: Envelope<T>;
398
487
  try {
399
488
  data = (await res.json()) as Envelope<T>;
400
489
  } catch {
401
490
  // A body we can't read isn't an answer: a lookup reading it as "absent" would forget a live resource.
402
- if (method === "GET") throw new CloudflareError(what, res.status, []);
491
+ // A write is unknown, unless the platform proved it ran nothing (codex code r2).
492
+ if (method === "GET" || (notRun && !res.ok)) throw new CloudflareError(what, res.status, []);
403
493
  throw new UnknownOutcome(what);
404
494
  }
405
495
  if (!res.ok || data.success !== true) {
406
- if (res.status >= 500 && method !== "GET") throw new UnknownOutcome(what);
496
+ // A write refused without proof it ran nothing may have taken effect: a 5xx, or a 429 from inside a
497
+ // write of several steps (salve's activation), stays unknown for the caller's intent to reconcile.
498
+ if (method !== "GET" && !notRun && (res.status >= 500 || res.status === 429)) throw new UnknownOutcome(what);
407
499
  throw new CloudflareError(
408
500
  what,
409
501
  res.status,
@@ -415,6 +507,39 @@ export function restCloudflare(
415
507
  }
416
508
  const get = <T>(what: string, path: string, ok404 = false) => call<T>(what, "GET", path, undefined, ok404);
417
509
 
510
+ /**
511
+ * The Worker's settings, read before and after a deploy changes them. A
512
+ * platform that doesn't serve the read stops the deploy, saying so: Kedge
513
+ * never infers invocation logs are off (p0.11 row 4).
514
+ */
515
+ async function getSettings<T>(what: string, path: string): Promise<T | null> {
516
+ try {
517
+ return await get<T>(what, path);
518
+ } catch (e) {
519
+ if (e instanceof CloudflareError && e.codes.includes(PLATFORM_REFUSED))
520
+ throw new CloudflareError(
521
+ `${what}: this platform doesn't serve the Worker's settings (GET …/script-settings), so Kedge can't confirm its logs keep customer data out, and stops`,
522
+ e.status,
523
+ e.codes,
524
+ e.detail,
525
+ );
526
+ throw e;
527
+ }
528
+ }
529
+
530
+ async function subdomain(): Promise<string> {
531
+ let r: { subdomain: string } | null;
532
+ try {
533
+ r = await get<{ subdomain: string }>("workers.dev subdomain", "/workers/subdomain");
534
+ } catch (e) {
535
+ // 10007 is Cloudflare's "no subdomain", as Wrangler reads it (wrangler-dist/cli.js:164937, 4.152.0).
536
+ if (e instanceof CloudflareError && e.codes.includes(10007)) throw noSubdomain(account);
537
+ throw e;
538
+ }
539
+ if (!r?.subdomain) throw noSubdomain(account);
540
+ return r.subdomain;
541
+ }
542
+
418
543
  async function all<T>(what: string, path: string): Promise<T[]> {
419
544
  const out: T[] = [];
420
545
  for (let page = 1; page <= 50; page++) {
@@ -490,10 +615,20 @@ export function restCloudflare(
490
615
  },
491
616
  async createD1(name, jurisdiction) {
492
617
  // `jurisdiction` in the create body, the only time it can be set (D1 data location, cited above).
493
- const r = await call<{ uuid: string }>(`create D1 ${name}`, "POST", "/d1/database", {
494
- name,
495
- ...(jurisdiction ? { jurisdiction } : {}),
496
- });
618
+ let r: { uuid: string } | null;
619
+ try {
620
+ r = await call<{ uuid: string }>(`create D1 ${name}`, "POST", "/d1/database", {
621
+ name,
622
+ ...(jurisdiction ? { jurisdiction } : {}),
623
+ });
624
+ } catch (e) {
625
+ // A platform's refusal made nothing; the first create is <store>-ops's, so nothing else exists yet (p0.11 row 7).
626
+ if (jurisdiction && e instanceof CloudflareError && e.codes.includes(PLATFORM_REFUSED))
627
+ throw new NeedsPerson(
628
+ `this platform doesn't take a jurisdiction for a database (${e.detail.join("; ").slice(0, 200) || "refused"}), so a store's data here is unrestricted: run this again with --jurisdiction none`,
629
+ );
630
+ throw e;
631
+ }
497
632
  if (!r?.uuid) throw new UnknownOutcome(`create D1 ${name}`);
498
633
  return r.uuid;
499
634
  },
@@ -525,10 +660,16 @@ export function restCloudflare(
525
660
  await call(`delete queue ${id}`, "DELETE", `/queues/${id}`, undefined, true);
526
661
  },
527
662
  async consumers(queueId) {
528
- const r = await get<{ consumer_id: string; script?: string; service?: string }[]>(
529
- `consumers of ${queueId}`,
530
- `/queues/${queueId}/consumers`,
531
- );
663
+ type Consumer = { consumer_id: string; script?: string; service?: string };
664
+ let r: Consumer[] | null;
665
+ try {
666
+ r = await get<Consumer[]>(`consumers of ${queueId}`, `/queues/${queueId}/consumers`);
667
+ } catch (e) {
668
+ // A platform that doesn't serve the list (salve) still answers the queue, which carries its consumers
669
+ // (https://developers.cloudflare.com/api/resources/queues/methods/get/; p0.11 row 14).
670
+ if (!(e instanceof CloudflareError && e.codes.includes(PLATFORM_REFUSED))) throw e;
671
+ r = (await get<{ consumers?: Consumer[] }>(`queue ${queueId}`, `/queues/${queueId}`))?.consumers ?? [];
672
+ }
532
673
  return (r ?? []).map((c) => ({ id: c.consumer_id, script: c.script ?? c.service ?? "" }));
533
674
  },
534
675
  async deleteConsumer(queueId, consumerId) {
@@ -544,7 +685,15 @@ export function restCloudflare(
544
685
  return (await get(`Worker ${name}`, `/workers/scripts/${encodeURIComponent(name)}/settings`, true)) !== null;
545
686
  },
546
687
  async deleteWorker(name) {
547
- await call(`delete Worker ${name}`, "DELETE", `/workers/scripts/${encodeURIComponent(name)}`, undefined, true);
688
+ // Several steps on a platform that deletes in stages (salve): never replayed on a 429 (p0.11 row 16).
689
+ await call(
690
+ `delete Worker ${name}`,
691
+ "DELETE",
692
+ `/workers/scripts/${encodeURIComponent(name)}`,
693
+ undefined,
694
+ true,
695
+ false,
696
+ );
548
697
  },
549
698
  async versions(name) {
550
699
  const r = await get<{
@@ -578,6 +727,9 @@ export function restCloudflare(
578
727
  // Only `workers/message`: Cloudflare refuses `workers/triggered_by` here (10210, research/26).
579
728
  annotations: { "workers/message": message },
580
729
  },
730
+ false,
731
+ // salve's activation re-uploads, then deletes dropped secrets: a 429 there can follow a step that landed.
732
+ false,
581
733
  );
582
734
  },
583
735
  async schedules(name) {
@@ -596,16 +748,24 @@ export function restCloudflare(
596
748
  );
597
749
  },
598
750
  async settings(name) {
599
- const r = await get<{ logpush?: boolean; observability?: { logs?: { invocation_logs?: boolean } } }>(
600
- `settings of ${name}`,
601
- `/workers/scripts/${encodeURIComponent(name)}/script-settings`,
602
- );
603
- return { logpush: r?.logpush ?? false, invocationLogs: r?.observability?.logs?.invocation_logs ?? null };
751
+ const r = await getSettings<{
752
+ logpush?: boolean;
753
+ observability?: { redact_query_string?: boolean; logs?: { invocation_logs?: boolean } };
754
+ }>(`settings of ${name}`, `/workers/scripts/${encodeURIComponent(name)}/script-settings`);
755
+ return {
756
+ logpush: r?.logpush ?? false,
757
+ invocationLogs: r?.observability?.logs?.invocation_logs ?? null,
758
+ redactQueryString: r?.observability?.redact_query_string ?? null,
759
+ };
604
760
  },
605
761
  async patchSettings(name, s) {
606
762
  await call(`settings of ${name}`, "PATCH", `/workers/scripts/${encodeURIComponent(name)}/script-settings`, {
607
763
  logpush: s.logpush,
608
- observability: { enabled: true, logs: { enabled: true, invocation_logs: s.invocationLogs ?? false } },
764
+ observability: {
765
+ enabled: true,
766
+ redact_query_string: s.redactQueryString ?? true,
767
+ logs: { enabled: true, invocation_logs: s.invocationLogs ?? false },
768
+ },
609
769
  });
610
770
  },
611
771
  async secretNames(name) {
@@ -662,17 +822,22 @@ export function restCloudflare(
662
822
  rmSync(dir, { recursive: true, force: true });
663
823
  }
664
824
  },
665
- async workersSubdomain() {
666
- let r: { subdomain: string } | null;
825
+ workersSubdomain: subdomain,
826
+ async workersDevUrl(worker) {
827
+ // The Worker's own flag (https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/subdomain/methods/get/).
828
+ let r: { enabled?: boolean } | null;
667
829
  try {
668
- r = await get<{ subdomain: string }>("workers.dev subdomain", "/workers/subdomain");
830
+ r = await get<{ enabled?: boolean }>(
831
+ `workers.dev route of ${worker}`,
832
+ `/workers/scripts/${encodeURIComponent(worker)}/subdomain`,
833
+ true,
834
+ );
669
835
  } catch (e) {
670
- // 10007 is Cloudflare's "no subdomain", as Wrangler reads it (wrangler-dist/cli.js:164937, 4.152.0).
671
- if (e instanceof CloudflareError && e.codes.includes(10007)) throw noSubdomain(account);
836
+ // A platform that doesn't serve the read doesn't serve the store at workers.dev either.
837
+ if (e instanceof CloudflareError && e.codes.includes(PLATFORM_REFUSED)) return null;
672
838
  throw e;
673
839
  }
674
- if (!r?.subdomain) throw noSubdomain(account);
675
- return r.subdomain;
840
+ return r?.enabled === true ? `https://${worker}.${await subdomain()}.workers.dev` : null;
676
841
  },
677
842
  async d1Jurisdiction(id) {
678
843
  // The database's own record (https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/get/);
@@ -683,7 +848,7 @@ export function restCloudflare(
683
848
  return j;
684
849
  },
685
850
  async readable() {
686
- const out: Record<ResourceKind, number | null> = { Workers: null, D1: null, Queues: null };
851
+ const out: Record<ResourceKind, number | "not served" | null> = { Workers: null, D1: null, Queues: null };
687
852
  const paths: [ResourceKind, string][] = [
688
853
  ["Workers", "/workers/scripts"],
689
854
  ["D1", "/d1/database"],
@@ -693,7 +858,8 @@ export function restCloudflare(
693
858
  try {
694
859
  await get(`list ${kind}`, path);
695
860
  } catch (e) {
696
- if (e instanceof CloudflareError) out[kind] = e.status;
861
+ // A platform that doesn't serve a list says nothing about the token.
862
+ if (e instanceof CloudflareError) out[kind] = e.codes.includes(PLATFORM_REFUSED) ? "not served" : e.status;
697
863
  else if (e instanceof UnknownOutcome) out[kind] = 0;
698
864
  else throw e;
699
865
  }
@@ -14,6 +14,8 @@ export const OLDER_KEDGE = "this store runs a Kedge older than this CLI, without
14
14
 
15
15
  export interface SessionDeps {
16
16
  cloudflare: () => Promise<Cloudflare>;
17
+ /** The proof's request (p0.11 §3). */
18
+ fetch?: typeof fetch;
17
19
  /** `wrangler d1 execute --local` in the store's directory: `kedge dev`'s database. */
18
20
  executeLocal: (b: CliBatch) => number;
19
21
  now?: () => number;
@@ -28,8 +30,11 @@ async function write(target: StoreTarget, b: CliBatch, d: SessionDeps): Promise<
28
30
  try {
29
31
  await cf.executeD1File({ database: target.database.name, databaseId: target.database.id as string, sql: b.sql });
30
32
  } catch (e) {
31
- // A database from before P0.10 has no table to hold it.
32
- if (e instanceof UnknownOutcome && /no such table: cli_session/.test(e.message))
33
+ // A database from before P0.10 has no table to hold it, and one from before 0.11 no origin (codex code r6).
34
+ if (
35
+ e instanceof UnknownOutcome &&
36
+ /no such table: cli_session|table cli_session has no column named origin/.test(e.message)
37
+ )
33
38
  throw new DeployRefused(OLDER_KEDGE);
34
39
  throw e;
35
40
  }
@@ -43,7 +48,8 @@ export async function withCliSession<T>(
43
48
  work: (auth: Record<string, string>) => Promise<T>,
44
49
  ): Promise<T> {
45
50
  const now = d.now ?? Date.now;
46
- const s = await cliSessionSql(grants, now());
51
+ // Bound to the origin it's sent to, so it reads nothing anywhere else (p0.11 §3).
52
+ const s = await cliSessionSql(grants, now(), { origin: target.url });
47
53
  await write(target, s, d);
48
54
  try {
49
55
  return await work({ authorization: `Bearer ${s.token}` });
@@ -54,4 +60,37 @@ export async function withCliSession<T>(
54
60
  }
55
61
 
56
62
  /** Whether a request's credential was a CLI session, so a 401 can be read as an older Worker. */
57
- export const isCliSession = (auth: Record<string, string>) => auth.authorization?.startsWith("Bearer kcs_") === true;
63
+ export const isCliSession = (auth: Record<string, string>) =>
64
+ auth.authorization?.startsWith("Bearer kcs_") === true || auth.authorization?.startsWith("Bearer kcb_") === true;
65
+
66
+ /**
67
+ * Proves a URL the person gave is this store (p0.11 §3), before anything else
68
+ * goes there: a `meta:read` session bound to its origin, written to this
69
+ * store's database, then `GET /admin/meta/me`. Only this store's database holds
70
+ * the session, and only a request reaching the store at that origin unlocks it,
71
+ * so a `200` naming the session's own id proves both; a relay in front of the
72
+ * real store reaches it at the store's origin and is refused. A Worker before
73
+ * 0.11 refuses the `kcb_` token, so it fails too. No redirect is followed.
74
+ */
75
+ export async function proveTarget(target: StoreTarget, d: SessionDeps): Promise<void> {
76
+ const now = d.now ?? Date.now;
77
+ const s = await cliSessionSql(["meta:read"], now(), { origin: target.url });
78
+ await write(target, s, d);
79
+ let echoed: unknown = null;
80
+ try {
81
+ const r = await (d.fetch ?? fetch)(new URL("/admin/meta/me", target.url), {
82
+ headers: { authorization: `Bearer ${s.token}` },
83
+ redirect: "manual",
84
+ });
85
+ if (r.status === 200) echoed = ((await r.json()) as { principal?: { kind?: string; id?: unknown } }).principal;
86
+ } catch {
87
+ // No answer, or no JSON: not proved.
88
+ } finally {
89
+ await write(target, cliSessionEndSql(s.id, now()), d).catch(() => undefined);
90
+ }
91
+ const p = echoed as { kind?: string; id?: unknown } | null;
92
+ if (p?.kind !== "cli" || p.id !== s.id)
93
+ throw new DeployRefused(
94
+ `${target.url} isn't this store, or runs a Kedge older than 0.11 (kedge deploy first): nothing else was sent there`,
95
+ );
96
+ }
package/src/commands.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  UnknownOutcome,
22
22
  wranglerBin,
23
23
  } from "./cf";
24
- import { isCliSession, OLDER_KEDGE, type SessionDeps, withCliSession } from "./cli-session";
24
+ import { isCliSession, OLDER_KEDGE, proveTarget, type SessionDeps, withCliSession } from "./cli-session";
25
25
  import { ComposeError, compose, type Lock } from "./compose";
26
26
  import { type Executor, wranglerExecute } from "./d1-batch";
27
27
  import {
@@ -268,7 +268,7 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
268
268
  );
269
269
  // Adoption takes the store's jurisdiction from what exists, so it asks nothing; a choice is checked against it.
270
270
  const choice = flag(args, "--jurisdiction");
271
- await preflight(cf);
271
+ await preflight(cf, undefined, undefined, readWrangler(d.cwd)?.config.workers_dev !== false);
272
272
  if (choice !== undefined && !isJurisdictionChoice(choice))
273
273
  throw new DeployRefused(`--jurisdiction takes eu, us, fedramp or none, not "${choice}"`);
274
274
  return dep.adoptExisting(
@@ -280,7 +280,7 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
280
280
  const window = windowOf(flag(args, "--maintenance-window"));
281
281
  const takeOver = flag(args, "--take-over");
282
282
  const choice = flag(args, "--jurisdiction");
283
- await preflight(cf, dep.names.ops, choice);
283
+ await preflight(cf, dep.names.ops, choice, readWrangler(d.cwd)?.config.workers_dev !== false);
284
284
  await dep.deploy({
285
285
  ...(choice ? { jurisdiction: choice as JurisdictionChoice } : {}),
286
286
  vars: varsOf(args),
@@ -291,12 +291,18 @@ export async function deployCommand(args: readonly string[], d: CommandDeps): Pr
291
291
  ...(window ? { window } : {}),
292
292
  ...(takeOver ? { takeOver } : {}),
293
293
  });
294
- // From the account's subdomain now, never a recorded URL: its owner can change it (p0.10 §4).
295
- const url = `https://${dep.names.worker}.${await cf.workersSubdomain()}.workers.dev`;
296
- return { worker: dep.names.worker, account: cf.account, url, admin: adminUrl(d.cwd, url) };
294
+ // From the account's subdomain now, never a recorded URL: its owner can change it (p0.10 §4). Only where the
295
+ // Worker is served there: a platform with its own domain answers an account subdomain too (p0.11 row 2).
296
+ const url = await cf.workersDevUrl(dep.names.worker);
297
+ if (!url) (d.log ?? console.log)(PLATFORM_URL);
298
+ return { worker: dep.names.worker, account: cf.account, url, admin: url ? adminUrl(d.cwd, url) : null };
297
299
  });
298
300
  }
299
301
 
302
+ /** What deploy says when the store isn't at workers.dev: its platform knows where it is. */
303
+ export const PLATFORM_URL =
304
+ "this store isn't served at workers.dev: its address is the one its host gives (salve hosting: hosting_status); use that https URL as --url";
305
+
300
306
  /** The admin's address, when the store serves it (p0.9 §3.3); null when it serves none. */
301
307
  function adminUrl(cwd: string, url: string): string | null {
302
308
  return readWrangler(cwd)?.config.assets ? `${url}/app/` : null;
@@ -439,11 +445,18 @@ const SAMPLE_APPAREL = [
439
445
  const isLocal = (url: string) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname);
440
446
 
441
447
  /** The store a URL is, for a CLI session or a check: what `resolveTarget` says, credentials resolved as for any remote command. */
442
- const targetOf = (url: string, d: CommandDeps) => resolveTarget(url, d.cwd, () => cloudflareOf(d));
448
+ const unprovedOf = (url: string, d: CommandDeps) => resolveTarget(url, d.cwd, () => cloudflareOf(d));
449
+ const targetOf = async (url: string, d: CommandDeps) => {
450
+ const t = await unprovedOf(url, d);
451
+ // A URL the CLI didn't derive is proved before anything else goes there, a saved key included (p0.11 §3).
452
+ if (t?.proof) await proveTarget(t, sessionDeps(d));
453
+ return t;
454
+ };
443
455
 
444
456
  /** How a CLI session's row is written (p0.10 §6.2). */
445
457
  const sessionDeps = (d: CommandDeps): SessionDeps => ({
446
458
  cloudflare: () => cloudflareOf(d),
459
+ fetch: d.fetch ?? fetch,
447
460
  executeLocal: (b) => (d.execute ?? wranglerExecute)(b, { database: "DB", remote: false, cwd: d.cwd }),
448
461
  });
449
462
 
@@ -495,6 +508,7 @@ function authOf(args: readonly string[]): Record<string, string> {
495
508
  async function signOutBearer(url: string, token: string, d: CommandDeps): Promise<void> {
496
509
  await (d.fetch ?? fetch)(new URL("/auth/admin/sign-out", url), {
497
510
  method: "POST",
511
+ redirect: "manual",
498
512
  headers: { authorization: `Bearer ${token}` },
499
513
  }).catch(() => undefined);
500
514
  }
@@ -569,7 +583,8 @@ export async function seedCommand(args: readonly string[], d: CommandDeps): Prom
569
583
  // customers would see sample products, so it's the owner's call (p0.10 §5).
570
584
  let target: StoreTarget | null = null;
571
585
  if (!isLocal(url) && !args.includes("--live")) {
572
- target = await targetOf(url, d);
586
+ // Only a preview goes on from here, and a preview's origin is derived, never proved: anything else is a handoff.
587
+ target = await unprovedOf(url, d);
573
588
  if (target?.kind !== "preview")
574
589
  throw new NeedsPerson(
575
590
  `${url} isn't local or a preview this store knows: sample products on a live store are for its owner to allow; with their yes, run this again with --live`,
@@ -596,9 +611,11 @@ export async function smokeCommand(args: readonly string[], d: CommandDeps): Pro
596
611
  const name = flag(args, "--run") ?? "first";
597
612
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name))
598
613
  throw new DeployRefused("--run takes a name of letters, digits, - and _, up to 64");
599
- const target = await targetOf(url, d);
614
+ // The live store's handoff comes before any proof: nothing goes to a store its owner hasn't allowed.
615
+ const target = await unprovedOf(url, d);
600
616
  if (!target) throw new DeployRefused(`${url} isn't a store this checkout knows: smoke runs only on its own stores`);
601
617
  needsLive(target, args.includes("--live"));
618
+ if (target.proof) await proveTarget(target, sessionDeps(d));
602
619
  return smoke(target, name, {
603
620
  cwd: d.cwd,
604
621
  fetch: d.fetch ?? fetch,