Skip to content

Commit 6db63fb

Browse files
committed
feat: continue restart plan
1 parent c782e14 commit 6db63fb

15 files changed

Lines changed: 481 additions & 8 deletions

File tree

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 16 additions & 4 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 | **Mostly done** | Template DB integration test + CI `DATABASE_ADMIN_URL`; deploy doc: [docs/DEPLOY_CLOUDFLARE.md](./docs/DEPLOY_CLOUDFLARE.md). Optional: Wrangler deploy job. **Gap:** `apps/web` tests still no-op—see Phase 2 checklist + [Frontend / UI track](#frontend--ui-track). |
16-
| **3** — REST + Drizzle features | **In progress** | `POST /api/sessions/login|refresh|logout` + **`POST /api/sessions/demo`** + **`GET /api/users/me`** + **`POST /api/users`** (registration) via `@deepnotes/session`. Optional **Upstash** (`UPSTASH_REDIS_REST_*`) for failed-login limits; **`SEND_EMAILS=false`** auto-verifies new users (no mailer yet). Next: email verification resend/confirm REST, pages/groups CRUD, Stripe. |
16+
| **3** — REST + Drizzle features | **In progress** | Auth + registration + **email resend/confirm** (Resend) below; optional **Upstash** for login rate limits. **Next (Phase 3):** pages/groups CRUD, realtime/collab, **Stripe** webhook, then Phase 2 gap (**real `apps/web` tests** in parallel is OK). |
1717
| **4** — Client MVP | **Not started** | Auth → list → page → Yjs → groups; crypto/libs port as needed. **Parallel:** SPA structure, OpenAPI client, Vitest+DOM in CI, small E2E smoke—see [Frontend / UI track](#frontend--ui-track) (not deferred to “when MVP is done”). |
1818
| **5** — Cutover | **Not started** | Canary, redirect, retire `/trpc` when safe. |
1919

@@ -31,14 +31,25 @@ Living checklist for the greenfield work described in [docs/RESTART_PLAN.md](../
3131

3232
## Phase 3 checklist (REST + Drizzle)
3333

34+
### Sessions + account (current)
35+
3436
- [x] Document **sessions** REST paths + request schemas in OpenAPI; demo + `users/me` contracts updated.
3537
- [x] Implement **sessions.login** / refresh / logout against Drizzle + legacy crypto semantics (JWT via `jose`; optional **Redis** failed-login limits when Upstash env is set).
3638
- [x] Implement **sessions.start-demo** (`POST /api/sessions/demo`) + **Redis** for failed-login when Upstash env is set.
3739
- [x] **JWT + httpOnly cookies** (`accessToken`, `refreshToken`, `loggedIn`) matching [docs/AUTH_AND_CORS.md](./docs/AUTH_AND_CORS.md).
3840
- [x] **`GET /api/users/me`** (minimal summary from `accessToken` cookie).
39-
- [x] **Users** `POST /api/users` registration (crypto payload aligned with demo; conflict / unverified parity; optional `SEND_EMAILS=false` auto-verify).
40-
- [ ] **Users** email verification resend/confirm (`POST /api/users/me/email-verification/*`) + remaining TRPC_REST_MAP slices as needed.
41-
- [ ] Pages/groups CRUD, realtime/collab, Stripe webhook (no RevenueCat).
41+
- [x] **Users** `POST /api/users` registration (crypto payload aligned with demo; conflict / unverified parity; `SEND_EMAILS=false` auto-verifies; when mail is on, `RESEND_API_KEY` required and registration email is sent after commit).
42+
- [x] **Users — email verification**
43+
- [x] `POST /api/users/email-verification/resend` — public, `{ "email" }` (legacy `resendVerificationEmail`); 204 / 400 / 404 / 409 / 502 / 503; [docs/TRPC_REST_MAP.md](./docs/TRPC_REST_MAP.md) updated (paths are **not** under `/me/`; legacy was never cookie-based).
44+
- [x] `POST /api/users/email-verification/confirm` — public, `{ "emailVerificationCode" }` (nanoid, legacy `verifyEmail`); 204 / 400; DB update copies `encrypted_new_email``encrypted_email`.
45+
- [x] `sendRegistrationEmail` + optional **`RESEND_API_KEY`**, optional **`PUBLIC_APP_URL`** in `SessionEnv` / [template.env](./template.env); duplicate unverified registration re-sends via same helper (401 “New email sent”).
46+
47+
### Not started (Phase 3 remainder)
48+
49+
- [ ] **Account (remaining tRPC):** `POST /api/users/me/email-change` (+ confirm), 2FA enable/load/disable/recovery routes from [docs/TRPC_REST_MAP.md](./docs/TRPC_REST_MAP.md), `DELETE /api/users/me`, password change (see map + legacy WS).
50+
- [ ] **Pages** (user prefs + CRUD) and **groups** CRUD / privacy / passwords per map.
51+
- [ ] **Realtime / collab** (new or adapted protocols; no key rotation).
52+
- [ ] **Stripe:** `POST /api/webhooks/stripe`, checkout/portal (no RevenueCat).
4253

4354
---
4455

@@ -111,6 +122,7 @@ Cross-cutting work so the new SPA does not repeat **legacy `apps/client`** patte
111122

112123
| Date | Change |
113124
|------|--------|
125+
| 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. |
114126
| 2026-04-26 | Phase 3: **`POST /api/users`** (`performUserRegister`), `encryptUserRehashedLoginHash`, `addHours`, OpenAPI 201/400/401/409; optional **`SEND_EMAILS`** on session env (auto-verify when `false`); group password on register still rejected (same as demo). |
115127
| 2026-04-26 | Phase 3: `POST /api/sessions/demo` (`performSessionStartDemo`), `GET /api/users/me`, Redis failed-login limits (`SessionRedisPort` + optional Upstash), `USER_EMAIL_ENCRYPTION_KEY` on `SessionEnv`; OpenAPI 200/400 for demo, 429 for login, `userMeResponseSchema`; Vitest `login-rate-limit.test.ts`. |
116128
| 2026-04-26 | Docs: [docs/RESTART_PLAN.md](../docs/RESTART_PLAN.md) §3.5 legacy frontend pain points, §5.8 frontend testing/CI, phased updates; this file: **Frontend / UI track** + Phase 2/4 notes on real web tests. |

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe("api-worker", () => {
2929
["POST", "/api/sessions/demo"],
3030
["GET", "/api/users/me"],
3131
["POST", "/api/users"],
32+
["POST", "/api/users/email-verification/resend"],
3233
] as const)("returns 503 for %s %s when auth env is not configured", async (method, path) => {
3334
const res = await app.request(`http://test${path}`, { method });
3435
expect(res.status).toBe(503);
@@ -37,4 +38,18 @@ describe("api-worker", () => {
3738
});
3839
});
3940

41+
it("returns 503 for POST /api/users/email-verification/confirm when hyperdrive is not bound", async () => {
42+
const res = await app.request(
43+
"http://test/api/users/email-verification/confirm",
44+
{
45+
method: "POST",
46+
body: JSON.stringify({ emailVerificationCode: "a".repeat(21) }),
47+
headers: { "Content-Type": "application/json" },
48+
},
49+
);
50+
expect(res.status).toBe(503);
51+
await expect(res.json()).resolves.toMatchObject({
52+
code: "SERVICE_UNAVAILABLE",
53+
});
54+
});
4055
});

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

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
emailVerificationConfirmRequestSchema,
3+
emailVerificationResendRequestSchema,
24
getOpenApiDocument,
35
healthResponseSchema,
46
sessionDemoRequestSchema,
@@ -366,4 +368,110 @@ app.get("/api/users/me", async (c) => {
366368
}
367369
});
368370

