Skip to content

✨ server: link whatsapp id - #1157

Draft
mainqueg wants to merge 32 commits into
infra-wipfrom
link
Draft

✨ server: link whatsapp id#1157
mainqueg wants to merge 32 commits into
infra-wipfrom
link

Conversation

@mainqueg

@mainqueg mainqueg commented Jul 17, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features
    • Added authenticated chat association endpoints to check chat ID availability, request a 6-digit verification code, and confirm the association using an encrypted token.
    • Introduced WA-ID persistence with conflict handling to allow reassignment and clearing prior links.
  • Security & Validation
    • Enforced strict token validation (audience/issuer, expiry, claims) and per-WA-ID cooldown for code requests.
  • Tests
    • Added integration coverage for the chat association flow and token encryption/decryption behavior.
  • Release
    • Patch release notes recorded for the server package’s new chat association capability.

@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 31c6b9e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@exactly/server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds encrypted chat identity utilities and authenticated chat association endpoints. Credentials store unique WhatsApp IDs, Redis holds verification state and cooldowns, and integration tests cover token validation, code verification, conflicts, and authentication.

Changes

Chat association

Layer / File(s) Summary
Chat identity tokens and runtime setup
server/utils/chat.ts, server/test/utils/chat.test.ts, server/vitest.config.mts, server/script/openapi.ts
Adds encrypted JWT encoding and decoding, environment-key validation, placeholder code delivery, and comprehensive token tests.
Credential association routes
server/database/schema.ts, server/api/chat.ts, server/api/index.ts, server/test/api/chat.test.ts
Adds unique credential WA IDs and authenticated endpoints for availability checks, verification-code delivery, cooldowns, and association replacement.
Chat association release metadata
.changeset/warm-otters-associate.md
Records a patch release for the server package and the chat association endpoint.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ChatAPI
  participant Redis
  participant Database
  Client->>ChatAPI: Submit encrypted chat token
  ChatAPI->>Redis: Store verification code and cooldown
  ChatAPI->>Database: Associate credential waId
  ChatAPI-->>Client: Return status or waId
Loading

Possibly related PRs

  • exactly/exa#896: Changes uniqueness constraints on the credentials table, overlapping with this PR’s new wa_id unique index.

Suggested reviewers: nfmelendez

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately describes the main change: linking a WhatsApp ID on the server.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch link
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch link
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch link

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the ability to link a WhatsApp ID (waId) to a user's credential during registration. It adds a unique waId column to the credentials database table, validates the encrypted Wa-Id header in the registration endpoints, and implements JWT token utilities for encoding and decoding the WhatsApp ID. The feedback suggests adding a unit test to verify that attempting to register a duplicate waId is properly rejected.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread server/test/api/auth.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 79eeb584-b2b5-4ed4-8af5-0d523fd12c66

📥 Commits

Reviewing files that changed from the base of the PR and between 76a9dc4 and 3c4cfe1.

📒 Files selected for processing (10)
  • .changeset/quick-otters-link.md
  • server/api/auth/registration.ts
  • server/database/schema.ts
  • server/script/openapi.ts
  • server/test/api/auth.test.ts
  • server/test/utils/token.test.ts
  • server/utils/chatSecret.ts
  • server/utils/createCredential.ts
  • server/utils/token.ts
  • server/vitest.config.mts

Comment thread server/utils/chatSecret.ts Outdated
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
1284 1 1283 1
View the top 1 failed test(s) by shortest run time
test/e2e.ts > e2e > runs server
Stack Traces | 564s run time
AssertionError: expected [ { status: 'fulfilled', …(1) }, …(5) ] to be null

- Expected:
null

+ Received:
[
  {
    "status": "fulfilled",
    "value": null,
  },
  {
    "status": "fulfilled",
    "value": undefined,
  },
  {
    "status": "fulfilled",
    "value": undefined,
  },
  {
    "status": "fulfilled",
    "value": undefined,
  },
  {
    "status": "fulfilled",
    "value": undefined,
  },
  {
    "status": "fulfilled",
    "value": undefined,
  },
]

 ❯ test/e2e.ts:61:7

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@mainqueg
mainqueg changed the base branch from main to infra July 23, 2026 17:30

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1fe655b3-83e2-4674-b97b-c0e26e240c08

📥 Commits

Reviewing files that changed from the base of the PR and between 3c4cfe1 and 1354e98.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (100)
  • .changeset/afraid-mangos-follow.md
  • .changeset/blue-bottles-wave.md
  • .changeset/bumpy-regions-read.md
  • .changeset/bumpy-toes-jump.md
  • .changeset/chubby-papayas-visit.md
  • .changeset/free-lamps-pump.md
  • .changeset/khaki-pugs-heal.md
  • .changeset/large-candies-brake.md
  • .changeset/plain-pumas-relax.md
  • .changeset/proud-tools-sneeze.md
  • .changeset/short-radios-stand.md
  • .changeset/spicy-ideas-mix.md
  • .changeset/ten-planets-prove.md
  • .changeset/tender-foxes-feel.md
  • .changeset/version.sh
  • .changeset/warm-otters-associate.md
  • .github/workflows/server-base-sepolia.yaml
  • .github/workflows/server-base.yaml
  • .github/workflows/server-build.yaml
  • .github/workflows/server-deploy.yaml
  • .github/workflows/server-production.yaml
  • .github/workflows/server-sandbox.yaml
  • .github/workflows/substreams-build.yaml
  • .github/workflows/test.yaml
  • .github/workflows/version.yaml
  • AGENTS.md
  • common/stack.js
  • cspell.json
  • infra/Pulumi.base-sepolia.yaml
  • infra/Pulumi.meta.yaml
  • infra/Pulumi.yaml
  • infra/eslint.config.mjs
  • infra/index.ts
  • infra/meta.ts
  • infra/package.json
  • infra/tsconfig.json
  • infra/utils/rejectSecrets.ts
  • pnpm-workspace.yaml
  • server/Dockerfile
  • server/api/card.ts
  • server/api/chat.ts
  • server/api/index.ts
  • server/database/schema.ts
  • server/hooks/activity.ts
  • server/hooks/block.ts
  • server/hooks/panda.ts
  • server/hooks/persona.ts
  • server/i18n/es.json
  • server/i18n/pt.json
  • server/index.ts
  • server/instrument.cjs
  • server/package.json
  • server/script/openapi.ts
  • server/test/api/auth.test.ts
  • server/test/api/card.test.ts
  • server/test/api/chat.test.ts
  • server/test/e2e.ts
  • server/test/hooks/activity.test.ts
  • server/test/hooks/block.test.ts
  • server/test/hooks/panda.test.ts
  • server/test/hooks/persona.test.ts
  • server/test/mocks/alchemy.ts
  • server/test/mocks/keeper.ts
  • server/test/mocks/sentry.ts
  • server/test/mocks/wallet.ts
  • server/test/utils/alchemy.test.ts
  • server/test/utils/chat.test.ts
  • server/test/utils/createCredential.test.ts
  • server/test/utils/secret.test.ts
  • server/test/utils/wallet.test.ts
  • server/test/workers/allow.test.ts
  • server/test/workers/credit.test.ts
  • server/test/workers/poke.test.ts
  • server/test/workers/refund.test.ts
  • server/test/workers/subscribe.test.ts
  • server/tsconfig.json
  • server/utils/activityWebhook.ts
  • server/utils/alchemy.ts
  • server/utils/chat.ts
  • server/utils/createCredential.ts
  • server/utils/onesignal.ts
  • server/utils/panda.ts
  • server/utils/secret.ts
  • server/utils/wallet.ts
  • server/vitest.config.mts
  • server/workers/allow/job.ts
  • server/workers/allow/queue.ts
  • server/workers/allow/worker.ts
  • server/workers/credit/job.ts
  • server/workers/credit/queue.ts
  • server/workers/credit/worker.ts
  • server/workers/poke/job.ts
  • server/workers/poke/queue.ts
  • server/workers/poke/worker.ts
  • server/workers/refund/job.ts
  • server/workers/refund/queue.ts
  • server/workers/refund/worker.ts
  • server/workers/subscribe/job.ts
  • server/workers/subscribe/queue.ts
  • server/workers/subscribe/worker.ts
