@glw907/cairn-cms 0.14.0 → 0.18.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 (96) hide show
  1. package/dist/auth/crypto.d.ts +8 -2
  2. package/dist/auth/crypto.d.ts.map +1 -1
  3. package/dist/auth/crypto.js +12 -2
  4. package/dist/auth/store.d.ts +2 -0
  5. package/dist/auth/store.d.ts.map +1 -1
  6. package/dist/auth/store.js +17 -5
  7. package/dist/components/EditPage.svelte +13 -8
  8. package/dist/components/EditPage.svelte.d.ts +3 -1
  9. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  10. package/dist/content/compose.d.ts.map +1 -1
  11. package/dist/content/compose.js +1 -0
  12. package/dist/content/links.d.ts +14 -0
  13. package/dist/content/links.d.ts.map +1 -0
  14. package/dist/content/links.js +41 -0
  15. package/dist/content/manifest.d.ts +55 -0
  16. package/dist/content/manifest.d.ts.map +1 -0
  17. package/dist/content/manifest.js +98 -0
  18. package/dist/content/types.d.ts +10 -1
  19. package/dist/content/types.d.ts.map +1 -1
  20. package/dist/delivery/content-index.d.ts.map +1 -1
  21. package/dist/delivery/content-index.js +11 -9
  22. package/dist/delivery/feeds.d.ts +1 -1
  23. package/dist/delivery/feeds.d.ts.map +1 -1
  24. package/dist/delivery/feeds.js +31 -16
  25. package/dist/delivery/index.d.ts +1 -0
  26. package/dist/delivery/index.d.ts.map +1 -1
  27. package/dist/delivery/index.js +1 -0
  28. package/dist/delivery/manifest.d.ts +13 -0
  29. package/dist/delivery/manifest.d.ts.map +1 -0
  30. package/dist/delivery/manifest.js +31 -0
  31. package/dist/delivery/site-indexes.d.ts.map +1 -1
  32. package/dist/delivery/site-indexes.js +9 -1
  33. package/dist/env.d.ts.map +1 -1
  34. package/dist/env.js +14 -0
  35. package/dist/github/repo.d.ts +21 -0
  36. package/dist/github/repo.d.ts.map +1 -1
  37. package/dist/github/repo.js +79 -0
  38. package/dist/github/signing.d.ts +12 -0
  39. package/dist/github/signing.d.ts.map +1 -1
  40. package/dist/github/signing.js +22 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -0
  44. package/dist/render/pipeline.d.ts +14 -1
  45. package/dist/render/pipeline.d.ts.map +1 -1
  46. package/dist/render/pipeline.js +22 -3
  47. package/dist/render/resolve-links.d.ts +8 -0
  48. package/dist/render/resolve-links.d.ts.map +1 -0
  49. package/dist/render/resolve-links.js +36 -0
  50. package/dist/render/sanitize-schema.d.ts +20 -0
  51. package/dist/render/sanitize-schema.d.ts.map +1 -0
  52. package/dist/render/sanitize-schema.js +57 -0
  53. package/dist/sveltekit/auth-routes.d.ts.map +1 -1
  54. package/dist/sveltekit/auth-routes.js +29 -11
  55. package/dist/sveltekit/content-routes.d.ts +3 -0
  56. package/dist/sveltekit/content-routes.d.ts.map +1 -1
  57. package/dist/sveltekit/content-routes.js +31 -4
  58. package/dist/sveltekit/guard.d.ts +1 -1
  59. package/dist/sveltekit/guard.d.ts.map +1 -1
  60. package/dist/sveltekit/guard.js +25 -10
  61. package/dist/sveltekit/nav-routes.js +2 -2
  62. package/dist/sveltekit/public-routes.d.ts +2 -0
  63. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  64. package/dist/sveltekit/public-routes.js +3 -2
  65. package/dist/sveltekit/types.d.ts +6 -0
  66. package/dist/sveltekit/types.d.ts.map +1 -1
  67. package/package.json +3 -2
  68. package/src/lib/auth/crypto.ts +14 -2
  69. package/src/lib/auth/store.ts +18 -5
  70. package/src/lib/components/EditPage.svelte +13 -8
  71. package/src/lib/content/compose.ts +1 -0
  72. package/src/lib/content/links.ts +48 -0
  73. package/src/lib/content/manifest.ts +138 -0
  74. package/src/lib/content/types.ts +10 -3
  75. package/src/lib/delivery/content-index.ts +12 -9
  76. package/src/lib/delivery/feeds.ts +34 -19
  77. package/src/lib/delivery/index.ts +1 -0
  78. package/src/lib/delivery/manifest.ts +38 -0
  79. package/src/lib/delivery/site-indexes.ts +13 -1
  80. package/src/lib/env.ts +13 -0
  81. package/src/lib/github/repo.ts +103 -0
  82. package/src/lib/github/signing.ts +32 -0
  83. package/src/lib/index.ts +16 -0
  84. package/src/lib/render/pipeline.ts +33 -3
  85. package/src/lib/render/resolve-links.ts +42 -0
  86. package/src/lib/render/sanitize-schema.ts +66 -0
  87. package/src/lib/sveltekit/auth-routes.ts +30 -11
  88. package/src/lib/sveltekit/content-routes.ts +38 -6
  89. package/src/lib/sveltekit/guard.ts +25 -10
  90. package/src/lib/sveltekit/nav-routes.ts +2 -2
  91. package/src/lib/sveltekit/public-routes.ts +5 -3
  92. package/src/lib/sveltekit/types.ts +5 -1
  93. package/dist/render/sanitize.d.ts +0 -8
  94. package/dist/render/sanitize.d.ts.map +0 -1
  95. package/dist/render/sanitize.js +0 -26
  96. package/src/lib/render/sanitize.ts +0 -27