371+
app.post("/api/users/email-verification/resend", async (c) => {
372+
const sessionEnv = getSessionEnv(c.env);
373+
if (sessionEnv == null) {
374+
return c.json(serviceUnavailableBody, 503);
375+
}
376+
const hyper = c.env.HYPERDRIVE;
377+
if (hyper == null) {
378+
return c.json(
379+
{
380+
code: "SERVICE_UNAVAILABLE" as const,
381+
message: "HYPERDRIVE binding is not configured.",
382+
},
383+
503,
384+
);
385+
}
386+
387+
let bodyJson: unknown;
388+
try {
389+
bodyJson = await c.req.json();
390+
} catch {
391+
return c.json({ code: "BAD_REQUEST", message: "Expected JSON body." }, 400);
392+
}
393+
394+
const parsed = emailVerificationResendRequestSchema.safeParse(bodyJson);
395+
if (!parsed.success) {
396+
return c.json(
397+
{
398+
code: "VALIDATION_ERROR",
399+
message: parsed.error.flatten().formErrors.join("; "),
400+
},
401+
400,
402+
);
403+
}
404+
405+
const db = getDbForConnectionString(hyper.connectionString);
406+
try {
407+
const { performResendEmailVerification } = await import(
408+
"@deepnotes/session"
409+
);
410+
await performResendEmailVerification({
411+
db,
412+
env: sessionEnv,
413+
email: parsed.data.email,
414+
});
415+
return c.body(null, 204);
416+
} catch (e) {
417+
const { SessionError } = await import("@deepnotes/session");
418+
if (e instanceof SessionError) {
419+
return c.json(
420+
{ code: e.code, message: e.message },
421+
e.status as ContentfulStatusCode,
422+
);
423+
}
424+
throw e;
425+
}
426+
});
427+
428+
app.post("/api/users/email-verification/confirm", async (c) => {
429+
const hyper = c.env?.HYPERDRIVE;
430+
if (hyper == null) {
431+
return c.json(
432+
{
433+
code: "SERVICE_UNAVAILABLE" as const,
434+
message: "HYPERDRIVE binding is not configured.",
435+
},
436+
503,
437+
);
438+
}
439+
440+
let bodyJson: unknown;
441+
try {
442+
bodyJson = await c.req.json();
443+
} catch {
444+
return c.json({ code: "BAD_REQUEST", message: "Expected JSON body." }, 400);
445+
}
446+
447+
const parsed = emailVerificationConfirmRequestSchema.safeParse(bodyJson);
448+
if (!parsed.success) {
449+
return c.json(
450+
{
451+
code: "VALIDATION_ERROR",
452+
message: parsed.error.flatten().formErrors.join("; "),
453+
},
454+
400,
455+
);
456+
}
457+
458+
const db = getDbForConnectionString(hyper.connectionString);
459+
try {
460+
const { performConfirmEmailVerification } = await import(
461+
"@deepnotes/session"
462+
);
463+
await performConfirmEmailVerification({ db, body: parsed.data });
464+
return c.body(null, 204);
465+
} catch (e) {
466+
const { SessionError } = await import("@deepnotes/session");
467+
if (e instanceof SessionError) {
468+
return c.json(
469+
{ code: e.code, message: e.message },
470+
e.status as ContentfulStatusCode,
471+
);
472+
}
473+
throw e;
474+
}
475+
});
476+
369477
export default app;

