@glw907/cairn-cms 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- 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/components/AdminLayout.svelte +6 -6
- package/dist/components/AdminLayout.svelte.d.ts +2 -2
- package/dist/components/AdminLayout.svelte.d.ts.map +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/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 +15 -7
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/sveltekit/index.d.ts +7 -60
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +37 -157
- package/package.json +34 -4
- 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/components/AdminLayout.svelte +6 -6
- package/src/lib/components/ConfirmPage.svelte +31 -0
- 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 +14 -7
- package/src/lib/index.ts +2 -2
- package/src/lib/sveltekit/index.ts +46 -228
- 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
package/src/lib/auth.ts
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
// cairn-core: magic-link auth + signed sessions.
|
|
2
|
-
//
|
|
3
|
-
// Generic across sites — no ecnordic specifics here. Crypto is Web Crypto (HMAC-SHA256)
|
|
4
|
-
// so it runs unchanged on Cloudflare Workers under nodejs_compat. Single-use enforcement
|
|
5
|
-
// for magic links rides on a KV nonce; signature + expiry are self-contained in the token.
|
|
6
|
-
|
|
7
|
-
import type { KVNamespace } from '@cloudflare/workers-types';
|
|
8
|
-
import { bytesToB64url } from './utils';
|
|
9
|
-
|
|
10
|
-
/** Two-tier, per-site role. `owner`s manage the editor allowlist; `editor`s only edit content. */
|
|
11
|
-
export type Role = 'owner' | 'editor';
|
|
12
|
-
|
|
13
|
-
export interface Editor {
|
|
14
|
-
email: string;
|
|
15
|
-
name: string;
|
|
16
|
-
role: Role;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const SESSION_COOKIE = 'cairn_session';
|
|
20
|
-
|
|
21
|
-
const MAGIC_TTL_SECONDS = 600; // 10 minutes
|
|
22
|
-
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
|
23
|
-
|
|
24
|
-
export const SESSION_MAX_AGE = SESSION_TTL_SECONDS;
|
|
25
|
-
|
|
26
|
-
const encoder = new TextEncoder();
|
|
27
|
-
const decoder = new TextDecoder();
|
|
28
|
-
|
|
29
|
-
function b64urlToBytes(value: string): Uint8Array {
|
|
30
|
-
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
31
|
-
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
32
|
-
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// TextEncoder/atob produce Uint8Arrays whose generic buffer type no longer satisfies
|
|
36
|
-
// Web Crypto's BufferSource under strict lib types; hand the underlying ArrayBuffer over.
|
|
37
|
-
function buf(bytes: Uint8Array): ArrayBuffer {
|
|
38
|
-
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function hmacKey(secret: string): Promise<CryptoKey> {
|
|
42
|
-
return crypto.subtle.importKey(
|
|
43
|
-
'raw',
|
|
44
|
-
buf(encoder.encode(secret)),
|
|
45
|
-
{ name: 'HMAC', hash: 'SHA-256' },
|
|
46
|
-
false,
|
|
47
|
-
['sign', 'verify'],
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Sign an arbitrary JSON payload as `<base64url(payload)>.<base64url(hmac)>`. */
|
|
52
|
-
async function signToken(data: unknown, secret: string): Promise<string> {
|
|
53
|
-
const payload = bytesToB64url(encoder.encode(JSON.stringify(data)));
|
|
54
|
-
const key = await hmacKey(secret);
|
|
55
|
-
const sig = await crypto.subtle.sign('HMAC', key, buf(encoder.encode(payload)));
|
|
56
|
-
return `${payload}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Verify signature (constant-time via subtle.verify) and parse the payload, or null. */
|
|
60
|
-
async function verifyToken<T>(token: string, secret: string): Promise<T | null> {
|
|
61
|
-
const dot = token.indexOf('.');
|
|
62
|
-
if (dot < 0) return null;
|
|
63
|
-
const payload = token.slice(0, dot);
|
|
64
|
-
const sig = token.slice(dot + 1);
|
|
65
|
-
const key = await hmacKey(secret);
|
|
66
|
-
let ok = false;
|
|
67
|
-
try {
|
|
68
|
-
ok = await crypto.subtle.verify('HMAC', key, buf(b64urlToBytes(sig)), buf(encoder.encode(payload)));
|
|
69
|
-
} catch {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
if (!ok) return null;
|
|
73
|
-
try {
|
|
74
|
-
return JSON.parse(decoder.decode(b64urlToBytes(payload))) as T;
|
|
75
|
-
} catch {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
interface MagicPayload {
|
|
81
|
-
email: string;
|
|
82
|
-
exp: number;
|
|
83
|
-
nonce: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Issue a single-use magic-link token and register its nonce in KV with a TTL. */
|
|
87
|
-
export async function createMagicLink(
|
|
88
|
-
email: string,
|
|
89
|
-
secret: string,
|
|
90
|
-
kv: KVNamespace,
|
|
91
|
-
): Promise<string> {
|
|
92
|
-
const nonce = bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
|
|
93
|
-
const exp = Date.now() + MAGIC_TTL_SECONDS * 1000;
|
|
94
|
-
const token = await signToken({ email, exp, nonce } satisfies MagicPayload, secret);
|
|
95
|
-
await kv.put(`ml:${nonce}`, email, { expirationTtl: MAGIC_TTL_SECONDS });
|
|
96
|
-
return token;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Redeem a magic-link token: verify, check expiry, then consume the KV nonce (single use). */
|
|
100
|
-
export async function redeemMagicToken(
|
|
101
|
-
token: string,
|
|
102
|
-
secret: string,
|
|
103
|
-
kv: KVNamespace,
|
|
104
|
-
): Promise<string | null> {
|
|
105
|
-
const payload = await verifyToken<MagicPayload>(token, secret);
|
|
106
|
-
if (!payload || Date.now() > payload.exp) return null;
|
|
107
|
-
const stored = await kv.get(`ml:${payload.nonce}`);
|
|
108
|
-
if (stored !== payload.email) return null;
|
|
109
|
-
await kv.delete(`ml:${payload.nonce}`); // burn it — single use
|
|
110
|
-
return payload.email;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
interface SessionPayload extends Editor {
|
|
114
|
-
exp: number;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export async function createSession(editor: Editor, secret: string): Promise<string> {
|
|
118
|
-
const exp = Date.now() + SESSION_TTL_SECONDS * 1000;
|
|
119
|
-
return signToken({ ...editor, exp } satisfies SessionPayload, secret);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export async function verifySession(token: string, secret: string): Promise<Editor | null> {
|
|
123
|
-
const payload = await verifyToken<SessionPayload>(token, secret);
|
|
124
|
-
if (!payload || Date.now() > payload.exp) return null;
|
|
125
|
-
// Sessions signed before roles existed carry no `role` — treat them as plain editors.
|
|
126
|
-
return { email: payload.email, name: payload.name, role: payload.role ?? 'editor' };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const KEY_PREFIX = 'editor:';
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Decode a stored allowlist value into name + role. Current entries are JSON
|
|
133
|
-
* (`{"name","role"}`); legacy entries are a bare display-name string, read as `editor`
|
|
134
|
-
* so the allowlist migrates lazily — re-saving an entry upgrades it to the JSON shape.
|
|
135
|
-
*/
|
|
136
|
-
function parseEditorValue(raw: string): { name: string; role: Role } {
|
|
137
|
-
try {
|
|
138
|
-
const parsed = JSON.parse(raw) as { name?: unknown; role?: unknown };
|
|
139
|
-
if (parsed && typeof parsed.name === 'string') {
|
|
140
|
-
return { name: parsed.name, role: parsed.role === 'owner' ? 'owner' : 'editor' };
|
|
141
|
-
}
|
|
142
|
-
} catch {
|
|
143
|
-
// Not JSON — legacy bare display-name; treat as editor.
|
|
144
|
-
}
|
|
145
|
-
return { name: raw, role: 'editor' };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function serializeEditorValue(name: string, role: Role): string {
|
|
149
|
-
return JSON.stringify({ name, role });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Look up an editor in the KV allowlist (`editor:<email>` → `{name, role}`). */
|
|
153
|
-
export async function lookupEditor(email: string, kv: KVNamespace): Promise<Editor | null> {
|
|
154
|
-
const normalized = email.trim().toLowerCase();
|
|
155
|
-
const raw = await kv.get(`${KEY_PREFIX}${normalized}`);
|
|
156
|
-
if (raw === null) return null;
|
|
157
|
-
return { email: normalized, ...parseEditorValue(raw) };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Every allowlisted editor, sorted by email — the manage-admins list. */
|
|
161
|
-
export async function listEditors(kv: KVNamespace): Promise<Editor[]> {
|
|
162
|
-
const { keys } = await kv.list({ prefix: KEY_PREFIX });
|
|
163
|
-
const editors = await Promise.all(
|
|
164
|
-
keys.map(async ({ name: key }): Promise<Editor> => {
|
|
165
|
-
const raw = (await kv.get(key)) ?? '';
|
|
166
|
-
return { email: key.slice(KEY_PREFIX.length), ...parseEditorValue(raw) };
|
|
167
|
-
}),
|
|
168
|
-
);
|
|
169
|
-
return editors.sort((a, b) => a.email.localeCompare(b.email));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Add or update an allowlist entry (JSON value). Email is normalized. */
|
|
173
|
-
export async function setEditor(
|
|
174
|
-
email: string,
|
|
175
|
-
name: string,
|
|
176
|
-
role: Role,
|
|
177
|
-
kv: KVNamespace,
|
|
178
|
-
): Promise<void> {
|
|
179
|
-
await kv.put(`${KEY_PREFIX}${email.trim().toLowerCase()}`, serializeEditorValue(name, role));
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Remove an allowlist entry. */
|
|
183
|
-
export async function removeEditor(email: string, kv: KVNamespace): Promise<void> {
|
|
184
|
-
await kv.delete(`${KEY_PREFIX}${email.trim().toLowerCase()}`);
|
|
185
|
-
}
|