@kedge/cli 0.0.0-stage → 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/fork.ts ADDED
@@ -0,0 +1,67 @@
1
+ // `kedge doctor --core-diff` (docs/proposals/p0.8-cli-agent-kit.md §8.3): how far
2
+ // a fork's packages have drifted from a release, per package and file, the
3
+ // way Magento's core:code:changes turns "how far have we drifted" into a number
4
+ // before an upgrade (research/14 §1). It reports; only --strict fails on it.
5
+ // Each package.json is left out: a fork's names its siblings by workspace:*,
6
+ // a release's by version, and that difference isn't drift. Each package's
7
+ // LICENSE is the root one, copied in only while packing, so it's compared
8
+ // with the fork's root LICENSE.
9
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
+ import { join, relative } from "node:path";
11
+ import { fileHash, type Manifest } from "./manifest";
12
+
13
+ export interface PackageDiff {
14
+ package: string;
15
+ changed: string[];
16
+ added: string[];
17
+ removed: string[];
18
+ }
19
+
20
+ export function coreDiff(repo: string, manifest: Manifest): PackageDiff[] {
21
+ const out: PackageDiff[] = [];
22
+ for (const [name, files] of Object.entries(manifest.packages)) {
23
+ const dir = join(repo, "packages", name.replace(/^@kedge\//, ""));
24
+ const d: PackageDiff = { package: name, changed: [], added: [], removed: [] };
25
+ const tops = new Set(
26
+ Object.keys(files)
27
+ .map((f) => f.split("/")[0] as string)
28
+ .filter((t) => !t.endsWith(".json")),
29
+ );
30
+ for (const [f, want] of Object.entries(files)) {
31
+ if (f === "package.json") continue;
32
+ const p = f === "LICENSE" ? join(repo, "LICENSE") : join(dir, f);
33
+ if (!existsSync(p)) d.removed.push(f);
34
+ else if (fileHash(f, readFileSync(p)) !== want) d.changed.push(f);
35
+ }
36
+ const walk = (d0: string) => {
37
+ if (!existsSync(d0)) return;
38
+ for (const e of readdirSync(d0, { withFileTypes: true })) {
39
+ const p = join(d0, e.name);
40
+ if (e.isDirectory()) walk(p);
41
+ else {
42
+ const rel = relative(dir, p);
43
+ if (files[rel] === undefined && !(rel.startsWith("migrations/") && !rel.endsWith(".sql"))) d.added.push(rel);
44
+ }
45
+ }
46
+ };
47
+ for (const t of tops) walk(join(dir, t));
48
+ if (d.changed.length || d.added.length || d.removed.length) out.push(d);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export function formatCoreDiff(diffs: readonly PackageDiff[], version: string): string {
54
+ if (!diffs.length) return `the packages match Kedge ${version}`;
55
+ const lines = [`drift from Kedge ${version}:`];
56
+ for (const d of diffs) {
57
+ lines.push(` ${d.package}: ${d.changed.length} changed, ${d.added.length} added, ${d.removed.length} removed`);
58
+ for (const f of d.changed) lines.push(` ~ ${f}`);
59
+ for (const f of d.added) lines.push(` + ${f}`);
60
+ for (const f of d.removed) lines.push(` - ${f}`);
61
+ }
62
+ const files = diffs.reduce((n, d) => n + d.changed.length + d.added.length + d.removed.length, 0);
63
+ lines.push(
64
+ ` ${files} file${files === 1 ? "" : "s"} across ${diffs.length} package${diffs.length === 1 ? "" : "s"}: each is a merge at the next upgrade`,
65
+ );
66
+ return lines.join("\n");
67
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export {
2
+ CORE_TABLES,
3
+ errorSources,
4
+ type LintIssue,
5
+ lintSql,
6
+ type Owner,
7
+ PERMANENT_IDENTITIES,
8
+ statementTexts,
9
+ tokenize,
10
+ } from "@kedge/core";
11
+ export { ComposeError, type ComposeInput, type ComposeResult, compose, type Lock, type LockEntry } from "./compose";
12
+ export { lintPlugin } from "./plugin-lint";
@@ -0,0 +1,168 @@
1
+ // INVARIANTS.md and invariants.json (docs/proposals/p0.8-cli-agent-kit.md §4.2):
2
+ // core's catalog checked against the composed schema, then each plugin's own
3
+ // constraints and RAISE codes with the codes they map to. `kedge generate`
4
+ // writes both; doctor's `invariants` check reports the catalog's problems.
5
+ import type { ErrorMapEntry } from "@kedge/core";
6
+ import {
7
+ CATALOG_EXEMPT,
8
+ type CatalogEntry,
9
+ CORE_RAISE_CODES,
10
+ catalogPattern,
11
+ ENTITY_RAISE_CODES,
12
+ INVARIANT_CATALOG,
13
+ } from "@kedge/db";
14
+ import { byCodePoint } from "./order";
15
+ import { raiseCodes, type Schema, type SchemaObject } from "./schema";
16
+
17
+ /** The merchant's own, from `local/`: theirs to name and keep, never core's catalog's (codex code r19). */
18
+ export const isLocal = (o: SchemaObject) => o.name.startsWith("local_") || o.table.startsWith("local_");
19
+
20
+ /** Objects the catalog speaks for: core's triggers and named constraints. Plugins' are listed per plugin. */
21
+ const claimable = (o: SchemaObject) =>
22
+ (o.type === "trigger" || o.type === "constraint") && !o.name.startsWith("plg_") && !isLocal(o);
23
+
24
+ /** Every code core's own SQL raises, and what each becomes. */
25
+ export const CORE_CODES: Readonly<Record<string, string>> = { ...CORE_RAISE_CODES, ...ENTITY_RAISE_CODES };
26
+
27
+ export interface CatalogProblem {
28
+ message: string;
29
+ }
30
+
31
+ export function catalogProblems(
32
+ schema: Schema,
33
+ checks: ReadonlySet<string>,
34
+ catalog: readonly CatalogEntry[] = INVARIANT_CATALOG,
35
+ exempt: readonly { objects: readonly string[]; reason: string }[] = CATALOG_EXEMPT,
36
+ ): CatalogProblem[] {
37
+ const problems: CatalogProblem[] = [];
38
+ const objects = schema.objects.filter(claimable);
39
+ const claimed = new Set<string>();
40
+ const claim = (where: string, pattern: string) => {
41
+ const re = catalogPattern(pattern);
42
+ const hits = objects.filter((o) => re.test(o.name));
43
+ if (hits.length === 0 && !generated.has(pattern))
44
+ problems.push({ message: `${where} names "${pattern}", which matches no core trigger or constraint` });
45
+ for (const h of hits) claimed.add(h.name);
46
+ };
47
+ const ids = new Set<string>();
48
+ const generated = new Set(catalog.filter((e) => e.generated).flatMap((e) => e.objects));
49
+ for (const e of catalog) {
50
+ if (ids.has(e.id)) problems.push({ message: `${e.id} is listed twice` });
51
+ ids.add(e.id);
52
+ for (const p of e.objects) claim(e.id, p);
53
+ if (e.check && !checks.has(e.check))
54
+ problems.push({ message: `${e.id}'s check "${e.check}" isn't in @kedge/testing's INVARIANTS` });
55
+ }
56
+ for (const x of exempt) for (const p of x.objects) claim("an exemption", p);
57
+ const unclaimed = [...new Set(objects.filter((o) => !claimed.has(o.name)).map((o) => o.name))];
58
+ if (unclaimed.length)
59
+ problems.push({
60
+ message: `core objects no catalog entry claims: ${unclaimed.join(", ")} (add them to packages/db/src/invariant-catalog.ts)`,
61
+ });
62
+ return problems;
63
+ }
64
+
65
+ export interface RenderedEntry {
66
+ id: string;
67
+ title: string;
68
+ rule: string;
69
+ check: string | null;
70
+ objects: string[];
71
+ raises: { code: string; error: string }[];
72
+ }
73
+
74
+ export interface PluginInvariants {
75
+ plugin: string;
76
+ constraints: { name: string; table: string; error: string | null }[];
77
+ raises: { code: string; object: string; error: string | null }[];
78
+ }
79
+
80
+ export interface Invariants {
81
+ version: 1;
82
+ core: RenderedEntry[];
83
+ plugins: PluginInvariants[];
84
+ }
85
+
86
+ const coreError = (code: string) => {
87
+ const e = CORE_CODES[code];
88
+ return e === undefined ? "UNMAPPED" : e === "defect" ? "INTERNAL (a core defect)" : e;
89
+ };
90
+
91
+ /**
92
+ * The plugin a `kedge_<plugin>_*` object belongs to: core generates them around a plugin's own SQL (its
93
+ * acquire statements, its entities' triggers), so their codes are the plugin's or core's. Longest name
94
+ * first, so `kedge_gift_card_*` is gift_card's beside a plugin named gift. Doctor and the catalog share
95
+ * it: a code doctor accepts as mapped is the one INVARIANTS.md shows (codex code r31).
96
+ */
97
+ export function generatedFor<P extends { name: string }>(name: string, plugins: readonly P[]): P | undefined {
98
+ return [...plugins].sort((a, b) => b.name.length - a.name.length).find((p) => name.startsWith(`kedge_${p.name}_`));
99
+ }
100
+
101
+ /** The catalog and every plugin's constraints, as invariants.json holds them. */
102
+ export function invariantsOf(
103
+ schema: Schema,
104
+ plugins: readonly { name: string; errors: Readonly<Record<string, ErrorMapEntry>> }[],
105
+ ): Invariants {
106
+ const objects = schema.objects.filter(claimable);
107
+ const core = INVARIANT_CATALOG.map((e) => {
108
+ const res = e.objects.map(catalogPattern);
109
+ const hits = objects.filter((o) => res.some((r) => r.test(o.name)));
110
+ const raises = new Map<string, { code: string; error: string }>();
111
+ for (const o of hits) {
112
+ const owner = generatedFor(o.name, plugins);
113
+ for (const code of raiseCodes(o.sql)) {
114
+ const error = owner?.errors[code]?.code ?? coreError(code);
115
+ raises.set(`${code} ${error}`, { code, error });
116
+ }
117
+ }
118
+ return {
119
+ id: e.id,
120
+ title: e.title,
121
+ rule: e.rule,
122
+ check: e.check ?? null,
123
+ objects: [...new Set(hits.map((o) => o.name))],
124
+ raises: [...raises.values()].sort((a, b) => byCodePoint(a.code, b.code) || byCodePoint(a.error, b.error)),
125
+ };
126
+ });
127
+ const out: PluginInvariants[] = [];
128
+ for (const p of plugins) {
129
+ const prefix = `plg_${p.name}_`;
130
+ const mapped = (key: string) => p.errors[key]?.code ?? null;
131
+ const constraints = schema.objects
132
+ .filter((o) => o.type === "constraint" && o.table.startsWith(prefix))
133
+ .map((o) => ({ name: o.name, table: o.table, error: mapped(o.name) }));
134
+ const raises = schema.objects
135
+ .filter((o) => (o.type === "trigger" || o.type === "view") && o.name.startsWith(prefix))
136
+ .flatMap((o) => raiseCodes(o.sql).map((code) => ({ code, object: o.name, error: mapped(code) })));
137
+ if (constraints.length || raises.length) out.push({ plugin: p.name, constraints, raises });
138
+ }
139
+ return { version: 1, core, plugins: out };
140
+ }
141
+
142
+ export function invariantsMarkdown(inv: Invariants): string {
143
+ const lines = [
144
+ "# Invariants",
145
+ "",
146
+ "Generated by `kedge generate` from the composed migrations; never edit it. Each",
147
+ "rule is enforced by D1 itself, in the batch, so code that breaks it is refused",
148
+ "with the error shown. Follow the rule and you never meet the error.",
149
+ "",
150
+ ];
151
+ for (const e of inv.core) {
152
+ lines.push(`## ${e.id}: ${e.title}`, "", e.rule, "");
153
+ if (e.raises.length) lines.push(`- **Errors:** ${e.raises.map((r) => `\`${r.code}\` → ${r.error}`).join("; ")}`);
154
+ if (e.check) lines.push(`- **Checked after every test** by \`describeKedgeInvariants\` (\`${e.check}\`)`);
155
+ lines.push(`- **Enforced by:** ${e.objects.length} objects (\`invariants.json\` lists them)`, "");
156
+ }
157
+ if (inv.plugins.length) {
158
+ lines.push("## Plugins", "");
159
+ for (const p of inv.plugins) {
160
+ lines.push(`### ${p.plugin}`, "");
161
+ for (const c of p.constraints)
162
+ lines.push(`- constraint \`${c.name}\` on \`${c.table}\` → ${c.error ?? "UNMAPPED"}`);
163
+ for (const r of p.raises) lines.push(`- \`RAISE '${r.code}'\` in \`${r.object}\` → ${r.error ?? "UNMAPPED"}`);
164
+ lines.push("");
165
+ }
166
+ }
167
+ return `${lines.join("\n").trimEnd()}\n`;
168
+ }
@@ -0,0 +1,298 @@
1
+ // `kedge keys create --save` (docs/proposals/p0.10-agent-path.md §6.2): a key
2
+ // made through a credential reference with a CLI session, and saved to
3
+ // `.env.local` beside the store's identity. Every step survives a lost answer:
4
+ // a ref is recorded before it's sent, a recorded ref whose key was never saved
5
+ // is voided before another is made (revoking a key that committed unseen, or
6
+ // fencing one still in flight), and each key is saved before the next step.
7
+ // Only refs this checkout recorded are ever voided, so no one else's key, nor
8
+ // a key someone named the same, is ever touched.
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import type { DatabaseSync } from "node:sqlite";
11
+ import { NeedsPerson } from "./cf";
12
+ import { OLDER_KEDGE, type SessionDeps, withCliSession } from "./cli-session";
13
+ import { DeployRefused } from "./deploy";
14
+ import { type RefRecord, readEnvLocal, saveEnv, stateDb, withStoreLock } from "./local-state";
15
+ import { storeRequest } from "./store-api";
16
+ import type { StoreTarget } from "./target";
17
+
18
+ /** The `.env.local` names a saved key goes under. */
19
+ export const ENV = {
20
+ store: "KEDGE_KEYS_STORE",
21
+ storefront: "KEDGE_STOREFRONT_ID",
22
+ publishable: "KEDGE_PUBLISHABLE_KEY",
23
+ secret: "KEDGE_SECRET_KEY",
24
+ } as const;
25
+
26
+ export type Wanted =
27
+ | { kind: "publishable"; name: string; origins: readonly string[] }
28
+ | { kind: "secret"; name: string; grants: readonly string[] };
29
+
30
+ export interface SaveDeps {
31
+ cwd: string;
32
+ fetch: typeof fetch;
33
+ session: SessionDeps;
34
+ log: (s: string) => void;
35
+ now?: () => number;
36
+ }
37
+
38
+ /** A store's identity as records bind to it (§4): account, Worker and database, never the URL. */
39
+ export const identityOf = (t: StoreTarget) => `${t.identity.account}/${t.identity.worker}/${t.identity.database}`;
40
+
41
+ const newRef = () => randomBytes(32).toString("base64url");
42
+ const keyDigest = (key: string) => createHash("sha256").update(key).digest("hex");
43
+
44
+ /** What the server answers for a ref: its state and what it made. */
45
+ interface RefAnswer {
46
+ state: "used" | "fenced" | "revoked";
47
+ storefront_id: string | null;
48
+ key_id: string | null;
49
+ key?: string;
50
+ }
51
+
52
+ /** Makes (or confirms) the wanted key for `target`, saved in `.env.local`; returns what's saved. */
53
+ export async function keysSave(target: StoreTarget, wanted: Wanted, d: SaveDeps): Promise<Record<string, string>> {
54
+ return withStoreLock(d.cwd, "keys create --save", () => keysSaveHeld(target, wanted, d));
55
+ }
56
+
57
+ /** The same, for a command already holding the checkout's lock (`smoke`): taking it again would refuse itself. */
58
+ export async function keysSaveHeld(target: StoreTarget, wanted: Wanted, d: SaveDeps): Promise<Record<string, string>> {
59
+ const db = stateDb(d.cwd);
60
+ try {
61
+ return await save(db, target, wanted, d);
62
+ } finally {
63
+ db.close();
64
+ }
65
+ }
66
+
67
+ async function save(db: DatabaseSync, target: StoreTarget, wanted: Wanted, d: SaveDeps) {
68
+ const now = d.now ?? Date.now;
69
+ const store = identityOf(target);
70
+ const envName = wanted.kind === "publishable" ? ENV.publishable : ENV.secret;
71
+ const refs = () =>
72
+ db
73
+ .prepare("SELECT * FROM credential_ref WHERE store = ? AND env_name = ? ORDER BY created_at")
74
+ .all(store, envName) as unknown as (RefRecord & { created_at: number })[];
75
+ const set = (ref: string, a: Partial<RefRecord>) => {
76
+ const cols = Object.keys(a);
77
+ db.prepare(`UPDATE credential_ref SET ${cols.map((c) => `${c} = ?`).join(", ")}, updated_at = ? WHERE ref = ?`).run(
78
+ ...cols.map((c) => (a as Record<string, string | null>)[c] ?? null),
79
+ now(),
80
+ ref,
81
+ );
82
+ };
83
+
84
+ // 1. Every key .env.local holds is bound to its store by the ref this checkout recorded for it, by
85
+ // digest, never by a label beside it: a key with no such ref, or one for another store, would be sent
86
+ // to the wrong store or relabelled as this one's (codex code r11, r13). So any such key, of either
87
+ // kind, is the person's to remove before anything is saved beside it.
88
+ const env = readEnvLocal(d.cwd);
89
+ const all = db.prepare("SELECT * FROM credential_ref").all() as unknown as RefRecord[];
90
+ const foreign: string[] = [];
91
+ for (const name of [ENV.publishable, ENV.secret]) {
92
+ const value = env[name];
93
+ if (!value) continue;
94
+ const digest = keyDigest(value);
95
+ const ref = all.find((r) => r.key_digest === digest && r.env_name === name);
96
+ if (!ref || ref.store !== store) {
97
+ foreign.push(name);
98
+ continue;
99
+ }
100
+ // A crash between the rename and marking it saved leaves its ref `used` (codex code r10).
101
+ if (ref.state === "used") set(ref.ref, { state: "saved" });
102
+ }
103
+ const sf = env[ENV.storefront];
104
+ if (sf && !all.some((r) => r.store === store && r.storefront_id === sf)) foreign.push(ENV.storefront);
105
+ if (foreign.length)
106
+ throw new NeedsPerson(
107
+ `.env.local holds ${foreign.join(", ")} that this checkout didn't make for this store: remove ${foreign.length === 1 ? "that line" : "those lines"}, or save this store's keys in its own checkout, then run this again`,
108
+ );
109
+ const saved = env[envName];
110
+ if (saved && (await works(target, wanted, saved, d))) {
111
+ if (wanted.kind === "publishable") await allowOrigins(target, env[ENV.storefront], wanted.origins, d);
112
+ return pick(env, wanted);
113
+ }
114
+
115
+ const grants = wanted.kind === "secret" ? ["api_key:write", ...wanted.grants] : ["storefront:write"];
116
+ const made = await withCliSession(target, grants, d.session, async (auth) => {
117
+ const call = async (method: string, path: string, body: unknown) => {
118
+ const r = await storeRequest(d.fetch, new URL(path, target.url), {
119
+ method,
120
+ headers: { ...auth, "content-type": "application/json" },
121
+ body: JSON.stringify(body),
122
+ });
123
+ // A Worker from before P0.10 has no such route: nothing was made, and nothing is sent again.
124
+ if (r.status === 404) throw new DeployRefused(OLDER_KEDGE);
125
+ // Settling a ref needs every permission its key grants: one this session can't hold is an Owner's to void.
126
+ if (r.status === 403 && path.endsWith("/void"))
127
+ throw new NeedsPerson(
128
+ `credential ref ${path.split("/")[3]} made a key this command can't revoke: an Owner can revoke it in the admin's API keys, then run this again`,
129
+ );
130
+ if (!r.ok)
131
+ throw new DeployRefused(
132
+ `${method} ${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
133
+ );
134
+ return r.body as unknown as RefAnswer;
135
+ };
136
+
137
+ // 2. Every ref of ours not yet saved, and a saved one whose key stopped working, is settled first.
138
+ let storefront =
139
+ wanted.kind === "publishable" ? (refs().find((r) => r.storefront_id)?.storefront_id ?? null) : null;
140
+ for (const r of refs()) {
141
+ if (r.state === "fenced" || r.state === "revoked") continue;
142
+ const a = await call("POST", `/admin/credential-refs/${r.ref}/void`, { kind: r.kind });
143
+ set(r.ref, { state: a.state, storefront_id: a.storefront_id, key_id: a.key_id });
144
+ // A storefront's ref voided after it landed still names the storefront: the next key goes on it.
145
+ if (a.storefront_id) storefront = a.storefront_id;
146
+ }
147
+
148
+ // 3. A new ref, recorded before it's sent.
149
+ const ref = newRef();
150
+ const kind: RefRecord["kind"] = wanted.kind === "secret" ? "secret" : storefront ? "publishable" : "storefront";
151
+ const t = now();
152
+ db.prepare(
153
+ "INSERT INTO credential_ref (ref, store, kind, state, storefront_id, key_id, env_name, created_at, updated_at) VALUES (?, ?, ?, 'recorded', ?, NULL, ?, ?, ?)",
154
+ ).run(ref, store, kind, storefront, envName, t, t);
155
+ const body =
156
+ wanted.kind === "secret"
157
+ ? { kind, name: wanted.name, grants: wanted.grants.map((permission) => ({ permission, row_scope: "all" })) }
158
+ : kind === "publishable"
159
+ ? { kind, name: wanted.name, storefront_id: storefront }
160
+ : { kind, name: wanted.name, allowed_origins: wanted.origins };
161
+ const made = await call("PUT", `/admin/credential-refs/${ref}`, body);
162
+ if (!made.key)
163
+ throw new DeployRefused("the store made the key but answered no key: run this again to void and replace it");
164
+ // The key's digest, never the key, before .env.local is written: what binds the file's key to this ref.
165
+ set(ref, {
166
+ state: made.state,
167
+ storefront_id: made.storefront_id,
168
+ key_id: made.key_id,
169
+ key_digest: keyDigest(made.key),
170
+ });
171
+
172
+ // 4. Saved before anything else happens, then marked saved.
173
+ const values: Record<string, string> = { [ENV.store]: store, [envName]: made.key };
174
+ if (made.storefront_id) values[ENV.storefront] = made.storefront_id;
175
+ saveEnv(d.cwd, values);
176
+ set(ref, { state: "saved" });
177
+ d.log(`saved ${Object.keys(values).join(", ")} to .env.local`);
178
+ return values;
179
+ });
180
+ // A key on a storefront that already existed was made without the origins asked for now.
181
+ if (wanted.kind === "publishable") await allowOrigins(target, made[ENV.storefront], wanted.origins, d);
182
+ return made;
183
+ }
184
+
185
+ /**
186
+ * The storefront allows every origin asked for, keeping those it lists
187
+ * (codex code r14): a key a browser on that origin can't use is no key, and
188
+ * replacing the key never changes its storefront's origins. The write carries
189
+ * the list it was built from, so it commits only while that's still the list:
190
+ * an origin an Owner removed meanwhile is never put back, and a concurrent
191
+ * addition never lost (codex code r16). A conflict is read again, three times at most.
192
+ */
193
+ async function allowOrigins(
194
+ target: StoreTarget,
195
+ storefront: string | undefined,
196
+ origins: readonly string[],
197
+ d: SaveDeps,
198
+ ): Promise<void> {
199
+ if (!origins.length) return;
200
+ if (!storefront) throw new DeployRefused("no storefront was saved to allow those origins on");
201
+ await withCliSession(target, ["storefront:read", "storefront:write"], d.session, async (auth) => {
202
+ const read = async () => {
203
+ const list = await storeRequest(d.fetch, new URL("/admin/storefronts", target.url), {
204
+ method: "GET",
205
+ headers: auth,
206
+ });
207
+ if (!list.ok) throw new DeployRefused(`GET /admin/storefronts answered ${list.status}`);
208
+ const sf = (list.body.storefronts as { id: string; allowed_origins: string[] }[] | undefined)?.find(
209
+ (s) => s.id === storefront,
210
+ );
211
+ if (!sf) throw new DeployRefused(`storefront ${storefront} no longer exists`);
212
+ return sf.allowed_origins;
213
+ };
214
+ const first = await read();
215
+ // What this command adds is fixed by its first read: a retry never adds an origin that was already
216
+ // there then and has gone since, which is someone removing it on purpose (codex code r17).
217
+ const adding = origins.filter((o) => !first.includes(o));
218
+ if (!adding.length) return;
219
+ let current = first;
220
+ for (let attempt = 1; ; attempt++) {
221
+ const removed = origins.filter((o) => first.includes(o) && !current.includes(o));
222
+ if (removed.length)
223
+ throw new NeedsPerson(
224
+ `${removed.join(", ")} was removed from storefront ${storefront} while this ran: ask whoever removed it before adding it back`,
225
+ );
226
+ const next = [...current, ...adding.filter((o) => !current.includes(o))];
227
+ if (next.length === current.length) return;
228
+ const put = await storeRequest(d.fetch, new URL(`/admin/storefronts/${storefront}/origins`, target.url), {
229
+ method: "PUT",
230
+ headers: { ...auth, "content-type": "application/json" },
231
+ body: JSON.stringify({ allowed_origins: next, expected: current }),
232
+ });
233
+ if (put.ok) {
234
+ d.log(`storefront ${storefront} now allows ${adding.join(", ")}`);
235
+ return;
236
+ }
237
+ const code = (put.body.error as { code?: string } | undefined)?.code;
238
+ if (code !== "CONFLICT" || attempt >= 3)
239
+ throw new DeployRefused(`PUT /admin/storefronts/${storefront}/origins answered ${put.status} ${code ?? ""}`);
240
+ current = await read();
241
+ }
242
+ });
243
+ }
244
+
245
+ /** An origin as the store accepts one (db's `origins`): a scheme, host and optional port, nothing else. */
246
+ export function badOrigins(origins: readonly string[]): string[] {
247
+ return origins.filter((o) => {
248
+ try {
249
+ const u = new URL(o);
250
+ return (u.protocol !== "https:" && u.protocol !== "http:") || u.origin !== o;
251
+ } catch {
252
+ return true;
253
+ }
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Whether a saved key still does its job (§6.2): a publishable key passes the
259
+ * storefront gate; a secret key holds its grants. Only a definite answer
260
+ * decides: the key refused (401, 403), or its permissions read and short.
261
+ * Anything else (429, 5xx, a shape it doesn't know) stops with nothing
262
+ * changed, since replacing a working key revokes it for everyone using it
263
+ * (codex code r15).
264
+ */
265
+ async function works(target: StoreTarget, wanted: Wanted, key: string, d: SaveDeps): Promise<boolean> {
266
+ const inconclusive = (what: string, status: number) =>
267
+ new DeployRefused(`couldn't check the saved key (${what} answered ${status}); nothing was changed: run this again`);
268
+ if (wanted.kind === "publishable") {
269
+ // The storefront gate judges the key before the route: a product that doesn't exist is 404 to a working key.
270
+ const r = await storeRequest(d.fetch, new URL("/store/products/kedge-key-check?currency=USD", target.url), {
271
+ method: "GET",
272
+ headers: { "x-kedge-key": key },
273
+ });
274
+ if (r.status === 401 || r.status === 403) return false;
275
+ if (r.status === 404 || r.status === 400 || r.ok) return true;
276
+ throw inconclusive("the storefront gate", r.status);
277
+ }
278
+ const r = await storeRequest(d.fetch, new URL("/admin/meta/me", target.url), {
279
+ method: "GET",
280
+ headers: { authorization: `Bearer ${key}` },
281
+ });
282
+ if (r.status === 401 || r.status === 403) return false;
283
+ if (!r.ok || !Array.isArray(r.body.permissions)) throw inconclusive("/admin/meta/me", r.status);
284
+ // `{ code, scopes }` per permission (server/src/meta-routes.ts:114); a saved key needs each for every row (codex code r10).
285
+ const held = new Map(
286
+ (r.body.permissions as { code?: string; scopes?: string[] }[]).map((p) => [p.code, p.scopes ?? []]),
287
+ );
288
+ return wanted.grants.every((g) => held.get(g)?.includes("all") === true);
289
+ }
290
+
291
+ const pick = (env: Record<string, string>, wanted: Wanted) =>
292
+ Object.fromEntries(
293
+ Object.entries(env).filter(([k]) =>
294
+ wanted.kind === "publishable"
295
+ ? [ENV.store, ENV.storefront, ENV.publishable].includes(k as never)
296
+ : [ENV.store, ENV.secret].includes(k as never),
297
+ ),
298
+ );
@@ -0,0 +1,110 @@
1
+ // A store checkout's local state (docs/proposals/p0.10-agent-path.md §6.2):
2
+ // one lock the operating system releases, a record of every credential
3
+ // reference before it's sent, and `.env.local` rewritten whole.
4
+ //
5
+ // The lock is an exclusive SQLite transaction on `.kedge/lock.db`, held from
6
+ // before a command's first read of this state to after its last write.
7
+ // SQLite takes it from the OS's file locks, which end with the process, so a
8
+ // command killed with SIGKILL leaves nothing to take over (checked on Linux
9
+ // while drafting §6.2). The lock's transaction writes nothing: what it would
10
+ // write is lost with a crash, and a record must survive one, so records live
11
+ // in `.kedge/state.db`, each committed on its own before the request it guards.
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { DatabaseSync } from "node:sqlite";
15
+ import { parseEnv } from "node:util";
16
+ import { DeployRefused } from "./deploy";
17
+
18
+ const dir = (cwd: string) => {
19
+ const d = join(cwd, ".kedge");
20
+ mkdirSync(d, { recursive: true });
21
+ return d;
22
+ };
23
+
24
+ /**
25
+ * Runs `work` holding the checkout's lock. A second command is refused at once
26
+ * (no wait), naming the one that holds it, so two never interleave their reads
27
+ * and rewrites of `.env.local` or a record.
28
+ */
29
+ export async function withStoreLock<T>(cwd: string, command: string, work: () => Promise<T>): Promise<T> {
30
+ const d = dir(cwd);
31
+ const db = new DatabaseSync(join(d, "lock.db"), { timeout: 0 });
32
+ const holder = join(d, "lock.holder");
33
+ try {
34
+ try {
35
+ db.exec("BEGIN EXCLUSIVE");
36
+ } catch (e) {
37
+ if (!/database is locked/.test((e as Error).message)) throw e;
38
+ // Written by the holder after it took the lock: informative only, the lock is what decides.
39
+ const who = existsSync(holder) ? readFileSync(holder, "utf8").trim() : "another kedge command";
40
+ throw new DeployRefused(`${who} is using this store's local state; run this again when it has finished`);
41
+ }
42
+ writeFileSync(holder, `kedge ${command} (pid ${process.pid})\n`);
43
+ try {
44
+ return await work();
45
+ } finally {
46
+ db.exec("ROLLBACK");
47
+ }
48
+ } finally {
49
+ db.close();
50
+ }
51
+ }
52
+
53
+ /** A credential reference this checkout made (§6.2), bound to the store's identity and the key's kind. */
54
+ export interface RefRecord {
55
+ ref: string;
56
+ store: string;
57
+ kind: "secret" | "publishable" | "storefront";
58
+ /** recorded: about to be sent; then the server's answer; saved: its key is in `.env.local`. */
59
+ state: "recorded" | "used" | "fenced" | "revoked" | "saved";
60
+ storefront_id: string | null;
61
+ key_id: string | null;
62
+ /** The `.env.local` name its key is saved under. */
63
+ env_name: string;
64
+ /** SHA-256 of the key it made, recorded before the key is written: binds the saved key to this ref. */
65
+ key_digest: string | null;
66
+ }
67
+
68
+ /** `.kedge/state.db`, mode 0600: records only a command holding the lock writes, each committed alone. */
69
+ export function stateDb(cwd: string): DatabaseSync {
70
+ const file = join(dir(cwd), "state.db");
71
+ const db = new DatabaseSync(file);
72
+ chmodSync(file, 0o600);
73
+ db.exec(`CREATE TABLE IF NOT EXISTS credential_ref (
74
+ ref TEXT PRIMARY KEY, store TEXT NOT NULL, kind TEXT NOT NULL, state TEXT NOT NULL,
75
+ storefront_id TEXT, key_id TEXT, env_name TEXT NOT NULL, key_digest TEXT,
76
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`);
77
+ return db;
78
+ }
79
+
80
+ /**
81
+ * Writes `values` into `.env.local`, keeping every other line: a temporary
82
+ * file in the same directory, mode 0600, renamed over it, so a crash leaves
83
+ * the old file or the new one, never half of either.
84
+ */
85
+ export function saveEnv(cwd: string, values: Record<string, string>): void {
86
+ const file = join(cwd, ".env.local");
87
+ const lines = existsSync(file) ? readFileSync(file, "utf8").split("\n") : [];
88
+ const left = new Set(Object.keys(values));
89
+ const out = lines.map((line) => {
90
+ const name = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
91
+ if (!name || !(name in values)) return line;
92
+ left.delete(name);
93
+ return `${name}=${values[name]}`;
94
+ });
95
+ while (out.length && out.at(-1) === "") out.pop();
96
+ for (const name of left) out.push(`${name}=${values[name]}`);
97
+ const tmp = join(cwd, `.env.local.${process.pid}.tmp`);
98
+ try {
99
+ writeFileSync(tmp, `${out.join("\n")}\n`, { mode: 0o600 });
100
+ renameSync(tmp, file);
101
+ } finally {
102
+ rmSync(tmp, { force: true });
103
+ }
104
+ }
105
+
106
+ /** `.env.local`'s values, as the credential resolver reads them. */
107
+ export function readEnvLocal(cwd: string): Record<string, string> {
108
+ const file = join(cwd, ".env.local");
109
+ return existsSync(file) ? (parseEnv(readFileSync(file, "utf8")) as Record<string, string>) : {};
110
+ }