new-deepnotes/apps/api-worker/src/session-env.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export type WorkerSessionBindings = {
1313
EMAIL_CASE_SENSITIVITY_EXCEPTIONS?: string;
1414
/** When `"false"`, new registrations are email-verified without sending mail (local/CI). */
1515
SEND_EMAILS?: string;
16+
/** Resend.com API key; required when `SEND_EMAILS` is not `false` and email is sent. */
17+
RESEND_API_KEY?: string;
18+
/** Optional; default `https://deepnotes.app` for verification links. */
19+
PUBLIC_APP_URL?: string;
1620
/** Optional; when set with token, failed-login rate limits use Upstash REST Redis. */
1721
UPSTASH_REDIS_REST_URL?: string;
1822
UPSTASH_REDIS_REST_TOKEN?: string;
@@ -56,5 +60,7 @@ export function getSessionEnv(
5660
COOKIE_DOMAIN: env.COOKIE_DOMAIN,
5761
EMAIL_CASE_SENSITIVITY_EXCEPTIONS: env.EMAIL_CASE_SENSITIVITY_EXCEPTIONS,
5862
SEND_EMAILS: env.SEND_EMAILS,
63+
RESEND_API_KEY: env.RESEND_API_KEY,
64+
PUBLIC_APP_URL: env.PUBLIC_APP_URL,
5965
};
6066
}

