@webtwizz/cloud 0.3.0 → 0.4.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/AI_GUIDE.md +37 -2
- package/CHANGELOG.md +12 -0
- package/README.md +4 -1
- package/dist/emails.d.ts +23 -0
- package/dist/emails.js +83 -0
- package/dist/server.js +4 -3
- package/package.json +8 -2
package/AI_GUIDE.md
CHANGED
|
@@ -41,7 +41,7 @@ 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.
|
|
44
|
+
Confirm email is off unless the owner turns it on in Settings > Cloud > Users, so sign-up signs people straight in and an unconfirmed email never stops a sign-in. 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
45
|
|
|
46
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):
|
|
47
47
|
|
|
@@ -66,10 +66,34 @@ Never destructure `{ user }` from the top level.
|
|
|
66
66
|
|
|
67
67
|
8. Every sign-in page gets a "Forgot password?" link to `/reset-password`, a client page in two steps: their email, then `authClient.forgetPassword.emailOtp({ email })`, which emails a 6-digit code; then the code and a new password, then `authClient.emailOtp.resetPassword({ email, otp, password })`, then send them to sign in. Leave `/reset-password` out of the proxy matcher. The owner can also send a reset code from the Data panel, and it lands on this page.
|
|
68
68
|
|
|
69
|
+
9. Admins are accounts whose role is `'admin'`. Admin pages and admin writes check it on the server, the same way as step 6:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const { data: session } = await auth.getSession();
|
|
73
|
+
if (session?.user?.role !== 'admin') return Response.json({ error: 'Admins only' }, { status: 403 });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The first admin is the owner's own account on the site: they sign up like any visitor (their WebTwizz account is separate), then make that account an admin under Users in the Data panel, or ask you to do it. A new role reaches their session within a few minutes, or right away when they sign in again.
|
|
77
|
+
|
|
69
78
|
Sign-ins are email and password only. Sign-in emails (reset codes, confirmations) are sent for the site automatically; never build email sending or add an email service for them. If the user asks for another way to sign in, say WebTwizz Cloud sign-ins are email and password for now and build that.
|
|
70
79
|
|
|
71
80
|
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
81
|
|
|
82
|
+
### Sending sign-in emails yourself
|
|
83
|
+
|
|
84
|
+
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:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { handleSignInEmails, signInEmailCopy } from '@webtwizz/cloud/emails';
|
|
88
|
+
|
|
89
|
+
export const POST = handleSignInEmails(async (email) => {
|
|
90
|
+
const { subject, text } = signInEmailCopy(email, 'Site name');
|
|
91
|
+
// send `subject` and `text` to `email.to` with the site's email connector, e.g. Resend
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`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.
|
|
96
|
+
|
|
73
97
|
## Database
|
|
74
98
|
|
|
75
99
|
One shared client, server-side only (route handlers, server components, server actions):
|
|
@@ -87,6 +111,17 @@ The database can pause or fill up on the owner's plan. Wrap reads and saves in t
|
|
|
87
111
|
|
|
88
112
|
Pages that only show data set `export const revalidate = 60`, and the server action that saves calls `revalidatePath` for them. Pages for a signed-in user stay dynamic.
|
|
89
113
|
|
|
114
|
+
The database runs while pages read or save data and stops after 5 quiet minutes; its running time comes out of the owner's monthly allowance. So a page that refreshes data on a timer (a feed, a chat, a dashboard) refreshes only while the tab is visible, and again when the visitor comes back:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const refresh = () => { if (document.visibilityState === 'visible') load(); };
|
|
119
|
+
const timer = setInterval(refresh, 60_000);
|
|
120
|
+
document.addEventListener('visibilitychange', refresh);
|
|
121
|
+
return () => { clearInterval(timer); document.removeEventListener('visibilitychange', refresh); };
|
|
122
|
+
}, []);
|
|
123
|
+
```
|
|
124
|
+
|
|
90
125
|
## Files
|
|
91
126
|
|
|
92
127
|
Two buckets: `public` (anyone can view, for images shown on the site) and `private` (documents, opened through short-lived links). Images you place on pages yourself keep using the usual image tools; this is for what visitors or the site's features upload.
|
|
@@ -117,4 +152,4 @@ Show files with `createFiles().publicUrl(key)` in a plain `<img>` or link, or `a
|
|
|
117
152
|
|
|
118
153
|
## Owner tools
|
|
119
154
|
|
|
120
|
-
The owner manages users under Users in the Data panel (password reset, block, delete with their rows, export), sets who can sign up and the sign-in emails in Settings > Cloud > Users, and downloads everything from Settings > Cloud > Database. Point them there when they ask.
|
|
155
|
+
The owner manages users under Users in the Data panel (make admin, password reset, block, delete with their rows, export), sets who can sign up and the sign-in emails in Settings > Cloud > Users, and downloads everything from Settings > Cloud > Database. Point them there when they ask.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @webtwizz/cloud
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 67343d5: Signed-in visitors can sign out, and `authClient` can read their session: since 0.2.0 the sign-in route answered both with a server error inside Next, because it copied a request Next hands over as a Proxy. Visitors who pressed Sign out stayed signed in.
|
|
8
|
+
|
|
9
|
+
## 0.4.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 0.3.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
|
|
|
@@ -39,6 +40,8 @@ npm run build
|
|
|
39
40
|
|
|
40
41
|
`test/fixture` is a small Next app that CI builds against the packed tarball.
|
|
41
42
|
|
|
43
|
+
To try a change in the local WebTwizz preview site without publishing: `npm run preview` builds the package, puts it in the preview container's `node_modules` under a unique dev version, and restarts its dev server (about 10 seconds). `npm run dev` does the same on every save.
|
|
44
|
+
|
|
42
45
|
## Releases
|
|
43
46
|
|
|
44
47
|
- Add a changeset (`npx changeset`) to any PR that changes what sites get.
|
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
|
@@ -1435,7 +1435,8 @@ function forLibrary(request, make) {
|
|
|
1435
1435
|
if (translated === cookie) return request;
|
|
1436
1436
|
const headers2 = new Headers(request.headers);
|
|
1437
1437
|
headers2.set("cookie", translated);
|
|
1438
|
-
|
|
1438
|
+
const body = request.method === "GET" || request.method === "HEAD" ? void 0 : request.body;
|
|
1439
|
+
return make(request.url, { method: request.method, headers: headers2, body, signal: request.signal, ...body ? { duplex: "half" } : {} });
|
|
1439
1440
|
}
|
|
1440
1441
|
function forBrowser(response) {
|
|
1441
1442
|
const provider = [...response.headers.keys()].filter((name) => PROVIDER_HEADER.test(name));
|
|
@@ -1481,7 +1482,7 @@ function createAuth(options = {}) {
|
|
|
1481
1482
|
const middleware = auth.middleware.bind(auth);
|
|
1482
1483
|
auth.handler = () => {
|
|
1483
1484
|
const methods = handler();
|
|
1484
|
-
const wrap = (method) => async (request, context) => forBrowser(await method(forLibrary(request, (
|
|
1485
|
+
const wrap = (method) => async (request, context) => forBrowser(await method(forLibrary(request, (url, init) => new Request(url, init)), context));
|
|
1485
1486
|
const post = wrap(methods.POST);
|
|
1486
1487
|
const signUp = async (request, context) => {
|
|
1487
1488
|
const response = await post(request, context);
|
|
@@ -1493,7 +1494,7 @@ function createAuth(options = {}) {
|
|
|
1493
1494
|
};
|
|
1494
1495
|
auth.middleware = (config) => {
|
|
1495
1496
|
const run = middleware(config);
|
|
1496
|
-
return async (request) => forBrowser(await run(forLibrary(request, (
|
|
1497
|
+
return async (request) => forBrowser(await run(forLibrary(request, (url, init) => new NextRequest2(url, init))));
|
|
1497
1498
|
};
|
|
1498
1499
|
return auth;
|
|
1499
1500
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webtwizz/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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
|
},
|
|
@@ -51,7 +55,9 @@
|
|
|
51
55
|
"typecheck": "tsc --noEmit",
|
|
52
56
|
"test": "vitest run",
|
|
53
57
|
"test:integration": "npm run build && vitest run --config vitest.integration.config.ts",
|
|
54
|
-
"prepublishOnly": "npm run build && npm run typecheck && npm test"
|
|
58
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm test",
|
|
59
|
+
"preview": "tsup && node scripts/preview.mjs",
|
|
60
|
+
"dev": "tsup --watch --onSuccess \"node scripts/preview.mjs\""
|
|
55
61
|
},
|
|
56
62
|
"engines": {
|
|
57
63
|
"node": ">=20"
|