@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BowenLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # @thebes/cadmus
2
+
3
+ V8-first, Cloudflare-native full-stack framework primitives.
4
+
5
+ > **0.x — active development.** APIs will change. Not production-ready.
6
+ > Star [bowenlabs/project-thebes](https://github.com/bowenlabs/project-thebes) to follow along.
7
+
8
+ ---
9
+
10
+ ## What is Cadmus?
11
+
12
+ Cadmus provides the primitives needed to build complete web applications
13
+ on Cloudflare Workers without Node.js assumptions, adapter layers, or
14
+ configuration overhead.
15
+
16
+ Cloudflare is an exceptional platform — ethical, privacy-focused, at-cost
17
+ pricing. Cadmus makes building on it feel complete.
18
+
19
+ **Inspired by:** Vue's progressive adoption model, Hono's V8-first proof of concept.
20
+
21
+ ---
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ npm install @thebes/cadmus
27
+ # or
28
+ pnpm add @thebes/cadmus
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Primitives
34
+
35
+ Each primitive is independently usable — import only what you need:
36
+
37
+ ```typescript
38
+ import { db } from '@thebes/cadmus/db'
39
+ import { createMagicLink } from '@thebes/cadmus/auth'
40
+ import { upload } from '@thebes/cadmus/storage'
41
+ import { purgeCache } from '@thebes/cadmus/cache'
42
+ import { sendEmail } from '@thebes/cadmus/email'
43
+ import { rateLimit } from '@thebes/cadmus/rate-limit'
44
+ import { createSession } from '@thebes/cadmus/session'
45
+ import { enqueue } from '@thebes/cadmus/queues'
46
+ ```
47
+
48
+ Hono users get ergonomic middleware via a separate entrypoint:
49
+
50
+ ```typescript
51
+ import { cadmusAuth, cadmusRateLimit } from '@thebes/cadmus/hono'
52
+ ```
53
+
54
+ **A CMS engine on top of the primitives above** — model content as
55
+ collections and get a generated Drizzle schema, a typed, access-controlled,
56
+ hook-driven Local API, and a Payload-equivalent REST API
57
+ (`mountCmsRoutes`, also from `@thebes/cadmus/hono`):
58
+
59
+ ```typescript
60
+ import { createLocalApi, defineCmsConfig } from '@thebes/cadmus/cms'
61
+ ```
62
+
63
+ See [`src/cms/README.md`](./src/cms/README.md) for the full reference —
64
+ config/plugins, access control, hooks, relationship `depth` resolution,
65
+ draft/publish versioning, and mounting the REST API.
66
+
67
+ ---
68
+
69
+ ## Design principles
70
+
71
+ - **V8-first** — no Node.js APIs. Web Crypto, Web Fetch, Web Streams throughout.
72
+ - **Independent primitives** — zero cross-primitive dependencies. Use one or all.
73
+ - **Raw bindings** — pass `env.KV`, `env.DB` directly. Explicit over magic.
74
+ - **Thrown errors** — `CadmusError` and typed subclasses. Reliable `instanceof` checks.
75
+ - **Composing, not wrapping** — Cadmus uses Hono, Drizzle, and Astro rather
76
+ than reimplementing what they do well.
77
+
78
+ ---
79
+
80
+ ## Email (`@thebes/cadmus/email`)
81
+
82
+ Thin wrapper over the CF Email Workers `send_email` binding.
83
+
84
+ ```typescript
85
+ import { sendEmail } from '@thebes/cadmus/email'
86
+
87
+ await sendEmail(env.EMAIL, {
88
+ from: 'noreply@yourdomain.com',
89
+ to: 'owner@example.com',
90
+ subject: 'Your magic link',
91
+ html: '<p>Click <a href="https://example.com">here</a></p>',
92
+ })
93
+ ```
94
+
95
+ `sendEmail` throws `CadmusEmailError` on failure — wrap calls in a
96
+ best-effort handler if the surrounding flow shouldn't fail just because
97
+ an email didn't send (e.g. a form submission notification).
98
+
99
+ **Required setup — before sending anything:**
100
+
101
+ 1. The `from` address must be on a domain with **Cloudflare Email Routing**
102
+ enabled (Cloudflare dashboard → your domain → Email → Email Routing).
103
+ 2. Add the DNS records Cloudflare generates for that domain:
104
+ - **SPF** — `TXT` record authorizing Cloudflare to send on the domain's behalf
105
+ - **DKIM** — `TXT` record for signing outbound mail
106
+ - **DMARC** — `TXT` record (`_dmarc.yourdomain.com`) declaring your policy
107
+ Cloudflare's Email Routing setup screen lists the exact records to add.
108
+ 3. Add `send_email` to the binding's `wrangler.jsonc`:
109
+ ```jsonc
110
+ { "send_email": [{ "name": "EMAIL" }] }
111
+ ```
112
+
113
+ **Local dev:** `env.EMAIL` is `undefined` in `wrangler dev` unless you
114
+ configure a destination address for it — there is no real routing for
115
+ `localhost`. Callers should check for the binding (or catch
116
+ `CadmusEmailError`) and fall back to logging rather than assuming
117
+ `sendEmail` always succeeds in dev.
118
+
119
+ ---
120
+
121
+ ## Compatibility
122
+
123
+ | Framework | Status |
124
+ |---|---|
125
+ | Astro + `@astrojs/cloudflare` | ✅ Tested |
126
+ | TanStack Start + `@cloudflare/vite-plugin` | ✅ Tested |
127
+ | Hono on Workers | ✅ Tested |
128
+ | Raw Cloudflare Workers | ✅ Tested |
129
+ | Others | 🔲 Should work — PRs with tests welcome |
130
+
131
+ ---
132
+
133
+ ## Community primitives
134
+
135
+ Third-party integrations and opinionated patterns live under
136
+ `@cadmus-community/*`. Contribution guide forthcoming.
137
+
138
+ ---
139
+
140
+ ## License
141
+
142
+ MIT — [LICENSE](./LICENSE)
143
+
144
+ ---
145
+
146
+ ## Maintained by
147
+
148
+ [BowenLabs](https://bowenlabs.com) — one person, built with care.
149
+ PRs welcome. No SLA. If you're building something critical on `0.x`,
150
+ be comfortable reading and if necessary patching the source.
@@ -0,0 +1,149 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_auth_index = require("../auth/index.cjs");
3
+ const require_rate_limit_index = require("../rate-limit/index.cjs");
4
+ //#region src/astro/index.ts
5
+ const DEFAULT_COOKIE_NAME = "cadmus_session";
6
+ const DEFAULT_COOKIE_MAX_AGE_SECONDS = 3600 * 24 * 7;
7
+ const DEFAULT_MAGIC_LINK_TTL_SECONDS = 900;
8
+ const DEFAULT_RATE_LIMIT = {
9
+ limit: 3,
10
+ windowSeconds: 900
11
+ };
12
+ const KV_RETRY_ATTEMPTS = 2;
13
+ const KV_RETRY_DELAY_MS = 100;
14
+ function sleep(ms) {
15
+ return new Promise((resolve) => setTimeout(resolve, ms));
16
+ }
17
+ function isSafeRedirect(value) {
18
+ return !!value && value.startsWith("/") && !value.startsWith("//");
19
+ }
20
+ function resolve(value, context, fallback) {
21
+ if (value === void 0) return fallback;
22
+ return typeof value === "function" ? value(context) : value;
23
+ }
24
+ function defaultIsLocalDev(context) {
25
+ const hostname = context.url.hostname;
26
+ return hostname === "localhost" || hostname === "127.0.0.1";
27
+ }
28
+ function defaultOnLocalDev(_context, { email, verifyUrl }) {
29
+ console.log(`[dev] Magic link for ${email}: ${verifyUrl.toString()}`);
30
+ }
31
+ /**
32
+ * Builds the magic-link request (`POST`) and verify (`GET`) Astro
33
+ * `APIRoute` handlers — mount both at the same route, e.g.
34
+ * `export const { POST, GET } = createMagicLinkHandlers(options)` from
35
+ * `src/pages/api/auth/[...path].ts`, or wire `POST`/`GET` into separate
36
+ * `magic-link.ts`/`verify.ts` routes matching `verifyPath` below.
37
+ */
38
+ function createMagicLinkHandlers(options) {
39
+ const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
40
+ const cookieMaxAgeSeconds = options.cookieMaxAgeSeconds ?? DEFAULT_COOKIE_MAX_AGE_SECONDS;
41
+ const magicLinkTtlSeconds = options.magicLinkTtlSeconds ?? DEFAULT_MAGIC_LINK_TTL_SECONDS;
42
+ const rateLimit = options.rateLimit === false ? null : options.rateLimit ?? DEFAULT_RATE_LIMIT;
43
+ const verifyPath = options.verifyPath ?? "/api/auth/verify";
44
+ const loginPath = options.loginPath ?? "/login";
45
+ const isLocalDev = options.isLocalDev ?? defaultIsLocalDev;
46
+ const onLocalDev = options.onLocalDev ?? defaultOnLocalDev;
47
+ const POST = async (context) => {
48
+ const body = await context.request.json().catch(() => null);
49
+ const email = body?.email?.trim().toLowerCase();
50
+ if (!email) return Response.json({ ok: true });
51
+ const redirect = isSafeRedirect(body?.redirect) ? body.redirect : null;
52
+ const kv = options.kv(context);
53
+ if (rateLimit) {
54
+ const { allowed } = await require_rate_limit_index.checkRateLimit(kv, `magiclink:ratelimit:${email}`, rateLimit.limit, rateLimit.windowSeconds);
55
+ if (!allowed) return Response.json({ ok: true });
56
+ }
57
+ if (!await options.findUser(context, email)) return Response.json({ ok: true });
58
+ const token = require_auth_index.generateToken();
59
+ const hash = await require_auth_index.hashToken(token);
60
+ await kv.put(`magiclink:${hash}`, email, { expirationTtl: magicLinkTtlSeconds });
61
+ const verifyUrl = new URL(verifyPath, context.url);
62
+ verifyUrl.searchParams.set("token", token);
63
+ if (redirect) verifyUrl.searchParams.set("redirect", redirect);
64
+ if (isLocalDev(context)) await onLocalDev(context, {
65
+ email,
66
+ verifyUrl
67
+ });
68
+ else await options.sendMagicLinkEmail(context, {
69
+ email,
70
+ verifyUrl
71
+ });
72
+ return Response.json({ ok: true });
73
+ };
74
+ const GET = async (context) => {
75
+ const token = context.url.searchParams.get("token");
76
+ if (!token) return context.redirect(`${loginPath}?error=invalid`);
77
+ const kv = options.kv(context);
78
+ const key = `magiclink:${await require_auth_index.hashToken(token)}`;
79
+ let email = null;
80
+ for (let attempt = 0; attempt <= KV_RETRY_ATTEMPTS; attempt++) {
81
+ email = await kv.get(key);
82
+ if (email !== null) break;
83
+ if (attempt < KV_RETRY_ATTEMPTS) await sleep(KV_RETRY_DELAY_MS);
84
+ }
85
+ if (email === null) return context.redirect(`${loginPath}?error=invalid`);
86
+ await kv.delete(key);
87
+ const user = await options.findUser(context, email);
88
+ if (!user) return context.redirect(`${loginPath}?error=unauthorized`);
89
+ const { sessionId } = await options.createSession(context, user);
90
+ const signature = await require_auth_index.signSession(sessionId, options.secret(context));
91
+ context.cookies.set(cookieName, `${sessionId}.${signature}`, {
92
+ httpOnly: true,
93
+ secure: true,
94
+ sameSite: "lax",
95
+ path: "/",
96
+ maxAge: cookieMaxAgeSeconds
97
+ });
98
+ const requestedRedirect = context.url.searchParams.get("redirect");
99
+ const redirectTo = isSafeRedirect(requestedRedirect) ? requestedRedirect : resolve(options.defaultRedirect, context, "/");
100
+ return context.redirect(redirectTo);
101
+ };
102
+ return {
103
+ POST,
104
+ GET
105
+ };
106
+ }
107
+ /** Builds a logout `APIRoute` — clears the session cookie and its backing store entry. */
108
+ function createLogoutHandler(options) {
109
+ const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
110
+ return async (context) => {
111
+ const cookieValue = context.cookies.get(cookieName)?.value;
112
+ if (cookieValue) {
113
+ const [sessionId] = cookieValue.split(".");
114
+ if (sessionId) await options.deleteSession(context, sessionId);
115
+ }
116
+ context.cookies.delete(cookieName, { path: "/" });
117
+ return context.redirect(resolve(options.redirectTo, context, "/login"));
118
+ };
119
+ }
120
+ /**
121
+ * Astro middleware that verifies the session cookie's signature and
122
+ * populates `context.locals[localsKey]` with the resolved session, or
123
+ * null if there isn't one. Mirrors `cadmusAuth()`'s role in the Hono
124
+ * layer: it authenticates the request, it doesn't gate access — pages
125
+ * and routes downstream decide what to do with a null session.
126
+ */
127
+ function cadmusAuthGuard(options) {
128
+ const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
129
+ const localsKey = options.localsKey ?? "session";
130
+ const handler = async (context, next) => {
131
+ const cookieValue = context.cookies.get(cookieName)?.value;
132
+ let session = null;
133
+ if (cookieValue) {
134
+ const [sessionId, signature] = cookieValue.split(".");
135
+ if (sessionId && signature) {
136
+ if (await require_auth_index.verifySession(sessionId, signature, options.secret(context))) session = await options.getSession(context, sessionId);
137
+ }
138
+ }
139
+ context.locals[localsKey] = session;
140
+ return next();
141
+ };
142
+ return handler;
143
+ }
144
+ //#endregion
145
+ exports.cadmusAuthGuard = cadmusAuthGuard;
146
+ exports.createLogoutHandler = createLogoutHandler;
147
+ exports.createMagicLinkHandlers = createMagicLinkHandlers;
148
+
149
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":["checkRateLimit","generateToken","hashToken","signSession","verifySession"],"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":"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"}
@@ -0,0 +1,101 @@
1
+ import { APIContext, APIRoute, MiddlewareHandler } from "astro";
2
+
3
+ //#region src/astro/index.d.ts
4
+ interface MagicLinkHandlersOptions<TUser> {
5
+ /** Resolves the KV namespace magic-link tokens are stored in, per request. */
6
+ kv: (context: APIContext) => KVNamespace;
7
+ /** Resolves the secret session cookies are HMAC-signed with — must match cadmusAuthGuard's `secret`. */
8
+ secret: (context: APIContext) => string;
9
+ /**
10
+ * Looks up the user a request's email belongs to. Returning null is
11
+ * indistinguishable to the client from a successful send — this is
12
+ * the anti-enumeration guarantee the same way the hand-rolled
13
+ * Worker 1 route this replaces always returned `{ ok: true }`.
14
+ */
15
+ findUser: (context: APIContext, email: string) => Promise<TUser | null>;
16
+ /** Creates a session for a verified user, returning the session ID to sign into the cookie. */
17
+ createSession: (context: APIContext, user: TUser) => Promise<{
18
+ sessionId: string;
19
+ }>;
20
+ /** Sends the magic-link email. Not called when `isLocalDev` returns true — see that option. */
21
+ sendMagicLinkEmail: (context: APIContext, params: {
22
+ email: string;
23
+ verifyUrl: URL;
24
+ }) => Promise<void>;
25
+ /** Cookie name the session is signed into. Defaults to "cadmus_session". */
26
+ cookieName?: string;
27
+ /** Cookie `maxAge` in seconds. Defaults to 7 days. */
28
+ cookieMaxAgeSeconds?: number;
29
+ /** Magic-link token TTL in seconds. Defaults to 15 minutes. */
30
+ magicLinkTtlSeconds?: number;
31
+ /**
32
+ * Per-email rate limit on magic-link requests, keyed in the same KV
33
+ * namespace `kv` resolves. Defaults to 3 requests / 15 minutes. Pass
34
+ * `false` to disable.
35
+ */
36
+ rateLimit?: {
37
+ limit: number;
38
+ windowSeconds: number;
39
+ } | false;
40
+ /** Path the verify GET handler is mounted at — used to build the emailed link. Defaults to "/api/auth/verify". */
41
+ verifyPath?: string;
42
+ /** Path to redirect to on a failed verify, with `?error=invalid|unauthorized` appended. Defaults to "/login". */
43
+ loginPath?: string;
44
+ /** Redirect target after a successful verify when no `redirect` param was supplied. Defaults to "/". */
45
+ defaultRedirect?: string | ((context: APIContext) => string);
46
+ /**
47
+ * Whether this request should skip emailing and log the link instead —
48
+ * see `onLocalDev`. Defaults to checking for a localhost/127.0.0.1
49
+ * request hostname, since no deployed environment is ever literally
50
+ * that (unlike checking `sendMagicLinkEmail`'s own success/failure,
51
+ * which local email emulators can mask).
52
+ */
53
+ isLocalDev?: (context: APIContext) => boolean;
54
+ /** Called instead of `sendMagicLinkEmail` when `isLocalDev` is true. Defaults to a console.log of the link. */
55
+ onLocalDev?: (context: APIContext, params: {
56
+ email: string;
57
+ verifyUrl: URL;
58
+ }) => void | Promise<void>;
59
+ }
60
+ /**
61
+ * Builds the magic-link request (`POST`) and verify (`GET`) Astro
62
+ * `APIRoute` handlers — mount both at the same route, e.g.
63
+ * `export const { POST, GET } = createMagicLinkHandlers(options)` from
64
+ * `src/pages/api/auth/[...path].ts`, or wire `POST`/`GET` into separate
65
+ * `magic-link.ts`/`verify.ts` routes matching `verifyPath` below.
66
+ */
67
+ declare function createMagicLinkHandlers<TUser>(options: MagicLinkHandlersOptions<TUser>): {
68
+ POST: APIRoute;
69
+ GET: APIRoute;
70
+ };
71
+ interface LogoutHandlerOptions {
72
+ /** Cookie name the session is signed into. Must match createMagicLinkHandlers' `cookieName`. */
73
+ cookieName?: string;
74
+ /** Deletes the session identified by the cookie's session ID (e.g. from KV). */
75
+ deleteSession: (context: APIContext, sessionId: string) => Promise<void>;
76
+ /** Where to redirect after logout. Defaults to "/login". */
77
+ redirectTo?: string | ((context: APIContext) => string);
78
+ }
79
+ /** Builds a logout `APIRoute` — clears the session cookie and its backing store entry. */
80
+ declare function createLogoutHandler(options: LogoutHandlerOptions): APIRoute;
81
+ interface AuthGuardOptions<TSession> {
82
+ /** Cookie name the session is signed into. Must match createMagicLinkHandlers' `cookieName`. */
83
+ cookieName?: string;
84
+ /** Resolves the secret session cookies are HMAC-signed with — must match createMagicLinkHandlers' `secret`. */
85
+ secret: (context: APIContext) => string;
86
+ /** Reads the session for a verified session ID (e.g. from KV). Returning null treats the session as missing. */
87
+ getSession: (context: APIContext, sessionId: string) => Promise<TSession | null>;
88
+ /** Key set on `context.locals`. Defaults to "session". */
89
+ localsKey?: string;
90
+ }
91
+ /**
92
+ * Astro middleware that verifies the session cookie's signature and
93
+ * populates `context.locals[localsKey]` with the resolved session, or
94
+ * null if there isn't one. Mirrors `cadmusAuth()`'s role in the Hono
95
+ * layer: it authenticates the request, it doesn't gate access — pages
96
+ * and routes downstream decide what to do with a null session.
97
+ */
98
+ declare function cadmusAuthGuard<TSession>(options: AuthGuardOptions<TSession>): MiddlewareHandler;
99
+ //#endregion
100
+ export { AuthGuardOptions, LogoutHandlerOptions, MagicLinkHandlersOptions, cadmusAuthGuard, createLogoutHandler, createMagicLinkHandlers };
101
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/astro/index.ts"],"mappings":";;;UA2DiB,wBAAA;;EAEf,EAAA,GAAK,OAAA,EAAS,UAAA,KAAe,WAAA;EAFU;EAIvC,MAAA,GAAS,OAAA,EAAS,UAAA;EAFJ;;;;;;EASd,QAAA,GAAW,OAAA,EAAS,UAAA,EAAY,KAAA,aAAkB,OAAA,CAAQ,KAAA;EAIlD;EAFR,aAAA,GACE,OAAA,EAAS,UAAA,EACT,IAAA,EAAM,KAAA,KACH,OAAA;IAAU,SAAA;EAAA;EAKV;EAHL,kBAAA,GACE,OAAA,EAAS,UAAA,EACT,MAAA;IAAU,KAAA;IAAe,SAAA,EAAW,GAAA;EAAA,MACjC,OAAA;EA+BO;EA7BZ,UAAA;EA6BmB;EA3BnB,mBAAA;EAvBA;EAyBA,mBAAA;EAzBK;;;;;EA+BL,SAAA;IAAc,KAAA;IAAe,aAAA;EAAA;EAtBqB;EAwBlD,UAAA;EAtBA;EAwBA,SAAA;EAvBE;EAyBF,eAAA,cAA6B,OAAA,EAAS,UAAA;EAxBpC;;;;;;;EAgCF,UAAA,IAAc,OAAA,EAAS,UAAA;EA3Be;EA6BtC,UAAA,IACE,OAAA,EAAS,UAAA,EACT,MAAA;IAAU,KAAA;IAAe,SAAA,EAAW,GAAA;EAAA,aAC1B,OAAA;AAAA;;;;;;;;iBAsBE,uBAAA,QACd,OAAA,EAAS,wBAAA,CAAyB,KAAA;EAC/B,IAAA,EAAM,QAAA;EAAU,GAAA,EAAK,QAAA;AAAA;AAAA,UAwGT,oBAAA;EAlIJ;EAoIX,UAAA;EAnIY;EAqIZ,aAAA,GAAgB,OAAA,EAAS,UAAA,EAAY,SAAA,aAAsB,OAAA;EArIrB;EAuItC,UAAA,cAAwB,OAAA,EAAS,UAAA;AAAA;;iBAInB,mBAAA,CAAoB,OAAA,EAAS,oBAAA,GAAuB,QAAQ;AAAA,UAc3D,gBAAA;EAlIsB;EAoIrC,UAAA;EAnIkC;EAqIlC,MAAA,GAAS,OAAA,EAAS,UAAA;EApIT;EAsIT,UAAA,GACE,OAAA,EAAS,UAAA,EACT,SAAA,aACG,OAAA,CAAQ,QAAA;EAzImB;EA2IhC,SAAA;AAAA;;;;;;;;iBAUc,eAAA,WACd,OAAA,EAAS,gBAAA,CAAiB,QAAA,IACzB,iBAAA"}
@@ -0,0 +1,101 @@
1
+ import { APIContext, APIRoute, MiddlewareHandler } from "astro";
2
+
3
+ //#region src/astro/index.d.ts
4
+ interface MagicLinkHandlersOptions<TUser> {
5
+ /** Resolves the KV namespace magic-link tokens are stored in, per request. */
6
+ kv: (context: APIContext) => KVNamespace;
7
+ /** Resolves the secret session cookies are HMAC-signed with — must match cadmusAuthGuard's `secret`. */
8
+ secret: (context: APIContext) => string;
9
+ /**
10
+ * Looks up the user a request's email belongs to. Returning null is
11
+ * indistinguishable to the client from a successful send — this is
12
+ * the anti-enumeration guarantee the same way the hand-rolled
13
+ * Worker 1 route this replaces always returned `{ ok: true }`.
14
+ */
15
+ findUser: (context: APIContext, email: string) => Promise<TUser | null>;
16
+ /** Creates a session for a verified user, returning the session ID to sign into the cookie. */
17
+ createSession: (context: APIContext, user: TUser) => Promise<{
18
+ sessionId: string;
19
+ }>;
20
+ /** Sends the magic-link email. Not called when `isLocalDev` returns true — see that option. */
21
+ sendMagicLinkEmail: (context: APIContext, params: {
22
+ email: string;
23
+ verifyUrl: URL;
24
+ }) => Promise<void>;
25
+ /** Cookie name the session is signed into. Defaults to "cadmus_session". */
26
+ cookieName?: string;
27
+ /** Cookie `maxAge` in seconds. Defaults to 7 days. */
28
+ cookieMaxAgeSeconds?: number;
29
+ /** Magic-link token TTL in seconds. Defaults to 15 minutes. */
30
+ magicLinkTtlSeconds?: number;
31
+ /**
32
+ * Per-email rate limit on magic-link requests, keyed in the same KV
33
+ * namespace `kv` resolves. Defaults to 3 requests / 15 minutes. Pass
34
+ * `false` to disable.
35
+ */
36
+ rateLimit?: {
37
+ limit: number;
38
+ windowSeconds: number;
39
+ } | false;
40
+ /** Path the verify GET handler is mounted at — used to build the emailed link. Defaults to "/api/auth/verify". */
41
+ verifyPath?: string;
42
+ /** Path to redirect to on a failed verify, with `?error=invalid|unauthorized` appended. Defaults to "/login". */
43
+ loginPath?: string;
44
+ /** Redirect target after a successful verify when no `redirect` param was supplied. Defaults to "/". */
45
+ defaultRedirect?: string | ((context: APIContext) => string);
46
+ /**
47
+ * Whether this request should skip emailing and log the link instead —
48
+ * see `onLocalDev`. Defaults to checking for a localhost/127.0.0.1
49
+ * request hostname, since no deployed environment is ever literally
50
+ * that (unlike checking `sendMagicLinkEmail`'s own success/failure,
51
+ * which local email emulators can mask).
52
+ */
53
+ isLocalDev?: (context: APIContext) => boolean;
54
+ /** Called instead of `sendMagicLinkEmail` when `isLocalDev` is true. Defaults to a console.log of the link. */
55
+ onLocalDev?: (context: APIContext, params: {
56
+ email: string;
57
+ verifyUrl: URL;
58
+ }) => void | Promise<void>;
59
+ }
60
+ /**
61
+ * Builds the magic-link request (`POST`) and verify (`GET`) Astro
62
+ * `APIRoute` handlers — mount both at the same route, e.g.
63
+ * `export const { POST, GET } = createMagicLinkHandlers(options)` from
64
+ * `src/pages/api/auth/[...path].ts`, or wire `POST`/`GET` into separate
65
+ * `magic-link.ts`/`verify.ts` routes matching `verifyPath` below.
66
+ */
67
+ declare function createMagicLinkHandlers<TUser>(options: MagicLinkHandlersOptions<TUser>): {
68
+ POST: APIRoute;
69
+ GET: APIRoute;
70
+ };
71
+ interface LogoutHandlerOptions {
72
+ /** Cookie name the session is signed into. Must match createMagicLinkHandlers' `cookieName`. */
73
+ cookieName?: string;
74
+ /** Deletes the session identified by the cookie's session ID (e.g. from KV). */
75
+ deleteSession: (context: APIContext, sessionId: string) => Promise<void>;
76
+ /** Where to redirect after logout. Defaults to "/login". */
77
+ redirectTo?: string | ((context: APIContext) => string);
78
+ }
79
+ /** Builds a logout `APIRoute` — clears the session cookie and its backing store entry. */
80
+ declare function createLogoutHandler(options: LogoutHandlerOptions): APIRoute;
81
+ interface AuthGuardOptions<TSession> {
82
+ /** Cookie name the session is signed into. Must match createMagicLinkHandlers' `cookieName`. */
83
+ cookieName?: string;
84
+ /** Resolves the secret session cookies are HMAC-signed with — must match createMagicLinkHandlers' `secret`. */
85
+ secret: (context: APIContext) => string;
86
+ /** Reads the session for a verified session ID (e.g. from KV). Returning null treats the session as missing. */
87
+ getSession: (context: APIContext, sessionId: string) => Promise<TSession | null>;
88
+ /** Key set on `context.locals`. Defaults to "session". */
89
+ localsKey?: string;
90
+ }
91
+ /**
92
+ * Astro middleware that verifies the session cookie's signature and
93
+ * populates `context.locals[localsKey]` with the resolved session, or
94
+ * null if there isn't one. Mirrors `cadmusAuth()`'s role in the Hono
95
+ * layer: it authenticates the request, it doesn't gate access — pages
96
+ * and routes downstream decide what to do with a null session.
97
+ */
98
+ declare function cadmusAuthGuard<TSession>(options: AuthGuardOptions<TSession>): MiddlewareHandler;
99
+ //#endregion
100
+ export { AuthGuardOptions, LogoutHandlerOptions, MagicLinkHandlersOptions, cadmusAuthGuard, createLogoutHandler, createMagicLinkHandlers };
101
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/astro/index.ts"],"mappings":";;;UA2DiB,wBAAA;;EAEf,EAAA,GAAK,OAAA,EAAS,UAAA,KAAe,WAAA;EAFU;EAIvC,MAAA,GAAS,OAAA,EAAS,UAAA;EAFJ;;;;;;EASd,QAAA,GAAW,OAAA,EAAS,UAAA,EAAY,KAAA,aAAkB,OAAA,CAAQ,KAAA;EAIlD;EAFR,aAAA,GACE,OAAA,EAAS,UAAA,EACT,IAAA,EAAM,KAAA,KACH,OAAA;IAAU,SAAA;EAAA;EAKV;EAHL,kBAAA,GACE,OAAA,EAAS,UAAA,EACT,MAAA;IAAU,KAAA;IAAe,SAAA,EAAW,GAAA;EAAA,MACjC,OAAA;EA+BO;EA7BZ,UAAA;EA6BmB;EA3BnB,mBAAA;EAvBA;EAyBA,mBAAA;EAzBK;;;;;EA+BL,SAAA;IAAc,KAAA;IAAe,aAAA;EAAA;EAtBqB;EAwBlD,UAAA;EAtBA;EAwBA,SAAA;EAvBE;EAyBF,eAAA,cAA6B,OAAA,EAAS,UAAA;EAxBpC;;;;;;;EAgCF,UAAA,IAAc,OAAA,EAAS,UAAA;EA3Be;EA6BtC,UAAA,IACE,OAAA,EAAS,UAAA,EACT,MAAA;IAAU,KAAA;IAAe,SAAA,EAAW,GAAA;EAAA,aAC1B,OAAA;AAAA;;;;;;;;iBAsBE,uBAAA,QACd,OAAA,EAAS,wBAAA,CAAyB,KAAA;EAC/B,IAAA,EAAM,QAAA;EAAU,GAAA,EAAK,QAAA;AAAA;AAAA,UAwGT,oBAAA;EAlIJ;EAoIX,UAAA;EAnIY;EAqIZ,aAAA,GAAgB,OAAA,EAAS,UAAA,EAAY,SAAA,aAAsB,OAAA;EArIrB;EAuItC,UAAA,cAAwB,OAAA,EAAS,UAAA;AAAA;;iBAInB,mBAAA,CAAoB,OAAA,EAAS,oBAAA,GAAuB,QAAQ;AAAA,UAc3D,gBAAA;EAlIsB;EAoIrC,UAAA;EAnIkC;EAqIlC,MAAA,GAAS,OAAA,EAAS,UAAA;EApIT;EAsIT,UAAA,GACE,OAAA,EAAS,UAAA,EACT,SAAA,aACG,OAAA,CAAQ,QAAA;EAzImB;EA2IhC,SAAA;AAAA;;;;;;;;iBAUc,eAAA,WACd,OAAA,EAAS,gBAAA,CAAiB,QAAA,IACzB,iBAAA"}