@glw907/cairn-cms 0.3.1 → 0.5.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.
- package/README.md +17 -9
- package/dist/adapter.d.ts +10 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/auth/admins.d.ts +33 -0
- package/dist/auth/admins.d.ts.map +1 -0
- package/dist/auth/admins.js +90 -0
- package/dist/auth/config.d.ts +2097 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/auth/config.js +78 -0
- package/dist/auth/guard.d.ts +34 -0
- package/dist/auth/guard.d.ts.map +1 -0
- package/dist/auth/guard.js +47 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +6 -0
- package/dist/auth/schema.d.ts +750 -0
- package/dist/auth/schema.d.ts.map +1 -0
- package/dist/auth/schema.js +93 -0
- package/dist/carta.d.ts +1 -1
- package/dist/carta.d.ts.map +1 -1
- package/dist/components/AdminLayout.svelte +9 -9
- package/dist/components/AdminLayout.svelte.d.ts +2 -2
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
- package/dist/components/AdminList.svelte +1 -1
- package/dist/components/ConfirmPage.svelte +31 -0
- package/dist/components/ConfirmPage.svelte.d.ts +11 -0
- package/dist/components/ConfirmPage.svelte.d.ts.map +1 -0
- package/dist/components/EditPage.svelte +5 -5
- package/dist/components/LoginPage.svelte +35 -18
- package/dist/components/LoginPage.svelte.d.ts +0 -2
- package/dist/components/LoginPage.svelte.d.ts.map +1 -1
- package/dist/components/ManageAdmins.svelte +1 -1
- package/dist/components/ManageAdmins.svelte.d.ts +1 -1
- package/dist/components/ManageAdmins.svelte.d.ts.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/email.d.ts.map +1 -1
- package/dist/email.js +19 -11
- package/dist/github.d.ts +22 -2
- package/dist/github.d.ts.map +1 -1
- package/dist/github.js +40 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/render/glyph.d.ts +6 -0
- package/dist/render/glyph.d.ts.map +1 -0
- package/dist/render/glyph.js +5 -0
- package/dist/render/index.d.ts +6 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +8 -0
- package/dist/render/pipeline.d.ts +16 -0
- package/dist/render/pipeline.d.ts.map +1 -0
- package/dist/render/pipeline.js +29 -0
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.d.ts.map +1 -0
- package/dist/render/registry.js +11 -0
- package/dist/render/rehype-dispatch.d.ts +24 -0
- package/dist/render/rehype-dispatch.d.ts.map +1 -0
- package/dist/render/rehype-dispatch.js +86 -0
- package/dist/render/remark-directives.d.ts +4 -0
- package/dist/render/remark-directives.d.ts.map +1 -0
- package/dist/render/remark-directives.js +74 -0
- package/dist/sveltekit/index.d.ts +20 -58
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +35 -152
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +2 -2
- package/package.json +48 -6
- package/src/lib/adapter.ts +12 -3
- package/src/lib/auth/admins.ts +106 -0
- package/src/lib/auth/config.ts +108 -0
- package/src/lib/auth/guard.ts +60 -0
- package/src/lib/auth/index.ts +6 -0
- package/src/lib/auth/schema.ts +112 -0
- package/src/lib/carta.ts +2 -2
- package/src/lib/components/AdminLayout.svelte +9 -9
- package/src/lib/components/AdminList.svelte +1 -1
- package/src/lib/components/ConfirmPage.svelte +31 -0
- package/src/lib/components/EditPage.svelte +5 -5
- package/src/lib/components/LoginPage.svelte +35 -18
- package/src/lib/components/ManageAdmins.svelte +1 -1
- package/src/lib/components/index.ts +1 -0
- package/src/lib/email.ts +18 -11
- package/src/lib/github.ts +38 -6
- package/src/lib/index.ts +3 -2
- package/src/lib/render/glyph.ts +14 -0
- package/src/lib/render/index.ts +8 -0
- package/src/lib/render/pipeline.ts +37 -0
- package/src/lib/render/registry.ts +36 -0
- package/src/lib/render/rehype-dispatch.ts +97 -0
- package/src/lib/render/remark-directives.ts +71 -0
- package/src/lib/sveltekit/index.ts +59 -227
- package/src/lib/utils.ts +2 -2
- package/dist/auth.d.ts +0 -25
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js +0 -132
- package/src/lib/auth.ts +0 -185
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Element } from 'hast';
|
|
2
|
+
|
|
3
|
+
/** A site component: how it inserts (editor) and how it renders (rehype). */
|
|
4
|
+
export interface ComponentDef {
|
|
5
|
+
/** Directive name, e.g. 'card' (matches `:::card`). */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Palette label. */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Palette description. */
|
|
10
|
+
description: string;
|
|
11
|
+
/** Markdown scaffold inserted at the cursor by the editor palette. */
|
|
12
|
+
insertTemplate: string;
|
|
13
|
+
/** Build the final hast element from the stamped directive element. */
|
|
14
|
+
build: (node: Element, rise?: string) => Element;
|
|
15
|
+
/** Optional role→default-icon (e.g. `{ caution: 'warning' }`). */
|
|
16
|
+
defaultIconByRole?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ComponentRegistry {
|
|
20
|
+
defs: ComponentDef[];
|
|
21
|
+
names: string[];
|
|
22
|
+
get(name: string): ComponentDef | undefined;
|
|
23
|
+
defaultIcon(name: string, role?: string): string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Build a registry from a site's component definitions. The single source the
|
|
27
|
+
* render pipeline (directive stamp + rehype dispatch) and the editor palette read. */
|
|
28
|
+
export function defineRegistry(input: { components: ComponentDef[] }): ComponentRegistry {
|
|
29
|
+
const byName = new Map(input.components.map((c) => [c.name, c]));
|
|
30
|
+
return {
|
|
31
|
+
defs: input.components,
|
|
32
|
+
names: input.components.map((c) => c.name),
|
|
33
|
+
get: (name) => byName.get(name),
|
|
34
|
+
defaultIcon: (name, role) => (role ? byName.get(name)?.defaultIconByRole?.[role] : undefined),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Root, Element, ElementContent, Properties } from 'hast';
|
|
2
|
+
import { h } from 'hastscript';
|
|
3
|
+
import type { ComponentRegistry } from './registry';
|
|
4
|
+
|
|
5
|
+
export function isElement(node: ElementContent | undefined): node is Element {
|
|
6
|
+
return !!node && node.type === 'element';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// hast Properties values are PropertyValue (string | number | boolean | array | null).
|
|
10
|
+
// Directive markers (dataIcon/dataRole/dataPrimitive) are always stamped as strings;
|
|
11
|
+
// this reads them back with that guarantee instead of casting at each call site.
|
|
12
|
+
export function strProp(node: Element, name: string): string | undefined {
|
|
13
|
+
const value = node.properties?.[name];
|
|
14
|
+
return typeof value === 'string' ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Wrap a pre-built glyph in an ec-icon span; secondary role adds the modifier. */
|
|
18
|
+
export function iconSpan(glyphEl: Element, role?: string): Element {
|
|
19
|
+
const className = role === 'secondary' ? ['ec-icon', 'ec-icon-secondary'] : ['ec-icon'];
|
|
20
|
+
return h('span', { className }, [glyphEl]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A site's icon factory: turn a stamped icon name + role into a hast element. */
|
|
24
|
+
export type MakeIcon = (name: string, role?: string) => Element;
|
|
25
|
+
|
|
26
|
+
// Pull the section's <h2> out, retag it .card-title, and build the .ec-head row
|
|
27
|
+
// (optional icon + heading). Returns the head plus the remaining body children.
|
|
28
|
+
// `makeIcon` (site-supplied) turns the stamped data-icon into an element; omit it
|
|
29
|
+
// for a head with no icon.
|
|
30
|
+
export function splitHead(node: Element, makeIcon?: MakeIcon): { head: Element; rest: ElementContent[] } {
|
|
31
|
+
const children = node.children as ElementContent[];
|
|
32
|
+
const i = children.findIndex((c) => isElement(c) && c.tagName === 'h2');
|
|
33
|
+
const h2 = children[i] as Element;
|
|
34
|
+
h2.properties = { ...h2.properties, className: ['card-title'] };
|
|
35
|
+
const rest = children.filter((_, j) => j !== i);
|
|
36
|
+
const icon = strProp(node, 'dataIcon');
|
|
37
|
+
const role = strProp(node, 'dataRole');
|
|
38
|
+
const headKids: ElementContent[] = [];
|
|
39
|
+
if (makeIcon && icon) headKids.push(makeIcon(icon, role));
|
|
40
|
+
headKids.push(h2);
|
|
41
|
+
return { head: h('div', { className: ['ec-head'] }, headKids), rest };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Section wrapper: `<section class=…><div class="card-body">…</div></section>`,
|
|
45
|
+
* with an optional inline rise style. */
|
|
46
|
+
export function cardShell(classes: string[], rise: string | undefined, body: ElementContent[]): Element {
|
|
47
|
+
const properties: Properties = { className: classes };
|
|
48
|
+
if (rise) properties.style = rise;
|
|
49
|
+
return h('section', properties, [h('div', { className: ['card-body'] }, body)]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Tag the first <ul> among children with `ec-grid` and strip its whitespace-only
|
|
53
|
+
* text nodes so the bare list serializes without newlines. Returns that <ul>. */
|
|
54
|
+
export function markFirstList(children: ElementContent[]): Element | undefined {
|
|
55
|
+
const ul = children.find((c) => isElement(c) && c.tagName === 'ul') as Element | undefined;
|
|
56
|
+
if (ul) {
|
|
57
|
+
ul.properties = { ...ul.properties, className: ['ec-grid'] };
|
|
58
|
+
ul.children = (ul.children as ElementContent[]).filter(
|
|
59
|
+
(c) => !(c.type === 'text' && /^\s*$/.test(c.value)),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return ul;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Recurse into a node's children, transforming any nested primitive sections
|
|
66
|
+
// (a grid inside a card, panels inside a split) WITHOUT a rise stagger.
|
|
67
|
+
function transformChildren(children: ElementContent[], registry: ComponentRegistry): ElementContent[] {
|
|
68
|
+
return children.map((c) => {
|
|
69
|
+
if (isElement(c) && c.properties?.dataPrimitive) return transformNode(c, registry);
|
|
70
|
+
if (isElement(c)) c.children = transformChildren(c.children as ElementContent[], registry);
|
|
71
|
+
return c;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function transformNode(node: Element, registry: ComponentRegistry, rise?: string): Element {
|
|
76
|
+
node.children = transformChildren(node.children as ElementContent[], registry);
|
|
77
|
+
const name = strProp(node, 'dataPrimitive');
|
|
78
|
+
const def = name ? registry.get(name) : undefined;
|
|
79
|
+
return def ? def.build(node, rise) : node;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Rehype transformer: dispatch each stamped element through its registry `build`
|
|
83
|
+
* fn. Top-level primitives get a document-order rise stagger when `rise` is
|
|
84
|
+
* supplied (a site's per-index motion formula); nested ones don't. Non-primitive
|
|
85
|
+
* content (lede, intro paragraphs, the page-toc nav) passes through untouched. */
|
|
86
|
+
export function rehypeDispatch(registry: ComponentRegistry, rise?: (idx: number) => string) {
|
|
87
|
+
return (tree: Root) => {
|
|
88
|
+
let idx = 0;
|
|
89
|
+
tree.children = (tree.children as ElementContent[]).map((child) => {
|
|
90
|
+
if (isElement(child) && child.properties?.dataPrimitive) {
|
|
91
|
+
return transformNode(child, registry, rise ? rise(idx++) : undefined);
|
|
92
|
+
}
|
|
93
|
+
if (isElement(child)) child.children = transformChildren(child.children as ElementContent[], registry);
|
|
94
|
+
return child;
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Paragraph, PhrasingContent, Root, Text } from 'mdast';
|
|
2
|
+
import type { ContainerDirective, LeafDirective, TextDirective } from 'mdast-util-directive';
|
|
3
|
+
import { visit } from 'unist-util-visit';
|
|
4
|
+
import type { ComponentRegistry } from './registry';
|
|
5
|
+
|
|
6
|
+
// Reconstruct a directive's authored attribute block (`{#id .class key="value"}`).
|
|
7
|
+
// Accidental prose directives carry none, so this is almost always empty.
|
|
8
|
+
function serializeAttributes(attributes?: Record<string, string | null | undefined> | null): string {
|
|
9
|
+
if (!attributes) return '';
|
|
10
|
+
const tokens: string[] = [];
|
|
11
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
12
|
+
if (value == null) tokens.push(key);
|
|
13
|
+
else if (key === 'id') tokens.push(`#${value}`);
|
|
14
|
+
else if (key === 'class') for (const c of value.split(/\s+/).filter(Boolean)) tokens.push(`.${c}`);
|
|
15
|
+
else tokens.push(`${key}="${value}"`);
|
|
16
|
+
}
|
|
17
|
+
return tokens.length ? `{${tokens.join(' ')}}` : '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The vocabulary is container-only (`:::name`). A text directive (`:name`) or
|
|
21
|
+
// leaf directive (`::name`) is therefore always an accidental colon in prose
|
|
22
|
+
// ("4:00", "9:30", "ratio 16:9") that micromark tokenized as a directive.
|
|
23
|
+
// Restore it to its literal source text so prose renders verbatim.
|
|
24
|
+
function restoreLiteral(node: TextDirective | LeafDirective): PhrasingContent[] {
|
|
25
|
+
const marker = node.type === 'leafDirective' ? '::' : ':';
|
|
26
|
+
const attrs = serializeAttributes(node.attributes);
|
|
27
|
+
if (node.children.length === 0) {
|
|
28
|
+
return [{ type: 'text', value: marker + node.name + attrs }];
|
|
29
|
+
}
|
|
30
|
+
const open: Text = { type: 'text', value: `${marker}${node.name}[` };
|
|
31
|
+
const close: Text = { type: 'text', value: `]${attrs}` };
|
|
32
|
+
return [open, ...(node.children as PhrasingContent[]), close];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Stamp each registered container directive with data-* markers carrying its
|
|
36
|
+
// component name, icon, and role. No structure is built here; the rehype
|
|
37
|
+
// dispatcher rewrites the marked elements once their children are hast.
|
|
38
|
+
// Text and leaf directives are restored to literal text (accidental prose colons).
|
|
39
|
+
export function remarkDirectiveStamp(registry: ComponentRegistry) {
|
|
40
|
+
const known = new Set(registry.names);
|
|
41
|
+
return (tree: Root) => {
|
|
42
|
+
visit(tree, 'containerDirective', (node: ContainerDirective) => {
|
|
43
|
+
if (!known.has(node.name)) return;
|
|
44
|
+
const attrs = node.attributes ?? {};
|
|
45
|
+
const role = attrs.role || undefined;
|
|
46
|
+
let icon = attrs.icon || undefined;
|
|
47
|
+
if (!icon && role) icon = registry.defaultIcon(node.name, role);
|
|
48
|
+
|
|
49
|
+
const properties: Record<string, string> = { dataPrimitive: node.name };
|
|
50
|
+
if (icon) properties.dataIcon = icon;
|
|
51
|
+
if (role) properties.dataRole = role;
|
|
52
|
+
|
|
53
|
+
const data = node.data ?? (node.data = {});
|
|
54
|
+
data.hName = 'div';
|
|
55
|
+
data.hProperties = properties;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
|
|
59
|
+
if (!parent || index == null) return;
|
|
60
|
+
const literal = restoreLiteral(node as TextDirective | LeafDirective);
|
|
61
|
+
if (node.type === 'leafDirective') {
|
|
62
|
+
// Leaf directives sit at block level; wrap the restored text in a paragraph.
|
|
63
|
+
const paragraph: Paragraph = { type: 'paragraph', children: literal };
|
|
64
|
+
parent.children.splice(index, 1, paragraph);
|
|
65
|
+
} else {
|
|
66
|
+
parent.children.splice(index, 1, ...literal);
|
|
67
|
+
}
|
|
68
|
+
return index;
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -1,41 +1,30 @@
|
|
|
1
|
-
// cairn-core: the SvelteKit route server logic, extracted so each site's `admin/**`
|
|
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
|
-
// `src/routes/`, but their bodies are identical across sites
|
|
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
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
import
|
|
8
|
+
// `redirect`/`error` from `@sveltejs/kit` (a peer dependency, so the thrown objects share
|
|
9
|
+
// class identity with the host's runtime; otherwise 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 type { CairnUser } from '../auth/guard';
|
|
13
14
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
SESSION_MAX_AGE,
|
|
23
|
-
type Editor,
|
|
24
|
-
type Role,
|
|
25
|
-
} from '../auth';
|
|
26
|
-
import { sendMagicLink, type EmailSender } from '../email';
|
|
27
|
-
import { listMarkdown, readRaw, commitFile, installationToken, type RepoFile } from '../github';
|
|
15
|
+
listMarkdown,
|
|
16
|
+
readRaw,
|
|
17
|
+
commitFile,
|
|
18
|
+
installationToken,
|
|
19
|
+
signingSelfTest,
|
|
20
|
+
CommitConflictError,
|
|
21
|
+
type RepoFile,
|
|
22
|
+
} from '../github';
|
|
28
23
|
import { serializeMarkdown } from '../content';
|
|
29
24
|
import { findCollection, frontmatterFromForm, type CairnAdapter, type CairnField } from '../adapter';
|
|
30
25
|
|
|
31
|
-
/** The `platform.env` bindings the
|
|
26
|
+
/** The `platform.env` bindings the content routes read. All optional; the handlers guard. */
|
|
32
27
|
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
28
|
GITHUB_APP_ID?: string;
|
|
40
29
|
GITHUB_APP_INSTALLATION_ID?: string;
|
|
41
30
|
GITHUB_APP_PRIVATE_KEY_B64?: string;
|
|
@@ -45,13 +34,11 @@ interface PlatformEvent {
|
|
|
45
34
|
platform?: { env?: AdminEnv };
|
|
46
35
|
}
|
|
47
36
|
|
|
48
|
-
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
49
|
-
|
|
50
37
|
/**
|
|
51
38
|
* Mint a GitHub App installation token for *reads* when the App is configured, else undefined
|
|
52
39
|
* (reads then fall back to anonymous). Authenticated reads get the 5000/hr limit; anonymous
|
|
53
40
|
* reads share GitHub's 60/hr-per-IP budget across Cloudflare's egress IPs, so they 403 in prod.
|
|
54
|
-
* A mint failure degrades gracefully to anonymous rather than 500ing
|
|
41
|
+
* A mint failure degrades gracefully to anonymous rather than 500ing. Unlike the commit path,
|
|
55
42
|
* where a missing App is fatal, a read can still succeed unauthenticated.
|
|
56
43
|
*/
|
|
57
44
|
async function readToken(env: AdminEnv | undefined): Promise<string | undefined> {
|
|
@@ -73,23 +60,23 @@ async function readToken(env: AdminEnv | undefined): Promise<string | undefined>
|
|
|
73
60
|
// ── /admin layout ──────────────────────────────────────────────────────────
|
|
74
61
|
|
|
75
62
|
export interface AdminLayoutData {
|
|
76
|
-
|
|
63
|
+
user: CairnUser | null;
|
|
77
64
|
siteName: string;
|
|
78
65
|
pathname: string;
|
|
79
66
|
}
|
|
80
67
|
|
|
81
68
|
/**
|
|
82
69
|
* Branding + session for every admin page. `siteName` flows from the adapter without pulling
|
|
83
|
-
* its plugin graph into client bundles
|
|
70
|
+
* its plugin graph into client bundles; the import stays server-side in the layout load.
|
|
84
71
|
* `pathname` lets the shared shell highlight the active nav item without a `$app/*` import
|
|
85
72
|
* (those kit virtual modules have no types outside a kit app, so they can't live in the
|
|
86
73
|
* package); reading `event.url` here also opts the layout load into rerunning on navigation.
|
|
87
74
|
*/
|
|
88
75
|
export function adminLayoutLoad(
|
|
89
|
-
event: { locals: {
|
|
76
|
+
event: { locals: { user: CairnUser | null }; url: URL },
|
|
90
77
|
adapter: CairnAdapter,
|
|
91
78
|
): AdminLayoutData {
|
|
92
|
-
return {
|
|
79
|
+
return { user: event.locals.user, siteName: adapter.siteName, pathname: event.url.pathname };
|
|
93
80
|
}
|
|
94
81
|
|
|
95
82
|
// ── /admin (content list) ────────────────────────────────────────────────────
|
|
@@ -120,20 +107,6 @@ export async function adminListLoad(
|
|
|
120
107
|
return { collections };
|
|
121
108
|
}
|
|
122
109
|
|
|
123
|
-
// ── /admin/login ──────────────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
export interface LoginData {
|
|
126
|
-
sent: boolean;
|
|
127
|
-
error: string | null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function loginLoad(event: { url: URL }): LoginData {
|
|
131
|
-
return {
|
|
132
|
-
sent: event.url.searchParams.get('sent') === '1',
|
|
133
|
-
error: event.url.searchParams.get('error'),
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
110
|
// ── /admin/edit/[type]/[id] ─────────────────────────────────────────────────
|
|
138
111
|
|
|
139
112
|
export interface EditData {
|
|
@@ -179,92 +152,14 @@ export async function editLoad(
|
|
|
179
152
|
};
|
|
180
153
|
}
|
|
181
154
|
|
|
182
|
-
// ── /admin/auth/request (POST) ──────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
export async function authRequest(
|
|
185
|
-
event: PlatformEvent & { request: Request; url: URL },
|
|
186
|
-
adapter: CairnAdapter,
|
|
187
|
-
): Promise<never> {
|
|
188
|
-
const env = event.platform?.env;
|
|
189
|
-
if (!env?.AUTH_KV || !env.MAGIC_LINK_SECRET || !env.EMAIL) {
|
|
190
|
-
throw redirect(303, '/admin/login?error=config');
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const form = await event.request.formData();
|
|
194
|
-
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
195
|
-
if (!EMAIL_RE.test(email)) {
|
|
196
|
-
throw redirect(303, '/admin/login?error=invalid');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const editor = await lookupEditor(email, env.AUTH_KV);
|
|
200
|
-
if (!editor) {
|
|
201
|
-
throw redirect(303, '/admin/login?error=denied');
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const token = await createMagicLink(email, env.MAGIC_LINK_SECRET, env.AUTH_KV);
|
|
205
|
-
// PUBLIC_ORIGIN overrides url.origin for local dev (where wrangler's custom-domain
|
|
206
|
-
// route makes url.origin the production host); unset in prod → url.origin is correct.
|
|
207
|
-
const origin = env.PUBLIC_ORIGIN || event.url.origin;
|
|
208
|
-
const link = `${origin}/admin/auth/callback?token=${encodeURIComponent(token)}`;
|
|
209
|
-
try {
|
|
210
|
-
await sendMagicLink(env.EMAIL, email, link, adapter.siteName, adapter.sender);
|
|
211
|
-
} catch (err) {
|
|
212
|
-
console.error('magic-link send failed:', err);
|
|
213
|
-
throw redirect(303, '/admin/login?error=config');
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
throw redirect(303, '/admin/login?sent=1');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ── /admin/auth/callback (GET) ──────────────────────────────────────────────
|
|
220
|
-
|
|
221
|
-
export async function authCallback(
|
|
222
|
-
event: PlatformEvent & { url: URL; cookies: Cookies },
|
|
223
|
-
): Promise<never> {
|
|
224
|
-
const env = event.platform?.env;
|
|
225
|
-
if (!env?.AUTH_KV || !env.MAGIC_LINK_SECRET || !env.SESSION_SECRET) {
|
|
226
|
-
throw redirect(303, '/admin/login?error=config');
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const token = event.url.searchParams.get('token') ?? '';
|
|
230
|
-
const email = await redeemMagicToken(token, env.MAGIC_LINK_SECRET, env.AUTH_KV);
|
|
231
|
-
if (!email) {
|
|
232
|
-
throw redirect(303, '/admin/login?error=expired');
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Re-check the allowlist at redemption — membership may have changed since issue.
|
|
236
|
-
const editor = await lookupEditor(email, env.AUTH_KV);
|
|
237
|
-
if (!editor) {
|
|
238
|
-
throw redirect(303, '/admin/login?error=denied');
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const session = await createSession(editor, env.SESSION_SECRET);
|
|
242
|
-
event.cookies.set(SESSION_COOKIE, session, {
|
|
243
|
-
path: '/',
|
|
244
|
-
httpOnly: true,
|
|
245
|
-
secure: event.url.protocol === 'https:',
|
|
246
|
-
sameSite: 'lax',
|
|
247
|
-
maxAge: SESSION_MAX_AGE,
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
throw redirect(303, '/admin');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ── /admin/auth/logout (POST) ───────────────────────────────────────────────
|
|
254
|
-
|
|
255
|
-
export function logout(event: { cookies: Cookies }): never {
|
|
256
|
-
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
|
257
|
-
throw redirect(303, '/admin/login');
|
|
258
|
-
}
|
|
259
|
-
|
|
260
155
|
// ── /admin/save (POST) ──────────────────────────────────────────────────────
|
|
261
156
|
|
|
262
157
|
export async function saveCommit(
|
|
263
|
-
event: PlatformEvent & { request: Request; locals: {
|
|
158
|
+
event: PlatformEvent & { request: Request; locals: { user: CairnUser | null } },
|
|
264
159
|
adapter: CairnAdapter,
|
|
265
160
|
): Promise<never> {
|
|
266
|
-
const
|
|
267
|
-
if (!
|
|
161
|
+
const user = event.locals.user;
|
|
162
|
+
if (!user) throw error(401, 'Not signed in');
|
|
268
163
|
|
|
269
164
|
const env = event.platform?.env;
|
|
270
165
|
if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
@@ -295,109 +190,46 @@ export async function saveCommit(
|
|
|
295
190
|
privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
|
|
296
191
|
});
|
|
297
192
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
193
|
+
try {
|
|
194
|
+
await commitFile(
|
|
195
|
+
adapter.backend,
|
|
196
|
+
`${collection.dir}/${id}.md`,
|
|
197
|
+
markdown,
|
|
198
|
+
{ message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: user.name, email: user.email } },
|
|
199
|
+
token,
|
|
200
|
+
);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
// Concurrent-edit 409 (C3): fail safe. Bounce back with a reload prompt; the editor reloads
|
|
203
|
+
// the current version and reapplies. Any other error is unexpected, so rethrow.
|
|
204
|
+
if (err instanceof CommitConflictError) {
|
|
205
|
+
const message = 'This file changed since you opened it. Reload and reapply your edits.';
|
|
206
|
+
throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}`);
|
|
207
|
+
}
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
305
210
|
|
|
306
211
|
throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
|
|
307
212
|
}
|
|
308
213
|
|
|
309
|
-
// ── /admin/
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* The privilege-escalation gate for the manage-admins surface: only `owner`s may load it or
|
|
313
|
-
* run its actions. Returns the acting owner (so callers can guard self-targeted mutations).
|
|
314
|
-
*/
|
|
315
|
-
function requireOwner(event: { locals: { editor: Editor | null } }): Editor {
|
|
316
|
-
const editor = event.locals.editor;
|
|
317
|
-
if (!editor) throw error(401, 'Not signed in');
|
|
318
|
-
if (editor.role !== 'owner') throw error(403, 'Owner access required');
|
|
319
|
-
return editor;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** Resolve AUTH_KV or fail loudly — the management surface is useless without it. */
|
|
323
|
-
function ownerKv(event: PlatformEvent): KVNamespace {
|
|
324
|
-
const kv = event.platform?.env?.AUTH_KV;
|
|
325
|
-
if (!kv) throw error(500, 'Editor allowlist is not configured');
|
|
326
|
-
return kv;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export interface AdminsData {
|
|
330
|
-
admins: Editor[];
|
|
331
|
-
/** Acting owner's email, so the UI can disable self-targeted remove/demote. */
|
|
332
|
-
self: string;
|
|
333
|
-
saved: boolean;
|
|
334
|
-
error: string | null;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/** List the allowlist for the manage-admins page. Owner-only. */
|
|
338
|
-
export async function adminsLoad(
|
|
339
|
-
event: PlatformEvent & { locals: { editor: Editor | null }; url: URL },
|
|
340
|
-
): Promise<AdminsData> {
|
|
341
|
-
const owner = requireOwner(event);
|
|
342
|
-
const admins = await listEditors(ownerKv(event));
|
|
343
|
-
return {
|
|
344
|
-
admins,
|
|
345
|
-
self: owner.email,
|
|
346
|
-
saved: event.url.searchParams.get('saved') === '1',
|
|
347
|
-
error: event.url.searchParams.get('error'),
|
|
348
|
-
};
|
|
349
|
-
}
|
|
214
|
+
// ── /admin/healthz (GET) ──────────────────────────────────────────────────────
|
|
350
215
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
function parseRole(value: unknown): Role {
|
|
357
|
-
return value === 'owner' ? 'owner' : 'editor';
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Add (or update) an allowlist entry. Owner-only. */
|
|
361
|
-
export async function addAdmin(event: AdminsActionEvent): Promise<never> {
|
|
362
|
-
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 name = String(form.get('name') ?? '').trim();
|
|
367
|
-
if (!EMAIL_RE.test(email) || !name) {
|
|
368
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent('Enter a valid email and name')}`);
|
|
369
|
-
}
|
|
370
|
-
await setEditor(email, name, parseRole(form.get('role')), kv);
|
|
371
|
-
throw redirect(303, '/admin/admins?saved=1');
|
|
216
|
+
export interface HealthData {
|
|
217
|
+
ok: boolean;
|
|
218
|
+
checks: { githubAppSigning: { ok: boolean; detail?: string } };
|
|
372
219
|
}
|
|
373
220
|
|
|
374
|
-
/**
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** Change an editor's role. Owner-only; owners can't demote themselves (anti-lockout). */
|
|
388
|
-
export async function setAdminRole(event: AdminsActionEvent): Promise<never> {
|
|
389
|
-
const owner = requireOwner(event);
|
|
390
|
-
const kv = ownerKv(event);
|
|
391
|
-
const form = await event.request.formData();
|
|
392
|
-
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
393
|
-
const role = parseRole(form.get('role'));
|
|
394
|
-
if (email === owner.email && role !== 'owner') {
|
|
395
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent("You can't demote yourself")}`);
|
|
396
|
-
}
|
|
397
|
-
const existing = await lookupEditor(email, kv);
|
|
398
|
-
if (!existing) {
|
|
399
|
-
throw redirect(303, `/admin/admins?error=${encodeURIComponent('No such editor')}`);
|
|
221
|
+
/**
|
|
222
|
+
* Deploy-time health check (M2): signs a dummy App JWT to prove the GitHub App key loads and
|
|
223
|
+
* the PKCS#1→PKCS#8 conversion still works, before an editor hits it on save. Behind the
|
|
224
|
+
* `/admin` guard (signed-in editors only); returns ok/fail with no secret in the body.
|
|
225
|
+
*/
|
|
226
|
+
export async function healthLoad(event: PlatformEvent): Promise<HealthData> {
|
|
227
|
+
const env = event.platform?.env;
|
|
228
|
+
let githubAppSigning: { ok: boolean; detail?: string };
|
|
229
|
+
if (env?.GITHUB_APP_ID && env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
230
|
+
githubAppSigning = await signingSelfTest(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY_B64);
|
|
231
|
+
} else {
|
|
232
|
+
githubAppSigning = { ok: false, detail: 'GitHub App not configured' };
|
|
400
233
|
}
|
|
401
|
-
|
|
402
|
-
throw redirect(303, '/admin/admins?saved=1');
|
|
234
|
+
return { ok: githubAppSigning.ok, checks: { githubAppSigning } };
|
|
403
235
|
}
|
package/src/lib/utils.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// cairn-core: internal encoding helpers shared across modules.
|
|
2
2
|
//
|
|
3
|
-
// Deliberately NOT re-exported from index.ts
|
|
3
|
+
// Deliberately NOT re-exported from index.ts. These are implementation details of the
|
|
4
4
|
// auth/github crypto, not part of the public API (auth.ts signs tokens, github.ts builds
|
|
5
5
|
// the App JWT; both need base64url). Keeping them here stops bytesToB64url leaking through
|
|
6
6
|
// the `export *` barrel.
|
|
7
7
|
|
|
8
|
-
/** Encode bytes as unpadded base64url (RFC 4648 §5)
|
|
8
|
+
/** Encode bytes as unpadded base64url (RFC 4648 §5), the JWT/token wire format. */
|
|
9
9
|
export function bytesToB64url(bytes: Uint8Array): string {
|
|
10
10
|
const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join('');
|
|
11
11
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
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
|
package/dist/auth.d.ts.map
DELETED
|
@@ -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"}
|