@kedge/cli 0.9.0 → 0.10.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
@@ -10,8 +10,10 @@ import { spawnSync } from "node:child_process";
10
10
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { createRequire } from "node:module";
12
12
  import { tmpdir } from "node:os";
13
- import { dirname, join } from "node:path";
13
+ import { basename, dirname, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
+ import { parseEnv } from "node:util";
16
+ import { jsoncToJson } from "./worker-config";
15
17
 
16
18
  export type Row = Record<string, unknown>;
17
19
 
@@ -28,8 +30,8 @@ export interface WorkerSettings {
28
30
 
29
31
  /** A call whose answer never came: the caller records it as an unknown outcome (§3.1). */
30
32
  export class UnknownOutcome extends Error {
31
- constructor(what: string) {
32
- super(`${what}: no answer from Cloudflare; its outcome is unknown`);
33
+ constructor(what: string, from = "Cloudflare") {
34
+ super(`${what}: no answer from ${from}; its outcome is unknown`);
33
35
  this.name = "UnknownOutcome";
34
36
  }
35
37
  }
@@ -70,7 +72,10 @@ export class CloudflareError extends Error {
70
72
  export interface Cloudflare {
71
73
  readonly account: string;
72
74
  findD1(name: string): Promise<string | null>;
73
- createD1(name: string): Promise<string>;
75
+ /** Creates a database in `jurisdiction` (none when null): set only at creation, never after (p0.10 §7). */
76
+ createD1(name: string, jurisdiction?: Jurisdiction | null): Promise<string>;
77
+ /** The database's jurisdiction, as Cloudflare records it; `none` when it has none. */
78
+ d1Jurisdiction(id: string): Promise<JurisdictionChoice>;
74
79
  deleteD1(id: string): Promise<void>;
75
80
  queryD1(id: string, sql: string, params?: readonly unknown[]): Promise<Row[]>;
76
81
  bookmark(id: string): Promise<string>;
@@ -100,14 +105,36 @@ export interface Cloudflare {
100
105
  applyMigrations(input: { config: string; database: string }): Promise<void>;
101
106
  /** One SQL file as one transaction (`wrangler d1 execute --file`): the CLI's audited batches (p0.5 §3.4). */
102
107
  executeD1File(input: { database: string; databaseId: string; sql: string }): Promise<void>;
103
- /** The account's workers.dev subdomain. */
108
+ /** The account's workers.dev subdomain; none is the person's choice to make (`NeedsPerson`). */
104
109
  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>>;
105
112
  /** The account's Logpush jobs, or null when the token can't read them. */
106
113
  logpushJobs(): Promise<{ dataset: string; enabled: boolean; fields: string[] | null }[] | null>;
107
114
  /** `wrangler versions secret put|delete`: a new version with one secret changed, not deployed. */
108
115
  versionSecret(worker: string, name: string, value: string | null, message: string): Promise<string>;
109
116
  }
110
117
 
118
+ export type ResourceKind = "Workers" | "D1" | "Queues";
119
+
120
+ /**
121
+ * Where a store's data lives (p0.10 §7): D1 and Durable Object storage
122
+ * restricted to one jurisdiction, or `none`, which is a choice too. The values
123
+ * are Cloudflare's for both
124
+ * (https://developers.cloudflare.com/d1/configuration/data-location/,
125
+ * https://developers.cloudflare.com/durable-objects/reference/data-location/).
126
+ */
127
+ export const JURISDICTIONS = ["eu", "us", "fedramp"] as const;
128
+ export type Jurisdiction = (typeof JURISDICTIONS)[number];
129
+ export type JurisdictionChoice = Jurisdiction | "none";
130
+
131
+ export const isJurisdictionChoice = (v: string): v is JurisdictionChoice =>
132
+ v === "none" || (JURISDICTIONS as readonly string[]).includes(v);
133
+
134
+ /** What the person is asked before a store's first resource exists. */
135
+ export const CHOOSE_JURISDICTION =
136
+ "choose where this store's data lives: eu, us, fedramp or none (it can't change later), then run this again with --jurisdiction <choice>";
137
+
111
138
  export interface UploadInput {
112
139
  /** A generated Wrangler config, every path in it absolute. */
113
140
  config: string;
@@ -117,6 +144,196 @@ export interface UploadInput {
117
144
 
118
145
  const API = "https://api.cloudflare.com/client/v4";
119
146
 
147
+ /**
148
+ * What only a person can do next (p0.10 §5): the command stops with exit 3 and
149
+ * this one sentence, which an agent passes on as written.
150
+ */
151
+ export class NeedsPerson extends Error {
152
+ constructor(readonly handoff: string) {
153
+ super(handoff);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Where every Cloudflare call goes: `CLOUDFLARE_API_BASE_URL` when set, as
159
+ * Wrangler's own calls do, else Cloudflare (p0.10 §4). salve's broker sets it
160
+ * with a token valid only there, so sending that token to Cloudflare itself
161
+ * would leak it and fail.
162
+ */
163
+ export function cloudflareBase(env: NodeJS.ProcessEnv): string {
164
+ const raw = env.CLOUDFLARE_API_BASE_URL?.trim();
165
+ if (!raw) return API;
166
+ let u: URL;
167
+ try {
168
+ u = new URL(raw);
169
+ } catch {
170
+ throw new Error(`CLOUDFLARE_API_BASE_URL isn't a URL: ${raw}`);
171
+ }
172
+ const local = u.hostname === "localhost" || u.hostname === "127.0.0.1";
173
+ if (u.protocol !== "https:" && !(local && u.protocol === "http:"))
174
+ throw new Error(`CLOUDFLARE_API_BASE_URL must be https: ${raw}`);
175
+ return u.href.replace(/\/+$/, "");
176
+ }
177
+
178
+ /**
179
+ * An account without a workers.dev subdomain has nowhere to serve a store, and
180
+ * Kedge never registers one (p0.10 §4): it names every Worker on the account,
181
+ * so it's the account owner's choice, made where Wrangler sends people to make it
182
+ * (wrangler-dist/cli.js:165132, 4.152.0).
183
+ */
184
+ export function noSubdomain(account: string): NeedsPerson {
185
+ return new NeedsPerson(
186
+ `this Cloudflare account has no workers.dev subdomain yet: choose one at https://dash.cloudflare.com/${account}/workers/subdomain, then run this again`,
187
+ );
188
+ }
189
+
190
+ /**
191
+ * What every command that can create a store resource checks before its first
192
+ * Cloudflare write (p0.10 §4), so a missing subdomain stops it with nothing
193
+ * made rather than after the database and Worker exist. Credentials have
194
+ * resolved by the time a `Cloudflare` exists. A store with no `<store>-ops`
195
+ * yet needs its jurisdiction chosen (§7); one that has it must not be given
196
+ * another. `ops` is that database's name; `preview apply` from a plan names
197
+ * its store only once the plan is read, and `openOps` checks the same there.
198
+ */
199
+ export async function preflight(cf: Cloudflare, ops?: string, choice?: string): Promise<void> {
200
+ if (choice !== undefined && !isJurisdictionChoice(choice))
201
+ throw new NeedsPerson(
202
+ `--jurisdiction takes ${JURISDICTIONS.join(", ")} or none, not "${choice}": ${CHOOSE_JURISDICTION}`,
203
+ );
204
+ await cf.workersSubdomain();
205
+ if (ops === undefined) return;
206
+ const id = await cf.findD1(ops);
207
+ if (!id && choice === undefined) throw new NeedsPerson(CHOOSE_JURISDICTION);
208
+ if (id && choice !== undefined) {
209
+ const actual = await cf.d1Jurisdiction(id);
210
+ if (actual !== choice)
211
+ throw new NeedsPerson(
212
+ `this store's data lives in jurisdiction ${actual}, fixed when it was first deployed: run this again without --jurisdiction, or with --jurisdiction ${actual}`,
213
+ );
214
+ }
215
+ }
216
+
217
+ /** The resolved credentials: where they came from, the account, and a token fresh enough to send. */
218
+ export interface CloudflareAuth {
219
+ source: "environment" | ".env.local" | "wrangler login";
220
+ account: string;
221
+ token: () => string;
222
+ }
223
+
224
+ /**
225
+ * The three sources, in order (p0.10 §4): the environment (salve, CI), the
226
+ * store's `.env.local`, then a `wrangler login` session read through Wrangler's
227
+ * own `wrangler auth token --json`, never from its files. A login's token is
228
+ * asked for again before every call: Wrangler refreshes an OAuth token only once
229
+ * it has expired, and says nothing of how long it has left, so a cached one
230
+ * could lapse in the middle of a cutover (codex code r1).
231
+ */
232
+ export async function resolveCloudflare(
233
+ env: NodeJS.ProcessEnv,
234
+ cwd: string,
235
+ fetchImpl: typeof fetch = fetch,
236
+ wranglerEntry: string = wranglerBin(),
237
+ ): Promise<CloudflareAuth> {
238
+ if (env.CLOUDFLARE_API_TOKEN) {
239
+ 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 };
243
+ }
244
+ const file = join(cwd, ".env.local");
245
+ const local = existsSync(file) ? parseEnv(readFileSync(file, "utf8")) : {};
246
+ if (local.CLOUDFLARE_API_TOKEN) {
247
+ 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");
250
+ sameAccount(account, configIn(cwd));
251
+ return { source: ".env.local", account, token: () => token };
252
+ }
253
+ const token = () => {
254
+ const t = wranglerLoginToken(env, wranglerEntry);
255
+ if (!t)
256
+ throw new NeedsPerson(
257
+ "add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to .env.local, or run `wrangler login`, then run this again",
258
+ );
259
+ return t;
260
+ };
261
+ // Asked once here too, so a missing login is the handoff now, never a failed call later (codex code r1).
262
+ const first = token();
263
+ const account =
264
+ env.CLOUDFLARE_ACCOUNT_ID ?? local.CLOUDFLARE_ACCOUNT_ID ?? (await onlyAccount(env, first, fetchImpl));
265
+ sameAccount(account, configIn(cwd));
266
+ return { source: "wrangler login", account, token };
267
+ }
268
+
269
+ /** The store's Wrangler config file, if it has one. */
270
+ function configIn(cwd: string): string | null {
271
+ for (const f of ["wrangler.jsonc", "wrangler.json"]) if (existsSync(join(cwd, f))) return join(cwd, f);
272
+ return null;
273
+ }
274
+
275
+ /**
276
+ * A Wrangler config's `account_id` wins over `CLOUDFLARE_ACCOUNT_ID` in every
277
+ * Wrangler run (`getActiveAccountId`, wrangler-dist/cli.js:133664, 4.152.0),
278
+ * so a config naming another account than the credentials resolved would send
279
+ * Wrangler's writes there while Kedge's own calls go here (codex code r8).
280
+ * Which account is the store's is the person's to say.
281
+ */
282
+ export function sameAccount(account: string, configFile: string | null): void {
283
+ if (!configFile || !existsSync(configFile)) return;
284
+ const named = (JSON.parse(jsoncToJson(readFileSync(configFile, "utf8"))) as { account_id?: unknown }).account_id;
285
+ if (typeof named === "string" && named !== "" && named !== account)
286
+ throw new NeedsPerson(
287
+ `${basename(configFile)} names Cloudflare account ${named}, but the credentials are for ${account}: remove its account_id, or use credentials for ${named}, then run this again`,
288
+ );
289
+ }
290
+
291
+ const LEGACY_CREDENTIALS = new Set([
292
+ "CLOUDFLARE_API_TOKEN",
293
+ "CLOUDFLARE_API_KEY",
294
+ "CLOUDFLARE_EMAIL",
295
+ "CF_API_TOKEN",
296
+ "CF_API_KEY",
297
+ "CF_EMAIL",
298
+ ]);
299
+
300
+ /** The login's token from Wrangler itself, in the person's own environment; null when there's none. */
301
+ function wranglerLoginToken(env: NodeJS.ProcessEnv, wranglerEntry: string): string | null {
302
+ // A directory of our own, so Wrangler reads no store's `.env`; HOME stays the person's, where the login is.
303
+ const dir = mkdtempSync(join(tmpdir(), "kedge-auth-"));
304
+ try {
305
+ // Every credential Wrangler would take from the environment goes, its legacy aliases too, so
306
+ // what answers is the login and nothing else (codex code r5).
307
+ const rest = Object.fromEntries(Object.entries(env).filter(([k]) => !LEGACY_CREDENTIALS.has(k)));
308
+ const r = spawnSync(process.execPath, [wranglerEntry, "auth", "token", "--json"], {
309
+ cwd: dir,
310
+ encoding: "utf8",
311
+ env: { ...rest, WRANGLER_SEND_METRICS: "false" },
312
+ });
313
+ if (r.status !== 0) return null;
314
+ const j = JSON.parse(r.stdout) as { type?: unknown; token?: unknown };
315
+ return j.type === "oauth" && typeof j.token === "string" && j.token ? j.token : null;
316
+ } catch {
317
+ return null;
318
+ } finally {
319
+ rmSync(dir, { recursive: true, force: true });
320
+ }
321
+ }
322
+
323
+ /** The one account a token can reach; more than one is the person's choice. */
324
+ async function onlyAccount(env: NodeJS.ProcessEnv, token: string, fetchImpl: typeof fetch): Promise<string> {
325
+ const res = await fetchImpl(`${cloudflareBase(env)}/memberships?per_page=50`, {
326
+ headers: { authorization: `Bearer ${token}` },
327
+ });
328
+ const data = (await res.json().catch(() => ({}))) as { result?: { account?: { id?: string; name?: string } }[] };
329
+ const accounts = (data.result ?? []).map((m) => m.account).filter((a): a is { id: string; name?: string } => !!a?.id);
330
+ if (accounts.length === 1) return (accounts[0] as { id: string }).id;
331
+ if (accounts.length === 0) throw new CloudflareError("the login's accounts", res.status, []);
332
+ throw new NeedsPerson(
333
+ `this login reaches ${accounts.length} Cloudflare accounts: set CLOUDFLARE_ACCOUNT_ID in .env.local to one of ${accounts.map((a) => `${a.id} (${a.name ?? "?"})`).join(", ")}`,
334
+ );
335
+ }
336
+
120
337
  type Envelope<T> = {
121
338
  success?: boolean;
122
339
  result?: T;
@@ -130,24 +347,45 @@ export function wranglerBin(): string {
130
347
  return join(dirname(require.resolve("wrangler/package.json")), "bin", "wrangler.js");
131
348
  }
132
349
 
350
+ /** A store's Cloudflare, from whichever source resolves (p0.10 §4). */
351
+ export async function cloudflareFor(
352
+ env: NodeJS.ProcessEnv,
353
+ cwd: string,
354
+ fetchImpl: typeof fetch = fetch,
355
+ wranglerEntry: string = wranglerBin(),
356
+ ): Promise<Cloudflare> {
357
+ return restCloudflare(env, fetchImpl, wranglerEntry, await resolveCloudflare(env, cwd, fetchImpl, wranglerEntry));
358
+ }
359
+
133
360
  export function restCloudflare(
134
361
  env: NodeJS.ProcessEnv,
135
362
  fetchImpl: typeof fetch = fetch,
136
363
  /** The Wrangler entry to run; tests give a stand-in. */
137
364
  wranglerEntry: string = wranglerBin(),
365
+ /** Resolved credentials; without them, the environment's two variables. */
366
+ auth?: CloudflareAuth,
138
367
  ): Cloudflare {
139
- const token = env.CLOUDFLARE_API_TOKEN;
140
- const account = env.CLOUDFLARE_ACCOUNT_ID;
141
- if (!token || !account) throw new Error("set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID");
142
- const base = `${API}/accounts/${account}`;
368
+ const resolved =
369
+ auth ??
370
+ (() => {
371
+ const t = env.CLOUDFLARE_API_TOKEN;
372
+ const a = env.CLOUDFLARE_ACCOUNT_ID;
373
+ if (!t || !a) throw new Error("set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID");
374
+ return { source: "environment" as const, account: a, token: () => t };
375
+ })();
376
+ const account = resolved.account;
377
+ const apiBase = cloudflareBase(env);
378
+ const base = `${apiBase}/accounts/${account}`;
143
379
 
144
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();
145
383
  let res: Response;
146
384
  try {
147
385
  res = await fetchImpl(`${base}${path}`, {
148
386
  method,
149
387
  headers: {
150
- authorization: `Bearer ${token}`,
388
+ authorization: `Bearer ${bearer}`,
151
389
  ...(body === undefined ? {} : { "content-type": "application/json" }),
152
390
  },
153
391
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
@@ -196,7 +434,9 @@ export function restCloudflare(
196
434
  ): { out: string; outputs: Row[] } {
197
435
  // A fresh directory of our own: Wrangler loads `.env` from its working
198
436
  // directory and honours CLOUDFLARE_API_BASE_URL, so neither the store's
199
- // files nor the caller's environment may reach it (§5.3).
437
+ // files nor the caller's environment may reach it (§5.3). The base URL
438
+ // Kedge resolved is passed on explicitly: salve's broker token is valid
439
+ // only there (p0.10 §4).
200
440
  const dir = mkdtempSync(join(tmpdir(), "kedge-wrangler-"));
201
441
  const envFile = join(dir, "empty.env");
202
442
  const output = join(dir, "output.ndjson");
@@ -209,8 +449,9 @@ export function restCloudflare(
209
449
  env: {
210
450
  PATH: env.PATH ?? "",
211
451
  HOME: dir,
212
- CLOUDFLARE_API_TOKEN: token as string,
213
- CLOUDFLARE_ACCOUNT_ID: account as string,
452
+ CLOUDFLARE_API_TOKEN: resolved.token(),
453
+ CLOUDFLARE_ACCOUNT_ID: account,
454
+ ...(apiBase === API ? {} : { CLOUDFLARE_API_BASE_URL: apiBase }),
214
455
  WRANGLER_SEND_METRICS: "false",
215
456
  WRANGLER_OUTPUT_FILE_PATH: output,
216
457
  CI: "1",
@@ -247,8 +488,12 @@ export function restCloudflare(
247
488
  );
248
489
  return r.find((d) => d.name === name)?.uuid ?? null;
249
490
  },
250
- async createD1(name) {
251
- const r = await call<{ uuid: string }>(`create D1 ${name}`, "POST", "/d1/database", { name });
491
+ async createD1(name, jurisdiction) {
492
+ // `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
+ });
252
497
  if (!r?.uuid) throw new UnknownOutcome(`create D1 ${name}`);
253
498
  return r.uuid;
254
499
  },
@@ -418,10 +663,43 @@ export function restCloudflare(
418
663
  }
419
664
  },
420
665
  async workersSubdomain() {
421
- const r = await get<{ subdomain: string }>("workers.dev subdomain", "/workers/subdomain");
422
- if (!r?.subdomain) throw new CloudflareError("no workers.dev subdomain", 200, []);
666
+ let r: { subdomain: string } | null;
667
+ try {
668
+ r = await get<{ subdomain: string }>("workers.dev subdomain", "/workers/subdomain");
669
+ } 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);
672
+ throw e;
673
+ }
674
+ if (!r?.subdomain) throw noSubdomain(account);
423
675
  return r.subdomain;
424
676
  },
677
+ async d1Jurisdiction(id) {
678
+ // The database's own record (https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/get/);
679
+ // absent is none.
680
+ const r = await get<{ jurisdiction?: string | null }>(`D1 ${id}`, `/d1/database/${id}`);
681
+ const j = r?.jurisdiction ?? "none";
682
+ if (!isJurisdictionChoice(j)) throw new CloudflareError(`D1 ${id} answered jurisdiction "${j}"`, 200, []);
683
+ return j;
684
+ },
685
+ async readable() {
686
+ const out: Record<ResourceKind, number | null> = { Workers: null, D1: null, Queues: null };
687
+ const paths: [ResourceKind, string][] = [
688
+ ["Workers", "/workers/scripts"],
689
+ ["D1", "/d1/database"],
690
+ ["Queues", "/queues"],
691
+ ];
692
+ for (const [kind, path] of paths) {
693
+ try {
694
+ await get(`list ${kind}`, path);
695
+ } catch (e) {
696
+ if (e instanceof CloudflareError) out[kind] = e.status;
697
+ else if (e instanceof UnknownOutcome) out[kind] = 0;
698
+ else throw e;
699
+ }
700
+ }
701
+ return out;
702
+ },
425
703
  async logpushJobs() {
426
704
  try {
427
705
  const r = await get<{ dataset: string; enabled: boolean; output_options?: { field_names?: string[] } }[]>(
package/src/check.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { existsSync } from "node:fs";
7
7
  import { join } from "node:path";
8
+ import { childStdio } from "./output";
8
9
 
9
10
  export interface CheckInput {
10
11
  cwd: string;
@@ -21,7 +22,7 @@ const runBin = (bin: string, args: string[], cwd: string): number => {
21
22
  console.error(`${bin} isn't installed in this store: run pnpm install`);
22
23
  return 1;
23
24
  }
24
- return spawnSync(path, args, { cwd, stdio: "inherit" }).status ?? 1;
25
+ return spawnSync(path, args, { cwd, stdio: childStdio() }).status ?? 1;
25
26
  };
26
27
 
27
28
  export async function check(input: CheckInput): Promise<number> {
@@ -0,0 +1,57 @@
1
+ // A CLI session for one command (docs/proposals/p0.10-agent-path.md §6.2):
2
+ // whoever holds the store's Cloudflare credentials writes the row, one audited
3
+ // `system:cli` batch, to the target's own database and no other, and the
4
+ // command sends the token to that target alone. The token lives in this
5
+ // process's memory only: never printed, logged or saved. The row is deleted
6
+ // when the command ends; one a crash leaves expires within ten minutes.
7
+ import { type CliBatch, cliSessionEndSql, cliSessionSql } from "@kedge/db";
8
+ import { type Cloudflare, UnknownOutcome } from "./cf";
9
+ import { DeployRefused } from "./deploy";
10
+ import type { StoreTarget } from "./target";
11
+
12
+ /** What an older Worker's or database's refusal of a CLI session means for the person running it. */
13
+ export const OLDER_KEDGE = "this store runs a Kedge older than this CLI, without CLI sessions: deploy first";
14
+
15
+ export interface SessionDeps {
16
+ cloudflare: () => Promise<Cloudflare>;
17
+ /** `wrangler d1 execute --local` in the store's directory: `kedge dev`'s database. */
18
+ executeLocal: (b: CliBatch) => number;
19
+ now?: () => number;
20
+ }
21
+
22
+ async function write(target: StoreTarget, b: CliBatch, d: SessionDeps): Promise<void> {
23
+ if (target.kind === "local") {
24
+ if (d.executeLocal(b) !== 0) throw new DeployRefused("writing the CLI session to kedge dev's database failed");
25
+ return;
26
+ }
27
+ const cf = await d.cloudflare();
28
+ try {
29
+ await cf.executeD1File({ database: target.database.name, databaseId: target.database.id as string, sql: b.sql });
30
+ } 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
+ throw new DeployRefused(OLDER_KEDGE);
34
+ throw e;
35
+ }
36
+ }
37
+
38
+ /** Runs `work` with a session holding `grants` on `target`, ending it after, whatever happened. */
39
+ export async function withCliSession<T>(
40
+ target: StoreTarget,
41
+ grants: readonly string[],
42
+ d: SessionDeps,
43
+ work: (auth: Record<string, string>) => Promise<T>,
44
+ ): Promise<T> {
45
+ const now = d.now ?? Date.now;
46
+ const s = await cliSessionSql(grants, now());
47
+ await write(target, s, d);
48
+ try {
49
+ return await work({ authorization: `Bearer ${s.token}` });
50
+ } finally {
51
+ // Ending is housekeeping: a row left behind is useless without the token, and expires anyway.
52
+ await write(target, cliSessionEndSql(s.id, now()), d).catch(() => undefined);
53
+ }
54
+ }
55
+
56
+ /** 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;