💤 Files with no reviewable changes (3)
  • .github/workflows/test.yaml
  • server/test/mocks/keeper.ts
  • server/utils/panda.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 13


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1fe655b3-83e2-4674-b97b-c0e26e240c08

📥 Commits

Reviewing files that changed from the base of the PR and between 3c4cfe1 and 1354e98.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (100)
  • .changeset/afraid-mangos-follow.md
  • .changeset/blue-bottles-wave.md
  • .changeset/bumpy-regions-read.md
  • .changeset/bumpy-toes-jump.md
  • .changeset/chubby-papayas-visit.md
  • .changeset/free-lamps-pump.md
  • .changeset/khaki-pugs-heal.md
  • .changeset/large-candies-brake.md
  • .changeset/plain-pumas-relax.md
  • .changeset/proud-tools-sneeze.md
  • .changeset/short-radios-stand.md
  • .changeset/spicy-ideas-mix.md
  • .changeset/ten-planets-prove.md
  • .changeset/tender-foxes-feel.md
  • .changeset/version.sh
  • .changeset/warm-otters-associate.md
  • .github/workflows/server-base-sepolia.yaml
  • .github/workflows/server-base.yaml
  • .github/workflows/server-build.yaml
  • .github/workflows/server-deploy.yaml
  • .github/workflows/server-production.yaml
  • .github/workflows/server-sandbox.yaml
  • .github/workflows/substreams-build.yaml
  • .github/workflows/test.yaml
  • .github/workflows/version.yaml
  • AGENTS.md
  • common/stack.js
  • cspell.json
  • infra/Pulumi.base-sepolia.yaml
  • infra/Pulumi.meta.yaml
  • infra/Pulumi.yaml
  • infra/eslint.config.mjs
  • infra/index.ts
  • infra/meta.ts
  • infra/package.json
  • infra/tsconfig.json
  • infra/utils/rejectSecrets.ts
  • pnpm-workspace.yaml
  • server/Dockerfile
  • server/api/card.ts
  • server/api/chat.ts
  • server/api/index.ts
  • server/database/schema.ts
  • server/hooks/activity.ts
  • server/hooks/block.ts
  • server/hooks/panda.ts
  • server/hooks/persona.ts
  • server/i18n/es.json
  • server/i18n/pt.json
  • server/index.ts
  • server/instrument.cjs
  • server/package.json
  • server/script/openapi.ts
  • server/test/api/auth.test.ts
  • server/test/api/card.test.ts
  • server/test/api/chat.test.ts
  • server/test/e2e.ts
  • server/test/hooks/activity.test.ts
  • server/test/hooks/block.test.ts
  • server/test/hooks/panda.test.ts
  • server/test/hooks/persona.test.ts
  • server/test/mocks/alchemy.ts
  • server/test/mocks/keeper.ts
  • server/test/mocks/sentry.ts
  • server/test/mocks/wallet.ts
  • server/test/utils/alchemy.test.ts
  • server/test/utils/chat.test.ts
  • server/test/utils/createCredential.test.ts
  • server/test/utils/secret.test.ts
  • server/test/utils/wallet.test.ts
  • server/test/workers/allow.test.ts
  • server/test/workers/credit.test.ts
  • server/test/workers/poke.test.ts
  • server/test/workers/refund.test.ts
  • server/test/workers/subscribe.test.ts
  • server/tsconfig.json
  • server/utils/activityWebhook.ts
  • server/utils/alchemy.ts
  • server/utils/chat.ts
  • server/utils/createCredential.ts
  • server/utils/onesignal.ts
  • server/utils/panda.ts
  • server/utils/secret.ts
  • server/utils/wallet.ts
  • server/vitest.config.mts
  • server/workers/allow/job.ts
  • server/workers/allow/queue.ts
  • server/workers/allow/worker.ts
  • server/workers/credit/job.ts
  • server/workers/credit/queue.ts
  • server/workers/credit/worker.ts
  • server/workers/poke/job.ts
  • server/workers/poke/queue.ts
  • server/workers/poke/worker.ts
  • server/workers/refund/job.ts
  • server/workers/refund/queue.ts
  • server/workers/refund/worker.ts
  • server/workers/subscribe/job.ts
  • server/workers/subscribe/queue.ts
  • server/workers/subscribe/worker.ts
💤 Files with no reviewable changes (3)
  • .github/workflows/test.yaml
  • server/test/mocks/keeper.ts
  • server/utils/panda.ts
🛑 Comments failed to post (13)
.github/workflows/server-base-sepolia.yaml (1)