@@ -0,0 +1,57 @@
1
+ import { defaultSchema } from 'hast-util-sanitize';
2
+ import { visit } from 'unist-util-visit';
3
+ import { dataAttrProp } from './registry.js';
4
+ // The fixed directive markers the stamp writes and the dispatch reads. They are inert data
5
+ // attributes, never a script vector, and must survive the floor so the dispatch still runs.
6
+ const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataIcon', 'dataRole', 'dataRise'];
7
+ /**
8
+ * Build the delivery sanitize schema. Starts from hast-util-sanitize's defaultSchema, the
9
+ * GitHub-lineage allowlist that strips scripts, inline event handlers, and javascript:/data: URLs,
10
+ * then adds exactly what cairn's render needs. The directive markers (the fixed ones plus the
11
+ * dataAttr<Key> markers derived from the registry) survive so the dispatch reads its stamps after
12
+ * the floor. The benign author tags real content uses (nav, details, summary) and class/target/rel
13
+ * on anchors are admitted. A site extends the result through `extend`, always starting from this
14
+ * safe base, so it can add to the allowlist but not weaken the core strip.
15
+ */
16
+ export function buildSanitizeSchema(registry, extend) {
17
+ const attrMarkers = registry.defs.flatMap((d) => (d.attributes ?? []).map((a) => dataAttrProp(a.key)));
18
+ const markers = [...FIXED_MARKERS, ...attrMarkers];
19
+ const attributes = defaultSchema.attributes ?? {};
20
+ // defaultSchema's `a` entry carries a className tuple (`['className', 'data-footnote-backref']`)
21
+ // that restricts a link's class to that one value. A per-tag tuple wins over a bare `*` entry, so
22
+ // it would strip an author's link class. Drop that tuple before admitting a free-form `className`.
23
+ const anchorAttrs = (attributes.a ?? []).filter((entry) => !(Array.isArray(entry) && entry[0] === 'className'));
24
+ // Admit the inert `cairn:` href scheme on top of the default protocol allowlist. The render
25
+ // resolver rewrites a `cairn:` link to a live permalink before delivery; an unresolved one
26
+ // survives the floor in its inert token form (a visible unresolved-link signal), never as an
27
+ // executable vector. The dangerous-protocol strip (javascript:, data:) is preserved.
28
+ const protocols = defaultSchema.protocols ?? {};
29
+ const schema = {
30
+ ...defaultSchema,
31
+ tagNames: [...(defaultSchema.tagNames ?? []), 'nav', 'details', 'summary'],
32
+ attributes: {
33
+ ...attributes,
34
+ '*': [...(attributes['*'] ?? []), 'className', ...markers],
35
+ a: [...anchorAttrs, 'className', 'target', 'rel'],
36
+ },
37
+ protocols: {
38
+ ...protocols,
39
+ href: [...(protocols.href ?? []), 'cairn'],
40
+ },
41
+ };
42
+ return extend ? extend(schema) : schema;
43
+ }
44
+ /**
45
+ * Force rel="noopener noreferrer" on every target="_blank" anchor, to prevent reverse-tabnabbing.
46
+ * hast-util-sanitize runs no per-node hook, so this small transform carries the behavior the old
47
+ * DOMPurify preview pass enforced, now on the delivered output as well.
48
+ */
49
+ export function rehypeAnchorRel() {
50
+ return (tree) => {
51
+ visit(tree, 'element', (node) => {
52
+ if (node.tagName === 'a' && node.properties?.target === '_blank') {
53
+ node.properties.rel = 'noopener noreferrer';
54
+ }
55
+ });
56
+ };
57
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"auth-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/auth-routes.ts"],"names":[],"mappings":"AAcA,OAAO,EAAyC,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAC3G,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,YAAY,CAAC;IACvB,IAAI,CAAC,EAAE,aAAa,CAAC;CACtB;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB;uBA2B7B,cAAc,KAAG;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;2BAnBjD,cAAc,KAAG,OAAO,CAAC;QAAE,IAAI,EAAE,IAAI,CAAA;KAAE,CAAC;yBA4BnE,cAAc,KACpB;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;2BAcxB,cAAc,KAAG,OAAO,CAAC,KAAK,CAAC;0BAwBhC,cAAc,KAAG,OAAO,CAAC,KAAK,CAAC;EASnE"}
1
+ {"version":3,"file":"auth-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/auth-routes.ts"],"names":[],"mappings":"AAeA,OAAO,EAAyC,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAC3G,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,YAAY,CAAC;IACvB,IAAI,CAAC,EAAE,aAAa,CAAC;CACtB;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB;uBA2C7B,cAAc,KAAG;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;2BAnCjD,cAAc,KAAG,OAAO,CAAC;QAAE,IAAI,EAAE,IAAI,CAAA;KAAE,CAAC;yBA4CnE,cAAc,KACpB;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;2BAcxB,cAAc,KAAG,OAAO,CAAC,KAAK,CAAC;0BAyBhC,cAAc,KAAG,OAAO,CAAC,KAAK,CAAC;EAUnE"}
@@ -3,8 +3,8 @@
3
3
  // against a sink. The confirm-load, confirm, and logout handlers arrive in Task 6.