new-deepnotes/docs/TRPC_REST_MAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ Working checklist for Phase 0 of [docs/RESTART_PLAN.md](../../docs/RESTART_PLAN.
1616
| Legacy procedure | Proposed REST / notes |
1717
|------------------|----------------------|
1818
| `users.account.register` | `POST /api/users` |
19-
| `users.account.resendVerificationEmail` | `POST /api/users/me/email-verification/resend` |
20-
| `users.account.verifyEmail` | `POST /api/users/me/email-verification/confirm` |
19+
| `users.account.resendVerificationEmail` | `POST /api/users/email-verification/resend` (public; body `{ "email" }` — matches legacy, not an authenticated “me” call) |
20+
| `users.account.verifyEmail` | `POST /api/users/email-verification/confirm` (public; body `{ "emailVerificationCode" }`, nanoid) |
2121
| `users.account.emailChange.request` | `POST /api/users/me/email-change` |
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` |

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export {
2323
type UserRegisterRequest,
2424
} from "./schemas/sessions.js";
2525
export {
26+
emailVerificationConfirmRequestSchema,
27+
emailVerificationResendRequestSchema,
2628
userMeResponseSchema,
2729
userRegisterResponseSchema,
2830
type UserMeResponse,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,11 @@ 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"]?.post).toBeDefined();
19+
expect(
20+
doc.paths?.["/api/users/email-verification/resend"]?.post,
21+
).toBeDefined();
22+
expect(
23+
doc.paths?.["/api/users/email-verification/confirm"]?.post,
24+
).toBeDefined();
1925
});
2026
});

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
userRegisterRequestSchema,
1818
} from "./schemas/sessions.js";
1919
import {
20+
emailVerificationConfirmRequestSchema,
21+
emailVerificationResendRequestSchema,
2022
userMeResponseSchema,
2123
userRegisterResponseSchema,
2224
} from "./schemas/users.js";
@@ -60,6 +62,15 @@ const sessionConflict409 = {
6062
},
6163
} as const;
6264

65+
const sessionNotFound404 = {
66+
description: "Resource not found.",
67+
content: {
68+
"application/json": {
69+
schema: sessionErrorResponseSchema,
70+
},
71+
},
72+
} as const;
73+
6374
registry.registerPath({
6475
method: "get",
6576
path: "/api/health",
@@ -141,6 +152,14 @@ registry.registerPath({
141152
},
142153
401: sessionUnauthorized401,
143154
409: sessionConflict409,
155+
502: {
156+
description: "Email send failed (e.g. Resend API error after user row was created; rare).",
157+
content: {
158+
"application/json": {
159+
schema: sessionErrorResponseSchema,
160+
},
161+
},
162+
},
144163
503: sessionServiceUnavailable503,
145164
},
146165
});
@@ -165,6 +184,76 @@ registry.registerPath({
165184
},
166185
});
167186

187+
registry.registerPath({
188+
method: "post",
189+
path: "/api/users/email-verification/resend",
190+
summary: "Resend email verification (public, by email)",
191+
description:
192+
"Replaces legacy `users.account.resendVerificationEmail`. Uses Resend when `SEND_EMAILS` is not `false`.",
193+
request: {
194+
body: {
195+
content: {
196+
"application/json": {
197+
schema: emailVerificationResendRequestSchema,
198+
},
199+
},
200+
},
201+
},
202+
responses: {
203+
204: {
204+
description: "Email sent (or accepted by provider).",
205+
},
206+
400: {
207+
description: "Validation error or outbound email disabled for this environment.",
208+
content: {
209+
"application/json": {
210+
schema: sessionErrorResponseSchema,
211+
},
212+
},
213+
},
214+
404: sessionNotFound404,
215+
409: sessionConflict409,
216+
502: {
217+
description: "Email provider (Resend) request failed.",
218+
content: {
219+
"application/json": {
220+
schema: sessionErrorResponseSchema,
221+
},
222+
},
223+
},
224+
503: sessionServiceUnavailable503,
225+
},
226+
});
227+
228+
registry.registerPath({
229+
method: "post",
230+
path: "/api/users/email-verification/confirm",
231+
summary: "Confirm email with nanoid code",
232+
description: "Replaces legacy `users.account.verifyEmail` (public).",
233+
request: {
234+
body: {
235+
content: {
236+
"application/json": {
237+
schema: emailVerificationConfirmRequestSchema,
238+
},
239+
},
240+
},
241+
},
242+
responses: {
243+
204: {
244+
description: "Email verified; account updated.",
245+
},
246+
400: {
247+
description: "Invalid or expired code.",
248+
content: {
249+
"application/json": {
250+
schema: sessionErrorResponseSchema,
251+
},
252+
},
253+
},
254+
},
255+
});
256+
168257
registry.registerPath({
169258
method: "post",
170259
path: "/api/sessions/refresh",

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,20 @@ export const userRegisterResponseSchema = z
2222
.openapi("UserRegisterResponse");
2323

2424
export type UserRegisterResponse = z.infer<typeof userRegisterResponseSchema>;
25+
26+
const nanoidVerificationCode = z
27+
.string()
28+
.min(1)
29+
.regex(/^[A-Za-z0-9_-]{21}$/, "expected nanoid verification code");
30+
31+
export const emailVerificationResendRequestSchema = z
32+
.object({
33+
email: z.string().email(),
34+
})
35+
.openapi("EmailVerificationResendRequest");
36+
37+
export const emailVerificationConfirmRequestSchema = z
38+
.object({
39+
emailVerificationCode: nanoidVerificationCode,
40+
})
41+
.openapi("EmailVerificationConfirmRequest");

0 commit comments

Comments
 (0)