@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.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/astro/index.cjs +149 -0
- package/dist/astro/index.cjs.map +1 -0
- package/dist/astro/index.d.cts +101 -0
- package/dist/astro/index.d.cts.map +1 -0
- package/dist/astro/index.d.ts +101 -0
- package/dist/astro/index.d.ts.map +1 -0
- package/dist/astro/index.js +146 -0
- package/dist/astro/index.js.map +1 -0
- package/dist/auth/index.cjs +59 -0
- package/dist/auth/index.cjs.map +1 -0
- package/dist/auth/index.d.cts +14 -0
- package/dist/auth/index.d.cts.map +1 -0
- package/dist/auth/index.d.ts +14 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +54 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/cache/index.cjs +18 -0
- package/dist/cache/index.cjs.map +1 -0
- package/dist/cache/index.d.cts +10 -0
- package/dist/cache/index.d.cts.map +1 -0
- package/dist/cache/index.d.ts +10 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +17 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cms/index.cjs +763 -0
- package/dist/cms/index.cjs.map +1 -0
- package/dist/cms/index.d.cts +2 -0
- package/dist/cms/index.d.ts +2 -0
- package/dist/cms/index.js +743 -0
- package/dist/cms/index.js.map +1 -0
- package/dist/db/index.cjs +10 -0
- package/dist/db/index.cjs.map +1 -0
- package/dist/db/index.d.cts +7 -0
- package/dist/db/index.d.cts.map +1 -0
- package/dist/db/index.d.ts +7 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +9 -0
- package/dist/db/index.js.map +1 -0
- package/dist/email/index.cjs +25 -0
- package/dist/email/index.cjs.map +1 -0
- package/dist/email/index.d.cts +12 -0
- package/dist/email/index.d.cts.map +1 -0
- package/dist/email/index.d.ts +12 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +24 -0
- package/dist/email/index.js.map +1 -0
- package/dist/errors-CW6Lz0AQ.cjs +196 -0
- package/dist/errors-CW6Lz0AQ.cjs.map +1 -0
- package/dist/errors-mZIqZJO4.js +125 -0
- package/dist/errors-mZIqZJO4.js.map +1 -0
- package/dist/hono/index.cjs +132 -0
- package/dist/hono/index.cjs.map +1 -0
- package/dist/hono/index.d.cts +59 -0
- package/dist/hono/index.d.cts.map +1 -0
- package/dist/hono/index.d.ts +59 -0
- package/dist/hono/index.d.ts.map +1 -0
- package/dist/hono/index.js +130 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/index-BUrCSGVb.d.cts +616 -0
- package/dist/index-BUrCSGVb.d.cts.map +1 -0
- package/dist/index-BUrCSGVb.d.ts +616 -0
- package/dist/index-BUrCSGVb.d.ts.map +1 -0
- package/dist/index.cjs +60 -0
- package/dist/index.d.cts +107 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/queues/index.cjs +31 -0
- package/dist/queues/index.cjs.map +1 -0
- package/dist/queues/index.d.cts +22 -0
- package/dist/queues/index.d.cts.map +1 -0
- package/dist/queues/index.d.ts +22 -0
- package/dist/queues/index.d.ts.map +1 -0
- package/dist/queues/index.js +29 -0
- package/dist/queues/index.js.map +1 -0
- package/dist/rate-limit/index.cjs +38 -0
- package/dist/rate-limit/index.cjs.map +1 -0
- package/dist/rate-limit/index.d.cts +14 -0
- package/dist/rate-limit/index.d.cts.map +1 -0
- package/dist/rate-limit/index.d.ts +14 -0
- package/dist/rate-limit/index.d.ts.map +1 -0
- package/dist/rate-limit/index.js +37 -0
- package/dist/rate-limit/index.js.map +1 -0
- package/dist/session/index.cjs +48 -0
- package/dist/session/index.cjs.map +1 -0
- package/dist/session/index.d.cts +14 -0
- package/dist/session/index.d.cts.map +1 -0
- package/dist/session/index.d.ts +14 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +45 -0
- package/dist/session/index.js.map +1 -0
- package/dist/storage/index.cjs +29 -0
- package/dist/storage/index.cjs.map +1 -0
- package/dist/storage/index.d.cts +38 -0
- package/dist/storage/index.d.cts.map +1 -0
- package/dist/storage/index.d.ts +38 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +26 -0
- package/dist/storage/index.js.map +1 -0
- 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"}
|