4
4
  import { redirect } from '@sveltejs/kit';
5
5
  import { requireOrigin, requireDb } from '../env.js';
6
- import { generateToken, generateSessionId, hashToken, TOKEN_TTL_MS, SESSION_TTL_MS, COOKIE_NAME, } from '../auth/crypto.js';
7
- import { findEditor, issueToken, consumeToken, createSession, deleteSession } from '../auth/store.js';
6
+ import { generateToken, generateSessionId, hashToken, TOKEN_TTL_MS, SESSION_TTL_MS, SEND_COOLDOWN_MS, sessionCookieName, } from '../auth/crypto.js';
7
+ import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
8
8
  import { buildMagicLinkMessage, cloudflareSend } from '../email.js';
9
9
  export function createAuthRoutes(config) {
10
10
  const send = config.send ?? cloudflareSend;
@@ -21,11 +21,27 @@ export function createAuthRoutes(config) {
21
21
  const email = String(form.get('email') ?? '').trim().toLowerCase();
22
22
  const editor = email ? await findEditor(db, email) : null;
23
23
  if (editor) {
24
- const token = generateToken();
25
24
  const now = Date.now();
26
- await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
27
- const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
28
- await send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link }));
25
+ // Per-email cooldown: skip the reissue and send when a token for this email was issued within
26
+ // the window, so the endpoint cannot flood an editor's inbox. The response is unchanged, so
27
+ // the non-leak property holds.
28
+ if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
29
+ const token = generateToken();
30
+ await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
31
+ const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
32
+ // The token row is the security-critical write the email depends on, so it is awaited. The
33
+ // send is a post-response side effect, handed to waitUntil so a slow email provider does not
34
+ // hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
35
+ // failure is logged so observability survives a backgrounded send.
36
+ const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch((err) => console.error('cairn: magic-link send failed', err));
37
+ // adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
38
+ // deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
39
+ const ctx = event.platform?.ctx ?? event.platform?.context;
40
+ if (ctx?.waitUntil)
41
+ ctx.waitUntil(sending);
42
+ else
43
+ await sending;
44
+ }
29
45
  }
30
46
  return { sent: true };
31
47
  }
