Skip to content

Commit 3040c28

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

14 files changed

Lines changed: 656 additions & 19 deletions

File tree

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ Living checklist for the greenfield work described in [docs/RESTART_PLAN.md](../
1212
|-------|--------|--------|
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. |
15-
| **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** | 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). |
17-
| **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”). |
15+
| **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, **`DELETE /api/users/me`** (`performUserAccountDelete`: password + sole-owner guard, Drizzle tx; Stripe customer hook optional on worker). **Next:** `POST /api/users/me/password`, email-change + confirm, 2FA routes, pages/groups CRUD, realtime/collab, Stripe webhook. |
17+
| **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

2020
---
@@ -43,19 +43,23 @@ Living checklist for the greenfield work described in [docs/RESTART_PLAN.md](../
4343
- [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).
4444
- [x] `POST /api/users/email-verification/confirm` — public, `{ "emailVerificationCode" }` (nanoid, legacy `verifyEmail`); 204 / 400; DB update copies `encrypted_new_email``encrypted_email`.
4545
- [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+
- [x] **`DELETE /api/users/me`** — replaces legacy `users.account.delete`: JSON `{ "loginHash" }` (base64); verifies access JWT + password (`encrypted_rehashed_login_hash`); blocks when any membership has `member_count > 1` and `owner_count <= 1`; deletes join invites/requests, solo-member groups (cascade pages), remaining `group_members`, then user row; clears session cookies; optional `deleteStripeCustomer(customerId)` hook (worker can wire Stripe later; failures swallowed like legacy).
4647

4748
### Not started (Phase 3 remainder)
4849

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+
- [ ] **Account (remaining tRPC / WS parity):**
51+
- [ ] `POST /api/users/me/email-change` + `POST /api/users/me/email-change/confirm` (WS finish → REST).
52+
- [ ] 2FA: `POST …/2fa/enable/request|finish`, `GET …/2fa`, `POST …/2fa/recovery-codes`, `POST …/2fa/devices/forget`, `POST …/2fa/disable` (see [docs/TRPC_REST_MAP.md](./docs/TRPC_REST_MAP.md)).
53+
- [ ] `POST /api/users/me/password` (legacy WS `change-password` → REST).
5054
- [ ] **Pages** (user prefs + CRUD) and **groups** CRUD / privacy / passwords per map.
5155
- [ ] **Realtime / collab** (new or adapted protocols; no key rotation).
52-
- [ ] **Stripe:** `POST /api/webhooks/stripe`, checkout/portal (no RevenueCat).
56+
- [ ] **Stripe:** `POST /api/webhooks/stripe`, checkout/portal (no RevenueCat); wire **`deleteStripeCustomer`** from account delete when keys exist.
5357

5458
---
5559

5660
## Phase 4 checklist (client MVP)
5761

58-
- [ ] **Tooling:** Vitest + `jsdom` or `happy-dom` + `@vue/test-utils`; `@vitejs/plugin-vue` in Vitest config (same as [Frontend / UI track](#frontend--ui-track)).
62+
- [x] **Tooling (bootstrap):** Vitest + **happy-dom** + `@vue/test-utils` in `@deepnotes/web` (minimal `App` test); same Vite 6 pipeline via `vitest/config` `defineConfig` (RESTART_PLAN §5.8).
5963
- [ ] **API client:** consume **OpenAPI** (generated types + `fetch`, or hey-api) from `@deepnotes/api` / published spec—**no** workspace dependency on Worker or DB packages from web source.
6064
- [ ] **Routing + auth UI:** login / refresh / logout / 2FA flows aligned with [docs/AUTH_AND_CORS.md](./docs/AUTH_AND_CORS.md); composable or component tests + **E2E smoke** for cookie session.
6165
- [ ] **Pages:** list → open editor shell → integrate **Yjs** / collab when API is ready.
@@ -72,7 +76,7 @@ Living checklist for the greenfield work described in [docs/RESTART_PLAN.md](../
7276
- [x] Document **Pages** / preview vs production env vars; optional deploy job to CF preview → [docs/DEPLOY_CLOUDFLARE.md](./docs/DEPLOY_CLOUDFLARE.md).
7377
- [x] CI: lint, typecheck, tests, `drizzle-kit check`, build (Postgres service present for future migrate/tests).
7478
- [x] CI: Postgres role with **CREATEDB** + **template DB** integration tests (RESTART_PLAN §5.7) — `DATABASE_ADMIN_URL` + `src/template-db.test.ts`.
75-
- [ ] **Web package tests are real:** `apps/web` currently uses a **no-op** `test` script; replace with **Vitest** + `jsdom` or `happy-dom` + `@vue/test-utils` and wire into root `pnpm test` / CI (RESTART_PLAN §5.8).
79+
- [x] **Web package tests are real:** `@deepnotes/web` runs `vitest run` with happy-dom; `src/app.test.ts` mounts `App.vue` (RESTART_PLAN §5.8).
7680

7781
---
7882

@@ -88,7 +92,7 @@ Cross-cutting work so the new SPA does not repeat **legacy `apps/client`** patte
8892

8993
### Testing (see RESTART_PLAN §5.8)
9094

91-
- [ ] **Vitest** in `apps/web` with DOM environment and `@vitejs/plugin-vue` aligned with Vite 6.
95+
- [x] **Vitest** in `apps/web` with DOM environment (`happy-dom`) and `@vue/test-utils` aligned with Vite 6.
9296
- [ ] **Component or composable tests** for the first **auth** / session flows (forms, validation, error mapping from API).
9397
- [ ] **Contract tests** for the fetch wrapper (MSW or recorded OpenAPI fixtures)—optional until multiple features consume the API.
9498
- [ ] **E2E smoke** (Playwright recommended): login or session refresh with **httpOnly cookies** against **local compose** or **Cloudflare preview**—add CI job when stable enough (can start `manual`/`workflow_dispatch` if cost is a concern).
@@ -97,9 +101,9 @@ Cross-cutting work so the new SPA does not repeat **legacy `apps/client`** patte
97101

98102
| Legacy (`apps/client`) | New (`new-deepnotes/apps/web`) |
99103
|------------------------|--------------------------------|
100-
| Quasar + Vite 2, 4GB heap builds | Vite 6 + Vue 3.5, minimal app shell today |
104+
| Quasar + Vite 2, 4GB heap builds | Vite 6 + Vue 3.5, Vitest + happy-dom in CI |
101105
| Imports `AppRouter`, server websocket paths | Must use **OpenAPI** + documented WS only |
102-
| No automated UI tests | **To do:** real `test` script + CI |
106+
| No automated UI tests | **Done:** real `test` script + one component test |
103107

104108
---
105109

@@ -114,14 +118,16 @@ Cross-cutting work so the new SPA does not repeat **legacy `apps/client`** patte
114118
- [x] No tRPC / superjson / RevenueCat / key-rotation in **this** tree (keep absent); product sign-off for IAP/Stripe when billing ships.
115119
- [x] Client: zero undocumented forks, or a short owned exception list — see [docs/CLIENT_FORKS.md](./docs/CLIENT_FORKS.md).
116120
- [ ] Cloudflare: deploy runbook; Hyperdrive + Postgres + Redis proven in staging; collab/realtime topology chosen and load-tested.
117-
- [ ] Web: Vitest + DOM env in CI; no server/db imports from web source; E2E smoke for session cookies (RESTART_PLAN §8 extended items).
121+
- [x] Web: Vitest + DOM env in CI (happy-dom + `@vue/test-utils` on `App.vue`).
122+
- [ ] Web: enforce **no** server/db imports from web source (ESLint `import/no-restricted-paths` or README when features land); **E2E** smoke for session cookies (RESTART_PLAN §8).
118123

119124
---
120125

121126
## Short log (newest first)
122127

123128
| Date | Change |
124129
|------|--------|
130+
| 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. |
125131
| 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. |
126132
| 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). |
127133
| 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`. |

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe("api-worker", () => {
2828
["POST", "/api/sessions/logout"],
2929
["POST", "/api/sessions/demo"],
3030
["GET", "/api/users/me"],
31+
["DELETE", "/api/users/me"],
3132
["POST", "/api/users"],
3233
["POST", "/api/users/email-verification/resend"],
3334
] as const)("returns 503 for %s %s when auth env is not configured", async (method, path) => {

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
healthResponseSchema,
66
sessionDemoRequestSchema,
77
sessionLoginRequestSchema,
8+
userAccountDeleteRequestSchema,
89
userRegisterRequestSchema,
910
} from "@deepnotes/api";
1011
import type { ContentfulStatusCode } from "hono/utils/http-status";
@@ -329,6 +330,78 @@ app.post("/api/users", async (c) => {
329330
}
330331
});
331332

333+
app.delete("/api/users/me", async (c) => {
334+
const sessionEnv = getSessionEnv(c.env);
335+
if (sessionEnv == null) {
336+
return c.json(serviceUnavailableBody, 503);
337+
}
338+
const hyper = c.env.HYPERDRIVE;
339+
if (hyper == null) {
340+
return c.json(
341+
{
342+
code: "SERVICE_UNAVAILABLE" as const,
343+
message: "HYPERDRIVE binding is not configured.",
344+
},
345+
503,
346+
);
347+
}
348+
349+
let bodyJson: unknown;
350+
try {
351+
bodyJson = await c.req.json();
352+
} catch {
353+
return c.json({ code: "BAD_REQUEST", message: "Expected JSON body." }, 400);
354+
}
355+
356+
const parsed = userAccountDeleteRequestSchema.safeParse(bodyJson);
357+
if (!parsed.success) {
358+
return c.json(
359+
{
360+
code: "VALIDATION_ERROR",
361+
message: parsed.error.flatten().formErrors.join("; "),
362+
},
363+
400,
364+
);
365+
}
366+
367+
let loginHash: Uint8Array;
368+
try {
369+
loginHash = new Uint8Array(
370+
Buffer.from(parsed.data.loginHash, "base64"),
371+
);
372+
} catch {
373+
return c.json(
374+
{ code: "VALIDATION_ERROR", message: "loginHash must be valid base64." },
375+
400,
376+
);
377+
}
378+
379+
const db = getDbForConnectionString(hyper.connectionString);
380+
const cookieHeader = c.req.header("Cookie");
381+
382+
try {
383+
const { performUserAccountDelete } = await import("@deepnotes/session");
384+
const { cookieLines } = await performUserAccountDelete({
385+
db,
386+
env: sessionEnv,
387+
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
388+
loginHash,
389+
});
390+
const res = c.body(null, 204);
391+
appendSetCookies(res, cookieLines);
392+
return res;
393+
} catch (e) {
394+
const { SessionError } = await import("@deepnotes/session");
395+
if (e instanceof SessionError) {
396+
return c.json(
397+
{ code: e.code, message: e.message },
398+
e.status as ContentfulStatusCode,
399+
);
400+
}
401+
throw e;
402+
}
403+
});
404+
332405
app.get("/api/users/me", async (c) => {
333406
const sessionEnv = getSessionEnv(c.env);
334407
if (sessionEnv == null) {

new-deepnotes/apps/web/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"build": "vite build",
88
"dev": "vite",
99
"lint": "eslint vite.config.ts \"src/**/*.ts\"",
10-
"test": "node -e \"process.exit(0)\"",
10+
"test": "vitest run",
1111
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
1212
"preview": "vite preview"
1313
},
@@ -16,8 +16,11 @@
1616
},
1717
"devDependencies": {
1818
"@vitejs/plugin-vue": "^5.2.3",
19+
"@vue/test-utils": "^2.4.6",
20+
"happy-dom": "^17.4.4",
1921
"typescript": "^5.8.3",
2022
"vite": "^6.3.3",
23+
"vitest": "^3.2.4",
2124
"vue-tsc": "^2.2.10"
2225
}
2326
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { mount } from "@vue/test-utils";
2+
import { describe, expect, it } from "vitest";
3+
4+
import App from "./App.vue";
5+
6+
describe("App", () => {
7+
it("renders shell copy", () => {
8+
const wrapper = mount(App);
9+
expect(wrapper.text()).toContain("DeepNotes");
10+
expect(wrapper.text()).toContain("Greenfield SPA");
11+
});
12+
});
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import vue from "@vitejs/plugin-vue";
2-
import { defineConfig } from "vite";
2+
import { defineConfig } from "vitest/config";
33

44
export default defineConfig({
55
plugins: [vue()],
66
server: {
77
port: 5174,
88
},
9+
test: {
10+
environment: "happy-dom",
11+
include: ["src/**/*.test.ts"],
12+
},
913
});

new-deepnotes/docs/TRPC_REST_MAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Working checklist for Phase 0 of [docs/RESTART_PLAN.md](../../docs/RESTART_PLAN.
2727
| `users.account.twoFactorAuth.disable` | `POST /api/users/me/2fa/disable` |
2828
| `users.account.stripe.createCheckoutSession` | `POST /api/billing/stripe/checkout-session` |
2929
| `users.account.stripe.createPortalSession` | `POST /api/billing/stripe/portal-session` |
30-
| `users.account.delete` | `DELETE /api/users/me` |
30+
| `users.account.delete` | `DELETE /api/users/me` (JSON body `{ "loginHash" }` base64; clears cookies on 204; optional `deleteStripeCustomer` in worker when billing is wired) |
3131

3232
## Users — pages (`users.pages`)
3333

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ export {
2525
export {
2626
emailVerificationConfirmRequestSchema,
2727
emailVerificationResendRequestSchema,
28+
userAccountDeleteRequestSchema,
2829
userMeResponseSchema,
2930
userRegisterResponseSchema,
31+
type UserAccountDeleteRequest,
3032
type UserMeResponse,
3133
type UserRegisterResponse,
3234
} from "./schemas/users.js";

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ describe("getOpenApiDocument", () => {
2222
expect(
2323
doc.paths?.["/api/users/email-verification/confirm"]?.post,
2424
).toBeDefined();
25+
expect(doc.paths?.["/api/users/me"]?.delete).toBeDefined();
2526
});
2627
});

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import {
2020
emailVerificationConfirmRequestSchema,
2121
emailVerificationResendRequestSchema,
22+
userAccountDeleteRequestSchema,
2223
userMeResponseSchema,
2324
userRegisterResponseSchema,
2425
} from "./schemas/users.js";
@@ -184,6 +185,40 @@ registry.registerPath({
184185
},
185186
});
186187

188+
registry.registerPath({
189+
method: "delete",
190+
path: "/api/users/me",
191+
summary: "Delete current account (password confirmation)",
192+
description:
193+
"Replaces legacy `users.account.delete`. Requires `accessToken` cookie and correct `loginHash` in the JSON body. Clears session cookies on success. Optional Stripe customer deletion is handled by the deployment (not part of OpenAPI).",
194+
request: {
195+
body: {
196+
content: {
197+
"application/json": {
198+
schema: userAccountDeleteRequestSchema,
199+
},
200+
},
201+
},
202+
},
203+
responses: {
204+
204: {
205+
description:
206+
"Account removed; session cookies cleared (same names as login).",
207+
},
208+
400: {
209+
description: "Wrong password, ownership constraint, or validation error.",
210+
content: {
211+
"application/json": {
212+
schema: sessionErrorResponseSchema,
213+
},
214+
},
215+
},
216+
401: sessionUnauthorized401,
217+
404: sessionNotFound404,
218+
503: sessionServiceUnavailable503,
219+
},
220+
});
221+
187222
registry.registerPath({
188223
method: "post",
189224
path: "/api/users/email-verification/resend",

0 commit comments

Comments
 (0)