Skip to content

Commit 1f3b0f9

Browse files
committed
feat: continue restart plan
1 parent 82b740d commit 1f3b0f9

23 files changed

Lines changed: 822 additions & 38 deletions

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 6 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` wired via `@deepnotes/session` (Drizzle + legacy crypto + `jose` JWT + cookies). `POST /api/sessions/demo` still `501`. Next: Redis-backed login rate limits, `start-demo` / registration, `GET /api/users/me`. |
16+
| **3** — REST + Drizzle features | **In progress** | `POST /api/sessions/login|refresh|logout` + **`POST /api/sessions/demo`** + **`GET /api/users/me`** via `@deepnotes/session`. Optional **Upstash** (`UPSTASH_REDIS_REST_*`) wires legacy-style **failed-login rate limits**; without it, limits are skipped (local dev). **New secret:** `USER_EMAIL_ENCRYPTION_KEY` (legacy email encryption). Next: `POST /api/users` registration, pages/groups CRUD, Stripe. |
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,11 +31,12 @@ Living checklist for the greenfield work described in [docs/RESTART_PLAN.md](../
3131

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

34-
- [x] Document **sessions** REST paths + request schemas in OpenAPI; worker returns **501** until handlers exist.
34+
- [x] Document **sessions** REST paths + request schemas in OpenAPI; demo + `users/me` contracts updated.
3535
- [x] Implement **sessions.login** / refresh / logout against Drizzle + legacy crypto semantics (JWT via `jose`; **Redis** rate limits not wired yet—parity with legacy `login` lockouts).
36-
- [ ] Implement **sessions.start-demo** (registration path) + **Redis** for failed-login / optional session cache.
36+
- [x] Implement **sessions.start-demo** (`POST /api/sessions/demo`) + **Redis** for failed-login when Upstash env is set.
3737
- [x] **JWT + httpOnly cookies** (`accessToken`, `refreshToken`, `loggedIn`) matching [docs/AUTH_AND_CORS.md](./docs/AUTH_AND_CORS.md).
38-
- [ ] **Users** registration + `GET /api/users/me` (and remaining TRPC_REST_MAP slices as needed).
38+
- [x] **`GET /api/users/me`** (minimal summary from `accessToken` cookie).
39+
- [ ] **Users** `POST /api/users` registration + remaining TRPC_REST_MAP slices as needed.
3940
- [ ] Pages/groups CRUD, realtime/collab, Stripe webhook (no RevenueCat).
4041

4142
---
@@ -109,6 +110,7 @@ Cross-cutting work so the new SPA does not repeat **legacy `apps/client`** patte
109110

110111
| Date | Change |
111112
|------|--------|
113+
| 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`. |
112114
| 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. |
113115
| 2026-04-26 | Phase 3: `@deepnotes/session` (login/refresh/logout + 2FA TOTP/recovery), api-worker Hyperdrive + dynamic import for Workers bundle; OpenAPI 200/401/503 for session routes; demo remains `501`; session crypto vendored in-package (no parent `@stdlib` links); `libsodium-wrappers-sumo@^0.8` override for Wrangler. |
114116
| 2026-04-26 | Phase 3 start: OpenAPI + Zod for `POST /api/sessions/login|refresh|logout|demo`; api-worker `501` stubs; Phase 0 marked done in snapshot. |

