@thebes/cadmus 0.2.1

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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/astro/index.cjs +149 -0
  4. package/dist/astro/index.cjs.map +1 -0
  5. package/dist/astro/index.d.cts +101 -0
  6. package/dist/astro/index.d.cts.map +1 -0
  7. package/dist/astro/index.d.ts +101 -0
  8. package/dist/astro/index.d.ts.map +1 -0
  9. package/dist/astro/index.js +146 -0
  10. package/dist/astro/index.js.map +1 -0
  11. package/dist/auth/index.cjs +59 -0
  12. package/dist/auth/index.cjs.map +1 -0
  13. package/dist/auth/index.d.cts +14 -0
  14. package/dist/auth/index.d.cts.map +1 -0
  15. package/dist/auth/index.d.ts +14 -0
  16. package/dist/auth/index.d.ts.map +1 -0
  17. package/dist/auth/index.js +54 -0
  18. package/dist/auth/index.js.map +1 -0
  19. package/dist/cache/index.cjs +18 -0
  20. package/dist/cache/index.cjs.map +1 -0
  21. package/dist/cache/index.d.cts +10 -0
  22. package/dist/cache/index.d.cts.map +1 -0
  23. package/dist/cache/index.d.ts +10 -0
  24. package/dist/cache/index.d.ts.map +1 -0
  25. package/dist/cache/index.js +17 -0
  26. package/dist/cache/index.js.map +1 -0
  27. package/dist/cms/index.cjs +763 -0
  28. package/dist/cms/index.cjs.map +1 -0
  29. package/dist/cms/index.d.cts +2 -0
  30. package/dist/cms/index.d.ts +2 -0
  31. package/dist/cms/index.js +743 -0
  32. package/dist/cms/index.js.map +1 -0
  33. package/dist/db/index.cjs +10 -0
  34. package/dist/db/index.cjs.map +1 -0
  35. package/dist/db/index.d.cts +7 -0
  36. package/dist/db/index.d.cts.map +1 -0
  37. package/dist/db/index.d.ts +7 -0
  38. package/dist/db/index.d.ts.map +1 -0
  39. package/dist/db/index.js +9 -0
  40. package/dist/db/index.js.map +1 -0
  41. package/dist/email/index.cjs +25 -0
  42. package/dist/email/index.cjs.map +1 -0
  43. package/dist/email/index.d.cts +12 -0
  44. package/dist/email/index.d.cts.map +1 -0
  45. package/dist/email/index.d.ts +12 -0
  46. package/dist/email/index.d.ts.map +1 -0
  47. package/dist/email/index.js +24 -0
  48. package/dist/email/index.js.map +1 -0
  49. package/dist/errors-CW6Lz0AQ.cjs +196 -0
  50. package/dist/errors-CW6Lz0AQ.cjs.map +1 -0
  51. package/dist/errors-mZIqZJO4.js +125 -0
  52. package/dist/errors-mZIqZJO4.js.map +1 -0
  53. package/dist/hono/index.cjs +132 -0
  54. package/dist/hono/index.cjs.map +1 -0
  55. package/dist/hono/index.d.cts +59 -0
  56. package/dist/hono/index.d.cts.map +1 -0
  57. package/dist/hono/index.d.ts +59 -0
  58. package/dist/hono/index.d.ts.map +1 -0
  59. package/dist/hono/index.js +130 -0
  60. package/dist/hono/index.js.map +1 -0
  61. package/dist/index-BUrCSGVb.d.cts +616 -0
  62. package/dist/index-BUrCSGVb.d.cts.map +1 -0
  63. package/dist/index-BUrCSGVb.d.ts +616 -0
  64. package/dist/index-BUrCSGVb.d.ts.map +1 -0
  65. package/dist/index.cjs +60 -0
  66. package/dist/index.d.cts +107 -0
  67. package/dist/index.d.cts.map +1 -0
  68. package/dist/index.d.ts +107 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +11 -0
  71. package/dist/queues/index.cjs +31 -0
  72. package/dist/queues/index.cjs.map +1 -0
  73. package/dist/queues/index.d.cts +22 -0
  74. package/dist/queues/index.d.cts.map +1 -0
  75. package/dist/queues/index.d.ts +22 -0
  76. package/dist/queues/index.d.ts.map +1 -0
  77. package/dist/queues/index.js +29 -0
  78. package/dist/queues/index.js.map +1 -0
  79. package/dist/rate-limit/index.cjs +38 -0
  80. package/dist/rate-limit/index.cjs.map +1 -0
  81. package/dist/rate-limit/index.d.cts +14 -0
  82. package/dist/rate-limit/index.d.cts.map +1 -0
  83. package/dist/rate-limit/index.d.ts +14 -0
  84. package/dist/rate-limit/index.d.ts.map +1 -0
  85. package/dist/rate-limit/index.js +37 -0
  86. package/dist/rate-limit/index.js.map +1 -0
  87. package/dist/session/index.cjs +48 -0
  88. package/dist/session/index.cjs.map +1 -0
  89. package/dist/session/index.d.cts +14 -0
  90. package/dist/session/index.d.cts.map +1 -0
  91. package/dist/session/index.d.ts +14 -0
  92. package/dist/session/index.d.ts.map +1 -0
  93. package/dist/session/index.js +45 -0
  94. package/dist/session/index.js.map +1 -0
  95. package/dist/storage/index.cjs +29 -0
  96. package/dist/storage/index.cjs.map +1 -0
  97. package/dist/storage/index.d.cts +38 -0
  98. package/dist/storage/index.d.cts.map +1 -0
  99. package/dist/storage/index.d.ts +38 -0
  100. package/dist/storage/index.d.ts.map +1 -0
  101. package/dist/storage/index.js +26 -0
  102. package/dist/storage/index.js.map +1 -0
  103. package/package.json +115 -0
