diff --git a/.github/workflows/deploy-aws.yml b/.github/workflows/deploy-aws.yml index 0ffc7075..b6ec37c5 100644 --- a/.github/workflows/deploy-aws.yml +++ b/.github/workflows/deploy-aws.yml @@ -6,6 +6,7 @@ on: push: branches: - otr-dev + - otr-testnet - otr-prod pull_request: workflow_dispatch: @@ -16,9 +17,10 @@ on: type: choice options: - otr-dev + - otr-testnet - otr-prod repository_dispatch: - types: [deploy-otr-prod] + types: [deploy-otr-testnet, deploy-otr-prod] workflow_call: inputs: ref: @@ -138,6 +140,28 @@ jobs: variable-value: "ghcr.io/xmtplabs/convos-backend@${{ needs.push_to_registry.outputs.digest }}" variable-value-required-prefix: "ghcr.io/xmtplabs/convos-backend@sha256:" + deploy_otr_testnet: + name: Deploy to OTR Testnet + runs-on: ubuntu-latest + needs: push_to_registry + if: (inputs.ref == 'otr-testnet') || (inputs.environment == 'otr-testnet') || (github.ref == 'refs/heads/otr-testnet' && !inputs.ref && !inputs.environment) || (github.event_name == 'repository_dispatch' && github.event.client_payload.ref == 'otr-testnet') + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.ref || github.event_name == 'workflow_dispatch' && inputs.environment || github.event_name == 'workflow_call' && inputs.ref || github.ref }} + - name: Deploy to OTR Testnet Environment + uses: xmtp-labs/terraform-deployer@v1 + timeout-minutes: 20 + with: + timeout: 20m + terraform-token: ${{ secrets.TERRAFORM_TOKEN }} + terraform-org: xmtp + terraform-workspace: convos-otr-testnet + variable-name: api_image + variable-value: "ghcr.io/xmtplabs/convos-backend@${{ needs.push_to_registry.outputs.digest }}" + variable-value-required-prefix: "ghcr.io/xmtplabs/convos-backend@sha256:" + test_lifecycle_dev: name: Test S3 Lifecycle Renewal (Dev) runs-on: ubuntu-latest @@ -174,6 +198,42 @@ jobs: fi echo "✅ Lifecycle renewal test passed (dev)" >> $GITHUB_STEP_SUMMARY + test_lifecycle_testnet: + name: Test S3 Lifecycle Renewal (Testnet) + runs-on: ubuntu-latest + needs: deploy_otr_testnet + steps: + - name: Wait for deployment and verify health + run: | + echo "Waiting for deployment to stabilize..." + for i in {1..10}; do + sleep 10 + if curl -sf "${{ vars.TESTNET_API_URL }}/healthcheck" > /dev/null 2>&1; then + echo "Service is healthy after $((i * 10)) seconds" + exit 0 + fi + echo "Waiting for service to be ready (attempt $i/10)..." + done + echo "::error::Service did not become healthy after 100s" + exit 1 + + - name: Test lifecycle renewal endpoint + run: | + response=$(curl -s -w "\n%{http_code}" \ + --max-time 60 \ + --retry 3 \ + --retry-connrefused \ + -X POST "${{ vars.TESTNET_API_URL }}/api/v2/assets/test/lifecycle" \ + -H "Authorization: Bearer ${{ secrets.LIFECYCLE_TEST_TOKEN }}") + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | head -n -1) + echo "$body" + if [ "$http_code" != "200" ] || ! echo "$body" | jq -e '.success == true' > /dev/null 2>&1; then + echo "::error::Lifecycle test failed!" + exit 1 + fi + echo "✅ Lifecycle renewal test passed (testnet)" >> $GITHUB_STEP_SUMMARY + test_lifecycle_prod: name: Test S3 Lifecycle Renewal (Prod) runs-on: ubuntu-latest diff --git a/.github/workflows/s3-lifecycle-verify.yml b/.github/workflows/s3-lifecycle-verify.yml index 0c531021..83b4902e 100644 --- a/.github/workflows/s3-lifecycle-verify.yml +++ b/.github/workflows/s3-lifecycle-verify.yml @@ -78,6 +78,77 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "Status: **healthy**" >> $GITHUB_STEP_SUMMARY + verify-testnet: + name: Verify Lifecycle (Testnet) + runs-on: ubuntu-latest + steps: + - name: Verify S3 lifecycle canaries + run: | + response=$(curl -s -w "\n%{http_code}" \ + --max-time 60 \ + --retry 3 \ + --retry-connrefused \ + -X POST "${{ vars.TESTNET_API_URL }}/api/v2/assets/test/lifecycle-status" \ + -H "Authorization: Bearer ${{ secrets.LIFECYCLE_TEST_TOKEN }}") + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | head -n -1) + echo "Response:" + echo "$body" | jq . || echo "$body" + if [ "$http_code" != "200" ]; then + echo "::error::Lifecycle verification failed with HTTP $http_code" + exit 1 + fi + if ! echo "$body" | jq -e '.status == "healthy"' > /dev/null 2>&1; then + echo "::error::Lifecycle verification returned unhealthy status" + errors=$(echo "$body" | jq -r '.errors[]' 2>/dev/null || echo "Unknown errors") + echo "Errors: $errors" + exit 1 + fi + + # Build summary + echo "## Lifecycle Verification (Testnet)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Date: $(echo "$body" | jq -r '.date')" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Created Canaries" >> $GITHUB_STEP_SUMMARY + echo "- Delete canary: \`$(echo "$body" | jq -r '.created.deleteCanary')\`" >> $GITHUB_STEP_SUMMARY + echo "- Keep canary: \`$(echo "$body" | jq -r '.created.keepCanary')\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + echo "### Renewed" >> $GITHUB_STEP_SUMMARY + if [ "$(echo "$body" | jq '.renewed // [] | length')" -eq 0 ]; then + echo "- _(none)_" >> $GITHUB_STEP_SUMMARY + else + echo "$body" | jq -r '.renewed // [] | .[]' | while read -r key; do echo "- \`$key\`"; done >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + echo "### Verified" >> $GITHUB_STEP_SUMMARY + echo "**Deleted as expected:**" >> $GITHUB_STEP_SUMMARY + if [ "$(echo "$body" | jq '.verified.deletedAsExpected // [] | length')" -eq 0 ]; then + echo "- _(none yet - first week of operation)_" >> $GITHUB_STEP_SUMMARY + else + echo "$body" | jq -r '.verified.deletedAsExpected // [] | .[]' | while read -r key; do echo "- \`$key\`"; done >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + echo "**Exists as expected:**" >> $GITHUB_STEP_SUMMARY + if [ "$(echo "$body" | jq '.verified.existsAsExpected // [] | length')" -eq 0 ]; then + echo "- _(none yet - first week of operation)_" >> $GITHUB_STEP_SUMMARY + else + echo "$body" | jq -r '.verified.existsAsExpected // [] | .[]' | while read -r key; do echo "- \`$key\`"; done >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + echo "### Cleaned" >> $GITHUB_STEP_SUMMARY + if [ "$(echo "$body" | jq '.cleaned // [] | length')" -eq 0 ]; then + echo "- _(none)_" >> $GITHUB_STEP_SUMMARY + else + echo "$body" | jq -r '.cleaned // [] | .[]' | while read -r key; do echo "- \`$key\`"; done >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "Status: **healthy**" >> $GITHUB_STEP_SUMMARY + verify-prod: name: Verify Lifecycle (Prod) runs-on: ubuntu-latest diff --git a/RELEASE.md b/RELEASE.md index fa02bb8e..85400ac7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,19 +5,33 @@ - Default branch: `otr-dev` - PRs merged to `otr-dev` trigger automatic backend deployment via workflow +## Testnet + +- Merge `otr-dev` into `otr-testnet` locally +- Push directly to `otr-testnet` - **deploys to testnet** +- **Always use fast-forward merge** to maintain identical commit history + +```bash +git checkout otr-testnet +git merge --ff-only otr-dev +git push origin otr-testnet +``` + ## Production -- Merge `otr-dev` into `otr-prod` locally +- Merge `otr-testnet` into `otr-prod` locally after testnet verification - Push directly to `otr-prod` - **deploys to production** - **Always use fast-forward merge** to maintain identical commit history ```bash git checkout otr-prod -git merge --ff-only otr-dev +git merge --ff-only otr-testnet git push origin otr-prod ``` ## Rules - **Never push commits to `otr-prod` that aren't on `otr-dev`** +- **Never push commits to `otr-prod` that aren't on `otr-testnet`** - All commits must exist on `otr-dev` first +- Production commits must pass through `otr-testnet` first diff --git a/bun.lock b/bun.lock index bad89f1c..de730d7d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "convos-backend", @@ -37,7 +38,6 @@ "uuid": "^11.0.5", "viem": "^2.23.2", "zod": "^3.24.1", - "zod-prisma-types": "^3.2.4", }, "devDependencies": { "@bufbuild/buf": "^1.50.0", @@ -61,6 +61,7 @@ "rimraf": "^6.0.1", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", + "zod-prisma-types": "^3.2.4", }, }, }, diff --git a/docs/plans/invite-code-multi-use.md b/docs/plans/invite-code-multi-use.md index dffd6854..b48b2031 100644 --- a/docs/plans/invite-code-multi-use.md +++ b/docs/plans/invite-code-multi-use.md @@ -1,8 +1,7 @@ # Multi-Use Invite Codes with Viral Redemption > **Status**: Draft -> **Branch**: `feat/invite-code-multi-use` -> **Parent PR**: #183 (Invite code gating for Instant Assistant) +> **Branch**: `feat/invite-code-multi-use` > **Parent PR**: #183 (Invite code gating for Instant Assistant) > **Created**: 2026-04-01 ## Overview @@ -33,23 +32,23 @@ Expand the invite code system so that codes can be redeemed a configurable numbe ### Schema Changes to `InviteCode` -| Column | Type | Default | Notes | -| ------------------ | -------- | ------- | -------------------------------------------------- | -| `name` | String? | null | Optional human-readable label for the code | -| `maxRedemptions` | Int | 1 | How many times this code can be redeemed | -| `redemptionCount` | Int | 0 | How many times this code has been redeemed so far | -| `parentCodeId` | UUID? | null | FK → InviteCode.id — the code that was redeemed to generate this one | +| Column | Type | Default | Notes | +| ----------------- | ------- | ------- | -------------------------------------------------------------------- | +| `name` | String? | null | Optional human-readable label for the code | +| `maxRedemptions` | Int | 1 | How many times this code can be redeemed | +| `redemptionCount` | Int | 0 | How many times this code has been redeemed so far | +| `parentCodeId` | UUID? | null | FK → InviteCode.id — the code that was redeemed to generate this one | ### New Table: `InviteCodeRedemption` Track each individual redemption event (for auditability and the viral chain). -| Column | Type | Notes | -| -------------- | --------- | ---------------------------------------- | -| `id` | UUID | Primary key | -| `inviteCodeId` | UUID | FK → InviteCode.id (the code redeemed) | +| Column | Type | Notes | +| -------------- | --------- | -------------------------------------------------------- | +| `id` | UUID | Primary key | +| `inviteCodeId` | UUID | FK → InviteCode.id (the code redeemed) | | `childCodeId` | UUID? | FK → InviteCode.id (the code generated for the redeemer) | -| `redeemedAt` | Timestamp | When this redemption occurred | +| `redeemedAt` | Timestamp | When this redemption occurred | ### Migration Strategy @@ -109,11 +108,13 @@ model InviteCodeRedemption { ### 2a. `POST /api/v2/invite-codes/redeem` — Updated **Request body** (unchanged): + ```json { "code": "XKQBWFMR" } ``` **Success response** (`200`) — **updated to include generated code**: + ```json { "success": true, @@ -130,6 +131,7 @@ model InviteCodeRedemption { ``` **Logic changes:** + 1. Look up the code 2. Check `redemptionCount < maxRedemptions` (replaces the `redeemedAt == null` check) 3. Atomically increment `redemptionCount` (use `updateMany` with `where: { code, redemptionCount: { lt: maxRedemptions } }` to prevent races) @@ -139,12 +141,12 @@ model InviteCodeRedemption { **Error responses** (unchanged error codes for backwards compatibility): -| HTTP status | Error code | Meaning | -| ----------- | ----------------------- | ------------------------------------------------------ | -| 404 | `CODE_NOT_FOUND` | No code exists with that value | -| 409 | `CODE_ALREADY_REDEEMED` | Code exists but has reached its max redemptions | -| 422 | `CODE_INVALID_FORMAT` | Malformed code string | -| 401 | — | Invalid or missing JWT | +| HTTP status | Error code | Meaning | +| ----------- | ----------------------- | ----------------------------------------------- | +| 404 | `CODE_NOT_FOUND` | No code exists with that value | +| 409 | `CODE_ALREADY_REDEEMED` | Code exists but has reached its max redemptions | +| 422 | `CODE_INVALID_FORMAT` | Malformed code string | +| 401 | — | Invalid or missing JWT | > Note: We keep `CODE_ALREADY_REDEEMED` as the error code even though a code can now be redeemed multiple times. The meaning is "this code has already been fully redeemed" — semantically close enough, and avoids a breaking change for existing iOS clients. @@ -155,6 +157,7 @@ Check the remaining redemptions for a given invite code. **Authentication**: Requires a valid JWT (same as redeem). **Response** (`200`): + ```json { "success": true, @@ -170,15 +173,16 @@ Check the remaining redemptions for a given invite code. **Error responses:** -| HTTP status | Error code | Meaning | -| ----------- | ---------------- | ------------------------------------- | -| 404 | `CODE_NOT_FOUND` | No code exists with that value | -| 422 | `CODE_INVALID_FORMAT` | Malformed code string | -| 401 | — | Invalid or missing JWT | +| HTTP status | Error code | Meaning | +| ----------- | --------------------- | ------------------------------ | +| 404 | `CODE_NOT_FOUND` | No code exists with that value | +| 422 | `CODE_INVALID_FORMAT` | Malformed code string | +| 401 | — | Invalid or missing JWT | ### 2c. `POST /api/v2/invite-codes/admin/generate` — Updated **Request body** — add optional fields: + ```json { "count": 10, @@ -188,12 +192,12 @@ Check the remaining redemptions for a given invite code. } ``` -| Field | Type | Default | Notes | -| ---------------- | ------- | ------- | ------------------------------------------- | -| `count` | Int | — | Required, 1–500 | -| `batchLabel` | String? | null | Optional batch label | -| `name` | String? | null | Optional name applied to all generated codes| -| `maxRedemptions` | Int? | 1 | Max redemptions for each generated code | +| Field | Type | Default | Notes | +| ---------------- | ------- | ------- | -------------------------------------------- | +| `count` | Int | — | Required, 1–500 | +| `batchLabel` | String? | null | Optional batch label | +| `name` | String? | null | Optional name applied to all generated codes | +| `maxRedemptions` | Int? | 1 | Max redemptions for each generated code | ### 2d. `GET /api/v2/invite-codes/admin/codes` — Updated @@ -215,6 +219,7 @@ Add new fields to the response objects: ``` **Status values** — the list endpoint returns **both** old and new status representations for backwards compatibility: + - `status`: keeps the original values `"pending"` / `"redeemed"` (derived: `redeemed` if `redemptionCount >= maxRedemptions`, `pending` otherwise) - `redeemedAt`: kept — set to the most recent redemption timestamp (or `null`) - New additive fields: `maxRedemptions`, `redemptionCount`, `remainingRedemptions`, `name`, `parentCode` @@ -243,18 +248,18 @@ Update the admin HTML page (`admin-page.ts`) to: ## 5. File-by-File Change List -| File | Change | -|------|--------| -| `prisma/schema.prisma` | Add `name`, `maxRedemptions`, `redemptionCount`, `parentCodeId` to `InviteCode`; keep `redeemedAt`; add `InviteCodeRedemption` model | -| `prisma/migrations/2026XXXX_multi_use_invite_codes/migration.sql` | New migration: alter `InviteCode` (add columns), create `InviteCodeRedemption`, backfill `redemptionCount` from existing `redeemedAt` | -| `src/api/v2/invite-codes/handlers/redeem.ts` | Rewrite redemption logic: check `redemptionCount < maxRedemptions`, atomic increment, generate child code, create redemption row, return child code | -| `src/api/v2/invite-codes/handlers/status.ts` | **New file** — handler for `GET /:code/status` | -| `src/api/v2/invite-codes/handlers/generate.ts` | Accept `name` and `maxRedemptions` in body schema; pass to `createMany` | -| `src/api/v2/invite-codes/handlers/list.ts` | Add new additive fields to response; keep existing `status`/`redeemedAt` fields for compat | -| `src/api/v2/invite-codes/handlers/admin-page.ts` | Update HTML to show new columns, new filter options, new generate form fields | -| `src/api/v2/invite-codes/invite-codes.router.ts` | Add `GET /:code/status` route | -| `src/api/v2/index.ts` | No changes needed (router already mounted) | -| `tests/invite-codes.test.ts` | Update existing tests, add tests for: multi-use redemption, child code generation, status endpoint, exhausted codes | +| File | Change | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `prisma/schema.prisma` | Add `name`, `maxRedemptions`, `redemptionCount`, `parentCodeId` to `InviteCode`; keep `redeemedAt`; add `InviteCodeRedemption` model | +| `prisma/migrations/2026XXXX_multi_use_invite_codes/migration.sql` | New migration: alter `InviteCode` (add columns), create `InviteCodeRedemption`, backfill `redemptionCount` from existing `redeemedAt` | +| `src/api/v2/invite-codes/handlers/redeem.ts` | Rewrite redemption logic: check `redemptionCount < maxRedemptions`, atomic increment, generate child code, create redemption row, return child code | +| `src/api/v2/invite-codes/handlers/status.ts` | **New file** — handler for `GET /:code/status` | +| `src/api/v2/invite-codes/handlers/generate.ts` | Accept `name` and `maxRedemptions` in body schema; pass to `createMany` | +| `src/api/v2/invite-codes/handlers/list.ts` | Add new additive fields to response; keep existing `status`/`redeemedAt` fields for compat | +| `src/api/v2/invite-codes/handlers/admin-page.ts` | Update HTML to show new columns, new filter options, new generate form fields | +| `src/api/v2/invite-codes/invite-codes.router.ts` | Add `GET /:code/status` route | +| `src/api/v2/index.ts` | No changes needed (router already mounted) | +| `tests/invite-codes.test.ts` | Update existing tests, add tests for: multi-use redemption, child code generation, status endpoint, exhausted codes | --- @@ -283,10 +288,10 @@ Update the admin HTML page (`admin-page.ts`) to: ## 8. Risks & Mitigations -| Risk | Impact | Mitigation | -|------|--------|------------| -| Race condition on `redemptionCount` increment | High | Use atomic `updateMany` with `where: { redemptionCount: { lt: maxRedemptions } }` — same pattern as current `redeemedAt: null` check | -| Migration on existing data | Medium | Backfill `redemptionCount` from `redeemedAt`; keep `redeemedAt` column; run in transaction | -| ~~Breaking change for iOS~~ | ~~Medium~~ | **Resolved**: keeping `CODE_ALREADY_REDEEMED` error code and `pending`/`redeemed` status values; all new fields are additive | -| Child code generation failure during redemption | Low | Wrap redemption + child creation in a transaction; roll back both on failure | -| Unbounded viral chain depth | Low | Not a concern at 5 uses per child; monitor via `parentCodeId` lineage if needed | +| Risk | Impact | Mitigation | +| ----------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Race condition on `redemptionCount` increment | High | Use atomic `updateMany` with `where: { redemptionCount: { lt: maxRedemptions } }` — same pattern as current `redeemedAt: null` check | +| Migration on existing data | Medium | Backfill `redemptionCount` from `redeemedAt`; keep `redeemedAt` column; run in transaction | +| ~~Breaking change for iOS~~ | ~~Medium~~ | **Resolved**: keeping `CODE_ALREADY_REDEEMED` error code and `pending`/`redeemed` status values; all new fields are additive | +| Child code generation failure during redemption | Low | Wrap redemption + child creation in a transaction; roll back both on failure | +| Unbounded viral chain depth | Low | Not a concern at 5 uses per child; monitor via `parentCodeId` lineage if needed | diff --git a/src/api/v2/agents/handlers/join.ts b/src/api/v2/agents/handlers/join.ts index 59a124f3..2b38e04c 100644 --- a/src/api/v2/agents/handlers/join.ts +++ b/src/api/v2/agents/handlers/join.ts @@ -1,6 +1,12 @@ import type { Request, Response } from "express"; import { z } from "zod"; -import { AGENT_POOL_API_KEY, AGENT_POOL_URL, XMTP_ENV } from "@/config"; +import { + AGENT_POOL_API_KEY, + AGENT_POOL_URL, + shouldUseDevBehavior, + XMTP_ENV, + type XmtpEnv, +} from "@/config"; const bodySchema = z.object({ slug: z.string().min(1, "Slug is required").max(2048), @@ -27,12 +33,25 @@ const ERRORS = { }, } as const; -function buildInviteUrl(slug: string): string { - const domain = - XMTP_ENV === "production" ? "popup.convos.org" : "dev.convos.org"; +export function buildInviteUrl( + slug: string, + xmtpEnv: XmtpEnv = XMTP_ENV, +): string { + const domainByEnv: Record< + Extract, + string + > = { + production: "popup.convos.org", + testnet: "testnet.convos.org", + }; + const domain = xmtpEnv === "dev" ? "dev.convos.org" : domainByEnv[xmtpEnv]; return `https://${domain}/v2?i=${encodeURIComponent(slug)}`; } +export function shouldAllowForcedErrors(xmtpEnv: XmtpEnv = XMTP_ENV): boolean { + return shouldUseDevBehavior(xmtpEnv); +} + /** * Handler for POST /api/v2/agents/join * @@ -64,8 +83,9 @@ function buildInviteUrl(slug: string): string { */ export async function joinHandler(req: Request, res: Response) { // Force error responses for testing (non-production XMTP env only) — see JSDoc above for usage - const forceError = - XMTP_ENV !== "production" ? req.headers["x-force-error"] : undefined; + const forceError = shouldAllowForcedErrors() + ? req.headers["x-force-error"] + : undefined; const forcedError = Object.values(ERRORS).find( (e) => String(e.status) === forceError, ); diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index 403e57af..350a467b 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import { shouldUseDevBehavior, XMTP_ENV } from "@/config"; import { agentApiKeyAuth, authOrAgentApiKeyAuth } from "@/middleware/agentAuth"; import { appCheckOnlyMiddleware, @@ -38,7 +39,7 @@ import { webhookRouter } from "./notifications/webhook.router"; const v2Router = Router(); -if (process.env.XMTP_ENV !== "production") { +if (shouldUseDevBehavior(XMTP_ENV)) { v2Router.use("/dev", devAuthMiddleware, devRouter); } diff --git a/src/api/v2/invite-codes/handlers/list.ts b/src/api/v2/invite-codes/handlers/list.ts index fc4982b9..f4970518 100644 --- a/src/api/v2/invite-codes/handlers/list.ts +++ b/src/api/v2/invite-codes/handlers/list.ts @@ -1,5 +1,5 @@ -import type { Request, Response } from "express"; import { Prisma } from "@prisma/client"; +import type { Request, Response } from "express"; import { z } from "zod"; import { prisma } from "@/utils/prisma"; @@ -44,13 +44,9 @@ export async function listHandler(req: Request, res: Response) { const conditions: Prisma.Sql[] = []; if (status === "pending") { - conditions.push( - Prisma.sql`"redemptionCount" < "maxRedemptions"`, - ); + conditions.push(Prisma.sql`"redemptionCount" < "maxRedemptions"`); } else if (status === "redeemed") { - conditions.push( - Prisma.sql`"redemptionCount" >= "maxRedemptions"`, - ); + conditions.push(Prisma.sql`"redemptionCount" >= "maxRedemptions"`); } if (batchLabel !== undefined) { @@ -118,7 +114,7 @@ export async function listHandler(req: Request, res: Response) { redemptionCount: c.redemptionCount, remainingRedemptions: c.maxRedemptions - c.redemptionCount, parentCode: c.parentCodeId - ? parentCodeMap.get(c.parentCodeId) ?? null + ? (parentCodeMap.get(c.parentCodeId) ?? null) : null, })), total, diff --git a/src/api/v2/notifications/handlers/webhook.ts b/src/api/v2/notifications/handlers/webhook.ts index c48ae639..4b5bedc6 100644 --- a/src/api/v2/notifications/handlers/webhook.ts +++ b/src/api/v2/notifications/handlers/webhook.ts @@ -3,6 +3,7 @@ import type { Request, Response } from "express"; import { createApnsService } from "@/api/v2/notifications/apns-push.service"; import { createFcmService } from "@/api/v2/notifications/fcm-push.service"; import type { V2NotificationPayload } from "@/api/v2/notifications/types"; +import { isXmtpProduction, XMTP_ENV } from "@/config"; import { createNotificationClient, webhookNotificationBodySchema, @@ -263,10 +264,7 @@ async function handleV2Notification(args: { }); // Auto-disable in XMTP production only to preserve test devices in dev/staging for debugging - if ( - process.env.XMTP_ENV === "production" && - u.pushFailures >= MAX_PUSH_FAILURES - ) { + if (isXmtpProduction(XMTP_ENV) && u.pushFailures >= MAX_PUSH_FAILURES) { await tx.deviceRegistration.updateMany({ where: { deviceId: client.deviceId, diff --git a/src/config.ts b/src/config.ts index 3c1e4c56..6e5b93bb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,4 +35,29 @@ export const AGENT_POOL_API_KEY = process.env.AGENT_POOL_API_KEY || ""; // Agent asset upload auth (optional — endpoint returns 503 if not configured) export const AGENT_ASSETS_API_KEY = process.env.AGENT_ASSETS_API_KEY || ""; -export const XMTP_ENV = process.env.XMTP_ENV || "dev"; +export const VALID_XMTP_ENVS = ["production", "testnet", "dev"] as const; +export type XmtpEnv = (typeof VALID_XMTP_ENVS)[number]; + +function isValidXmtpEnv(value: string): value is XmtpEnv { + return (VALID_XMTP_ENVS as readonly string[]).includes(value); +} + +export function parseXmtpEnv(value = process.env.XMTP_ENV || "dev"): XmtpEnv { + if (!isValidXmtpEnv(value)) { + throw new Error( + `Invalid XMTP_ENV: ${value}. Must be one of: ${VALID_XMTP_ENVS.join(", ")}`, + ); + } + + return value; +} + +export const XMTP_ENV = parseXmtpEnv(); + +export function isXmtpProduction(xmtpEnv: XmtpEnv = XMTP_ENV): boolean { + return xmtpEnv === "production"; +} + +export function shouldUseDevBehavior(xmtpEnv: XmtpEnv = XMTP_ENV): boolean { + return !isXmtpProduction(xmtpEnv); +} diff --git a/tests/agent-join.test.ts b/tests/agent-join.test.ts new file mode 100644 index 00000000..5711cd50 --- /dev/null +++ b/tests/agent-join.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { + buildInviteUrl, + shouldAllowForcedErrors, +} from "@/api/v2/agents/handlers/join"; + +describe("buildInviteUrl", () => { + test("uses production domain for production", () => { + expect(buildInviteUrl("abc123", "production")).toBe( + "https://popup.convos.org/v2?i=abc123", + ); + }); + + test("uses testnet domain for testnet", () => { + expect(buildInviteUrl("abc123", "testnet")).toBe( + "https://testnet.convos.org/v2?i=abc123", + ); + }); + + test("uses dev domain for all other environments", () => { + expect(buildInviteUrl("abc123", "dev")).toBe( + "https://dev.convos.org/v2?i=abc123", + ); + }); + + test("encodes invite slugs", () => { + expect(buildInviteUrl("abc 123/?", "testnet")).toBe( + "https://testnet.convos.org/v2?i=abc%20123%2F%3F", + ); + }); + + test("allows forced errors in dev-like environments only", () => { + expect(shouldAllowForcedErrors("dev")).toBe(true); + expect(shouldAllowForcedErrors("testnet")).toBe(true); + expect(shouldAllowForcedErrors("production")).toBe(false); + }); +}); diff --git a/tests/config-environment.test.ts b/tests/config-environment.test.ts new file mode 100644 index 00000000..84a2959c --- /dev/null +++ b/tests/config-environment.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { isXmtpProduction, parseXmtpEnv, shouldUseDevBehavior } from "@/config"; + +describe("XMTP environment helpers", () => { + test("treats only production as production", () => { + expect(isXmtpProduction("production")).toBe(true); + expect(isXmtpProduction("dev")).toBe(false); + expect(isXmtpProduction("testnet")).toBe(false); + }); + + test("treats testnet as dev-like behavior", () => { + expect(shouldUseDevBehavior("dev")).toBe(true); + expect(shouldUseDevBehavior("testnet")).toBe(true); + expect(shouldUseDevBehavior("production")).toBe(false); + }); + + test("rejects invalid environment values", () => { + expect(() => parseXmtpEnv("local")).toThrow( + "Invalid XMTP_ENV: local. Must be one of: production, testnet, dev", + ); + }); +}); diff --git a/tests/invite-codes.test.ts b/tests/invite-codes.test.ts index a657a657..37247429 100644 --- a/tests/invite-codes.test.ts +++ b/tests/invite-codes.test.ts @@ -199,7 +199,6 @@ describe("Invite Codes API Tests", () => { }); expect(redemptions).toHaveLength(1); const firstRedemption = redemptions[0]; - if (!firstRedemption) throw new Error("Expected a redemption record"); expect(firstRedemption.childCodeId).toBe(childCode.id); }); @@ -408,9 +407,7 @@ describe("Invite Codes API Tests", () => { }); test("should return 422 for invalid code format", async () => { - const response = await fetch( - `${baseURL}/api/v2/invite-codes/bad/status`, - ); + const response = await fetch(`${baseURL}/api/v2/invite-codes/bad/status`); expect(response.status).toBe(422); const data = (await response.json()) as { error: string }; diff --git a/tests/preload.ts b/tests/preload.ts index d1be1e2e..8ac7e700 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -6,7 +6,7 @@ process.env.LOG_FORMAT = "json"; // Set required environment variables for tests process.env.PUBLIC_ASSETS_BUCKET = "test-public-assets-bucket"; process.env.FIREBASE_SERVICE_ACCOUNT = "{}"; -process.env.XMTP_ENV = "local"; +process.env.XMTP_ENV = "dev"; process.env.NOTIFICATION_SERVER_URL = "http://localhost:8080"; // Only set default if not already set (CI uses GitHub secrets) process.env.XMTP_NOTIFICATION_SECRET =