5-7: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow metadata and permissions =="
if [ -f .github/workflows/server-base-sepolia.yaml ]; then
  nl -ba .github/workflows/server-base-sepolia.yaml | sed -n '1,120p'
else
  echo "missing .github/workflows/server-base-sepolia.yaml"
fi

echo
echo "== reusable workflow permissions =="
if [ -f .github/workflows/server-build.yaml ]; then
  nl -ba .github/workflows/server-build.yaml | sed -n '1,160p'
else
  echo "missing .github/workflows/server-build.yaml"
fi

echo
echo "== all server workflow files mentioning server-build or permissions =="
rg -n "uses: \./\.github/workflows/server-build\.yaml|permissions:|id-token|packages|contents|attest" .github/workflows/server-*.yaml .github/workflows 2>/dev/null || true

echo
echo "== parse YAML workflow structure (minimal, read-only) =="
python3 - <<'PY'
from pathlib import Path
import re

paths = [Path(".github/workflows/server-base-sepolia.yaml"), Path(".github/workflows/server-build.yaml")]
for path in paths:
    print(f"--- {path} ---")
    if not path.exists():
        print("MISSING")
        continue
    text = path.read_text()
    lines = text.splitlines()
    root_job = None
    current_job = None
    is_in_permissions = False
    for i,l in enumerate(lines, 1):
        # crude but sufficient to collect first top-level run/workflow-level uses? no. 
        if re.match(r'\b(\w+(?:\.\w+)?)\s*=|^\s*permissions\s*:', l):
            is_in_permissions = bool(re.match(r'\s*permissions\s*:', l))
        # collect jobs and callable workflow uses
        if re.match(r'^\s{2,4}(\w+)\s*:', l):
            pass
        if re.match(r'\s{2}-?\s*uses:\s*\./\.github/workflows/server-build\.yaml', l):
            print("call at line", i, l.strip())
            # show permissions block in same job preceding lines
            for j in range(max(1,i-12), i):
                if re.match(r'^\s{2}permissions\s*:', lines[j-1]):
                    print("precede permissions snippet:")
                    for k in range(j, min(i, j+8)+1):
                        print(f"{k}: {lines[k-1]}")
                    break
    # collect explicit permission lines
    for i,l in enumerate(lines, 1):
        if re.match(r'\s*permissions\s*:', l):
            print("permission line at", i, l.strip())
PY

Repository: exactly/exa

Length of output: 229


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow metadata and permissions =="
if [ -f .github/workflows/server-base-sepolia.yaml ]; then
  awk '{printf "%6d  %s\n", NR, $0}' .github/workflows/server-base-sepolia.yaml | sed -n '1,140p'
else
  echo "missing .github/workflows/server-base-sepolia.yaml"
fi

echo
echo "== reusable workflow permissions =="
if [ -f .github/workflows/server-build.yaml ]; then
  awk '{printf "%6d  %s\n", NR, $0}' .github/workflows/server-build.yaml | sed -n '1,200p'
else
  echo "missing .github/workflows/server-build.yaml"
fi

echo
echo "== all server workflow files mentioning server-build or permissions =="
rg -n "uses: \./\.github/workflows/server-build\.yaml|permissions:|id-token|packages|contents|attest" .github/workflows/server-*.yaml .github/workflows 2>/dev/null || true

echo
echo "== parser/check permissions around reusable calls (read-only) =="
python3 - <<'PY'
from pathlib import Path
import re

paths = [Path(".github/workflows/server-base-sepolia.yaml"), Path(".github/workflows/server-build.yaml")]
for path in paths:
    print(f"--- {path} ---")
    if not path.exists():
        print("MISSING")
        continue
    text = path.read_text()
    lines = text.splitlines()
    in_permissions = None
    for i,l in enumerate(lines, 1):
        if re.match(r'\s*permissions\s*:', l):
            in_permissions = i
        elif re.search(r'^(\s{2,6}\w|\s+permissions)\s*:\s*\S', l):
            in_permissions = None
        if re.search(r'\s{6,}-?\s*uses:\s*\./\.github/workflows/server-build\.yaml', l):
            print("callable call at line", i, l.strip())
            if in_permissions:
                print("preceding permissions at", in_permissions, lines[in_permissions-1].strip())
                for j in range(max(1, in_permissions), min(i, in_permissions+12)+1):
                    print(f"{j}: {lines[j-1]}")
            else:
                print("no preceding permission line in parsed scope before call")
    print("permission line occurrences:")
    for i,l in enumerate(lines, 1):
        if re.search(r'^\s*permissions\s*:', l):
            print(i, l.strip())
PY

Repository: exactly/exa

Length of output: 5397


🌐 Web query:

GitHub Actions reusable worklows called workflow call default permissions reusable workflow permissions

💡 Result:

When using GitHub Actions reusable workflows, the permissions for the GITHUB_TOKEN are governed by the caller's job configuration [1]. If you do not explicitly define a permissions block for the job that calls a reusable workflow (using jobs.<job_id>.permissions), the called reusable workflow will inherit the default GITHUB_TOKEN permissions [1]. These default permissions are typically set at the repository or organization level and grant a broad set of permissions [2][3]. Key rules regarding reusable workflow permissions include: 1. Inheritance and Constraints: A called workflow receives permissions based on the context of the caller job [1][4]. These permissions can only be maintained or downgraded; they cannot be elevated in the called workflow [1][5]. For example, if a caller workflow only grants read access to contents, a nested or called workflow cannot be granted write access to contents [1]. 2. Validation: GitHub validates the caller job's declared permissions against the requirements of the called reusable workflow [6]. If the called workflow (or its nested jobs) requires specific permissions and the caller job does not explicitly grant at least those permissions—or if the caller job defaults are insufficient—the workflow may fail validation before it begins [6]. 3. Best Practice: To ensure security and predictable behavior, it is highly recommended to explicitly define the permissions at the job level within the caller workflow [4]. This allows you to follow the principle of least privilege by granting only the minimum access required for the reusable workflow to function [2][3][4]. For further details on configuring these settings, refer to the official GitHub documentation on reusing workflow configurations [1] and workflow syntax [3].

Citations:


Set explicit job-level permissions for the reusable-workflow calls.

server-base-sepolia inherits repository-default token scopes for both build and deploy, while server-build expects write scopes for packages/attestations/ID token and server-deploy needs ID-token write. Declare the minimal permissions required by each called job (for example, contents: read, packages: write, attestations: write, id-token: write for the build job; contents: read, id-token: write for deploy) instead of relying on repository defaults.

