@glw907/cairn-cms 0.3.0 → 0.4.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 (57) hide show
  1. package/README.md +14 -6
  2. package/dist/auth/admins.d.ts +33 -0
  3. package/dist/auth/admins.d.ts.map +1 -0
  4. package/dist/auth/admins.js +90 -0
  5. package/dist/auth/config.d.ts +2097 -0
  6. package/dist/auth/config.d.ts.map +1 -0
  7. package/dist/auth/config.js +78 -0
  8. package/dist/auth/guard.d.ts +34 -0
  9. package/dist/auth/guard.d.ts.map +1 -0
  10. package/dist/auth/guard.js +47 -0
  11. package/dist/auth/index.d.ts +4 -0
  12. package/dist/auth/index.d.ts.map +1 -0
  13. package/dist/auth/index.js +6 -0
  14. package/dist/auth/schema.d.ts +750 -0
  15. package/dist/auth/schema.d.ts.map +1 -0
  16. package/dist/auth/schema.js +93 -0
  17. package/dist/components/AdminLayout.svelte +6 -6
  18. package/dist/components/AdminLayout.svelte.d.ts +2 -2
  19. package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
  20. package/dist/components/ConfirmPage.svelte +31 -0
  21. package/dist/components/ConfirmPage.svelte.d.ts +11 -0
  22. package/dist/components/ConfirmPage.svelte.d.ts.map +1 -0
  23. package/dist/components/LoginPage.svelte +35 -18
  24. package/dist/components/LoginPage.svelte.d.ts +0 -2
  25. package/dist/components/LoginPage.svelte.d.ts.map +1 -1
  26. package/dist/components/ManageAdmins.svelte +1 -1
  27. package/dist/components/ManageAdmins.svelte.d.ts +1 -1
  28. package/dist/components/ManageAdmins.svelte.d.ts.map +1 -1
  29. package/dist/components/index.d.ts +1 -0
  30. package/dist/components/index.d.ts.map +1 -1
  31. package/dist/components/index.js +1 -0
  32. package/dist/email.d.ts.map +1 -1
  33. package/dist/email.js +15 -7
  34. package/dist/index.d.ts +0 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/sveltekit/index.d.ts +7 -60
  38. package/dist/sveltekit/index.d.ts.map +1 -1
  39. package/dist/sveltekit/index.js +37 -157
  40. package/package.json +34 -4
  41. package/src/lib/auth/admins.ts +106 -0
  42. package/src/lib/auth/config.ts +108 -0
  43. package/src/lib/auth/guard.ts +60 -0
  44. package/src/lib/auth/index.ts +6 -0
  45. package/src/lib/auth/schema.ts +112 -0
  46. package/src/lib/components/AdminLayout.svelte +6 -6
  47. package/src/lib/components/ConfirmPage.svelte +31 -0
  48. package/src/lib/components/LoginPage.svelte +35 -18
  49. package/src/lib/components/ManageAdmins.svelte +1 -1
  50. package/src/lib/components/index.ts +1 -0
  51. package/src/lib/email.ts +14 -7
  52. package/src/lib/index.ts +2 -2
  53. package/src/lib/sveltekit/index.ts +46 -228
  54. package/dist/auth.d.ts +0 -25
  55. package/dist/auth.d.ts.map +0 -1
  56. package/dist/auth.js +0 -132
  57. package/src/lib/auth.ts +0 -185
@@ -1,41 +1,22 @@
1
- // cairn-core: the SvelteKit route server logic, extracted so each site's `admin/**` route
2
- // files are thin shims (`export const load = (event) => editLoad(event, cairn)`).
1
+ // cairn-core: the SvelteKit content-route server logic, extracted so each site's `admin/**`
2
+ // route files are thin shims (`export const load = (event) => editLoad(event, cairn)`).
3
3
  //
4
4
  // SvelteKit's filesystem routing requires the route *files* to live in each site's
5
5
  // `src/routes/`, but their bodies are identical across sites — only the adapter differs.
6
6
  // These functions take the SvelteKit event (typed structurally, to avoid depending on the
7
7
  // site-generated `App.*` ambient types) plus the site `CairnAdapter`, and throw
