@webtwizz/cloud 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI_GUIDE.md +17 -0
- package/CHANGELOG.md +12 -0
- package/README.md +2 -1
- package/dist/emails.d.ts +23 -0
- package/dist/emails.js +83 -0
- package/dist/server.js +20 -2
- package/package.json +5 -1
package/AI_GUIDE.md
CHANGED
|
@@ -41,6 +41,8 @@ if (error) setMessage(authMessage(error));
|
|
|
41
41
|
|
|
42
42
|
For wording that fits the site, pass overrides: `authMessage(error, { USER_ALREADY_EXISTS: 'You already have a booking account.' })`.
|
|
43
43
|
|
|
44
|
+
When the owner turns on Confirm email, sign-up returns no session and the 6-digit code is emailed automatically. Show a code step: `authClient.emailOtp.verifyEmail({ email, otp })`, then sign them in, with a "Send a new code" button that calls `authClient.emailOtp.sendVerificationOtp({ email, type: 'email-verification' })`. Never send the first code yourself.
|
|
45
|
+
|
|
44
46
|
5. Protect pages in `src/proxy.ts`, listing only the protected routes in the matcher (a matcher over every path blocks the sign-in page's own scripts):
|
|
45
47
|
|
|
46
48
|
```ts
|
|
@@ -68,6 +70,21 @@ Sign-ins are email and password only. Sign-in emails (reset codes, confirmations
|
|
|
68
70
|
|
|
69
71
|
Sites built before this package import sign-ins from another package in `src/lib/auth/`. When you work on their sign-ins, move `server.ts`, `client.ts` and the auth route to the files above (same sign-ins, same users) and remove the old package.
|
|
70
72
|
|
|
73
|
+
### Sending sign-in emails yourself
|
|
74
|
+
|
|
75
|
+
Only when the owner picked "Your site" under Cloud > Users > Emails. Add `src/app/api/sign-in-emails/route.ts`; the site's sign-in service calls it with every code, and the package checks the call really comes from it:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { handleSignInEmails, signInEmailCopy } from '@webtwizz/cloud/emails';
|
|
79
|
+
|
|
80
|
+
export const POST = handleSignInEmails(async (email) => {
|
|
81
|
+
const { subject, text } = signInEmailCopy(email, 'Site name');
|
|
82
|
+
// send `subject` and `text` to `email.to` with the site's email connector, e.g. Resend
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`email` has `to`, `kind` ('code' or 'link'), `purpose` ('email-verification', 'forget-password' or 'sign-in'), and `code` or `link`. Throw if sending fails: the visitor sees the sign-in fail instead of waiting for an email that never comes. It starts working once the site is published; until then WebTwizz sends them.
|
|
87
|
+
|
|
71
88
|
## Database
|
|
72
89
|
|
|
73
90
|
One shared client, server-side only (route handlers, server components, server actions):
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @webtwizz/cloud
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 87e84b1: Sites can send their own sign-in emails: `@webtwizz/cloud/emails` adds `handleSignInEmails()`, which checks each call comes from the site's sign-in service and hands over the address and code, and `signInEmailCopy()` with WebTwizz's wording.
|
|
8
|
+
|
|
9
|
+
## 0.3.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- f6f3ed3: When Confirm email is on, the sign-up code now goes out through WebTwizz's own emails: the site's sign-in route asks for it right after a sign-up that needs confirming.
|
|
14
|
+
|
|
3
15
|
## 0.2.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -14,9 +14,10 @@ WebTwizz puts every setting in the site's environment, so each module works with
|
|
|
14
14
|
| `@webtwizz/cloud/server` | `createAuth()` with `.handler()`, `.middleware()`, `.getSession()` and the server sign-in methods |
|
|
15
15
|
| `@webtwizz/cloud/client` | `createAuthClient()` for client components |
|
|
16
16
|
| `@webtwizz/cloud/db` | `createDb()`, a `postgres` client with pooler-safe settings |
|
|
17
|
+
| `@webtwizz/cloud/emails` | `handleSignInEmails()` for sites that send their own sign-in emails, `signInEmailCopy()` for the wording |
|
|
17
18
|
| `@webtwizz/cloud/files` | `createFiles()` with `uploadUrl`, `publicUrl`, `privateUrl`, `remove` |
|
|
18
19
|
|
|
19
|
-
`server`, `db` and `
|
|
20
|
+
`server`, `db`, `files` and `emails` are server-only. [AI_GUIDE.md](AI_GUIDE.md) has the full walkthrough and ships with each version.
|
|
20
21
|
|
|
21
22
|
## Environment
|
|
22
23
|
|
package/dist/emails.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type SignInEmail = {
|
|
2
|
+
to: string;
|
|
3
|
+
kind: 'code' | 'link';
|
|
4
|
+
purpose: 'sign-in' | 'email-verification' | 'forget-password' | (string & {});
|
|
5
|
+
code?: string;
|
|
6
|
+
link?: string;
|
|
7
|
+
expiresAt?: string;
|
|
8
|
+
};
|
|
9
|
+
type SignInEmailOptions = {
|
|
10
|
+
/** The sign-in address the site's settings point at; defaults to WEBTWIZZ_AUTH_URL. */
|
|
11
|
+
authUrl?: string;
|
|
12
|
+
};
|
|
13
|
+
/** True when the request really comes from the site's sign-in service. */
|
|
14
|
+
declare function verifySignInEmail(request: Request, body: string, options?: SignInEmailOptions): Promise<boolean>;
|
|
15
|
+
/** Route handler for the sign-in emails a site sends itself: `export const POST = handleSignInEmails(async (email) => { ... })`. */
|
|
16
|
+
declare function handleSignInEmails(send: (email: SignInEmail) => Promise<void>, options?: SignInEmailOptions): (request: Request) => Promise<Response>;
|
|
17
|
+
/** WebTwizz's own subject and wording for a sign-in email, for sites that send their own. */
|
|
18
|
+
declare function signInEmailCopy(email: SignInEmail, site: string): {
|
|
19
|
+
subject: string;
|
|
20
|
+
text: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export { type SignInEmail, type SignInEmailOptions, handleSignInEmails, signInEmailCopy, verifySignInEmail };
|
package/dist/emails.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
env
|
|
3
|
+
} from "./chunk-ESAV545E.js";
|
|
4
|
+
|
|
5
|
+
// src/emails.ts
|
|
6
|
+
import "server-only";
|
|
7
|
+
var MAX_AGE_MS = 5 * 60 * 1e3;
|
|
8
|
+
var keys = /* @__PURE__ */ new Map();
|
|
9
|
+
var fromBase64Url = (value) => Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
|
10
|
+
var toBase64Url = (value) => btoa(String.fromCharCode(...new TextEncoder().encode(value))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
11
|
+
async function signingKey(authUrl, kid) {
|
|
12
|
+
const find = (list) => list.find((k) => k.kid === kid && k.crv === "Ed25519" && k.x);
|
|
13
|
+
const cached = keys.get(authUrl);
|
|
14
|
+
let match = cached ? find(cached.keys) : void 0;
|
|
15
|
+
if (!match && (!cached || Date.now() - cached.at > 6e4)) {
|
|
16
|
+
const response = await fetch(`${authUrl.replace(/\/+$/, "")}/.well-known/jwks.json`).catch(() => null);
|
|
17
|
+
const list = (await response?.json().catch(() => null))?.keys ?? [];
|
|
18
|
+
keys.set(authUrl, { at: Date.now(), keys: list });
|
|
19
|
+
match = find(list);
|
|
20
|
+
}
|
|
21
|
+
if (!match) return null;
|
|
22
|
+
return crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: match.x }, { name: "Ed25519" }, false, ["verify"]);
|
|
23
|
+
}
|
|
24
|
+
async function verifySignInEmail(request, body, options = {}) {
|
|
25
|
+
const signature = request.headers.get("x-neon-signature");
|
|
26
|
+
const kid = request.headers.get("x-neon-signature-kid");
|
|
27
|
+
const timestamp = request.headers.get("x-neon-timestamp");
|
|
28
|
+
if (!signature || !kid || !timestamp || !/^\d+$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > MAX_AGE_MS) return false;
|
|
29
|
+
const [header, middle, sig] = signature.split(".");
|
|
30
|
+
if (!header || middle !== "" || !sig) return false;
|
|
31
|
+
const key = await signingKey(options.authUrl ?? env("WEBTWIZZ_AUTH_URL"), kid);
|
|
32
|
+
if (!key) return false;
|
|
33
|
+
const input = new TextEncoder().encode(`${header}.${toBase64Url(`${timestamp}.${toBase64Url(body)}`)}`);
|
|
34
|
+
return crypto.subtle.verify({ name: "Ed25519" }, key, fromBase64Url(sig), input).catch(() => false);
|
|
35
|
+
}
|
|
36
|
+
function handleSignInEmails(send, options = {}) {
|
|
37
|
+
return async (request) => {
|
|
38
|
+
const body = await request.text();
|
|
39
|
+
if (!await verifySignInEmail(request, body, options)) return Response.json({ error: "signature" }, { status: 401 });
|
|
40
|
+
const event = (() => {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(body);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
const type = event?.event_type;
|
|
48
|
+
const data = event?.event_data ?? {};
|
|
49
|
+
const to = String(event?.user?.email ?? data.email ?? "").trim().toLowerCase();
|
|
50
|
+
if ((data.delivery_preference ?? "email") === "sms") return Response.json({ error: "text messages are not sent" }, { status: 422 });
|
|
51
|
+
if (type !== "send.otp" && type !== "send.magic_link" || !to) return Response.json({ ok: true, skipped: true });
|
|
52
|
+
const email = {
|
|
53
|
+
to,
|
|
54
|
+
kind: type === "send.otp" ? "code" : "link",
|
|
55
|
+
purpose: String((type === "send.otp" ? data.otp_type : data.link_type) ?? "sign-in"),
|
|
56
|
+
...data.otp_code ? { code: String(data.otp_code) } : {},
|
|
57
|
+
...data.link_url ? { link: String(data.link_url) } : {},
|
|
58
|
+
...data.expires_at ? { expiresAt: String(data.expires_at) } : {}
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
await send(email);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error("[webtwizz-auth] The site could not send a sign-in email", error);
|
|
64
|
+
return Response.json({ error: "send" }, { status: 502 });
|
|
65
|
+
}
|
|
66
|
+
return Response.json({ ok: true });
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function signInEmailCopy(email, site) {
|
|
70
|
+
const code = email.kind === "code";
|
|
71
|
+
const copy = email.purpose === "email-verification" ? { subject: `Confirm your email for ${site}`, lead: code ? `Enter this code on ${site} to confirm your email address.` : `Open this link to confirm your email address for ${site}.`, label: "Confirmation code", note: `If you didn't sign up for ${site}, ignore this email.` } : email.purpose === "forget-password" ? { subject: `Reset your ${site} password`, lead: code ? `Enter this code on ${site} to choose a new password.` : `Open this link to choose a new password for ${site}.`, label: "Reset code", note: "If you didn't ask to reset your password, ignore this email. Your password stays the same." } : { subject: code ? `Your ${site} sign-in code` : `Sign in to ${site}`, lead: code ? `Enter this code on ${site} to sign in.` : `Open this link to sign in to ${site}.`, label: "Sign-in code", note: `If you didn't try to sign in to ${site}, ignore this email.` };
|
|
72
|
+
const value = code ? `${copy.label}: ${email.code}` : email.link;
|
|
73
|
+
return { subject: copy.subject, text: `${copy.lead}
|
|
74
|
+
|
|
75
|
+
${value}
|
|
76
|
+
|
|
77
|
+
${copy.note}` };
|
|
78
|
+
}
|
|
79
|
+
export {
|
|
80
|
+
handleSignInEmails,
|
|
81
|
+
signInEmailCopy,
|
|
82
|
+
verifySignInEmail
|
|
83
|
+
};
|
package/dist/server.js
CHANGED
|
@@ -1458,9 +1458,20 @@ function forBrowser(response) {
|
|
|
1458
1458
|
return new Response(response.body, { status: response.status, statusText: response.statusText, headers: headers2 });
|
|
1459
1459
|
}
|
|
1460
1460
|
}
|
|
1461
|
+
async function sendSignUpCode(baseUrl, request, response) {
|
|
1462
|
+
const data = await response.clone().json().catch(() => null);
|
|
1463
|
+
const email = data?.user?.email;
|
|
1464
|
+
if (!email || data.user.emailVerified !== false || data.token) return;
|
|
1465
|
+
await fetch(`${baseUrl.replace(/\/+$/, "")}/email-otp/send-verification-otp`, {
|
|
1466
|
+
method: "POST",
|
|
1467
|
+
headers: { "content-type": "application/json", origin: request.headers.get("origin") || new URL(request.url).origin },
|
|
1468
|
+
body: JSON.stringify({ email, type: "email-verification" })
|
|
1469
|
+
}).catch((error) => console.error("[webtwizz-auth] Could not send the sign-up code", error));
|
|
1470
|
+
}
|
|
1461
1471
|
function createAuth(options = {}) {
|
|
1472
|
+
const baseUrl = options.url ?? env("WEBTWIZZ_AUTH_URL");
|
|
1462
1473
|
const auth = createNeonAuth({
|
|
1463
|
-
baseUrl
|
|
1474
|
+
baseUrl,
|
|
1464
1475
|
cookies: {
|
|
1465
1476
|
secret: options.secret ?? env("WEBTWIZZ_AUTH_SECRET"),
|
|
1466
1477
|
sessionDataTtl: options.sessionDataTtl
|
|
@@ -1471,7 +1482,14 @@ function createAuth(options = {}) {
|
|
|
1471
1482
|
auth.handler = () => {
|
|
1472
1483
|
const methods = handler();
|
|
1473
1484
|
const wrap = (method) => async (request, context) => forBrowser(await method(forLibrary(request, (original, headers2) => new Request(original, { headers: headers2 })), context));
|
|
1474
|
-
|
|
1485
|
+
const post = wrap(methods.POST);
|
|
1486
|
+
const signUp = async (request, context) => {
|
|
1487
|
+
const response = await post(request, context);
|
|
1488
|
+
const { path } = await context.params;
|
|
1489
|
+
if (response.ok && path?.join("/") === "sign-up/email") await sendSignUpCode(baseUrl, request, response);
|
|
1490
|
+
return response;
|
|
1491
|
+
};
|
|
1492
|
+
return { GET: wrap(methods.GET), POST: signUp, PUT: wrap(methods.PUT), DELETE: wrap(methods.DELETE), PATCH: wrap(methods.PATCH) };
|
|
1475
1493
|
};
|
|
1476
1494
|
auth.middleware = (config) => {
|
|
1477
1495
|
const run = middleware(config);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webtwizz/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Sign-ins, database and file storage for sites built on WebTwizz Cloud",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"types": "./dist/files.d.ts",
|
|
44
44
|
"default": "./dist/files.js"
|
|
45
45
|
},
|
|
46
|
+
"./emails": {
|
|
47
|
+
"types": "./dist/emails.d.ts",
|
|
48
|
+
"default": "./dist/emails.js"
|
|
49
|
+
},
|
|
46
50
|
"./AI_GUIDE.md": "./AI_GUIDE.md",
|
|
47
51
|
"./package.json": "./package.json"
|
|
48
52
|
},
|