diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 062504ef..4089734f 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -39,8 +39,8 @@ jobs: with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) - # model: "claude-opus-4-20250514" + # Optional: Specify model (defaults to Claude Sonnet) + model: ${{ vars.CLAUDE_MODEL || 'claude-sonnet-4-5-20250929' }} # Direct prompt for automated review (no @claude mention needed) direct_prompt: | diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 860d25e6..0399949b 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -40,8 +40,8 @@ jobs: additional_permissions: | actions: read - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) - # model: "claude-opus-4-20250514" + # Optional: Specify model (defaults to Claude Sonnet) + model: ${{ vars.CLAUDE_MODEL || 'claude-sonnet-4-5-20250929' }} # Optional: Customize the trigger phrase (default: @claude) # trigger_phrase: "/claude" diff --git a/.github/workflows/validate-code-quality.yml b/.github/workflows/validate-code-quality.yml index e71db254..6e6675d9 100644 --- a/.github/workflows/validate-code-quality.yml +++ b/.github/workflows/validate-code-quality.yml @@ -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" diff --git a/.gitignore b/.gitignore index 163572ae..0f970395 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ logs # Prisma generated files /prisma/generated/ +# Protobuf generated files +/src/gen/ + # Claude .claude - diff --git a/Dockerfile b/Dockerfile index 9b066d60..83face39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,18 +11,28 @@ 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 cd /temp/prod && bun install --frozen-lockfile --production +RUN mkdir -p /temp/prod/proto +COPY proto /temp/prod/proto + +# Install all dependencies (including devDependencies needed for code generation) +RUN cd /temp/prod && bun install --frozen-lockfile 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 +# Remove devDependencies to keep the image small +RUN cd /temp/prod && bun install --frozen-lockfile --production + FROM base AS release # install bun diff --git a/buf.gen.yaml b/buf.gen.yaml index 9a9fbded..21fe2e8a 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -1,6 +1,6 @@ version: v2 plugins: - local: protoc-gen-es - out: src/notifications/gen + out: src/gen include_imports: true opt: target=ts diff --git a/bun.lock b/bun.lock index d704c561..6bf1fc54 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,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", @@ -1419,12 +1421,16 @@ "next": ["next@15.3.1", "", { "dependencies": { "@next/env": "15.3.1", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.1", "@next/swc-darwin-x64": "15.3.1", "@next/swc-linux-arm64-gnu": "15.3.1", "@next/swc-linux-arm64-musl": "15.3.1", "@next/swc-linux-x64-gnu": "15.3.1", "@next/swc-linux-x64-musl": "15.3.1", "@next/swc-win32-arm64-msvc": "15.3.1", "@next/swc-win32-x64-msvc": "15.3.1", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g=="], + "node-addon-api": ["node-addon-api@5.1.0", "", {}, "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-fetch-native": ["node-fetch-native@1.6.6", "", {}, "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ=="], "node-forge": ["node-forge@1.3.1", "", {}, "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-mock-http": ["node-mock-http@1.0.0", "", {}, "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1511,7 +1517,7 @@ "proto3-json-serializer": ["proto3-json-serializer@2.0.2", "", { "dependencies": { "protobufjs": "^7.2.5" } }, "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ=="], - "protobufjs": ["protobufjs@7.4.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw=="], + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -1587,6 +1593,8 @@ "scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], + "secp256k1": ["secp256k1@5.0.1", "", { "dependencies": { "elliptic": "^6.5.7", "node-addon-api": "^5.0.0", "node-gyp-build": "^4.2.0" } }, "sha512-lDFs9AAIaWP9UCdtWrotXWWF9t8PWgQDcxqgAnpM9rMqxb3Oaq2J0thzPVSxBwdJgyQtkU/sYtFtbM1RSt/iYA=="], + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], @@ -1791,10 +1799,16 @@ "@ethersproject/signing-key/bn.js": ["bn.js@5.2.1", "", {}, "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ=="], + "@google-cloud/firestore/protobufjs": ["protobufjs@7.4.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw=="], + "@google-cloud/storage/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "@grpc/proto-loader/protobufjs": ["protobufjs@7.4.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw=="], + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + "@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.4.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw=="], + "@prisma/generator-helper/@prisma/debug": ["@prisma/debug@6.5.0", "", {}, "sha512-fc/nusYBlJMzDmDepdUtH9aBsJrda2JNErP9AzuHbgUEQY0/9zQYZdNlXmKoIWENtio+qarPNe/+DQtrX5kMcQ=="], "@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "semver": "^7.5.2", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg=="], @@ -1827,6 +1841,8 @@ "@walletconnect/window-metadata/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "@xmtp/proto/protobufjs": ["protobufjs@7.4.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw=="], + "accepts/mime-types": ["mime-types@3.0.0", "", { "dependencies": { "mime-db": "^1.53.0" } }, "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -1853,6 +1869,8 @@ "glob/minimatch": ["minimatch@10.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ=="], + "google-gax/protobufjs": ["protobufjs@7.4.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw=="], + "google-gax/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -1867,6 +1885,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "proto3-json-serializer/protobufjs": ["protobufjs@7.4.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "raw-body/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], diff --git a/eslint.config.js b/eslint.config.js index a3a91203..b12f1e3e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, diff --git a/package.json b/package.json index 7d275531..d78a2d0b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/proto/invite/v2/invite.proto b/proto/invite/v2/invite.proto new file mode 100644 index 00000000..0333c3db --- /dev/null +++ b/proto/invite/v2/invite.proto @@ -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; +} diff --git a/src/api/index.ts b/src/api/index.ts index 67799ec0..233b9e4b 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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; diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts new file mode 100644 index 00000000..0087ad52 --- /dev/null +++ b/src/api/v2/index.ts @@ -0,0 +1,8 @@ +import { Router } from "express"; +import invitesV2Router from "./invites/invites.router"; + +const v2Router = Router(); + +v2Router.use("/invites", invitesV2Router); + +export default v2Router; diff --git a/src/api/v2/invites/handlers/decode-invite-slug.ts b/src/api/v2/invites/handlers/decode-invite-slug.ts new file mode 100644 index 00000000..24fc2a36 --- /dev/null +++ b/src/api/v2/invites/handlers/decode-invite-slug.ts @@ -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; + +// 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, + 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; + } +} diff --git a/src/api/v2/invites/handlers/secp256k1.d.ts b/src/api/v2/invites/handlers/secp256k1.d.ts new file mode 100644 index 00000000..73112a88 --- /dev/null +++ b/src/api/v2/invites/handlers/secp256k1.d.ts @@ -0,0 +1,8 @@ +declare module "secp256k1" { + export function ecdsaRecover( + signature: Uint8Array, + recoveryId: number, + messageHash: Uint8Array, + compressed: boolean, + ): Uint8Array; +} diff --git a/src/api/v2/invites/invites.router.ts b/src/api/v2/invites/invites.router.ts new file mode 100644 index 00000000..0890234f --- /dev/null +++ b/src/api/v2/invites/invites.router.ts @@ -0,0 +1,8 @@ +import { Router } from "express"; +import { decodeInviteSlugHandler } from "./handlers/decode-invite-slug"; + +const invitesV2Router = Router(); + +invitesV2Router.get("/:slug", decodeInviteSlugHandler); + +export default invitesV2Router; diff --git a/src/notifications/client.ts b/src/notifications/client.ts index b660d17a..37adaa92 100644 --- a/src/notifications/client.ts +++ b/src/notifications/client.ts @@ -7,7 +7,7 @@ import { Subscription_HmacKeySchema, SubscriptionSchema, type Subscription, -} from "@/notifications/gen/notifications/v1/service_pb"; +} from "@/gen/notifications/v1/service_pb"; export function createNotificationClient() { if (!process.env.NOTIFICATION_SERVER_URL) { diff --git a/src/notifications/gen/notifications/v1/service_pb.ts b/src/notifications/gen/notifications/v1/service_pb.ts deleted file mode 100644 index 9b8da50f..00000000 --- a/src/notifications/gen/notifications/v1/service_pb.ts +++ /dev/null @@ -1,348 +0,0 @@ -// @generated by protoc-gen-es v2.2.3 with parameter "target=ts" -// @generated from file notifications/v1/service.proto (package notifications.v1, syntax proto3) -/* eslint-disable */ - -import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv1"; -import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv1"; -import type { Message } from "@bufbuild/protobuf"; - -/** - * Describes the file notifications/v1/service.proto. - */ -export const file_notifications_v1_service: GenFile = /*@__PURE__*/ - fileDesc("Ch5ub3RpZmljYXRpb25zL3YxL3NlcnZpY2UucHJvdG8SEG5vdGlmaWNhdGlvbnMudjEihAEKEURlbGl2ZXJ5TWVjaGFuaXNtEhsKEWFwbnNfZGV2aWNlX3Rva2VuGAEgASgJSAASHwoVZmlyZWJhc2VfZGV2aWNlX3Rva2VuGAIgASgJSAASFgoMY3VzdG9tX3Rva2VuGAMgASgJSABCGQoXZGVsaXZlcnlfbWVjaGFuaXNtX3R5cGUidwobUmVnaXN0ZXJJbnN0YWxsYXRpb25SZXF1ZXN0EhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRI/ChJkZWxpdmVyeV9tZWNoYW5pc20YAiABKAsyIy5ub3RpZmljYXRpb25zLnYxLkRlbGl2ZXJ5TWVjaGFuaXNtIkwKHFJlZ2lzdGVySW5zdGFsbGF0aW9uUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJEhMKC3ZhbGlkX3VudGlsGAIgASgEIjQKGURlbGV0ZUluc3RhbGxhdGlvblJlcXVlc3QSFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIhwKGkRlbGV0ZUluc3RhbGxhdGlvblJlc3BvbnNlIqsBCgxTdWJzY3JpcHRpb24SDQoFdG9waWMYASABKAkSOQoJaG1hY19rZXlzGAIgAygLMiYubm90aWZpY2F0aW9ucy52MS5TdWJzY3JpcHRpb24uSG1hY0tleRIRCglpc19zaWxlbnQYAyABKAgaPgoHSG1hY0tleRImCh50aGlydHlfZGF5X3BlcmlvZHNfc2luY2VfZXBvY2gYASABKA0SCwoDa2V5GAIgASgMIm4KHFN1YnNjcmliZVdpdGhNZXRhZGF0YVJlcXVlc3QSFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJEjUKDXN1YnNjcmlwdGlvbnMYAiADKAsyHi5ub3RpZmljYXRpb25zLnYxLlN1YnNjcmlwdGlvbiIfCh1TdWJzY3JpYmVXaXRoTWV0YWRhdGFSZXNwb25zZSI7ChBTdWJzY3JpYmVSZXF1ZXN0EhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRIOCgZ0b3BpY3MYAiADKAkiEwoRU3Vic2NyaWJlUmVzcG9uc2UiPQoSVW5zdWJzY3JpYmVSZXF1ZXN0EhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRIOCgZ0b3BpY3MYAiADKAkiFQoTVW5zdWJzY3JpYmVSZXNwb25zZTKjBAoNTm90aWZpY2F0aW9ucxJ1ChRSZWdpc3Rlckluc3RhbGxhdGlvbhItLm5vdGlmaWNhdGlvbnMudjEuUmVnaXN0ZXJJbnN0YWxsYXRpb25SZXF1ZXN0Gi4ubm90aWZpY2F0aW9ucy52MS5SZWdpc3Rlckluc3RhbGxhdGlvblJlc3BvbnNlEm8KEkRlbGV0ZUluc3RhbGxhdGlvbhIrLm5vdGlmaWNhdGlvbnMudjEuRGVsZXRlSW5zdGFsbGF0aW9uUmVxdWVzdBosLm5vdGlmaWNhdGlvbnMudjEuRGVsZXRlSW5zdGFsbGF0aW9uUmVzcG9uc2USVAoJU3Vic2NyaWJlEiIubm90aWZpY2F0aW9ucy52MS5TdWJzY3JpYmVSZXF1ZXN0GiMubm90aWZpY2F0aW9ucy52MS5TdWJzY3JpYmVSZXNwb25zZRJ4ChVTdWJzY3JpYmVXaXRoTWV0YWRhdGESLi5ub3RpZmljYXRpb25zLnYxLlN1YnNjcmliZVdpdGhNZXRhZGF0YVJlcXVlc3QaLy5ub3RpZmljYXRpb25zLnYxLlN1YnNjcmliZVdpdGhNZXRhZGF0YVJlc3BvbnNlEloKC1Vuc3Vic2NyaWJlEiQubm90aWZpY2F0aW9ucy52MS5VbnN1YnNjcmliZVJlcXVlc3QaJS5ub3RpZmljYXRpb25zLnYxLlVuc3Vic2NyaWJlUmVzcG9uc2ViBnByb3RvMw"); - -/** - * An union of possible delibery mechanisms - * - * @generated from message notifications.v1.DeliveryMechanism - */ -export type DeliveryMechanism = Message<"notifications.v1.DeliveryMechanism"> & { - /** - * @generated from oneof notifications.v1.DeliveryMechanism.delivery_mechanism_type - */ - deliveryMechanismType: { - /** - * @generated from field: string apns_device_token = 1; - */ - value: string; - case: "apnsDeviceToken"; - } | { - /** - * @generated from field: string firebase_device_token = 2; - */ - value: string; - case: "firebaseDeviceToken"; - } | { - /** - * @generated from field: string custom_token = 3; - */ - value: string; - case: "customToken"; - } | { case: undefined; value?: undefined }; -}; - -/** - * Describes the message notifications.v1.DeliveryMechanism. - * Use `create(DeliveryMechanismSchema)` to create a new message. - */ -export const DeliveryMechanismSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 0); - -/** - * A request to register an installation with the service - * - * @generated from message notifications.v1.RegisterInstallationRequest - */ -export type RegisterInstallationRequest = Message<"notifications.v1.RegisterInstallationRequest"> & { - /** - * @generated from field: string installation_id = 1; - */ - installationId: string; - - /** - * @generated from field: notifications.v1.DeliveryMechanism delivery_mechanism = 2; - */ - deliveryMechanism?: DeliveryMechanism; -}; - -/** - * Describes the message notifications.v1.RegisterInstallationRequest. - * Use `create(RegisterInstallationRequestSchema)` to create a new message. - */ -export const RegisterInstallationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 1); - -/** - * Response to RegisterInstallationRequest - * - * @generated from message notifications.v1.RegisterInstallationResponse - */ -export type RegisterInstallationResponse = Message<"notifications.v1.RegisterInstallationResponse"> & { - /** - * @generated from field: string installation_id = 1; - */ - installationId: string; - - /** - * @generated from field: uint64 valid_until = 2; - */ - validUntil: bigint; -}; - -/** - * Describes the message notifications.v1.RegisterInstallationResponse. - * Use `create(RegisterInstallationResponseSchema)` to create a new message. - */ -export const RegisterInstallationResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 2); - -/** - * Delete an installation from the service - * - * @generated from message notifications.v1.DeleteInstallationRequest - */ -export type DeleteInstallationRequest = Message<"notifications.v1.DeleteInstallationRequest"> & { - /** - * @generated from field: string installation_id = 1; - */ - installationId: string; -}; - -/** - * Describes the message notifications.v1.DeleteInstallationRequest. - * Use `create(DeleteInstallationRequestSchema)` to create a new message. - */ -export const DeleteInstallationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 3); - -/** - * Response to DeleteInstallationRequest - * - * @generated from message notifications.v1.DeleteInstallationResponse - */ -export type DeleteInstallationResponse = Message<"notifications.v1.DeleteInstallationResponse"> & { -}; - -/** - * Describes the message notifications.v1.DeleteInstallationResponse. - * Use `create(DeleteInstallationResponseSchema)` to create a new message. - */ -export const DeleteInstallationResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 4); - -/** - * A subscription with associated metadata - * - * @generated from message notifications.v1.Subscription - */ -export type Subscription = Message<"notifications.v1.Subscription"> & { - /** - * @generated from field: string topic = 1; - */ - topic: string; - - /** - * @generated from field: repeated notifications.v1.Subscription.HmacKey hmac_keys = 2; - */ - hmacKeys: Subscription_HmacKey[]; - - /** - * @generated from field: bool is_silent = 3; - */ - isSilent: boolean; -}; - -/** - * Describes the message notifications.v1.Subscription. - * Use `create(SubscriptionSchema)` to create a new message. - */ -export const SubscriptionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 5); - -/** - * @generated from message notifications.v1.Subscription.HmacKey - */ -export type Subscription_HmacKey = Message<"notifications.v1.Subscription.HmacKey"> & { - /** - * @generated from field: uint32 thirty_day_periods_since_epoch = 1; - */ - thirtyDayPeriodsSinceEpoch: number; - - /** - * @generated from field: bytes key = 2; - */ - key: Uint8Array; -}; - -/** - * Describes the message notifications.v1.Subscription.HmacKey. - * Use `create(Subscription_HmacKeySchema)` to create a new message. - */ -export const Subscription_HmacKeySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 5, 0); - -/** - * A request to subscribe to a list of topics and update the associated metadata - * - * @generated from message notifications.v1.SubscribeWithMetadataRequest - */ -export type SubscribeWithMetadataRequest = Message<"notifications.v1.SubscribeWithMetadataRequest"> & { - /** - * @generated from field: string installation_id = 1; - */ - installationId: string; - - /** - * @generated from field: repeated notifications.v1.Subscription subscriptions = 2; - */ - subscriptions: Subscription[]; -}; - -/** - * Describes the message notifications.v1.SubscribeWithMetadataRequest. - * Use `create(SubscribeWithMetadataRequestSchema)` to create a new message. - */ -export const SubscribeWithMetadataRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 6); - -/** - * Response to SubscribeWithMetadataRequest - * - * @generated from message notifications.v1.SubscribeWithMetadataResponse - */ -export type SubscribeWithMetadataResponse = Message<"notifications.v1.SubscribeWithMetadataResponse"> & { -}; - -/** - * Describes the message notifications.v1.SubscribeWithMetadataResponse. - * Use `create(SubscribeWithMetadataResponseSchema)` to create a new message. - */ -export const SubscribeWithMetadataResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 7); - -/** - * Subscribe to a list of topics - * - * @generated from message notifications.v1.SubscribeRequest - */ -export type SubscribeRequest = Message<"notifications.v1.SubscribeRequest"> & { - /** - * @generated from field: string installation_id = 1; - */ - installationId: string; - - /** - * @generated from field: repeated string topics = 2; - */ - topics: string[]; -}; - -/** - * Describes the message notifications.v1.SubscribeRequest. - * Use `create(SubscribeRequestSchema)` to create a new message. - */ -export const SubscribeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 8); - -/** - * Response to SubscribeRequest - * - * @generated from message notifications.v1.SubscribeResponse - */ -export type SubscribeResponse = Message<"notifications.v1.SubscribeResponse"> & { -}; - -/** - * Describes the message notifications.v1.SubscribeResponse. - * Use `create(SubscribeResponseSchema)` to create a new message. - */ -export const SubscribeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 9); - -/** - * Unsubscribe from a list of topics - * - * @generated from message notifications.v1.UnsubscribeRequest - */ -export type UnsubscribeRequest = Message<"notifications.v1.UnsubscribeRequest"> & { - /** - * @generated from field: string installation_id = 1; - */ - installationId: string; - - /** - * @generated from field: repeated string topics = 2; - */ - topics: string[]; -}; - -/** - * Describes the message notifications.v1.UnsubscribeRequest. - * Use `create(UnsubscribeRequestSchema)` to create a new message. - */ -export const UnsubscribeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 10); - -/** - * Response to UnsubscribeRequest - * - * @generated from message notifications.v1.UnsubscribeResponse - */ -export type UnsubscribeResponse = Message<"notifications.v1.UnsubscribeResponse"> & { -}; - -/** - * Describes the message notifications.v1.UnsubscribeResponse. - * Use `create(UnsubscribeResponseSchema)` to create a new message. - */ -export const UnsubscribeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_notifications_v1_service, 11); - -/** - * @generated from service notifications.v1.Notifications - */ -export const Notifications: GenService<{ - /** - * @generated from rpc notifications.v1.Notifications.RegisterInstallation - */ - registerInstallation: { - methodKind: "unary"; - input: typeof RegisterInstallationRequestSchema; - output: typeof RegisterInstallationResponseSchema; - }, - /** - * @generated from rpc notifications.v1.Notifications.DeleteInstallation - */ - deleteInstallation: { - methodKind: "unary"; - input: typeof DeleteInstallationRequestSchema; - output: typeof DeleteInstallationResponseSchema; - }, - /** - * @generated from rpc notifications.v1.Notifications.Subscribe - */ - subscribe: { - methodKind: "unary"; - input: typeof SubscribeRequestSchema; - output: typeof SubscribeResponseSchema; - }, - /** - * @generated from rpc notifications.v1.Notifications.SubscribeWithMetadata - */ - subscribeWithMetadata: { - methodKind: "unary"; - input: typeof SubscribeWithMetadataRequestSchema; - output: typeof SubscribeWithMetadataResponseSchema; - }, - /** - * @generated from rpc notifications.v1.Notifications.Unsubscribe - */ - unsubscribe: { - methodKind: "unary"; - input: typeof UnsubscribeRequestSchema; - output: typeof UnsubscribeResponseSchema; - }, -}> = /*@__PURE__*/ - serviceDesc(file_notifications_v1_service, 0); - diff --git a/tests/invites-v2.test.ts b/tests/invites-v2.test.ts new file mode 100644 index 00000000..df5a67d6 --- /dev/null +++ b/tests/invites-v2.test.ts @@ -0,0 +1,164 @@ +import type { Server } from "node:http"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import express from "express"; +import invitesV2Router from "@/api/v2/invites/invites.router"; +import { jsonMiddleware } from "@/middleware/json"; +import { pinoMiddleware } from "@/middleware/pino"; +import { prisma } from "@/utils/prisma"; + +const app = express(); +app.use(pinoMiddleware); +app.use(jsonMiddleware); +app.use("/api/v2/invites", invitesV2Router); + +describe("V2 Invite API Tests", () => { + let server: Server; + const baseURL = "http://localhost:4001"; + + beforeAll(async () => { + await new Promise((resolve) => { + server = app.listen(4001, () => { + resolve(); + }); + }); + }); + + afterAll(async () => { + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + await prisma.$disconnect(); + }); + + describe("GET /api/v2/invites/:slug", () => { + test("should return 404 for missing slug", async () => { + const response = await fetch(`${baseURL}/api/v2/invites/`, { + method: "GET", + }); + + expect(response.status).toBe(404); + }); + + test("should return 400 for invalid base64 slug", async () => { + const response = await fetch(`${baseURL}/api/v2/invites/invalid!!!`, { + method: "GET", + }); + + expect(response.status).toBe(400); + const data = (await response.json()) as { + success: boolean; + error: string; + }; + expect(data.success).toBe(false); + expect(data.error).toBe("INVALID_INVITE"); + }); + + test("should return 400 for malformed protobuf", async () => { + const invalidProtobuf = Buffer.from("not a valid protobuf").toString( + "base64url", + ); + const response = await fetch( + `${baseURL}/api/v2/invites/${invalidProtobuf}`, + { + method: "GET", + }, + ); + + expect(response.status).toBe(400); + const data = (await response.json()) as { + success: boolean; + error: string; + }; + expect(data.success).toBe(false); + expect(data.error).toBe("INVALID_INVITE"); + }); + + test("should reject slugs that are too large (DoS protection)", async () => { + const hugSlug = "A".repeat(2050); + const response = await fetch(`${baseURL}/api/v2/invites/${hugSlug}`, { + method: "GET", + }); + + // Handler rejects with 413 (Payload Too Large) for slugs over 2048 chars + expect(response.status).toBe(413); + }); + + test("should return valid structure with null fields for valid old-format invite", async () => { + // Pre-generated valid SignedInvite slug + // Contains: conversationId, inviteId, signature; no name/description/imageURL + const validSlug = + "CqQBClRBZjBJY3ZNSmo5TW9UcFZEc2x0YnNSakNwaldvcTA2am9iYmRWcVRZb2l4RUQzNVN1X1BJWmNaRmN0MkM5eDFqdEQydE4wZXM0Tkx4MDZMRkJhS3USQDYyZTFmMDIwNTc4YmRjNjMxMDZkZTJmYmFkODUzMzVjM2VkYzRhNzlhNWIyMWVhNjgxMjE2OGQxZjY3MTNlMjUaClVhN2RSRlFqdmESQcEhHVsmCTay20THnnQlEUDVGfhG9OnyHqgbtTFa9WBFat7aUl_22_SPdWfKSZuFUw3N90jc2vtWHkr2zb8eNrEB"; + + const response = await fetch(`${baseURL}/api/v2/invites/${validSlug}`, { + method: "GET", + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { + success: boolean; + data: { + name: string | null; + description: string | null; + imageURL: string | null; + }; + }; + expect(data.success).toBe(true); + expect(data.data).toBeDefined(); + expect(data.data).toHaveProperty("name"); + expect(data.data).toHaveProperty("description"); + expect(data.data).toHaveProperty("imageURL"); + expect(data.data.name).toBeNull(); + expect(data.data.description).toBeNull(); + expect(data.data.imageURL).toBeNull(); + }); + }); + + describe("Response format validation", () => { + test("should return consistent error format", async () => { + const response = await fetch(`${baseURL}/api/v2/invites/invalid`, { + method: "GET", + }); + + const data = (await response.json()) as { + success: boolean; + error: string; + message: string; + }; + expect(data).toHaveProperty("success"); + expect(data).toHaveProperty("error"); + expect(data).toHaveProperty("message"); + expect(data.success).toBe(false); + expect(typeof data.error).toBe("string"); + expect(typeof data.message).toBe("string"); + }); + }); + + describe("Security tests", () => { + test("should not expose internal error details", async () => { + const response = await fetch(`${baseURL}/api/v2/invites/invalid`, { + method: "GET", + }); + + const data = (await response.json()) as { message: string }; + expect(data.message).not.toContain("stack"); + expect(data.message).not.toContain("Error:"); + expect(JSON.stringify(data)).not.toContain("prisma"); + }); + + test("should handle special characters in slug safely", async () => { + const specialChars = "../../../etc/passwd"; + const response = await fetch( + `${baseURL}/api/v2/invites/${encodeURIComponent(specialChars)}`, + { + method: "GET", + }, + ); + + expect(response.status).toBe(400); + const data = (await response.json()) as { success: boolean }; + expect(data.success).toBe(false); + }); + }); +});