8
- // `redirect`/`error` from `@sveltejs/kit`. That `@sveltejs/kit` is a peer dependency so the
9
- // thrown objects share class identity with the host's runtime (else the redirect 500s).
10
- import { redirect, error, type Cookies } from '@sveltejs/kit';
11
- import type { KVNamespace } from '@cloudflare/workers-types';
8
+ // `redirect`/`error` from `@sveltejs/kit` (a peer dependency, so the thrown objects share
9
+ // class identity with the host's runtime else the redirect 500s). Auth/session/manage-editors
10
+ // logic lives under `@glw907/cairn-cms/auth`; this module is content-only (list/edit/save).
11
+ import { redirect, error } from '@sveltejs/kit';
12
12
  import matter from 'gray-matter';
13
- import {
14
- createMagicLink,
15
- redeemMagicToken,
16
- createSession,
17
- lookupEditor,
18
- listEditors,
19
- setEditor,
20
- removeEditor,
21
- SESSION_COOKIE,
22
- SESSION_MAX_AGE,
23
- type Editor,
24
- type Role,
25
- } from '../auth';
26
- import { sendMagicLink, type EmailSender } from '../email';
13
+ import type { CairnUser } from '../auth/guard';
27
14
  import { listMarkdown, readRaw, commitFile, installationToken, type RepoFile } from '../github';
28
15
  import { serializeMarkdown } from '../content';
29
16
  import { findCollection, frontmatterFromForm, type CairnAdapter, type CairnField } from '../adapter';
30
17
 
31
- /** The `platform.env` bindings the admin routes read. All optional — the handlers guard. */
18
+ /** The `platform.env` bindings the content routes read. All optional — the handlers guard. */
32
19
  export interface AdminEnv {
33
- AUTH_KV?: KVNamespace;
34
- MAGIC_LINK_SECRET?: string;
35
- SESSION_SECRET?: string;
36
- EMAIL?: EmailSender;
37
- /** Overrides `url.origin` for the magic-link base (set in dev, unset in prod). */
38
- PUBLIC_ORIGIN?: string;
39
20
  GITHUB_APP_ID?: string;
40
21
  GITHUB_APP_INSTALLATION_ID?: string;
41
22
  GITHUB_APP_PRIVATE_KEY_B64?: string;
@@ -45,12 +26,33 @@ interface PlatformEvent {
45
26
  platform?: { env?: AdminEnv };
46
27
  }
47
28
 
48
- const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
29
+ /**
30
+ * Mint a GitHub App installation token for *reads* when the App is configured, else undefined
31
+ * (reads then fall back to anonymous). Authenticated reads get the 5000/hr limit; anonymous
32
+ * reads share GitHub's 60/hr-per-IP budget across Cloudflare's egress IPs, so they 403 in prod.
33
+ * A mint failure degrades gracefully to anonymous rather than 500ing — unlike the commit path,
34
+ * where a missing App is fatal, a read can still succeed unauthenticated.
35
+ */
36
+ async function readToken(env: AdminEnv | undefined): Promise<string | undefined> {
37
+ if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
38
+ return undefined;
39
+ }
40
+ try {
41
+ return await installationToken({
42
+ appId: env.GITHUB_APP_ID,
43
+ installationId: env.GITHUB_APP_INSTALLATION_ID,
44
+ privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
45
+ });
46
+ } catch (err) {
47
+ console.error('read token mint failed; falling back to anonymous read:', err);
48
+ return undefined;
49
+ }
50
+ }
49
51
 
50
52
  // ── /admin layout ──────────────────────────────────────────────────────────
51
53
 
52
54
  export interface AdminLayoutData {
53
- editor: Editor | null;
55
+ user: CairnUser | null;
54
56
  siteName: string;
55
57
  pathname: string;
56
58
  }
@@ -63,10 +65,10 @@ export interface AdminLayoutData {
63
65
  * package); reading `event.url` here also opts the layout load into rerunning on navigation.
64
66
  */
65
67
  export function adminLayoutLoad(
66
- event: { locals: { editor: Editor | null }; url: URL },
68
+ event: { locals: { user: CairnUser | null }; url: URL },
67
69
  adapter: CairnAdapter,
68
70
  ): AdminLayoutData {
69
- return { editor: event.locals.editor, siteName: adapter.siteName, pathname: event.url.pathname };
71
+ return { user: event.locals.user, siteName: adapter.siteName, pathname: event.url.pathname };
70
72
  }
