Skip to content

Commit 24bda88

Browse files
committed
feat: continue restart plan
1 parent ad6f918 commit 24bda88

11 files changed

Lines changed: 695 additions & 5 deletions

File tree

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Living checklist for the greenfield work described in [docs/RESTART_PLAN.md](../
1313
| **0** — OpenAPI + Drizzle inventory | **Done** | tRPC→REST/WS map: [docs/TRPC_REST_MAP.md](./docs/TRPC_REST_MAP.md). Drizzle + migration `0000_legacy_baseline` match `postgres-init.sql` core tables. Auth/CORS/forks: [docs/AUTH_AND_CORS.md](./docs/AUTH_AND_CORS.md), [docs/CLIENT_FORKS.md](./docs/CLIENT_FORKS.md). |
1414
| **1** — Legacy repo hygiene | **Optional / n/a** | Parallel track only if still editing the old monorepo. |
1515
| **2** — Repo bootstrap | **Done** | Template DB integration test + CI `DATABASE_ADMIN_URL`; deploy doc: [docs/DEPLOY_CLOUDFLARE.md](./docs/DEPLOY_CLOUDFLARE.md). **`@deepnotes/web`:** Vitest + happy-dom + `@vue/test-utils`; `vite.config` uses `defineConfig` from `vitest/config`. Optional: Wrangler deploy job. |
16-
| **3** — REST + Drizzle features | **In progress** | Sessions, register, email verify/resend/confirm, account delete, **password change** (see Phase 3 checklist). **Next:** email-change + confirm, 2FA routes, pages/groups CRUD, realtime/collab, Stripe webhook. |
16+
| **3** — REST + Drizzle features | **In progress** | Auth + account: sessions, register, public email verify, account delete, password + **email change** (see Phase 3 checklist). **Backlog (priority):** 2FA HTTP surface → pages/groups CRUDrealtime/collab Stripe webhook. |
1717
| **4** — Client MVP | **Not started** | Auth → list → page → Yjs → groups; crypto/libs port as needed. **Parallel:** SPA structure, OpenAPI client, small E2E smoke—see [Frontend / UI track](#frontend--ui-track). |
1818
| **5** — Cutover | **Not started** | Canary, redirect, retire `/trpc` when safe. |
1919

@@ -48,7 +48,10 @@ Living checklist for the greenfield work described in [docs/RESTART_PLAN.md](../
4848

4949
### Account routes still to ship (Phase 3)
5050

51-
- [ ] **`POST /api/users/me/email-change`** + **`POST /api/users/me/email-change/confirm`** — legacy tRPC `emailChange.request` + WS `email-change/finish` → two REST steps; Resend / `encrypted_new_email` / verification fields; invalidate sessions on confirm if legacy does (verify in `apps/app-server`).
51+
- [x] **Email change**
52+
- [x] `POST /api/users/me/email-change``performUserEmailChangeRequest`: `oldLoginHash` + `newEmail`; **403** demo, **400** bad password or “email already in use” (global `email_hash` match, same as legacy); sets `encrypted_new_email` + 6-digit `email_verification_code`; Resend (subject/body like legacy) or **200** `{ "emailVerificationCode" }` when `SEND_EMAILS=false`; **204** when emailed.
53+
- [x] `POST /api/users/me/email-change/confirm``performUserEmailChangeConfirm`: one call (WS two-step collapsed); `oldLoginHash`, `emailVerificationCode` (6 digits), `newLoginHash`, `userEncrypted*Keyring` (b64, same as register/password); verifies code + password; applies new `encrypted_email` / `email_hash`, clears pending fields, PHC + rewrapped keyrings, invalidates **all** `sessions`, **204** + `buildClearSessionCookies`; optional `updateStripeCustomerEmail` in worker (matches legacy `customers.update` after commit, errors non-fatal).
54+
- [x] **`decryptUserEmail`** in `@deepnotes/session` for confirm; **`sendEmailChangeVerificationEmail`** (Resend); OpenAPI + [docs/TRPC_REST_MAP.md](./docs/TRPC_REST_MAP.md) updated.
5255
- [ ] **2FA (HTTP surface)**`POST /api/users/me/2fa/enable/request|finish`, `GET /api/users/me/2fa`, `POST /api/users/me/2fa/recovery-codes`, `POST /api/users/me/2fa/devices/forget`, `POST /api/users/me/2fa/disable` ([docs/TRPC_REST_MAP.md](./docs/TRPC_REST_MAP.md)). **Note:** `@deepnotes/session` already implements TOTP/recovery verification for **`POST /api/sessions/login`**; these routes expose enable/disable/load for the SPA.
5356

5457
### Not started (Phase 3 — pages, groups, infra)
@@ -125,10 +128,17 @@ Cross-cutting work so the new SPA does not repeat **legacy `apps/client`** patte
125128

126129
---
127130

131+
## Phase 3 working order (suggested)
132+
133+
Use this when resuming: **(done)** account HTTP surface through email change including password change. **(next)** 2FA CRUD on `/api/users/me/2fa*`, reusing session crypto already used at login. **(then)** pages + groups from [TRPC_REST_MAP](./docs/TRPC_REST_MAP.md) (user prefs, CRUD, group privacy/password). **(then)** long pole: **realtime + collab** (protocol, Worker/DO, no key rotation) and **Stripe** webhook + billing routes + wire `updateStripeCustomerEmail` / `deleteStripeCustomer` from account flows where applicable.
134+
135+
---
136+
128137
## Short log (newest first)
129138

130139
| Date | Change |
131140
|------|--------|
141+
| 2026-04-26 | Phase 3: **email change**`POST /api/users/me/email-change` + `…/confirm` (`change-user-email.ts`, `decryptUserEmail`, `send-email-change-code`); `userEmailChange*Request` schemas, OpenAPI, Hono; TRPC_REST_MAP; PLAN_PROGRESS detail + suggested Phase 3 order. |
132142
| 2026-04-26 | Phase 3: **`POST /api/users/me/password`**`performUserPasswordChange` (`change-user-password.ts`): old password verify, demo **403**, new keyrings + PHC, invalidate all `sessions`, clear cookies **204**; `userPasswordChangeRequestSchema`, OpenAPI + worker; export **`byteB64`** from `@deepnotes/api`; TRPC_REST_MAP rows for change-password; PLAN_PROGRESS Phase 3 account section expanded. |
133143
| 2026-04-26 | Phase 2 + §5.8: `@deepnotes/web` — Vitest + happy-dom + `@vue/test-utils`, `vite.config` from `vitest/config`, `src/app.test.ts`; Phase 3: `DELETE /api/users/me` + `performUserAccountDelete` (ownership guard, Drizzle tx, clear cookies); `userAccountDeleteRequestSchema` + OpenAPI; api-worker route; TRPC_REST_MAP note on delete body / Stripe hook. |
134144
| 2026-04-26 | Phase 3: email verification `POST /api/users/email-verification/resend` and `…/confirm`; `performResendEmailVerification` / `performConfirmEmailVerification`; Resend in `sendRegistrationEmail`; `RESEND_API_KEY` + `PUBLIC_APP_URL`; first mail on register + re-send on duplicate unverified; OpenAPI 502 on register if provider fails; `c.env?.HYPERDRIVE` on confirm for Vitest. |

new-deepnotes/apps/api-worker/src/index.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
sessionDemoRequestSchema,
77
sessionLoginRequestSchema,
88
userAccountDeleteRequestSchema,
9+
userEmailChangeConfirmRequestSchema,
10+
userEmailChangeRequestSchema,
911
userPasswordChangeRequestSchema,
1012
userRegisterRequestSchema,
1113
} from "@deepnotes/api";
@@ -394,6 +396,135 @@ app.post("/api/users/me/password", async (c) => {
394396
}
395397
});
396398

399+
app.post("/api/users/me/email-change", async (c) => {
400+
const sessionEnv = getSessionEnv(c.env);
401+
if (sessionEnv == null) {
402+
return c.json(serviceUnavailableBody, 503);
403+
}
404+
const hyper = c.env.HYPERDRIVE;
405+
if (hyper == null) {
406+
return c.json(
407+
{
408+
code: "SERVICE_UNAVAILABLE" as const,
409+
message: "HYPERDRIVE binding is not configured.",
410+
},
411+
503,
412+
);
413+
}
414+
415+
let bodyJson: unknown;
416+
try {
417+
bodyJson = await c.req.json();
418+
} catch {
419+
return c.json({ code: "BAD_REQUEST", message: "Expected JSON body." }, 400);
420+
}
421+
422+
const parsed = userEmailChangeRequestSchema.safeParse(bodyJson);
423+
if (!parsed.success) {
424+
return c.json(
425+
{
426+
code: "VALIDATION_ERROR",
427+
message: parsed.error.flatten().formErrors.join("; "),
428+
},
429+
400,
430+
);
431+
}
432+
433+
const db = getDbForConnectionString(hyper.connectionString);
434+
const cookieHeader = c.req.header("Cookie");
435+
436+
try {
437+
const { performUserEmailChangeRequest } = await import("@deepnotes/session");
438+
const out = await performUserEmailChangeRequest({
439+
db,
440+
env: sessionEnv,
441+
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
442+
oldLoginHash: parsed.data.oldLoginHash,
443+
newEmail: parsed.data.newEmail,
444+
});
445+
if (out.devEmailVerificationCode != null) {
446+
return c.json(
447+
{ emailVerificationCode: out.devEmailVerificationCode },
448+
200,
449+
);
450+
}
451+
return c.body(null, 204);
452+
} catch (e) {
453+
const { SessionError } = await import("@deepnotes/session");
454+
if (e instanceof SessionError) {
455+
return c.json(
456+
{ code: e.code, message: e.message },
457+
e.status as ContentfulStatusCode,
458+
);
459+
}
460+
throw e;
461+
}
462+
});
463+
464+
app.post("/api/users/me/email-change/confirm", async (c) => {
465+
const sessionEnv = getSessionEnv(c.env);
466+
if (sessionEnv == null) {
467+
return c.json(serviceUnavailableBody, 503);
468+
}
469+
const hyper = c.env.HYPERDRIVE;
470+
if (hyper == null) {
471+
return c.json(
472+
{
473+
code: "SERVICE_UNAVAILABLE" as const,
474+
message: "HYPERDRIVE binding is not configured.",
475+
},
476+
503,
477+
);
478+
}
479+
480+
let bodyJson: unknown;
481+
try {
482+
bodyJson = await c.req.json();
483+
} catch {
484+
return c.json({ code: "BAD_REQUEST", message: "Expected JSON body." }, 400);
485+
}
486+
487+
const parsed = userEmailChangeConfirmRequestSchema.safeParse(bodyJson);
488+
if (!parsed.success) {
489+
return c.json(
490+
{
491+
code: "VALIDATION_ERROR",
492+
message: parsed.error.flatten().formErrors.join("; "),
493+
},
494+
400,
495+
);
496+
}
497+
498+
const db = getDbForConnectionString(hyper.connectionString);
499+
const cookieHeader = c.req.header("Cookie");
500+
501+
try {
502+
const { performUserEmailChangeConfirm } = await import("@deepnotes/session");
503+
const { cookieLines } = await performUserEmailChangeConfirm({
504+
db,
505+
env: sessionEnv,
506+
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
507+
oldLoginHash: parsed.data.oldLoginHash,
508+
emailVerificationCode: parsed.data.emailVerificationCode,
509+
newLoginHash: parsed.data.newLoginHash,
510+
newEncryptedPrivateKeyring: parsed.data.userEncryptedPrivateKeyring,
511+
newEncryptedSymmetricKeyring: parsed.data.userEncryptedSymmetricKeyring,
512+
});
513+
const res = c.body(null, 204);
514+
appendSetCookies(res, cookieLines);
515+
return res;
516+
} catch (e) {
517+
const { SessionError } = await import("@deepnotes/session");
518+
if (e instanceof SessionError) {
519+
return c.json(
520+
{ code: e.code, message: e.message },
521+
e.status as ContentfulStatusCode,
522+
);
523+
}
524+
throw e;
525+
}
526+
});
527+
397528
app.delete("/api/users/me", async (c) => {
398529
const sessionEnv = getSessionEnv(c.env);
399530
if (sessionEnv == null) {

new-deepnotes/docs/TRPC_REST_MAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Working checklist for Phase 0 of [docs/RESTART_PLAN.md](../../docs/RESTART_PLAN.
1818
| `users.account.register` | `POST /api/users` |
1919
| `users.account.resendVerificationEmail` | `POST /api/users/email-verification/resend` (public; body `{ "email" }` — matches legacy, not an authenticated “me” call) |
2020
| `users.account.verifyEmail` | `POST /api/users/email-verification/confirm` (public; body `{ "emailVerificationCode" }`, nanoid) |
21-
| `users.account.emailChange.request` | `POST /api/users/me/email-change` |
21+
| `users.account.emailChange.request` | `POST /api/users/me/email-change` (body: `oldLoginHash` b64, `newEmail`; **204** or **200** with `{ "emailVerificationCode" }` when `SEND_EMAILS=false`) |
2222
| `users.account.twoFactorAuth.enable.request` | `POST /api/users/me/2fa/enable/request` |
2323
| `users.account.twoFactorAuth.enable.finish` | `POST /api/users/me/2fa/enable/finish` |
2424
| `users.account.twoFactorAuth.load` | `GET /api/users/me/2fa` |
@@ -90,7 +90,7 @@ Working checklist for Phase 0 of [docs/RESTART_PLAN.md](../../docs/RESTART_PLAN.
9090
| `websocket/groups/rotate-keys` || **removed** per RESTART_PLAN |
9191
| `websocket/pages/move` | `POST /api/pages/:pageId/move` | |
9292
| `websocket/users/account/change-password` | `POST /api/users/me/password` | **implemented** in `@deepnotes/session` (`performUserPasswordChange`) |
93-
| `websocket/users/account/email-change/finish` | `POST /api/users/me/email-change/confirm` | |
93+
| `websocket/users/account/email-change/finish` | `POST /api/users/me/email-change/confirm` | **implemented** — one call: `oldLoginHash`, `emailVerificationCode` (6 digits), `newLoginHash`, `userEncryptedPrivateKeyring`, `userEncryptedSymmetricKeyring` (b64; same as register/password); 204, clears cookies; optional Stripe in worker |
9494
| `websocket/users/account/rotate-keys` || **removed** |
9595

9696
## Webhooks (not tRPC)

new-deepnotes/packages/api/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@ export {
2727
emailVerificationConfirmRequestSchema,
2828
emailVerificationResendRequestSchema,
2929
userAccountDeleteRequestSchema,
30+
userEmailChangeConfirmRequestSchema,
31+
userEmailChangeRequestResponseSchema,
32+
userEmailChangeRequestSchema,
3033
userMeResponseSchema,
3134
userPasswordChangeRequestSchema,
3235
userRegisterResponseSchema,
3336
type UserAccountDeleteRequest,
37+
type UserEmailChangeConfirmRequest,
38+
type UserEmailChangeRequest,
3439
type UserMeResponse,
3540
type UserPasswordChangeRequest,
3641
type UserRegisterResponse,

new-deepnotes/packages/api/src/openapi.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ describe("getOpenApiDocument", () => {
1616
expect(doc.paths?.["/api/sessions/demo"]?.post).toBeDefined();
1717
expect(doc.paths?.["/api/users/me"]?.get).toBeDefined();
1818
expect(doc.paths?.["/api/users/me/password"]?.post).toBeDefined();
19+
expect(
20+
doc.paths?.["/api/users/me/email-change"]?.post,
21+
).toBeDefined();
22+
expect(
23+
doc.paths?.["/api/users/me/email-change/confirm"]?.post,
24+
).toBeDefined();
1925
expect(doc.paths?.["/api/users"]?.post).toBeDefined();
2026
expect(
2127
doc.paths?.["/api/users/email-verification/resend"]?.post,

new-deepnotes/packages/api/src/openapi.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import {
2020
emailVerificationConfirmRequestSchema,
2121
emailVerificationResendRequestSchema,
2222
userAccountDeleteRequestSchema,
23+
userEmailChangeConfirmRequestSchema,
24+
userEmailChangeRequestResponseSchema,
25+
userEmailChangeRequestSchema,
2326
userMeResponseSchema,
2427
userPasswordChangeRequestSchema,
2528
userRegisterResponseSchema,
@@ -230,6 +233,91 @@ registry.registerPath({
230233
},
231234
});
232235

236+
registry.registerPath({
237+
method: "post",
238+
path: "/api/users/me/email-change",
239+
summary: "Request account email change (6-digit code email)",
240+
description:
241+
"Replaces legacy `users.account.emailChange.request`. Verifies `oldLoginHash` and that the new address is not already registered. When outbound email is enabled, sends a 6-digit code. When `SEND_EMAILS=false` (e.g. local), returns 200 with `emailVerificationCode` instead of emailing.",
242+
request: {
243+
body: {
244+
content: {
245+
"application/json": {
246+
schema: userEmailChangeRequestSchema,
247+
},
248+
},
249+
},
250+
},
251+
responses: {
252+
204: {
253+
description: "Code emailed to the new address; pending change stored on the user row.",
254+
},
255+
200: {
256+
description:
257+
"Out-of-band dev response when `SEND_EMAILS=false` (verification code not emailed).",
258+
content: {
259+
"application/json": {
260+
schema: userEmailChangeRequestResponseSchema,
261+
},
262+
},
263+
},
264+
400: {
265+
description: "Wrong password, address in use, or validation error.",
266+
content: {
267+
"application/json": {
268+
schema: sessionErrorResponseSchema,
269+
},
270+
},
271+
},
272+
401: sessionUnauthorized401,
273+
403: sessionForbidden403,
274+
404: sessionNotFound404,
275+
502: {
276+
description: "Email send failed (e.g. Resend) after the pending state was written.",
277+
content: {
278+
"application/json": {
279+
schema: sessionErrorResponseSchema,
280+
},
281+
},
282+
},
283+
503: sessionServiceUnavailable503,
284+
},
285+
});
286+
287+
registry.registerPath({
288+
method: "post",
289+
path: "/api/users/me/email-change/confirm",
290+
summary: "Confirm email change (re-wrap keyrings, new password)",
291+
description:
292+
"Replaces legacy WebSocket `users.account.emailChange.finish` (step 1 + 2 in one). Verifies 6-digit code and `oldLoginHash`, then applies new email + new password-encrypted keyrings, invalidates sessions, clears cookies; optional Stripe customer email update in the deployment (not in OpenAPI).",
293+
request: {
294+
body: {
295+
content: {
296+
"application/json": {
297+
schema: userEmailChangeConfirmRequestSchema,
298+
},
299+
},
300+
},
301+
},
302+
responses: {
303+
204: {
304+
description: "Email updated; sessions cleared; re-login required.",
305+
},
306+
400: {
307+
description: "Wrong code, wrong password, no pending change, or invalid keyrings.",
308+
content: {
309+
"application/json": {
310+
schema: sessionErrorResponseSchema,
311+
},
312+
},
313+
},
314+
401: sessionUnauthorized401,
315+
403: sessionForbidden403,
316+
404: sessionNotFound404,
317+
503: sessionServiceUnavailable503,
318+
},
319+
});
320+
233321
registry.registerPath({
234322
method: "delete",
235323
path: "/api/users/me",

new-deepnotes/packages/api/src/schemas/users.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,45 @@ export const userPasswordChangeRequestSchema = z
7777
export type UserPasswordChangeRequest = z.infer<
7878
typeof userPasswordChangeRequestSchema
7979
>;
80+
81+
const sixDigitCode = z
82+
.string()
83+
.regex(/^\d{6}$/, "expected 6-digit verification code");
84+
85+
/**
86+
* `POST /api/users/me/email-change` — legacy `users.account.emailChange.request`.
87+
*/
88+
export const userEmailChangeRequestSchema = z
89+
.object({
90+
oldLoginHash: byteB64,
91+
newEmail: z.string().email(),
92+
})
93+
.openapi("UserEmailChangeRequest");
94+
95+
export type UserEmailChangeRequest = z.infer<typeof userEmailChangeRequestSchema>;
96+
97+
/**
98+
* When `SEND_EMAILS=false`, the server returns this body (dev / local only).
99+
*/
100+
export const userEmailChangeRequestResponseSchema = z
101+
.object({
102+
emailVerificationCode: sixDigitCode,
103+
})
104+
.openapi("UserEmailChangeRequestResponse");
105+
106+
/**
107+
* `POST /api/users/me/email-change/confirm` — legacy WS `emailChange.finish` (two steps as one call).
108+
*/
109+
export const userEmailChangeConfirmRequestSchema = z
110+
.object({
111+
oldLoginHash: byteB64,
112+
emailVerificationCode: sixDigitCode,
113+
newLoginHash: byteB64,
114+
userEncryptedPrivateKeyring: byteB64,
115+
userEncryptedSymmetricKeyring: byteB64,
116+
})
117+
.openapi("UserEmailChangeConfirmRequest");
118+
119+
export type UserEmailChangeConfirmRequest = z.infer<
120+
typeof userEmailChangeConfirmRequestSchema
121+
>;

0 commit comments

Comments
 (0)