@realiizlabs/admin 0.11.2 → 0.13.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.
Files changed (39) hide show
  1. package/dist/Sidebar-CNuphww6.d.cts +60 -0
  2. package/dist/Sidebar-CNuphww6.d.ts +60 -0
  3. package/dist/auth/index.d.cts +5 -127
  4. package/dist/auth/index.d.ts +5 -127
  5. package/dist/auth-ui/index.js +124 -1
  6. package/dist/auth-ui/index.js.map +1 -1
  7. package/dist/forms/index.js +287 -1
  8. package/dist/forms/index.js.map +1 -1
  9. package/dist/forms-ui/index.js +355 -6
  10. package/dist/forms-ui/index.js.map +1 -1
  11. package/dist/git/index.cjs.map +1 -1
  12. package/dist/git/index.d.cts +3 -77
  13. package/dist/git/index.d.ts +3 -77
  14. package/dist/git/index.js.map +1 -1
  15. package/dist/members-nhbRSYyr.d.cts +106 -0
  16. package/dist/members-nhbRSYyr.d.ts +106 -0
  17. package/dist/roles-BjWuj2_v.d.cts +25 -0
  18. package/dist/roles-Dnwj-DAm.d.ts +25 -0
  19. package/dist/shell/index.d.cts +3 -58
  20. package/dist/shell/index.d.ts +3 -58
  21. package/dist/studio/index.cjs +1150 -0
  22. package/dist/studio/index.cjs.map +1 -0
  23. package/dist/studio/index.d.cts +325 -0
  24. package/dist/studio/index.d.ts +325 -0
  25. package/dist/studio/index.js +1131 -0
  26. package/dist/studio/index.js.map +1 -0
  27. package/dist/studio-ui/index.cjs +3931 -0
  28. package/dist/studio-ui/index.cjs.map +1 -0
  29. package/dist/studio-ui/index.d.cts +375 -0
  30. package/dist/studio-ui/index.d.ts +375 -0
  31. package/dist/studio-ui/index.js +3900 -0
  32. package/dist/studio-ui/index.js.map +1 -0
  33. package/dist/types-BP5G1myE.d.cts +78 -0
  34. package/dist/types-BP5G1myE.d.ts +78 -0
  35. package/package.json +20 -2
  36. package/dist/chunk-2GSYBARR.js +0 -126
  37. package/dist/chunk-2GSYBARR.js.map +0 -1
  38. package/dist/chunk-JLR5K6RP.js +0 -289
  39. package/dist/chunk-JLR5K6RP.js.map +0 -1