🧰 Tools
🪛 zizmor (1.26.1)

[warning] 5-7: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

Source: Linters/SAST tools

.github/workflows/server-base.yaml (1)

10-12: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Replace secrets: inherit with the declared secrets only. The reusable deploy workflow only needs DIGITALOCEAN_TOKEN and, for production Sentry publishing, SENTRY_AUTH_TOKEN; inheriting every caller secret violates least privilege.

  • .github/workflows/server-base.yaml#L10-L12: pass only DIGITALOCEAN_TOKEN.
  • .github/workflows/server-production.yaml#L10-L12: pass DIGITALOCEAN_TOKEN and SENTRY_AUTH_TOKEN.
  • .github/workflows/server-sandbox.yaml#L10-L12: pass only DIGITALOCEAN_TOKEN.
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 10-10: secrets unconditionally inherited by called workflow (secrets-inherit): this reusable workflow

(secrets-inherit)

📍 Affects 3 files
  • .github/workflows/server-base.yaml#L10-L12 (this comment)
  • .github/workflows/server-production.yaml#L10-L12
  • .github/workflows/server-sandbox.yaml#L10-L12

Source: Linters/SAST tools

.github/workflows/server-deploy.yaml (2)

22-22: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Workflow files:"
git ls-files .github/workflows || true

