@webtwizz/cloud 0.3.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 +15 -0
- package/CHANGELOG.md +6 -0
- package/README.md +2 -1
- package/dist/emails.d.ts +23 -0
- package/dist/emails.js +83 -0
- package/package.json +5 -1
package/AI_GUIDE.md
CHANGED
|
@@ -70,6 +70,21 @@ Sign-ins are email and password only. Sign-in emails (reset codes, confirmations
|
|
|
70
70
|
|
|
71
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.
|
|
72
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
|
+
|
|
73
88
|
## Database
|
|
74
89
|
|
|
75
90
|
One shared client, server-side only (route handlers, server components, server actions):
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.3.0
|
|
4
10
|
|
|
5
11
|
### 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/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
|
},
|