@@ -62,11 +78,12 @@ export function createAuthRoutes(config) {
62
78
  throw redirect(303, '/admin/login?error=expired');
63
79
  const id = generateSessionId();
64
80
  await createSession(db, id, email, now + SESSION_TTL_MS, now);
65
- event.cookies.set(COOKIE_NAME, id, {
81
+ const secure = event.url.protocol === 'https:';
82
+ event.cookies.set(sessionCookieName(secure), id, {
66
83
  path: '/',
67
84
  httpOnly: true,
68
- // Secure on HTTPS (every real deploy); off on local http dev so the cookie sticks.
69
- secure: event.url.protocol === 'https:',
85
+ // __Host- needs Secure unconditionally on https; local http dev drops the prefix and Secure.
86
+ secure,
70
87
  sameSite: 'lax',
71
88
  maxAge: Math.floor(SESSION_TTL_MS / 1000),
72
89
  });
@@ -75,10 +92,11 @@ export function createAuthRoutes(config) {
75
92
  /** POST /admin/auth/logout. Deletes the session row and clears the cookie. */
76
93
  async function logoutAction(event) {
77
94
  const db = requireDb(event.platform?.env ?? {});
78
- const id = event.cookies.get(COOKIE_NAME);
95
+ const name = sessionCookieName(event.url.protocol === 'https:');
96
+ const id = event.cookies.get(name);
79
97
  if (id)
80
98
  await deleteSession(db, id);
81
- event.cookies.delete(COOKIE_NAME, { path: '/' });
99
+ event.cookies.delete(name, { path: '/' });
82
100
  throw redirect(303, '/admin/login');
83
101
  }
84
102
  return { loginLoad, requestAction, confirmLoad, confirmAction, logoutAction };
@@ -1,4 +1,5 @@
1
1
  import { type GithubKeyEnv } from '../github/credentials.js';
2
+ import { type LinkTarget } from '../content/manifest.js';
2
3
  import type { CairnRuntime, FrontmatterField } from '../content/types.js';
3
4
  import type { Editor, Role } from '../auth/types.js';
4
5
  /** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
@@ -50,6 +51,8 @@ export interface EditData {
50
51
  isNew: boolean;
51
52
  saved: boolean;
52
53
  error: string | null;
54
+ /** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
55
+ linkTargets: LinkTarget[];
53
56
  }
54
57
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
55
58
  export interface ContentEvent {
@@ -1 +1 @@
1
- {"version":3,"file":"content-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/content-routes.ts"],"names":[],"mappings":"AAQA,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAI7E,OAAO,KAAK,EAAE,YAAY,EAAqB,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC7F,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAErD,wGAAwG;AACxG,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gGAAgG;AAChG,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,IAAI,CAAA;KAAE,CAAC;IAC1C,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,yGAAyG;IACzG,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,wCAAwC;AACxC,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,oCAAoC;AACpC,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,2FAA2F;IAC3F,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,gFAAgF;IAChF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,qDAAqD;IACrD,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,6FAA6F;AAC7F,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,gGAAgG;AAChG,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,GAAG,CAAC;IACT,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACnC,QAAQ,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,YAAY,CAAA;KAAE,CAAC;CACnC;AAED,sFAAsF;AACtF,MAAM,WAAW,iBAAiB;IAChC,6FAA6F;IAC7F,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACpD;AAgBD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,YAAY,EAAE,IAAI,GAAE,iBAAsB;wBAK1D,YAAY,KAAG,UAAU;yBAa1B,KAAK;sBAqBA,YAAY,KAAG,OAAO,CAAC,QAAQ,CAAC;0BAqB5B,YAAY,KAAG,OAAO,CAAC,KAAK,CAAC;sBAyCjC,YAAY,KAAG,OAAO,CAAC,QAAQ,CAAC;wBAgC9B,YAAY,KAAG,OAAO,CAAC,KAAK,CAAC;qBAtJ5C,YAAY,KAAK,OAAO,CAAC,MAAM,CAAC;EA+LnD"}
1
+ {"version":3,"file":"content-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/content-routes.ts"],"names":[],"mappings":"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"}
@@ -7,8 +7,9 @@ import { findConcept } from '../content/concepts.js';
7
7
  import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
8
8
  import { isValidId, slugify, filenameFromId, composeDatedId } from '../content/ids.js';
9
9
  import { appCredentials } from '../github/credentials.js';
10
- import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
11
- import { installationToken } from '../github/signing.js';
10
+ import { listMarkdown, readRaw, commitFiles } from '../github/repo.js';
11
+ import { cachedInstallationToken } from '../github/signing.js';
12
+ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry } from '../content/manifest.js';
12
13
  import { CommitConflictError } from '../github/types.js';
13
14
  /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
14
15
  function sessionOf(event) {
@@ -25,7 +26,7 @@ function conceptOf(runtime, params) {
25
26
  return concept;
26
27
  }
27
28
  export function createContentRoutes(runtime, deps = {}) {
28
- const mintToken = deps.mintToken ?? ((env) => installationToken(appCredentials(runtime.backend, env)));
29
+ const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
29
30
  /** Layout load for every admin page: the nav, the user, and the active path. */
30
31
  function layoutLoad(event) {
31
32
  const editor = sessionOf(event);
@@ -139,6 +140,18 @@ export function createContentRoutes(runtime, deps = {}) {
139
140
  throw error(404, 'Entry not found');
140
141
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
141
142
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
143
+ let linkTargets = [];
144
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
145
+ if (manifestRaw !== null) {
146
+ linkTargets = parseManifest(manifestRaw).entries.map((e) => ({
147
+ concept: e.concept,
148
+ id: e.id,
149
+ permalink: e.permalink,
150
+ title: e.title,
151
+ date: e.date,
152
+ draft: e.draft,
153
+ }));
154
+ }
142
155
  return {
143
156
  conceptId: concept.id,
144
157
  id,
@@ -150,6 +163,7 @@ export function createContentRoutes(runtime, deps = {}) {
150
163
  isNew,
151
164
  saved: event.url.searchParams.get('saved') === '1',
152
165
  error: event.url.searchParams.get('error'),
166
+ linkTargets,
153
167
  };
154
168
  }
155
169
  /** Match a commit conflict by class and by name (bundling can alias the class identity). */
@@ -177,8 +191,21 @@ export function createContentRoutes(runtime, deps = {}) {
177
191
  }
178
192
  const markdown = serializeMarkdown(result.data, body);
179
193
  const token = await mintToken(event.platform?.env ?? {});
194
+ // Read the committed manifest, upsert this entry's row, and commit content and manifest in one
195
+ // commit. A missing manifest starts empty (first save on a fresh repo). The build regenerates
196
+ // and verifies the manifest, so this incremental patch is the cheap request-time path. On a
197
+ // 422 retry commitFiles re-sends this manifest blob last-writer-wins. A concurrent save can then
198
+ // leave the committed manifest stale, which the next build rejects via verifyManifest; regenerate
199
+ // it with npm run cairn:manifest to recover.
200
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
201
+ const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
202
+ const row = manifestEntryFromFile(concept, { path, raw: markdown });
203
+ const nextManifest = serializeManifest(upsertEntry(manifest, row));
180
204
  try {
181
- await commitFile(runtime.backend, path, markdown, { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
205
+ await commitFiles(runtime.backend, [
206
+ { path, content: markdown },
207
+ { path: runtime.manifestPath, content: nextManifest },
208
+ ], { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
182
209
  }
183
210
  catch (err) {
184
211
  if (isConflict(err)) {
@@ -1,6 +1,6 @@
1
1
  import type { Editor } from '../auth/types.js';
2
2
  import type { HandleInput, RequestContext } from './types.js';
3
- /** The SvelteKit `Handle` that guards `/admin/**`. */
3
+ /** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
4
4
  export declare function createAuthGuard(): ({ event, resolve }: HandleInput) => Promise<Response>;
5
5
  /** For a protected load/action: the session the guard already resolved, or a login redirect. */
6
6
  export declare function requireSession(event: RequestContext): Editor;
@@ -1 +1 @@
1
- {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/guard.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAW9D,sDAAsD;AACtD,wBAAgB,eAAe,KACA,oBAAoB,WAAW,KAAG,OAAO,CAAC,QAAQ,CAAC,CAYjF;AAED,gGAAgG;AAChG,wBAAgB,cAAc,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAI5D;AAED,2EAA2E;AAC3E,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAI1D"}
1
+ {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/guard.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAwB9D,kFAAkF;AAClF,wBAAgB,eAAe,KACA,oBAAoB,WAAW,KAAG,OAAO,CAAC,QAAQ,CAAC,CAcjF;AAED,gGAAgG;AAChG,wBAAgB,cAAc,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAI5D;AAED,2EAA2E;AAC3E,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAI1D"}
@@ -3,7 +3,7 @@
3
3
  // stays free of a site's App.* ambient types.
4
4
  import { redirect, error } from '@sveltejs/kit';
5
5
  import { resolveSession } from '../auth/store.js';
6
- import { COOKIE_NAME } from '../auth/crypto.js';
6
+ import { sessionCookieName } from '../auth/crypto.js';
7
7
  /** The login page and the auth endpoints are public; everything else under /admin is gated. */
8
8
  function isPublicAdminPath(pathname) {
9
9
  return pathname === '/admin/login' || pathname.startsWith('/admin/auth/');
@@ -11,20 +11,35 @@ function isPublicAdminPath(pathname) {
11
11
  function isAdminPath(pathname) {
12
12
  return pathname === '/admin' || pathname.startsWith('/admin/');
13
13
  }
14
- /** The SvelteKit `Handle` that guards `/admin/**`. */
14
+ /**
15
+ * Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
16
+ * design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
17
+ */
18
+ function applySecurityHeaders(headers) {
19
+ headers.set('X-Content-Type-Options', 'nosniff');
20
+ headers.set('X-Frame-Options', 'DENY');
21
+ headers.set('Content-Security-Policy', "frame-ancestors 'none'");
22
+ headers.set('Referrer-Policy', 'no-referrer');
23
+ headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
24
+ headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
25
+ }
26
+ /** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
15
27
  export function createAuthGuard() {
16
28
  return async function handle({ event, resolve }) {
17
29
  const { pathname } = event.url;
18
- if (!isAdminPath(pathname) || isPublicAdminPath(pathname)) {
30
+ if (!isAdminPath(pathname))
19
31
  return resolve(event);
32
+ if (!isPublicAdminPath(pathname)) {
33
+ const env = event.platform?.env ?? {};
34
+ const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
35
+ const editor = id && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
36
+ if (!editor)
37
+ throw redirect(303, '/admin/login');
38
+ event.locals.editor = editor;
20
39
  }
21
- const env = event.platform?.env ?? {};
22
- const id = event.cookies.get(COOKIE_NAME);
23
- const editor = id && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
24
- if (!editor)
25
- throw redirect(303, '/admin/login');
26
- event.locals.editor = editor;
27
- return resolve(event);
40
+ const response = await resolve(event);
41
+ applySecurityHeaders(response.headers);
42
+ return response;
28
43
  };
29
44
  }
30
45
  /** For a protected load/action: the session the guard already resolved, or a login redirect. */
@@ -3,7 +3,7 @@
3
3
  // and commit paths are unit-testable against a fetch double with an injected token.
4
4
  import { redirect, error } from '@sveltejs/kit';
5
5
  import { appCredentials } from '../github/credentials.js';
6
- import { installationToken } from '../github/signing.js';
6
+ import { cachedInstallationToken } from '../github/signing.js';
7
7
  import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
8
8
  import { CommitConflictError } from '../github/types.js';
9
9
  import { parseSiteConfig, extractMenu, validateNavTree, setMenu } from '../nav/site-config.js';
@@ -19,7 +19,7 @@ function isConflict(err) {
19
19
  return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
20
20
  }
21
21
  export function createNavRoutes(runtime, deps = {}) {
22
- const mintToken = deps.mintToken ?? ((env) => installationToken(appCredentials(runtime.backend, env)));
22
+ const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
23
23
  /** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
24
24
  async function pageOptions(token) {
25
25
  const pageConcepts = runtime.concepts.filter((c) => c.routing.routable && !c.routing.dated);
@@ -1,11 +1,13 @@
1
1
  import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
2
2
  import type { SiteIndex } from '../delivery/site-index.js';
3
3
  import type { SeoMeta } from '../delivery/seo.js';
4
+ import type { LinkResolve } from '../content/links.js';
4
5
  /** Injected dependencies for the public loaders. */
5
6
  export interface PublicRoutesDeps {
6
7
  site: SiteIndex;
7
8
  render: (md: string, opts?: {
8
9
  stagger?: boolean;
10
+ resolve?: LinkResolve;
9
11
  }) => string | Promise<string>;
10
12
  origin: string;
11
13
  /** Site name for og:site_name and the SEO head. */
@@ -1 +1 @@
1
- {"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGlD,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC;6EACyE;IACzE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,uDAAuD;AACvD,MAAM,WAAW,OAAQ,SAAQ,QAAQ;IACvC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,oDAAoD;AACpD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACxC;AAED,oFAAoF;AACpF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB;AAED,2DAA2D;AAC3D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB;uBAWvB;QAAE,GAAG,EAAE,GAAG,CAAA;KAAE,KAAG,OAAO,CAAC,SAAS,CAAC;6BA0BjC,MAAM,KAAG,QAAQ;8BAKhB,MAAM,KAAG,YAAY;yBAK1B,MAAM,SAAS;QAAE,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAG,OAAO;mBAO5D;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE;EAKvC"}
1
+ {"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGlD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,WAAW,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACtG,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC;6EACyE;IACzE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,uDAAuD;AACvD,MAAM,WAAW,OAAQ,SAAQ,QAAQ;IACvC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,oDAAoD;AACpD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACxC;AAED,oFAAoF;AACpF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB;AAED,2DAA2D;AAC3D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB;uBAWvB;QAAE,GAAG,EAAE,GAAG,CAAA;KAAE,KAAG,OAAO,CAAC,SAAS,CAAC;6BA0BjC,MAAM,KAAG,QAAQ;8BAKhB,MAAM,KAAG,YAAY;yBAK1B,MAAM,SAAS;QAAE,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAG,OAAO;mBAO5D;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE;EAKvC"}
@@ -6,6 +6,7 @@
6
6
  import { error } from '@sveltejs/kit';
7
7
  import { buildSeoMeta } from '../delivery/seo.js';
8
8
  import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
9
+ import { buildLinkResolver } from '../delivery/manifest.js';
9
10
  /** Build the public loaders for a site's unified index. */
10
11
  export function createPublicRoutes(deps) {
11
12
  const { site, render, origin, siteName, description, feeds, defaultImage } = deps;
@@ -38,9 +39,9 @@ export function createPublicRoutes(deps) {
38
39
  ...(image ? { image } : {}),
39
40
  ...(fields.robots ? { robots: fields.robots } : {}),
40
41
  ...(fields.author ? { author: fields.author } : {}),
41
- feeds,
42
+ ...(entry.date ? { feeds } : {}),
42
43
  });
43
- return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
44
+ return { entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
44
45
  }
45
46
  /** The chronological archive for one concept: every non-draft summary, newest-first. */
46
47
  function archiveLoad(conceptId) {
@@ -22,6 +22,12 @@ export interface RequestContext {
22
22
  };
23
23
  platform?: {
24
24
  env?: AuthEnv;
25
+ ctx?: {
26
+ waitUntil(promise: Promise<unknown>): void;
27
+ };
28
+ context?: {
29
+ waitUntil(promise: Promise<unknown>): void;
30
+ };
25
31
  };
26
32
  setHeaders(headers: Record<string, string>): void;
27
33
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAExD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;IACtC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC/D,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,SAAS,CAAC;IACnB,MAAM,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACnC,QAAQ,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IAG7B,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CACnD;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,cAAc,CAAC;IACtB,OAAO,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC;CAC9D"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAExD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;IACtC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC/D,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,SAAS,CAAC;IACnB,MAAM,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACnC,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC;QACd,GAAG,CAAC,EAAE;YAAE,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAA;SAAE,CAAC;QACrD,OAAO,CAAC,EAAE;YAAE,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAA;SAAE,CAAC;KAC1D,CAAC;IAGF,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CACnD;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,cAAc,CAAC;IACtB,OAAO,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC;CAC9D"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.14.0",
3
+ "version": "0.18.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -73,11 +73,12 @@
73
73
  "@types/hast": "^3.0.4",
74
74
  "@types/mdast": "^4.0.4",
75
75
  "codemirror": "^6.0.2",
76
- "dompurify": "^3.4.7",
77
76
  "gray-matter": "^4",
77
+ "hast-util-sanitize": "^5.0.2",
78
78
  "hastscript": "^9.0.1",
79
79
  "mdast-util-directive": "^3.1.0",
80
80
  "rehype-raw": "^7.0.0",
81
+ "rehype-sanitize": "^6.0.0",
81
82
  "rehype-slug": "^6.0.0",
82
83
  "rehype-stringify": "^10.0.1",
83
84
  "remark-directive": "^4.0.0",
@@ -2,8 +2,17 @@
2
2
  // code runs unchanged in workerd. The store keeps only the hash of a token, never the
3
3
  // token itself (spec 7.1).
4
4
 
5
- /** The session cookie name. */
6
- export const COOKIE_NAME = 'cairn_session';
5
+ /** The base session cookie name, prefixed with __Host- when the cookie is Secure. */
6
+ const COOKIE_BASE = 'cairn_session';
7
+
8
+ /**
9
+ * The session cookie name. On https the cookie is Secure and takes the __Host- prefix, which
10
+ * binds it to the origin (the browser enforces Secure, Path=/, and no Domain). On local http
11
+ * dev the prefix is dropped, since __Host- requires Secure and the dev cookie cannot set it.
12
+ */
13
+ export function sessionCookieName(secure: boolean): string {
14
+ return secure ? `__Host-${COOKIE_BASE}` : COOKIE_BASE;
15
+ }
7
16
 
8
17
  /** Magic-link tokens live 10 minutes. */
9
18
  export const TOKEN_TTL_MS = 10 * 60 * 1000;
@@ -11,6 +20,9 @@ export const TOKEN_TTL_MS = 10 * 60 * 1000;
11
20
  /** Sessions live 30 days. */
12
21
  export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
13
22
 
23
+ /** A magic link is sent at most once per email per minute, to throttle inbox flooding. */
24
+ export const SEND_COOLDOWN_MS = 60 * 1000;
25
+
14
26
  function randomBase64Url(byteLength = 32): string {
15
27
  const bytes = new Uint8Array(byteLength);
16
28
  crypto.getRandomValues(bytes);
@@ -28,13 +28,23 @@ export async function issueToken(
28
28
  now: number,
29
29
  ): Promise<void> {
30
30
  await db.batch([
31
- db.prepare('DELETE FROM magic_token WHERE email = ?').bind(email),
31
+ // Replace this email's prior token, and sweep any expired token while here (no cron needed).
32
+ db.prepare('DELETE FROM magic_token WHERE email = ? OR expires_at <= ?').bind(email, now),
32
33
  db
33
34
  .prepare('INSERT INTO magic_token (token_hash, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
34
35
  .bind(tokenHash, email, expiresAt, now),
35
36
  ]);
36
37
  }
37
38
 
39
+ /** True when a magic-link token for this email was issued at or after `since`, for the send cooldown. */
40
+ export async function recentlyIssued(db: D1Database, email: string, since: number): Promise<boolean> {
41
+ const row = await db
42
+ .prepare('SELECT 1 AS one FROM magic_token WHERE email = ? AND created_at >= ? LIMIT 1')
43
+ .bind(email, since)
44
+ .first<{ one: number }>();
45
+ return row != null;
46
+ }
47
+
38
48
  /**
39
49
  * Consume a token in one atomic statement. A returned email means the token was present and
40
50
  * unexpired and is now gone, so the link is single-use by construction on strongly-consistent D1.
@@ -55,10 +65,13 @@ export async function createSession(
55
65
  expiresAt: number,
56
66
  now: number,
57
67
  ): Promise<void> {
58
- await db
59
- .prepare('INSERT INTO session (id, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
60
- .bind(id, email, expiresAt, now)
61
- .run();
68
+ await db.batch([
69
+ // Sweep expired sessions on login, so abandoned rows do not accumulate (no cron needed).
70
+ db.prepare('DELETE FROM session WHERE expires_at <= ?').bind(now),
71
+ db
72
+ .prepare('INSERT INTO session (id, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
73
+ .bind(id, email, expiresAt, now),
74
+ ]);
62
75
  }
63
76
 
64
77
  /**
@@ -12,15 +12,16 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
12
12
  import type { IconSet } from '../render/glyph.js';
13
13
  import type { EditData } from '../sveltekit/content-routes.js';
14
14
  import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
15
- import { sanitizePreviewHtml } from '../render/sanitize.js';
15
+ import type { LinkResolve } from '../content/links.js';
16
+ import { manifestLinkResolver } from '../content/manifest.js';
16
17
 
17
18
  interface Props {
18
19
  /** The edit load's data, plus the site name for the heading. */
19
20
  data: EditData & { siteName: string };
20
21
  /** The site's component registry, for the insert palette. */
21
22
  registry?: ComponentRegistry;
22
- /** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
23
- render?: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
23
+ /** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
24
+ render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
24
25
  /** The site's icon set, for the guided form's icon fields. */
25
26
  icons?: IconSet;
26
27
  }
@@ -34,6 +35,10 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
34
35
  let previewHtml = $state('');
35
36
  let insert = $state.raw<(text: string) => void>(() => {});
36
37
 
38
+ // The manifest-backed resolver turns a cairn: link into its live permalink in the preview, and
39
+ // returns undefined for a missing target so the render step marks it cairn-broken-link.
40
+ const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
41
+
37
42
  const PREVIEW_KEY = 'cairn-admin:preview';
38
43
 
39
44
  $effect(() => {
@@ -46,20 +51,20 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
46
51
  localStorage.setItem(PREVIEW_KEY, showPreview ? '1' : '0');
47
52
  }
48
53
 
49
- // Render the design-accurate preview as the body changes, debounced, and sanitize before the DOM.
50
- // The sanitize is the one barrier between editor-authored markdown and the page (the editor is unsanitized).
54
+ // Render the design-accurate preview as the body changes, debounced. The site's render is the
55
+ // floored engine pipeline, so its output is already sanitized; the preview mirrors the page.
51
56
  // previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
52
57
  // async render call resolves after a newer one has started, the stale result is discarded.
53
58
  let previewRun = 0;
54
59
  $effect(() => {
55
60
  if (!showPreview || !render) return;
56
61
  const md = body;
62
+ const resolve = resolveLink; // tracked read in the effect body
57
63
  const run = ++previewRun;
58
64
  const handle = setTimeout(async () => {
59
65
  try {
60
- const html = await render(md);
61
- const safe = await sanitizePreviewHtml(html);
62
- if (run === previewRun) previewHtml = safe;
66
+ const html = await render(md, { resolve });
67
+ if (run === previewRun) previewHtml = html;
63
68
  } catch {
64
69
  if (run === previewRun) previewHtml = '';
65
70
  }
@@ -31,6 +31,7 @@ export function composeRuntime(
31
31
  backend: adapter.backend,
32
32
  sender: adapter.sender,
33
33
  render: adapter.render,
34
+ manifestPath: adapter.manifestPath ?? 'src/content/.cairn/index.json',
34
35
  registry: adapter.registry,
35
36
  icons: adapter.icons,
36
37
  navMenu: adapter.navMenu,
@@ -0,0 +1,48 @@
1
+ // cairn-cms: the cairn: internal-link token. An internal link is a standard CommonMark link
2
+ // whose href is `cairn:<concept>/<id>`, keyed to the target's permanent filename stem so it
3
+ // survives a slug, date, or permalink change (content-graph design). This module owns the
4
+ // grammar; the render resolver (resolve-links.ts) reuses parseCairnToken.
5
+ import { unified } from 'unified';
6
+ import remarkParse from 'remark-parse';
7
+ import remarkGfm from 'remark-gfm';
8
+ import { visit } from 'unist-util-visit';
9
+ import { isValidId } from './ids.js';
10
+
11
+ /** A resolved reference to a content entry by its concept and permanent id. */
12
+ export interface CairnRef {
13
+ concept: string;
14
+ id: string;
15
+ }
16
+
17
+ /** Resolve a reference to its live permalink. Returns undefined when the target is missing (the
18
+ * preview marks it); the build resolver throws instead, so a dangling token fails the build. */
19
+ export type LinkResolve = (ref: CairnRef) => string | undefined;
20
+
21
+ /** Parse a `cairn:<concept>/<id>` href, or null for any other href or a malformed token. */
22
+ export function parseCairnToken(href: string): CairnRef | null {
23
+ if (!href.startsWith('cairn:')) return null;
24
+ const rest = href.slice('cairn:'.length);
25
+ const slash = rest.indexOf('/');
26
+ if (slash <= 0) return null;
27
+ const concept = rest.slice(0, slash);
28
+ const id = rest.slice(slash + 1);
29
+ if (!concept || !isValidId(id)) return null;
30
+ return { concept, id };
31
+ }
32
+
33
+ /** The cairn links a markdown body points at, in first-occurrence order, deduped by concept/id.
34
+ * Parses the body as mdast, so a token inside a code span or fence is never matched. */
35
+ export function extractCairnLinks(body: string): CairnRef[] {
36
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
37
+ const seen = new Set<string>();
38
+ const refs: CairnRef[] = [];
39
+ visit(tree, 'link', (node: { url?: string }) => {
40
+ const ref = node.url ? parseCairnToken(node.url) : null;
41
+ if (!ref) return;
42
+ const key = `${ref.concept}/${ref.id}`;
43
+ if (seen.has(key)) return;
44
+ seen.add(key);
45
+ refs.push(ref);
46
+ });
47
+ return refs;
48
+ }