echo
echo "Relevant action uses in workflows:"
if [ -d .github/workflows ]; then
  for f in .github/workflows/*.yaml .github/workflows/*.yml; do
    [ -f "$f" ] || continue
    echo "-- $f"
    rg -n "uses:\s*[^#]+" "$f" || true
  done
fi

echo
echo "server-deploy relevant lines:"
if [ -f .github/workflows/server-deploy.yaml ]; then
  nl -ba .github/workflows/server-deploy.yaml | sed -n '1,110p'
fi

echo
echo "server-build relevant lines:"
if [ -f .github/workflows/server-build.yaml ]; then
  nl -ba .github/workflows/server-build.yaml | sed -n '1,80p'
fi

Repository: exactly/exa

Length of output: 4178


🌐 Web query:

GitHub Actions security hardening pin action to commit SHA mutable tag documentation

💡 Result:

Pinning GitHub Actions to a full-length commit SHA is a recommended security best practice to prevent supply chain attacks, such as those involving malicious updates to mutable version tags [1][2][3]. Key points regarding SHA pinning and mutable tags: Immutable Reference: Pinning an action to a full-length (40-character) commit SHA provides an immutable reference [1][4]. Unlike tags (e.g., @v1) or branch names (e.g., @main), which are mutable pointers that can be updated or deleted by maintainers or unauthorized actors, a commit SHA points to a specific, unchangeable state of the repository [1][5][6]. Security Risk of Tags: Using mutable tags leaves workflows vulnerable to "retargeting" attacks, where a compromised account or repository can redirect a tag to execute malicious code [5][6][3]. Shortened commit SHAs should never be used, as they are susceptible to collision attacks [7]. Policy Enforcement: GitHub allows administrators to enforce SHA pinning at the repository, organization, and enterprise levels [8][9][10]. When this policy is active, workflows that use mutable references like tags or branches will fail [11]. Note that this policy applies to the entire dependency tree, meaning if a primary action internally references other actions by tag, those sub-actions must also be pinned to SHAs to pass the policy check [11]. Maintenance Strategies: - Tracking Versions: To maintain the convenience of semantic versioning while utilizing the security of SHA pinning, it is common practice to append the version tag as a comment in the workflow file (e.g., uses: actions/checkout@a824008 # v4.2.1) [5][6]. - Automated Updates: Tools like Dependabot and Renovate support this "SHA pin with version comment" convention and can automatically create pull requests to update the SHA while maintaining the version comment [5][6][3]. Official documentation emphasizes that while pinning to a tag is more convenient, it should only be done if you trust the action's creator, whereas pinning to a full-length SHA is the only way to ensure an action is used as an immutable release [1][7][9].

Citations:


Pin all GitHub Actions to full-length commit SHAs. Mutable tags can be retargeted, causing workflows to run a changed action graph with access to package, OIDC, attestation, and deployment credentials.

Apply this across all action pins, including the server deploy/build workflows and the broader set of current version-tagged actions such as actions/checkout, actions/setup-node, actions/cache, Docker actions, Sentry, Codecov, and changesets.

🧰 Tools
🪛 zizmor (1.26.1)

[error] 22-22: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

📍 Affects 2 files
  • .github/workflows/server-deploy.yaml#L22-L22 (this comment)
  • .github/workflows/server-deploy.yaml#L26-L26
  • .github/workflows/server-deploy.yaml#L30-L30
  • .github/workflows/server-deploy.yaml#L32-L32
  • .github/workflows/server-deploy.yaml#L36-L36
  • .github/workflows/server-deploy.yaml#L49-L49
  • .github/workflows/server-deploy.yaml#L56-L56
  • .github/workflows/server-deploy.yaml#L59-L59
  • .github/workflows/server-deploy.yaml#L86-L86
  • .github/workflows/server-build.yaml#L16-L16

Source: Linters/SAST tools


40-40: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make deployment wait for infrastructure provisioning.

infra and digitalocean currently run concurrently. The server can deploy before pulumi up creates or updates its required infrastructure. Add needs: infra to digitalocean.

Proposed fix
   digitalocean:
+    needs: infra
     runs-on: ubuntu-latest
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  digitalocean:
    needs: infra
🧰 Tools
🪛 zizmor (1.26.1)

[info] 40-40: workflow or action definition without a name (anonymous-definition): this job

(anonymous-definition)

.github/workflows/version.yaml (1)

28-36: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow file =="
cat -n .github/workflows/version.yaml | sed -n '1,80p'

echo
echo "== all workflow action refs in repository =="
rg -n "uses:\s*[^#]+@" .github/workflows -g '*.yml' -g '*.yaml' | sed -n '1,200p'

echo
echo "== search for pinning/security policy docs =="
rg -n -i "pin.*sha|sha|immutable|updatecli|untrusted|workflow|action|version\(y\)aml|changesets/create-github-app-token|security" README.md README .github docs SECURITY* 2>/dev/null | sed -n '1,240p'

Repository: exactly/exa

Length of output: 23862


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files named updatecli/metadata / policy-related =="
find .github . -maxdepth 4 \( -name '*update*' -o -name 'SECURITY*' -o -name '*secret*' -o -name 'README.md' \) -type f -print | sort | sed -n '1,200p'

echo
echo "== targeted security/pinning text =="
rg -n -i "pin.*sha|immutable|commit sha|actionlint|untrusted|unverified|supply chain|scorecard|release.*policy|github app|create-github-app-token|changesets/action|third (party|party)|external.*action" README.md README .github docs SECURITY* Makefile .github/workflows/version.yaml 2>/dev/null | sed -n '1,240p'

echo
echo "== repo top-level files =="
git ls-files | awk -F/ '{print $1}' | sort -u | sed -n '1,120p'

Repository: exactly/exa

Length of output: 617


Pin release workflow actions to immutable commit SHAs.

This workflow reads EXA_VERSION_KEY and creates tokens with contents and pull-requests write permissions, so the mutable actions/create-github-app-token@v3 and changesets/action@v1 refs should use full 40-character commit SHAs.

🧰 Tools
🪛 actionlint (1.7.12)

[error] 28-28: missing input "app-id" which is required by action "actions/create-github-app-token@v3". all required inputs are "app-id", "private-key"

(action)


[error] 31-31: input "client-id" is not defined in action "actions/create-github-app-token@v3". available inputs are "app-id", "github-api-url", "owner", "permission-actions", "permission-administration", "permission-checks", "permission-codespaces", "permission-contents", "permission-custom-properties-for-organizations", "permission-dependabot-secrets", "permission-deployments", "permission-email-addresses", "permission-enterprise-custom-properties-for-organizations", "permission-environments", "permission-followers", "permission-git-ssh-keys", "permission-gpg-keys", "permission-interaction-limits", "permission-issues", "permission-members", "permission-metadata", "permission-organization-administration", "permission-organization-announcement-banners", "permission-organization-copilot-seat-management", "permission-organization-custom-org-roles", "permission-organization-custom-properties", "permission-organization-custom-roles", "permission-organization-events", "permission-organization-hooks", "permission-organization-packages", "permission-organization-personal-access-token-requests", "permission-organization-personal-access-tokens", "permission-organization-plan", "permission-organization-projects", "permission-organization-secrets", "permission-organization-self-hosted-runners", "permission-organization-user-blocking", "permission-packages", "permission-pages", "permission-profile", "permission-pull-requests", "permission-repository-custom-properties", "permission-repository-hooks", "permission-repository-projects", "permission-secret-scanning-alerts", "permission-secrets", "permission-security-events", "permission-single-file", "permission-starring", "permission-statuses", "permission-team-discussions", "permission-vulnerability-alerts", "permission-workflows", "private-key", "repositories", "skip-token-revoke"

(action)

🪛 zizmor (1.26.1)

[error] 28-28: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 36-36: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

Source: Linters/SAST tools

infra/index.ts (1)

58-111: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

gcp.cloudrunv2.WorkerPool launchStage required GA @pulumi/gcp 9

💡 Result:

In Pulumi's @pulumi/gcp provider (including version 9.x), the launchStage property for gcp.cloudrunv2.WorkerPool is not required [1][2]. The launchStage property is an optional field that accepts one of the following values: UNIMPLEMENTED, PRELAUNCH, EARLY_ACCESS, ALPHA, BETA, GA, or DEPRECATED [1]. If no value is specified, GA (General Availability) is assumed by default [1]. You only need to explicitly set launchStage to a preview stage (such as BETA or ALPHA) if you intend to use preview features that are not yet generally available [1]. Otherwise, omitting the field is standard practice for production-ready, GA-level resources [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package manifests =="
for f in package.json pnpm-lock.yaml package-lock.json yarn.lock; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    if [ "$f" = "package.json" ]; then cat "$f"; else rg -n '"`@pulumi/gcp`"|`@pulumi/gcp`|gcp:' "$f" | head -40 || true; fi
  fi
done

echo "== infra WorkerPool occurrences =="
if [ -f infra/index.ts ]; then
  ast-grep outline infra/index.ts || true
  sed -n '1,180p' infra/index.ts | cat -n
else
  fd -a 'index\.ts$' . | rg '/infra/index\.ts$|infra/' || true
fi

echo "== available npm package docs snippets =="
npm view `@pulumi/gcp`@9.28.0 version dist.tarball dist.shasum --json 2>/dev/null || true
npm view `@pulumi/gcp`@9.3.0 version dist.tarball dist.shasum --json 2>/dev/null || true

python3 - <<'PY'
import urllib.request, urllib.error, re, json, tarfile, io
for version in ["9.3.0", "9.28.0"]:
    try:
        info = json.loads(urllib.request.urlopen(f"https://registry.npmjs.org/@pulumi%2fgcp", timeout=20).read().decode())
        tarball = info["versions"][version]["dist"]["tarball"]
        print(f"\n== {version} tarball {tarball} ==")
        with urllib.request.urlopen(tarball, timeout=30) as resp:
            data = io.BytesIO(resp.read())
        with tarfile.open(fileobj=data, mode="r:gz") as tf:
            names = [n for n in tf.getnames() if n.startswith("cloudrunv2/") and (n.endswith(".d.ts") or n.endswith("workerPool.ts"))]
            print("files:", sorted(names)[:50])
            for name in names:
                content = tf.extractfile(name)
                if not isinstance(content, io._io.BufferedReader):
                    content = io.TextIOWrapper(content)
                text = content.read()
                if "deletionProtection" in text or "launchStage" in text or "preview" in text.lower():
                    print(f"\n--- {name} ---")
                    for i, line in enumerate(text.splitlines(), 1):
                        if "deletionProtection" in line or "launchStage" in line or "preview" in line.lower():
                            print(f"{i}: {line}")
    except Exception as e:
        print(f"ERROR {version}: {type(e).__name__}: {e}")
PY

Repository: exactly/exa

Length of output: 25949


🌐 Web query:

@pulumi/gcp 9.31.0 cloudrunv2 WorkerPool deletionProtection launchStage TypeScript types

💡 Result:

In the Pulumi GCP provider version 9.31.0, the gcp.cloudrunv2.WorkerPool resource includes explicit TypeScript types for both deletionProtection and launchStage. 1. deletionProtection: This property is a boolean (or pulumi.Input) that, when set to true, prevents the resource from being destroyed [1][2][3]. 2. launchStage: This property defines the lifecycle stage of the resource (e.g., ALPHA, BETA, GA) as per Google Cloud Platform Launch Stages [2]. It accepts a string value and defaults to GA if not specified [2]. The supported values are: UNIMPLEMENTED, PRELAUNCH, EARLY_ACCESS, ALPHA, BETA, GA, and DEPRECATED [2]. The Pulumi GCP provider is built upon the underlying Google Cloud Terraform provider [4]. In version 9.31.0, the provider has been upgraded to utilize terraform-provider-google-beta v7.40.0 [5][6]. You can access these properties directly within the WorkerPoolArgs interface when defining a new WorkerPool resource in your TypeScript program [1][2][7].

Citations:


🌐 Web query:

terraform-provider-google-beta 7.40.0 cloudrun_v2_worker_pool deletionProtection launchStage

💡 Result:

In the terraform-provider-google-beta provider, including version 7.40.0, the google_cloud_run_v2_worker_pool resource includes both deletion_protection and launch_stage as optional arguments [1][2]. deletion_protection: This argument determines whether Terraform is prevented from destroying the resource [1][3]. It defaults to true [1][3]. When set to true (or left unset), any terraform apply or terraform destroy command that would delete the WorkerPool will fail [1][3]. When set to false, deletion of the WorkerPool is permitted [1][3]. launch_stage: This argument defines the launch stage of the resource according to Google Cloud Platform Launch Stages [1][3]. It supports the values: UNIMPLEMENTED, PRELAUNCH, EARLY_ACCESS, ALPHA, BETA, GA, and DEPRECATED [1][3]. If no value is specified, GA is assumed [1][3]. Setting this to a preview stage (e.g., ALPHA or BETA) on input allows the use of preview features associated with that stage [1][3]. On output, this field reflects whether the resource is currently utilizing preview features; for instance, if ALPHA is provided as input but only BETA-level features are utilized, the field will report BETA upon reading the state [1][3].

Citations:


Be explicit about WorkerPool lifecycle behavior.

The five cloudrunv2.WorkerPool resources omit launchStage, which is optional and treats GA as the default, so this is not a required preview-stability fix. However, WorkerPools use the beta provider path where deletionProtection defaults to true; add deletionProtection: false for any ephemeral/dev stacks or intentional teardown scenarios, otherwise destroy/replace can be blocked by default.

server/api/chat.ts (1)

14-16: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Extract shared audience/issuer constants instead of duplicating "chat-wa"/"chat-webhook".

These literals appear twice in this file (lines 34, 96) and are re-declared separately in the test suite. Since this is a second use and enforces a cross-boundary invariant (encode/decode/webhook must agree on aud/iss), it should be extracted into a shared constant (e.g., exported from ../utils/chat) rather than duplicated inline.

As per coding guidelines: "Keep single-use values, functions, and destructuring inline; extract only after a second use or when enforcing an invariant across the project."

♻️ Proposed extraction
+export const chatAudience = "chat-wa";
+export const chatIssuer = "chat-webhook";
-      const waId = await decode(c.req.valid("query").token, "chat-wa", "chat-webhook").catch(() => undefined);
+      const waId = await decode(c.req.valid("query").token, chatAudience, chatIssuer).catch(() => undefined);
-        waId = await decode(c.req.valid("json").token, "chat-wa", "chat-webhook");
+        waId = await decode(c.req.valid("json").token, chatAudience, chatIssuer);

Also applies to: 34-34, 96-96

Source: Coding guidelines

server/hooks/block.ts (1)

60-60: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Cache the in-flight keeper initialization.

Awaiting getWallet("keeper") on the right-hand side leaves keeper unset during initialization, so concurrent queue callbacks can create multiple wallet clients.

  • server/hooks/block.ts#L60-L60: add a shared promise-based initializer that clears the promise on rejection.
  • server/hooks/block.ts#L260-L260: use the helper for nonce skipping.
  • server/hooks/block.ts#L296-L296: use the helper for proposal execution.
  • server/hooks/block.ts#L512-L512: use the helper for withdrawals.
Proposed fix
 let keeper: Awaited<ReturnType<typeof getWallet>> | undefined;
+let keeperPromise: ReturnType<typeof getWallet> | undefined;
+const getKeeper = async () => {
+  if (keeper) return keeper;
+  const promise = (keeperPromise ??= getWallet("keeper"));
+  try {
+    return (keeper ??= await promise);
+  } finally {
+    if (keeperPromise === promise) keeperPromise = undefined;
+  }
+};

- (keeper ??= await getWallet("keeper")).exaSend(
+ (await getKeeper()).exaSend(

Apply the same call-site replacement to the proposal execution and withdrawal paths.

📍 Affects 1 file
  • server/hooks/block.ts#L60-L60 (this comment)
  • server/hooks/block.ts#L260-L260
  • server/hooks/block.ts#L296-L296
  • server/hooks/block.ts#L512-L512
server/hooks/persona.ts (1)

337-346: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated allow-enqueue block.

The enqueue payload at Lines 340-346 is byte-identical to Lines 404-410. Extract a small helper (e.g. enqueueAllow) taking account.output and credential, so the two branches can't diverge.

As per coding guidelines: "Duplicate code (copy/paste, similar logic, abstractions)" and extract "after a second use".

♻️ Sketch
const enqueueAllow = (output: Address) =>
  enqueue({ account: output, chainId: chain.id, factory: parse(Address, credential.factory), publicKey: bytesToHex(credential.publicKey), source: credential.source });
// ...
if (account.success && firewallAddress) await enqueueAllow(account.output);
server/test/mocks/alchemy.ts (1)

4-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Type the hoisted mocks against their real signatures.

findWebhook, createWebhook, and addWebhookAddresses are declared with untyped vi.fn(), unlike sibling mock files in this PR that parametrize vi.fn<typeof X>() to keep mocks checked against the real export signature.

As per coding guidelines, **/*.{ts,tsx}: Use strict TypeScript; do not use any; prefer TypeScript and Valibot types for compile-time safety.

