@cancia/astro 0.11.0 → 0.12.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 (35) hide show
  1. package/dist/chunk-4PYVIIOO.js +25 -0
  2. package/dist/{chunk-GD37XVES.js → chunk-5R3I5RLT.js} +6 -8
  3. package/dist/chunk-6GUOGDWO.js +172 -0
  4. package/dist/chunk-DEI3FBNY.js +65 -0
  5. package/dist/{chunk-T2GDBQJ7.js → chunk-DOFLZ7KG.js} +5 -6
  6. package/dist/{chunk-GJIX7MWC.js → chunk-ECPYHIAW.js} +9 -1
  7. package/dist/{chunk-TUZNTXZR.js → chunk-R7VA3Z5N.js} +8 -2
  8. package/dist/{chunk-ZPXMPLQU.js → chunk-VF3EXXEM.js} +130 -7
  9. package/dist/chunk-XZCXWILA.js +145 -0
  10. package/dist/{upload-DwCGjXbz.d.ts → email-BRK4WAO2.d.ts} +9 -1
  11. package/dist/endpoints/auth-callback.d.ts +1 -0
  12. package/dist/endpoints/auth-callback.js +9 -0
  13. package/dist/endpoints/auth-logout.d.ts +1 -0
  14. package/dist/endpoints/auth-logout.js +9 -0
  15. package/dist/endpoints/auth-request.d.ts +18 -0
  16. package/dist/endpoints/auth-request.js +9 -0
  17. package/dist/endpoints/auth-session.d.ts +1 -0
  18. package/dist/endpoints/auth-session.js +9 -0
  19. package/dist/endpoints/auth.js +10 -2
  20. package/dist/endpoints/content.js +12 -12
  21. package/dist/endpoints/lists.js +4 -1
  22. package/dist/endpoints/publish.js +8 -6
  23. package/dist/endpoints/schemas.js +5 -2
  24. package/dist/endpoints/upload.js +7 -6
  25. package/dist/{git-backed-DtiH52EI.d.ts → git-backed-CwTFfUS4.d.ts} +2 -2
  26. package/dist/{github-client-Db0pIrqE.d.ts → github-client-Db5EPcSL.d.ts} +1 -1
  27. package/dist/index.d.ts +32 -6
  28. package/dist/index.js +117 -27
  29. package/dist/loader/index.d.ts +12 -2
  30. package/dist/loader/index.js +1 -1
  31. package/dist/runtime.d.ts +69 -3
  32. package/dist/runtime.js +3 -1
  33. package/dist/storage/index.d.ts +4 -4
  34. package/dist/{types-BMlLS-OS.d.ts → types-CKzfz5iu.d.ts} +1 -1
  35. package/package.json +1 -1
@@ -0,0 +1,25 @@
1
+ import {
2
+ authorizeRequest
3
+ } from "./chunk-6GUOGDWO.js";
4
+
5
+ // src/auth/guard.ts
6
+ var unauthorized = () => new Response(JSON.stringify({ error: "Unauthorized" }), {
7
+ status: 401,
8
+ headers: { "Content-Type": "application/json" }
9
+ });
10
+ async function guardRequest(request, cfg) {
11
+ if (!cfg.secret && !cfg.auth) {
12
+ return { deny: null, email: null };
13
+ }
14
+ const result = await authorizeRequest({
15
+ request,
16
+ secret: cfg.secret,
17
+ auth: cfg.auth
18
+ });
19
+ if (!result.ok) return { deny: unauthorized(), email: null };
20
+ return { deny: null, email: result.email };
21
+ }
22
+
23
+ export {
24
+ guardRequest
25
+ };
@@ -1,3 +1,6 @@
1
+ import {
2
+ guardRequest
3
+ } from "./chunk-4PYVIIOO.js";
1
4
  import {
2
5
  loadListSchema
3
6
  } from "./chunk-VL6FO446.js";