@@ -0,0 +1,1150 @@
1
+ 'use strict';
2
+
3
+ require('server-only');
4
+ var navigation = require('next/navigation');
5
+ var ssr = require('@supabase/ssr');
6
+ var matter = require('gray-matter');
7
+ var unified = require('unified');
8
+ var remarkParse = require('remark-parse');
9
+ var remarkGfm = require('remark-gfm');
10
+ var remarkRehype = require('remark-rehype');
11
+ var rehypeRaw = require('rehype-raw');
12
+ var rehypeSlug = require('rehype-slug');
13
+ var rehypeStringify = require('rehype-stringify');
14
+ var supabaseJs = require('@supabase/supabase-js');
15
+
16
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
17
+
18
+ var matter__default = /*#__PURE__*/_interopDefault(matter);
19
+ var remarkParse__default = /*#__PURE__*/_interopDefault(remarkParse);
20
+ var remarkGfm__default = /*#__PURE__*/_interopDefault(remarkGfm);
21
+ var remarkRehype__default = /*#__PURE__*/_interopDefault(remarkRehype);
22
+ var rehypeRaw__default = /*#__PURE__*/_interopDefault(rehypeRaw);
23
+ var rehypeSlug__default = /*#__PURE__*/_interopDefault(rehypeSlug);
24
+ var rehypeStringify__default = /*#__PURE__*/_interopDefault(rehypeStringify);
25
+
26
+ // src/studio/index.ts
27
+ function createIdentityClient(ctx) {
28
+ const store = ctx.cookies;
29
+ return ssr.createServerClient(ctx.url, ctx.anonKey, {
30
+ cookies: {
31
+ getAll: () => store.getAll(),
32
+ setAll: (list) => {
33
+ try {
34
+ if (typeof store.setAll === "function") {
35
+ void store.setAll(list);
36
+ } else if (typeof store.set === "function") {
37
+ for (const c of list) store.set(c.name, c.value, c.options);
38
+ }
39
+ } catch {
40
+ }
41
+ }
42
+ }
43
+ });
44
+ }
45
+
46
+ // src/auth/errors.ts
47
+ var IdentityError = class extends Error {
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = "IdentityError";
51
+ }
52
+ };
53
+ var UnauthenticatedError = class extends IdentityError {
54
+ constructor() {
55
+ super("Not signed in");
56
+ this.name = "UnauthenticatedError";
57
+ }
58
+ };
59
+ var ForbiddenError = class extends IdentityError {
60
+ constructor(siteId, required, actual) {
61
+ super(`Requires ${required} on site "${siteId}" (you are ${actual ?? "not a member"})`);
62
+ this.name = "ForbiddenError";
63
+ this.siteId = siteId;
64
+ this.required = required;
65
+ this.actual = actual;
66
+ }
67
+ };
68
+
69
+ // src/auth/session.ts
70
+ async function getCurrentUser(client) {
71
+ const { data, error } = await client.auth.getUser();
72
+ if (error || !data.user) return null;
73
+ return { id: data.user.id, email: data.user.email ?? null };
74
+ }
75
+
76
+ // src/auth/types.ts
77
+ var ROLE_RANK = { editor: 1, owner: 2, staff: 3 };
78
+
79
+ // src/auth/roles.ts
80
+ async function getSiteRole(client, siteId, userId) {
81
+ const uid = userId ?? (await getCurrentUser(client))?.id;
82
+ if (!uid) return null;
83
+ const [staff, member] = await Promise.all([
84
+ client.from("staff").select("user_id").eq("user_id", uid).maybeSingle(),
85
+ client.from("site_users").select("role").eq("user_id", uid).eq("site_id", siteId).maybeSingle()
86
+ ]);
87
+ if (staff.error) throw staff.error;
88
+ if (member.error) throw member.error;
89
+ if (staff.data) return "staff";
90
+ const role = member.data?.role;
91
+ return role === "owner" || role === "editor" ? role : null;
92
+ }
93
+ function roleAtLeast(actual, minimum) {
94
+ return actual !== null && ROLE_RANK[actual] >= ROLE_RANK[minimum];
95
+ }
96
+ async function requireSiteUser(client, siteId, opts = {}) {
97
+ const user = await getCurrentUser(client);
98
+ if (!user) throw new UnauthenticatedError();
99
+ const minimum = opts.minimumRole ?? "editor";
100
+ const role = await getSiteRole(client, siteId, user.id);
101
+ if (!roleAtLeast(role, minimum)) throw new ForbiddenError(siteId, minimum, role);
102
+ return { user, role };
103
+ }
104
+
105
+ // src/studio/context.ts
106
+ function makeContext(config) {
107
+ const identityClient = async () => {
108
+ const store = await config.cookies();
109
+ return createIdentityClient({ ...config.env().identity, cookies: store });
110
+ };
111
+ const requireAdmin = async (opts = {}) => {
112
+ try {
113
+ return await requireSiteUser(await identityClient(), config.env().siteId, opts);
114
+ } catch (err) {
115
+ if (err instanceof UnauthenticatedError) navigation.redirect("/admin/sign-in");
116
+ if (err instanceof ForbiddenError) throw err;
117
+ throw err;
118
+ }
119
+ };
120
+ return { identityClient, requireAdmin };
121
+ }
122
+
123
+ // src/studio/registry.ts
124
+ function makeRegistry(config) {
125
+ const entries = Object.values(config.contentTypes);
126
+ const entry = (typeId) => config.contentTypes[typeId] ?? null;
127
+ const slugOf = (e, fileName) => {
128
+ if (e.slugFromFilename) return e.slugFromFilename(fileName);
129
+ return fileName.endsWith(".mdx") ? fileName.slice(0, -4) : null;
130
+ };
131
+ const locate = (path) => {
132
+ for (const e of entries) {
133
+ if (!path.startsWith(`${e.folder}/`)) continue;
134
+ const slug = slugOf(e, path.slice(e.folder.length + 1));
135
+ if (slug) return { typeId: e.id, slug, entry: e };
136
+ }
137
+ return null;
138
+ };
139
+ const publicUrlFor = (typeId, slug) => config.publicUrls?.[typeId]?.(slug) ?? null;
140
+ const publicDirFor = (typeId) => {
141
+ const e = entry(typeId);
142
+ return `/${e?.folder.split("/").filter(Boolean).pop() ?? "images"}/`;
143
+ };
144
+ return { entries, entry, slugOf, locate, publicUrlFor, publicDirFor };
145
+ }
146
+
147
+ // src/git/errors.ts
148
+ var GitHubError = class extends Error {
149
+ constructor(message, opts) {
150
+ super(message);
151
+ this.name = "GitHubError";
152
+ this.status = opts.status;
153
+ this.path = opts.path;
154
+ this.body = opts.body;
155
+ }
156
+ };
157
+ var StaleBaseError = class extends GitHubError {
158
+ constructor(pullNumber, opts) {
159
+ super(`Pull request #${pullNumber} conflicts with the base branch`, opts);
160
+ this.name = "StaleBaseError";
161
+ this.pullNumber = pullNumber;
162
+ }
163
+ };
164
+ var _TokenScopeError = class _TokenScopeError extends GitHubError {
165
+ constructor(opts) {
166
+ super(
167
+ `GitHub refused ${opts.path} (${opts.status}). The repo token needs these fine-grained permissions: ${_TokenScopeError.REQUIRED_PERMISSIONS}`,
168
+ opts
169
+ );
170
+ this.name = "TokenScopeError";
171
+ }
172
+ };
173
+ _TokenScopeError.REQUIRED_PERMISSIONS = "Contents: Read and write, Pull requests: Read and write, Commit statuses: Read-only";
174
+ var TokenScopeError = _TokenScopeError;
175
+ var ChecksFailedError = class extends Error {
176
+ constructor(pullNumber, failingCheck) {
177
+ super(
178
+ `Pull request #${pullNumber} has failing checks${failingCheck ? ` (${failingCheck})` : ""}`
179
+ );
180
+ this.name = "ChecksFailedError";
181
+ this.pullNumber = pullNumber;
182
+ this.failingCheck = failingCheck;
183
+ }
184
+ };
185
+
186
+ // src/git/client.ts
187
+ var API = "https://api.github.com";
188
+ var GITHUB_TIMEOUT_MS = 1e4;
189
+ async function ghFetch(ctx, method, path, body) {
190
+ const res = await fetch(API + path, {
191
+ method,
192
+ signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
193
+ headers: {
194
+ Authorization: `Bearer ${ctx.token}`,
195
+ Accept: "application/vnd.github+json",
196
+ "X-GitHub-Api-Version": "2022-11-28",
197
+ "User-Agent": "realiizlabs-admin",
198
+ ...body !== void 0 ? { "Content-Type": "application/json" } : {}
199
+ },
200
+ body: body !== void 0 ? JSON.stringify(body) : void 0
201
+ });
202
+ const text = await res.text();
203
+ let json = null;
204
+ if (text) {
205
+ try {
206
+ json = JSON.parse(text);
207
+ } catch {
208
+ json = { raw: text };
209
+ }
210
+ }
211
+ return { ok: res.ok, status: res.status, json };
212
+ }
213
+ async function ghFetchOrThrow(ctx, method, path, body) {
214
+ const res = await ghFetch(ctx, method, path, body);
215
+ if (!res.ok) throw toError(res, path);
216
+ return res.json;
217
+ }
218
+ function toError(res, path) {
219
+ const opts = { status: res.status, path, body: res.json };
220
+ if (res.status === 403) return new TokenScopeError(opts);
221
+ const message = res.json?.message ?? `GitHub ${res.status} on ${path}`;
222
+ return new GitHubError(message, opts);
223
+ }
224
+ function repoPath(ctx) {
225
+ return `/repos/${encodeURIComponent(ctx.owner)}/${encodeURIComponent(ctx.repo)}`;
226
+ }
227
+ function baseBranch(ctx) {
228
+ return ctx.baseBranch ?? "main";
229
+ }
230
+
231
+ // src/git/pulls.ts
232
+ async function openPullRequest(ctx, branch, opts) {
233
+ const pr = await ghFetchOrThrow(ctx, "POST", `${repoPath(ctx)}/pulls`, {
234
+ title: opts.title,
235
+ head: branch,
236
+ base: baseBranch(ctx),
237
+ body: opts.body ?? ""
238
+ });
239
+ return { number: pr.number, html_url: pr.html_url, headSha: pr.head.sha, branch };
240
+ }
241
+ async function getPullRequest(ctx, number) {
242
+ return ghFetchOrThrow(ctx, "GET", `${repoPath(ctx)}/pulls/${number}`);
243
+ }
244
+ async function listOpenPullRequests(ctx, opts) {
245
+ const prs = await ghFetchOrThrow(ctx, "GET", `${repoPath(ctx)}/pulls?state=open&per_page=100`);
246
+ return prs.filter((p) => p.head.ref.startsWith(opts.headPrefix)).map((p) => ({ number: p.number, title: p.title, html_url: p.html_url, headSha: p.head.sha, branch: p.head.ref }));
247
+ }
248
+ async function closePullRequest(ctx, number) {
249
+ await ghFetchOrThrow(ctx, "PATCH", `${repoPath(ctx)}/pulls/${number}`, { state: "closed" });
250
+ }
251
+
252
+ // src/git/status.ts
253
+ async function getPullRequestStatus(ctx, number, log = console) {
254
+ const pr = await getPullRequest(ctx, number);
255
+ if (pr.mergeable === null || pr.mergeable_state === "unknown") {
256
+ return { state: "pending" };
257
+ }
258
+ const combined = await ghFetchOrThrow(
259
+ ctx,
260
+ "GET",
261
+ `${repoPath(ctx)}/commits/${pr.head.sha}/status`
262
+ );
263
+ const failing = combined.statuses.find((s) => s.state === "failure" || s.state === "error");
264
+ if (pr.mergeable_state === "dirty") {
265
+ return { state: "red", failingCheck: failing?.context ?? "merge conflict" };
266
+ }
267
+ if (failing || pr.mergeable_state === "unstable") {
268
+ return { state: "red", failingCheck: failing?.context ?? null };
269
+ }
270
+ if (combined.total_count === 0) {
271
+ if (pr.mergeable_state === "clean" || pr.mergeable_state === "behind") {
272
+ const warning = `[admin/git] ${ctx.owner}/${ctx.repo} has no CI statuses on PR #${number} \u2014 treating as green. A client site with no CI is a setup bug.`;
273
+ log.warn(warning);
274
+ return { state: "green", warning };
275
+ }
276
+ return { state: "pending" };
277
+ }
278
+ if (combined.state === "success" && pr.mergeable_state !== "blocked") {
279
+ const previewUrl = await previewUrlFor(ctx, pr.head.sha, combined);
280
+ return previewUrl ? { state: "green", previewUrl } : { state: "green" };
281
+ }
282
+ return { state: "pending" };
283
+ }
284
+ var DASHBOARD_HOST = /^https:\/\/(www\.)?(vercel\.com|app\.netlify\.com)\//i;
285
+ async function previewUrlFor(ctx, sha, combined) {
286
+ const deployments = await ghFetch(ctx, "GET", `${repoPath(ctx)}/deployments?sha=${sha}&per_page=10`);
287
+ if (deployments.ok && Array.isArray(deployments.json)) {
288
+ for (const d of deployments.json.filter((d2) => !/^prod/i.test(d2.environment))) {
289
+ const statuses = await ghFetch(ctx, "GET", `${repoPath(ctx)}/deployments/${d.id}/statuses?per_page=10`);
290
+ if (!statuses.ok || !Array.isArray(statuses.json)) continue;
291
+ const live = statuses.json.find((s) => s.state === "success" && s.environment_url && !DASHBOARD_HOST.test(s.environment_url));
292
+ if (live?.environment_url) return live.environment_url;
293
+ }
294
+ }
295
+ const status = combined.statuses.find(
296
+ (s) => s.state === "success" && /vercel|netlify|deploy|preview/i.test(s.context) && s.target_url && !DASHBOARD_HOST.test(s.target_url)
297
+ );
298
+ return status?.target_url ?? null;
299
+ }
300
+
301
+ // src/studio/pending.ts
302
+ var BRANCH_RE = /^admin\/([a-z0-9-]+)\/(.+)-\d{8}T\d{3,6}$/;
303
+ function makePending(config, registry) {
304
+ const repo = () => config.env().repo;
305
+ const listPendingChanges = async () => {
306
+ const ctx = repo();
307
+ const prs = await listOpenPullRequests(ctx, { headPrefix: "admin/" });
308
+ const out = /* @__PURE__ */ new Map();
309
+ const resolved = await Promise.all(
310
+ prs.map(async (pr) => {
311
+ const m = BRANCH_RE.exec(pr.branch);
312
+ if (!m) return null;
313
+ const entry = registry.entry(m[1]);
314
+ if (!entry) return null;
315
+ const path = `${entry.folder}/${m[2]}.mdx`;
316
+ const status = await getPullRequestStatus(ctx, pr.number);
317
+ const state = status.state === "green" ? "ready" : status.state === "red" ? "failed" : "checking";
318
+ return {
319
+ number: pr.number,
320
+ branch: pr.branch,
321
+ title: pr.title.replace(/^(Publish|Remove):\s*/, ""),
322
+ removal: pr.title.startsWith("Remove: "),
323
+ html_url: pr.html_url,
324
+ path,
325
+ typeId: entry.id,
326
+ state,
327
+ failingCheck: status.state === "red" ? status.failingCheck : null
328
+ };
329
+ })
330
+ );
331
+ for (const p of resolved) {
332
+ if (!p) continue;
333
+ const prev = out.get(p.path);
334
+ if (!prev || p.number > prev.number) out.set(p.path, p);
335
+ }
336
+ return out;
337
+ };
338
+ const pendingFor = async (path) => (await listPendingChanges()).get(path) ?? null;
339
+ return { listPendingChanges, pendingFor };
340
+ }
341
+ var pendingLabel = {
342
+ checking: "Checking\u2026",
343
+ ready: "Ready to publish",
344
+ failed: "Needs fixing"
345
+ };
346
+ function pendingText(p) {
347
+ if (p.removal) return p.state === "ready" ? "Removal ready" : p.state === "failed" ? "Removal needs fixing" : "Removing\u2026";
348
+ return pendingLabel[p.state];
349
+ }
350
+
351
+ // src/git/reads.ts
352
+ var NotFoundError = class extends GitHubError {
353
+ constructor(path) {
354
+ super(`Not found in repo: ${path}`, { status: 404, path, body: null });
355
+ this.name = "NotFoundError";
356
+ }
357
+ };
358
+ function contentsPath(ctx, path, ref) {
359
+ const clean = path.replace(/^\/+|\/+$/g, "");
360
+ const encoded = clean.split("/").map(encodeURIComponent).join("/");
361
+ return `${repoPath(ctx)}/contents/${encoded}?ref=${encodeURIComponent(ref ?? baseBranch(ctx))}`;
362
+ }
363
+ async function listDirectory(ctx, path, opts = {}) {
364
+ const url = contentsPath(ctx, path, opts.ref);
365
+ const res = await ghFetch(ctx, "GET", url);
366
+ if (res.status === 404) return [];
367
+ if (!res.ok) throw toError(res, url);
368
+ const list = Array.isArray(res.json) ? res.json : [];
369
+ return list.filter((e) => e.type === "file").map((e) => ({ name: e.name, path: e.path, sha: e.sha, size: e.size, type: "file" })).sort((a, b) => a.name.localeCompare(b.name));
370
+ }
371
+ async function readFile(ctx, path, opts = {}) {
372
+ const url = contentsPath(ctx, path, opts.ref);
373
+ const res = await ghFetch(ctx, "GET", url);
374
+ if (res.status === 404) throw new NotFoundError(path);
375
+ if (!res.ok) throw toError(res, url);
376
+ const entry = res.json;
377
+ if (Array.isArray(entry) || entry.type !== "file") throw new GitHubError(`${path} is not a file`, { status: res.status, path: url, body: entry });
378
+ if (typeof entry.content !== "string") {
379
+ throw new GitHubError(`${path} is too large to read through the Contents API`, { status: res.status, path: url, body: null });
380
+ }
381
+ const content = decodeBase64Utf8(entry.content);
382
+ return { path: entry.path, sha: entry.sha, content };
383
+ }
384
+ function decodeBase64Utf8(b64) {
385
+ const clean = b64.replace(/\s+/g, "");
386
+ const bin = atob(clean);
387
+ const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
388
+ return new TextDecoder("utf-8").decode(bytes);
389
+ }
390
+
391
+ // src/studio/readers.ts
392
+ var NotHere = class extends Error {
393
+ constructor(slug) {
394
+ super(`No item with the web address "${slug}".`);
395
+ this.name = "NotHere";
396
+ }
397
+ };
398
+ var LIST_CAP = 50;
399
+ var isItem = (name) => name.endsWith(".mdx") && !name.startsWith("_");
400
+ function dateOf(data) {
401
+ const raw = data.publishedAt ?? data.date;
402
+ if (!raw) return null;
403
+ const d = new Date(raw);
404
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
405
+ }
406
+ function makeReaders(config, registry, pending) {
407
+ const repo = () => config.env().repo;
408
+ const need = (typeId) => {
409
+ const e = registry.entry(typeId);
410
+ if (!e) throw new Error(`Unknown content type "${typeId}"`);
411
+ return e;
412
+ };
413
+ const pathOf = async (typeId, slug) => {
414
+ const e = need(typeId);
415
+ if (!e.slugFromFilename) return `${e.folder}/${slug}.mdx`;
416
+ const entries = await listDirectory(repo(), e.folder);
417
+ return entries.find((x) => registry.slugOf(e, x.name) === slug)?.path ?? null;
418
+ };
419
+ const listItems = async (typeId) => {
420
+ const e = need(typeId);
421
+ const ctx = repo();
422
+ const [entries, open] = await Promise.all([
423
+ listDirectory(ctx, e.folder).then((all) => all.filter((x) => isItem(x.name))),
424
+ pending.listPendingChanges()
425
+ ]);
426
+ const describe = async (path, ref, extra) => {
427
+ const fileName = path.slice(e.folder.length + 1);
428
+ const slug = registry.slugOf(e, fileName) ?? fileName.replace(/\.mdx$/, "");
429
+ try {
430
+ const file = await readFile(ctx, path, ref ? { ref } : {});
431
+ const { data } = matter__default.default(file.content);
432
+ return {
433
+ slug,
434
+ path,
435
+ title: typeof data.title === "string" && data.title ? data.title : slug,
436
+ date: dateOf(data),
437
+ needsAttention: !e.schema.safeParse(data).success,
438
+ ...extra
439
+ };
440
+ } catch {
441
+ return { slug, path, title: slug, date: null, needsAttention: true, ...extra };
442
+ }
443
+ };
444
+ const live = await Promise.all(entries.slice(0, LIST_CAP).map((x) => describe(x.path, void 0, { pending: open.get(x.path) ?? null, isNew: false })));
445
+ const livePaths = new Set(entries.map((x) => x.path));
446
+ const onlyPending = await Promise.all(
447
+ [...open.values()].filter((p) => p.typeId === e.id && !livePaths.has(p.path)).map((p) => describe(p.path, p.branch, { pending: p, isNew: true }))
448
+ );
449
+ return [...onlyPending, ...live].sort((a, b) => a.isNew !== b.isNew ? a.isNew ? -1 : 1 : (b.date ?? "").localeCompare(a.date ?? ""));
450
+ };
451
+ const countItems = async (typeId) => {
452
+ const entries = await listDirectory(repo(), need(typeId).folder);
453
+ return entries.filter((x) => isItem(x.name)).length;
454
+ };
455
+ const readItem = async (typeId, slug, opts = {}) => {
456
+ const path = await pathOf(typeId, slug);
457
+ if (!path) throw new NotHere(slug);
458
+ const file = await readFile(repo(), path, opts.ref ? { ref: opts.ref } : {});
459
+ const { data, content } = matter__default.default(file.content);
460
+ return { frontmatter: data, body: content.trim(), sha: file.sha, path };
461
+ };
462
+ const lastAuthor = async (typeId) => {
463
+ for (const p of await listItems(typeId)) {
464
+ if (p.isNew) continue;
465
+ try {
466
+ const { frontmatter } = await readItem(typeId, p.slug);
467
+ if (typeof frontmatter.author === "string" && frontmatter.author) return frontmatter.author;
468
+ } catch {
469
+ }
470
+ }
471
+ return null;
472
+ };
473
+ const getPullRequest2 = async (number) => {
474
+ try {
475
+ const pr = await getPullRequest(repo(), number);
476
+ const body = pr.body ?? "";
477
+ return {
478
+ number: pr.number,
479
+ title: pr.title,
480
+ html_url: pr.html_url,
481
+ merged: pr.merged,
482
+ state: pr.state,
483
+ postPath: body.match(/File: `([^`]+)`/)?.[1] ?? null,
484
+ branch: pr.head.ref,
485
+ removal: /^Action: remove$/m.test(body) || pr.title.startsWith("Remove: "),
486
+ mergeCommitSha: pr.merged ? pr.merge_commit_sha ?? null : null
487
+ };
488
+ } catch (err) {
489
+ if (err instanceof GitHubError && err.status === 404) return null;
490
+ throw err;
491
+ }
492
+ };
493
+ const prStatus = (number) => getPullRequestStatus(repo(), number);
494
+ return { pathOf, listItems, countItems, readItem, lastAuthor, getPullRequest: getPullRequest2, prStatus };
495
+ }
496
+
497
+ // src/git/branch.ts
498
+ async function getBranchSha(ctx, branch) {
499
+ const ref = await ghFetchOrThrow(
500
+ ctx,
501
+ "GET",
502
+ `${repoPath(ctx)}/git/ref/heads/${encodeURIComponent(branch)}`
503
+ );
504
+ return ref.object.sha;
505
+ }
506
+ async function createBranch(ctx, branch) {
507
+ const baseSha = await getBranchSha(ctx, baseBranch(ctx));
508
+ await ghFetchOrThrow(ctx, "POST", `${repoPath(ctx)}/git/refs`, {
509
+ ref: `refs/heads/${branch}`,
510
+ sha: baseSha
511
+ });
512
+ return { branch, baseSha };
513
+ }
514
+ async function deleteBranch(ctx, branch) {
515
+ await ghFetchOrThrow(
516
+ ctx,
517
+ "DELETE",
518
+ `${repoPath(ctx)}/git/refs/heads/${encodeURIComponent(branch)}`
519
+ );
520
+ }
521
+
522
+ // src/git/files.ts
523
+ async function writeFiles(ctx, branch, files, message, opts = {}) {
524
+ const remove = opts.remove ?? [];
525
+ if (files.length === 0 && remove.length === 0) throw new Error("writeFiles: no files given");
526
+ const base = repoPath(ctx);
527
+ const parentSha = await getBranchSha(ctx, branch);
528
+ const tree = [];
529
+ for (const file of files) {
530
+ if (file.encoding === "base64") {
531
+ const blob = await ghFetchOrThrow(ctx, "POST", `${base}/git/blobs`, {
532
+ content: file.content,
533
+ encoding: "base64"
534
+ });
535
+ tree.push({ path: file.path, mode: "100644", type: "blob", sha: blob.sha });
536
+ } else {
537
+ tree.push({ path: file.path, mode: "100644", type: "blob", content: file.content });
538
+ }
539
+ }
540
+ for (const path of remove) tree.push({ path, mode: "100644", type: "blob", sha: null });
541
+ const newTree = await ghFetchOrThrow(ctx, "POST", `${base}/git/trees`, {
542
+ base_tree: parentSha,
543
+ tree
544
+ });
545
+ const commit = await ghFetchOrThrow(ctx, "POST", `${base}/git/commits`, {
546
+ message,
547
+ tree: newTree.sha,
548
+ parents: [parentSha]
549
+ });
550
+ await ghFetchOrThrow(
551
+ ctx,
552
+ "PATCH",
553
+ `${base}/git/refs/heads/${encodeURIComponent(branch)}`,
554
+ { sha: commit.sha, force: false }
555
+ );
556
+ return { branch, commitSha: commit.sha, treeSha: newTree.sha, fileCount: files.length + remove.length };
557
+ }
558
+
559
+ // src/studio/images.ts
560
+ var IMAGE_LIMITS = { maxEach: 800 * 1024, maxTotal: 3.5 * 1024 * 1024, prefix: "/blog/" };
561
+ var IMAGE_ERRORS = {
562
+ path: "That image path isn\u2019t allowed.",
563
+ size: "That picture is too large to send \u2014 try a smaller one."
564
+ };
565
+ var SAFE_PATH = /^[a-z0-9][a-z0-9/_-]*\.webp$/;
566
+ function decodedSize(b64) {
567
+ const clean = b64.replace(/\s+/g, "");
568
+ const pad = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0;
569
+ return Math.floor(clean.length * 3 / 4) - pad;
570
+ }
571
+ function validateImages(images, limits = IMAGE_LIMITS) {
572
+ let total = 0;
573
+ const files = [];
574
+ for (const img of images) {
575
+ if (typeof img?.path !== "string" || typeof img?.base64 !== "string") return { ok: false, error: IMAGE_ERRORS.path };
576
+ if (!img.path.startsWith(limits.prefix) || img.path.includes("..")) return { ok: false, error: IMAGE_ERRORS.path };
577
+ const rel = img.path.slice(1);
578
+ if (!SAFE_PATH.test(rel)) return { ok: false, error: IMAGE_ERRORS.path };
579
+ const size = decodedSize(img.base64);
580
+ if (size === 0 || size > limits.maxEach) return { ok: false, error: IMAGE_ERRORS.size };
581
+ total += size;
582
+ if (total > limits.maxTotal) return { ok: false, error: IMAGE_ERRORS.size };
583
+ files.push({ path: `public/${rel}`, content: img.base64.replace(/\s+/g, ""), encoding: "base64" });
584
+ }
585
+ return { ok: true, files };
586
+ }
587
+
588
+ // src/studio/publish.ts
589
+ var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
590
+ var stamp = () => (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").slice(0, 13);
591
+ function serialiseDates(fm) {
592
+ const out = {};
593
+ for (const [k, v] of Object.entries(fm)) {
594
+ if (v === void 0) continue;
595
+ out[k] = v instanceof Date ? v.toISOString() : v;
596
+ }
597
+ return out;
598
+ }
599
+ function makePublish(config, registry, ctx, readers) {
600
+ const publishContent = async (input) => {
601
+ const { user } = await ctx.requireAdmin();
602
+ const entry = registry.entry(input.type);
603
+ if (!entry) return { ok: false, error: `Unknown content type "${input.type}"` };
604
+ const parsed = entry.schema.safeParse(input.frontmatter);
605
+ if (!parsed.success) return { ok: false, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") };
606
+ const fm = serialiseDates(parsed.data);
607
+ const fileName = entry.filename(fm);
608
+ const path = `${entry.folder}/${fileName}`;
609
+ const content = matter__default.default.stringify(`
610
+ ${input.body.trim()}
611
+ `, fm);
612
+ const title = String(fm.title ?? fileName);
613
+ const pictures = validateImages(input.images ?? [], { ...IMAGE_LIMITS, prefix: registry.publicDirFor(entry.id) });
614
+ if (!pictures.ok) return { ok: false, error: pictures.error };
615
+ const files = [{ path, content }, ...pictures.files];
616
+ const repo = config.env().repo;
617
+ if (input.existing && await stillOpen(input.existing.number)) {
618
+ await writeFiles(repo, input.existing.branch, files, `Update: ${title}`);
619
+ navigation.redirect(`/admin/pr/${input.existing.number}`);
620
+ }
621
+ const branch = `admin/${entry.id}/${fileName.replace(/\.mdx$/, "")}-${stamp()}`;
622
+ await createBranch(repo, branch);
623
+ await writeFiles(repo, branch, files, `Publish: ${title}`);
624
+ const pr = await openPullRequest(repo, branch, {
625
+ title: `Publish: ${title}`,
626
+ body: `Published from the dashboard by ${user.email ?? user.id}.
627
+
628
+ File: \`${path}\``
629
+ });
630
+ navigation.redirect(`/admin/pr/${pr.number}`);
631
+ };
632
+ const stillOpen = async (number) => {
633
+ try {
634
+ const pr = await readers.getPullRequest(number);
635
+ return Boolean(pr && pr.state === "open" && !pr.merged);
636
+ } catch {
637
+ return false;
638
+ }
639
+ };
640
+ const removeContent = async (input) => {
641
+ const { user } = await ctx.requireAdmin();
642
+ const entry = registry.entry(input.type);
643
+ if (!entry) return { ok: false, error: `Unknown content type "${input.type}"` };
644
+ if (!SLUG_RE.test(input.slug)) return { ok: false, error: "That web address isn't valid." };
645
+ const path = await readers.pathOf(input.type, input.slug) ?? `${entry.folder}/${input.slug}.mdx`;
646
+ const repo = config.env().repo;
647
+ const branch = `admin/${entry.id}/${input.slug}-${stamp()}`;
648
+ await createBranch(repo, branch);
649
+ await writeFiles(repo, branch, [], `Remove: ${input.slug}`, { remove: [path] });
650
+ const pr = await openPullRequest(repo, branch, {
651
+ title: `Remove: ${input.slug}`,
652
+ body: `Removed from the dashboard by ${user.email ?? user.id}.
653
+
654
+ File: \`${path}\`
655
+ Action: remove`
656
+ });
657
+ navigation.redirect(`/admin/pr/${pr.number}`);
658
+ };
659
+ return { publishContent, removeContent };
660
+ }
661
+
662
+ // src/git/merge.ts
663
+ async function mergePullRequest(ctx, number, opts = {}) {
664
+ const requireGreen = opts.requireGreen ?? true;
665
+ const path = `${repoPath(ctx)}/pulls/${number}/merge`;
666
+ if (requireGreen) {
667
+ const status = await getPullRequestStatus(ctx, number);
668
+ if (status.state === "pending") return { merged: false, state: "pending" };
669
+ if (status.state === "red") {
670
+ const pr = await getPullRequest(ctx, number);
671
+ if (pr.mergeable_state === "dirty") {
672
+ throw new StaleBaseError(number, { status: 0, path, body: pr });
673
+ }
674
+ throw new ChecksFailedError(number, status.failingCheck);
675
+ }
676
+ }
677
+ const res = await ghFetch(ctx, "PUT", path, {
678
+ merge_method: "squash",
679
+ ...opts.commitTitle ? { commit_title: opts.commitTitle } : {}
680
+ });
681
+ if (!res.ok) {
682
+ if (res.status === 405 || res.status === 409) {
683
+ throw new StaleBaseError(number, { status: res.status, path, body: res.json });
684
+ }
685
+ throw toError(res, path);
686
+ }
687
+ if (opts.deleteBranch ?? true) {
688
+ const pr = await getPullRequest(ctx, number);
689
+ await deleteBranch(ctx, pr.head.ref);
690
+ }
691
+ return { merged: true, sha: res.json.sha };
692
+ }
693
+
694
+ // src/studio/merge.ts
695
+ function makeMerge(config, ctx, readers) {
696
+ const revalidate = config.revalidate ?? (() => {
697
+ });
698
+ const mergeContentPr = async (number) => {
699
+ await ctx.requireAdmin();
700
+ try {
701
+ const result = await mergePullRequest(config.env().repo, number, { requireGreen: true });
702
+ revalidate(`/admin/pr/${number}`);
703
+ return result.merged ? { state: "merged", sha: result.sha } : { state: "pending" };
704
+ } catch (err) {
705
+ if (err instanceof StaleBaseError) return { state: "stale" };
706
+ if (err instanceof ChecksFailedError) return { state: "red", failingCheck: err.failingCheck };
707
+ return { state: "error", message: err instanceof Error ? err.message : String(err) };
708
+ }
709
+ };
710
+ const discardChange = async (number, branch) => {
711
+ await ctx.requireAdmin();
712
+ const repo = config.env().repo;
713
+ try {
714
+ const pr = await readers.getPullRequest(number);
715
+ if (pr?.merged) return { state: "already-published" };
716
+ if (pr && pr.state === "open") await closePullRequest(repo, number);
717
+ if (branch.startsWith("admin/")) {
718
+ try {
719
+ await deleteBranch(repo, branch);
720
+ } catch {
721
+ }
722
+ }
723
+ revalidate("/admin");
724
+ const m = /^admin\/([a-z0-9-]+)\//.exec(branch);
725
+ if (m) revalidate(`/admin/${m[1]}`);
726
+ return { state: "discarded" };
727
+ } catch (err) {
728
+ return { state: "error", message: err instanceof Error ? err.message : String(err) };
729
+ }
730
+ };
731
+ return { mergeContentPr, discardChange };
732
+ }
733
+ var MAX_CHARS = 2e5;
734
+ var processor = unified.unified().use(remarkParse__default.default).use(remarkGfm__default.default).use(remarkRehype__default.default, { allowDangerousHtml: true }).use(rehypeRaw__default.default).use(rehypeSlug__default.default).use(rehypeStringify__default.default);
735
+ function makePreview(ctx) {
736
+ const previewBody = async (markdown) => {
737
+ await ctx.requireAdmin();
738
+ if (markdown.length > MAX_CHARS) return { error: "That's longer than the preview will render." };
739
+ try {
740
+ const source = markdown.replace(/^(import|export)\s.*$/gm, "");
741
+ return { html: String(await processor.process(source)) };
742
+ } catch (err) {
743
+ return { error: err instanceof Error ? err.message.split("\n")[0] : String(err) };
744
+ }
745
+ };
746
+ return { previewBody };
747
+ }
748
+
749
+ // src/auth/branding.ts
750
+ async function getSiteBranding(client, siteId) {
751
+ const { data, error } = await client.from("sites").select("name, logo_url, favicon_url, business_email, business_phone").eq("site_id", siteId).maybeSingle();
752
+ if (error) throw error;
753
+ if (!data) return null;
754
+ const r = data;
755
+ return { name: r.name, logoUrl: r.logo_url, faviconUrl: r.favicon_url, businessEmail: r.business_email, businessPhone: r.business_phone };
756
+ }
757
+ async function updateSiteBranding(client, siteId, patch) {
758
+ const row = {
759
+ site_id: siteId,
760
+ name: patch.name.trim(),
761
+ logo_url: patch.logoUrl ?? null,
762
+ favicon_url: patch.faviconUrl ?? null,
763
+ business_email: emptyToNull(patch.businessEmail),
764
+ business_phone: emptyToNull(patch.businessPhone)
765
+ };
766
+ const { error } = await client.from("sites").upsert(row, { onConflict: "site_id" });
767
+ if (error) throw error;
768
+ }
769
+ function emptyToNull(v) {
770
+ const s = (v ?? "").trim();
771
+ return s === "" ? null : s;
772
+ }
773
+
774
+ // src/auth/profile.ts
775
+ var MIN_PASSWORD_LENGTH = 10;
776
+ var PASSWORD_TOO_SHORT = "Use at least 10 characters.";
777
+ function profileOf(user) {
778
+ const m = user?.user_metadata ?? {};
779
+ const str = (v) => typeof v === "string" && v.trim() ? v.trim() : null;
780
+ const name = str(m.name);
781
+ const displayName = str(m.display_name) ?? name;
782
+ const avatarUrl = typeof m.avatar_url === "string" && m.avatar_url ? m.avatar_url : null;
783
+ return { name, displayName, avatarUrl };
784
+ }
785
+ async function updateProfile(client, patch) {
786
+ const data = {};
787
+ if (patch.name !== void 0) data.name = (patch.name ?? "").trim().slice(0, 80);
788
+ if (patch.displayName !== void 0) data.display_name = (patch.displayName ?? "").trim().slice(0, 80);
789
+ if (patch.avatarUrl !== void 0) data.avatar_url = patch.avatarUrl ?? "";
790
+ const { data: res, error } = await client.auth.updateUser({ data });
791
+ if (error) throw new IdentityError(error.message);
792
+ return profileOf(res.user);
793
+ }
794
+ async function setPassword(client, password) {
795
+ if (typeof password !== "string" || password.length < MIN_PASSWORD_LENGTH) throw new IdentityError(PASSWORD_TOO_SHORT);
796
+ const { error } = await client.auth.updateUser({ password });
797
+ if (error) throw new IdentityError(error.message);
798
+ }
799
+
800
+ // src/studio/account.ts
801
+ var MAX_UPLOAD = 800 * 1024;
802
+ function makeAccount(config, ctx) {
803
+ const revalidate = config.revalidate ?? (() => {
804
+ });
805
+ const siteId = () => config.env().siteId;
806
+ const fail = (err) => ({ ok: false, error: err instanceof Error ? err.message : "Something went wrong. Try again." });
807
+ const upload = async (client, bucket, path, base64) => {
808
+ const bytes = Buffer.from(base64.replace(/\s+/g, ""), "base64");
809
+ if (bytes.length === 0 || bytes.length > MAX_UPLOAD) throw new IdentityError("That picture is too large to send \u2014 try a smaller one.");
810
+ const { error } = await client.storage.from(bucket).upload(path, bytes, { contentType: "image/webp", upsert: true, cacheControl: "60" });
811
+ if (error) throw new IdentityError(error.message);
812
+ const { data } = client.storage.from(bucket).getPublicUrl(path);
813
+ return `${data.publicUrl}?v=${Date.now()}`;
814
+ };
815
+ const saveProfile = async (input) => {
816
+ await ctx.requireAdmin();
817
+ try {
818
+ await updateProfile(await ctx.identityClient(), { name: input.name, displayName: input.displayName });
819
+ revalidate("/admin", "layout");
820
+ return { ok: true, message: "Saved." };
821
+ } catch (err) {
822
+ return fail(err);
823
+ }
824
+ };
825
+ const saveAvatar = async (input) => {
826
+ const { user } = await ctx.requireAdmin();
827
+ const client = await ctx.identityClient();
828
+ try {
829
+ if (!input) {
830
+ await updateProfile(client, { avatarUrl: null });
831
+ revalidate("/admin", "layout");
832
+ return { ok: true, message: "Photo removed." };
833
+ }
834
+ const url = await upload(client, "avatars", `${user.id}/avatar.webp`, input.base64);
835
+ await updateProfile(client, { avatarUrl: url });
836
+ revalidate("/admin", "layout");
837
+ return { ok: true, message: "Photo saved." };
838
+ } catch (err) {
839
+ return fail(err);
840
+ }
841
+ };
842
+ const changePassword = async (input) => {
843
+ await ctx.requireAdmin();
844
+ if (input.password !== input.confirm) return { ok: false, error: "Those passwords don\u2019t match." };
845
+ try {
846
+ await setPassword(await ctx.identityClient(), input.password);
847
+ return { ok: true, message: "Password saved. You can sign in with it next time, or keep using email links." };
848
+ } catch (err) {
849
+ return fail(err);
850
+ }
851
+ };
852
+ const loadBranding = async () => {
853
+ await ctx.requireAdmin();
854
+ return getSiteBranding(await ctx.identityClient(), siteId());
855
+ };
856
+ const saveBranding = async (input) => {
857
+ await ctx.requireAdmin({ minimumRole: "owner" });
858
+ const client = await ctx.identityClient();
859
+ if (!input.name.trim()) return { ok: false, error: "Give your business a name." };
860
+ try {
861
+ const current = await getSiteBranding(client, siteId());
862
+ await updateSiteBranding(client, siteId(), { name: input.name, businessEmail: input.businessEmail, businessPhone: input.businessPhone, logoUrl: current?.logoUrl ?? null, faviconUrl: current?.faviconUrl ?? null });
863
+ revalidate("/admin", "layout");
864
+ return { ok: true, message: "Saved. Your name now shows at the top of Studio." };
865
+ } catch (err) {
866
+ return fail(err);
867
+ }
868
+ };
869
+ const saveBrandingPicture = async (input) => {
870
+ await ctx.requireAdmin({ minimumRole: "owner" });
871
+ const client = await ctx.identityClient();
872
+ try {
873
+ const current = await getSiteBranding(client, siteId());
874
+ if (!current) return { ok: false, error: "Save your business name first, then add the pictures." };
875
+ const url = input.base64 ? await upload(client, "branding", `${siteId()}/${input.kind}.webp`, input.base64) : null;
876
+ await updateSiteBranding(client, siteId(), { ...current, [input.kind === "logo" ? "logoUrl" : "faviconUrl"]: url });
877
+ revalidate("/admin", "layout");
878
+ return { ok: true, message: input.base64 ? `${input.kind === "logo" ? "Logo" : "Favicon"} saved.` : "Removed." };
879
+ } catch (err) {
880
+ return fail(err);
881
+ }
882
+ };
883
+ const loadMe = async () => {
884
+ const { user, role } = await ctx.requireAdmin();
885
+ const { data } = await (await ctx.identityClient()).auth.getUser();
886
+ return { email: user.email ?? "", role, ...profileOf(data.user) };
887
+ };
888
+ return { saveProfile, saveAvatar, changePassword, loadBranding, saveBranding, saveBrandingPicture, loadMe };
889
+ }
890
+
891
+ // src/auth/members.ts
892
+ async function listSiteMembers(client, siteId) {
893
+ const { data, error } = await client.rpc("site_members", { p_site_id: siteId });
894
+ if (error) {
895
+ if (error.code === "42501" || /not allowed/i.test(error.message)) throw new ForbiddenError(siteId, "owner", null);
896
+ throw error;
897
+ }
898
+ return (data ?? []).map((r) => ({
899
+ userId: r.user_id,
900
+ email: r.email,
901
+ name: r.name,
902
+ avatarUrl: r.avatar_url,
903
+ role: r.role === "owner" ? "owner" : "editor",
904
+ status: r.status === "active" ? "active" : "invited",
905
+ invitedAt: r.invited_at
906
+ }));
907
+ }
908
+ function createServiceClient(ctx) {
909
+ return supabaseJs.createClient(ctx.url, ctx.serviceRoleKey, {
910
+ auth: { persistSession: false, autoRefreshToken: false }
911
+ });
912
+ }
913
+ async function resolveActorRole(svc, actorId, siteId) {
914
+ const staff = await svc.from("staff").select("user_id").eq("user_id", actorId).maybeSingle();
915
+ if (staff.error) throw staff.error;
916
+ if (staff.data) return "staff";
917
+ const m = await svc.from("site_users").select("role").eq("user_id", actorId).eq("site_id", siteId).maybeSingle();
918
+ if (m.error) throw m.error;
919
+ const role = m.data?.role;
920
+ return role === "owner" || role === "editor" ? role : null;
921
+ }
922
+ function assertMayGrant(actorRole, siteId, granting) {
923
+ if (actorRole === "staff") return;
924
+ if (actorRole === "owner" && granting === "editor") return;
925
+ throw new ForbiddenError(siteId, granting === "owner" ? "staff" : "owner", actorRole);
926
+ }
927
+ async function inviteUser(ctx, input) {
928
+ const svc = createServiceClient(ctx);
929
+ const actorRole = await resolveActorRole(svc, input.actor.id, input.siteId);
930
+ assertMayGrant(actorRole, input.siteId, input.role);
931
+ const email = input.email.trim().toLowerCase();
932
+ let userId;
933
+ let invitedByEmail = false;
934
+ const invite = await svc.auth.admin.inviteUserByEmail(email, {
935
+ redirectTo: input.redirectTo,
936
+ data: { site_id: input.siteId, role: input.role }
937
+ });
938
+ if (!invite.error) {
939
+ userId = invite.data.user.id;
940
+ invitedByEmail = true;
941
+ } else if (/already|exists|registered/i.test(invite.error.message)) {
942
+ userId = await findUserIdByEmail(svc, email);
943
+ if (!userId) throw new IdentityError(`Could not find existing user ${email}`);
944
+ } else {
945
+ throw new IdentityError(`Invite failed: ${invite.error.message}`);
946
+ }
947
+ const upsert = await svc.from("site_users").upsert({ user_id: userId, site_id: input.siteId, role: input.role, invited_by: input.actor.id }, { onConflict: "user_id,site_id" });
948
+ if (upsert.error) throw upsert.error;
949
+ return { userId, siteId: input.siteId, role: input.role, invitedByEmail };
950
+ }
951
+ async function findUserIdByEmail(svc, email) {
952
+ for (let page = 1; page <= 20; page++) {
953
+ const { data, error } = await svc.auth.admin.listUsers({ page, perPage: 200 });
954
+ if (error) throw error;
955
+ const hit = data.users.find((u) => u.email?.toLowerCase() === email);
956
+ if (hit) return hit.id;
957
+ if (data.users.length < 200) break;
958
+ }
959
+ return void 0;
960
+ }
961
+ async function removeSiteUser(ctx, input) {
962
+ const svc = createServiceClient(ctx);
963
+ const actorRole = await resolveActorRole(svc, input.actor.id, input.siteId);
964
+ const target = await svc.from("site_users").select("role").eq("user_id", input.userId).eq("site_id", input.siteId).maybeSingle();
965
+ if (target.error) throw target.error;
966
+ const targetRole = target.data?.role ?? "editor";
967
+ assertMayGrant(actorRole, input.siteId, targetRole);
968
+ if (targetRole === "owner") await assertNotLastOwner(svc, input.siteId);
969
+ const del = await svc.from("site_users").delete().eq("user_id", input.userId).eq("site_id", input.siteId);
970
+ if (del.error) throw del.error;
971
+ }
972
+ async function updateSiteRole(ctx, input) {
973
+ const svc = createServiceClient(ctx);
974
+ const actorRole = await resolveActorRole(svc, input.actor.id, input.siteId);
975
+ assertMayGrant(actorRole, input.siteId, input.role);
976
+ const target = await svc.from("site_users").select("role").eq("user_id", input.userId).eq("site_id", input.siteId).maybeSingle();
977
+ if (target.error) throw target.error;
978
+ const current = target.data?.role;
979
+ if (!current) throw new IdentityError("That person isn't a member of this site.");
980
+ if (current === "owner") assertMayGrant(actorRole, input.siteId, "owner");
981
+ if (current === "owner" && input.role === "editor") await assertNotLastOwner(svc, input.siteId);
982
+ const upd = await svc.from("site_users").update({ role: input.role }).eq("user_id", input.userId).eq("site_id", input.siteId);
983
+ if (upd.error) throw upd.error;
984
+ }
985
+ async function assertNotLastOwner(svc, siteId) {
986
+ const owners = await svc.from("site_users").select("user_id").eq("site_id", siteId).eq("role", "owner");
987
+ if (owners.error) throw owners.error;
988
+ if ((owners.data ?? []).length <= 1) throw new IdentityError("A site needs at least one owner \u2014 make someone else an owner first.");
989
+ }
990
+ async function resendInvite(ctx, input) {
991
+ const svc = createServiceClient(ctx);
992
+ const actorRole = await resolveActorRole(svc, input.actor.id, input.siteId);
993
+ assertMayGrant(actorRole, input.siteId, "editor");
994
+ const email = input.email.trim().toLowerCase();
995
+ const again = await svc.auth.admin.inviteUserByEmail(email, { redirectTo: input.redirectTo, data: { site_id: input.siteId } });
996
+ if (!again.error) return { resent: true, message: `Invitation sent again to ${email}.` };
997
+ if (/already|exists|registered|confirmed/i.test(again.error.message)) {
998
+ return { resent: false, message: `${email} has already accepted \u2014 they can sign in with an email link any time.` };
999
+ }
1000
+ throw new IdentityError(`Resend failed: ${again.error.message}`);
1001
+ }
1002
+
1003
+ // src/studio/team.ts
1004
+ var TEAM_NOT_CONFIGURED = "Inviting people isn\u2019t set up on this deployment yet.";
1005
+ var OWNER_ONLY = "Only the site's owner can manage the team.";
1006
+ function makeTeam(config, ctx) {
1007
+ const revalidate = config.revalidate ?? (() => {
1008
+ });
1009
+ const fail = (err) => err instanceof ForbiddenError ? { ok: false, error: "You don\u2019t have permission to do that here." } : { ok: false, error: err instanceof Error ? err.message : "Something went wrong. Try again." };
1010
+ const acceptUrl = async () => `${await config.baseUrl()}/admin/auth/accept`;
1011
+ const loadTeam = async () => {
1012
+ const { role } = await ctx.requireAdmin();
1013
+ if (role === "editor") return { ok: false, error: OWNER_ONLY };
1014
+ try {
1015
+ const members = await listSiteMembers(await ctx.identityClient(), config.env().siteId);
1016
+ return { ok: true, members, canInvite: config.env().service !== null };
1017
+ } catch (err) {
1018
+ if (err instanceof ForbiddenError) return { ok: false, error: OWNER_ONLY };
1019
+ throw err;
1020
+ }
1021
+ };
1022
+ const invite = async (input) => {
1023
+ const { user } = await ctx.requireAdmin({ minimumRole: "owner" });
1024
+ const env = config.env();
1025
+ if (!env.service) return { ok: false, error: TEAM_NOT_CONFIGURED };
1026
+ const email = input.email.trim().toLowerCase();
1027
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { ok: false, error: "That doesn\u2019t look like an email address." };
1028
+ try {
1029
+ await inviteUser(env.service, { email, siteId: env.siteId, role: input.role, actor: { id: user.id }, redirectTo: await acceptUrl() });
1030
+ revalidate("/admin/account");
1031
+ return { ok: true, message: `Invitation sent to ${email}.` };
1032
+ } catch (err) {
1033
+ return fail(err);
1034
+ }
1035
+ };
1036
+ const changeRole = async (input) => {
1037
+ const { user } = await ctx.requireAdmin({ minimumRole: "owner" });
1038
+ const env = config.env();
1039
+ if (!env.service) return { ok: false, error: TEAM_NOT_CONFIGURED };
1040
+ try {
1041
+ await updateSiteRole(env.service, { userId: input.userId, siteId: env.siteId, role: input.role, actor: { id: user.id } });
1042
+ revalidate("/admin/account");
1043
+ return { ok: true, message: "Role updated." };
1044
+ } catch (err) {
1045
+ return fail(err);
1046
+ }
1047
+ };
1048
+ const resend = async (input) => {
1049
+ const { user } = await ctx.requireAdmin({ minimumRole: "owner" });
1050
+ const env = config.env();
1051
+ if (!env.service) return { ok: false, error: TEAM_NOT_CONFIGURED };
1052
+ try {
1053
+ const r = await resendInvite(env.service, { email: input.email, siteId: env.siteId, actor: { id: user.id }, redirectTo: await acceptUrl() });
1054
+ return { ok: true, message: r.message };
1055
+ } catch (err) {
1056
+ return fail(err);
1057
+ }
1058
+ };
1059
+ const remove = async (input) => {
1060
+ const { user } = await ctx.requireAdmin({ minimumRole: "owner" });
1061
+ const env = config.env();
1062
+ if (!env.service) return { ok: false, error: TEAM_NOT_CONFIGURED };
1063
+ if (input.userId === user.id) return { ok: false, error: "You can\u2019t remove yourself. Ask another owner to do it." };
1064
+ try {
1065
+ await removeSiteUser(env.service, { userId: input.userId, siteId: env.siteId, actor: { id: user.id } });
1066
+ revalidate("/admin/account");
1067
+ return { ok: true, message: "Removed." };
1068
+ } catch (err) {
1069
+ return fail(err);
1070
+ }
1071
+ };
1072
+ return { loadTeam, invite, changeRole, resend, remove };
1073
+ }
1074
+
1075
+ // src/auth/callback.ts
1076
+ function safeNextPath(next, fallback) {
1077
+ if (!next) return fallback;
1078
+ if (!/^\/(?![/\\])/.test(next)) return fallback;
1079
+ if (/[\r\n]/.test(next) || /^\/[^?#]*:\/\//.test(next)) return fallback;
1080
+ return next;
1081
+ }
1082
+ async function handleAuthCallback(client, requestUrl, opts = {}) {
1083
+ const fallback = opts.fallbackPath ?? "/admin";
1084
+ const url = typeof requestUrl === "string" ? new URL(requestUrl, "http://placeholder.invalid") : requestUrl;
1085
+ const code = url.searchParams.get("code");
1086
+ if (!code) return { ok: false, reason: "missing_code" };
1087
+ const { error } = await client.auth.exchangeCodeForSession(code);
1088
+ if (error) return { ok: false, reason: "exchange_failed", message: error.message };
1089
+ return { ok: true, redirectTo: safeNextPath(url.searchParams.get("next"), fallback) };
1090
+ }
1091
+
1092
+ // src/auth/signout.ts
1093
+ async function signOut(client) {
1094
+ const { error } = await client.auth.signOut({ scope: "local" });
1095
+ if (error) throw error;
1096
+ }
1097
+
1098
+ // src/studio/handlers.ts
1099
+ function makeHandlers(ctx) {
1100
+ const callback = async (request) => {
1101
+ const result = await handleAuthCallback(await ctx.identityClient(), request.url, { fallbackPath: "/admin" });
1102
+ const target = result.ok ? result.redirectTo : "/admin/sign-in?error=link";
1103
+ return Response.redirect(new URL(target, request.url), 307);
1104
+ };
1105
+ const signout = async (request) => {
1106
+ try {
1107
+ await signOut(await ctx.identityClient());
1108
+ } catch {
1109
+ }
1110
+ return Response.redirect(new URL("/admin/sign-in", request.url), 303);
1111
+ };
1112
+ return { callback, signout };
1113
+ }
1114
+
1115
+ // src/studio/index.ts
1116
+ function createStudio(config) {
1117
+ const ctx = makeContext(config);
1118
+ const registry = makeRegistry(config);
1119
+ const pending = makePending(config, registry);
1120
+ const readers = makeReaders(config, registry, pending);
1121
+ const actions = {
1122
+ ...makePublish(config, registry, ctx, readers),
1123
+ ...makeMerge(config, ctx, readers),
1124
+ ...makePreview(ctx),
1125
+ ...makeAccount(config, ctx),
1126
+ ...makeTeam(config, ctx)
1127
+ };
1128
+ return {
1129
+ config,
1130
+ isConfigured: config.isConfigured,
1131
+ entry: registry.entry,
1132
+ entries: registry.entries,
1133
+ requireAdmin: ctx.requireAdmin,
1134
+ identityClient: ctx.identityClient,
1135
+ readers: { ...readers, ...pending, locate: registry.locate, publicUrlFor: registry.publicUrlFor, publicDirFor: registry.publicDirFor },
1136
+ actions,
1137
+ authHandlers: makeHandlers(ctx)
1138
+ };
1139
+ }
1140
+
1141
+ exports.IMAGE_ERRORS = IMAGE_ERRORS;
1142
+ exports.IMAGE_LIMITS = IMAGE_LIMITS;
1143
+ exports.NotHere = NotHere;
1144
+ exports.createStudio = createStudio;
1145
+ exports.decodedSize = decodedSize;
1146
+ exports.pendingLabel = pendingLabel;
1147
+ exports.pendingText = pendingText;
1148
+ exports.validateImages = validateImages;
1149
+ //# sourceMappingURL=index.cjs.map
1150
+ //# sourceMappingURL=index.cjs.map