♻️ Proposed typed mocks
+import type { addWebhookAddresses as AddWebhookAddresses, createWebhook as CreateWebhook, findWebhook as FindWebhook } from "../../utils/alchemy";
+
 const { addWebhookAddresses, createWebhook, findWebhook } = vi.hoisted(() => ({
-  findWebhook: vi.fn().mockResolvedValue({ id: "activity", signing_key: "mock-signing-key" }),
-  createWebhook: vi.fn().mockResolvedValue({ id: "mock-webhook-id", signing_key: "mock-signing-key" }),
-  addWebhookAddresses: vi.fn().mockResolvedValue(undefined), // eslint-disable-line unicorn/no-useless-undefined
+  findWebhook: vi.fn<typeof FindWebhook>().mockResolvedValue({ id: "activity", signing_key: "mock-signing-key" }),
+  createWebhook: vi.fn<typeof CreateWebhook>().mockResolvedValue({ id: "mock-webhook-id", signing_key: "mock-signing-key" }),
+  addWebhookAddresses: vi.fn<typeof AddWebhookAddresses>().mockResolvedValue(undefined), // eslint-disable-line unicorn/no-useless-undefined
 }));

Source: Coding guidelines

server/test/workers/refund.test.ts (1)

247-265: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider moving the hoisted mocks and vi.mock blocks to the top for consistency.

