Skip to content

Commit 21893b2

Browse files
committed
feat: continue restart plan
1 parent 55ea4a0 commit 21893b2

11 files changed

Lines changed: 576 additions & 14 deletions

File tree

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 23 additions & 10 deletions
Large diffs are not rendered by default.

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ describe("api-worker", () => {
7878
"PATCH",
7979
"/api/groups/aaaaaaaaaaaaaaaaaaaaa/privacy/join-requests",
8080
],
81+
[
82+
"POST",
83+
"/api/groups/aaaaaaaaaaaaaaaaaaaaa/privacy/private",
84+
],
8185
["DELETE", "/api/groups/aaaaaaaaaaaaaaaaaaaaa"],
8286
[
8387
"POST",

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
groupPasswordDisableRequestSchema,
99
groupPasswordEnableRequestSchema,
1010
groupPrivacyJoinRequestsPatchSchema,
11+
groupPrivacyPrivateRequestSchema,
1112
groupPrivacyPublicRequestSchema,
1213
healthResponseSchema,
1314
userDefaultArrowPatchSchema,
@@ -1673,6 +1674,78 @@ app.patch("/api/groups/:groupId/privacy/join-requests", async (c) => {
16731674
}
16741675
});
16751676

1677+
app.post("/api/groups/:groupId/privacy/private", async (c) => {
1678+
const sessionEnv = getSessionEnv(c.env);
1679+
if (sessionEnv == null) {
1680+
return c.json(serviceUnavailableBody, 503);
1681+
}
1682+
const hyper = c.env.HYPERDRIVE;
1683+
if (hyper == null) {
1684+
return c.json(
1685+
{
1686+
code: "SERVICE_UNAVAILABLE" as const,
1687+
message: "HYPERDRIVE binding is not configured.",
1688+
},
1689+
503,
1690+
);
1691+
}
1692+
1693+
let bodyJson: unknown;
1694+
try {
1695+
bodyJson = await c.req.json();
1696+
} catch {
1697+
return c.json({ code: "BAD_REQUEST", message: "Expected JSON body." }, 400);
1698+
}
1699+
1700+
const parsed = groupPrivacyPrivateRequestSchema.safeParse(bodyJson);
1701+
if (!parsed.success) {
1702+
return c.json(
1703+
{
1704+
code: "VALIDATION_ERROR",
1705+
message: parsed.error.flatten().formErrors.join("; "),
1706+
},
1707+
400,
1708+
);
1709+
}
1710+
1711+
const db = getDbForConnectionString(hyper.connectionString);
1712+
const cookieHeader = c.req.header("Cookie");
1713+
const groupId = c.req.param("groupId");
1714+
1715+
const payload = {
1716+
groupAccessKeyring: parsed.data.groupAccessKeyring,
1717+
groupEncryptedName: parsed.data.groupEncryptedName,
1718+
groupEncryptedContentKeyring: parsed.data.groupEncryptedContentKeyring,
1719+
groupPublicKeyring: parsed.data.groupPublicKeyring,
1720+
groupEncryptedPrivateKeyring: parsed.data.groupEncryptedPrivateKeyring,
1721+
groupMembers: parsed.data.groupMembers,
1722+
groupJoinInvitations: parsed.data.groupJoinInvitations,
1723+
groupJoinRequests: parsed.data.groupJoinRequests,
1724+
groupPages: parsed.data.groupPages,
1725+
};
1726+
1727+
try {
1728+
const { performGroupPrivacyMakePrivate } = await import("@deepnotes/session");
1729+
await performGroupPrivacyMakePrivate({
1730+
db,
1731+
env: sessionEnv,
1732+
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
1733+
groupId,
1734+
payload,
1735+
});
1736+
return c.body(null, 204);
1737+
} catch (e) {
1738+
const { SessionError } = await import("@deepnotes/session");
1739+
if (e instanceof SessionError) {
1740+
return c.json(
1741+
{ code: e.code, message: e.message },
1742+
e.status as ContentfulStatusCode,
1743+
);
1744+
}
1745+
throw e;
1746+
}
1747+
});
1748+
16761749
app.delete("/api/groups/:groupId", async (c) => {
16771750
const sessionEnv = getSessionEnv(c.env);
16781751
if (sessionEnv == null) {

new-deepnotes/docs/TRPC_REST_MAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Working checklist for Phase 0 of [docs/RESTART_PLAN.md](../../docs/RESTART_PLAN.
5959
| `groups.password.disable` | `DELETE /api/groups/:groupId/password` (JSON body; **implemented**`performGroupPasswordDisable`; not Pro-gated, legacy match) |
6060
| `groups.privacy.makePublic` | `POST /api/groups/:groupId/privacy/public` (**implemented**`performGroupPrivacyMakePublic`; clears `group_members` / `group_join_invitations` `encrypted_access_keyring`) |
6161
| `groups.privacy.setJoinRequestsAllowed` | `PATCH /api/groups/:groupId/privacy/join-requests` (**implemented**`performGroupPrivacySetJoinRequestsAllowed`) |
62+
| (WS) `groups.privacy.makePrivate` step 1+2 | `POST /api/groups/:groupId/privacy/private` (**implemented**`performGroupPrivacyMakePrivate`; single body = legacy `rotateGroupKeys` / `groupKeyRotationSchema`; omits `pages.next_key_rotation_date` bumps per RESTART_PLAN) |
6263
| `groups.deletion.delete` | `DELETE /api/groups/:groupId` (soft) (**implemented**`performGroupSoftDelete`) |
6364
| `groups.deletion.restore` | `POST /api/groups/:groupId/restore` (**implemented**`performGroupRestore`; grace only; not after purge) |
6465
| `groups.deletion.deletePermanently` | `POST /api/groups/:groupId/purge` (**implemented**`performGroupPurge`) |
@@ -86,7 +87,7 @@ Working checklist for Phase 0 of [docs/RESTART_PLAN.md](../../docs/RESTART_PLAN.
8687
| `websocket/groups/join-requests/*` | same | send / accept / reject / cancel |
8788
| `websocket/groups/change-user-role` | `PATCH /api/groups/:groupId/members/:userId` | prefer REST if acceptable |
8889
| `websocket/groups/remove-user` | `DELETE /api/groups/:groupId/members/:userId` | |
89-
| `websocket/groups/privacy/make-private` | `POST /api/groups/:groupId/privacy/private` | |
90+
| `websocket/groups/privacy/make-private` | `POST /api/groups/:groupId/privacy/private` | **implemented** — see `groups.privacy.makePrivate` row above |
9091
| `websocket/groups/rotate-keys` || **removed** per RESTART_PLAN |
9192
| `websocket/pages/move` | `POST /api/pages/:pageId/move` | |
9293
| `websocket/users/account/change-password` | `POST /api/users/me/password` | **implemented** in `@deepnotes/session` (`performUserPasswordChange`) |

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ export {
3535
groupPasswordDisableRequestSchema,
3636
groupPasswordEnableRequestSchema,
3737
groupPrivacyJoinRequestsPatchSchema,
38+
groupPrivacyPrivateRequestSchema,
3839
groupPrivacyPublicRequestSchema,
3940
userGroupIdsResponseSchema,
4041
} from "./schemas/pages-groups.js";
42+
export type { GroupPrivacyPrivateRequest } from "./schemas/pages-groups.js";
4143
export {
4244
userCurrentPathResponseSchema,
4345
userDefaultArrowPatchSchema,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ describe("getOpenApiDocument", () => {
6969
expect(
7070
doc.paths?.["/api/groups/{groupId}/privacy/join-requests"]?.patch,
7171
).toBeDefined();
72+
expect(
73+
doc.paths?.["/api/groups/{groupId}/privacy/private"]?.post,
74+
).toBeDefined();
7275
expect(doc.paths?.["/api/groups/{groupId}"]?.delete).toBeDefined();
7376
expect(doc.paths?.["/api/groups/{groupId}/restore"]?.post).toBeDefined();
7477
expect(doc.paths?.["/api/groups/{groupId}/purge"]?.post).toBeDefined();

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
groupPasswordDisableRequestSchema,
2929
groupPasswordEnableRequestSchema,
3030
groupPrivacyJoinRequestsPatchSchema,
31+
groupPrivacyPrivateRequestSchema,
3132
groupPrivacyPublicRequestSchema,
3233
userGroupIdsResponseSchema,
3334
} from "./schemas/pages-groups.js";
@@ -780,6 +781,37 @@ registry.registerPath({
780781
},
781782
});
782783

784+
registry.registerPath({
785+
method: "post",
786+
path: "/api/groups/{groupId}/privacy/private",
787+
summary: "Make group private (Pro) — full re-key payload",
788+
description:
789+
"Replaces legacy WS `groups.privacy.makePrivate` (step 2 `rotateGroupKeys`) in one request. Clears `access_keyring` when `groupAccessKeyring` is omitted. Member / invitation / request / page record keys must match the DB exactly.",
790+
request: {
791+
params: groupIdPathSchema,
792+
body: {
793+
content: {
794+
"application/json": {
795+
schema: groupPrivacyPrivateRequestSchema,
796+
},
797+
},
798+
},
799+
},
800+
responses: {
801+
204: { description: "Group is private; ciphertext updated." },
802+
400: {
803+
description: "Already private or payload key sets do not match group.",
804+
content: {
805+
"application/json": { schema: sessionErrorResponseSchema },
806+
},
807+
},
808+
401: sessionUnauthorized401,
809+
403: sessionForbidden403,
810+
404: sessionNotFound404,
811+
503: sessionServiceUnavailable503,
812+
},
813+
});
814+
783815
registry.registerPath({
784816
method: "delete",
785817
path: "/api/groups/{groupId}",

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,63 @@ export const groupPrivacyJoinRequestsPatchSchema = z
111111
areJoinRequestsAllowed: z.boolean(),
112112
})
113113
.openapi("GroupPrivacyJoinRequestsPatch");
114+
115+
const nanoidRecordKeySchema = z
116+
.string()
117+
.regex(/^[A-Za-z0-9_-]{21}$/, "expected nanoid id");
118+
119+
const groupPrivacyPrivateMemberSchema = z
120+
.object({
121+
encryptedAccessKeyring: byteB64.optional(),
122+
encryptedInternalKeyring: byteB64,
123+
encryptedName: byteB64.nullable(),
124+
})
125+
.openapi("GroupPrivacyPrivateMember");
126+
127+
const groupPrivacyPrivateInvitationSchema = z
128+
.object({
129+
encryptedAccessKeyring: byteB64.optional(),
130+
encryptedInternalKeyring: byteB64,
131+
encryptedName: byteB64,
132+
})
133+
.openapi("GroupPrivacyPrivateInvitation");
134+
135+
const groupPrivacyPrivateJoinRequestSchema = z
136+
.object({
137+
encryptedName: byteB64,
138+
})
139+
.openapi("GroupPrivacyPrivateJoinRequest");
140+
141+
const groupPrivacyPrivatePageSchema = z
142+
.object({
143+
encryptedSymmetricKeyring: byteB64,
144+
})
145+
.openapi("GroupPrivacyPrivatePage");
146+
147+
/**
148+
* Re-key payload for `POST …/privacy/private` (legacy WS `groups.privacy.makePrivate` step 2 + `rotateGroupKeys` in one call).
149+
* Record keys are user ids (members, invitations, requests) or page ids; must match current DB rows exactly.
150+
*/
151+
export const groupPrivacyPrivateRequestSchema = z
152+
.object({
153+
groupAccessKeyring: byteB64.optional(),
154+
groupEncryptedName: byteB64,
155+
groupEncryptedContentKeyring: byteB64,
156+
groupPublicKeyring: byteB64,
157+
groupEncryptedPrivateKeyring: byteB64,
158+
groupMembers: z.record(nanoidRecordKeySchema, groupPrivacyPrivateMemberSchema),
159+
groupJoinInvitations: z.record(
160+
nanoidRecordKeySchema,
161+
groupPrivacyPrivateInvitationSchema,
162+
),
163+
groupJoinRequests: z.record(
164+
nanoidRecordKeySchema,
165+
groupPrivacyPrivateJoinRequestSchema,
166+
),
167+
groupPages: z.record(nanoidRecordKeySchema, groupPrivacyPrivatePageSchema),
168+
})
169+
.openapi("GroupPrivacyPrivateRequest");
170+
171+
export type GroupPrivacyPrivateRequest = z.infer<
172+
typeof groupPrivacyPrivateRequestSchema
173+
>;

0 commit comments

Comments
 (0)