71
73
 
72
74
  // ── /admin (content list) ────────────────────────────────────────────────────
@@ -79,11 +81,15 @@ export interface AdminCollectionList {
79
81
  }
80
82
 
81
83
  /** List every collection's markdown files. A failed listing degrades to an inline error. */
82
- export async function adminListLoad(adapter: CairnAdapter): Promise<{ collections: AdminCollectionList[] }> {
84
+ export async function adminListLoad(
85
+ event: PlatformEvent,
86
+ adapter: CairnAdapter,
87
+ ): Promise<{ collections: AdminCollectionList[] }> {
88
+ const token = await readToken(event.platform?.env);
83
89
  const collections = await Promise.all(
84
90
  adapter.collections.map(async ({ type, label, dir }): Promise<AdminCollectionList> => {
85
91
  try {
86
- return { type, label, files: await listMarkdown(adapter.backend, dir) };
92
+ return { type, label, files: await listMarkdown(adapter.backend, dir, token) };
87
93
  } catch (err) {
88
94
  // A failed listing (rate limit, network) shouldn't 500 the whole admin.
89
95
  return { type, label, files: [], error: err instanceof Error ? err.message : 'Failed to load' };
@@ -93,20 +99,6 @@ export async function adminListLoad(adapter: CairnAdapter): Promise<{ collection
93
99
  return { collections };
94
100
  }
95
101
 
96
- // ── /admin/login ──────────────────────────────────────────────────────────────
97
-
98
- export interface LoginData {
99
- sent: boolean;
100
- error: string | null;
101
- }
102
-
103
- export function loginLoad(event: { url: URL }): LoginData {
104
- return {
105
- sent: event.url.searchParams.get('sent') === '1',
106
- error: event.url.searchParams.get('error'),
107
- };
108
- }
109
-
110
102
  // ── /admin/edit/[type]/[id] ─────────────────────────────────────────────────
111
103
 
112
104
  export interface EditData {
@@ -123,15 +115,15 @@ export interface EditData {
123
115
  }
124
116
 
125
117
  export async function editLoad(
126
- event: { params: { type: string; id: string }; url: URL },
118
+ event: PlatformEvent & { params: { type: string; id: string }; url: URL },
127
119
  adapter: CairnAdapter,
128
120
  ): Promise<EditData> {
129
121
  const collection = findCollection(adapter, event.params.type);
130
122
  if (!collection) throw error(404, 'Unknown collection');
131
123
 
132
- // Anonymous read — repos are public; the GitHub App token is commit-only (see saveCommit).
124
+ const token = await readToken(event.platform?.env);
133
125
  const path = `${collection.dir}/${event.params.id}.md`;
134
- const raw = await readRaw(adapter.backend, path);
126
+ const raw = await readRaw(adapter.backend, path, token);
135
127
  if (raw === null) throw error(404, 'Content not found');
136
128
 
137
129
  // Split frontmatter from body server-side; the editor form binds to the frontmatter and
@@ -152,92 +144,14 @@ export async function editLoad(
152
144
  };
153
145
  }
154
146
 
155
- // ── /admin/auth/request (POST) ──────────────────────────────────────────────
156
-
157
- export async function authRequest(
158
- event: PlatformEvent & { request: Request; url: URL },
159
- adapter: CairnAdapter,
160
- ): Promise<never> {
161
- const env = event.platform?.env;
162
- if (!env?.AUTH_KV || !env.MAGIC_LINK_SECRET || !env.EMAIL) {
163
- throw redirect(303, '/admin/login?error=config');
164
- }
165
-
166
- const form = await event.request.formData();
167
- const email = String(form.get('email') ?? '').trim().toLowerCase();
168
- if (!EMAIL_RE.test(email)) {
169
- throw redirect(303, '/admin/login?error=invalid');
170
- }
171
-
172
- const editor = await lookupEditor(email, env.AUTH_KV);
173
- if (!editor) {
174
- throw redirect(303, '/admin/login?error=denied');
175
- }
176
-
177
- const token = await createMagicLink(email, env.MAGIC_LINK_SECRET, env.AUTH_KV);
178
- // PUBLIC_ORIGIN overrides url.origin for local dev (where wrangler's custom-domain
179
- // route makes url.origin the production host); unset in prod → url.origin is correct.
180
- const origin = env.PUBLIC_ORIGIN || event.url.origin;
181
- const link = `${origin}/admin/auth/callback?token=${encodeURIComponent(token)}`;
182
- try {
183
- await sendMagicLink(env.EMAIL, email, link, adapter.siteName, adapter.sender);
184
- } catch (err) {
185
- console.error('magic-link send failed:', err);
186
- throw redirect(303, '/admin/login?error=config');
187
- }
188
-
189
- throw redirect(303, '/admin/login?sent=1');
190
- }
191
-
192
- // ── /admin/auth/callback (GET) ──────────────────────────────────────────────
193
-
194
- export async function authCallback(
195
- event: PlatformEvent & { url: URL; cookies: Cookies },
196
- ): Promise<never> {
197
- const env = event.platform?.env;
198
- if (!env?.AUTH_KV || !env.MAGIC_LINK_SECRET || !env.SESSION_SECRET) {
199
- throw redirect(303, '/admin/login?error=config');
200
- }
201
-
202
- const token = event.url.searchParams.get('token') ?? '';
203
- const email = await redeemMagicToken(token, env.MAGIC_LINK_SECRET, env.AUTH_KV);
204
- if (!email) {
205
- throw redirect(303, '/admin/login?error=expired');
206
- }
207
-
208
- // Re-check the allowlist at redemption — membership may have changed since issue.
209
- const editor = await lookupEditor(email, env.AUTH_KV);
210
- if (!editor) {
211
- throw redirect(303, '/admin/login?error=denied');
212
- }
213
-
214
- const session = await createSession(editor, env.SESSION_SECRET);
215
- event.cookies.set(SESSION_COOKIE, session, {
216
- path: '/',
217
- httpOnly: true,
218
- secure: event.url.protocol === 'https:',
219
- sameSite: 'lax',
220
- maxAge: SESSION_MAX_AGE,
221
- });
222
-
223
- throw redirect(303, '/admin');
224
- }
225
-
226
- // ── /admin/auth/logout (POST) ───────────────────────────────────────────────
227
-
228
- export function logout(event: { cookies: Cookies }): never {
229
- event.cookies.delete(SESSION_COOKIE, { path: '/' });
230
- throw redirect(303, '/admin/login');
231
- }
232
-
233
147
  // ── /admin/save (POST) ──────────────────────────────────────────────────────
234
148
 
235
149
  export async function saveCommit(
236
- event: PlatformEvent & { request: Request; locals: { editor: Editor | null } },
150
+ event: PlatformEvent & { request: Request; locals: { user: CairnUser | null } },
237
151
  adapter: CairnAdapter,
238
152
  ): Promise<never> {
239
- const editor = event.locals.editor;
240
- if (!editor) throw error(401, 'Not signed in');
153
+ const user = event.locals.user;
154
+ if (!user) throw error(401, 'Not signed in');
241
155
 
242
156
  const env = event.platform?.env;
243
157
  if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
@@ -272,105 +186,9 @@ export async function saveCommit(
272
186
  adapter.backend,
273
187
  `${collection.dir}/${id}.md`,
274
188
  markdown,
275
- { message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: editor.name, email: editor.email } },
189
+ { message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: user.name, email: user.email } },
276
190
  token,
277
191
  );
278
192
 
279
193
  throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
280
194
  }
281
-
282
- // ── /admin/admins (owner-gated editor management) ────────────────────────────
283
-
284
- /**
285
- * The privilege-escalation gate for the manage-admins surface: only `owner`s may load it or
286
- * run its actions. Returns the acting owner (so callers can guard self-targeted mutations).
287
- */
288
- function requireOwner(event: { locals: { editor: Editor | null } }): Editor {
289
- const editor = event.locals.editor;
290
- if (!editor) throw error(401, 'Not signed in');
291
- if (editor.role !== 'owner') throw error(403, 'Owner access required');
292
- return editor;
293
- }
294
-
295
- /** Resolve AUTH_KV or fail loudly — the management surface is useless without it. */
296
- function ownerKv(event: PlatformEvent): KVNamespace {
297
- const kv = event.platform?.env?.AUTH_KV;
298
- if (!kv) throw error(500, 'Editor allowlist is not configured');
299
- return kv;
300
- }
301
-
302
- export interface AdminsData {
303
- admins: Editor[];
304
- /** Acting owner's email, so the UI can disable self-targeted remove/demote. */
305
- self: string;
306
- saved: boolean;
307
- error: string | null;
308
- }
309
-
310
- /** List the allowlist for the manage-admins page. Owner-only. */
311
- export async function adminsLoad(
312
- event: PlatformEvent & { locals: { editor: Editor | null }; url: URL },
313
- ): Promise<AdminsData> {
314
- const owner = requireOwner(event);
315
- const admins = await listEditors(ownerKv(event));
316
- return {
317
- admins,
318
- self: owner.email,
319
- saved: event.url.searchParams.get('saved') === '1',
320
- error: event.url.searchParams.get('error'),
321
- };
322
- }
323
-
324
- type AdminsActionEvent = PlatformEvent & {
325
- request: Request;
326
- locals: { editor: Editor | null };
327
- };
328
-
329
- function parseRole(value: unknown): Role {
330
- return value === 'owner' ? 'owner' : 'editor';
331
- }
332
-
333
- /** Add (or update) an allowlist entry. Owner-only. */
334
- export async function addAdmin(event: AdminsActionEvent): Promise<never> {
335
- requireOwner(event);
336
- const kv = ownerKv(event);
337
- const form = await event.request.formData();
338
- const email = String(form.get('email') ?? '').trim().toLowerCase();
339
- const name = String(form.get('name') ?? '').trim();
340
- if (!EMAIL_RE.test(email) || !name) {
341
- throw redirect(303, `/admin/admins?error=${encodeURIComponent('Enter a valid email and name')}`);
342
- }
343
- await setEditor(email, name, parseRole(form.get('role')), kv);
344
- throw redirect(303, '/admin/admins?saved=1');
345
- }
346
-
347
- /** Remove an allowlist entry. Owner-only; owners can't remove themselves (anti-lockout). */
348
- export async function removeAdmin(event: AdminsActionEvent): Promise<never> {
349
- const owner = requireOwner(event);
350
- const kv = ownerKv(event);
351
- const form = await event.request.formData();
352
- const email = String(form.get('email') ?? '').trim().toLowerCase();
353
- if (email === owner.email) {
354
- throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't remove yourself")}`);
355
- }
356
- await removeEditor(email, kv);
357
- throw redirect(303, '/admin/admins?saved=1');
358
- }
359
-
360
- /** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
361
- export async function setAdminRole(event: AdminsActionEvent): Promise<never> {
362
- const owner = requireOwner(event);
363
- const kv = ownerKv(event);
364
- const form = await event.request.formData();
365
- const email = String(form.get('email') ?? '').trim().toLowerCase();
366
- const role = parseRole(form.get('role'));
367
- if (email === owner.email && role !== 'owner') {
368
- throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't demote yourself")}`);
369
- }
370
- const existing = await lookupEditor(email, kv);
371
- if (!existing) {
372
- throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
373
- }
374
- await setEditor(email, existing.name, role, kv);
375
- throw redirect(303, '/admin/admins?saved=1');
376
- }
package/dist/auth.d.ts DELETED
@@ -1,25 +0,0 @@
1
- import type { KVNamespace } from '@cloudflare/workers-types';
2
- /** Two-tier, per-site role. `owner`s manage the editor allowlist; `editor`s only edit content. */
3
- export type Role = 'owner' | 'editor';
4
- export interface Editor {
5
- email: string;
6
- name: string;
7
- role: Role;
8
- }
9
- export declare const SESSION_COOKIE = "cairn_session";
10
- export declare const SESSION_MAX_AGE: number;
11
- /** Issue a single-use magic-link token and register its nonce in KV with a TTL. */
12
- export declare function createMagicLink(email: string, secret: string, kv: KVNamespace): Promise<string>;
13
- /** Redeem a magic-link token: verify, check expiry, then consume the KV nonce (single use). */
14
- export declare function redeemMagicToken(token: string, secret: string, kv: KVNamespace): Promise<string | null>;
15
- export declare function createSession(editor: Editor, secret: string): Promise<string>;
16
- export declare function verifySession(token: string, secret: string): Promise<Editor | null>;
17
- /** Look up an editor in the KV allowlist (`editor:<email>` → `{name, role}`). */
18
- export declare function lookupEditor(email: string, kv: KVNamespace): Promise<Editor | null>;
19
- /** Every allowlisted editor, sorted by email — the manage-admins list. */
20
- export declare function listEditors(kv: KVNamespace): Promise<Editor[]>;
21
- /** Add or update an allowlist entry (JSON value). Email is normalized. */
22
- export declare function setEditor(email: string, name: string, role: Role, kv: KVNamespace): Promise<void>;
23
- /** Remove an allowlist entry. */
24
- export declare function removeEditor(email: string, kv: KVNamespace): Promise<void>;
25
- //# sourceMappingURL=auth.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/lib/auth.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAG7D,kGAAkG;AAClG,MAAM,MAAM,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEtC,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,IAAI,CAAC;CACZ;AAED,eAAO,MAAM,cAAc,kBAAkB,CAAC;AAK9C,eAAO,MAAM,eAAe,QAAsB,CAAC;AA8DnD,mFAAmF;AACnF,wBAAsB,eAAe,CACnC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,MAAM,CAAC,CAMjB;AAED,+FAA+F;AAC/F,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOxB;AAMD,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGnF;AAED,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKzF;AAyBD,iFAAiF;AACjF,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKzF;AAED,0EAA0E;AAC1E,wBAAsB,WAAW,CAAC,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CASpE;AAED,0EAA0E;AAC1E,wBAAsB,SAAS,CAC7B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,IAAI,EACV,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,IAAI,CAAC,CAEf;AAED,iCAAiC;AACjC,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAEhF"}
package/dist/auth.js DELETED
@@ -1,132 +0,0 @@
1
- // cairn-core: magic-link auth + signed sessions.
2
- //
3
- // Generic across sites — no ecnordic specifics here. Crypto is Web Crypto (HMAC-SHA256)
4
- // so it runs unchanged on Cloudflare Workers under nodejs_compat. Single-use enforcement
5
- // for magic links rides on a KV nonce; signature + expiry are self-contained in the token.
6
- import { bytesToB64url } from './utils';
7
- export const SESSION_COOKIE = 'cairn_session';
8
- const MAGIC_TTL_SECONDS = 600; // 10 minutes
9
- const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
10
- export const SESSION_MAX_AGE = SESSION_TTL_SECONDS;
11
- const encoder = new TextEncoder();
12
- const decoder = new TextDecoder();
13
- function b64urlToBytes(value) {
14
- const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
15
- const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
16
- return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
17
- }
18
- // TextEncoder/atob produce Uint8Arrays whose generic buffer type no longer satisfies
19
- // Web Crypto's BufferSource under strict lib types; hand the underlying ArrayBuffer over.
20
- function buf(bytes) {
21
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
22
- }
23
- async function hmacKey(secret) {
24
- return crypto.subtle.importKey('raw', buf(encoder.encode(secret)), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
25
- }
26
- /** Sign an arbitrary JSON payload as `<base64url(payload)>.<base64url(hmac)>`. */
27
- async function signToken(data, secret) {
28
- const payload = bytesToB64url(encoder.encode(JSON.stringify(data)));
29
- const key = await hmacKey(secret);
30
- const sig = await crypto.subtle.sign('HMAC', key, buf(encoder.encode(payload)));
31
- return `${payload}.${bytesToB64url(new Uint8Array(sig))}`;
32
- }
33
- /** Verify signature (constant-time via subtle.verify) and parse the payload, or null. */
34
- async function verifyToken(token, secret) {
35
- const dot = token.indexOf('.');
36
- if (dot < 0)
37
- return null;
38
- const payload = token.slice(0, dot);
39
- const sig = token.slice(dot + 1);
40
- const key = await hmacKey(secret);
41
- let ok = false;
42
- try {
43
- ok = await crypto.subtle.verify('HMAC', key, buf(b64urlToBytes(sig)), buf(encoder.encode(payload)));
44
- }
45
- catch {
46
- return null;
47
- }
48
- if (!ok)
49
- return null;
50
- try {
51
- return JSON.parse(decoder.decode(b64urlToBytes(payload)));
52
- }
53
- catch {
54
- return null;
55
- }
56
- }
57
- /** Issue a single-use magic-link token and register its nonce in KV with a TTL. */
58
- export async function createMagicLink(email, secret, kv) {
59
- const nonce = bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
60
- const exp = Date.now() + MAGIC_TTL_SECONDS * 1000;
61
- const token = await signToken({ email, exp, nonce }, secret);
62
- await kv.put(`ml:${nonce}`, email, { expirationTtl: MAGIC_TTL_SECONDS });
63
- return token;
64
- }
65
- /** Redeem a magic-link token: verify, check expiry, then consume the KV nonce (single use). */
66
- export async function redeemMagicToken(token, secret, kv) {
67
- const payload = await verifyToken(token, secret);
68
- if (!payload || Date.now() > payload.exp)
69
- return null;
70
- const stored = await kv.get(`ml:${payload.nonce}`);
71
- if (stored !== payload.email)
72
- return null;
73
- await kv.delete(`ml:${payload.nonce}`); // burn it — single use
74
- return payload.email;
75
- }
76
- export async function createSession(editor, secret) {
77
- const exp = Date.now() + SESSION_TTL_SECONDS * 1000;
78
- return signToken({ ...editor, exp }, secret);
79
- }
80
- export async function verifySession(token, secret) {
81
- const payload = await verifyToken(token, secret);
82
- if (!payload || Date.now() > payload.exp)
83
- return null;
84
- // Sessions signed before roles existed carry no `role` — treat them as plain editors.
85
- return { email: payload.email, name: payload.name, role: payload.role ?? 'editor' };
86
- }
87
- const KEY_PREFIX = 'editor:';
88
- /**
89
- * Decode a stored allowlist value into name + role. Current entries are JSON
90
- * (`{"name","role"}`); legacy entries are a bare display-name string, read as `editor`
91
- * so the allowlist migrates lazily — re-saving an entry upgrades it to the JSON shape.
92
- */
93
- function parseEditorValue(raw) {
94
- try {
95
- const parsed = JSON.parse(raw);
96
- if (parsed && typeof parsed.name === 'string') {
97
- return { name: parsed.name, role: parsed.role === 'owner' ? 'owner' : 'editor' };
98
- }
99
- }
100
- catch {
101
- // Not JSON — legacy bare display-name; treat as editor.
102
- }
103
- return { name: raw, role: 'editor' };
104
- }
105
- function serializeEditorValue(name, role) {
106
- return JSON.stringify({ name, role });
107
- }
108
- /** Look up an editor in the KV allowlist (`editor:<email>` → `{name, role}`). */
109
- export async function lookupEditor(email, kv) {
110
- const normalized = email.trim().toLowerCase();
111
- const raw = await kv.get(`${KEY_PREFIX}${normalized}`);
112
- if (raw === null)
113
- return null;
114
- return { email: normalized, ...parseEditorValue(raw) };
115
- }
116
- /** Every allowlisted editor, sorted by email — the manage-admins list. */
117
- export async function listEditors(kv) {
118
- const { keys } = await kv.list({ prefix: KEY_PREFIX });
119
- const editors = await Promise.all(keys.map(async ({ name: key }) => {
120
- const raw = (await kv.get(key)) ?? '';
121
- return { email: key.slice(KEY_PREFIX.length), ...parseEditorValue(raw) };
122
- }));
123
- return editors.sort((a, b) => a.email.localeCompare(b.email));
124
- }
125
- /** Add or update an allowlist entry (JSON value). Email is normalized. */
126
- export async function setEditor(email, name, role, kv) {
127
- await kv.put(`${KEY_PREFIX}${email.trim().toLowerCase()}`, serializeEditorValue(name, role));
128
- }
129
- /** Remove an allowlist entry. */
130
- export async function removeEditor(email, kv) {
131
- await kv.delete(`${KEY_PREFIX}${email.trim().toLowerCase()}`);
132
- }