mocks is used in beforeEach (Lines 122-123) but declared here at the bottom; it works only because Vitest hoists vi.hoisted/vi.mock. Sibling suites (server/test/workers/allow.test.ts, server/test/workers/poke.test.ts) place these near the top, which reads more clearly.

server/utils/chat.ts (1)

5-6: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

jose library recommended key derivation for dir A256GCM JWE encryption key from arbitrary secret

💡 Result:

When using the "dir" (direct) encryption algorithm in JWE with A256GCM, there is no key derivation step [1]. The "dir" algorithm is a pass-through mechanism where the shared symmetric key you provide is used directly as the Content Encryption Key (CEK) for the chosen encryption algorithm [2][3][1]. Because "dir" uses the provided secret directly, it is critical that the secret you provide meets the requirements of the encryption algorithm being used [1]. For A256GCM, your secret must be a 256-bit (32-byte) key [2][4][5]. Implementation Guidance: 1. Requirement for Secret Size: Since "dir" does not perform key derivation (such as KDF or KEK wrapping), you must provide a secret that is exactly the size required by the "enc" algorithm (e.g., 256 bits for A256GCM) [2][3][1]. 2. Avoid Arbitrary Strings: Do not pass a raw, arbitrary string of arbitrary length. If you have an arbitrary password or secret, you should first pass it through a proper Key Derivation Function (KDF) like PBKDF2, Argon2, or HKDF to derive a cryptographically strong 256-bit key before using it in the "dir" JWE operation [1]. 3. Library-Specific Key Generation: Most JOSE libraries provide utility functions to generate secrets of the correct size if you need a new key [6][7]. If you are migrating a legacy secret, ensure it is properly hashed or derived to the exact bit length required by the A256GCM algorithm before passing it to the library's encryption method [1]. In short, "dir" assumes you have already performed any necessary key derivation; it does not perform one for you [1]. If you provide a secret that is not 256 bits, the underlying cryptographic implementation will likely fail or produce insecure results [1]. [2][3][1]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- files ---\n'
git ls-files | rg '(^|/)chat\.ts$|node\.json|pnpm-lock\.yaml|package\.json|pulumi|infra|README|\.env.example|env|deploy|Docker|compose|terraform' || true

printf '\n--- server/utils/chat.ts ---\n'
if [ -f server/utils/chat.ts ]; then
  cat -n server/utils/chat.ts
fi