new-deepnotes/apps/api-worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@deepnotes/api": "workspace:*",
1515
"@deepnotes/db": "workspace:*",
1616
"@deepnotes/session": "workspace:*",
17+
"@upstash/redis": "^1.34.8",
1718
"hono": "^4.7.7"
1819
},
1920
"devDependencies": {

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

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ describe("api-worker", () => {
2626
["POST", "/api/sessions/login"],
2727
["POST", "/api/sessions/refresh"],
2828
["POST", "/api/sessions/logout"],
29+
["POST", "/api/sessions/demo"],
30+
["GET", "/api/users/me"],
2931
] as const)("returns 503 for %s %s when auth env is not configured", async (method, path) => {
3032
const res = await app.request(`http://test${path}`, { method });
3133
expect(res.status).toBe(503);
@@ -34,13 +36,4 @@ describe("api-worker", () => {
3436
});
3537
});
3638

37-
it("POST /api/sessions/demo returns 501 until registration is wired", async () => {
38-
const res = await app.request("http://test/api/sessions/demo", {
39-
method: "POST",
40-
});
41-
expect(res.status).toBe(501);
42-
await expect(res.json()).resolves.toMatchObject({
43-
code: "NOT_IMPLEMENTED",
44-
});
45-
});
4639
});

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

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import {
22
getOpenApiDocument,
33
healthResponseSchema,
4+
sessionDemoRequestSchema,
45
sessionLoginRequestSchema,
56
} from "@deepnotes/api";
67
import type { ContentfulStatusCode } from "hono/utils/http-status";
78
import { Hono } from "hono";
89

910
import { getDbForConnectionString } from "./db-pool.js";
1011
import { readCookieHeader } from "./cookies.js";
12+
import { getSessionRedisPort } from "./redis-port.js";
1113
import { getSessionEnv, type WorkerSessionBindings } from "./session-env.js";
1214

