@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/kedge-manifest.json +52 -42
- package/package.json +7 -7
- package/src/cf.ts +216 -50
- package/src/cli-session.ts +43 -4
- package/src/commands.ts +26 -9
- package/src/deploy.ts +48 -13
- package/src/doctor.ts +34 -9
- package/src/main.ts +9 -4
- package/src/ops.ts +26 -7
- package/src/preview.ts +5 -0
- package/src/store-api.ts +8 -1
- package/src/store.ts +27 -5
- package/src/target.ts +39 -11
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
|
-
/**
|
|
111
|
-
|
|
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(
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 =
|
|
249
|
-
|
|
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>(
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
);
|
|
603
|
-
return {
|
|
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: {
|
|
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
|
-
|
|
666
|
-
|
|
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<{
|
|
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
|
-
//
|
|
671
|
-
if (e instanceof CloudflareError && e.codes.includes(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/cli-session.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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>) =>
|
|
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
|
-
|
|
296
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|