The five write handlers under /api/whatsapp resolved the caller's
account_id straight off their profile row, which only proves account
membership — never a role. Every other write surface in the app
(account/, ai/, automations/, flows/, quick-replies/) already goes
through requireRole(); this family was missed.
RLS did not backstop it, because each of these routes calls Meta
*before* it persists anything:
- broadcast: makes no database write at all, so there was no policy
involved on any path
- send / react: the Meta call precedes the insert, so the outbound
message or reaction reached the customer and only the local mirror
was refused
- templates/submit, templates/sync: push to Meta before upserting the
local catalog
Gate each handler at the role its data class already implies, matching
lib/auth/roles and the migration 017 policies:
- send, react, broadcast -> 'agent' (canSendMessages)
- templates/submit, sync -> 'admin' (canEditSettings, and the
message_templates_insert/update
policies)
Also switches these handlers to toErrorResponse so an authorization
failure returns 401/403 instead of being folded into a generic 500. The
two template routes keep their existing message-surfacing 500 for
non-auth errors, so a Meta failure still reports its own reason.
Adds role-enforcement coverage to the send route's test: a viewer is
refused with 403 and sendTemplateMessage is never called, while an agent
passes through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/automations-and-cron.md was never written. Replace the pointer
with the details it was standing in for: both cron endpoints, the
x-cron-secret header, and the 503 they return until
AUTOMATION_CRON_SECRET is set.
The image was on node:22-alpine while CI pins node-version: 20 and
package.json declares engines.node >=20.0.0. Align all three stages
so the container builds and runs on the same major the project is
tested against.
Setting PORT in .env.local moved the port the server listens on
*inside* the container (env_file injects it), while the mapping and
the healthcheck stayed on 3000 — so the container came up
unreachable.
The published port is now HOST_PORT, and PORT is pinned to 3000 via
`environment` (which takes precedence over env_file) so a leftover
PORT in .env.local can no longer desync the app from the mapping.
Documented both, including for the plain-docker path.
Give forkers who self-host on their own infra a first-class container
path instead of hand-rolling a Dockerfile and guessing the Next.js
standalone wiring.
- Multi-stage Dockerfile (deps -> build -> runtime) using Next.js
output: "standalone"; runs as a non-root user.
- Single-service docker-compose.yml — Supabase stays external, so no
database container is included.
- .dockerignore and docs/docker.md documenting the setup, including the
build-time (NEXT_PUBLIC_*, inlined into the client bundle) vs runtime
(server secrets via env_file) split.
- Set output: "standalone" in next.config.ts. This is a no-op for
next start and the Hostinger path; only the Docker build consumes it.
Purely additive: no dependency or stack changes, existing deploy paths
are unaffected. Database migrations remain out of scope for the
container (applied via the Supabase CLI, matching the external-Supabase
model).
The Details tab rendered `t("company")` above the name input, so the
panel showed "Company" twice and the name field carried the wrong
label — in English as well as Korean. There was no
`Contacts.detailView.name` key at all; the call papered over it with
`{ fallback: "Name" }`, which next-intl ignores (the second argument
is ICU values, not options).
Add the missing key to both catalogues, point the label at it, and
drop the two decorative `fallback` arguments so the remaining call
sites read as what they are.
`mcp-server/` is a separate npm project with its own lockfile, so the
root-level audit pass in #442 never touched it. Both of its open
Dependabot alerts are runtime-scope transitives of the MCP SDK:
- fast-uri 3.1.3 → 3.1.4 — host confusion via a literal backslash
authority delimiter (high)
- @hono/node-server 1.19.14 → 2.0.12 — serve-static path traversal on
Windows via an encoded backslash (moderate)
Reaching those required @modelcontextprotocol/sdk 1.29.0 → 1.30.0, which
`^1.18.0` already allows, so package.json is unchanged. Lockfile only.
`npm audit` in mcp-server/ now reports 0 vulnerabilities.
Verified: `npm ci`, `npm run typecheck`, `npm run build`, and a stdio
smoke test — initialize succeeds and tools/list still returns all 7
tools on the bumped SDK. Note CI does not cover this directory, so this
was checked by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`main` moved next 16.2.6 → 16.2.12 (#441) and applied transitive audit
fixes (#442) since this branch was cut, so both manifests conflicted.
Resolved by taking main's manifests and re-applying the group's bumps on
top: prettier ^3.9.6 and prettier-plugin-tailwindcss ^0.8.1 in
package.json, plus tailwindcss and @tailwindcss/postcss 4.3.2 → 4.3.3,
which are lockfile-only here because both are specced as `^4`. Lockfile
regenerated with npm 10 — the version CI's Node 20 ships, so the nested
optional-peer @swc/helpers entry `npm ci` needs is preserved.
Verified next 16.2.12 and #442's transitive versions all hold, and
`npm ci` succeeds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`main` moved next 16.2.6 → 16.2.12 (#441) and applied transitive audit
fixes (#442) since this branch was cut, so both manifests conflicted.
Resolved by taking main's manifests and re-applying only this branch's
`lucide-react` bump, then regenerating package-lock.json with npm 10 —
the version CI's Node 20 ships, so the nested optional-peer @swc/helpers
entry `npm ci` needs is preserved. Verified next 16.2.12, lucide-react
1.27.0, and #442's transitive versions all hold, and `npm ci` succeeds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`main` moved next 16.2.6 → 16.2.12 (#441) and applied transitive audit
fixes (#442) since this branch was cut, so both manifests conflicted.
Resolved by keeping main's `next` and this branch's `next-intl`, then
regenerating package-lock.json from the merged package.json with npm 10 —
the version CI's Node 20 ships, so the nested optional-peer @swc/helpers
entry `npm ci` needs is preserved. Verified next 16.2.12, next-intl
4.13.4, and every audit-fixed transitive version from #442 survive, and
`npm ci` succeeds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects that together produce the reported symptom — automation logs
that read `status: 'success'` with `steps_executed: []`.
1. The dispatch loop in the inbound webhook was fire-and-forget inside the
route's `after()` block. `after()` only keeps the function alive for
promises it can see, so the run could be frozen after the log row was
inserted but before any step executed. This is the same failure mode as
issue #301, recurring one level down — the comments on the AI auto-reply
and `message.received` dispatches immediately below already spell out
the invariant this violated. Now awaited; the per-iteration `.catch` is
kept so one trigger type can't skip the rest of the loop.
2. `executeAutomation` seeded the log row with `status: 'success'` before
running anything. A run that died mid-flight therefore left a permanent
success with no steps, indistinguishable from an automation that
genuinely had nothing to do. Seeded as `'failed'` instead, so the status
only becomes success if execution actually reaches a terminal path. No
migration needed — 'failed' is already in the column's CHECK constraint,
and every terminal path overwrites it.
Two more points from the issue are deliberately not addressed here, as
both change existing behaviour and want a maintainer decision: switching
`contains` keyword matching to word boundaries (would alter which
automations fire for current users) and re-opening a closed conversation
on an inbound reply.
Refs #409.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The button combined `variant="destructive"` — which is the soft treatment,
`bg-destructive/10 text-destructive` — with a `bg-red-600` override. The
override replaced the tinted background but not the red text, leaving red
on red and no readable label.
Drop the variant and add `text-white`, which is how every other solid-red
button in the app is written, including the delete-confirm button 100
lines up in this same dialog.
Closes#420.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI installs with Node 20 (npm 10), which keeps next-intl's nested
optional-peer @swc/helpers entry; Dependabot generates the lockfile with
npm 11, which drops it, so `npm ci` fails as out-of-sync before any of
lint/typecheck/test/build runs. Regenerated with npm 10 — the only diff
is re-adding that nested entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI installs with Node 20 (npm 10), which keeps next-intl's nested
optional-peer @swc/helpers entry; Dependabot generates the lockfile with
npm 11, which drops it, so `npm ci` fails as out-of-sync before any of
lint/typecheck/test/build runs. Regenerated with npm 10 — the only diff
is re-adding that nested entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI installs with Node 20 (npm 10), which keeps next-intl's nested
optional-peer @swc/helpers entry; Dependabot generates the lockfile with
npm 11, which drops it, so `npm ci` fails as out-of-sync before any of
lint/typecheck/test/build runs. Regenerated with npm 10 — the only diff
is re-adding that nested entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI installs with Node 20 (npm 10), which keeps next-intl's nested
optional-peer @swc/helpers entry; Dependabot generates the lockfile with
npm 11, which drops it, so `npm ci` fails as out-of-sync before any of
lint/typecheck/test/build runs. Regenerated with npm 10 — the only diff
is re-adding that nested entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lockfile-only bumps of indirect dependencies flagged by Dependabot;
no direct dependency or `package.json` entry changes.
- fast-uri 3.1.3 → 3.1.4 (two host-confusion advisories, high)
- js-yaml → 4.3.0 (quadratic CPU via YAML merge-key chains, high)
- hono → 4.12.32 and @hono/node-server → 2.0.12 (cross-request context
disclosure in hono/jsx, serve-static path traversal on Windows)
- body-parser → 2.3.0 (size enforcement silently disabled on an invalid
limit value)
- postcss → 8.5.24
Regenerated with npm 10 to match CI's Node 20, so the nested
`@swc/helpers` optional-peer entry `npm ci` needs is preserved.
Verified: `npm ci`, lint (39 warnings, unchanged from main), typecheck,
645 tests, and `next build` all pass.
Still open after this, and deliberately left alone: `next` (needs a
direct bump — separate branch), `sharp` (major, `--force` only), and
`brace-expansion` nested under the dev-only `@ts-morph/common`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16.2.6 is in the vulnerable range for eight advisories, all patched in
16.2.11: SSRF in rewrites via an attacker-controlled destination
hostname, SSRF in Server Actions on custom servers, middleware/proxy
bypass in App Router, DoS in App Router Server Actions, DoS in the image
optimizer via SVG, unauthenticated disclosure of internal Server
Function endpoints, and two response-body cache-confusion issues.
`eslint-config-next` moves with it to keep the pair on one version.
Lockfile regenerated with npm 10 to match CI's Node 20 — the default
npm 11 drops next-intl's nested optional-peer `@swc/helpers` entry and
breaks `npm ci`.
Verified: lint (39 warnings, unchanged from main), typecheck, 645 tests,
and `next build` all pass on 16.2.12.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ensureImageHeaderHandle` fetched the caller-supplied `header_media_url`
server-side with no validation, so a template submit — an action any
authenticated member can perform — could reach loopback, RFC1918 and
cloud-metadata addresses, with the response status and content-type
echoed back through the thrown error.
Wire in the project's existing `isDeliverableUrl` guard, which the two
other outbound-fetch call sites (webhook delivery, automation
`send_webhook`) already use, and reuse their `redirect: 'manual'` +
timeout so a public URL can't 3xx-bounce to an internal one. The
rejection reuses the unreachable-host message verbatim so the failure
doesn't distinguish "blocked" from "down".
Addresses GHSA-6fr5-pxw7-r3xw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Base UI's <SelectValue> with no children does not reliably extract
text from the selected <SelectItem> when the item's children is a
function call (e.g. tRoles(r.value)) — the trigger falls back to
displaying the raw enum string ('admin', 'agent', 'viewer').
Render the translated label explicitly as children of <SelectValue>
so the trigger always shows the human-readable role name.
Scope: only the two role selectors in Settings (matches this PR's
theme). The same pattern affects ~5 other sites (template-manager
header format, button type; ai-config provider and handoff target;
flows/broadcasts) — those are left as follow-up and tracked in #375.
Part of #375
next-intl rich tags must be attribute-less. The className on the
<code> wrapping 'GET /api/v1/me' was being silently dropped, so the
'text-[11px]' Tailwind utility was a phantom — the code rendered at
the surrounding text size anyway.
This strips the className from both en.json and ko.json so the rich
tag is parsed correctly. The <code> element still renders at default
size; the smaller styling can be restored globally via CSS
('code { font-size: ... }') if desired.
Part of #375
The identity card at the top of the Settings overview was calling
useTranslations('roles') (root-level) instead of useTranslations
('Settings.roles'). The root 'roles' namespace only exists in the
es-CO translation as a workaround added to mask this bug — in English
the chip rendered the raw keypath (e.g. 'roles.admin').
Aligns with invite-member-dialog.tsx:78 and members-tab.tsx:129,
which already use the 'Settings.roles' namespace.
Part of #375
The settings page derived its active panel from `useSearchParams()` but
wasn't wrapped in a Suspense boundary. In Next 16 that fails the
production build ("missing Suspense with CSR bailout") and forces the
page to bail to full client-side rendering — shipping a settings screen
whose rail click handlers never wire up. Because the account-menu
Settings link targets `?tab=whatsapp`, users land on the WhatsApp panel
and can't navigate to any other section ("locked on WhatsApp").
Wrap the `useSearchParams` consumer in `<Suspense>` on the three
affected dashboard pages — settings, inbox, automations/new — mirroring
the split login/signup already use. Also add the missing
`Settings.sections.quick-replies` label, which was rendering as a raw
translation key in the settings rail.
Verified: production build now succeeds (previously errored out during
static generation); every settings rail section navigates correctly in a
production build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>