1315
type Bindings = WorkerSessionBindings & {
@@ -17,16 +19,10 @@ type Bindings = WorkerSessionBindings & {
1719

1820
const app = new Hono<{ Bindings: Bindings }>();
1921

20-
const sessionNotImplementedBody = {
21-
code: "NOT_IMPLEMENTED" as const,
22-
message:
23-
"Demo registration is not implemented yet. See OpenAPI for the contract.",
24-
};
25-
2622
const serviceUnavailableBody = {
2723
code: "SERVICE_UNAVAILABLE" as const,
2824
message:
29-
"Session routes require ACCESS_SECRET, REFRESH_SECRET, USER_EMAIL_SECRET, USER_REHASHED_LOGIN_HASH_ENCRYPTION_KEY, USER_AUTHENTICATOR_SECRET_ENCRYPTION_KEY, and USER_RECOVERY_CODES_ENCRYPTION_KEY (e.g. Wrangler secrets / .dev.vars).",
25+
"Session routes require ACCESS_SECRET, REFRESH_SECRET, USER_EMAIL_SECRET, USER_EMAIL_ENCRYPTION_KEY, USER_REHASHED_LOGIN_HASH_ENCRYPTION_KEY, USER_AUTHENTICATOR_SECRET_ENCRYPTION_KEY, and USER_RECOVERY_CODES_ENCRYPTION_KEY (e.g. Wrangler secrets / .dev.vars).",
3026
};
3127

3228
function appendSetCookies(res: Response, lines: string[]): void {
@@ -93,6 +89,7 @@ app.post("/api/sessions/login", async (c) => {
9389
}
9490

9591
const db = getDbForConnectionString(hyper.connectionString);
92+
const redis = getSessionRedisPort(c.env);
9693

9794
try {
9895
const { performSessionLogin } = await import("@deepnotes/session");
@@ -109,6 +106,7 @@ app.post("/api/sessions/login", async (c) => {
109106
},
110107
clientIp: c.req.header("CF-Connecting-IP") ?? "127.0.0.1",
111108
userAgent: c.req.header("User-Agent") ?? "",
109+
redis,
112110
});
113111
const res = c.json(json, 200);
114112
appendSetCookies(res, cookieLines);
@@ -198,8 +196,103 @@ app.post("/api/sessions/logout", async (c) => {
198196
return res;
199197
});
200198

201-
app.post("/api/sessions/demo", (c) =>
202-
c.json(sessionNotImplementedBody, 501),
203-
);
199+
app.post("/api/sessions/demo", async (c) => {
200+
const sessionEnv = getSessionEnv(c.env);
201+
if (sessionEnv == null) {
202+
return c.json(serviceUnavailableBody, 503);
203+
}
204+
const hyper = c.env.HYPERDRIVE;
205+
if (hyper == null) {
206+
return c.json(
207+
{
208+
code: "SERVICE_UNAVAILABLE" as const,
209+
message: "HYPERDRIVE binding is not configured.",
210+
},
211+
503,
212+
);
213+
}
214+
215+
let bodyJson: unknown;
216+
try {
217+
bodyJson = await c.req.json();
218+
} catch {
219+
return c.json({ code: "BAD_REQUEST", message: "Expected JSON body." }, 400);
220+
}
221+
222+
const parsed = sessionDemoRequestSchema.safeParse(bodyJson);
223+
if (!parsed.success) {
224+
return c.json(
225+
{
226+
code: "VALIDATION_ERROR",
227+
message: parsed.error.flatten().formErrors.join("; "),
228+
},
229+
400,
230+
);
231+
}
232+
233+
const db = getDbForConnectionString(hyper.connectionString);
234+
235+
try {
236+
const { performSessionStartDemo } = await import("@deepnotes/session");
237+
const { json, cookieLines } = await performSessionStartDemo({
238+
db,
239+
env: sessionEnv,
240+
body: parsed.data,
241+
clientIp: c.req.header("CF-Connecting-IP") ?? "127.0.0.1",
242+
userAgent: c.req.header("User-Agent") ?? "",
243+
});
244+
const res = c.json(json, 200);
245+
appendSetCookies(res, cookieLines);
246+
return res;
247+
} catch (e) {
248+
const { SessionError } = await import("@deepnotes/session");
249+
if (e instanceof SessionError) {
250+
return c.json(
251+
{ code: e.code, message: e.message },
252+
e.status as ContentfulStatusCode,
253+
);
254+
}
255+
throw e;
256+
}
257+
});
258+
259+
app.get("/api/users/me", async (c) => {
260+
const sessionEnv = getSessionEnv(c.env);
261+
if (sessionEnv == null) {
262+
return c.json(serviceUnavailableBody, 503);
263+
}
264+
const hyper = c.env.HYPERDRIVE;
265+
if (hyper == null) {
266+
return c.json(
267+
{
268+
code: "SERVICE_UNAVAILABLE" as const,
269+
message: "HYPERDRIVE binding is not configured.",
270+
},
271+
503,
272+
);
273+
}
274+
275+
const db = getDbForConnectionString(hyper.connectionString);
276+
const cookieHeader = c.req.header("Cookie");
277+
278+
try {
279+
const { getAuthenticatedUserSummary } = await import("@deepnotes/session");
280+
const summary = await getAuthenticatedUserSummary({
281+
db,
282+
env: sessionEnv,
283+
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
284+
});
285+
return c.json(summary, 200);
286+
} catch (e) {
287+
const { SessionError } = await import("@deepnotes/session");
288+
if (e instanceof SessionError) {
289+
return c.json(
290+
{ code: e.code, message: e.message },
291+
e.status as ContentfulStatusCode,
292+
);
293+
}
294+
throw e;
295+
}
296+
});
204297

205298
export default app;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Redis } from "@upstash/redis";
2+
3+
import type { SessionRedisPort } from "@deepnotes/session";
4+
5+
import type { WorkerSessionBindings } from "./session-env.js";
6+
7+
export function getSessionRedisPort(
8+
env: Pick<WorkerSessionBindings, "UPSTASH_REDIS_REST_URL" | "UPSTASH_REDIS_REST_TOKEN">,
9+
): SessionRedisPort | undefined {
10+
const url = env.UPSTASH_REDIS_REST_URL;
11+
const token = env.UPSTASH_REDIS_REST_TOKEN;
12+
if (url == null || url === "" || token == null || token === "") {
13+
return undefined;
14+
}
15+
16+
const redis = new Redis({ url, token });
17+
return {
18+
async get(key: string) {
19+
const v = await redis.get(key);
20+
if (v == null) return null;
21+
return typeof v === "string" ? v : String(v);
22+
},
23+
ttl: (key) => redis.ttl(key),
24+
incr: (key) => redis.incr(key),
25+
expire: async (key, seconds) => {
26+
await redis.expire(key, seconds);
27+
},
28+
};
29+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ export type WorkerSessionBindings = {
44
ACCESS_SECRET?: string;
55
REFRESH_SECRET?: string;
66
USER_EMAIL_SECRET?: string;
7+
USER_EMAIL_ENCRYPTION_KEY?: string;
78
USER_REHASHED_LOGIN_HASH_ENCRYPTION_KEY?: string;
89
USER_AUTHENTICATOR_SECRET_ENCRYPTION_KEY?: string;
910
USER_RECOVERY_CODES_ENCRYPTION_KEY?: string;
1011
DEV?: string;
1112
COOKIE_DOMAIN?: string;
1213
EMAIL_CASE_SENSITIVITY_EXCEPTIONS?: string;
14+
/** Optional; when set with token, failed-login rate limits use Upstash REST Redis. */
15+
UPSTASH_REDIS_REST_URL?: string;
16+
UPSTASH_REDIS_REST_TOKEN?: string;
1317
};
1418

1519
export function getSessionEnv(
@@ -22,6 +26,7 @@ export function getSessionEnv(
2226
ACCESS_SECRET,
2327
REFRESH_SECRET,
2428
USER_EMAIL_SECRET,
29+
USER_EMAIL_ENCRYPTION_KEY,
2530
USER_REHASHED_LOGIN_HASH_ENCRYPTION_KEY,
2631
USER_AUTHENTICATOR_SECRET_ENCRYPTION_KEY,
2732
USER_RECOVERY_CODES_ENCRYPTION_KEY,
@@ -30,6 +35,7 @@ export function getSessionEnv(
3035
!ACCESS_SECRET ||
3136
!REFRESH_SECRET ||
3237
!USER_EMAIL_SECRET ||
38+
!USER_EMAIL_ENCRYPTION_KEY ||
3339
!USER_REHASHED_LOGIN_HASH_ENCRYPTION_KEY ||
3440
!USER_AUTHENTICATOR_SECRET_ENCRYPTION_KEY ||
3541
!USER_RECOVERY_CODES_ENCRYPTION_KEY
@@ -40,6 +46,7 @@ export function getSessionEnv(
4046
ACCESS_SECRET,
4147
REFRESH_SECRET,
4248
USER_EMAIL_SECRET,
49+
USER_EMAIL_ENCRYPTION_KEY,
4350
USER_REHASHED_LOGIN_HASH_ENCRYPTION_KEY,
4451
USER_AUTHENTICATOR_SECRET_ENCRYPTION_KEY,
4552
USER_RECOVERY_CODES_ENCRYPTION_KEY,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,10 @@ export {
1717
sessionDemoRequestSchema,
1818
sessionLoginEmailSchema,
1919
sessionLoginRequestSchema,
20+
type SessionDemoRequest,
2021
type SessionLoginRequest,
2122
} from "./schemas/sessions.js";
23+
export {
24+
userMeResponseSchema,
25+
type UserMeResponse,
26+
} 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
@@ -14,5 +14,6 @@ describe("getOpenApiDocument", () => {
1414
expect(doc.paths?.["/api/sessions/refresh"]?.post).toBeDefined();
1515
expect(doc.paths?.["/api/sessions/logout"]?.post).toBeDefined();
1616
expect(doc.paths?.["/api/sessions/demo"]?.post).toBeDefined();
17+
expect(doc.paths?.["/api/users/me"]?.get).toBeDefined();
1718
});
1819
});

0 commit comments

Comments
 (0)