Skip to content

Commit 3235a60

Browse files
committed
feat: continue restart plan
1 parent 3d51b40 commit 3235a60

11 files changed

Lines changed: 746 additions & 14 deletions

File tree

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 29 additions & 13 deletions
Large diffs are not rendered by default.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ describe("api-worker", () => {
8888
"/api/groups/aaaaaaaaaaaaaaaaaaaaa/restore",
8989
],
9090
["POST", "/api/groups/aaaaaaaaaaaaaaaaaaaaa/purge"],
91+
["POST", "/api/pages/aaaaaaaaaaaaaaaaaaaaa/move"],
9192
["POST", "/api/pages/aaaaaaaaaaaaaaaaaaaaa/bump"],
9293
["POST", "/api/pages/aaaaaaaaaaaaaaaaaaaaa/backlinks"],
9394
[

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

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
groupPagesListQuerySchema,
77
pageBacklinkCreateRequestSchema,
88
pageBumpRequestSchema,
9+
pageMoveRequestSchema,
910
pageIdPathSchema,
1011
pageSnapshotCreateResponseSchema,
1112
pageSnapshotSaveRequestSchema,
@@ -33,6 +34,7 @@ import {
3334
} from "@deepnotes/api";
3435
import type { ContentfulStatusCode } from "hono/utils/http-status";
3536
import { Hono } from "hono";
37+
import type { PageMoveBody } from "@deepnotes/session";
3638

3739
import { getDbForConnectionString } from "./db-pool.js";
3840
import { readCookieHeader } from "./cookies.js";
@@ -1371,6 +1373,113 @@ app.post("/api/groups/:groupId/pages", async (c) => {
13711373
}
13721374
});
13731375

1376+
app.post("/api/pages/:pageId/move", async (c) => {
1377+
const sessionEnv = getSessionEnv(c.env);
1378+
if (sessionEnv == null) {
1379+
return c.json(serviceUnavailableBody, 503);
1380+
}
1381+
const hyper = c.env.HYPERDRIVE;
1382+
if (hyper == null) {
1383+
return c.json(
1384+
{
1385+
code: "SERVICE_UNAVAILABLE" as const,
1386+
message: "HYPERDRIVE binding is not configured.",
1387+
},
1388+
503,
1389+
);
1390+
}
1391+
1392+
let bodyJson: unknown;
1393+
try {
1394+
bodyJson = await c.req.json();
1395+
} catch {
1396+
return c.json({ code: "BAD_REQUEST", message: "Expected JSON body." }, 400);
1397+
}
1398+
1399+
const pParams = pageIdPathSchema.safeParse({ pageId: c.req.param("pageId") });
1400+
if (!pParams.success) {
1401+
return c.json(
1402+
{ code: "VALIDATION_ERROR", message: pParams.error.message },
1403+
400,
1404+
);
1405+
}
1406+
const parsed = pageMoveRequestSchema.safeParse(bodyJson);
1407+
if (!parsed.success) {
1408+
return c.json(
1409+
{
1410+
code: "VALIDATION_ERROR",
1411+
message: parsed.error.flatten().formErrors.join("; "),
1412+
},
1413+
400,
1414+
);
1415+
}
1416+
1417+
const d = parsed.data;
1418+
const moveBody: PageMoveBody = {
1419+
destGroupId: d.destGroupId,
1420+
setAsMainPage: d.setAsMainPage,
1421+
groupCreation:
1422+
d.groupCreation == null
1423+
? undefined
1424+
: {
1425+
groupEncryptedName: d.groupCreation.groupEncryptedName,
1426+
groupPasswordHash: d.groupCreation.groupPasswordHash,
1427+
groupIsPublic: d.groupCreation.groupIsPublic,
1428+
groupAccessKeyring: d.groupCreation.groupAccessKeyring,
1429+
groupEncryptedInternalKeyring:
1430+
d.groupCreation.groupEncryptedInternalKeyring,
1431+
groupEncryptedContentKeyring: d.groupCreation.groupEncryptedContentKeyring,
1432+
groupPublicKeyring: d.groupCreation.groupPublicKeyring,
1433+
groupEncryptedPrivateKeyring: d.groupCreation.groupEncryptedPrivateKeyring,
1434+
groupOwnerEncryptedName: d.groupCreation.groupOwnerEncryptedName,
1435+
},
1436+
reencrypt:
1437+
d.reencrypt == null
1438+
? undefined
1439+
: {
1440+
pageEncryptedSymmetricKeyring:
1441+
d.reencrypt.pageEncryptedSymmetricKeyring,
1442+
pageEncryptedRelativeTitle: d.reencrypt.pageEncryptedRelativeTitle,
1443+
pageEncryptedAbsoluteTitle: d.reencrypt.pageEncryptedAbsoluteTitle,
1444+
pageEncryptedUpdate: d.reencrypt.pageEncryptedUpdate,
1445+
pageEncryptedSnapshots: Object.fromEntries(
1446+
Object.entries(d.reencrypt.pageEncryptedSnapshots).map(
1447+
([id, snap]) => [
1448+
id,
1449+
{
1450+
encryptedSymmetricKey: snap.encryptedSymmetricKey,
1451+
encryptedData: snap.encryptedData,
1452+
},
1453+
],
1454+
),
1455+
),
1456+
},
1457+
};
1458+
1459+
const db = getDbForConnectionString(hyper.connectionString);
1460+
const cookieHeader = c.req.header("Cookie");
1461+
try {
1462+
const { performPageMove } = await import("@deepnotes/session");
1463+
await performPageMove({
1464+
db,
1465+
env: sessionEnv,
1466+
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
1467+
pageId: pParams.data.pageId,
1468+
body: moveBody,
1469+
});
1470+
return c.body(null, 204);
1471+
} catch (e) {
1472+
const { SessionError } = await import("@deepnotes/session");
1473+
if (e instanceof SessionError) {
1474+
return c.json(
1475+
{ code: e.code, message: e.message },
1476+
e.status as ContentfulStatusCode,
1477+
);
1478+
}
1479+
throw e;
1480+
}
1481+
});
1482+
13741483
app.post("/api/pages/:pageId/bump", async (c) => {
13751484
const sessionEnv = getSessionEnv(c.env);
13761485
if (sessionEnv == null) {

new-deepnotes/docs/TRPC_REST_MAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Working checklist for Phase 0 of [docs/RESTART_PLAN.md](../../docs/RESTART_PLAN.
8989
| `websocket/groups/remove-user` | `DELETE /api/groups/:groupId/members/:userId` | |
9090
| `websocket/groups/privacy/make-private` | `POST /api/groups/:groupId/privacy/private` | **implemented** — see `groups.privacy.makePrivate` row above |
9191
| `websocket/groups/rotate-keys` || **removed** per RESTART_PLAN |
92-
| `websocket/pages/move` | `POST /api/pages/:pageId/move` | |
92+
| `websocket/pages/move` | `POST /api/pages/:pageId/move` (**implemented**`pageMoveRequestSchema`; `performPageMove`: Pro, optional `groupCreation`, `reencrypt` when changing group) | |
9393
| `websocket/users/account/change-password` | `POST /api/users/me/password` | **implemented** in `@deepnotes/session` (`performUserPasswordChange`) |
9494
| `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 |
9595
| `websocket/users/account/rotate-keys` || **removed** |

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export {
3939
groupPrivacyPublicRequestSchema,
4040
pageBacklinkCreateRequestSchema,
4141
pageBumpRequestSchema,
42+
pageMoveRequestSchema,
4243
pageIdPathSchema,
4344
pageSnapshotCreateResponseSchema,
4445
pageSnapshotLoadResponseSchema,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ describe("getOpenApiDocument", () => {
7575
expect(doc.paths?.["/api/groups/{groupId}"]?.delete).toBeDefined();
7676
expect(doc.paths?.["/api/groups/{groupId}/restore"]?.post).toBeDefined();
7777
expect(doc.paths?.["/api/groups/{groupId}/purge"]?.post).toBeDefined();
78+
expect(doc.paths?.["/api/pages/{pageId}/move"]?.post).toBeDefined();
7879
expect(doc.paths?.["/api/pages/{pageId}/bump"]?.post).toBeDefined();
7980
expect(
8081
doc.paths?.["/api/pages/{pageId}/backlinks"]?.post,

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
groupPrivacyPublicRequestSchema,
3333
pageBacklinkCreateRequestSchema,
3434
pageBumpRequestSchema,
35+
pageMoveRequestSchema,
3536
pageIdPathSchema,
3637
pageSnapshotCreateResponseSchema,
3738
pageSnapshotLoadResponseSchema,
@@ -886,6 +887,35 @@ registry.registerPath({
886887
},
887888
});
888889

890+
registry.registerPath({
891+
method: "post",
892+
path: "/api/pages/{pageId}/move",
893+
summary: "Move page (optionally create group, re-key, set main)",
894+
description:
895+
"Replaces `websocket/pages/move` — Pro-only; `editGroupSettings` on the page's current group, `editGroupPages` on destination unless `groupCreation` creates it. `reencrypt` is required when the page changes group (Yjs `page_updates` replaced with a single index-0 row; snapshots updated by id).",
896+
request: {
897+
params: pageIdPathSchema,
898+
body: {
899+
content: {
900+
"application/json": {
901+
schema: pageMoveRequestSchema,
902+
},
903+
},
904+
},
905+
},
906+
responses: {
907+
204: { description: "Move completed." },
908+
400: {
909+
description: "No-op move, or invalid payload.",
910+
content: { "application/json": { schema: sessionErrorResponseSchema } },
911+
},
912+
401: sessionUnauthorized401,
913+
403: sessionForbidden403,
914+
404: sessionNotFound404,
915+
503: sessionServiceUnavailable503,
916+
},
917+
});
918+
889919
registry.registerPath({
890920
method: "post",
891921
path: "/api/pages/{pageId}/bump",

new-deepnotes/packages/api/src/schemas/pages-groups.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,56 @@ export const pageBumpRequestSchema = z
211211
})
212212
.openapi("PageBumpRequest");
213213

214+
/** Ciphertext to persist after a cross-group move (legacy `pageKeyRotationSchema` + Yjs update). */
215+
export const pageMoveReencryptRequestSchema = z
216+
.object({
217+
pageEncryptedSymmetricKeyring: byteB64,
218+
pageEncryptedRelativeTitle: byteB64,
219+
pageEncryptedAbsoluteTitle: byteB64,
220+
pageEncryptedUpdate: byteB64,
221+
pageEncryptedSnapshots: z
222+
.record(
223+
nanoidRecordKeySchema,
224+
z.object({
225+
encryptedSymmetricKey: byteB64,
226+
encryptedData: byteB64,
227+
}),
228+
)
229+
.default({}),
230+
})
231+
.openapi("PageMoveReencryptRequest");
232+
233+
/** Create a new shared group in the same call as a move (legacy WS step 1 `groupCreation`). */
234+
export const pageMoveGroupCreationRequestSchema = z
235+
.object({
236+
groupEncryptedName: byteB64,
237+
groupPasswordHash: byteB64.optional(),
238+
groupIsPublic: z.boolean(),
239+
groupAccessKeyring: byteB64,
240+
groupEncryptedInternalKeyring: byteB64,
241+
groupEncryptedContentKeyring: byteB64,
242+
groupPublicKeyring: byteB64,
243+
groupEncryptedPrivateKeyring: byteB64,
244+
groupOwnerEncryptedName: byteB64,
245+
})
246+
.openapi("PageMoveGroupCreationRequest");
247+
248+
/**
249+
* Replaces `websocket/pages/move` (two tRPC steps) with one `POST` (optional `reencrypt` when
250+
* `sourceGroupId !== destGroupId`).
251+
*/
252+
export const pageMoveRequestSchema = z
253+
.object({
254+
destGroupId: z
255+
.string()
256+
.regex(/^[A-Za-z0-9_-]{21}$/)
257+
.openapi(nanoidIdOpenapi),
258+
setAsMainPage: z.boolean(),
259+
groupCreation: pageMoveGroupCreationRequestSchema.optional(),
260+
reencrypt: pageMoveReencryptRequestSchema.optional(),
261+
})
262+
.openapi("PageMoveRequest");
263+
214264
export const pageBacklinkCreateRequestSchema = z
215265
.object({
216266
sourcePageId: z

0 commit comments

Comments
 (0)