@@ -0,0 +1,146 @@
1
+ import { generateToken, hashToken, signSession, verifySession } from "../auth/index.js";
2
+ import { checkRateLimit } from "../rate-limit/index.js";
3
+ //#region src/astro/index.ts
4
+ const DEFAULT_COOKIE_NAME = "cadmus_session";
5
+ const DEFAULT_COOKIE_MAX_AGE_SECONDS = 3600 * 24 * 7;
6
+ const DEFAULT_MAGIC_LINK_TTL_SECONDS = 900;
7
+ const DEFAULT_RATE_LIMIT = {
8
+ limit: 3,
9
+ windowSeconds: 900
10
+ };
11
+ const KV_RETRY_ATTEMPTS = 2;
12
+ const KV_RETRY_DELAY_MS = 100;
13
+ function sleep(ms) {
14
+ return new Promise((resolve) => setTimeout(resolve, ms));
15
+ }
16
+ function isSafeRedirect(value) {
17
+ return !!value && value.startsWith("/") && !value.startsWith("//");
18
+ }
19
+ function resolve(value, context, fallback) {
20
+ if (value === void 0) return fallback;
21
+ return typeof value === "function" ? value(context) : value;
22
+ }
23
+ function defaultIsLocalDev(context) {
24
+ const hostname = context.url.hostname;
25
+ return hostname === "localhost" || hostname === "127.0.0.1";
26
+ }
27
+ function defaultOnLocalDev(_context, { email, verifyUrl }) {
28
+ console.log(`[dev] Magic link for ${email}: ${verifyUrl.toString()}`);
29
+ }
30
+ /**
31
+ * Builds the magic-link request (`POST`) and verify (`GET`) Astro
32
+ * `APIRoute` handlers — mount both at the same route, e.g.
33
+ * `export const { POST, GET } = createMagicLinkHandlers(options)` from
34
+ * `src/pages/api/auth/[...path].ts`, or wire `POST`/`GET` into separate
35
+ * `magic-link.ts`/`verify.ts` routes matching `verifyPath` below.
36
+ */
37
+ function createMagicLinkHandlers(options) {
38
+ const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
39
+ const cookieMaxAgeSeconds = options.cookieMaxAgeSeconds ?? DEFAULT_COOKIE_MAX_AGE_SECONDS;
40
+ const magicLinkTtlSeconds = options.magicLinkTtlSeconds ?? DEFAULT_MAGIC_LINK_TTL_SECONDS;
41
+ const rateLimit = options.rateLimit === false ? null : options.rateLimit ?? DEFAULT_RATE_LIMIT;
42
+ const verifyPath = options.verifyPath ?? "/api/auth/verify";
43
+ const loginPath = options.loginPath ?? "/login";
44
+ const isLocalDev = options.isLocalDev ?? defaultIsLocalDev;
45
+ const onLocalDev = options.onLocalDev ?? defaultOnLocalDev;
46
+ const POST = async (context) => {
47
+ const body = await context.request.json().catch(() => null);
48
+ const email = body?.email?.trim().toLowerCase();
49
+ if (!email) return Response.json({ ok: true });
50
+ const redirect = isSafeRedirect(body?.redirect) ? body.redirect : null;
51
+ const kv = options.kv(context);
52
+ if (rateLimit) {
53
+ const { allowed } = await checkRateLimit(kv, `magiclink:ratelimit:${email}`, rateLimit.limit, rateLimit.windowSeconds);
54
+ if (!allowed) return Response.json({ ok: true });
55
+ }
56
+ if (!await options.findUser(context, email)) return Response.json({ ok: true });
57
+ const token = generateToken();
58
+ const hash = await hashToken(token);
59
+ await kv.put(`magiclink:${hash}`, email, { expirationTtl: magicLinkTtlSeconds });
60
+ const verifyUrl = new URL(verifyPath, context.url);
61
+ verifyUrl.searchParams.set("token", token);
62
+ if (redirect) verifyUrl.searchParams.set("redirect", redirect);
63
+ if (isLocalDev(context)) await onLocalDev(context, {
64
+ email,
65
+ verifyUrl
66
+ });
67
+ else await options.sendMagicLinkEmail(context, {
68
+ email,
69
+ verifyUrl
70
+ });
71
+ return Response.json({ ok: true });
72
+ };
73
+ const GET = async (context) => {
74
+ const token = context.url.searchParams.get("token");
75
+ if (!token) return context.redirect(`${loginPath}?error=invalid`);
76
+ const kv = options.kv(context);
77
+ const key = `magiclink:${await hashToken(token)}`;
78
+ let email = null;
79
+ for (let attempt = 0; attempt <= KV_RETRY_ATTEMPTS; attempt++) {
80
+ email = await kv.get(key);
81
+ if (email !== null) break;
82
+ if (attempt < KV_RETRY_ATTEMPTS) await sleep(KV_RETRY_DELAY_MS);
83
+ }
84
+ if (email === null) return context.redirect(`${loginPath}?error=invalid`);
85
+ await kv.delete(key);
86
+ const user = await options.findUser(context, email);
87
+ if (!user) return context.redirect(`${loginPath}?error=unauthorized`);
88
+ const { sessionId } = await options.createSession(context, user);
89
+ const signature = await signSession(sessionId, options.secret(context));
90
+ context.cookies.set(cookieName, `${sessionId}.${signature}`, {
91
+ httpOnly: true,
92
+ secure: true,
93
+ sameSite: "lax",
94
+ path: "/",
95
+ maxAge: cookieMaxAgeSeconds
96
+ });
97
+ const requestedRedirect = context.url.searchParams.get("redirect");
98
+ const redirectTo = isSafeRedirect(requestedRedirect) ? requestedRedirect : resolve(options.defaultRedirect, context, "/");
99
+ return context.redirect(redirectTo);
100
+ };
101
+ return {
102
+ POST,
103
+ GET
104
+ };
105
+ }
106
+ /** Builds a logout `APIRoute` — clears the session cookie and its backing store entry. */
107
+ function createLogoutHandler(options) {
108
+ const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
109
+ return async (context) => {
110
+ const cookieValue = context.cookies.get(cookieName)?.value;
111
+ if (cookieValue) {
112
+ const [sessionId] = cookieValue.split(".");
113
+ if (sessionId) await options.deleteSession(context, sessionId);
114
+ }
115
+ context.cookies.delete(cookieName, { path: "/" });
116
+ return context.redirect(resolve(options.redirectTo, context, "/login"));
117
+ };
118
+ }
119
+ /**
120
+ * Astro middleware that verifies the session cookie's signature and
121
+ * populates `context.locals[localsKey]` with the resolved session, or
122
+ * null if there isn't one. Mirrors `cadmusAuth()`'s role in the Hono
123
+ * layer: it authenticates the request, it doesn't gate access — pages
124
+ * and routes downstream decide what to do with a null session.
125
+ */
126
+ function cadmusAuthGuard(options) {
127
+ const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
128
+ const localsKey = options.localsKey ?? "session";
129
+ const handler = async (context, next) => {
130
+ const cookieValue = context.cookies.get(cookieName)?.value;
131
+ let session = null;
132
+ if (cookieValue) {
133
+ const [sessionId, signature] = cookieValue.split(".");
134
+ if (sessionId && signature) {
135
+ if (await verifySession(sessionId, signature, options.secret(context))) session = await options.getSession(context, sessionId);
136
+ }
137
+ }
138
+ context.locals[localsKey] = session;
139
+ return next();
140
+ };
141
+ return handler;
142
+ }
143
+ //#endregion
144
+ export { cadmusAuthGuard, createLogoutHandler, createMagicLinkHandlers };
145
+
146
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/astro/index.ts"],"sourcesContent":["// Copyright (c) 2026 BowenLabs. All rights reserved.\n// Cadmus is MIT licensed. See LICENSE in the repo root.\n//\n// @thebes/cadmus/astro\n//\n// Peer-integration layer for Astro — the same \"peer, not a dependency\"\n// treatment @thebes/cadmus/hono already gets (see that module's\n// index.ts). `astro` is an optional peer dependency; this entrypoint is\n// excluded from the package root export for the same reason hono is.\n// Unlike the hono layer, every `astro` import below is `import type` —\n// nothing from the real `astro` package executes in this module. Astro's\n// own `defineMiddleware` is `(fn) => fn`, a type-inference convenience\n// for app code calling it inline, not anything we need at runtime; we\n// return the typed function directly instead of importing it, so this\n// module stays V8-first with no Astro runtime in the bundled output.\n//\n// These handlers are thin HTTP plumbing over cadmus/auth, cadmus/session,\n// and cadmus/rate-limit — they don't introduce new crypto or storage\n// logic. What's genuinely app-specific (looking up a user by email,\n// shaping a session payload, sending the actual email) is always a\n// caller-supplied function, mirroring how @thebes/cadmus/hono's\n// mountCmsRoutes takes a `resolveContext` callback instead of guessing at\n// the app's auth model.\n\nimport type { APIContext, APIRoute, MiddlewareHandler } from \"astro\";\nimport {\n generateToken,\n hashToken,\n signSession,\n verifySession,\n} from \"../auth/index.js\";\nimport { checkRateLimit } from \"../rate-limit/index.js\";\n\nconst DEFAULT_COOKIE_NAME = \"cadmus_session\";\nconst DEFAULT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days\nconst DEFAULT_MAGIC_LINK_TTL_SECONDS = 60 * 15; // 15 min\nconst DEFAULT_RATE_LIMIT = { limit: 3, windowSeconds: 60 * 15 };\nconst KV_RETRY_ATTEMPTS = 2;\nconst KV_RETRY_DELAY_MS = 100;\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n// Only a same-origin relative path is safe to redirect to — a protocol-\n// relative \"//host/...\" or absolute URL turns this into an open redirect.\nfunction isSafeRedirect(value: string | null | undefined): value is string {\n return !!value && value.startsWith(\"/\") && !value.startsWith(\"//\");\n}\n\nfunction resolve<TContext>(\n value: string | ((context: TContext) => string) | undefined,\n context: TContext,\n fallback: string,\n): string {\n if (value === undefined) return fallback;\n return typeof value === \"function\" ? value(context) : value;\n}\n\nexport interface MagicLinkHandlersOptions<TUser> {\n /** Resolves the KV namespace magic-link tokens are stored in, per request. */\n kv: (context: APIContext) => KVNamespace;\n /** Resolves the secret session cookies are HMAC-signed with — must match cadmusAuthGuard's `secret`. */\n secret: (context: APIContext) => string;\n /**\n * Looks up the user a request's email belongs to. Returning null is\n * indistinguishable to the client from a successful send — this is\n * the anti-enumeration guarantee the same way the hand-rolled\n * Worker 1 route this replaces always returned `{ ok: true }`.\n */\n findUser: (context: APIContext, email: string) => Promise<TUser | null>;\n /** Creates a session for a verified user, returning the session ID to sign into the cookie. */\n createSession: (\n context: APIContext,\n user: TUser,\n ) => Promise<{ sessionId: string }>;\n /** Sends the magic-link email. Not called when `isLocalDev` returns true — see that option. */\n sendMagicLinkEmail: (\n context: APIContext,\n params: { email: string; verifyUrl: URL },\n ) => Promise<void>;\n /** Cookie name the session is signed into. Defaults to \"cadmus_session\". */\n cookieName?: string;\n /** Cookie `maxAge` in seconds. Defaults to 7 days. */\n cookieMaxAgeSeconds?: number;\n /** Magic-link token TTL in seconds. Defaults to 15 minutes. */\n magicLinkTtlSeconds?: number;\n /**\n * Per-email rate limit on magic-link requests, keyed in the same KV\n * namespace `kv` resolves. Defaults to 3 requests / 15 minutes. Pass\n * `false` to disable.\n */\n rateLimit?: { limit: number; windowSeconds: number } | false;\n /** Path the verify GET handler is mounted at — used to build the emailed link. Defaults to \"/api/auth/verify\". */\n verifyPath?: string;\n /** Path to redirect to on a failed verify, with `?error=invalid|unauthorized` appended. Defaults to \"/login\". */\n loginPath?: string;\n /** Redirect target after a successful verify when no `redirect` param was supplied. Defaults to \"/\". */\n defaultRedirect?: string | ((context: APIContext) => string);\n /**\n * Whether this request should skip emailing and log the link instead —\n * see `onLocalDev`. Defaults to checking for a localhost/127.0.0.1\n * request hostname, since no deployed environment is ever literally\n * that (unlike checking `sendMagicLinkEmail`'s own success/failure,\n * which local email emulators can mask).\n */\n isLocalDev?: (context: APIContext) => boolean;\n /** Called instead of `sendMagicLinkEmail` when `isLocalDev` is true. Defaults to a console.log of the link. */\n onLocalDev?: (\n context: APIContext,\n params: { email: string; verifyUrl: URL },\n ) => void | Promise<void>;\n}\n\nfunction defaultIsLocalDev(context: APIContext): boolean {\n const hostname = context.url.hostname;\n return hostname === \"localhost\" || hostname === \"127.0.0.1\";\n}\n\nfunction defaultOnLocalDev(\n _context: APIContext,\n { email, verifyUrl }: { email: string; verifyUrl: URL },\n): void {\n console.log(`[dev] Magic link for ${email}: ${verifyUrl.toString()}`);\n}\n\n/**\n * Builds the magic-link request (`POST`) and verify (`GET`) Astro\n * `APIRoute` handlers — mount both at the same route, e.g.\n * `export const { POST, GET } = createMagicLinkHandlers(options)` from\n * `src/pages/api/auth/[...path].ts`, or wire `POST`/`GET` into separate\n * `magic-link.ts`/`verify.ts` routes matching `verifyPath` below.\n */\nexport function createMagicLinkHandlers<TUser>(\n options: MagicLinkHandlersOptions<TUser>,\n): { POST: APIRoute; GET: APIRoute } {\n const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;\n const cookieMaxAgeSeconds =\n options.cookieMaxAgeSeconds ?? DEFAULT_COOKIE_MAX_AGE_SECONDS;\n const magicLinkTtlSeconds =\n options.magicLinkTtlSeconds ?? DEFAULT_MAGIC_LINK_TTL_SECONDS;\n const rateLimit =\n options.rateLimit === false\n ? null\n : (options.rateLimit ?? DEFAULT_RATE_LIMIT);\n const verifyPath = options.verifyPath ?? \"/api/auth/verify\";\n const loginPath = options.loginPath ?? \"/login\";\n const isLocalDev = options.isLocalDev ?? defaultIsLocalDev;\n const onLocalDev = options.onLocalDev ?? defaultOnLocalDev;\n\n const POST: APIRoute = async (context) => {\n const body = await context.request\n .json<{ email?: string; redirect?: string }>()\n .catch(() => null);\n const email = body?.email?.trim().toLowerCase();\n if (!email) return Response.json({ ok: true });\n\n const redirect = isSafeRedirect(body?.redirect) ? body.redirect : null;\n\n const kv = options.kv(context);\n\n if (rateLimit) {\n const { allowed } = await checkRateLimit(\n kv,\n `magiclink:ratelimit:${email}`,\n rateLimit.limit,\n rateLimit.windowSeconds,\n );\n if (!allowed) return Response.json({ ok: true });\n }\n\n const user = await options.findUser(context, email);\n if (!user) return Response.json({ ok: true });\n\n const token = generateToken();\n const hash = await hashToken(token);\n await kv.put(`magiclink:${hash}`, email, {\n expirationTtl: magicLinkTtlSeconds,\n });\n\n const verifyUrl = new URL(verifyPath, context.url);\n verifyUrl.searchParams.set(\"token\", token);\n if (redirect) verifyUrl.searchParams.set(\"redirect\", redirect);\n\n if (isLocalDev(context)) {\n await onLocalDev(context, { email, verifyUrl });\n } else {\n await options.sendMagicLinkEmail(context, { email, verifyUrl });\n }\n\n return Response.json({ ok: true });\n };\n\n const GET: APIRoute = async (context) => {\n const token = context.url.searchParams.get(\"token\");\n if (!token) return context.redirect(`${loginPath}?error=invalid`);\n\n const kv = options.kv(context);\n const hash = await hashToken(token);\n const key = `magiclink:${hash}`;\n\n // Single use, and retried — see cadmus/session's getSession for why\n // a read immediately following the write above can otherwise see a\n // false negative under KV's eventual consistency.\n let email: string | null = null;\n for (let attempt = 0; attempt <= KV_RETRY_ATTEMPTS; attempt++) {\n email = await kv.get(key);\n if (email !== null) break;\n if (attempt < KV_RETRY_ATTEMPTS) await sleep(KV_RETRY_DELAY_MS);\n }\n if (email === null) return context.redirect(`${loginPath}?error=invalid`);\n await kv.delete(key);\n\n const user = await options.findUser(context, email);\n if (!user) return context.redirect(`${loginPath}?error=unauthorized`);\n\n const { sessionId } = await options.createSession(context, user);\n const signature = await signSession(sessionId, options.secret(context));\n\n context.cookies.set(cookieName, `${sessionId}.${signature}`, {\n httpOnly: true,\n secure: true,\n sameSite: \"lax\",\n path: \"/\",\n maxAge: cookieMaxAgeSeconds,\n });\n\n // Re-validated here too — this query param isn't signed alongside\n // the token, so the request-side check above doesn't cover it.\n const requestedRedirect = context.url.searchParams.get(\"redirect\");\n const redirectTo = isSafeRedirect(requestedRedirect)\n ? requestedRedirect\n : resolve(options.defaultRedirect, context, \"/\");\n return context.redirect(redirectTo);\n };\n\n return { POST, GET };\n}\n\nexport interface LogoutHandlerOptions {\n /** Cookie name the session is signed into. Must match createMagicLinkHandlers' `cookieName`. */\n cookieName?: string;\n /** Deletes the session identified by the cookie's session ID (e.g. from KV). */\n deleteSession: (context: APIContext, sessionId: string) => Promise<void>;\n /** Where to redirect after logout. Defaults to \"/login\". */\n redirectTo?: string | ((context: APIContext) => string);\n}\n\n/** Builds a logout `APIRoute` — clears the session cookie and its backing store entry. */\nexport function createLogoutHandler(options: LogoutHandlerOptions): APIRoute {\n const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;\n\n return async (context) => {\n const cookieValue = context.cookies.get(cookieName)?.value;\n if (cookieValue) {\n const [sessionId] = cookieValue.split(\".\");\n if (sessionId) await options.deleteSession(context, sessionId);\n }\n context.cookies.delete(cookieName, { path: \"/\" });\n return context.redirect(resolve(options.redirectTo, context, \"/login\"));\n };\n}\n\nexport interface AuthGuardOptions<TSession> {\n /** Cookie name the session is signed into. Must match createMagicLinkHandlers' `cookieName`. */\n cookieName?: string;\n /** Resolves the secret session cookies are HMAC-signed with — must match createMagicLinkHandlers' `secret`. */\n secret: (context: APIContext) => string;\n /** Reads the session for a verified session ID (e.g. from KV). Returning null treats the session as missing. */\n getSession: (\n context: APIContext,\n sessionId: string,\n ) => Promise<TSession | null>;\n /** Key set on `context.locals`. Defaults to \"session\". */\n localsKey?: string;\n}\n\n/**\n * Astro middleware that verifies the session cookie's signature and\n * populates `context.locals[localsKey]` with the resolved session, or\n * null if there isn't one. Mirrors `cadmusAuth()`'s role in the Hono\n * layer: it authenticates the request, it doesn't gate access — pages\n * and routes downstream decide what to do with a null session.\n */\nexport function cadmusAuthGuard<TSession>(\n options: AuthGuardOptions<TSession>,\n): MiddlewareHandler {\n const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;\n const localsKey = options.localsKey ?? \"session\";\n\n const handler: MiddlewareHandler = async (context, next) => {\n const cookieValue = context.cookies.get(cookieName)?.value;\n let session: TSession | null = null;\n\n if (cookieValue) {\n const [sessionId, signature] = cookieValue.split(\".\");\n if (sessionId && signature) {\n const valid = await verifySession(\n sessionId,\n signature,\n options.secret(context),\n );\n if (valid) session = await options.getSession(context, sessionId);\n }\n }\n\n (context.locals as Record<string, unknown>)[localsKey] = session;\n return next();\n };\n\n return handler;\n}\n"],"mappings":";;;AAiCA,MAAM,sBAAsB;AAC5B,MAAM,iCAAiC,OAAU,KAAK;AACtD,MAAM,iCAAiC;AACvC,MAAM,qBAAqB;CAAE,OAAO;CAAG,eAAe;AAAQ;AAC9D,MAAM,oBAAoB;AAC1B,MAAM,oBAAoB;AAE1B,SAAS,MAAM,IAA2B;CACxC,OAAO,IAAI,SAAS,YAAY,WAAW,SAAS,EAAE,CAAC;AACzD;AAIA,SAAS,eAAe,OAAmD;CACzE,OAAO,CAAC,CAAC,SAAS,MAAM,WAAW,GAAG,KAAK,CAAC,MAAM,WAAW,IAAI;AACnE;AAEA,SAAS,QACP,OACA,SACA,UACQ;CACR,IAAI,UAAU,KAAA,GAAW,OAAO;CAChC,OAAO,OAAO,UAAU,aAAa,MAAM,OAAO,IAAI;AACxD;AAyDA,SAAS,kBAAkB,SAA8B;CACvD,MAAM,WAAW,QAAQ,IAAI;CAC7B,OAAO,aAAa,eAAe,aAAa;AAClD;AAEA,SAAS,kBACP,UACA,EAAE,OAAO,aACH;CACN,QAAQ,IAAI,wBAAwB,MAAM,IAAI,UAAU,SAAS,GAAG;AACtE;;;;;;;;AASA,SAAgB,wBACd,SACmC;CACnC,MAAM,aAAa,QAAQ,cAAc;CACzC,MAAM,sBACJ,QAAQ,uBAAuB;CACjC,MAAM,sBACJ,QAAQ,uBAAuB;CACjC,MAAM,YACJ,QAAQ,cAAc,QAClB,OACC,QAAQ,aAAa;CAC5B,MAAM,aAAa,QAAQ,cAAc;CACzC,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,aAAa,QAAQ,cAAc;CACzC,MAAM,aAAa,QAAQ,cAAc;CAEzC,MAAM,OAAiB,OAAO,YAAY;EACxC,MAAM,OAAO,MAAM,QAAQ,QACxB,KAA4C,CAAC,CAC7C,YAAY,IAAI;EACnB,MAAM,QAAQ,MAAM,OAAO,KAAK,CAAC,CAAC,YAAY;EAC9C,IAAI,CAAC,OAAO,OAAO,SAAS,KAAK,EAAE,IAAI,KAAK,CAAC;EAE7C,MAAM,WAAW,eAAe,MAAM,QAAQ,IAAI,KAAK,WAAW;EAElE,MAAM,KAAK,QAAQ,GAAG,OAAO;EAE7B,IAAI,WAAW;GACb,MAAM,EAAE,YAAY,MAAM,eACxB,IACA,uBAAuB,SACvB,UAAU,OACV,UAAU,aACZ;GACA,IAAI,CAAC,SAAS,OAAO,SAAS,KAAK,EAAE,IAAI,KAAK,CAAC;EACjD;EAGA,IAAI,CAAC,MADc,QAAQ,SAAS,SAAS,KAAK,GACvC,OAAO,SAAS,KAAK,EAAE,IAAI,KAAK,CAAC;EAE5C,MAAM,QAAQ,cAAc;EAC5B,MAAM,OAAO,MAAM,UAAU,KAAK;EAClC,MAAM,GAAG,IAAI,aAAa,QAAQ,OAAO,EACvC,eAAe,oBACjB,CAAC;EAED,MAAM,YAAY,IAAI,IAAI,YAAY,QAAQ,GAAG;EACjD,UAAU,aAAa,IAAI,SAAS,KAAK;EACzC,IAAI,UAAU,UAAU,aAAa,IAAI,YAAY,QAAQ;EAE7D,IAAI,WAAW,OAAO,GACpB,MAAM,WAAW,SAAS;GAAE;GAAO;EAAU,CAAC;OAE9C,MAAM,QAAQ,mBAAmB,SAAS;GAAE;GAAO;EAAU,CAAC;EAGhE,OAAO,SAAS,KAAK,EAAE,IAAI,KAAK,CAAC;CACnC;CAEA,MAAM,MAAgB,OAAO,YAAY;EACvC,MAAM,QAAQ,QAAQ,IAAI,aAAa,IAAI,OAAO;EAClD,IAAI,CAAC,OAAO,OAAO,QAAQ,SAAS,GAAG,UAAU,eAAe;EAEhE,MAAM,KAAK,QAAQ,GAAG,OAAO;EAE7B,MAAM,MAAM,aAAa,MADN,UAAU,KAAK;EAMlC,IAAI,QAAuB;EAC3B,KAAK,IAAI,UAAU,GAAG,WAAW,mBAAmB,WAAW;GAC7D,QAAQ,MAAM,GAAG,IAAI,GAAG;GACxB,IAAI,UAAU,MAAM;GACpB,IAAI,UAAU,mBAAmB,MAAM,MAAM,iBAAiB;EAChE;EACA,IAAI,UAAU,MAAM,OAAO,QAAQ,SAAS,GAAG,UAAU,eAAe;EACxE,MAAM,GAAG,OAAO,GAAG;EAEnB,MAAM,OAAO,MAAM,QAAQ,SAAS,SAAS,KAAK;EAClD,IAAI,CAAC,MAAM,OAAO,QAAQ,SAAS,GAAG,UAAU,oBAAoB;EAEpE,MAAM,EAAE,cAAc,MAAM,QAAQ,cAAc,SAAS,IAAI;EAC/D,MAAM,YAAY,MAAM,YAAY,WAAW,QAAQ,OAAO,OAAO,CAAC;EAEtE,QAAQ,QAAQ,IAAI,YAAY,GAAG,UAAU,GAAG,aAAa;GAC3D,UAAU;GACV,QAAQ;GACR,UAAU;GACV,MAAM;GACN,QAAQ;EACV,CAAC;EAID,MAAM,oBAAoB,QAAQ,IAAI,aAAa,IAAI,UAAU;EACjE,MAAM,aAAa,eAAe,iBAAiB,IAC/C,oBACA,QAAQ,QAAQ,iBAAiB,SAAS,GAAG;EACjD,OAAO,QAAQ,SAAS,UAAU;CACpC;CAEA,OAAO;EAAE;EAAM;CAAI;AACrB;;AAYA,SAAgB,oBAAoB,SAAyC;CAC3E,MAAM,aAAa,QAAQ,cAAc;CAEzC,OAAO,OAAO,YAAY;EACxB,MAAM,cAAc,QAAQ,QAAQ,IAAI,UAAU,CAAC,EAAE;EACrD,IAAI,aAAa;GACf,MAAM,CAAC,aAAa,YAAY,MAAM,GAAG;GACzC,IAAI,WAAW,MAAM,QAAQ,cAAc,SAAS,SAAS;EAC/D;EACA,QAAQ,QAAQ,OAAO,YAAY,EAAE,MAAM,IAAI,CAAC;EAChD,OAAO,QAAQ,SAAS,QAAQ,QAAQ,YAAY,SAAS,QAAQ,CAAC;CACxE;AACF;;;;;;;;AAuBA,SAAgB,gBACd,SACmB;CACnB,MAAM,aAAa,QAAQ,cAAc;CACzC,MAAM,YAAY,QAAQ,aAAa;CAEvC,MAAM,UAA6B,OAAO,SAAS,SAAS;EAC1D,MAAM,cAAc,QAAQ,QAAQ,IAAI,UAAU,CAAC,EAAE;EACrD,IAAI,UAA2B;EAE/B,IAAI,aAAa;GACf,MAAM,CAAC,WAAW,aAAa,YAAY,MAAM,GAAG;GACpD,IAAI,aAAa;QAMX,MALgB,cAClB,WACA,WACA,QAAQ,OAAO,OAAO,CACxB,GACW,UAAU,MAAM,QAAQ,WAAW,SAAS,SAAS;GAAA;EAEpE;EAEA,QAAS,OAAmC,aAAa;EACzD,OAAO,KAAK;CACd;CAEA,OAAO;AACT"}
@@ -0,0 +1,59 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/auth/index.ts
3
+ function toHex(bytes) {
4
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
5
+ }
6
+ function toBase64Url(bytes) {
7
+ let binary = "";
8
+ for (const byte of bytes) binary += String.fromCharCode(byte);
9
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
10
+ }
11
+ function fromBase64Url(value) {
12
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
13
+ const binary = atob(padded);
14
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
15
+ }
16
+ /** Generates a 32-byte random token, hex-encoded — for magic-link URLs. */
17
+ function generateToken() {
18
+ return toHex(crypto.getRandomValues(new Uint8Array(32)));
19
+ }
20
+ /** SHA-256 hashes a token for storage — raw tokens never touch KV. */
21
+ async function hashToken(token) {
22
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
23
+ return toHex(new Uint8Array(digest));
24
+ }
25
+ /** Generates a 16-byte random session ID, hex-encoded. */
26
+ function generateSessionId() {
27
+ return toHex(crypto.getRandomValues(new Uint8Array(16)));
28
+ }
29
+ async function hmacKey(secret) {
30
+ return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
31
+ name: "HMAC",
32
+ hash: "SHA-256"
33
+ }, false, ["sign", "verify"]);
34
+ }
35
+ /** HMAC-SHA256-signs a session ID, base64url-encoded. */
36
+ async function signSession(sessionId, secret) {
37
+ const key = await hmacKey(secret);
38
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(sessionId));
39
+ return toBase64Url(new Uint8Array(signature));
40
+ }
41
+ /** Verifies an HMAC-SHA256 session signature. */
42
+ async function verifySession(sessionId, signature, secret) {
43
+ const key = await hmacKey(secret);
44
+ let signatureBytes;
45
+ try {
46
+ signatureBytes = fromBase64Url(signature);
47
+ } catch {
48
+ return false;
49
+ }
50
+ return crypto.subtle.verify("HMAC", key, signatureBytes, new TextEncoder().encode(sessionId));
51
+ }
52
+ //#endregion
53
+ exports.generateSessionId = generateSessionId;
54
+ exports.generateToken = generateToken;
55
+ exports.hashToken = hashToken;
56
+ exports.signSession = signSession;
57
+ exports.verifySession = verifySession;
58
+
59
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../src/auth/index.ts"],"sourcesContent":["// Copyright (c) 2026 BowenLabs. All rights reserved.\n// Cadmus is MIT licensed. See LICENSE in the repo root.\n//\n// @thebes/cadmus/auth\n//\n// Web Crypto primitives for magic-link/token-based auth flows. No\n// passwords, no Node.js crypto — every operation here runs on\n// `crypto.subtle` / `crypto.getRandomValues`, available natively in the\n// V8 isolate. Callers own the KV storage and cookie wiring; this module\n// is pure functions over bytes.\n\nfunction toHex(bytes: Uint8Array): string {\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nfunction toBase64Url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary)\n .replace(/\\+/g, \"-\")\n .replace(/\\//g, \"_\")\n .replace(/=+$/, \"\");\n}\n\nfunction fromBase64Url(value: string): Uint8Array {\n const padded = value.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const binary = atob(padded);\n return Uint8Array.from(binary, (c) => c.charCodeAt(0));\n}\n\n/** Generates a 32-byte random token, hex-encoded — for magic-link URLs. */\nexport function generateToken(): string {\n return toHex(crypto.getRandomValues(new Uint8Array(32)));\n}\n\n/** SHA-256 hashes a token for storage — raw tokens never touch KV. */\nexport async function hashToken(token: string): Promise<string> {\n const digest = await crypto.subtle.digest(\n \"SHA-256\",\n new TextEncoder().encode(token),\n );\n return toHex(new Uint8Array(digest));\n}\n\n/** Generates a 16-byte random session ID, hex-encoded. */\nexport function generateSessionId(): string {\n return toHex(crypto.getRandomValues(new Uint8Array(16)));\n}\n\nasync function hmacKey(secret: string): Promise<CryptoKey> {\n return crypto.subtle.importKey(\n \"raw\",\n new TextEncoder().encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\", \"verify\"],\n );\n}\n\n/** HMAC-SHA256-signs a session ID, base64url-encoded. */\nexport async function signSession(\n sessionId: string,\n secret: string,\n): Promise<string> {\n const key = await hmacKey(secret);\n const signature = await crypto.subtle.sign(\n \"HMAC\",\n key,\n new TextEncoder().encode(sessionId),\n );\n return toBase64Url(new Uint8Array(signature));\n}\n\n/** Verifies an HMAC-SHA256 session signature. */\nexport async function verifySession(\n sessionId: string,\n signature: string,\n secret: string,\n): Promise<boolean> {\n const key = await hmacKey(secret);\n let signatureBytes: Uint8Array;\n try {\n signatureBytes = fromBase64Url(signature);\n } catch {\n return false;\n }\n return crypto.subtle.verify(\n \"HMAC\",\n key,\n signatureBytes as BufferSource,\n new TextEncoder().encode(sessionId),\n );\n}\n"],"mappings":";;AAWA,SAAS,MAAM,OAA2B;CACxC,OAAO,MAAM,KAAK,QAAQ,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;AAC1E;AAEA,SAAS,YAAY,OAA2B;CAC9C,IAAI,SAAS;CACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;CAC5D,OAAO,KAAK,MAAM,CAAC,CAChB,QAAQ,OAAO,GAAG,CAAC,CACnB,QAAQ,OAAO,GAAG,CAAC,CACnB,QAAQ,OAAO,EAAE;AACtB;AAEA,SAAS,cAAc,OAA2B;CAChD,MAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,CAAC,CAAC,QAAQ,MAAM,GAAG;CACzD,MAAM,SAAS,KAAK,MAAM;CAC1B,OAAO,WAAW,KAAK,SAAS,MAAM,EAAE,WAAW,CAAC,CAAC;AACvD;;AAGA,SAAgB,gBAAwB;CACtC,OAAO,MAAM,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC,CAAC;AACzD;;AAGA,eAAsB,UAAU,OAAgC;CAC9D,MAAM,SAAS,MAAM,OAAO,OAAO,OACjC,WACA,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,CAChC;CACA,OAAO,MAAM,IAAI,WAAW,MAAM,CAAC;AACrC;;AAGA,SAAgB,oBAA4B;CAC1C,OAAO,MAAM,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC,CAAC;AACzD;AAEA,eAAe,QAAQ,QAAoC;CACzD,OAAO,OAAO,OAAO,UACnB,OACA,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM,GAC/B;EAAE,MAAM;EAAQ,MAAM;CAAU,GAChC,OACA,CAAC,QAAQ,QAAQ,CACnB;AACF;;AAGA,eAAsB,YACpB,WACA,QACiB;CACjB,MAAM,MAAM,MAAM,QAAQ,MAAM;CAChC,MAAM,YAAY,MAAM,OAAO,OAAO,KACpC,QACA,KACA,IAAI,YAAY,CAAC,CAAC,OAAO,SAAS,CACpC;CACA,OAAO,YAAY,IAAI,WAAW,SAAS,CAAC;AAC9C;;AAGA,eAAsB,cACpB,WACA,WACA,QACkB;CAClB,MAAM,MAAM,MAAM,QAAQ,MAAM;CAChC,IAAI;CACJ,IAAI;EACF,iBAAiB,cAAc,SAAS;CAC1C,QAAQ;EACN,OAAO;CACT;CACA,OAAO,OAAO,OAAO,OACnB,QACA,KACA,gBACA,IAAI,YAAY,CAAC,CAAC,OAAO,SAAS,CACpC;AACF"}
@@ -0,0 +1,14 @@
1
+ //#region src/auth/index.d.ts
2
+ /** Generates a 32-byte random token, hex-encoded — for magic-link URLs. */
3
+ declare function generateToken(): string;
4
+ /** SHA-256 hashes a token for storage — raw tokens never touch KV. */
5
+ declare function hashToken(token: string): Promise<string>;
6
+ /** Generates a 16-byte random session ID, hex-encoded. */
7
+ declare function generateSessionId(): string;
8
+ /** HMAC-SHA256-signs a session ID, base64url-encoded. */
9
+ declare function signSession(sessionId: string, secret: string): Promise<string>;
10
+ /** Verifies an HMAC-SHA256 session signature. */
11
+ declare function verifySession(sessionId: string, signature: string, secret: string): Promise<boolean>;
12
+ //#endregion
13
+ export { generateSessionId, generateToken, hashToken, signSession, verifySession };
14
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/auth/index.ts"],"mappings":";;iBA+BgB,aAAA;;iBAKM,SAAA,CAAU,KAAA,WAAgB,OAAO;;iBASvC,iBAAA;AAThB;AAAA,iBAwBsB,WAAA,CACpB,SAAA,UACA,MAAA,WACC,OAAO;;iBAWY,aAAA,CACpB,SAAA,UACA,SAAA,UACA,MAAA,WACC,OAAO"}
@@ -0,0 +1,14 @@
1
+ //#region src/auth/index.d.ts
2
+ /** Generates a 32-byte random token, hex-encoded — for magic-link URLs. */
3
+ declare function generateToken(): string;
4
+ /** SHA-256 hashes a token for storage — raw tokens never touch KV. */
5
+ declare function hashToken(token: string): Promise<string>;
6
+ /** Generates a 16-byte random session ID, hex-encoded. */
7
+ declare function generateSessionId(): string;
8
+ /** HMAC-SHA256-signs a session ID, base64url-encoded. */
9
+ declare function signSession(sessionId: string, secret: string): Promise<string>;
10
+ /** Verifies an HMAC-SHA256 session signature. */
11
+ declare function verifySession(sessionId: string, signature: string, secret: string): Promise<boolean>;
12
+ //#endregion
13
+ export { generateSessionId, generateToken, hashToken, signSession, verifySession };
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/auth/index.ts"],"mappings":";;iBA+BgB,aAAA;;iBAKM,SAAA,CAAU,KAAA,WAAgB,OAAO;;iBASvC,iBAAA;AAThB;AAAA,iBAwBsB,WAAA,CACpB,SAAA,UACA,MAAA,WACC,OAAO;;iBAWY,aAAA,CACpB,SAAA,UACA,SAAA,UACA,MAAA,WACC,OAAO"}
@@ -0,0 +1,54 @@
1
+ //#region src/auth/index.ts
2
+ function toHex(bytes) {
3
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
4
+ }
5
+ function toBase64Url(bytes) {
6
+ let binary = "";
7
+ for (const byte of bytes) binary += String.fromCharCode(byte);
8
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9
+ }
10
+ function fromBase64Url(value) {
11
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
12
+ const binary = atob(padded);
13
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
14
+ }
15
+ /** Generates a 32-byte random token, hex-encoded — for magic-link URLs. */
16
+ function generateToken() {
17
+ return toHex(crypto.getRandomValues(new Uint8Array(32)));
18
+ }
19
+ /** SHA-256 hashes a token for storage — raw tokens never touch KV. */
20
+ async function hashToken(token) {
21
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
22
+ return toHex(new Uint8Array(digest));
23
+ }
24
+ /** Generates a 16-byte random session ID, hex-encoded. */
25
+ function generateSessionId() {
26
+ return toHex(crypto.getRandomValues(new Uint8Array(16)));
27
+ }
28
+ async function hmacKey(secret) {
29
+ return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
30
+ name: "HMAC",
31
+ hash: "SHA-256"
32
+ }, false, ["sign", "verify"]);
33
+ }
34
+ /** HMAC-SHA256-signs a session ID, base64url-encoded. */
35
+ async function signSession(sessionId, secret) {
36
+ const key = await hmacKey(secret);
37
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(sessionId));
38
+ return toBase64Url(new Uint8Array(signature));
39
+ }
40
+ /** Verifies an HMAC-SHA256 session signature. */
41
+ async function verifySession(sessionId, signature, secret) {
42
+ const key = await hmacKey(secret);
43
+ let signatureBytes;
44
+ try {
45
+ signatureBytes = fromBase64Url(signature);
46
+ } catch {
47
+ return false;
48
+ }
49
+ return crypto.subtle.verify("HMAC", key, signatureBytes, new TextEncoder().encode(sessionId));
50
+ }
51
+ //#endregion
52
+ export { generateSessionId, generateToken, hashToken, signSession, verifySession };
53
+
54
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/auth/index.ts"],"sourcesContent":["// Copyright (c) 2026 BowenLabs. All rights reserved.\n// Cadmus is MIT licensed. See LICENSE in the repo root.\n//\n// @thebes/cadmus/auth\n//\n// Web Crypto primitives for magic-link/token-based auth flows. No\n// passwords, no Node.js crypto — every operation here runs on\n// `crypto.subtle` / `crypto.getRandomValues`, available natively in the\n// V8 isolate. Callers own the KV storage and cookie wiring; this module\n// is pure functions over bytes.\n\nfunction toHex(bytes: Uint8Array): string {\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nfunction toBase64Url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary)\n .replace(/\\+/g, \"-\")\n .replace(/\\//g, \"_\")\n .replace(/=+$/, \"\");\n}\n\nfunction fromBase64Url(value: string): Uint8Array {\n const padded = value.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const binary = atob(padded);\n return Uint8Array.from(binary, (c) => c.charCodeAt(0));\n}\n\n/** Generates a 32-byte random token, hex-encoded — for magic-link URLs. */\nexport function generateToken(): string {\n return toHex(crypto.getRandomValues(new Uint8Array(32)));\n}\n\n/** SHA-256 hashes a token for storage — raw tokens never touch KV. */\nexport async function hashToken(token: string): Promise<string> {\n const digest = await crypto.subtle.digest(\n \"SHA-256\",\n new TextEncoder().encode(token),\n );\n return toHex(new Uint8Array(digest));\n}\n\n/** Generates a 16-byte random session ID, hex-encoded. */\nexport function generateSessionId(): string {\n return toHex(crypto.getRandomValues(new Uint8Array(16)));\n}\n\nasync function hmacKey(secret: string): Promise<CryptoKey> {\n return crypto.subtle.importKey(\n \"raw\",\n new TextEncoder().encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\", \"verify\"],\n );\n}\n\n/** HMAC-SHA256-signs a session ID, base64url-encoded. */\nexport async function signSession(\n sessionId: string,\n secret: string,\n): Promise<string> {\n const key = await hmacKey(secret);\n const signature = await crypto.subtle.sign(\n \"HMAC\",\n key,\n new TextEncoder().encode(sessionId),\n );\n return toBase64Url(new Uint8Array(signature));\n}\n\n/** Verifies an HMAC-SHA256 session signature. */\nexport async function verifySession(\n sessionId: string,\n signature: string,\n secret: string,\n): Promise<boolean> {\n const key = await hmacKey(secret);\n let signatureBytes: Uint8Array;\n try {\n signatureBytes = fromBase64Url(signature);\n } catch {\n return false;\n }\n return crypto.subtle.verify(\n \"HMAC\",\n key,\n signatureBytes as BufferSource,\n new TextEncoder().encode(sessionId),\n );\n}\n"],"mappings":";AAWA,SAAS,MAAM,OAA2B;CACxC,OAAO,MAAM,KAAK,QAAQ,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;AAC1E;AAEA,SAAS,YAAY,OAA2B;CAC9C,IAAI,SAAS;CACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;CAC5D,OAAO,KAAK,MAAM,CAAC,CAChB,QAAQ,OAAO,GAAG,CAAC,CACnB,QAAQ,OAAO,GAAG,CAAC,CACnB,QAAQ,OAAO,EAAE;AACtB;AAEA,SAAS,cAAc,OAA2B;CAChD,MAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,CAAC,CAAC,QAAQ,MAAM,GAAG;CACzD,MAAM,SAAS,KAAK,MAAM;CAC1B,OAAO,WAAW,KAAK,SAAS,MAAM,EAAE,WAAW,CAAC,CAAC;AACvD;;AAGA,SAAgB,gBAAwB;CACtC,OAAO,MAAM,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC,CAAC;AACzD;;AAGA,eAAsB,UAAU,OAAgC;CAC9D,MAAM,SAAS,MAAM,OAAO,OAAO,OACjC,WACA,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,CAChC;CACA,OAAO,MAAM,IAAI,WAAW,MAAM,CAAC;AACrC;;AAGA,SAAgB,oBAA4B;CAC1C,OAAO,MAAM,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC,CAAC;AACzD;AAEA,eAAe,QAAQ,QAAoC;CACzD,OAAO,OAAO,OAAO,UACnB,OACA,IAAI,YAAY,CAAC,CAAC,OAAO,MAAM,GAC/B;EAAE,MAAM;EAAQ,MAAM;CAAU,GAChC,OACA,CAAC,QAAQ,QAAQ,CACnB;AACF;;AAGA,eAAsB,YACpB,WACA,QACiB;CACjB,MAAM,MAAM,MAAM,QAAQ,MAAM;CAChC,MAAM,YAAY,MAAM,OAAO,OAAO,KACpC,QACA,KACA,IAAI,YAAY,CAAC,CAAC,OAAO,SAAS,CACpC;CACA,OAAO,YAAY,IAAI,WAAW,SAAS,CAAC;AAC9C;;AAGA,eAAsB,cACpB,WACA,WACA,QACkB;CAClB,MAAM,MAAM,MAAM,QAAQ,MAAM;CAChC,IAAI;CACJ,IAAI;EACF,iBAAiB,cAAc,SAAS;CAC1C,QAAQ;EACN,OAAO;CACT;CACA,OAAO,OAAO,OAAO,OACnB,QACA,KACA,gBACA,IAAI,YAAY,CAAC,CAAC,OAAO,SAAS,CACpC;AACF"}
@@ -0,0 +1,18 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/cache/index.ts
3
+ const isDev = typeof caches === "undefined" || typeof caches.default === "undefined";
4
+ async function purgeCache(url) {
5
+ if (isDev) {
6
+ console.log(`[cache] DEV — skipping purge: ${url}`);
7
+ return;
8
+ }
9
+ try {
10
+ await caches.default.delete(new Request(url));
11
+ } catch (err) {
12
+ console.warn("[cache] Purge failed:", err);
13
+ }
14
+ }
15
+ //#endregion
16
+ exports.purgeCache = purgeCache;
17
+
18
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../src/cache/index.ts"],"sourcesContent":["// Copyright (c) 2026 BowenLabs. All rights reserved.\n// Cadmus is MIT licensed. See LICENSE in the repo root.\n//\n// @thebes/cadmus/cache\n//\n// Cloudflare Cache API wrapper with a dev-mode bypass. `caches.default`\n// has been confirmed available under `wrangler dev` in current\n// wrangler/workerd versions (see DECISIONS.md, 2026-06-19) — this bypass\n// is defensive code for older versions and non-Workers test runtimes\n// (e.g. vitest-pool-workers), not something every dev request hits.\n\n// `@cloudflare/workers-types` never declares `caches.default` — that\n// Workers-runtime extension to the standard CacheStorage API is only ever\n// typed via wrangler's own per-project generated worker-configuration.d.ts,\n// not the static npm package. Cadmus ships standalone, so it declares the\n// minimal ambient extension itself rather than depending on a consumer's\n// generated types.\ndeclare global {\n interface CacheStorage {\n readonly default: Cache;\n }\n}\n\nconst isDev =\n typeof caches === \"undefined\" || typeof caches.default === \"undefined\";\n\nexport async function purgeCache(url: string): Promise<void> {\n if (isDev) {\n console.log(`[cache] DEV — skipping purge: ${url}`);\n return;\n }\n try {\n await caches.default.delete(new Request(url));\n } catch (err) {\n console.warn(\"[cache] Purge failed:\", err);\n }\n}\n"],"mappings":";;AAuBA,MAAM,QACJ,OAAO,WAAW,eAAe,OAAO,OAAO,YAAY;AAE7D,eAAsB,WAAW,KAA4B;CAC3D,IAAI,OAAO;EACT,QAAQ,IAAI,iCAAiC,KAAK;EAClD;CACF;CACA,IAAI;EACF,MAAM,OAAO,QAAQ,OAAO,IAAI,QAAQ,GAAG,CAAC;CAC9C,SAAS,KAAK;EACZ,QAAQ,KAAK,yBAAyB,GAAG;CAC3C;AACF"}
@@ -0,0 +1,10 @@
1
+ //#region src/cache/index.d.ts
2
+ declare global {
3
+ interface CacheStorage {
4
+ readonly default: Cache;
5
+ }
6
+ }
7
+ declare function purgeCache(url: string): Promise<void>;
8
+ //#endregion
9
+ export { purgeCache };
10
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/cache/index.ts"],"mappings":";QAiBQ,MAAA;EAAA,UACI,YAAA;IAAA,SACC,OAAA,EAAS,KAAK;EAAA;AAAA;AAAA,iBAOL,UAAA,CAAW,GAAA,WAAc,OAAO"}
@@ -0,0 +1,10 @@
1
+ //#region src/cache/index.d.ts
2
+ declare global {
3
+ interface CacheStorage {
4
+ readonly default: Cache;
5
+ }
6
+ }
7
+ declare function purgeCache(url: string): Promise<void>;
8
+ //#endregion
9
+ export { purgeCache };
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/cache/index.ts"],"mappings":";QAiBQ,MAAA;EAAA,UACI,YAAA;IAAA,SACC,OAAA,EAAS,KAAK;EAAA;AAAA;AAAA,iBAOL,UAAA,CAAW,GAAA,WAAc,OAAO"}
@@ -0,0 +1,17 @@
1
+ //#region src/cache/index.ts
2
+ const isDev = typeof caches === "undefined" || typeof caches.default === "undefined";
3
+ async function purgeCache(url) {
4
+ if (isDev) {
5
+ console.log(`[cache] DEV — skipping purge: ${url}`);
6
+ return;
7
+ }
8
+ try {
9
+ await caches.default.delete(new Request(url));
10
+ } catch (err) {
11
+ console.warn("[cache] Purge failed:", err);
12
+ }
13
+ }
14
+ //#endregion
15
+ export { purgeCache };
16
+
17
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/cache/index.ts"],"sourcesContent":["// Copyright (c) 2026 BowenLabs. All rights reserved.\n// Cadmus is MIT licensed. See LICENSE in the repo root.\n//\n// @thebes/cadmus/cache\n//\n// Cloudflare Cache API wrapper with a dev-mode bypass. `caches.default`\n// has been confirmed available under `wrangler dev` in current\n// wrangler/workerd versions (see DECISIONS.md, 2026-06-19) — this bypass\n// is defensive code for older versions and non-Workers test runtimes\n// (e.g. vitest-pool-workers), not something every dev request hits.\n\n// `@cloudflare/workers-types` never declares `caches.default` — that\n// Workers-runtime extension to the standard CacheStorage API is only ever\n// typed via wrangler's own per-project generated worker-configuration.d.ts,\n// not the static npm package. Cadmus ships standalone, so it declares the\n// minimal ambient extension itself rather than depending on a consumer's\n// generated types.\ndeclare global {\n interface CacheStorage {\n readonly default: Cache;\n }\n}\n\nconst isDev =\n typeof caches === \"undefined\" || typeof caches.default === \"undefined\";\n\nexport async function purgeCache(url: string): Promise<void> {\n if (isDev) {\n console.log(`[cache] DEV — skipping purge: ${url}`);\n return;\n }\n try {\n await caches.default.delete(new Request(url));\n } catch (err) {\n console.warn(\"[cache] Purge failed:\", err);\n }\n}\n"],"mappings":";AAuBA,MAAM,QACJ,OAAO,WAAW,eAAe,OAAO,OAAO,YAAY;AAE7D,eAAsB,WAAW,KAA4B;CAC3D,IAAI,OAAO;EACT,QAAQ,IAAI,iCAAiC,KAAK;EAClD;CACF;CACA,IAAI;EACF,MAAM,OAAO,QAAQ,OAAO,IAAI,QAAQ,GAAG,CAAC;CAC9C,SAAS,KAAK;EACZ,QAAQ,KAAK,yBAAyB,GAAG;CAC3C;AACF"}