@glw907/cairn-cms 0.37.1 → 0.40.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 (69) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +6 -5
  3. package/dist/components/AdminLayout.svelte +53 -0
  4. package/dist/components/ComponentInsertDialog.svelte +27 -13
  5. package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
  6. package/dist/components/ConceptList.svelte +13 -3
  7. package/dist/components/DeleteDialog.svelte +18 -7
  8. package/dist/components/DeleteDialog.svelte.d.ts +11 -1
  9. package/dist/components/EditPage.svelte +575 -70
  10. package/dist/components/EditPage.svelte.d.ts +8 -1
  11. package/dist/components/EditorToolbar.svelte +202 -29
  12. package/dist/components/EditorToolbar.svelte.d.ts +12 -4
  13. package/dist/components/LinkPicker.svelte +14 -6
  14. package/dist/components/LinkPicker.svelte.d.ts +9 -2
  15. package/dist/components/LoginPage.svelte +16 -4
  16. package/dist/components/LoginPage.svelte.d.ts +3 -1
  17. package/dist/components/MarkdownEditor.svelte +80 -34
  18. package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
  19. package/dist/components/MarkdownHelpDialog.svelte +58 -0
  20. package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
  21. package/dist/components/RenameDialog.svelte +13 -4
  22. package/dist/components/RenameDialog.svelte.d.ts +9 -1
  23. package/dist/components/WebLinkDialog.svelte +89 -0
  24. package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
  25. package/dist/components/cairn-admin.css +353 -4
  26. package/dist/components/editor-highlight.d.ts +9 -0
  27. package/dist/components/editor-highlight.js +62 -0
  28. package/dist/components/markdown-directives.d.ts +7 -0
  29. package/dist/components/markdown-directives.js +22 -0
  30. package/dist/components/markdown-format.d.ts +1 -1
  31. package/dist/components/markdown-format.js +91 -12
  32. package/dist/content/pending.d.ts +9 -0
  33. package/dist/content/pending.js +24 -0
  34. package/dist/diagnostics/conditions.js +16 -0
  35. package/dist/email.d.ts +20 -1
  36. package/dist/email.js +25 -0
  37. package/dist/github/branches.d.ts +11 -0
  38. package/dist/github/branches.js +75 -0
  39. package/dist/log/events.d.ts +1 -1
  40. package/dist/sveltekit/auth-routes.d.ts +16 -3
  41. package/dist/sveltekit/auth-routes.js +47 -28
  42. package/dist/sveltekit/content-routes.d.ts +22 -1
  43. package/dist/sveltekit/content-routes.js +312 -72
  44. package/dist/sveltekit/index.d.ts +1 -1
  45. package/package.json +3 -2
  46. package/src/lib/components/AdminLayout.svelte +53 -0
  47. package/src/lib/components/ComponentInsertDialog.svelte +27 -13
  48. package/src/lib/components/ConceptList.svelte +13 -3
  49. package/src/lib/components/DeleteDialog.svelte +18 -7
  50. package/src/lib/components/EditPage.svelte +575 -70
  51. package/src/lib/components/EditorToolbar.svelte +202 -29
  52. package/src/lib/components/LinkPicker.svelte +14 -6
  53. package/src/lib/components/LoginPage.svelte +16 -4
  54. package/src/lib/components/MarkdownEditor.svelte +80 -34
  55. package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
  56. package/src/lib/components/RenameDialog.svelte +13 -4
  57. package/src/lib/components/WebLinkDialog.svelte +89 -0
  58. package/src/lib/components/cairn-admin.css +26 -4
  59. package/src/lib/components/editor-highlight.ts +67 -0
  60. package/src/lib/components/markdown-directives.ts +23 -0
  61. package/src/lib/components/markdown-format.ts +118 -13
  62. package/src/lib/content/pending.ts +24 -0
  63. package/src/lib/diagnostics/conditions.ts +16 -0
  64. package/src/lib/email.ts +31 -1
  65. package/src/lib/github/branches.ts +83 -0
  66. package/src/lib/log/events.ts +3 -0
  67. package/src/lib/sveltekit/auth-routes.ts +59 -29
  68. package/src/lib/sveltekit/content-routes.ts +391 -73
  69. package/src/lib/sveltekit/index.ts +1 -1
@@ -13,7 +13,7 @@ import {
13
13
  sessionCookieName,
14
14
  } from '../auth/crypto.js';
15
15
  import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
16
- import { buildMagicLinkMessage, cloudflareSend, type AuthBranding, type SendMagicLink } from '../email.js';
16
+ import { buildMagicLinkMessage, cloudflareSend, emailSendFailure, errorCode, type AuthBranding, type SendMagicLink } from '../email.js';
17
17
  import { issueCsrfToken } from './csrf.js';
18
18
  import { log } from '../log/index.js';
19
19
  import type { RequestContext } from './types.js';
@@ -23,15 +23,37 @@ export interface AuthRoutesConfig {
23
23
  send?: SendMagicLink;
24
24
  }
25
25
 