printf '\n--- search CHAT_IDENTITY_KEY ---\n'
rg -n -C 3 'CHAT_IDENTITY_KEY|chat key|chat identity|createHash|EncryptJWT|DecryptJWT|AUTH_SECRET|WALLET_EXTENSION_SECRET' . --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' --glob '!server/generated/**' || true

printf '\n--- package jose usage ---\n'
rg -n 'jose' package.json pnpm-lock.yaml node_modules 2>/dev/null || true

Repository: exactly/exa

Length of output: 240


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- files ---'
git ls-files | rg '(^|/)chat\.ts$|node\.json|pnpm-lock\.yaml|package\.json|pulumi|infra|README|\.env\.example|env|deploy|Docker|compose|terraform' || true

printf '\n%s\n' '--- server/utils/chat.ts ---'
if [ -f server/utils/chat.ts ]; then
  cat -n server/utils/chat.ts
fi

printf '\n%s\n' '--- search CHAT_IDENTITY_KEY and related env ---'
rg -n -C 3 'CHAT_IDENTITY_KEY|chat key|chat identity|createHash|EncryptJWT|DecryptJWT|AUTH_SECRET|WALLET_EXTENSION_SECRET' . --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' --glob '!server/generated/**' || true

printf '\n%s\n' '--- jose package references ---'
rg -n 'jose' package.json pnpm-lock.yaml 2>/dev/null || true

Repository: exactly/exa

Length of output: 20921


Derive CHAT_IDENTITY_KEY before passing it to JWE “dir” encryption.

jose “dir” passes the key directly to A256GCM, so a raw/low-entropy CHAT_IDENTITY_KEY gets normalized but not hardened; using an unsalted fast hash does not add brute-force resistance. Use a provided 32-byte high-entropy key or derive it through a proper KDF/secret-provisioning path before using it here.

server/workers/poke/queue.ts (1)

43-43: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

BullMQ queue.add ignores duplicate jobId if completed job retained by removeOnComplete

💡 Result:

In BullMQ, the queue.add method ignores a job with a duplicate jobId if that ID already exists in the queue [1][2]. This behavior persists even if the existing job is in a completed or failed state, provided it has not yet been removed from the queue's storage [3][2]. When you configure removeOnComplete or removeOnFail, BullMQ removes these jobs lazily, typically only when a new job completes or fails [4][5]. Consequently, if a job is in the "completed" or "failed" set, it is still technically present in the queue, and any subsequent attempt to add a job with the same jobId will be treated as a duplicate, ignored, and trigger a "duplicated" event [4][1][6]. To ensure a new job can be added with an existing jobId, you must ensure the previous job with that ID is removed [1][6]. Strategies to manage this include: 1. Configuring stricter retention: Use removeOnComplete or removeOnFail settings (such as setting them to true or using a small count/age) to ensure completed or failed jobs are removed promptly [4][7][8]. 2. Adjusting your jobId logic: If your application requires different jobs to have different IDs even for the same "type" of task, include unique data (like a timestamp or a UUID) in the jobId generation [3]. 3. Manual removal: In specific workflows, you may need to explicitly clean up or remove the old job before adding a new one with the same ID, though this is generally less common than adjusting retention policies [1][6]. Note that the auto-removal process is lazy, meaning it does not run continuously in the background; it relies on events triggered by other job activity [4][5]. If your queue becomes "stalled" because all jobs are blocked by existing IDs in the completed/failed sets, you may need to manually trigger cleanup using the queue.clean method [9].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Repository files matching queue.ts:\n'
fd 'queue\.ts$' . | sed 's#^\./##' | sort

printf '\nTarget file outline and relevant lines:\n'
if [ -f server/workers/poke/queue.ts ]; then
  wc -l server/workers/poke/queue.ts
  cat -n server/workers/poke/queue.ts | sed -n '1,120p'
else
  echo 'missing server/workers/poke/queue.ts'
fi

printf '\nSearch for poke job id usages and deposits:\n'
rg -n "jobId|process deposit|deposit|assets|removeOnComplete|poke|pokeQueue|Poke" -S server . --glob '!node_modules' --glob '!dist' --glob '!build' | head -200

Repository: exactly/exa

Length of output: 23842


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'server/hooks/activity.ts relevant section:\n'
cat -n server/hooks/activity.ts | sed -n '90,170p'

printf '\nBullMQ package declaration / lock references:\n'
if [ -f package.json ]; then cat package.json | grep -E '"(devDependencies|dependencies)"|bullmq' || true; fi
for f in pnpm-lock.yaml package-lock.json yarn.lock bun.lockb; do
  [ -f "$f" ] && echo "--- $f" && rg -n "bullmq|node_modules/.pnpm/bullmq|versions.*bullmq|bullmq@" "$f" | head -40
done

printf '\nPackage metadata via npm registry if resolvable (no install):\n'
npm view bullmq peerDependencies dependencies version version --json 2>/dev/null | head -80 || true

Repository: exactly/exa

Length of output: 4409


Include a time component in the poke job id.

jobId is deduped by account + assets, and BullMQ keeps completed jobs with that id until retention eviction. A later deposit of the same asset set for an account whose previous poke is still in the retained completed set will be ignored, leaving funds unpoked. Add a per-deposit key such as timestamp/id, or remove completed jobs immediately.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2dae0789-7049-4c72-9bcc-452d5976f750

📥 Commits

Reviewing files that changed from the base of the PR and between 1354e98 and 99dfe96.

📒 Files selected for processing (9)
  • .changeset/warm-otters-associate.md
  • server/api/chat.ts
  • server/api/index.ts
  • server/database/schema.ts
  • server/script/openapi.ts
  • server/test/api/chat.test.ts
  • server/test/utils/chat.test.ts
  • server/utils/chat.ts
  • server/vitest.config.mts

Comment thread server/api/chat.ts Outdated
Comment thread server/api/chat.ts
Comment thread server/utils/chat.ts
@cruzdanilo cruzdanilo changed the title ✨ server: link wa id on registration ✨ server: link whatsapp id Jul 24, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
server/utils/chat.ts (1)

34-38: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Still logging the OTP code alongside the WA id.

debug("send validation code %s to %s", code, waId) logs a live one-time code with a phone-number identifier. This was already flagged in a previous review and remains unresolved.

🔒 Suggested fix
 export function sendCode(waId: string, code: string) {
   // TODO integrate message senders
-  debug("send validation code %s to %s", code, waId);
+  debug("send validation code to %s", waId);
   return Promise.resolve();
 }
server/api/chat.ts (1)

100-103: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

cspell:ignore still misplaced.

The flagged word "cooldown" is on line 100; the ignore annotation must be on the same line, not on a separate line below it. As per coding guidelines: "place cspell:ignore on the same line as the unknown word."

✏️ Suggested fix
-      if (!(await redis.set(`chat:cooldown:${waId}`, "1", "PX", 60_000, "NX"))) {
-        // cspell:ignore cooldown
+      if (!(await redis.set(`chat:cooldown:${waId}`, "1", "PX", 60_000, "NX"))) { // cspell:ignore cooldown
         return c.json({ code: "too soon" }, 429);
       }

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5ee140b1-7a3b-4326-b2cd-6b3f8b336ae1

📥 Commits

Reviewing files that changed from the base of the PR and between 99dfe96 and beb2ad9.

📒 Files selected for processing (8)
  • .changeset/warm-otters-associate.md
  • server/api/chat.ts
  • server/api/index.ts
  • server/script/openapi.ts
  • server/test/api/chat.test.ts
  • server/test/utils/chat.test.ts
  • server/utils/chat.ts
  • server/vitest.config.mts

Comment thread server/api/chat.ts
Comment thread server/api/chat.ts
@cruzdanilo
cruzdanilo force-pushed the infra branch 3 times, most recently from d330c43 to d67b7f6 Compare July 27, 2026 11:01
@cruzdanilo cruzdanilo linked an issue Jul 27, 2026 that may be closed by this pull request

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7570d401-5dc8-45e9-8410-0fa8133f4d16

📥 Commits

Reviewing files that changed from the base of the PR and between beb2ad9 and 2496502.

📒 Files selected for processing (9)
  • .changeset/warm-otters-associate.md
  • server/api/chat.ts
  • server/api/index.ts
  • server/database/schema.ts
  • server/script/openapi.ts
  • server/test/api/chat.test.ts
  • server/test/utils/chat.test.ts
  • server/utils/chat.ts
  • server/vitest.config.mts

Comment thread server/api/chat.ts
Comment on lines +14 to +16
const Token = object({
token: pipe(string(), title("Chat token"), description("Encrypted token encoding the chat id to associate.")),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Rename Token to token.

This is a schema value, not a type, component, or event. As per coding guidelines, “use camelCase for variables, functions, and modules.”

Source: Coding guidelines

Comment thread server/utils/chat.ts
Comment on lines +34 to +35
export function sendCode(waId: string, code: string) {
// TODO integrate message senders

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Implement delivery before reporting success.

This resolves without sending anything, so POST /chat/code returns 200 and persists a verification that the user cannot receive. Integrate a sender and clear pending/cooldown state when delivery fails. I can help add the provider integration and failure-path test.

Also applies to: 37-37

@mainqueg
mainqueg changed the base branch from infra to infra-wip July 30, 2026 14:19
@cruzdanilo
cruzdanilo force-pushed the infra-wip branch 19 times, most recently from 9b7a4f6 to 999ec58 Compare August 6, 2026 16:38
@cruzdanilo
cruzdanilo force-pushed the infra-wip branch 6 times, most recently from 6d1b378 to 72bc020 Compare August 8, 2026 09:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: whatsapp linking

3 participants