Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/validate-code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
with:
bun-version-file: ".bun-version"
- run: bun install
- run: bun run buf:generate
- run: ./dev/up
env:
TEST_NOTIFICATION_DELIVERY_URL: "http://host.docker.internal:8081"
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ logs
# Prisma generated files
/prisma/generated/

# Protobuf generated files
/src/gen/

# Claude
.claude

7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@ RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.2.2" && \
ln -s $HOME/.bun/bin/bun /usr/local/bin/bun

RUN mkdir -p /temp/prod
COPY package.json bun.lock tsconfig.json /temp/prod/
COPY package.json bun.lock tsconfig.json buf.yaml buf.gen.yaml /temp/prod/
RUN mkdir -p /temp/prod/src
COPY src /temp/prod/src
RUN mkdir -p /temp/prod/prisma
COPY prisma /temp/prod/prisma
RUN mkdir -p /temp/prod/proto
COPY proto /temp/prod/proto
RUN cd /temp/prod && bun install --frozen-lockfile --production

ENV NODE_ENV=production

# generate protobuf types
RUN cd /temp/prod && bun run buf:generate

# generate Prisma client
RUN cd /temp/prod && bun prisma generate

Expand Down
2 changes: 1 addition & 1 deletion buf.gen.yaml
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
22 changes: 21 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const gitignorePath = path.resolve(__dirname, ".gitignore");
export default tseslint.config(
includeIgnoreFile(gitignorePath),
{
ignores: [".yarn/**/*", "src/notifications/gen/**"],
ignores: [".yarn/**/*", "src/gen/**"],
},
eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
"pino": "^9.6.0",
"pino-http": "^10.4.0",
"pino-pretty": "^13.0.0",
"protobufjs": "^7.5.4",
"secp256k1": "^5.0.1",
"thirdweb": "^5.88.2",
"uint8array-extras": "^1.4.0",
"uuid": "^11.0.5",
Expand Down
29 changes: 29 additions & 0 deletions proto/invite/v2/invite.proto
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;
}
4 changes: 4 additions & 0 deletions src/api/index.ts
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;
8 changes: 8 additions & 0 deletions src/api/v2/index.ts
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;
181 changes: 181 additions & 0 deletions src/api/v2/invites/handlers/decode-invite-slug.ts
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";
Comment thread
lourou marked this conversation as resolved.

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;
}
}
8 changes: 8 additions & 0 deletions src/api/v2/invites/handlers/secp256k1.d.ts
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;
}
Loading
Loading