26
+ /**
27
+ * The request-action result. `status` is the discriminant; `sent` is kept for a site rendering its
28
+ * own form against `form.sent`, so the field is additive. The neutral and send-ok paths return the
29
+ * identical `{ status: 'sent', sent: true }`, so the common case never leaks allowlist membership.
30
+ */
31
+ export type RequestResult =
32
+ | { status: 'sent'; sent: true }
33
+ | { status: 'send_error'; sent: false }
34
+ | { status: 'throttled'; sent: false };
35
+
36
+ /**
37
+ * The loggable form of a send failure. The engine's own senders throw clean errors, but `send` is
38
+ * an injection seam, and a custom sender's thrown error may embed the failed message and with it
39
+ * the magic link. Scrub any token query value and cap the length, so the documented "records never
40
+ * carry a token" guarantee holds for the seam too.
41
+ */
42
+ function scrubSendError(err: unknown): string {
43
+ return String(err)
44
+ .replace(/([?&]token=)[^&\s"'<]+/g, '$1[redacted]')
45
+ .slice(0, 300);
46
+ }
47
+
26
48
  export function createAuthRoutes(config: AuthRoutesConfig) {
27
49
  const send = config.send ?? cloudflareSend;
28
50
 
29
51
  /**
30
- * POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token
31
- * and emails the confirmation link. The response is identical whether or not the email is
32
- * allow-listed, so the endpoint never leaks membership.
52
+ * POST /admin/auth/request. Looks the email up in the allowlist; on a match, issues a token,
53
+ * emails the confirmation link, and awaits the send so the status reflects its outcome. The
54
+ * neutral and send-ok responses are identical, so the common case never leaks membership.
33
55
  */
34
- async function requestAction(event: RequestContext): Promise<{ sent: true }> {
56
+ async function requestAction(event: RequestContext): Promise<RequestResult> {
35
57
  const env = event.platform?.env ?? {};
36
58
  const origin = requireOrigin(env);
37
59
  const db = requireDb(env);
@@ -43,31 +65,39 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
43
65
  log.info('auth.link.requested', { email: email.slice(0, 320) });
44
66
 
45
67
  const editor = email ? await findEditor(db, email) : null;
46
- if (editor) {
47
- const now = Date.now();
48
- // Per-email cooldown: skip the reissue and send when a token for this email was issued within
49
- // the window, so the endpoint cannot flood an editor's inbox. The response is unchanged, so
50
- // the non-leak property holds.
51
- if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
52
- const token = generateToken();
53
- await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
54
- log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
55
- const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
56
- // The token row is the security-critical write the email depends on, so it is awaited. The
57
- // send is a post-response side effect, handed to waitUntil so a slow email provider does not
58
- // hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
59
- // failure is logged so observability survives a backgrounded send.
60
- const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch(
61
- (err) => log.error('auth.link.send_failed', { email, error: String(err) }),
62
- );
63
- // adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
64
- // deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
65
- const ctx = event.platform?.ctx ?? event.platform?.context;
66
- if (ctx?.waitUntil) ctx.waitUntil(sending);
67
- else await sending;
68
- }
68
+ // Non-editor: byte-identical to the editor send-ok path, so the response body never leaks
69
+ // membership. Response timing still differs (the editor path awaits the send), the side-channel
70
+ // the design accepts as strictly weaker than the explicit throttled signal below.
71
+ if (!editor) return { status: 'sent', sent: true };
72
+
73
+ const now = Date.now();
74
+ // Per-email cooldown: an editor who requested within the window gets the throttled signal rather
75
+ // than a second email. This reveals editor membership, the deliberate relaxed-non-leak posture.
76
+ if (await recentlyIssued(db, email, now - SEND_COOLDOWN_MS)) {
77
+ return { status: 'throttled', sent: false };
78
+ }
79
+
80
+ const token = generateToken();
81
+ await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
82
+ log.info('auth.token.minted', { email, expiresAt: now + TOKEN_TTL_MS });
83
+ const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
84
+ // The token row is the security-critical write the email depends on, so it is awaited first.
85
+ // The send is now awaited too (no waitUntil backgrounding), so its outcome drives the response:
86
+ // confirm the link went out before telling an editor to check their inbox. The cost is one
87
+ // email-API round trip on the login POST, the right trade for a login flow.
88
+ try {
89
+ await send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link }));
90
+ } catch (err) {
91
+ // Map the binding failure to its registered condition (carried as a CairnError with the
92
+ // original as cause), and log the greppable code plus the conditionId so the next onboarding
93
+ // gap reads straight to its fix. The editor sees only a generic message, never this detail.
94
+ const failure = emailSendFailure(err);
95
+ log.error('auth.link.send_failed', { email, error: scrubSendError(err), code: errorCode(err), conditionId: failure.conditionId });
96
+ // A plain 200 with a status field, not fail(): the result stays one uniform union for the
97
+ // page, and the failure is already observable through the error-level log record.
98
+ return { status: 'send_error', sent: false };
69
99
  }
70
- return { sent: true };
100
+ return { status: 'sent', sent: true };
71
101
  }
72
102
 
73
103
  /** GET /admin/login. Public. Carries the site name, an optional `?error`, and the CSRF token. */