-
Notifications
You must be signed in to change notification settings - Fork 1
Implement invites v2 #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Implement invites v2 #140
Changes from 7 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
7754e15
Setup protobuf for invites v2
lourou 4e696be
Implement v2 router and invite decoding logic
lourou 90e5337
Update proto and invite reponse with metadata fields only
lourou 6a3cc8a
Type update
lourou 6e42127
Use Buffer
lourou 3607447
Max slug length (2048) and invites v2 tests
lourou 10cb266
Fix test
lourou 16f4249
Fix tests
lourou 957c909
Install protobuf CLI as system binary in Docker build stage
lourou d4c302e
Use `CLAUDE_MODEL` repo var with fallback to Sonnet 4.5
lourou 2bd9e3f
Fix Docker build by installing devDependencies for code generation
lourou 772ce86
Revert "Install protobuf CLI as system binary in Docker build stage"
lourou File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,6 +64,8 @@ logs | |
| # Prisma generated files | ||
| /prisma/generated/ | ||
|
|
||
| # Protobuf generated files | ||
| /src/gen/ | ||
|
|
||
| # Claude | ||
| .claude | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| version: v2 | ||
| plugins: | ||
| - local: protoc-gen-es | ||
| out: src/notifications/gen | ||
| out: src/gen | ||
| include_imports: true | ||
| opt: target=ts |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| syntax = "proto3"; | ||
|
|
||
| package invite.v2; | ||
|
|
||
| // InvitePayload represents the core invite data | ||
| message InvitePayload { | ||
| // The encrypted conversation id | ||
| string conversationToken = 1; | ||
|
|
||
| // The creator's inbox ID | ||
| string creator_inbox_id = 2; | ||
|
|
||
| // The tag to mark which conversation this corresponds to, lives in `ConversationCustomMetadata` | ||
| string tag = 3; | ||
|
|
||
| // optional conversation metadata | ||
| optional string name = 4; | ||
| optional string description = 5; | ||
| optional string imageURL = 6; | ||
| } | ||
|
|
||
| // SignedInvite represents an invite with its cryptographic signature | ||
| message SignedInvite { | ||
| // The invite payload containing the actual invite data | ||
| InvitePayload payload = 1; | ||
|
|
||
| // The cryptographic signature (typically 65 bytes for ECDSA with recovery) | ||
| bytes signature = 2; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,13 @@ | ||
| import { Router } from "express"; | ||
| import v1Router from "./v1"; | ||
| import v2Router from "./v2"; | ||
|
|
||
| const apiRouter = Router(); | ||
|
|
||
| // add v1 api | ||
| apiRouter.use("/v1", v1Router); | ||
|
|
||
| // add v2 api | ||
| apiRouter.use("/v2", v2Router); | ||
|
|
||
| export default apiRouter; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { Router } from "express"; | ||
| import invitesV2Router from "./invites/invites.router"; | ||
|
|
||
| const v2Router = Router(); | ||
|
|
||
| v2Router.use("/invites", invitesV2Router); | ||
|
|
||
| export default v2Router; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| import { createHash } from "crypto"; | ||
| import { fromBinary, toBinary } from "@bufbuild/protobuf"; | ||
| import type { Request, Response } from "express"; | ||
| import * as secp256k1 from "secp256k1"; | ||
| import { z } from "zod"; | ||
| import { | ||
| InvitePayloadSchema, | ||
| SignedInviteSchema, | ||
| type InvitePayload, | ||
| type SignedInvite, | ||
| } from "@/gen/invite/v2/invite_pb"; | ||
|
|
||
| const paramsSchema = z.object({ | ||
| slug: z.string().min(1, "Slug is required"), | ||
| }); | ||
|
|
||
| type DecodeInviteSlugParams = z.infer<typeof paramsSchema>; | ||
|
|
||
| // Use pick to only include the fields we need and avoid typing errors | ||
| type DecodedInvite = Pick< | ||
| InvitePayload, | ||
| | "conversationToken" | ||
| | "creatorInboxId" | ||
| | "tag" | ||
| | "name" | ||
| | "description" | ||
| | "imageURL" | ||
| >; | ||
|
|
||
| // Maximum slug length to prevent DoS (browser URL limit is ~2048 chars) | ||
| // Reserve some space for the rest of the URL path | ||
| const MAX_SLUG_LENGTH = 2048; | ||
|
|
||
| function base64URLDecode(slug: string): Uint8Array { | ||
| if (slug.length > MAX_SLUG_LENGTH) { | ||
| throw new Error("Slug too large"); | ||
| } | ||
|
|
||
| let base64 = slug.replace(/-/g, "+").replace(/_/g, "/"); | ||
|
|
||
| while (base64.length % 4 !== 0) { | ||
| base64 += "="; | ||
| } | ||
|
|
||
| const buffer = Buffer.from(base64, "base64"); | ||
| return new Uint8Array(buffer); | ||
| } | ||
|
|
||
| function sha256(data: Uint8Array): Buffer { | ||
| return createHash("sha256").update(data).digest(); | ||
| } | ||
|
|
||
| function recoverPublicKey(signedInvite: SignedInvite) { | ||
| const payload = signedInvite.payload; | ||
| if (!payload) { | ||
| throw new Error("Missing payload"); | ||
| } | ||
|
|
||
| const signature = signedInvite.signature; | ||
| if (signature.length !== 65) { | ||
| throw new Error("Invalid signature length"); | ||
| } | ||
|
|
||
| const signatureData = signature.slice(0, 64); | ||
| const recoveryId = signature[64]; | ||
|
|
||
| const payloadBytes = toBinary(InvitePayloadSchema, payload); | ||
| const messageHash = sha256(payloadBytes); | ||
|
|
||
| const publicKey = secp256k1.ecdsaRecover( | ||
| signatureData, | ||
| recoveryId, | ||
| messageHash, | ||
| false, | ||
| ); | ||
|
|
||
| return Buffer.from(publicKey); | ||
| } | ||
|
|
||
| function decodeInviteSlug(slug: string): DecodedInvite { | ||
| try { | ||
| const data = base64URLDecode(slug); | ||
|
|
||
| const signedInvite = fromBinary(SignedInviteSchema, data); | ||
| const payload = signedInvite.payload; | ||
|
|
||
| if (!payload) { | ||
| throw new Error("Missing payload in signed invite"); | ||
| } | ||
|
|
||
| recoverPublicKey(signedInvite); | ||
|
|
||
| return { | ||
| conversationToken: payload.conversationToken, | ||
| creatorInboxId: payload.creatorInboxId, | ||
| tag: payload.tag, | ||
| name: payload.name, | ||
| description: payload.description, | ||
| imageURL: payload.imageURL, | ||
| }; | ||
| } catch (error) { | ||
| throw new Error( | ||
| `Failed to decode invite slug: ${error instanceof Error ? error.message : "Unknown error"}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export type DecodeInviteSlugResponse = { | ||
| success: boolean; | ||
| data?: { | ||
| name: string | null; | ||
| description: string | null; | ||
| imageURL: string | null; | ||
| }; | ||
| error?: string; | ||
| message?: string; | ||
| }; | ||
|
|
||
| export async function decodeInviteSlugHandler( | ||
| req: Request<DecodeInviteSlugParams, unknown, unknown>, | ||
| res: Response, | ||
| ) { | ||
| try { | ||
| const { slug } = await paramsSchema.parseAsync(req.params); | ||
|
|
||
| const decoded = decodeInviteSlug(slug); | ||
|
|
||
| const response: DecodeInviteSlugResponse = { | ||
| success: true, | ||
| data: { | ||
| name: decoded.name ?? null, | ||
| description: decoded.description ?? null, | ||
| imageURL: decoded.imageURL ?? null, | ||
| }, | ||
| }; | ||
|
|
||
| res.status(200).json(response); | ||
| return; | ||
| } catch (error) { | ||
| if (error instanceof z.ZodError) { | ||
| res.status(400).json({ | ||
| success: false, | ||
| error: "INVALID_REQUEST", | ||
| message: "Slug is required", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| if (error instanceof Error && error.message.includes("Slug too large")) { | ||
| res.status(413).json({ | ||
| success: false, | ||
| error: "SLUG_TOO_LARGE", | ||
| message: "The invite link is too long", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| if ( | ||
| error instanceof Error && | ||
| error.message.includes("Failed to decode invite slug") | ||
| ) { | ||
| res.status(400).json({ | ||
| success: false, | ||
| error: "INVALID_INVITE", | ||
| message: "The invite link is invalid", | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| req.log.error( | ||
| { error, stack: error instanceof Error ? error.stack : undefined }, | ||
| "Invite decode error", | ||
| ); | ||
| res.status(500).json({ | ||
| success: false, | ||
| error: "INTERNAL_ERROR", | ||
| message: "Failed to decode invite", | ||
| }); | ||
| return; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| declare module "secp256k1" { | ||
| export function ecdsaRecover( | ||
| signature: Uint8Array, | ||
| recoveryId: number, | ||
| messageHash: Uint8Array, | ||
| compressed: boolean, | ||
| ): Uint8Array; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.