@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/deploy.ts CHANGED
@@ -37,9 +37,16 @@ import {
37
37
  sha256,
38
38
  storeNames,
39
39
  type WranglerJson,
40
+ withDatabaseId,
40
41
  } from "./store";
41
42
 
42
43
  export const SCOPE = "production";
44
+ /**
45
+ * The settings a release leaves: no Logpush, no invocation logs, query strings
46
+ * redacted, because a logged or thrown request is recorded with its URL, which
47
+ * erasure can't reach (docs/10; p0.11 row 18).
48
+ */
49
+ const RELEASE_SETTINGS: WorkerSettings = { logpush: false, invocationLogs: false, redactQueryString: true };
43
50
  /** Core's secrets, generated only at a first deploy and only where missing (§6.1). */
44
51
  export const CORE_SECRETS = [
45
52
  "CART_TOKEN_SECRET",
@@ -714,6 +721,25 @@ export class Deployer {
714
721
  else blocking.push(i);
715
722
  continue;
716
723
  }
724
+ if (i.kind === "triggers") {
725
+ // A schedule PUT or a settings PATCH sets whole values, so a read-back showing them settles it,
726
+ // and a lost answer to one that landed doesn't block --resume (salve's codex r7). A mismatch
727
+ // stays unknown: replaying could race a late landing of the original.
728
+ const d = JSON.parse(i.detail) as { crons?: string[] } & Partial<WorkerSettings>;
729
+ const shows = d.crons
730
+ ? JSON.stringify(await this.d.cf.schedules(this.names.worker)) === JSON.stringify(d.crons)
731
+ : await this.d.cf
732
+ .settings(this.names.worker)
733
+ .then((s) =>
734
+ (["logpush", "invocationLogs", "redactQueryString"] as const).every(
735
+ (k) => d[k] === undefined || s[k] === d[k],
736
+ ),
737
+ );
738
+ if (shows) {
739
+ await this.ops.closeIntent(i.id, "done", "a read-back shows what it set");
740
+ continue;
741
+ }
742
+ }
717
743
  if (i.kind === "activate") {
718
744
  const deps = await this.d.cf.deployments(this.names.worker);
719
745
  const landed = deps.find((d) => d.message?.includes(i.tag));
@@ -918,8 +944,9 @@ export class Deployer {
918
944
  if (!existsSync(file)) return;
919
945
  const text = readFileSync(file, "utf8");
920
946
  if (this.names.databaseId === PLACEHOLDER_ID) {
921
- if (text.includes(PLACEHOLDER_ID)) {
922
- writeFileSync(file, text.replace(PLACEHOLDER_ID, id));
947
+ const written = withDatabaseId(text, id);
948
+ if (written !== text) {
949
+ writeFileSync(file, written);
923
950
  this.log(`wrangler.jsonc now names database ${id}: commit it`);
924
951
  }
925
952
  } else if (this.names.databaseId !== id)
@@ -1015,10 +1042,9 @@ export class Deployer {
1015
1042
  const cf = this.d.cf;
1016
1043
  const applied = new Set(await appliedNames(cf, p.databaseId as string));
1017
1044
  const pending = (p.pending ?? []).filter((f) => !applied.has(f));
1018
- for (const f of [...applied]) {
1019
- const e = this.d.lock.entries.find((x) => x.file === f);
1020
- if (e) await this.ops.recordApplied(f, e.sha256);
1021
- }
1045
+ await this.ops.recordApplied(
1046
+ this.d.lock.entries.filter((e) => applied.has(e.file)).map((e) => [e.file, e.sha256] as const),
1047
+ );
1022
1048
  if (pending.length === 0) return;
1023
1049
  await this.step("migrate");
1024
1050
  const dir = this.dir(this.opId);
@@ -1047,7 +1073,9 @@ export class Deployer {
1047
1073
  );
1048
1074
  } finally {
1049
1075
  const now = new Set(await appliedNames(cf, p.databaseId as string));
1050
- for (const f of pending) if (now.has(f)) await this.ops.recordApplied(f, this.plan.files[f] as string);
1076
+ await this.ops.recordApplied(
1077
+ pending.filter((f) => now.has(f)).map((f) => [f, this.plan.files[f] as string] as const),
1078
+ );
1051
1079
  }
1052
1080
  }
1053
1081
 
@@ -1116,7 +1144,7 @@ export class Deployer {
1116
1144
  }
1117
1145
  if (!classChange) await this.activate("activate", p.appVersion as string);
1118
1146
  await this.markReleased();
1119
- await this.triggers(this.plan.config.triggers?.crons ?? [], { logpush: false, invocationLogs: false });
1147
+ await this.triggers(this.plan.config.triggers?.crons ?? [], RELEASE_SETTINGS);
1120
1148
  }
1121
1149
 
1122
1150
  /** Crons and settings, each a single-attempt call read back before it counts (§3.1). */
@@ -1132,13 +1160,17 @@ export class Deployer {
1132
1160
  throw new DeployRefused("the cron schedules didn't read back as set");
1133
1161
  }
1134
1162
  const s = await cf.settings(this.names.worker);
1135
- if (s.logpush !== settings.logpush || s.invocationLogs !== settings.invocationLogs) {
1163
+ if (
1164
+ s.logpush !== settings.logpush ||
1165
+ s.invocationLogs !== settings.invocationLogs ||
1166
+ s.redactQueryString !== settings.redactQueryString
1167
+ ) {
1136
1168
  await this.step("settings");
1137
1169
  await this.intent("settings", "triggers", this.tag("settings"), settings, () =>
1138
1170
  cf.patchSettings(this.names.worker, settings),
1139
1171
  );
1140
1172
  const back = await cf.settings(this.names.worker);
1141
- if (back.invocationLogs !== settings.invocationLogs)
1173
+ if (back.invocationLogs !== settings.invocationLogs || back.redactQueryString !== settings.redactQueryString)
1142
1174
  throw new DeployRefused("the Worker's settings didn't read back as set");
1143
1175
  }
1144
1176
  }
@@ -1341,7 +1373,9 @@ export class Deployer {
1341
1373
  );
1342
1374
  await this.reconcile();
1343
1375
  await this.activate("abort", before.versionId);
1344
- await this.triggers(before.crons ?? [], before.settings ?? { logpush: false, invocationLogs: false });
1376
+ // Redaction stays on through a rollback: a "before" read under 0.10 never
1377
+ // recorded it, and turning it off would put query strings back in the logs.
1378
+ await this.triggers(before.crons ?? [], { ...(before.settings ?? RELEASE_SETTINGS), redactQueryString: true });
1345
1379
  await ops.completeDeployment(p.deploymentId as string, {
1346
1380
  ...before,
1347
1381
  commit: servingCommit((await ops.deployments()).filter((r) => r.id !== p.deploymentId)),
@@ -1612,8 +1646,9 @@ export class Deployer {
1612
1646
  const qid = await cf.findQueue(this.names.queue);
1613
1647
  if (qid) await ops.recordResource("queue", this.names.queue, qid, "ours");
1614
1648
  await ops.recordResource("worker", this.names.worker, null, "ours");
1615
- for (const f of applied)
1616
- await ops.recordApplied(f, (this.d.lock.entries.find((e) => e.file === f) as LockEntry).sha256);
1649
+ await ops.recordApplied(
1650
+ applied.map((f) => [f, (this.d.lock.entries.find((e) => e.file === f) as LockEntry).sha256] as const),
1651
+ );
1617
1652
  const half: Half = {
1618
1653
  lockSha: sha256(
1619
1654
  JSON.stringify(this.d.lock.entries.filter((e) => applied.includes(e.file)).map((e) => [e.file, e.sha256])),
package/src/doctor.ts CHANGED
@@ -50,7 +50,8 @@ type WranglerConfig = {
50
50
  renamed_classes?: { from: string; to: string }[];
51
51
  }[];
52
52
  queues?: { producers?: { binding?: string }[] };
53
- observability?: { enabled?: boolean; logs?: { invocation_logs?: boolean } };
53
+ observability?: { enabled?: boolean; redact_query_string?: boolean; logs?: { invocation_logs?: boolean } };
54
+ workers_dev?: boolean;
54
55
  assets?: { directory?: string; binding?: string; not_found_handling?: string; run_worker_first?: unknown };
55
56
  env?: Record<string, { observability?: WranglerConfig["observability"] }>;
56
57
  };
@@ -382,8 +383,11 @@ export async function doctor(input: DoctorInput): Promise<CheckResult[]> {
382
383
  problems.length ? fail("wrangler", "wrangler.jsonc isn't what a Kedge Worker needs", problems) : pass("wrangler"),
383
384
  );
384
385
 
385
- // invocation-logs: Cloudflare's per-request log records the URL, which erasure can't reach.
386
- const off = (o: WranglerConfig["observability"]) => o?.logs?.invocation_logs === false;
386
+ // invocation-logs: Cloudflare records a request with its URL, which erasure can't reach: every
387
+ // request with invocation logs on, and one that logs or throws with them off unless its query
388
+ // string is redacted (p0.11 row 18).
389
+ const off = (o: WranglerConfig["observability"]) =>
390
+ o?.logs?.invocation_logs === false && o.redact_query_string === true;
387
391
  const where = [
388
392
  ...(off(c.observability) ? [] : ["the top level"]),
389
393
  ...Object.entries(c.env ?? {})
@@ -394,7 +398,7 @@ export async function doctor(input: DoctorInput): Promise<CheckResult[]> {
394
398
  where.length
395
399
  ? fail(
396
400
  "invocation-logs",
397
- `set "observability": { "logs": { "invocation_logs": false } } at ${where.join(", ")}: invocation logs record each request's URL, which can carry a customer's data that erasure can't reach`,
401
+ `set "observability": { "redact_query_string": true, "logs": { "invocation_logs": false } } at ${where.join(", ")}: Cloudflare records a request with its URL, which can carry a customer's data that erasure can't reach`,
398
402
  )
399
403
  : pass("invocation-logs"),
400
404
  );
@@ -461,13 +465,21 @@ export async function credentialsChecks(input: {
461
465
  cf: import("./cf").Cloudflare;
462
466
  source: string;
463
467
  base: string;
468
+ /** Whether the store is served at workers.dev; when it isn't, a missing subdomain is no failure (codex code r3). */
469
+ workersDev?: boolean;
464
470
  }): Promise<CheckResult[]> {
465
471
  const { NeedsPerson } = await import("./cf");
466
472
  const where = `from ${input.source}, sent to ${input.base}, account ${input.cf.account}`;
467
473
  const read = await input.cf.readable();
468
- const refused = Object.entries(read).filter(([, status]) => status !== null);
474
+ const refused = Object.entries(read).filter(([, status]) => status !== null && status !== "not served");
475
+ // A list the platform doesn't serve (salve hosting's Workers) is reported, never a failed credential (p0.11 row 5).
476
+ const notServed = Object.entries(read)
477
+ .filter(([, s]) => s === "not served")
478
+ .map(([kind]) => kind);
479
+ const lists = (["Workers", "D1", "Queues"] as const).filter((k) => !notServed.includes(k));
480
+ const unserved = notServed.length ? `; ${notServed.join(" and ")} listing not available here` : "";
469
481
  const out: CheckResult[] = [];
470
- if (refused.length === Object.keys(read).length && refused.every(([, s]) => s === 401 || s === 403))
482
+ if (refused.length && refused.length === lists.length && refused.every(([, s]) => s === 401 || s === 403))
471
483
  out.push(fail("credentials", `the token doesn't answer for this account (${where})`));
472
484
  else if (refused.length)
473
485
  out.push(
@@ -481,7 +493,7 @@ export async function credentialsChecks(input: {
481
493
  out.push(
482
494
  pass(
483
495
  "credentials",
484
- `${where}; lists Workers, D1 and Queues. Writes are unverified until the first deploy: Workers Scripts Edit, D1 Edit, Queues Edit`,
496
+ `${where}; lists ${lists.join(", ").replace(/, ([^,]*)$/, " and $1")}${unserved}. Writes are unverified until the first deploy: Workers Scripts Edit, D1 Edit, Queues Edit`,
485
497
  ),
486
498
  );
487
499
  try {
@@ -489,7 +501,9 @@ export async function credentialsChecks(input: {
489
501
  } catch (e) {
490
502
  // Any answer is a report: a token that can't read the subdomain fails this check, never the run (codex code r7).
491
503
  // An account without one is the owner's to choose: a handoff, not a technical failure (codex code r9).
492
- if (e instanceof NeedsPerson) out.push({ ...fail("workers.dev", e.handoff), handoff: e.handoff });
504
+ if (e instanceof NeedsPerson && input.workersDev === false)
505
+ out.push(skip("workers.dev", 'none, and none needed: wrangler.jsonc says "workers_dev": false'));
506
+ else if (e instanceof NeedsPerson) out.push({ ...fail("workers.dev", e.handoff), handoff: e.handoff });
493
507
  else out.push(fail("workers.dev", (e as Error).message));
494
508
  }
495
509
  return out;
@@ -549,7 +563,9 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
549
563
  if (last.crons && JSON.stringify(crons) !== JSON.stringify(last.crons))
550
564
  problems.push(`crons are ${JSON.stringify(crons)}, recorded ${JSON.stringify(last.crons)}`);
551
565
  const settings = await input.cf.settings(names.worker);
552
- if (last.settings && JSON.stringify(settings) !== JSON.stringify(last.settings))
566
+ // Only what was recorded: a 0.10 deployment recorded no redaction, and isn't drift for lacking it.
567
+ const recorded = last.settings;
568
+ if (recorded && (Object.keys(recorded) as (keyof typeof recorded)[]).some((k) => settings[k] !== recorded[k]))
553
569
  problems.push(`settings are ${JSON.stringify(settings)}, recorded ${JSON.stringify(last.settings)}`);
554
570
  const held = await ops.current(SCOPE);
555
571
  out.push(
@@ -560,6 +576,15 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
560
576
  held ? `operation ${held.op_id} (${held.kind}) holds the store at "${held.step}"` : undefined,
561
577
  ),
562
578
  );
579
+ // workers-dev: Wrangler enables workers.dev after a deploy lands, and a platform serving the store elsewhere
580
+ // refuses that (salve hosting, 400/10400), so every `wrangler deploy` would exit non-zero after landing.
581
+ if (readWrangler(input.cwd)?.config.workers_dev !== false && !(await input.cf.workersDevUrl(names.worker)))
582
+ out.push(
583
+ fail(
584
+ "workers-dev",
585
+ `wrangler.jsonc has "workers_dev": true, but ${names.worker} isn't served at workers.dev: on a host that serves it at its own address (salve hosting), set "workers_dev": false and commit it`,
586
+ ),
587
+ );
563
588
  // do-classes: rollback across a class change is blocked (a warning).
564
589
  const planned = (readWrangler(input.cwd)?.config.migrations ?? []) as unknown[];
565
590
  if (JSON.stringify(planned) !== JSON.stringify(last.doMigrations ?? []))
package/src/main.ts CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  } from "./commands";
38
38
  import { ComposeError, compose, type Lock } from "./compose";
39
39
  import { type Executor, wranglerExecute } from "./d1-batch";
40
- import { credentialsChecks, doctor, doctorRemote, formatReport } from "./doctor";
40
+ import { credentialsChecks, doctor, doctorRemote, formatReport, readWrangler } from "./doctor";
41
41
  import { invariantsMarkdown, invariantsOf } from "./invariants";
42
42
  import { installedPackages } from "./manifest";
43
43
  import { emit, withJsonOutput } from "./output";
@@ -128,8 +128,8 @@ async function inviteOrigin(
128
128
  }
129
129
  }
130
130
  try {
131
- const worker = storeNames(cwd).names.worker;
132
- return `https://${worker}.${await (cf ?? (await cloudflareFor(env, cwd))).workersSubdomain()}.workers.dev`;
131
+ // Only where it's served: elsewhere (salve hosting) the link needs --url, the address its host gives.
132
+ return await (cf ?? (await cloudflareFor(env, cwd))).workersDevUrl(storeNames(cwd).names.worker);
133
133
  } catch {
134
134
  return null;
135
135
  }
@@ -368,7 +368,12 @@ async function run(argv: string[], cwd: string, deps: RunDeps & { json: boolean
368
368
  if (auth) {
369
369
  const cf = restCloudflare(d.env, fetch, wranglerBin(), auth);
370
370
  const lock = JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
371
- const creds = await credentialsChecks({ cf, source: auth.source, base: cloudflareBase(d.env) });
371
+ const creds = await credentialsChecks({
372
+ cf,
373
+ source: auth.source,
374
+ base: cloudflareBase(d.env),
375
+ workersDev: readWrangler(cwd)?.config.workers_dev !== false,
376
+ });
372
377
  results.push(...creds);
373
378
  // Credentials that can't read the account would make every later check throw, not report (codex code r7).
374
379
  if (creds.some((c) => c.status === "fail"))
package/src/ops.ts CHANGED
@@ -53,6 +53,15 @@ export const OPS_SCHEMA = [
53
53
  classes TEXT NOT NULL DEFAULT '[]', destroyed_at INTEGER NOT NULL)`,
54
54
  ];
55
55
 
56
+ /** Every table and index OPS_SCHEMA makes, by name. */
57
+ const OPS_OBJECTS = OPS_SCHEMA.map((s) => (/IF NOT EXISTS (\w+)/.exec(s) as RegExpExecArray)[1] as string);
58
+
59
+ /**
60
+ * Rows per INSERT into `applied`: three bound parameters each, under D1's 100
61
+ * per query (https://developers.cloudflare.com/d1/platform/limits/).
62
+ */
63
+ const APPLIED_ROWS = 33;
64
+
56
65
  export class OperationHeld extends Error {
57
66
  constructor(readonly held: OperationRow) {
58
67
  super(
@@ -128,7 +137,13 @@ export class OpsStore {
128
137
  ) {}
129
138
 
130
139
  async init(store: string): Promise<void> {
131
- for (const s of OPS_SCHEMA) await this.q(s);
140
+ // Every object already there: the statements would change nothing, and a platform's rate limit counts
141
+ // each one (salve: 120 a minute per app, p0.11 row 16). The schema only ever gains objects, never columns.
142
+ const present = await this.q(
143
+ `SELECT name FROM sqlite_master WHERE name IN (${OPS_OBJECTS.map(() => "?").join(", ")})`,
144
+ OPS_OBJECTS,
145
+ );
146
+ if (present.length !== OPS_OBJECTS.length) for (const s of OPS_SCHEMA) await this.q(s);
132
147
  await this.q("INSERT INTO kedge_ops_meta (id, store, created_at) VALUES (1, ?, ?) ON CONFLICT (id) DO NOTHING", [
133
148
  store,
134
149
  this.now(),
@@ -344,12 +359,16 @@ export class OpsStore {
344
359
  return new Map((await this.q("SELECT file, sha256 FROM applied")).map((r) => [String(r.file), String(r.sha256)]));
345
360
  }
346
361
 
347
- async recordApplied(file: string, sha256: string): Promise<void> {
348
- await this.q("INSERT INTO applied (file, sha256, applied_at) VALUES (?, ?, ?) ON CONFLICT (file) DO NOTHING", [
349
- file,
350
- sha256,
351
- this.now(),
352
- ]);
362
+ /** Each applied file's hash, the first recorded kept: one statement per 33 files, not one per file (p0.11 row 16). */
363
+ async recordApplied(files: readonly (readonly [file: string, sha256: string])[]): Promise<void> {
364
+ const t = this.now();
365
+ for (let i = 0; i < files.length; i += APPLIED_ROWS) {
366
+ const chunk = files.slice(i, i + APPLIED_ROWS);
367
+ await this.q(
368
+ `INSERT INTO applied (file, sha256, applied_at) VALUES ${chunk.map(() => "(?, ?, ?)").join(", ")} ON CONFLICT (file) DO NOTHING`,
369
+ chunk.flatMap(([f, h]) => [f, h, t]),
370
+ );
371
+ }
353
372
  }
354
373
 
355
374
  // ---- the store's own resources (§6.1) ------------------------------------------------------------------
package/src/preview.ts CHANGED
@@ -200,6 +200,7 @@ export function previewConfig(source: WranglerJson, p: PreviewWorker): Record<st
200
200
  workers_dev: true,
201
201
  observability: {
202
202
  ...observability,
203
+ redact_query_string: true,
203
204
  logs: { ...((observability.logs as object | undefined) ?? {}), invocation_logs: false },
204
205
  },
205
206
  vars: { ...vars, ADMIN_ORIGINS: [...origins].join(","), KEDGE_ENVIRONMENT: "preview" },
@@ -720,6 +721,10 @@ class PreviewRun {
720
721
  // recreates under a new nonce, where a late landing can't replace it, and the sweep removes
721
722
  // what lands on the old name (codex code r4).
722
723
  if (e instanceof UnknownOutcome) await this.write("state = 'destroying', lost_create = 1", []);
724
+ // A host that serves one Worker per store refuses this upload (salve hosting), after the preview's
725
+ // database and queue exist: the way out is named, never inferred from the platform's words (p0.11 row 8).
726
+ if (e instanceof Error)
727
+ e.message += `\nThe preview's database and queue exist: remove what it made with kedge preview destroy --name ${this.name}. A host that serves one Worker per store (salve hosting) has no previews: test on kedge dev instead.`;
723
728
  throw e;
724
729
  } finally {
725
730
  if (secretsFile) rmSync(secretsFile, { force: true });
package/src/store-api.ts CHANGED
@@ -18,11 +18,18 @@ export async function storeRequest(
18
18
  const write = (init.method ?? "GET") !== "GET";
19
19
  let r: Response;
20
20
  try {
21
- r = await f(url, init);
21
+ // No redirect is followed (p0.11 §3): a session is bound to the origin it was sent to, and the
22
+ // person names the store's URL, not something in front of it that sends the request elsewhere.
23
+ r = await f(url, { ...init, redirect: "manual" });
22
24
  } catch {
23
25
  if (write) throw new UnknownOutcome(what, "the store");
24
26
  throw new DeployRefused(`${what}: the store didn't answer`);
25
27
  }
28
+ // Kedge's API never redirects, so this answer came from in front of the Worker, which ran nothing.
29
+ if (r.status >= 300 && r.status < 400)
30
+ throw new DeployRefused(
31
+ `${what} answered a redirect${r.headers.get("location") ? ` to ${r.headers.get("location")}` : ""}: the CLI follows none, so give the store's own URL`,
32
+ );
26
33
  let body: Record<string, unknown>;
27
34
  try {
28
35
  body = (await r.json()) as Record<string, unknown>;
package/src/store.ts CHANGED
@@ -13,6 +13,17 @@ import { byCodePoint } from "./order";
13
13
  /** The starter's id until a first deploy writes the real one (§6.1). */
14
14
  export const PLACEHOLDER_ID = "00000000-0000-0000-0000-000000000000";
15
15
 
16
+ /**
17
+ * wrangler.jsonc with `id` written over the placeholder `database_id`, and
18
+ * only there: `preview_database_id` keeps the zeros, because Wrangler keys the
19
+ * local database by it, and rewriting it would open an empty local store after
20
+ * the first deploy (p0.11 row 17). The opening quote keeps the pattern off
21
+ * `"preview_database_id"`, so the keys' order doesn't matter.
22
+ */
23
+ export function withDatabaseId(text: string, id: string): string {
24
+ return text.replace(new RegExp(`("database_id"\\s*:\\s*")${PLACEHOLDER_ID}"`), `$1${id}"`);
25
+ }
26
+
16
27
  export interface StoreNames {
17
28
  worker: string;
18
29
  database: string;
@@ -24,7 +35,13 @@ export interface StoreNames {
24
35
  export type WranglerJson = Record<string, unknown> & {
25
36
  name?: string;
26
37
  main?: string;
27
- d1_databases?: { binding?: string; database_name?: string; database_id?: string; migrations_dir?: string }[];
38
+ d1_databases?: {
39
+ binding?: string;
40
+ database_name?: string;
41
+ database_id?: string;
42
+ preview_database_id?: string;
43
+ migrations_dir?: string;
44
+ }[];
28
45
  queues?: { producers?: { binding?: string; queue?: string }[]; consumers?: { queue?: string }[] };
29
46
  migrations?: Record<string, unknown>[];
30
47
  triggers?: { crons?: string[] };
@@ -75,14 +92,19 @@ export function git(cwd: string): Git {
75
92
  if (r.status !== 0) throw new Error("kedge deploy needs a Git repository with a commit");
76
93
  return r.stdout.trim();
77
94
  },
95
+ // A store may sit below its repository's root (create-kedge reuses a workspace's repository, p0.11
96
+ // row 12): status covers the store's own directory, and its paths are the store's, as `show` reads
97
+ // them (codex code r5).
78
98
  dirty() {
79
- return run(["status", "--porcelain"])
99
+ const prefix = run(["rev-parse", "--show-prefix"]).stdout.trim();
100
+ return run(["status", "--porcelain", "--", "."])
80
101
  .stdout.split("\n")
81
102
  .filter(Boolean)
82
- .map((l) => l.slice(3));
103
+ .map((l) => l.slice(3))
104
+ .map((p) => (p.startsWith(prefix) ? p.slice(prefix.length) : p));
83
105
  },
84
106
  show(path) {
85
- const r = run(["show", `HEAD:${path}`]);
107
+ const r = run(["show", `HEAD:./${path}`]);
86
108
  return r.status === 0 ? r.stdout : null;
87
109
  },
88
110
  isAncestor(older, newer) {
@@ -100,7 +122,7 @@ export function dirtyBeyondId(cwd: string, g: Git, databaseId: string | null): s
100
122
  return g.dirty().filter((p) => {
101
123
  if (p !== "wrangler.jsonc" || !databaseId) return true;
102
124
  const head = g.show("wrangler.jsonc");
103
- return head === null || head.replace(PLACEHOLDER_ID, databaseId) !== readFileSync(join(cwd, p), "utf8");
125
+ return head === null || withDatabaseId(head, databaseId) !== readFileSync(join(cwd, p), "utf8");
104
126
  });
105
127
  }
106
128
 
package/src/target.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  // Which store a URL is (docs/proposals/p0.10-agent-path.md §4, §6.2). The CLI
2
2
  // sends a session, a saved key or a run's record only to a target it can name
3
3
  // by identity: production or a preview this store's `<store>-ops` records, or
4
- // `kedge dev` on this machine. Anything else gets nothing, so an unknown
5
- // endpoint never receives a token. A store is its account, Worker and
4
+ // `kedge dev` on this machine. Any other https URL may be production under
5
+ // another name (salve hosting's, a custom domain), and is proved before it
6
+ // gets anything but the proof's own session, bound to that URL's origin and so
7
+ // useless anywhere else (p0.11 §3). A store is its account, Worker and
6
8
  // database, not its URL: the account's owner can change the workers.dev
7
9
  // subdomain, so the URL is computed from the current one on every run.
8
- import type { Cloudflare } from "./cf";
10
+ import { type Cloudflare, NeedsPerson } from "./cf";
9
11
  import { openOps } from "./deploy";
10
12
  import { storeNames } from "./store";
11
13
 
@@ -17,6 +19,12 @@ export interface StoreTarget {
17
19
  identity: { account: string; worker: string; database: string };
18
20
  /** Where a CLI session's row is written: that target's own database, and no other. */
19
21
  database: { name: string; id: string | null };
22
+ /**
23
+ * A URL the person gave rather than one the CLI derived (salve's, a custom
24
+ * domain): it's proved to be this store before any other request goes there
25
+ * (p0.11 §3).
26
+ */
27
+ proof?: true;
20
28
  }
21
29
 
22
30
  /**
@@ -55,8 +63,7 @@ export async function resolveTarget(
55
63
  if (!(await cf.findD1(names.ops))) return null;
56
64
  const { ops } = await openOps(cf, names);
57
65
  const host = (worker: string, subdomain: string) => `https://${worker}.${subdomain}.workers.dev`;
58
- const subdomain = await cf.workersSubdomain();
59
- if (u.origin === host(names.worker, subdomain)) {
66
+ const production = async (proof: boolean): Promise<StoreTarget | null> => {
60
67
  const db = await ops.resource("d1", names.database);
61
68
  if (!db?.id) return null;
62
69
  return {
@@ -64,14 +71,35 @@ export async function resolveTarget(
64
71
  url: u.origin,
65
72
  identity: { account: cf.account, worker: names.worker, database: db.id },
66
73
  database: { name: names.database, id: db.id },
74
+ ...(proof ? { proof: true as const } : {}),
67
75
  };
76
+ };
77
+ // Derived only where the Worker is served at workers.dev: a platform's stand-in subdomain (salve's) names
78
+ // a host the store isn't on, which then gets the proof like any other URL (p0.11 row 2).
79
+ // A store served only at its own domain, on an account without a subdomain, still resolves (codex code r3).
80
+ const devUrl = await cf.workersDevUrl(names.worker).catch((e: unknown) => {
81
+ if (e instanceof NeedsPerson) return null;
82
+ throw e;
83
+ });
84
+ if (u.origin === devUrl) return production(false);
85
+ // A preview is only ever at workers.dev, so only such a host asks for the account's subdomain. The whole
86
+ // origin, as a preview's is made: a host that only begins with a preview's name isn't it.
87
+ let p: Record<string, unknown> | undefined;
88
+ if (u.hostname.endsWith(".workers.dev")) {
89
+ const subdomain = await cf.workersSubdomain().catch((e: unknown) => {
90
+ if (e instanceof NeedsPerson) return null;
91
+ throw e;
92
+ });
93
+ const rows = subdomain
94
+ ? await ops.query(
95
+ "SELECT worker, database_name, database_id FROM preview WHERE state = 'bootstrapped' AND database_id IS NOT NULL",
96
+ )
97
+ : [];
98
+ p = rows.find((r) => u.origin === host(String(r.worker), subdomain as string));
68
99
  }
69
- // The whole origin, as a preview's is made: a host that only begins with a preview's name isn't it.
70
- const rows = await ops.query(
71
- "SELECT worker, database_name, database_id FROM preview WHERE state = 'bootstrapped' AND database_id IS NOT NULL",
72
- );
73
- const p = rows.find((r) => u.origin === host(String(r.worker), subdomain));
74
- if (!p) return null;
100
+ // Any other https origin may be production under another name (salve hosting's, a custom domain):
101
+ // the proof decides, before a session's token goes there (p0.11 row 1).
102
+ if (!p) return production(true);
75
103
  return {
76
104
  kind: "preview",
77
105
  url: u.origin,