@@ -23,13 +26,8 @@ function getSite(request) {
23
26
  function getLocale(request) {
24
27
  return new URL(request.url).searchParams.get("locale");
25
28
  }
26
- function checkAuth(request, secret) {
27
- if (!secret) return null;
28
- const token = request.headers.get("Authorization")?.replace("Bearer ", "").trim();
29
- if (token !== secret) {
30
- return json({ error: "Unauthorized" }, 401);
31
- }
32
- return null;
29
+ async function checkAuth(request, secret, auth) {
30
+ return (await guardRequest(request, { secret, auth })).deny;
33
31
  }
34
32
  function json(body, status = 200) {
35
33
  return new Response(JSON.stringify(body), {
@@ -45,7 +43,7 @@ function makeListsRoutes(ctx) {
45
43
  if (!ctx.storageV2) {
46
44
  return json({ error: "Storage v2 not configured", code: "NO_STORAGE_V2" }, 503);
47
45
  }
48
- const deny = checkAuth(request, ctx.secret);
46
+ const deny = await checkAuth(request, ctx.secret, ctx.auth);
49
47
  if (deny) return deny;
50
48
  const parsed = parseListPath(request.url);
51
49
  if (!parsed) return json({ error: "Bad request" }, 400);
@@ -0,0 +1,172 @@
1
+ // src/auth/cookie.ts
2
+ var SESSION_COOKIE = "cancia_session";
3
+ function serialiseSessionCookie(value, opts = {}) {
4
+ const parts = [
5
+ `${SESSION_COOKIE}=${value}`,
6
+ "Path=/",
7
+ "HttpOnly",
8
+ "SameSite=Lax",
9
+ `Max-Age=${opts.maxAgeSeconds ?? 60 * 60 * 24 * 30}`
10
+ ];
11
+ if (opts.secure !== false) parts.push("Secure");
12
+ return parts.join("; ");
13
+ }
14
+ function clearSessionCookie(opts = {}) {
15
+ const parts = [
16
+ `${SESSION_COOKIE}=`,
17
+ "Path=/",
18
+ "HttpOnly",
19
+ "SameSite=Lax",
20
+ "Max-Age=0"
21
+ ];
22
+ if (opts.secure !== false) parts.push("Secure");
23
+ return parts.join("; ");
24
+ }
25
+ function readSessionCookie(request) {
26
+ const header = request.headers.get("cookie");
27
+ if (!header) return void 0;
28
+ const values = [];
29
+ for (const pair of header.split(";")) {
30
+ const eq = pair.indexOf("=");
31
+ if (eq === -1) continue;
32
+ if (pair.slice(0, eq).trim() !== SESSION_COOKIE) continue;
33
+ const value = pair.slice(eq + 1).trim();
34
+ if (value !== "") values.push(value);
35
+ }
36
+ return values.length > 0 ? values[values.length - 1] : void 0;
37
+ }
38
+ function isSecureRequest(request) {
39
+ const proto = request.headers.get("x-forwarded-proto");
40
+ if (proto) return proto.split(",")[0].trim() === "https";
41
+ try {
42
+ const url = new URL(request.url);
43
+ if (url.protocol === "https:") return true;
44
+ if (url.hostname === "localhost" || url.hostname === "127.0.0.1") return false;
45
+ } catch {
46
+ }
47
+ return true;
48
+ }
49
+
50
+ // src/auth/tokens.ts
51
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
52
+ var TOKEN_BYTES = 32;
53
+ function mintToken() {
54
+ return randomBytes(TOKEN_BYTES).toString("base64url");
55
+ }
56
+ function hashToken(raw) {
57
+ return createHash("sha256").update(raw, "utf8").digest("hex");
58
+ }
59
+ function safeEqual(a, b) {
60
+ const bufA = Buffer.from(a, "utf8");
61
+ const bufB = Buffer.from(b, "utf8");
62
+ if (bufA.length !== bufB.length) {
63
+ timingSafeEqual(bufA, bufA);
64
+ return false;
65
+ }
66
+ return timingSafeEqual(bufA, bufB);
67
+ }
68
+ function normaliseEmail(email) {
69
+ return email.trim().toLowerCase();
70
+ }
71
+
72
+ // src/auth/magic-link.ts
73
+ var LOGIN_TOKEN_TTL_MS = 15 * 60 * 1e3;
74
+ var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
75
+ var SESSION_TOUCH_AFTER_MS = 24 * 60 * 60 * 1e3;
76
+ async function requestLoginLink(store, site, rawEmail, now = Date.now()) {
77
+ const email = normaliseEmail(rawEmail);
78
+ if (!await store.isEditor(site, email)) {
79
+ return { token: null, email };
80
+ }
81
+ const token = mintToken();
82
+ await store.createLoginToken(site, {
83
+ hash: hashToken(token),
84
+ email,
85
+ expiresAt: new Date(now + LOGIN_TOKEN_TTL_MS).toISOString()
86
+ });
87
+ return { token, email };
88
+ }
89
+ var FAILED = { sessionToken: null, email: null, expiresAt: null };
90
+ async function redeemLoginToken(store, site, rawToken, now = Date.now()) {
91
+ if (!rawToken) return FAILED;
92
+ const record = await store.consumeLoginToken(site, hashToken(rawToken));
93
+ if (!record) return FAILED;
94
+ if (Date.parse(record.expiresAt) <= now) return FAILED;
95
+ if (!await store.isEditor(site, record.email)) return FAILED;
96
+ const sessionToken = mintToken();
97
+ const expiresAt = new Date(now + SESSION_TTL_MS).toISOString();
98
+ await store.createSession(site, {
99
+ id: hashToken(sessionToken),
100
+ email: record.email,
101
+ createdAt: new Date(now).toISOString(),
102
+ expiresAt
103
+ });
104
+ return { sessionToken, email: record.email, expiresAt };
105
+ }
106
+ var INVALID = { valid: false, email: null };
107
+ async function verifySession(store, site, rawToken, now = Date.now()) {
108
+ if (!rawToken) return INVALID;
109
+ const id = hashToken(rawToken);
110
+ const session = await store.getSession(site, id);
111
+ if (!session) return INVALID;
112
+ if (Date.parse(session.expiresAt) <= now) {
113
+ await store.deleteSession(site, id);
114
+ return INVALID;
115
+ }
116
+ if (!await store.isEditor(site, session.email)) {
117
+ await store.deleteSession(site, id);
118
+ return INVALID;
119
+ }
120
+ const elapsed = now - Date.parse(session.createdAt);
121
+ const remaining = Date.parse(session.expiresAt) - now;
122
+ if (elapsed > 0 && remaining < SESSION_TTL_MS - SESSION_TOUCH_AFTER_MS) {
123
+ const renewedExpiresAt = new Date(now + SESSION_TTL_MS).toISOString();
124
+ await store.touchSession(site, id, renewedExpiresAt);
125
+ return { valid: true, email: session.email, renewedExpiresAt };
126
+ }
127
+ return { valid: true, email: session.email };
128
+ }
129
+ async function revokeSession(store, site, rawToken) {
130
+ if (!rawToken) return;
131
+ await store.deleteSession(site, hashToken(rawToken));
132
+ }
133
+
134
+ // src/auth/authorize.ts
135
+ var DENY = { ok: false, email: null };
136
+ async function authorizeRequest(cfg) {
137
+ const { request, secret, auth, editorsConfigured } = cfg;
138
+ if (auth) {
139
+ const raw = readSessionCookie(request);
140
+ if (raw) {
141
+ const verdict = await verifySession(auth.store, auth.site, raw, cfg.now);
142
+ if (verdict.valid) {
143
+ return {
144
+ ok: true,
145
+ email: verdict.email,
146
+ renewedExpiresAt: verdict.renewedExpiresAt
147
+ };
148
+ }
149
+ }
150
+ }
151
+ const header = request.headers.get("Authorization");
152
+ if (header && secret) {
153
+ const token = header.replace("Bearer ", "").trim();
154
+ if (safeEqual(token, secret)) {
155
+ return { ok: true, email: null };
156
+ }
157
+ }
158
+ return DENY;
159
+ }
160
+
161
+ export {
162
+ serialiseSessionCookie,
163
+ clearSessionCookie,
164
+ readSessionCookie,
165
+ isSecureRequest,
166
+ normaliseEmail,
167
+ SESSION_TTL_MS,
168
+ requestLoginLink,
169
+ redeemLoginToken,
170
+ revokeSession,
171
+ authorizeRequest
172
+ };
@@ -0,0 +1,65 @@
1
+ import {
2
+ handleLoginCallback,
3
+ handleLoginRequest,
4
+ handleLogout,
5
+ handleSessionCheck,
6
+ resolveLoginEmailSender
7
+ } from "./chunk-XZCXWILA.js";
8
+ import {
9
+ createRateLimiter
10
+ } from "./chunk-ECPYHIAW.js";
11
+ import {
12
+ authorizeRequest
13
+ } from "./chunk-6GUOGDWO.js";
14
+
15
+ // src/endpoints/auth-magic.ts
16
+ import { getCanciaRuntime } from "virtual:cancia/runtime";
17
+ var limiter = createRateLimiter();
18
+ var notConfigured = () => new Response(JSON.stringify({ error: "Magic-link login is not configured for this site." }), {
19
+ status: 404,
20
+ headers: { "Content-Type": "application/json" }
21
+ });
22
+ function ctxFrom(rt) {
23
+ if (!rt.auth) return null;
24
+ return {
25
+ store: rt.auth.store,
26
+ site: rt.auth.site,
27
+ sendEmail: resolveLoginEmailSender(rt.sendLoginEmail),
28
+ limiter
29
+ };
30
+ }
31
+ async function requestLink({ request }) {
32
+ const rt = getCanciaRuntime();
33
+ const ctx = ctxFrom(rt);
34
+ if (!ctx) return notConfigured();
35
+ return handleLoginRequest(request, ctx, new URL(request.url).origin);
36
+ }
37
+ async function callback({ request }) {
38
+ const rt = getCanciaRuntime();
39
+ const ctx = ctxFrom(rt);
40
+ if (!ctx) return notConfigured();
41
+ return handleLoginCallback(request, ctx);
42
+ }
43
+ async function logout({ request }) {
44
+ const rt = getCanciaRuntime();
45
+ const ctx = ctxFrom(rt);
46
+ if (!ctx) return notConfigured();
47
+ return handleLogout(request, ctx);
48
+ }
49
+ async function session({ request }) {
50
+ const rt = getCanciaRuntime();
51
+ const ctx = ctxFrom(rt);
52
+ if (!ctx) return notConfigured();
53
+ return handleSessionCheck(
54
+ request,
55
+ ctx,
56
+ () => authorizeRequest({ request, secret: rt.secret, auth: rt.auth })
57
+ );
58
+ }
59
+
60
+ export {
61
+ requestLink,
62
+ callback,
63
+ logout,
64
+ session
65
+ };
@@ -1,3 +1,6 @@
1
+ import {
2
+ guardRequest
3
+ } from "./chunk-4PYVIIOO.js";
1
4
  import {
2
5
  loadSchemas
3
6
  } from "./chunk-VL6FO446.js";
@@ -14,12 +17,8 @@ function json(body, status = 200) {
14
17
  }
15
18
  function makeSchemasRoute(ctx) {
16
19
  return async function schemasRoute(request) {
17
- if (ctx.secret) {
18
- const token = request.headers.get("Authorization")?.replace("Bearer ", "").trim();
19
- if (token !== ctx.secret) {
20
- return json({ error: "Unauthorized" }, 401);
21
- }
22
- }
20
+ const deny = (await guardRequest(request, { secret: ctx.secret, auth: ctx.auth })).deny;
21
+ if (deny) return deny;
23
22
  const schemas = await loadSchemas(ctx.projectRoot, ctx.schemasPath);
24
23
  const descriptions = {};
25
24
  for (const [name, schema] of Object.entries(schemas)) {
@@ -5,7 +5,12 @@ function createRateLimiter() {
5
5
  return /* @__PURE__ */ new Map();
6
6
  }
7
7
  function getIp(req) {
8
- return req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? req.headers.get("x-real-ip") ?? "unknown";
8
+ const forwarded = req.headers.get("x-forwarded-for");
9
+ if (forwarded) {
10
+ const hops = forwarded.split(",").map((h) => h.trim()).filter(Boolean);
11
+ if (hops.length > 0) return hops[hops.length - 1];
12
+ }
13
+ return req.headers.get("x-real-ip") ?? "unknown";
9
14
  }
10
15
  function isRateLimited(limiter, ip, now = Date.now()) {
11
16
  const bucket = limiter.get(ip);
@@ -51,5 +56,8 @@ async function runAuth(cfg) {
51
56
 
52
57
  export {
53
58
  createRateLimiter,
59
+ getIp,
60
+ isRateLimited,
61
+ recordFailure,
54
62
  runAuth
55
63
  };
@@ -67,12 +67,18 @@ function canciaLoader(opts) {
67
67
  async load(ctx) {
68
68
  latestCtx = ctx;
69
69
  const projectRoot = opts.projectRoot ?? process.cwd();
70
+ const detectedDb = !opts.storage && !opts.db && existsSync(join2(projectRoot, "cancia.db"));
70
71
  const storage = opts.storage ?? resolveReadStorage(
71
- opts.db ? { kind: opts.db.kind === "sqlite" ? "sqlite-v2" : "json-file", dbPath: opts.db.path } : void 0,
72
+ opts.db ? { kind: opts.db.kind === "sqlite" ? "sqlite-v2" : "json-file", dbPath: opts.db.path } : detectedDb ? { kind: "sqlite-v2" } : void 0,
72
73
  projectRoot
73
74
  );
74
75
  const { lists } = storage;
75
- const usingJsonFile = !opts.storage && opts.db?.kind !== "sqlite";
76
+ if (detectedDb) {
77
+ ctx.logger.info(
78
+ `cancia: reading list "${opts.list}" from cancia.db (detected). Pass db: { kind: "json-file" } if that is not what you want.`
79
+ );
80
+ }
81
+ const usingJsonFile = !opts.storage && opts.db?.kind !== "sqlite" && !detectedDb;
76
82
  await syncOnce(
77
83
  ctx,
78
84
  lists,
@@ -2,6 +2,9 @@ import {
2
2
  detectUploadType,
3
3
  isValidSite
4
4
  } from "./chunk-RHFTQW4W.js";
5
+ import {
6
+ guardRequest
7
+ } from "./chunk-4PYVIIOO.js";
5
8
  import {
6
9
  createGitBackedAdapter
7
10
  } from "./chunk-XINNLVZP.js";
@@ -11,6 +14,115 @@ import {
11
14
  createSqliteAdapterV2
12
15
  } from "./chunk-S5YLB6P3.js";
13
16
 
17
+ // src/auth/sqlite-store.ts
18
+ import { createRequire } from "module";
19
+ import { mkdirSync } from "fs";
20
+ import { dirname } from "path";
21
+ var require2 = createRequire(import.meta.url);
22
+ var connections = /* @__PURE__ */ new Map();
23
+ function openAuthDB(dbPath) {
24
+ const cached = connections.get(dbPath);
25
+ if (cached) return cached;
26
+ const Database = require2("better-sqlite3");
27
+ if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true });
28
+ const db = new Database(dbPath);
29
+ db.pragma("journal_mode = WAL");
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS editors (
32
+ site TEXT NOT NULL, email TEXT NOT NULL, last_seen_at TEXT,
33
+ PRIMARY KEY (site, email)
34
+ );
35
+ CREATE TABLE IF NOT EXISTS sessions (
36
+ site TEXT NOT NULL, id TEXT NOT NULL, email TEXT NOT NULL,
37
+ created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
38
+ PRIMARY KEY (site, id)
39
+ );
40
+ CREATE TABLE IF NOT EXISTS login_tokens (
41
+ site TEXT NOT NULL, hash TEXT NOT NULL, email TEXT NOT NULL,
42
+ expires_at TEXT NOT NULL,
43
+ PRIMARY KEY (site, hash)
44
+ );
45
+ `);
46
+ connections.set(dbPath, db);
47
+ return db;
48
+ }
49
+ function createSqliteAuthStore(opts) {
50
+ const db = openAuthDB(opts.dbPath);
51
+ const insertEditor = db.prepare(
52
+ `INSERT INTO editors (site, email, last_seen_at) VALUES (?, ?, NULL)
53
+ ON CONFLICT(site, email) DO NOTHING`
54
+ );
55
+ const deleteOtherEditors = db.prepare("DELETE FROM editors WHERE site=?");
56
+ const selectEditor = db.prepare("SELECT email FROM editors WHERE site=? AND email=?");
57
+ const insertToken = db.prepare(
58
+ `INSERT INTO login_tokens (site, hash, email, expires_at) VALUES (?, ?, ?, ?)
59
+ ON CONFLICT(site, hash) DO NOTHING`
60
+ );
61
+ const selectToken = db.prepare(
62
+ "SELECT hash, email, expires_at FROM login_tokens WHERE site=? AND hash=?"
63
+ );
64
+ const deleteToken = db.prepare("DELETE FROM login_tokens WHERE site=? AND hash=?");
65
+ const insertSession = db.prepare(
66
+ "INSERT INTO sessions (site, id, email, created_at, expires_at) VALUES (?, ?, ?, ?, ?)"
67
+ );
68
+ const selectSession = db.prepare(
69
+ "SELECT id, email, created_at, expires_at FROM sessions WHERE site=? AND id=?"
70
+ );
71
+ const touchStmt = db.prepare("UPDATE sessions SET expires_at=? WHERE site=? AND id=?");
72
+ const deleteSessionStmt = db.prepare("DELETE FROM sessions WHERE site=? AND id=?");
73
+ const purgeTokens = db.prepare("DELETE FROM login_tokens WHERE site=? AND expires_at<=?");
74
+ const purgeSessions = db.prepare("DELETE FROM sessions WHERE site=? AND expires_at<=?");
75
+ const seedTx = db.transaction((site, emails) => {
76
+ deleteOtherEditors.run(site);
77
+ for (const email of emails) insertEditor.run(site, email);
78
+ });
79
+ const consumeTx = db.transaction((site, hash) => {
80
+ const row = selectToken.get(site, hash);
81
+ if (!row) return void 0;
82
+ deleteToken.run(site, hash);
83
+ return row;
84
+ });
85
+ return {
86
+ async seedEditors(site, emails) {
87
+ seedTx(site, emails);
88
+ },
89
+ async isEditor(site, email) {
90
+ return selectEditor.get(site, email) !== void 0;
91
+ },
92
+ async createLoginToken(site, rec) {
93
+ insertToken.run(site, rec.hash, rec.email, rec.expiresAt);
94
+ },
95
+ async consumeLoginToken(site, hash) {
96
+ const row = consumeTx(site, hash);
97
+ if (!row) return null;
98
+ return { hash: row.hash, email: row.email, expiresAt: row.expires_at };
99
+ },
100
+ async createSession(site, rec) {
101
+ insertSession.run(site, rec.id, rec.email, rec.createdAt, rec.expiresAt);
102
+ },
103
+ async getSession(site, id) {
104
+ const row = selectSession.get(site, id);
105
+ if (!row) return null;
106
+ return {
107
+ id: row.id,
108
+ email: row.email,
109
+ createdAt: row.created_at,
110
+ expiresAt: row.expires_at
111
+ };
112
+ },
113
+ async touchSession(site, id, expiresAt) {
114
+ touchStmt.run(expiresAt, site, id);
115
+ },
116
+ async deleteSession(site, id) {
117
+ deleteSessionStmt.run(site, id);
118
+ },
119
+ async purgeExpired(site, now) {
120
+ purgeTokens.run(site, now);
121
+ purgeSessions.run(site, now);
122
+ }
123
+ };
124
+ }
125
+
14
126
  // src/routes/upload.ts
15
127
  import { writeFile, mkdir } from "fs/promises";
16
128
  import { join } from "path";
@@ -27,14 +139,10 @@ function makeLocalUploadHandler(opts) {
27
139
  return `${publicUrlBase}/${site}/${name}`;
28
140
  };
29
141
  }
30
- function makeUploadRoute(uploadHandler, secret, maxMB = 10) {
142
+ function makeUploadRoute(uploadHandler, secret, maxMB = 10, auth) {
31
143
  return async ({ request }) => {
32
- if (secret) {
33
- const token = request.headers.get("Authorization")?.replace("Bearer ", "").trim();
34
- if (token !== secret) {
35
- return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
36
- }
37
- }
144
+ const deny = (await guardRequest(request, { secret, auth })).deny;
145
+ if (deny) return deny;
38
146
  const form = await request.formData().catch(() => null);
39
147
  const file = form?.get("file");
40
148
  const site = form?.get("site");
@@ -188,6 +296,18 @@ function buildRuntimeFromBaked(baked) {
188
296
  const storageV2 = buildStorageV2(baked, projectRoot);
189
297
  const storage = storageV2 ? storageV2.kv : lazyJsonFileAdapter(projectRoot);
190
298
  const uploadHandler = buildUploadHandler(baked, projectRoot);
299
+ const site = baked.site ?? "default";
300
+ const auth = baked.editors?.length ? {
301
+ store: createSqliteAuthStore({
302
+ dbPath: baked.authDbPath ? resolveDbPath(baked.authDbPath, projectRoot) : `${projectRoot}/cancia.db`
303
+ }),
304
+ site
305
+ } : void 0;
306
+ if (auth) {
307
+ auth.store.seedEditors(site, baked.editors.map((e) => e.trim().toLowerCase())).catch((err) => {
308
+ console.error(`[cancia] could not seed editors: ${err?.message ?? err}`);
309
+ });
310
+ }
191
311
  return {
192
312
  storage,
193
313
  storageV2,
@@ -196,6 +316,8 @@ function buildRuntimeFromBaked(baked) {
196
316
  defaultLocale: baked.defaultLocale,
197
317
  locales: baked.locales,
198
318
  secret,
319
+ site,
320
+ auth,
199
321
  uploadHandler,
200
322
  deployHook,
201
323
  deployHookToken,
@@ -269,6 +391,7 @@ export {
269
391
  makeLocalUploadHandler,
270
392
  makeUploadRoute,
271
393
  makeR2UploadHandler,
394
+ createSqliteAuthStore,
272
395
  setBakedConfig,
273
396
  setCanciaRuntime,
274
397
  __resetRuntimeForTests,
@@ -0,0 +1,145 @@
1
+ import {
2
+ createRateLimiter,
3
+ getIp,
4
+ isRateLimited,
5
+ recordFailure
6
+ } from "./chunk-ECPYHIAW.js";
7
+ import {
8
+ SESSION_TTL_MS,
9
+ clearSessionCookie,
10
+ isSecureRequest,
11
+ normaliseEmail,
12
+ readSessionCookie,
13
+ redeemLoginToken,
14
+ requestLoginLink,
15
+ revokeSession,
16
+ serialiseSessionCookie
17
+ } from "./chunk-6GUOGDWO.js";
18
+
19
+ // src/auth/email.ts
20
+ var LOOPS_API_URL = "https://app.loops.so/api/v1/transactional";
21
+ var consoleLoginEmail = async ({ to, url }) => {
22
+ console.log(
23
+ `
24
+ \x1B[36m[cancia]\x1B[0m login link for \x1B[1m${to}\x1B[0m:
25
+ ${url}
26
+ `
27
+ );
28
+ };
29
+ var loopsLoginEmail = async ({ to, url }) => {
30
+ const apiKey = process.env.LOOPS_API_KEY;
31
+ const transactionalId = process.env.CANCIA_LOGIN_TID;
32
+ if (!apiKey || !transactionalId) {
33
+ throw new Error(
34
+ "Loops login email needs LOOPS_API_KEY and CANCIA_LOGIN_TID in the server environment."
35
+ );
36
+ }
37
+ const res = await fetch(LOOPS_API_URL, {
38
+ method: "POST",
39
+ headers: {
40
+ "Content-Type": "application/json",
41
+ Authorization: `Bearer ${apiKey}`
42
+ },
43
+ body: JSON.stringify({
44
+ email: to,
45
+ transactionalId,
46
+ dataVariables: { loginUrl: url }
47
+ })
48
+ });
49
+ if (!res.ok) {
50
+ throw new Error(`Loops login email failed (${res.status})`);
51
+ }
52
+ };
53
+ function resolveLoginEmailSender(explicit) {
54
+ if (explicit) return explicit;
55
+ if (process.env.LOOPS_API_KEY && process.env.CANCIA_LOGIN_TID) return loopsLoginEmail;
56
+ return consoleLoginEmail;
57
+ }
58
+
59
+ // src/auth/handlers.ts
60
+ var json = (body, status, headers = {}) => new Response(JSON.stringify(body), {
61
+ status,
62
+ headers: { "Content-Type": "application/json", ...headers }
63
+ });
64
+ var NEUTRAL_ACK = { ok: true, message: "If that address can edit this site, a link is on its way." };
65
+ function looksLikeEmail(value) {
66
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && value.length <= 254;
67
+ }
68
+ async function handleLoginRequest(request, ctx, origin) {
69
+ const limiter = ctx.limiter ?? createRateLimiter();
70
+ const now = ctx.now ?? Date.now();
71
+ const ip = getIp(request);
72
+ if (isRateLimited(limiter, ip, now)) {
73
+ return json({ error: "Too many attempts. Try again in a minute." }, 429);
74
+ }
75
+ let email;
76
+ try {
77
+ const body = await request.json();
78
+ email = body?.email;
79
+ } catch {
80
+ return json({ error: "Invalid request body" }, 400);
81
+ }
82
+ if (!email || !looksLikeEmail(email.trim())) {
83
+ return json({ error: "Enter a valid email address." }, 400);
84
+ }
85
+ const normalised = normaliseEmail(email);
86
+ if (isRateLimited(limiter, `email:${normalised}`, now)) {
87
+ return json(NEUTRAL_ACK, 200);
88
+ }
89
+ recordFailure(limiter, ip, now);
90
+ recordFailure(limiter, `email:${normalised}`, now);
91
+ const { token } = await requestLoginLink(ctx.store, ctx.site, normalised, now);
92
+ if (token) {
93
+ const url = `${origin}/?cancia_login=${encodeURIComponent(token)}`;
94
+ void ctx.sendEmail({ to: normalised, url, site: ctx.site }).catch((err) => {
95
+ console.error(
96
+ `[cancia] failed to send login email: ${err instanceof Error ? err.message : String(err)}`
97
+ );
98
+ });
99
+ }
100
+ void ctx.store.purgeExpired(ctx.site, new Date(now).toISOString()).catch(() => {
101
+ });
102
+ return json(NEUTRAL_ACK, 200);
103
+ }
104
+ async function handleLoginCallback(request, ctx) {
105
+ const now = ctx.now ?? Date.now();
106
+ let token;
107
+ try {
108
+ const body = await request.json();
109
+ token = body?.token;
110
+ } catch {
111
+ return json({ error: "Invalid request body" }, 400);
112
+ }
113
+ const result = await redeemLoginToken(ctx.store, ctx.site, token ?? "", now);
114
+ if (!result.sessionToken) {
115
+ return json({ error: "That link is invalid or has expired. Request a new one." }, 401);
116
+ }
117
+ return json(
118
+ { ok: true, email: result.email },
119
+ 200,
120
+ {
121
+ "Set-Cookie": serialiseSessionCookie(result.sessionToken, {
122
+ secure: isSecureRequest(request),
123
+ maxAgeSeconds: Math.floor(SESSION_TTL_MS / 1e3)
124
+ })
125
+ }
126
+ );
127
+ }
128
+ async function handleLogout(request, ctx) {
129
+ await revokeSession(ctx.store, ctx.site, readSessionCookie(request));
130
+ return json({ ok: true }, 200, {
131
+ "Set-Cookie": clearSessionCookie({ secure: isSecureRequest(request) })
132
+ });
133
+ }
134
+ async function handleSessionCheck(request, ctx, authorize) {
135
+ const verdict = await authorize();
136
+ return json({ authenticated: verdict.ok, email: verdict.email }, 200);
137
+ }
138
+
139
+ export {
140
+ resolveLoginEmailSender,
141
+ handleLoginRequest,
142
+ handleLoginCallback,
143
+ handleLogout,
144
+ handleSessionCheck
145
+ };