@glw907/cairn-cms 0.1.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/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/adapter.d.ts +60 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +30 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +93 -0
- package/dist/carta.d.ts +39 -0
- package/dist/carta.d.ts.map +1 -0
- package/dist/carta.js +30 -0
- package/dist/components/AdminLayout.svelte +18 -0
- package/dist/components/AdminLayout.svelte.d.ts +8 -0
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -0
- package/dist/components/AdminList.svelte +41 -0
- package/dist/components/AdminList.svelte.d.ts +13 -0
- package/dist/components/AdminList.svelte.d.ts.map +1 -0
- package/dist/components/EditPage.svelte +125 -0
- package/dist/components/EditPage.svelte.d.ts +13 -0
- package/dist/components/EditPage.svelte.d.ts.map +1 -0
- package/dist/components/LoginPage.svelte +47 -0
- package/dist/components/LoginPage.svelte.d.ts +11 -0
- package/dist/components/LoginPage.svelte.d.ts.map +1 -0
- package/dist/components/index.d.ts +5 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/content.d.ts +3 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +10 -0
- package/dist/email.d.ts +14 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +17 -0
- package/dist/github.d.ts +52 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js +136 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/sveltekit/index.d.ts +91 -0
- package/dist/sveltekit/index.d.ts.map +1 -0
- package/dist/sveltekit/index.js +163 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +11 -0
- package/package.json +79 -0
- package/src/lib/adapter.ts +110 -0
- package/src/lib/auth.ts +130 -0
- package/src/lib/carta.ts +59 -0
- package/src/lib/components/AdminLayout.svelte +18 -0
- package/src/lib/components/AdminList.svelte +41 -0
- package/src/lib/components/EditPage.svelte +125 -0
- package/src/lib/components/LoginPage.svelte +47 -0
- package/src/lib/components/index.ts +6 -0
- package/src/lib/content.ts +11 -0
- package/src/lib/email.ts +35 -0
- package/src/lib/github.ts +188 -0
- package/src/lib/index.ts +7 -0
- package/src/lib/sveltekit/index.ts +272 -0
- package/src/lib/utils.ts +12 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LoginPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/LoginPage.svelte.ts"],"names":[],"mappings":"AAKE,UAAU,KAAK;IACb,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CACjE;AAwCH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as AdminLayout } from './AdminLayout.svelte';
|
|
2
|
+
export { default as AdminList } from './AdminList.svelte';
|
|
3
|
+
export { default as LoginPage } from './LoginPage.svelte';
|
|
4
|
+
export { default as EditPage } from './EditPage.svelte';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/components/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// cairn-cms admin UI shell. Consumers import from 'cairn-cms/components'; each site's
|
|
2
|
+
// admin route `.svelte` files are one-line shims around these.
|
|
3
|
+
export { default as AdminLayout } from './AdminLayout.svelte';
|
|
4
|
+
export { default as AdminList } from './AdminList.svelte';
|
|
5
|
+
export { default as LoginPage } from './LoginPage.svelte';
|
|
6
|
+
export { default as EditPage } from './EditPage.svelte';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../src/lib/content.ts"],"names":[],"mappings":"AAOA,0EAA0E;AAC1E,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3E"}
|
package/dist/content.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// cairn-core: reassemble a markdown file from frontmatter + body for committing.
|
|
2
|
+
//
|
|
3
|
+
// The inverse of the gray-matter parse the edit loader does on read. Kept as its own seam
|
|
4
|
+
// so a site adapter can own the on-disk serialization contract (quoting, key order)
|
|
5
|
+
// without the save endpoint reaching for gray-matter directly.
|
|
6
|
+
import matter from 'gray-matter';
|
|
7
|
+
/** Serialize frontmatter data + markdown body back into a file string. */
|
|
8
|
+
export function serializeMarkdown(frontmatter, body) {
|
|
9
|
+
return matter.stringify(body, frontmatter);
|
|
10
|
+
}
|
package/dist/email.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Cloudflare Email Sending binding surface (the object-form `send`, not the MIME form). */
|
|
2
|
+
export interface EmailSender {
|
|
3
|
+
send(message: {
|
|
4
|
+
to: string;
|
|
5
|
+
from: string;
|
|
6
|
+
subject: string;
|
|
7
|
+
text?: string;
|
|
8
|
+
html?: string;
|
|
9
|
+
}): Promise<{
|
|
10
|
+
messageId: string;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
export declare function sendMagicLink(sender: EmailSender, to: string, link: string, siteName: string, from: string): Promise<void>;
|
|
14
|
+
//# sourceMappingURL=email.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email.d.ts","sourceRoot":"","sources":["../src/lib/email.ts"],"names":[],"mappings":"AAQA,4FAA4F;AAC5F,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,OAAO,EAAE;QACZ,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACpC;AAED,wBAAsB,aAAa,CACjC,MAAM,EAAE,WAAW,EACnB,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,IAAI,CAAC,CASf"}
|
package/dist/email.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// cairn-core: pluggable magic-link email sender.
|
|
2
|
+
//
|
|
3
|
+
// Default adapter is Cloudflare Email Service → Email Sending (transactional, arbitrary
|
|
4
|
+
// recipients) — distinct from Email Routing's recipient-restricted `EmailMessage` flow.
|
|
5
|
+
// It is reached through the same `send_email` binding (configured without a
|
|
6
|
+
// destination_address) but a different call shape: `binding.send({ to, from, ... })`.
|
|
7
|
+
// Resend can slot in behind the same `sendMagicLink` signature if needed.
|
|
8
|
+
export async function sendMagicLink(sender, to, link, siteName, from) {
|
|
9
|
+
const expiry = "This link expires in 10 minutes and works only once. If you didn't request it, ignore this email.";
|
|
10
|
+
await sender.send({
|
|
11
|
+
to,
|
|
12
|
+
from,
|
|
13
|
+
subject: `Your ${siteName} sign-in link`,
|
|
14
|
+
text: `Sign in to ${siteName}:\n\n${link}\n\n${expiry}`,
|
|
15
|
+
html: `<p>Sign in to ${siteName}:</p><p><a href="${link}">Sign in</a></p><p style="color:#666;font-size:0.9em">${expiry}</p>`,
|
|
16
|
+
});
|
|
17
|
+
}
|
package/dist/github.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface RepoRef {
|
|
2
|
+
owner: string;
|
|
3
|
+
repo: string;
|
|
4
|
+
branch: string;
|
|
5
|
+
}
|
|
6
|
+
/** A markdown file in a collection directory. `id` is the slug (filename without `.md`). */
|
|
7
|
+
export interface RepoFile {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
path: string;
|
|
11
|
+
}
|
|
12
|
+
/** Build the contents-API URL for a repo path, pinned to the configured branch. */
|
|
13
|
+
export declare function contentsUrl(repo: RepoRef, path: string): string;
|
|
14
|
+
interface ContentsEntry {
|
|
15
|
+
name: string;
|
|
16
|
+
path: string;
|
|
17
|
+
type: string;
|
|
18
|
+
}
|
|
19
|
+
/** Keep only markdown files from a contents-API directory listing, newest id first. */
|
|
20
|
+
export declare function markdownFiles(entries: ContentsEntry[]): RepoFile[];
|
|
21
|
+
/** List the markdown files in a collection directory. */
|
|
22
|
+
export declare function listMarkdown(repo: RepoRef, dir: string, token?: string): Promise<RepoFile[]>;
|
|
23
|
+
/** Fetch a file's raw markdown, or null if it does not exist. */
|
|
24
|
+
export declare function readRaw(repo: RepoRef, path: string, token?: string): Promise<string | null>;
|
|
25
|
+
/** Mint a GitHub App JWT (RS256), valid ~9 min, with `iat` backdated for clock skew. */
|
|
26
|
+
export declare function appJwt(appId: string, privateKeyPem: string): Promise<string>;
|
|
27
|
+
export interface AppCredentials {
|
|
28
|
+
appId: string;
|
|
29
|
+
installationId: string;
|
|
30
|
+
/** The stored GITHUB_APP_PRIVATE_KEY_B64 — base64 of the PEM, single line. */
|
|
31
|
+
privateKeyB64: string;
|
|
32
|
+
}
|
|
33
|
+
/** Exchange the App JWT for a short-lived installation access token. */
|
|
34
|
+
export declare function installationToken(creds: AppCredentials): Promise<string>;
|
|
35
|
+
/** The current blob sha for a path, or null if the file does not yet exist. */
|
|
36
|
+
export declare function fileSha(repo: RepoRef, path: string, token: string): Promise<string | null>;
|
|
37
|
+
export interface CommitAuthor {
|
|
38
|
+
name: string;
|
|
39
|
+
email: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Commit `content` to `path` on the configured branch via the contents API. Author is the
|
|
43
|
+
* editor; committer is omitted so GitHub attributes it to the App (cairn-cms[bot]). Updates
|
|
44
|
+
* the file in place when it exists (passing its sha), creates it otherwise. Returns the
|
|
45
|
+
* commit sha.
|
|
46
|
+
*/
|
|
47
|
+
export declare function commitFile(repo: RepoRef, path: string, content: string, opts: {
|
|
48
|
+
message: string;
|
|
49
|
+
author: CommitAuthor;
|
|
50
|
+
}, token: string): Promise<string>;
|
|
51
|
+
export {};
|
|
52
|
+
//# sourceMappingURL=github.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"github.d.ts","sourceRoot":"","sources":["../src/lib/github.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,4FAA4F;AAC5F,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAcD,mFAAmF;AACnF,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAG/D;AAED,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,uFAAuF;AACvF,wBAAgB,aAAa,CAAC,OAAO,EAAE,aAAa,EAAE,GAAG,QAAQ,EAAE,CAKlE;AAED,yDAAyD;AACzD,wBAAsB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAIlG;AAED,iEAAiE;AACjE,wBAAsB,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKjG;AAqCD,wFAAwF;AACxF,wBAAsB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAclF;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,8EAA8E;IAC9E,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,wEAAwE;AACxE,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ9E;AAOD,+EAA+E;AAC/E,wBAAsB,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKhG;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,OAAO,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE,EAC/C,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,CAAC,CAgBjB"}
|
package/dist/github.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// cairn-core: read and write repository content through the GitHub API.
|
|
2
|
+
//
|
|
3
|
+
// Reads (Pass B) list a collection directory and fetch a file's raw markdown; the token
|
|
4
|
+
// is optional because ecnordic's repo is public. Writes (Pass C) mint a short-lived
|
|
5
|
+
// GitHub App installation token — App JWT (RS256) signed with Web Crypto, no octokit
|
|
6
|
+
// dependency — and commit through the contents API with author = editor, committer = the
|
|
7
|
+
// App (cairn-cms[bot]). The same token also lifts reads to the authenticated rate limit
|
|
8
|
+
// and unlocks private repos (e.g. 907-life).
|
|
9
|
+
import { bytesToB64url } from './utils';
|
|
10
|
+
const API = 'https://api.github.com';
|
|
11
|
+
function ghHeaders(accept, token) {
|
|
12
|
+
const headers = {
|
|
13
|
+
Accept: accept,
|
|
14
|
+
'User-Agent': 'cairn-cms',
|
|
15
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
16
|
+
};
|
|
17
|
+
if (token)
|
|
18
|
+
headers.Authorization = `Bearer ${token}`;
|
|
19
|
+
return headers;
|
|
20
|
+
}
|
|
21
|
+
/** Build the contents-API URL for a repo path, pinned to the configured branch. */
|
|
22
|
+
export function contentsUrl(repo, path) {
|
|
23
|
+
const clean = path.replace(/^\/+|\/+$/g, '');
|
|
24
|
+
return `${API}/repos/${repo.owner}/${repo.repo}/contents/${clean}?ref=${encodeURIComponent(repo.branch)}`;
|
|
25
|
+
}
|
|
26
|
+
/** Keep only markdown files from a contents-API directory listing, newest id first. */
|
|
27
|
+
export function markdownFiles(entries) {
|
|
28
|
+
return entries
|
|
29
|
+
.filter((entry) => entry.type === 'file' && entry.name.endsWith('.md'))
|
|
30
|
+
.map((entry) => ({ id: entry.name.replace(/\.md$/, ''), name: entry.name, path: entry.path }))
|
|
31
|
+
.sort((a, b) => b.id.localeCompare(a.id));
|
|
32
|
+
}
|
|
33
|
+
/** List the markdown files in a collection directory. */
|
|
34
|
+
export async function listMarkdown(repo, dir, token) {
|
|
35
|
+
const res = await fetch(contentsUrl(repo, dir), { headers: ghHeaders('application/vnd.github+json', token) });
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
throw new Error(`GitHub list ${dir} failed: ${res.status}`);
|
|
38
|
+
return markdownFiles((await res.json()));
|
|
39
|
+
}
|
|
40
|
+
/** Fetch a file's raw markdown, or null if it does not exist. */
|
|
41
|
+
export async function readRaw(repo, path, token) {
|
|
42
|
+
const res = await fetch(contentsUrl(repo, path), { headers: ghHeaders('application/vnd.github.raw', token) });
|
|
43
|
+
if (res.status === 404)
|
|
44
|
+
return null;
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
throw new Error(`GitHub read ${path} failed: ${res.status}`);
|
|
47
|
+
return res.text();
|
|
48
|
+
}
|
|
49
|
+
// --- Write path: GitHub App auth + commit (Pass C) -------------------------------------
|
|
50
|
+
const encoder = new TextEncoder();
|
|
51
|
+
// TextEncoder/atob produce Uint8Arrays whose generic buffer type no longer satisfies
|
|
52
|
+
// Web Crypto's BufferSource under strict lib types; hand the underlying ArrayBuffer over.
|
|
53
|
+
function buf(bytes) {
|
|
54
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
55
|
+
}
|
|
56
|
+
/** DER length octets for a value of `n` bytes (short form < 128, else long form). */
|
|
57
|
+
function derLength(n) {
|
|
58
|
+
if (n < 0x80)
|
|
59
|
+
return [n];
|
|
60
|
+
const out = [];
|
|
61
|
+
for (let v = n; v > 0; v >>= 8)
|
|
62
|
+
out.unshift(v & 0xff);
|
|
63
|
+
return [0x80 | out.length, ...out];
|
|
64
|
+
}
|
|
65
|
+
// AlgorithmIdentifier for rsaEncryption (OID 1.2.840.113549.1.1.1) with NULL parameters.
|
|
66
|
+
const RSA_ALG_ID = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00];
|
|
67
|
+
/** Wrap a PKCS#1 RSAPrivateKey (DER) as PKCS#8 — the only RSA form Web Crypto importKey takes. */
|
|
68
|
+
function pkcs1ToPkcs8(pkcs1) {
|
|
69
|
+
const octet = [0x04, ...derLength(pkcs1.length), ...pkcs1];
|
|
70
|
+
const body = [0x02, 0x01, 0x00, ...RSA_ALG_ID, ...octet];
|
|
71
|
+
return Uint8Array.from([0x30, ...derLength(body.length), ...body]);
|
|
72
|
+
}
|
|
73
|
+
/** Decode a PEM private key to PKCS#8 DER, converting from PKCS#1 (GitHub's format) if needed. */
|
|
74
|
+
function pemToPkcs8(pem) {
|
|
75
|
+
const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '');
|
|
76
|
+
const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
77
|
+
return pem.includes('RSA PRIVATE KEY') ? pkcs1ToPkcs8(der) : der;
|
|
78
|
+
}
|
|
79
|
+
/** Mint a GitHub App JWT (RS256), valid ~9 min, with `iat` backdated for clock skew. */
|
|
80
|
+
export async function appJwt(appId, privateKeyPem) {
|
|
81
|
+
const now = Math.floor(Date.now() / 1000);
|
|
82
|
+
const header = bytesToB64url(encoder.encode(JSON.stringify({ alg: 'RS256', typ: 'JWT' })));
|
|
83
|
+
const payload = bytesToB64url(encoder.encode(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId })));
|
|
84
|
+
const signingInput = `${header}.${payload}`;
|
|
85
|
+
const key = await crypto.subtle.importKey('pkcs8', buf(pemToPkcs8(privateKeyPem)), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
|
|
86
|
+
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, buf(encoder.encode(signingInput)));
|
|
87
|
+
return `${signingInput}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
88
|
+
}
|
|
89
|
+
/** Exchange the App JWT for a short-lived installation access token. */
|
|
90
|
+
export async function installationToken(creds) {
|
|
91
|
+
const jwt = await appJwt(creds.appId, atob(creds.privateKeyB64));
|
|
92
|
+
const res = await fetch(`${API}/app/installations/${creds.installationId}/access_tokens`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: ghHeaders('application/vnd.github+json', jwt),
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
throw new Error(`GitHub installation token failed: ${res.status}`);
|
|
98
|
+
return (await res.json()).token;
|
|
99
|
+
}
|
|
100
|
+
/** Standard (padded) base64 of UTF-8 text — the encoding the contents API expects. */
|
|
101
|
+
function toBase64(text) {
|
|
102
|
+
return btoa(Array.from(encoder.encode(text), (b) => String.fromCharCode(b)).join(''));
|
|
103
|
+
}
|
|
104
|
+
/** The current blob sha for a path, or null if the file does not yet exist. */
|
|
105
|
+
export async function fileSha(repo, path, token) {
|
|
106
|
+
const res = await fetch(contentsUrl(repo, path), { headers: ghHeaders('application/vnd.github+json', token) });
|
|
107
|
+
if (res.status === 404)
|
|
108
|
+
return null;
|
|
109
|
+
if (!res.ok)
|
|
110
|
+
throw new Error(`GitHub stat ${path} failed: ${res.status}`);
|
|
111
|
+
return (await res.json()).sha;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Commit `content` to `path` on the configured branch via the contents API. Author is the
|
|
115
|
+
* editor; committer is omitted so GitHub attributes it to the App (cairn-cms[bot]). Updates
|
|
116
|
+
* the file in place when it exists (passing its sha), creates it otherwise. Returns the
|
|
117
|
+
* commit sha.
|
|
118
|
+
*/
|
|
119
|
+
export async function commitFile(repo, path, content, opts, token) {
|
|
120
|
+
const sha = await fileSha(repo, path, token);
|
|
121
|
+
const url = `${API}/repos/${repo.owner}/${repo.repo}/contents/${path.replace(/^\/+|\/+$/g, '')}`;
|
|
122
|
+
const res = await fetch(url, {
|
|
123
|
+
method: 'PUT',
|
|
124
|
+
headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
message: opts.message,
|
|
127
|
+
content: toBase64(content),
|
|
128
|
+
branch: repo.branch,
|
|
129
|
+
author: opts.author,
|
|
130
|
+
...(sha ? { sha } : {}),
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
if (!res.ok)
|
|
134
|
+
throw new Error(`GitHub commit ${path} failed: ${res.status} ${await res.text()}`);
|
|
135
|
+
return (await res.json()).commit.sha;
|
|
136
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,QAAQ,CAAC;AACvB,cAAc,SAAS,CAAC;AACxB,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type Cookies } from '@sveltejs/kit';
|
|
2
|
+
import type { KVNamespace } from '@cloudflare/workers-types';
|
|
3
|
+
import { type Editor } from '../auth';
|
|
4
|
+
import { type EmailSender } from '../email';
|
|
5
|
+
import { type RepoFile } from '../github';
|
|
6
|
+
import { type CairnAdapter, type CairnField } from '../adapter';
|
|
7
|
+
/** The `platform.env` bindings the admin routes read. All optional — the handlers guard. */
|
|
8
|
+
export interface AdminEnv {
|
|
9
|
+
AUTH_KV?: KVNamespace;
|
|
10
|
+
MAGIC_LINK_SECRET?: string;
|
|
11
|
+
SESSION_SECRET?: string;
|
|
12
|
+
EMAIL?: EmailSender;
|
|
13
|
+
/** Overrides `url.origin` for the magic-link base (set in dev, unset in prod). */
|
|
14
|
+
PUBLIC_ORIGIN?: string;
|
|
15
|
+
GITHUB_APP_ID?: string;
|
|
16
|
+
GITHUB_APP_INSTALLATION_ID?: string;
|
|
17
|
+
GITHUB_APP_PRIVATE_KEY_B64?: string;
|
|
18
|
+
}
|
|
19
|
+
interface PlatformEvent {
|
|
20
|
+
platform?: {
|
|
21
|
+
env?: AdminEnv;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface AdminLayoutData {
|
|
25
|
+
editor: Editor | null;
|
|
26
|
+
siteName: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Branding + session for every admin page. `siteName` flows from the adapter without pulling
|
|
30
|
+
* its plugin graph into client bundles — the import stays server-side in the layout load.
|
|
31
|
+
*/
|
|
32
|
+
export declare function adminLayoutLoad(event: {
|
|
33
|
+
locals: {
|
|
34
|
+
editor: Editor | null;
|
|
35
|
+
};
|
|
36
|
+
}, adapter: CairnAdapter): AdminLayoutData;
|
|
37
|
+
export interface AdminCollectionList {
|
|
38
|
+
type: string;
|
|
39
|
+
label: string;
|
|
40
|
+
files: RepoFile[];
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
/** List every collection's markdown files. A failed listing degrades to an inline error. */
|
|
44
|
+
export declare function adminListLoad(adapter: CairnAdapter): Promise<{
|
|
45
|
+
collections: AdminCollectionList[];
|
|
46
|
+
}>;
|
|
47
|
+
export interface LoginData {
|
|
48
|
+
sent: boolean;
|
|
49
|
+
error: string | null;
|
|
50
|
+
}
|
|
51
|
+
export declare function loginLoad(event: {
|
|
52
|
+
url: URL;
|
|
53
|
+
}): LoginData;
|
|
54
|
+
export interface EditData {
|
|
55
|
+
type: string;
|
|
56
|
+
id: string;
|
|
57
|
+
label: string;
|
|
58
|
+
fields: CairnField[];
|
|
59
|
+
path: string;
|
|
60
|
+
body: string;
|
|
61
|
+
frontmatter: Record<string, unknown>;
|
|
62
|
+
title: string;
|
|
63
|
+
saved: boolean;
|
|
64
|
+
error: string | null;
|
|
65
|
+
}
|
|
66
|
+
export declare function editLoad(event: {
|
|
67
|
+
params: {
|
|
68
|
+
type: string;
|
|
69
|
+
id: string;
|
|
70
|
+
};
|
|
71
|
+
url: URL;
|
|
72
|
+
}, adapter: CairnAdapter): Promise<EditData>;
|
|
73
|
+
export declare function authRequest(event: PlatformEvent & {
|
|
74
|
+
request: Request;
|
|
75
|
+
url: URL;
|
|
76
|
+
}, adapter: CairnAdapter): Promise<never>;
|
|
77
|
+
export declare function authCallback(event: PlatformEvent & {
|
|
78
|
+
url: URL;
|
|
79
|
+
cookies: Cookies;
|
|
80
|
+
}): Promise<never>;
|
|
81
|
+
export declare function logout(event: {
|
|
82
|
+
cookies: Cookies;
|
|
83
|
+
}): never;
|
|
84
|
+
export declare function saveCommit(event: PlatformEvent & {
|
|
85
|
+
request: Request;
|
|
86
|
+
locals: {
|
|
87
|
+
editor: Editor | null;
|
|
88
|
+
};
|
|
89
|
+
}, adapter: CairnAdapter): Promise<never>;
|
|
90
|
+
export {};
|
|
91
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// cairn-core: the SvelteKit route server logic, extracted so each site's `admin/**` route
|
|
2
|
+
// files are thin shims (`export const load = (event) => editLoad(event, cairn)`).
|
|
3
|
+
//
|
|
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 — only the adapter differs.
|
|
6
|
+
// These functions take the SvelteKit event (typed structurally, to avoid depending on the
|
|
7
|
+
// site-generated `App.*` ambient types) plus the site `CairnAdapter`, and throw
|
|
8
|
+
// `redirect`/`error` from `@sveltejs/kit`. That `@sveltejs/kit` is a peer dependency so the
|
|
9
|
+
// thrown objects share class identity with the host's runtime (else the redirect 500s).
|
|
10
|
+
import { redirect, error } from '@sveltejs/kit';
|
|
11
|
+
import matter from 'gray-matter';
|
|
12
|
+
import { createMagicLink, redeemMagicToken, createSession, lookupEditor, SESSION_COOKIE, SESSION_MAX_AGE, } from '../auth';
|
|
13
|
+
import { sendMagicLink } from '../email';
|
|
14
|
+
import { listMarkdown, readRaw, commitFile, installationToken } from '../github';
|
|
15
|
+
import { serializeMarkdown } from '../content';
|
|
16
|
+
import { findCollection, frontmatterFromForm } from '../adapter';
|
|
17
|
+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
18
|
+
/**
|
|
19
|
+
* Branding + session for every admin page. `siteName` flows from the adapter without pulling
|
|
20
|
+
* its plugin graph into client bundles — the import stays server-side in the layout load.
|
|
21
|
+
*/
|
|
22
|
+
export function adminLayoutLoad(event, adapter) {
|
|
23
|
+
return { editor: event.locals.editor, siteName: adapter.siteName };
|
|
24
|
+
}
|
|
25
|
+
/** List every collection's markdown files. A failed listing degrades to an inline error. */
|
|
26
|
+
export async function adminListLoad(adapter) {
|
|
27
|
+
const collections = await Promise.all(adapter.collections.map(async ({ type, label, dir }) => {
|
|
28
|
+
try {
|
|
29
|
+
return { type, label, files: await listMarkdown(adapter.backend, dir) };
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
// A failed listing (rate limit, network) shouldn't 500 the whole admin.
|
|
33
|
+
return { type, label, files: [], error: err instanceof Error ? err.message : 'Failed to load' };
|
|
34
|
+
}
|
|
35
|
+
}));
|
|
36
|
+
return { collections };
|
|
37
|
+
}
|
|
38
|
+
export function loginLoad(event) {
|
|
39
|
+
return {
|
|
40
|
+
sent: event.url.searchParams.get('sent') === '1',
|
|
41
|
+
error: event.url.searchParams.get('error'),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export async function editLoad(event, adapter) {
|
|
45
|
+
const collection = findCollection(adapter, event.params.type);
|
|
46
|
+
if (!collection)
|
|
47
|
+
throw error(404, 'Unknown collection');
|
|
48
|
+
// Anonymous read — repos are public; the GitHub App token is commit-only (see saveCommit).
|
|
49
|
+
const path = `${collection.dir}/${event.params.id}.md`;
|
|
50
|
+
const raw = await readRaw(adapter.backend, path);
|
|
51
|
+
if (raw === null)
|
|
52
|
+
throw error(404, 'Content not found');
|
|
53
|
+
// Split frontmatter from body server-side; the editor form binds to the frontmatter and
|
|
54
|
+
// the Carta editor binds to the body, and /admin/save reassembles them on commit.
|
|
55
|
+
const { data: frontmatter, content: body } = matter(raw);
|
|
56
|
+
return {
|
|
57
|
+
type: event.params.type,
|
|
58
|
+
id: event.params.id,
|
|
59
|
+
label: collection.label,
|
|
60
|
+
fields: collection.fields,
|
|
61
|
+
path,
|
|
62
|
+
body,
|
|
63
|
+
frontmatter,
|
|
64
|
+
title: typeof frontmatter.title === 'string' ? frontmatter.title : event.params.id,
|
|
65
|
+
saved: event.url.searchParams.get('saved') === '1',
|
|
66
|
+
error: event.url.searchParams.get('error'),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// ── /admin/auth/request (POST) ──────────────────────────────────────────────
|
|
70
|
+
export async function authRequest(event, adapter) {
|
|
71
|
+
const env = event.platform?.env;
|
|
72
|
+
if (!env?.AUTH_KV || !env.MAGIC_LINK_SECRET || !env.EMAIL) {
|
|
73
|
+
throw redirect(303, '/admin/login?error=config');
|
|
74
|
+
}
|
|
75
|
+
const form = await event.request.formData();
|
|
76
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
77
|
+
if (!EMAIL_RE.test(email)) {
|
|
78
|
+
throw redirect(303, '/admin/login?error=invalid');
|
|
79
|
+
}
|
|
80
|
+
const editor = await lookupEditor(email, env.AUTH_KV);
|
|
81
|
+
if (!editor) {
|
|
82
|
+
throw redirect(303, '/admin/login?error=denied');
|
|
83
|
+
}
|
|
84
|
+
const token = await createMagicLink(email, env.MAGIC_LINK_SECRET, env.AUTH_KV);
|
|
85
|
+
// PUBLIC_ORIGIN overrides url.origin for local dev (where wrangler's custom-domain
|
|
86
|
+
// route makes url.origin the production host); unset in prod → url.origin is correct.
|
|
87
|
+
const origin = env.PUBLIC_ORIGIN || event.url.origin;
|
|
88
|
+
const link = `${origin}/admin/auth/callback?token=${encodeURIComponent(token)}`;
|
|
89
|
+
try {
|
|
90
|
+
await sendMagicLink(env.EMAIL, email, link, adapter.siteName, adapter.sender);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error('magic-link send failed:', err);
|
|
94
|
+
throw redirect(303, '/admin/login?error=config');
|
|
95
|
+
}
|
|
96
|
+
throw redirect(303, '/admin/login?sent=1');
|
|
97
|
+
}
|
|
98
|
+
// ── /admin/auth/callback (GET) ──────────────────────────────────────────────
|
|
99
|
+
export async function authCallback(event) {
|
|
100
|
+
const env = event.platform?.env;
|
|
101
|
+
if (!env?.AUTH_KV || !env.MAGIC_LINK_SECRET || !env.SESSION_SECRET) {
|
|
102
|
+
throw redirect(303, '/admin/login?error=config');
|
|
103
|
+
}
|
|
104
|
+
const token = event.url.searchParams.get('token') ?? '';
|
|
105
|
+
const email = await redeemMagicToken(token, env.MAGIC_LINK_SECRET, env.AUTH_KV);
|
|
106
|
+
if (!email) {
|
|
107
|
+
throw redirect(303, '/admin/login?error=expired');
|
|
108
|
+
}
|
|
109
|
+
// Re-check the allowlist at redemption — membership may have changed since issue.
|
|
110
|
+
const editor = await lookupEditor(email, env.AUTH_KV);
|
|
111
|
+
if (!editor) {
|
|
112
|
+
throw redirect(303, '/admin/login?error=denied');
|
|
113
|
+
}
|
|
114
|
+
const session = await createSession(editor, env.SESSION_SECRET);
|
|
115
|
+
event.cookies.set(SESSION_COOKIE, session, {
|
|
116
|
+
path: '/',
|
|
117
|
+
httpOnly: true,
|
|
118
|
+
secure: event.url.protocol === 'https:',
|
|
119
|
+
sameSite: 'lax',
|
|
120
|
+
maxAge: SESSION_MAX_AGE,
|
|
121
|
+
});
|
|
122
|
+
throw redirect(303, '/admin');
|
|
123
|
+
}
|
|
124
|
+
// ── /admin/auth/logout (POST) ───────────────────────────────────────────────
|
|
125
|
+
export function logout(event) {
|
|
126
|
+
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
|
127
|
+
throw redirect(303, '/admin/login');
|
|
128
|
+
}
|
|
129
|
+
// ── /admin/save (POST) ──────────────────────────────────────────────────────
|
|
130
|
+
export async function saveCommit(event, adapter) {
|
|
131
|
+
const editor = event.locals.editor;
|
|
132
|
+
if (!editor)
|
|
133
|
+
throw error(401, 'Not signed in');
|
|
134
|
+
const env = event.platform?.env;
|
|
135
|
+
if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
136
|
+
throw error(500, 'GitHub App is not configured');
|
|
137
|
+
}
|
|
138
|
+
const form = await event.request.formData();
|
|
139
|
+
const type = String(form.get('type') ?? '');
|
|
140
|
+
const id = String(form.get('id') ?? '');
|
|
141
|
+
const body = String(form.get('body') ?? '');
|
|
142
|
+
const collection = findCollection(adapter, type);
|
|
143
|
+
if (!collection || !id)
|
|
144
|
+
throw error(400, 'Bad request');
|
|
145
|
+
// Build frontmatter from the posted fields and validate against the collection's schema; a
|
|
146
|
+
// bad field bounces back to the editor with the validator's message rather than 500ing.
|
|
147
|
+
let frontmatter;
|
|
148
|
+
try {
|
|
149
|
+
frontmatter = collection.validate(frontmatterFromForm(collection, form), `${id}.md`);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
const message = err instanceof Error ? err.message : 'Invalid frontmatter';
|
|
153
|
+
throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}`);
|
|
154
|
+
}
|
|
155
|
+
const markdown = serializeMarkdown(frontmatter, body);
|
|
156
|
+
const token = await installationToken({
|
|
157
|
+
appId: env.GITHUB_APP_ID,
|
|
158
|
+
installationId: env.GITHUB_APP_INSTALLATION_ID,
|
|
159
|
+
privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
|
|
160
|
+
});
|
|
161
|
+
await commitFile(adapter.backend, `${collection.dir}/${id}.md`, markdown, { message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: editor.name, email: editor.email } }, token);
|
|
162
|
+
throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
|
|
163
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/lib/utils.ts"],"names":[],"mappings":"AAOA,oFAAoF;AACpF,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAGvD"}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// cairn-core: internal encoding helpers shared across modules.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately NOT re-exported from index.ts — these are implementation details of the
|
|
4
|
+
// auth/github crypto, not part of the public API (auth.ts signs tokens, github.ts builds
|
|
5
|
+
// the App JWT; both need base64url). Keeping them here stops bytesToB64url leaking through
|
|
6
|
+
// the `export *` barrel.
|
|
7
|
+
/** Encode bytes as unpadded base64url (RFC 4648 §5) — the JWT/token wire format. */
|
|
8
|
+
export function bytesToB64url(bytes) {
|
|
9
|
+
const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join('');
|
|
10
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
11
|
+
}
|