@kedge/cli 0.9.0 → 0.10.1
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/README.md +28 -0
- package/kedge-manifest.json +93 -61
- package/package.json +8 -5
- package/src/cf.ts +295 -17
- package/src/check.ts +4 -1
- package/src/cli-session.ts +57 -0
- package/src/commands.ts +339 -90
- package/src/d1-batch.ts +68 -0
- package/src/deploy.ts +91 -19
- package/src/doctor.ts +68 -3
- package/src/keys-save.ts +343 -0
- package/src/local-state.ts +122 -0
- package/src/main.ts +181 -71
- package/src/ops.ts +18 -0
- package/src/output.ts +44 -0
- package/src/preview.ts +35 -6
- package/src/smoke.ts +226 -0
- package/src/store-api.ts +36 -0
- package/src/target.ts +81 -0
package/src/keys-save.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
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 { ENV_FILE, 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
|
+
moveLocalBundle(db, d);
|
|
89
|
+
// Each store's keys in its own file (p0.10.1 §4): kedge dev's apart from Cloudflare's.
|
|
90
|
+
const envFile = target.kind === "local" ? ENV_FILE.local : ENV_FILE.production;
|
|
91
|
+
const env = readEnvLocal(d.cwd, envFile);
|
|
92
|
+
const all = db.prepare("SELECT * FROM credential_ref").all() as unknown as RefRecord[];
|
|
93
|
+
const foreign: string[] = [];
|
|
94
|
+
for (const name of [ENV.publishable, ENV.secret]) {
|
|
95
|
+
const value = env[name];
|
|
96
|
+
if (!value) continue;
|
|
97
|
+
const digest = keyDigest(value);
|
|
98
|
+
const ref = all.find((r) => r.key_digest === digest && r.env_name === name);
|
|
99
|
+
if (!ref || ref.store !== store) {
|
|
100
|
+
foreign.push(name);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// A crash between the rename and marking it saved leaves its ref `used` (codex code r10).
|
|
104
|
+
if (ref.state === "used") set(ref.ref, { state: "saved" });
|
|
105
|
+
}
|
|
106
|
+
const sf = env[ENV.storefront];
|
|
107
|
+
if (sf && !all.some((r) => r.store === store && r.storefront_id === sf)) foreign.push(ENV.storefront);
|
|
108
|
+
if (foreign.length)
|
|
109
|
+
throw new NeedsPerson(
|
|
110
|
+
`${envFile} 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`,
|
|
111
|
+
);
|
|
112
|
+
const saved = env[envName];
|
|
113
|
+
if (saved && (await works(target, wanted, saved, d))) {
|
|
114
|
+
if (wanted.kind === "publishable") await allowOrigins(target, env[ENV.storefront], wanted.origins, d);
|
|
115
|
+
return pick(env, wanted);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const grants = wanted.kind === "secret" ? ["api_key:write", ...wanted.grants] : ["storefront:write"];
|
|
119
|
+
const made = await withCliSession(target, grants, d.session, async (auth) => {
|
|
120
|
+
const call = async (method: string, path: string, body: unknown) => {
|
|
121
|
+
const r = await storeRequest(d.fetch, new URL(path, target.url), {
|
|
122
|
+
method,
|
|
123
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
});
|
|
126
|
+
// A Worker from before P0.10 has no such route: nothing was made, and nothing is sent again.
|
|
127
|
+
if (r.status === 404) throw new DeployRefused(OLDER_KEDGE);
|
|
128
|
+
// Settling a ref needs every permission its key grants: one this session can't hold is an Owner's to void.
|
|
129
|
+
if (r.status === 403 && path.endsWith("/void"))
|
|
130
|
+
throw new NeedsPerson(
|
|
131
|
+
`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`,
|
|
132
|
+
);
|
|
133
|
+
if (!r.ok)
|
|
134
|
+
throw new DeployRefused(
|
|
135
|
+
`${method} ${path} answered ${r.status} ${String((r.body.error as { code?: string } | undefined)?.code ?? "")}`,
|
|
136
|
+
);
|
|
137
|
+
return r.body as unknown as RefAnswer;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// 2. Every ref of ours not yet saved, and a saved one whose key stopped working, is settled first.
|
|
141
|
+
let storefront =
|
|
142
|
+
wanted.kind === "publishable" ? (refs().find((r) => r.storefront_id)?.storefront_id ?? null) : null;
|
|
143
|
+
for (const r of refs()) {
|
|
144
|
+
if (r.state === "fenced" || r.state === "revoked") continue;
|
|
145
|
+
const a = await call("POST", `/admin/credential-refs/${r.ref}/void`, { kind: r.kind });
|
|
146
|
+
set(r.ref, { state: a.state, storefront_id: a.storefront_id, key_id: a.key_id });
|
|
147
|
+
// A storefront's ref voided after it landed still names the storefront: the next key goes on it.
|
|
148
|
+
if (a.storefront_id) storefront = a.storefront_id;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 3. A new ref, recorded before it's sent.
|
|
152
|
+
const ref = newRef();
|
|
153
|
+
const kind: RefRecord["kind"] = wanted.kind === "secret" ? "secret" : storefront ? "publishable" : "storefront";
|
|
154
|
+
const t = now();
|
|
155
|
+
db.prepare(
|
|
156
|
+
"INSERT INTO credential_ref (ref, store, kind, state, storefront_id, key_id, env_name, created_at, updated_at) VALUES (?, ?, ?, 'recorded', ?, NULL, ?, ?, ?)",
|
|
157
|
+
).run(ref, store, kind, storefront, envName, t, t);
|
|
158
|
+
const body =
|
|
159
|
+
wanted.kind === "secret"
|
|
160
|
+
? { kind, name: wanted.name, grants: wanted.grants.map((permission) => ({ permission, row_scope: "all" })) }
|
|
161
|
+
: kind === "publishable"
|
|
162
|
+
? { kind, name: wanted.name, storefront_id: storefront }
|
|
163
|
+
: { kind, name: wanted.name, allowed_origins: wanted.origins };
|
|
164
|
+
const made = await call("PUT", `/admin/credential-refs/${ref}`, body);
|
|
165
|
+
if (!made.key)
|
|
166
|
+
throw new DeployRefused("the store made the key but answered no key: run this again to void and replace it");
|
|
167
|
+
// The key's digest, never the key, before .env.local is written: what binds the file's key to this ref.
|
|
168
|
+
set(ref, {
|
|
169
|
+
state: made.state,
|
|
170
|
+
storefront_id: made.storefront_id,
|
|
171
|
+
key_id: made.key_id,
|
|
172
|
+
key_digest: keyDigest(made.key),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// 4. Saved before anything else happens, then marked saved.
|
|
176
|
+
const values: Record<string, string> = { [ENV.store]: store, [envName]: made.key };
|
|
177
|
+
if (made.storefront_id) values[ENV.storefront] = made.storefront_id;
|
|
178
|
+
saveEnv(d.cwd, values, envFile);
|
|
179
|
+
set(ref, { state: "saved" });
|
|
180
|
+
d.log(`saved ${Object.keys(values).join(", ")} to .env.local`);
|
|
181
|
+
return values;
|
|
182
|
+
});
|
|
183
|
+
// A key on a storefront that already existed was made without the origins asked for now.
|
|
184
|
+
if (wanted.kind === "publishable") await allowOrigins(target, made[ENV.storefront], wanted.origins, d);
|
|
185
|
+
return made;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The storefront allows every origin asked for, keeping those it lists
|
|
190
|
+
* (codex code r14): a key a browser on that origin can't use is no key, and
|
|
191
|
+
* replacing the key never changes its storefront's origins. The write carries
|
|
192
|
+
* the list it was built from, so it commits only while that's still the list:
|
|
193
|
+
* an origin an Owner removed meanwhile is never put back, and a concurrent
|
|
194
|
+
* addition never lost (codex code r16). A conflict is read again, three times at most.
|
|
195
|
+
*/
|
|
196
|
+
async function allowOrigins(
|
|
197
|
+
target: StoreTarget,
|
|
198
|
+
storefront: string | undefined,
|
|
199
|
+
origins: readonly string[],
|
|
200
|
+
d: SaveDeps,
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
if (!origins.length) return;
|
|
203
|
+
if (!storefront) throw new DeployRefused("no storefront was saved to allow those origins on");
|
|
204
|
+
await withCliSession(target, ["storefront:read", "storefront:write"], d.session, async (auth) => {
|
|
205
|
+
const read = async () => {
|
|
206
|
+
const list = await storeRequest(d.fetch, new URL("/admin/storefronts", target.url), {
|
|
207
|
+
method: "GET",
|
|
208
|
+
headers: auth,
|
|
209
|
+
});
|
|
210
|
+
if (!list.ok) throw new DeployRefused(`GET /admin/storefronts answered ${list.status}`);
|
|
211
|
+
const sf = (list.body.storefronts as { id: string; allowed_origins: string[] }[] | undefined)?.find(
|
|
212
|
+
(s) => s.id === storefront,
|
|
213
|
+
);
|
|
214
|
+
if (!sf) throw new DeployRefused(`storefront ${storefront} no longer exists`);
|
|
215
|
+
return sf.allowed_origins;
|
|
216
|
+
};
|
|
217
|
+
const first = await read();
|
|
218
|
+
// What this command adds is fixed by its first read: a retry never adds an origin that was already
|
|
219
|
+
// there then and has gone since, which is someone removing it on purpose (codex code r17).
|
|
220
|
+
const adding = origins.filter((o) => !first.includes(o));
|
|
221
|
+
if (!adding.length) return;
|
|
222
|
+
let current = first;
|
|
223
|
+
for (let attempt = 1; ; attempt++) {
|
|
224
|
+
const removed = origins.filter((o) => first.includes(o) && !current.includes(o));
|
|
225
|
+
if (removed.length)
|
|
226
|
+
throw new NeedsPerson(
|
|
227
|
+
`${removed.join(", ")} was removed from storefront ${storefront} while this ran: ask whoever removed it before adding it back`,
|
|
228
|
+
);
|
|
229
|
+
const next = [...current, ...adding.filter((o) => !current.includes(o))];
|
|
230
|
+
if (next.length === current.length) return;
|
|
231
|
+
const put = await storeRequest(d.fetch, new URL(`/admin/storefronts/${storefront}/origins`, target.url), {
|
|
232
|
+
method: "PUT",
|
|
233
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
234
|
+
body: JSON.stringify({ allowed_origins: next, expected: current }),
|
|
235
|
+
});
|
|
236
|
+
if (put.ok) {
|
|
237
|
+
d.log(`storefront ${storefront} now allows ${adding.join(", ")}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const code = (put.body.error as { code?: string } | undefined)?.code;
|
|
241
|
+
if (code !== "CONFLICT" || attempt >= 3)
|
|
242
|
+
throw new DeployRefused(`PUT /admin/storefronts/${storefront}/origins answered ${put.status} ${code ?? ""}`);
|
|
243
|
+
current = await read();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** An origin as the store accepts one (db's `origins`): a scheme, host and optional port, nothing else. */
|
|
249
|
+
export function badOrigins(origins: readonly string[]): string[] {
|
|
250
|
+
return origins.filter((o) => {
|
|
251
|
+
try {
|
|
252
|
+
const u = new URL(o);
|
|
253
|
+
return (u.protocol !== "https:" && u.protocol !== "http:") || u.origin !== o;
|
|
254
|
+
} catch {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A 0.10.0 checkout saved `kedge dev`'s keys in `.env.local`, where they'd block
|
|
262
|
+
* Cloudflare's (p0.10.1 §4). Under the lock, before anything else, the bundle
|
|
263
|
+
* proven local moves to `.env.development.local`: each key whose digest matches
|
|
264
|
+
* a ref recorded for a `local/…` store, the storefront a local ref recorded, and
|
|
265
|
+
* a `local/…` store marker. Nothing unproven moves. The destination is written
|
|
266
|
+
* first and read back, then only names it now holds are removed from
|
|
267
|
+
* `.env.local`; a run stopped between the two finishes the removal next time.
|
|
268
|
+
* A destination already holding one of those names with another value is a handoff.
|
|
269
|
+
*/
|
|
270
|
+
function moveLocalBundle(db: DatabaseSync, d: SaveDeps): void {
|
|
271
|
+
const prod = readEnvLocal(d.cwd, ENV_FILE.production);
|
|
272
|
+
const refs = (db.prepare("SELECT * FROM credential_ref").all() as unknown as RefRecord[]).filter((r) =>
|
|
273
|
+
r.store.startsWith("local/"),
|
|
274
|
+
);
|
|
275
|
+
const moving: Record<string, string> = {};
|
|
276
|
+
for (const name of [ENV.publishable, ENV.secret]) {
|
|
277
|
+
const value = prod[name];
|
|
278
|
+
if (value && refs.some((r) => r.env_name === name && r.key_digest === keyDigest(value))) moving[name] = value;
|
|
279
|
+
}
|
|
280
|
+
const sf = prod[ENV.storefront];
|
|
281
|
+
if (sf && refs.some((r) => r.storefront_id === sf)) moving[ENV.storefront] = sf;
|
|
282
|
+
if (prod[ENV.store]?.startsWith("local/")) moving[ENV.store] = prod[ENV.store] as string;
|
|
283
|
+
const names = Object.keys(moving);
|
|
284
|
+
if (!names.length) return;
|
|
285
|
+
const dev = readEnvLocal(d.cwd, ENV_FILE.local);
|
|
286
|
+
const clash = names.filter((n) => dev[n] !== undefined && dev[n] !== moving[n]);
|
|
287
|
+
if (clash.length)
|
|
288
|
+
throw new NeedsPerson(
|
|
289
|
+
`${ENV_FILE.production} holds kedge dev's ${clash.join(", ")}, and ${ENV_FILE.local} holds others: keep one, remove the other line, then run this again`,
|
|
290
|
+
);
|
|
291
|
+
saveEnv(d.cwd, moving, ENV_FILE.local);
|
|
292
|
+
const landed = readEnvLocal(d.cwd, ENV_FILE.local);
|
|
293
|
+
saveEnv(
|
|
294
|
+
d.cwd,
|
|
295
|
+
{},
|
|
296
|
+
ENV_FILE.production,
|
|
297
|
+
names.filter((n) => landed[n] === moving[n]),
|
|
298
|
+
);
|
|
299
|
+
d.log(`moved kedge dev's ${names.join(", ")} from ${ENV_FILE.production} to ${ENV_FILE.local}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Whether a saved key still does its job (§6.2): a publishable key passes the
|
|
304
|
+
* storefront gate; a secret key holds its grants. Only a definite answer
|
|
305
|
+
* decides: the key refused (401, 403), or its permissions read and short.
|
|
306
|
+
* Anything else (429, 5xx, a shape it doesn't know) stops with nothing
|
|
307
|
+
* changed, since replacing a working key revokes it for everyone using it
|
|
308
|
+
* (codex code r15).
|
|
309
|
+
*/
|
|
310
|
+
async function works(target: StoreTarget, wanted: Wanted, key: string, d: SaveDeps): Promise<boolean> {
|
|
311
|
+
const inconclusive = (what: string, status: number) =>
|
|
312
|
+
new DeployRefused(`couldn't check the saved key (${what} answered ${status}); nothing was changed: run this again`);
|
|
313
|
+
if (wanted.kind === "publishable") {
|
|
314
|
+
// The storefront gate judges the key before the route: a product that doesn't exist is 404 to a working key.
|
|
315
|
+
const r = await storeRequest(d.fetch, new URL("/store/products/kedge-key-check?currency=USD", target.url), {
|
|
316
|
+
method: "GET",
|
|
317
|
+
headers: { "x-kedge-key": key },
|
|
318
|
+
});
|
|
319
|
+
if (r.status === 401 || r.status === 403) return false;
|
|
320
|
+
if (r.status === 404 || r.status === 400 || r.ok) return true;
|
|
321
|
+
throw inconclusive("the storefront gate", r.status);
|
|
322
|
+
}
|
|
323
|
+
const r = await storeRequest(d.fetch, new URL("/admin/meta/me", target.url), {
|
|
324
|
+
method: "GET",
|
|
325
|
+
headers: { authorization: `Bearer ${key}` },
|
|
326
|
+
});
|
|
327
|
+
if (r.status === 401 || r.status === 403) return false;
|
|
328
|
+
if (!r.ok || !Array.isArray(r.body.permissions)) throw inconclusive("/admin/meta/me", r.status);
|
|
329
|
+
// `{ code, scopes }` per permission (server/src/meta-routes.ts:114); a saved key needs each for every row (codex code r10).
|
|
330
|
+
const held = new Map(
|
|
331
|
+
(r.body.permissions as { code?: string; scopes?: string[] }[]).map((p) => [p.code, p.scopes ?? []]),
|
|
332
|
+
);
|
|
333
|
+
return wanted.grants.every((g) => held.get(g)?.includes("all") === true);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const pick = (env: Record<string, string>, wanted: Wanted) =>
|
|
337
|
+
Object.fromEntries(
|
|
338
|
+
Object.entries(env).filter(([k]) =>
|
|
339
|
+
wanted.kind === "publishable"
|
|
340
|
+
? [ENV.store, ENV.storefront, ENV.publishable].includes(k as never)
|
|
341
|
+
: [ENV.store, ENV.secret].includes(k as never),
|
|
342
|
+
),
|
|
343
|
+
);
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
/** Where a store's saved keys go (p0.10.1 §4): `kedge dev`'s apart from Cloudflare's, so neither blocks the other. */
|
|
81
|
+
export const ENV_FILE = { production: ".env.local", local: ".env.development.local" } as const;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Rewrites an env file, keeping every line it doesn't touch: `values` set
|
|
85
|
+
* (replaced in place, or added at the end) and `remove` dropped. A temporary
|
|
86
|
+
* file in the same directory, mode 0600, renamed over it, so a crash leaves
|
|
87
|
+
* the old file or the new one, never half of either.
|
|
88
|
+
*/
|
|
89
|
+
export function saveEnv(
|
|
90
|
+
cwd: string,
|
|
91
|
+
values: Record<string, string>,
|
|
92
|
+
name: string = ENV_FILE.production,
|
|
93
|
+
remove: readonly string[] = [],
|
|
94
|
+
): void {
|
|
95
|
+
const file = join(cwd, name);
|
|
96
|
+
const lines = existsSync(file) ? readFileSync(file, "utf8").split("\n") : [];
|
|
97
|
+
const left = new Set(Object.keys(values));
|
|
98
|
+
const drop = new Set(remove);
|
|
99
|
+
const out = lines.flatMap((line) => {
|
|
100
|
+
const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
|
|
101
|
+
if (!key) return [line];
|
|
102
|
+
if (drop.has(key)) return [];
|
|
103
|
+
if (!(key in values)) return [line];
|
|
104
|
+
left.delete(key);
|
|
105
|
+
return [`${key}=${values[key]}`];
|
|
106
|
+
});
|
|
107
|
+
while (out.length && out.at(-1) === "") out.pop();
|
|
108
|
+
for (const key of left) out.push(`${key}=${values[key]}`);
|
|
109
|
+
const tmp = join(cwd, `${name}.${process.pid}.tmp`);
|
|
110
|
+
try {
|
|
111
|
+
writeFileSync(tmp, `${out.join("\n")}\n`, { mode: 0o600 });
|
|
112
|
+
renameSync(tmp, file);
|
|
113
|
+
} finally {
|
|
114
|
+
rmSync(tmp, { force: true });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** An env file's values (`.env.local` by default), as the credential resolver reads them. */
|
|
119
|
+
export function readEnvLocal(cwd: string, name: string = ENV_FILE.production): Record<string, string> {
|
|
120
|
+
const file = join(cwd, name);
|
|
121
|
+
return existsSync(file) ? (parseEnv(readFileSync(file, "utf8")) as Record<string, string>) : {};
|
|
122
|
+
}
|