@stelstone/server 0.26.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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Cloudflare Access auth adapter.
3
+ *
4
+ * Verifies the `Cf-Access-Jwt-Assertion` header (or `CF_Authorization` cookie)
5
+ * signed by your Cloudflare Access team using RS256. No third-party libraries —
6
+ * uses Node's built-in `crypto` module.
7
+ *
8
+ * cms.config.mjs:
9
+ * auth: {
10
+ * provider: "cloudflare-access",
11
+ * teamDomain: "https://yourteam.cloudflareaccess.com",
12
+ * audience: "your-application-audience-tag", // from Access app settings
13
+ * roles: { // optional: map email → role
14
+ * "fatih@example.com": "admin",
15
+ * },
16
+ * defaultRole: "editor", // role when email not in roles map
17
+ * }
18
+ */
19
+
20
+ import crypto from "crypto";
21
+
22
+ const JWKS_TTL_MS = 60 * 60 * 1000; // 1 hour
23
+
24
+ /**
25
+ * @param {Object} opts
26
+ * @param {string} opts.teamDomain e.g. "https://yourteam.cloudflareaccess.com"
27
+ * @param {string} opts.audience Application Audience tag from Cloudflare Access
28
+ * @param {Object} [opts.roles] { "email": "admin" | "editor" }
29
+ * @param {string} [opts.defaultRole]
30
+ */
31
+ export function createCloudflareAccess({ teamDomain, audience, roles = {}, defaultRole = "editor" }) {
32
+ const domain = teamDomain.replace(/\/$/, "");
33
+ const certsUrl = `${domain}/cdn-cgi/access/certs`;
34
+
35
+ // JWKS cache
36
+ let jwksCache = null;
37
+ let jwksCachedAt = 0;
38
+
39
+ async function getJwks() {
40
+ if (jwksCache && Date.now() - jwksCachedAt < JWKS_TTL_MS) return jwksCache;
41
+ const res = await fetch(certsUrl);
42
+ if (!res.ok) throw new Error(`Failed to fetch Cloudflare Access JWKS: ${res.status}`);
43
+ jwksCache = await res.json();
44
+ jwksCachedAt = Date.now();
45
+ return jwksCache;
46
+ }
47
+
48
+ function base64urlDecode(str) {
49
+ return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
50
+ }
51
+
52
+ function importPublicKey(jwk) {
53
+ // Cloudflare Access JWKS uses x5c (certificate chain) or n/e (RSA components).
54
+ if (jwk.x5c?.length) {
55
+ const pem = `-----BEGIN CERTIFICATE-----\n${jwk.x5c[0].match(/.{1,64}/g).join("\n")}\n-----END CERTIFICATE-----`;
56
+ return crypto.createPublicKey(pem);
57
+ }
58
+ if (jwk.n && jwk.e) {
59
+ return crypto.createPublicKey({ key: jwk, format: "jwk" });
60
+ }
61
+ throw new Error("Unsupported JWK format — no x5c or n/e fields");
62
+ }
63
+
64
+ async function verifyToken(token) {
65
+ const parts = token.split(".");
66
+ if (parts.length !== 3) throw new Error("Invalid JWT format");
67
+
68
+ const [headerB64, payloadB64, sigB64] = parts;
69
+ const header = JSON.parse(base64urlDecode(headerB64).toString());
70
+ const payload = JSON.parse(base64urlDecode(payloadB64).toString());
71
+
72
+ // Claim validation
73
+ const now = Math.floor(Date.now() / 1000);
74
+ if (payload.exp && payload.exp < now) throw new Error("JWT expired");
75
+ if (payload.nbf && payload.nbf > now) throw new Error("JWT not yet valid");
76
+ if (payload.iss !== domain) throw new Error(`JWT issuer mismatch: ${payload.iss}`);
77
+ if (audience && payload.aud !== audience && !payload.aud?.includes?.(audience)) {
78
+ throw new Error("JWT audience mismatch");
79
+ }
80
+
81
+ // Signature verification
82
+ const jwks = await getJwks();
83
+ const jwk = jwks.keys?.find((k) => k.kid === header.kid) ?? jwks.keys?.[0];
84
+ if (!jwk) throw new Error("No matching JWK found");
85
+
86
+ const pubKey = importPublicKey(jwk);
87
+ const verify = crypto.createVerify("RSA-SHA256");
88
+ verify.update(`${headerB64}.${payloadB64}`);
89
+ const valid = verify.verify(pubKey, base64urlDecode(sigB64));
90
+ if (!valid) throw new Error("JWT signature invalid");
91
+
92
+ return payload;
93
+ }
94
+
95
+ function extractToken(request) {
96
+ const header = request.headers.get("cf-access-jwt-assertion");
97
+ if (header) return header;
98
+ // Fall back to cookie
99
+ const cookie = request.headers.get("cookie") || "";
100
+ const match = cookie.match(/(?:^|;\s*)CF_Authorization=([^;]+)/);
101
+ return match ? match[1] : null;
102
+ }
103
+
104
+ function resolveUser(payload) {
105
+ const email = payload.email || payload.sub;
106
+ const role = roles[email] ?? defaultRole;
107
+ return { login: email, name: email, role };
108
+ }
109
+
110
+ /**
111
+ * Cloudflare Access authenticates with its own header (or cookie) rather
112
+ * than Authorization — which is exactly why the handler passes the whole
113
+ * Request to `verify` instead of just the Authorization header. Before that,
114
+ * this adapter had no way to see its own token and returned null
115
+ * unconditionally.
116
+ *
117
+ * @param {Request} request
118
+ * @returns {Promise<object|null>}
119
+ */
120
+ async function verify(request) {
121
+ const token = extractToken(request);
122
+ if (!token) return null;
123
+ try {
124
+ return resolveUser(await verifyToken(token));
125
+ } catch (err) {
126
+ console.warn(`[cloudflare-access] Access denied: ${err.message}`);
127
+ return null;
128
+ }
129
+ }
130
+
131
+ return {
132
+ configured: Boolean(teamDomain && audience),
133
+ // Cloudflare Access issues no tokens of its own — media is expected to be
134
+ // protected by Cloudflare's own CDN auth. Callers ask before calling
135
+ // rather than discovering it as a 500.
136
+ supports: () => false,
137
+ issueMediaToken: () => {
138
+ throw new Error(
139
+ "cloudflare-access cannot issue media tokens — check auth.supports('mediaToken') first",
140
+ );
141
+ },
142
+ verify,
143
+ };
144
+ }
@@ -0,0 +1,302 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+ import { execSync } from "child_process";
5
+ import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue, buildListEntry, relationFields, isEntryFile } from "./_shared.mjs";
6
+
7
+ const HISTORY_KEEP = 50; // max revisions kept per file
8
+
9
+ /**
10
+ * Filesystem-backed content adapter. Pages live as JSON files under
11
+ * `<rootDir>/<pagesDir>/<collection>/<file>.json`. Publishing means
12
+ * `git add && commit && push` to a configured branch.
13
+ *
14
+ * @param {Object} opts
15
+ * @param {string} opts.rootDir
16
+ * @param {string} opts.pagesDir
17
+ * @param {string} opts.publishBranch
18
+ * @param {string[]} opts.publishPaths
19
+ * @param {(timestamp: string) => string} opts.commitMessage
20
+ * @returns {import('./types.mjs').ContentAdapter}
21
+ */
22
+ export function createFsJsonContent({
23
+ rootDir,
24
+ pagesDir,
25
+ publishBranch,
26
+ publishPaths,
27
+ commitMessage,
28
+ collections = {},
29
+ }) {
30
+ const PAGES_DIR = path.join(rootDir, pagesDir);
31
+ const HISTORY_DIR = path.join(rootDir, ".cms-history");
32
+ const PATHS_ARG = publishPaths.join(" ");
33
+
34
+ function historyDir(collection, file) {
35
+ return path.join(HISTORY_DIR, sanitize(collection), sanitize(file));
36
+ }
37
+
38
+ function saveHistory(collection, file) {
39
+ const src = pagePath(collection, file);
40
+ if (!fs.existsSync(src)) return;
41
+ const dir = historyDir(collection, file);
42
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
43
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
44
+ fs.copyFileSync(src, path.join(dir, `${ts}.json`));
45
+ // Prune old revisions beyond HISTORY_KEEP
46
+ const entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
47
+ while (entries.length > HISTORY_KEEP) {
48
+ fs.unlinkSync(path.join(dir, entries.shift()));
49
+ }
50
+ }
51
+
52
+ function git(cmd) {
53
+ return execSync(`git ${cmd}`, { cwd: rootDir, encoding: "utf-8" }).trim();
54
+ }
55
+
56
+ function pagePath(collection, file) {
57
+ return path.join(PAGES_DIR, sanitize(collection), sanitize(file));
58
+ }
59
+
60
+ return {
61
+ async listCollections() {
62
+ if (!fs.existsSync(PAGES_DIR)) return [];
63
+ const dirs = fs
64
+ .readdirSync(PAGES_DIR)
65
+ .filter((d) => fs.statSync(path.join(PAGES_DIR, d)).isDirectory());
66
+ return dirs.map((dir) => {
67
+ const files = fs
68
+ .readdirSync(path.join(PAGES_DIR, dir))
69
+ .filter(isEntryFile);
70
+ return { name: dir, count: files.length };
71
+ });
72
+ },
73
+
74
+ async listPages(collection, sortConfig = null) {
75
+ const dir = path.join(PAGES_DIR, sanitize(collection));
76
+ if (!fs.existsSync(dir)) return null;
77
+
78
+ // Build slug→displayName lookups for collections referenced by this
79
+ // collection's relation fields, so list columns/filters show names
80
+ // instead of raw slugs.
81
+ const lookups = {};
82
+ const targets = Object.values(relationFields(collections[collection]))
83
+ .flatMap((rel) => rel.collections);
84
+ for (const target of targets) {
85
+ if (lookups[target]) continue;
86
+ const targetDir = path.join(PAGES_DIR, sanitize(target));
87
+ if (!fs.existsSync(targetDir)) continue;
88
+ const table = {};
89
+ for (const f of fs.readdirSync(targetDir)) {
90
+ if (!isEntryFile(f)) continue;
91
+ try {
92
+ const d = JSON.parse(fs.readFileSync(path.join(targetDir, f), "utf-8"));
93
+ if (d.slug) table[d.slug] = d.meta?.title || d.meta?.name || d.slug;
94
+ } catch {
95
+ // Skip unreadable entries
96
+ }
97
+ }
98
+ lookups[target] = table;
99
+ }
100
+
101
+ const files = fs.readdirSync(dir).filter(isEntryFile);
102
+ const pages = [];
103
+ for (const file of files) {
104
+ try {
105
+ const data = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
106
+ pages.push(buildListEntry(collections[collection], collection, file, data, lookups));
107
+ } catch (err) {
108
+ // One corrupt entry must not take down the whole collection listing.
109
+ console.warn(`[listPages] Skipping unreadable ${collection}/${file}: ${err.message}`);
110
+ }
111
+ }
112
+ return sortPages(pages, sortConfig);
113
+ },
114
+
115
+ async readPage(collection, file) {
116
+ if (!safeFileName(file)) return null;
117
+ const filePath = pagePath(collection, file);
118
+ if (!fs.existsSync(filePath)) return null;
119
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
120
+ },
121
+
122
+ /**
123
+ * Version token for optimistic concurrency — a hash of the file's bytes.
124
+ * Content-derived rather than time-derived so it is stable across checkouts
125
+ * and unaffected by clock skew.
126
+ */
127
+ async versionOf(collection, file) {
128
+ if (!safeFileName(file)) return null;
129
+ const filePath = pagePath(collection, file);
130
+ if (!fs.existsSync(filePath)) return null;
131
+ return crypto.createHash("sha1").update(fs.readFileSync(filePath)).digest("hex");
132
+ },
133
+
134
+ /**
135
+ * @param {Object} [opts]
136
+ * @param {string} [opts.expectedVersion] Refuse the write when the stored
137
+ * version no longer matches — the caller edited a stale copy.
138
+ */
139
+ async writePage(collection, file, data, { expectedVersion } = {}) {
140
+ if (expectedVersion !== undefined && expectedVersion !== null) {
141
+ const current = await this.versionOf(collection, file);
142
+ if (current && current !== expectedVersion) {
143
+ const err = new Error("This entry changed since you loaded it.");
144
+ err.status = 412;
145
+ throw err;
146
+ }
147
+ }
148
+ saveHistory(collection, file);
149
+ const filePath = pagePath(collection, file);
150
+ const dir = path.dirname(filePath);
151
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
152
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
153
+ },
154
+
155
+ async createPage(collection, data) {
156
+ const slug = data.slug || data.id || `new-${Date.now()}`;
157
+ if (!sanitize(slug) || sanitize(slug) !== String(slug)) {
158
+ // Reject rather than silently stripping characters — otherwise the
159
+ // file name and data.slug would diverge (e.g. "ürünler" → "rnler").
160
+ const err = new Error(
161
+ `Slug "${slug}" contains unsupported characters — use only a-z, 0-9, dots, dashes, underscores.`,
162
+ );
163
+ err.status = 400;
164
+ throw err;
165
+ }
166
+ const fileName = `${sanitize(data.lang || "en")}-${sanitize(slug)}.json`;
167
+ const filePath = pagePath(collection, fileName);
168
+ if (fs.existsSync(filePath)) {
169
+ const err = new Error(
170
+ `An entry already exists for "${data.lang || "en"}/${slug}". Change the slug or language.`,
171
+ );
172
+ err.status = 409;
173
+ throw err;
174
+ }
175
+ const dir = path.dirname(filePath);
176
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
177
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
178
+ return { file: fileName };
179
+ },
180
+
181
+ async deletePage(collection, file) {
182
+ if (!safeFileName(file)) return null;
183
+ const filePath = pagePath(collection, file);
184
+ if (!fs.existsSync(filePath)) return false;
185
+ fs.unlinkSync(filePath);
186
+ return true;
187
+ },
188
+
189
+ async duplicatePage(collection, file) {
190
+ if (!safeFileName(file)) return null;
191
+ const filePath = pagePath(collection, file);
192
+ if (!fs.existsSync(filePath)) return null;
193
+ const src = JSON.parse(fs.readFileSync(filePath, "utf-8"));
194
+
195
+ const { data, fileName } = buildDuplicateData(src);
196
+ const dest = pagePath(collection, fileName);
197
+ const dir = path.dirname(dest);
198
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
199
+ fs.writeFileSync(dest, JSON.stringify(data, null, 2), "utf-8");
200
+ return { file: fileName };
201
+ },
202
+
203
+ // What this adapter can do beyond the shared contract. Routes consult
204
+ // this instead of sniffing methods, and the github adapter declares the
205
+ // opposite (its writes commit immediately, so publish scope is moot).
206
+ capabilities: { deferredPublish: true, perEntryPublish: true },
207
+
208
+ async pendingChanges() {
209
+ const status = git(`status --porcelain ${PATHS_ARG}`);
210
+ const lines = status.split("\n").filter(Boolean);
211
+ // Porcelain line: "XY path" (rename: "XY old -> new"). Only entries under
212
+ // pagesDir are attributed to a collection/file; other publishPaths (e.g.
213
+ // navigation data) still count but stay unattributed.
214
+ const files = lines.map((line) => {
215
+ const raw = line.slice(3).split(" -> ").pop().replace(/^"|"$/g, "");
216
+ const rel = path.relative(pagesDir, raw);
217
+ const attributed = !rel.startsWith("..") && rel.includes(path.sep);
218
+ return attributed
219
+ ? { path: raw, collection: path.dirname(rel), file: path.basename(rel) }
220
+ : { path: raw };
221
+ });
222
+ return { hasChanges: lines.length > 0, changedFiles: lines.length, files };
223
+ },
224
+
225
+ /**
226
+ * Commit and push. With `entries`, only those records' files are staged
227
+ * and committed — other editors' unfinished drafts stay out of the
228
+ * commit. Without it, everything under publishPaths goes (the "publish
229
+ * all" button and the migration scripts).
230
+ *
231
+ * @param {string} [message]
232
+ * @param {{ entries?: {collection: string, file: string}[] }} [opts]
233
+ */
234
+ async publish(message, { entries } = {}) {
235
+ const scoped = Array.isArray(entries) && entries.length > 0;
236
+ const pathsArg = scoped
237
+ ? entries
238
+ .map(({ collection, file }) => path.relative(rootDir, pagePath(collection, file)))
239
+ .map((p) => JSON.stringify(p))
240
+ .join(" ")
241
+ : PATHS_ARG;
242
+
243
+ const status = git(`status --porcelain -- ${pathsArg}`);
244
+ if (!status) return { ok: false, message: "No changes to publish" };
245
+
246
+ // `git add` stages deletions too, so publishing a deleted record works.
247
+ git(`add -- ${pathsArg}`);
248
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
249
+ const msg = message || commitMessage(timestamp);
250
+ git(`commit -m ${JSON.stringify(msg)}`);
251
+ git(`push origin HEAD:${publishBranch}`);
252
+
253
+ const shortSha = git("rev-parse --short HEAD");
254
+ const sha = git("rev-parse HEAD");
255
+ return {
256
+ ok: true,
257
+ message: `Published to ${publishBranch} (${shortSha})`,
258
+ sha,
259
+ shortSha,
260
+ branch: publishBranch,
261
+ scope: scoped ? "entries" : "all",
262
+ };
263
+ },
264
+
265
+ async listHistory(collection, file) {
266
+ if (!safeFileName(file)) return null;
267
+ const dir = historyDir(collection, file);
268
+ if (!fs.existsSync(dir)) return [];
269
+ return fs
270
+ .readdirSync(dir)
271
+ .filter((f) => f.endsWith(".json"))
272
+ .sort()
273
+ .reverse()
274
+ .map((f) => {
275
+ const stat = fs.statSync(path.join(dir, f));
276
+ return { ts: f.replace(".json", ""), size: stat.size };
277
+ });
278
+ },
279
+
280
+ async restoreHistory(collection, file, ts) {
281
+ if (!safeFileName(file)) return null;
282
+ const src = path.join(historyDir(collection, file), sanitize(ts) + ".json");
283
+ if (!fs.existsSync(src)) return false;
284
+ const data = JSON.parse(fs.readFileSync(src, "utf-8"));
285
+ // writePage saves history of current version first, then overwrites
286
+ await this.writePage(collection, file, data);
287
+ return true;
288
+ },
289
+
290
+ async writeBatch(items, _message) {
291
+ if (!items.length) return { ok: true, commitCount: 0 };
292
+ for (const { collection, file, data } of items) {
293
+ await this.writePage(collection, file, data);
294
+ }
295
+ return { ok: true, commitCount: items.length };
296
+ },
297
+
298
+ async listScheduled() {
299
+ return listScheduledDue(this);
300
+ },
301
+ };
302
+ }
@@ -0,0 +1,57 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { sanitize, safeFileName } from "./_shared.mjs";
4
+
5
+ /**
6
+ * Filesystem-backed block-template adapter. Templates are stored as JSON files
7
+ * under `<rootDir>/.cms-templates/<slug>.json`.
8
+ *
9
+ * @param {Object} opts
10
+ * @param {string} opts.rootDir
11
+ * @returns {import('./types.mjs').TemplatesAdapter}
12
+ */
13
+ export function createFsTemplates({ rootDir }) {
14
+ const DIR = path.join(rootDir, ".cms-templates");
15
+
16
+ return {
17
+ async list() {
18
+ if (!fs.existsSync(DIR)) return [];
19
+ return fs
20
+ .readdirSync(DIR)
21
+ .filter((f) => f.endsWith(".json"))
22
+ .map((f) => {
23
+ const data = JSON.parse(fs.readFileSync(path.join(DIR, f), "utf-8"));
24
+ return {
25
+ name: data.name,
26
+ slug: f.replace(".json", ""),
27
+ blockCount: (data.blocks || []).length,
28
+ };
29
+ });
30
+ },
31
+
32
+ async get(slug) {
33
+ if (!safeFileName(slug)) return null;
34
+ const file = path.join(DIR, sanitize(slug) + ".json");
35
+ if (!fs.existsSync(file)) return null;
36
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
37
+ },
38
+
39
+ async put(slug, data) {
40
+ if (!safeFileName(slug)) return null;
41
+ if (!fs.existsSync(DIR)) fs.mkdirSync(DIR, { recursive: true });
42
+ fs.writeFileSync(
43
+ path.join(DIR, sanitize(slug) + ".json"),
44
+ JSON.stringify(data, null, 2),
45
+ "utf-8",
46
+ );
47
+ },
48
+
49
+ async delete(slug) {
50
+ if (!safeFileName(slug)) return null;
51
+ const file = path.join(DIR, sanitize(slug) + ".json");
52
+ if (!fs.existsSync(file)) return false;
53
+ fs.unlinkSync(file);
54
+ return true;
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Shared helpers for adapters backed by the GitHub Contents API.
3
+ *
4
+ * Returns `{ apiGet, apiPut, apiDelete }` bound to one repo. Each call returns
5
+ * parsed JSON (or `null` on 404 for GET) and throws on other non-2xx responses.
6
+ *
7
+ * Thrown errors carry `upstreamStatus`, so callers can act on the specific
8
+ * failure — a 409 from a stale blob SHA is a concurrent edit, not a generic
9
+ * outage — instead of pattern-matching the message.
10
+ */
11
+ function apiError(status, message) {
12
+ const err = new Error(message);
13
+ err.upstreamStatus = status;
14
+ return err;
15
+ }
16
+ export function createGitHubApi({ token, owner, repo }) {
17
+ const BASE = `https://api.github.com/repos/${owner}/${repo}`;
18
+
19
+ const headers = () => ({
20
+ Authorization: `Bearer ${token}`,
21
+ Accept: "application/vnd.github+json",
22
+ "X-GitHub-Api-Version": "2022-11-28",
23
+ "User-Agent": "stelstone",
24
+ "Content-Type": "application/json",
25
+ });
26
+
27
+ async function apiGet(path) {
28
+ const r = await fetch(`${BASE}${path}`, { headers: headers() });
29
+ if (r.status === 404) return null;
30
+ if (!r.ok) throw apiError(r.status, `GitHub API ${r.status} GET ${path}`);
31
+ return r.json();
32
+ }
33
+
34
+ async function apiPut(path, body) {
35
+ const r = await fetch(`${BASE}${path}`, {
36
+ method: "PUT",
37
+ headers: headers(),
38
+ body: JSON.stringify(body),
39
+ });
40
+ if (!r.ok) {
41
+ const err = await r.json().catch(() => ({}));
42
+ throw apiError(r.status, `GitHub API ${r.status} PUT ${path}: ${err.message || ""}`);
43
+ }
44
+ return r.json();
45
+ }
46
+
47
+ async function apiDelete(path, body) {
48
+ const r = await fetch(`${BASE}${path}`, {
49
+ method: "DELETE",
50
+ headers: headers(),
51
+ body: JSON.stringify(body),
52
+ });
53
+ if (!r.ok) throw apiError(r.status, `GitHub API ${r.status} DELETE ${path}`);
54
+ return r.json();
55
+ }
56
+
57
+ async function apiPost(path, body) {
58
+ const r = await fetch(`${BASE}${path}`, {
59
+ method: "POST",
60
+ headers: headers(),
61
+ body: JSON.stringify(body),
62
+ });
63
+ if (!r.ok) {
64
+ const err = await r.json().catch(() => ({}));
65
+ throw apiError(r.status, `GitHub API ${r.status} POST ${path}: ${err.message || ""}`);
66
+ }
67
+ return r.json();
68
+ }
69
+
70
+ async function apiPatch(path, body) {
71
+ const r = await fetch(`${BASE}${path}`, {
72
+ method: "PATCH",
73
+ headers: headers(),
74
+ body: JSON.stringify(body),
75
+ });
76
+ if (!r.ok) {
77
+ const err = await r.json().catch(() => ({}));
78
+ throw apiError(r.status, `GitHub API ${r.status} PATCH ${path}: ${err.message || ""}`);
79
+ }
80
+ return r.json();
81
+ }
82
+
83
+ async function graphql(query, variables = {}) {
84
+ const r = await fetch("https://api.github.com/graphql", {
85
+ method: "POST",
86
+ headers: {
87
+ Authorization: `Bearer ${token}`,
88
+ "User-Agent": "stelstone",
89
+ "Content-Type": "application/json",
90
+ },
91
+ body: JSON.stringify({ query, variables }),
92
+ });
93
+ if (!r.ok) throw new Error(`GitHub GraphQL ${r.status}`);
94
+ const json = await r.json();
95
+ if (json.errors?.length) throw new Error(json.errors[0].message);
96
+ return json.data;
97
+ }
98
+
99
+ return { apiGet, apiPut, apiDelete, apiPost, apiPatch, graphql };
100
+ }