mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(api): invitation create + peek + redeem endpoints
PR 4 of the multi-user accounts series. Lands the link-only
invitation flow — admin generates a shareable URL, copies it,
sends it via any channel. No email service needed.
API surface
- GET /api/account/invitations — list pending (admin+)
- POST /api/account/invitations — create (admin+)
- DELETE /api/account/invitations/[id] — revoke (admin+)
- GET /api/invitations/[token]/peek — public, rate-limited per-IP
- POST /api/invitations/[token]/redeem — authenticated, rate-limited per-IP
Token lifecycle
- POST returns the plaintext token + share URL exactly ONCE in
the response. We store only the SHA-256 hash. There is no
endpoint that resurfaces it; if the admin dismisses the dialog
without copying, the only recourse is to revoke and re-issue.
- Peek and redeem take the plaintext token in the URL path, hash
it in TS via `hashInviteToken`, and look up by `token_hash`.
Two new SECURITY DEFINER RPCs back the cross-RLS work
(migration 019):
- `peek_invitation` — anonymous read. Returns a uniform JSON
envelope: `{ ok, account_name, role, expires_at }` or
`{ ok: false, reason: not_found|used|expired }`. The route
forwards verbatim so the join page can branch cleanly.
- `redeem_invitation` — authenticated. Locks the invite row
`FOR UPDATE`, validates expiry/usage, refuses if the caller is
already in a shared account OR their personal account has any
domain rows (checked across all 11 user-creatable parent
tables), then atomically moves profile.account_id +
account_role, stamps the invitation accepted, and deletes the
orphan personal account. Single transaction.
Error contract
- 22023 (invalid_parameter_value) → 400 — not_found / used / expired
- 42501 (insufficient_privilege) → 401 — caller not authenticated
- 23505 (unique_violation, reused) → 409 — caller's account has data,
or they're already a
member of the inviter's
account / another shared
account
Rate limiting
- Two new buckets in `RATE_LIMITS`: invitationPeek (30/min/IP)
and invitationRedeem (10/min/IP). Belt-and-braces against
token enumeration even though 256-bit entropy makes it
theoretical. Uses the existing fixed-window in-memory limiter.
Why no peek-then-redeem two-step on the server
- Peek is read-only and idempotent; redeem locks `FOR UPDATE` so
two concurrent redemptions can't both win. The peek result is
cosmetic ("show me what I'm joining"); the actual write
happens entirely inside the redeem RPC's transaction, so a
TOCTOU between peek and redeem just means the user sees the
same `{ ok: false, reason }` on the next render.
Verification
- `npm run lint` → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds; the 4 new routes register as ƒ
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dabddcfa05
commit
b013861160
24
CHANGELOG.md
24
CHANGELOG.md
@ -56,6 +56,24 @@ as the sole owner of their own account and sees identical data.
|
||||
freshly-created personal account (mirror of the signup flow).
|
||||
- `POST /api/account/transfer-ownership` — owner only. Atomic
|
||||
swap with the named member.
|
||||
- **Invitation API + redeem flow** — the no-email, link-only
|
||||
invite path. Backend is complete; the Members tab UI that
|
||||
drives it lands in a follow-up.
|
||||
- `GET /api/account/invitations` — list outstanding (admin+).
|
||||
- `POST /api/account/invitations` — create an invite, returns
|
||||
the plaintext token + share URL **exactly once** (we store
|
||||
only the SHA-256 hash on the row). Body
|
||||
`{ role, expiresInDays?, label? }`. Admin+.
|
||||
- `DELETE /api/account/invitations/[id]` — revoke (admin+).
|
||||
- `GET /api/invitations/[token]/peek` — public, per-IP
|
||||
rate-limited. Returns `{ ok, account_name, role, expires_at }`
|
||||
or `{ ok: false, reason }` so the join page can render
|
||||
"You're being invited to <Account> as <Role>".
|
||||
- `POST /api/invitations/[token]/redeem` — authenticated.
|
||||
Atomically moves the caller's profile to the inviter's
|
||||
account and cleans up the orphan personal account. Refuses
|
||||
with 409 if the caller's current account already contains
|
||||
domain data (no silent data loss).
|
||||
|
||||
### Migration required
|
||||
|
||||
@ -76,6 +94,12 @@ Apply against your Supabase project before deploying this version:
|
||||
role and raise SQLSTATE `42501` / `22023` on forbidden / bad
|
||||
input so the API layer can map cleanly to 403 / 400.
|
||||
Idempotent.
|
||||
- `supabase/migrations/019_invitation_rpcs.sql` — adds two
|
||||
`SECURITY DEFINER` RPCs: `peek_invitation` (anonymous read by
|
||||
token hash, returns a fixed-shape JSON envelope) and
|
||||
`redeem_invitation` (authenticated atomic move + orphan
|
||||
cleanup, with a domain-data safety check). Both bypass the
|
||||
RLS that would otherwise block their reads/writes. Idempotent.
|
||||
|
||||
## [0.2.2] — 2026-05-29
|
||||
|
||||
|
||||
61
src/app/api/account/invitations/[id]/route.ts
Normal file
61
src/app/api/account/invitations/[id]/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
// ============================================================
|
||||
// DELETE /api/account/invitations/[id]
|
||||
//
|
||||
// Admin+. Revokes a pending invitation by id. RLS on
|
||||
// `account_invitations` already restricts the DELETE to admins
|
||||
// of the inviting account; we lean on it and skip the explicit
|
||||
// ownership check.
|
||||
//
|
||||
// We intentionally delete the row outright rather than soft-
|
||||
// deleting (a "revoked_at" flag). Once revoked, an invite is
|
||||
// dead forever — there's no UX where a former invite should be
|
||||
// listed; the plaintext token is gone too. Hard delete keeps
|
||||
// the table small.
|
||||
// ============================================================
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const ctx = await requireRole("admin");
|
||||
const { id } = await params;
|
||||
|
||||
// No `eq('account_id', ctx.accountId)` — the RLS policy
|
||||
// (`is_account_member(account_id, 'admin')`) already scopes
|
||||
// the DELETE to invites in the caller's account. Adding the
|
||||
// filter would be redundant; omitting it surfaces a
|
||||
// cross-account attempt as a silent 0-row delete (which is
|
||||
// exactly what we want for a revocation endpoint).
|
||||
const { error, count } = await ctx.supabase
|
||||
.from("account_invitations")
|
||||
.delete({ count: "exact" })
|
||||
.eq("id", id);
|
||||
|
||||
if (error) {
|
||||
console.error("[DELETE /api/account/invitations/[id]] error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to revoke invitation" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
// Either the id doesn't exist or RLS hid it (different
|
||||
// account). 404 either way — surfacing "exists but not
|
||||
// yours" would leak existence.
|
||||
return NextResponse.json(
|
||||
{ error: "Invitation not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return toErrorResponse(err);
|
||||
}
|
||||
}
|
||||
144
src/app/api/account/invitations/route.ts
Normal file
144
src/app/api/account/invitations/route.ts
Normal file
@ -0,0 +1,144 @@
|
||||
// ============================================================
|
||||
// /api/account/invitations
|
||||
//
|
||||
// GET — list outstanding (un-redeemed, non-expired) invites.
|
||||
// POST — create a new invite link.
|
||||
//
|
||||
// Both admin+. The list endpoint is what the Members tab uses to
|
||||
// populate the "Pending invitations" section; create is what the
|
||||
// "Invite member" dialog calls.
|
||||
//
|
||||
// IMPORTANT: the plaintext token is returned exactly ONCE — in
|
||||
// the POST response. We store only the SHA-256 hash on the row,
|
||||
// so neither GET nor a future PATCH can ever resurface the
|
||||
// link. The admin sees it in the creation modal, copies it, and
|
||||
// shares it via WhatsApp/Slack/whatever they like. If they
|
||||
// dismiss the modal without copying, the only recourse is to
|
||||
// revoke and re-issue.
|
||||
// ============================================================
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
||||
import {
|
||||
clampExpiryDays,
|
||||
generateInviteToken,
|
||||
inviteExpiresAt,
|
||||
inviteUrl,
|
||||
} from "@/lib/auth/invitations";
|
||||
import { isAccountRole } from "@/lib/auth/roles";
|
||||
|
||||
// Resolve the base URL we publish invite links under. Mirrors the
|
||||
// .env.local.example default so dev / preview / forks all behave
|
||||
// without explicit config.
|
||||
function getBaseUrl(): string {
|
||||
return process.env.NEXT_PUBLIC_SITE_URL || "https://wacrm.tech";
|
||||
}
|
||||
|
||||
const MAX_LABEL_LEN = 80;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const ctx = await requireRole("admin");
|
||||
|
||||
const { data, error } = await ctx.supabase
|
||||
.from("account_invitations")
|
||||
.select(
|
||||
"id, role, label, created_by_user_id, created_at, expires_at, accepted_at, accepted_by_user_id",
|
||||
)
|
||||
.eq("account_id", ctx.accountId)
|
||||
.is("accepted_at", null)
|
||||
.gt("expires_at", new Date().toISOString())
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("[GET /api/account/invitations] fetch error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load invitations" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ invitations: data ?? [] });
|
||||
} catch (err) {
|
||||
return toErrorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const ctx = await requireRole("admin");
|
||||
|
||||
const body = (await request.json().catch(() => null)) as
|
||||
| { role?: unknown; expiresInDays?: unknown; label?: unknown }
|
||||
| null;
|
||||
|
||||
const role = body?.role;
|
||||
if (!isAccountRole(role) || role === "owner") {
|
||||
// The DB CHECK already rejects 'owner', but failing fast
|
||||
// here gives a clearer 400 than the eventual constraint
|
||||
// violation surfaced as a 500.
|
||||
return NextResponse.json(
|
||||
{ error: "'role' must be one of admin, agent, viewer" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const expiresInDaysRaw = body?.expiresInDays;
|
||||
// `clampExpiryDays` tolerates undefined / NaN / negatives by
|
||||
// collapsing to the safe default, so we just pass the raw
|
||||
// value through after a type narrow.
|
||||
const expiresInDays =
|
||||
typeof expiresInDaysRaw === "number" ? expiresInDaysRaw : undefined;
|
||||
const expiryDays = clampExpiryDays(expiresInDays);
|
||||
const expiresAt = inviteExpiresAt(expiryDays);
|
||||
|
||||
let label: string | null = null;
|
||||
if (typeof body?.label === "string") {
|
||||
const trimmed = body.label.trim();
|
||||
if (trimmed.length > MAX_LABEL_LEN) {
|
||||
return NextResponse.json(
|
||||
{ error: `Label must be ${MAX_LABEL_LEN} characters or fewer` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
label = trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
const { token, hash } = generateInviteToken();
|
||||
|
||||
const { data, error } = await ctx.supabase
|
||||
.from("account_invitations")
|
||||
.insert({
|
||||
account_id: ctx.accountId,
|
||||
token_hash: hash,
|
||||
role,
|
||||
created_by_user_id: ctx.userId,
|
||||
label,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
})
|
||||
.select("id, role, label, expires_at, created_at")
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
console.error("[POST /api/account/invitations] insert error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create invitation" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
invitation: data,
|
||||
// Plaintext payload — visible to the admin exactly once.
|
||||
token,
|
||||
url: inviteUrl(token, getBaseUrl()),
|
||||
expiresInDays: expiryDays,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (err) {
|
||||
return toErrorResponse(err);
|
||||
}
|
||||
}
|
||||
87
src/app/api/invitations/[token]/peek/route.ts
Normal file
87
src/app/api/invitations/[token]/peek/route.ts
Normal file
@ -0,0 +1,87 @@
|
||||
// ============================================================
|
||||
// GET /api/invitations/[token]/peek
|
||||
//
|
||||
// Public — no auth required. Lets the /join/<token> page render
|
||||
// "You're being invited to <Account> as <Role>" before the
|
||||
// visitor signs up or signs in.
|
||||
//
|
||||
// Security model
|
||||
// - Token is in the URL path, not the query, so it doesn't
|
||||
// show up in standard access-log "referer" fields the way a
|
||||
// `?token=` would.
|
||||
// - The plaintext token never crosses the DB boundary — we
|
||||
// hash it in TS first and look up by `token_hash`.
|
||||
// - The peek RPC is SECURITY DEFINER so it bypasses the RLS
|
||||
// that would otherwise block an anonymous SELECT on
|
||||
// `account_invitations`. It returns a fixed-shape JSON
|
||||
// payload that never leaks columns beyond what the join
|
||||
// page renders.
|
||||
// - Per-IP rate limit pinches brute-force enumeration of
|
||||
// tokens. With 256 bits of entropy the enumeration risk is
|
||||
// theoretical, but rate limiting is cheap insurance.
|
||||
// ============================================================
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { hashInviteToken } from "@/lib/auth/invitations";
|
||||
import {
|
||||
checkRateLimit,
|
||||
rateLimitResponse,
|
||||
RATE_LIMITS,
|
||||
} from "@/lib/rate-limit";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
/**
|
||||
* Best-effort client IP. The `x-forwarded-for` header is what
|
||||
* every reverse proxy (Vercel, Hostinger, Cloudflare) sets when
|
||||
* forwarding a request; we take the leftmost entry, which is
|
||||
* the original client.
|
||||
*
|
||||
* Falls back to a constant when no proxy is in front (e.g.
|
||||
* `localhost` during development) so rate-limit keys still
|
||||
* exist — the limit then effectively applies "globally," which
|
||||
* is fine for dev.
|
||||
*/
|
||||
function getClientIp(request: Request): string {
|
||||
const xff = request.headers.get("x-forwarded-for");
|
||||
if (xff) return xff.split(",")[0].trim();
|
||||
const xri = request.headers.get("x-real-ip");
|
||||
if (xri) return xri.trim();
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
// Rate-limit by IP first. Returns 429 to a serial bruteforcer
|
||||
// before we ever touch the DB.
|
||||
const ip = getClientIp(request);
|
||||
const limit = checkRateLimit(`peek:${ip}`, RATE_LIMITS.invitationPeek);
|
||||
if (!limit.success) return rateLimitResponse(limit);
|
||||
|
||||
const { token } = await params;
|
||||
if (!token || typeof token !== "string") {
|
||||
return NextResponse.json(
|
||||
{ ok: false, reason: "not_found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase.rpc("peek_invitation", {
|
||||
p_token_hash: hashInviteToken(token),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[peek] rpc error:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, reason: "server_error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// The RPC always returns a json object — either ok:true with
|
||||
// metadata or ok:false with a reason. Forward verbatim.
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
91
src/app/api/invitations/[token]/redeem/route.ts
Normal file
91
src/app/api/invitations/[token]/redeem/route.ts
Normal file
@ -0,0 +1,91 @@
|
||||
// ============================================================
|
||||
// POST /api/invitations/[token]/redeem
|
||||
//
|
||||
// Authenticated. Caller atomically moves from their personal
|
||||
// account (created at signup) to the inviter's account with the
|
||||
// invite's role. Heavy lifting lives in the SECURITY DEFINER
|
||||
// `redeem_invitation` RPC from migration 019.
|
||||
//
|
||||
// Refusal contract (from the RPC)
|
||||
// - SQLSTATE 42501 → 401 (caller not authenticated)
|
||||
// - SQLSTATE 22023 → 400 (invitation not_found / used / expired)
|
||||
// - SQLSTATE 23505 → 409 (caller's account already has data /
|
||||
// they're already in this or another shared account)
|
||||
//
|
||||
// Rate limit (per IP) is the same shape as peek but tighter —
|
||||
// a successful redeem changes data, and the RPC's data-loss
|
||||
// guard makes brute-force retries pointless past a few attempts.
|
||||
// ============================================================
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import type { PostgrestError } from "@supabase/supabase-js";
|
||||
|
||||
import { hashInviteToken } from "@/lib/auth/invitations";
|
||||
import {
|
||||
checkRateLimit,
|
||||
rateLimitResponse,
|
||||
RATE_LIMITS,
|
||||
} from "@/lib/rate-limit";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
function getClientIp(request: Request): string {
|
||||
const xff = request.headers.get("x-forwarded-for");
|
||||
if (xff) return xff.split(",")[0].trim();
|
||||
const xri = request.headers.get("x-real-ip");
|
||||
if (xri) return xri.trim();
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function rpcErrorToResponse(err: PostgrestError): NextResponse {
|
||||
if (err.code === "42501") {
|
||||
return NextResponse.json({ error: err.message }, { status: 401 });
|
||||
}
|
||||
if (err.code === "22023") {
|
||||
return NextResponse.json({ error: err.message }, { status: 400 });
|
||||
}
|
||||
if (err.code === "23505") {
|
||||
return NextResponse.json({ error: err.message }, { status: 409 });
|
||||
}
|
||||
console.error("[redeem] unexpected RPC error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to redeem invitation" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const ip = getClientIp(request);
|
||||
const limit = checkRateLimit(`redeem:${ip}`, RATE_LIMITS.invitationRedeem);
|
||||
if (!limit.success) return rateLimitResponse(limit);
|
||||
|
||||
const { token } = await params;
|
||||
if (!token || typeof token !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing invitation token" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
|
||||
// The RPC checks `auth.uid()` itself, but failing fast here
|
||||
// gives a cleaner 401 without a Supabase round trip on the
|
||||
// common "user clicked the link before logging in" path.
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data: accountId, error } = await supabase.rpc("redeem_invitation", {
|
||||
p_token_hash: hashInviteToken(token),
|
||||
});
|
||||
|
||||
if (error) return rpcErrorToResponse(error);
|
||||
|
||||
return NextResponse.json({ ok: true, accountId });
|
||||
}
|
||||
@ -125,6 +125,15 @@ export const RATE_LIMITS = {
|
||||
* fidget with reactions and a single "swap" is actually two calls
|
||||
* (remove + add) under the hood. */
|
||||
react: { limit: 120, windowMs: 60_000 },
|
||||
/** Invitation peek (public, per-IP). 30/min lets a forwarded link
|
||||
* retry a handful of times under flaky connectivity without
|
||||
* enabling brute-force token enumeration. With 256-bit tokens the
|
||||
* enumeration risk is theoretical; this is belt-and-braces. */
|
||||
invitationPeek: { limit: 30, windowMs: 60_000 },
|
||||
/** Invitation redeem (authed, per-IP+user). Tighter than peek —
|
||||
* successful redemption mutates two profiles and an invite row, so
|
||||
* the abuse surface is "spam join attempts." */
|
||||
invitationRedeem: { limit: 10, windowMs: 60_000 },
|
||||
} as const;
|
||||
|
||||
/** Test-only helper. Clears the in-memory state so unit tests don't
|
||||
|
||||
237
supabase/migrations/019_invitation_rpcs.sql
Normal file
237
supabase/migrations/019_invitation_rpcs.sql
Normal file
@ -0,0 +1,237 @@
|
||||
-- ============================================================
|
||||
-- 019_invitation_rpcs.sql — peek + redeem invitation RPCs
|
||||
--
|
||||
-- The third and last server-side migration in the multi-user
|
||||
-- accounts series. Both functions are SECURITY DEFINER for the
|
||||
-- same reason as the member RPCs in 018: the writes they need to
|
||||
-- do (or, for peek, the reads) cross RLS boundaries that the
|
||||
-- regular client policies (correctly) deny.
|
||||
--
|
||||
-- peek_invitation — anonymous read. The /join/<token> page
|
||||
-- calls this to render "You're being invited to <Account> as
|
||||
-- <Role>" before the visitor signs in. Returns a uniform
|
||||
-- `{ ok, reason?, account_name?, role?, expires_at? }` JSON
|
||||
-- so the API route doesn't have to interpret error rows.
|
||||
--
|
||||
-- redeem_invitation — authenticated. Atomically moves the caller
|
||||
-- from their just-created personal account to the inviter's
|
||||
-- account, cleans up the orphan personal account, and stamps
|
||||
-- the invitation accepted. Refuses if the caller's current
|
||||
-- account holds any domain data (to avoid silent data loss).
|
||||
--
|
||||
-- Idempotent — safe to run multiple times.
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- peek_invitation(p_token_hash text)
|
||||
--
|
||||
-- Anonymous read by token hash. The plaintext token never
|
||||
-- reaches the DB; the route handler hashes it first.
|
||||
--
|
||||
-- Returns a JSON object with one of two shapes:
|
||||
-- { "ok": true, "account_name": "...", "role": "...",
|
||||
-- "expires_at": "2026-..." }
|
||||
-- { "ok": false, "reason": "not_found" | "expired" | "used" }
|
||||
--
|
||||
-- We could collapse all three failure cases to "not_found" to
|
||||
-- harden against enumeration, but the join page needs the
|
||||
-- distinction for UX ("This invite has expired — ask <name>
|
||||
-- for a new one"). Tokens carry 256 bits of entropy, so the
|
||||
-- enumeration risk is theoretical; rate-limiting the route on
|
||||
-- the IP layer adds belt-and-braces.
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.peek_invitation(
|
||||
p_token_hash TEXT
|
||||
) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_inv account_invitations%ROWTYPE;
|
||||
v_account_name TEXT;
|
||||
BEGIN
|
||||
SELECT * INTO v_inv
|
||||
FROM account_invitations
|
||||
WHERE token_hash = p_token_hash;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN json_build_object('ok', false, 'reason', 'not_found');
|
||||
END IF;
|
||||
|
||||
IF v_inv.accepted_at IS NOT NULL THEN
|
||||
RETURN json_build_object('ok', false, 'reason', 'used');
|
||||
END IF;
|
||||
|
||||
IF v_inv.expires_at <= NOW() THEN
|
||||
RETURN json_build_object('ok', false, 'reason', 'expired');
|
||||
END IF;
|
||||
|
||||
SELECT name INTO v_account_name
|
||||
FROM accounts
|
||||
WHERE id = v_inv.account_id;
|
||||
|
||||
RETURN json_build_object(
|
||||
'ok', true,
|
||||
'account_name', v_account_name,
|
||||
'role', v_inv.role,
|
||||
'expires_at', v_inv.expires_at
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.peek_invitation(TEXT) OWNER TO postgres;
|
||||
REVOKE ALL ON FUNCTION public.peek_invitation(TEXT) FROM PUBLIC;
|
||||
-- `anon` so the /join/<token> page can call this before the user
|
||||
-- signs in; `authenticated` so the same page works when already
|
||||
-- signed in (e.g. existing user clicks a forwarded link).
|
||||
GRANT EXECUTE ON FUNCTION public.peek_invitation(TEXT) TO anon, authenticated;
|
||||
|
||||
-- ============================================================
|
||||
-- redeem_invitation(p_token_hash text)
|
||||
--
|
||||
-- Authenticated. The caller's auth.uid() is used both to scope
|
||||
-- the move ("which profile am I editing?") and as the safety
|
||||
-- check ("do you have any data we'd lose?").
|
||||
--
|
||||
-- Refusal codes (SQLSTATE):
|
||||
-- 22023 — invite invalid (not_found / used / expired)
|
||||
-- 42501 — caller not authenticated
|
||||
-- 23505 — caller's account has data (would be lost by joining)
|
||||
-- NOTE: we reuse Postgres's "unique_violation" code here
|
||||
-- rather than invent a custom SQLSTATE because there's
|
||||
-- no proper standard SQLSTATE for "conflict"; the route
|
||||
-- handler maps it to HTTP 409.
|
||||
--
|
||||
-- Order of operations
|
||||
-- 1. Lock the invite row (FOR UPDATE) so two concurrent redeems
|
||||
-- of the same token can't both succeed.
|
||||
-- 2. Read caller's current account_id.
|
||||
-- 3. Verify caller is the sole owner of their current account
|
||||
-- AND that the account has zero domain rows. (If the caller
|
||||
-- already joined someone else's account once, their
|
||||
-- profile.account_id points there, not to a personal account
|
||||
-- they own — that case fails the "is owner" check and
|
||||
-- surfaces as 23505.)
|
||||
-- 4. Move profile.account_id + account_role to invite's.
|
||||
-- 5. Mark invitation accepted (token_hash stays, so the same
|
||||
-- token can't be re-used).
|
||||
-- 6. Delete the old personal account. The ON DELETE CASCADE on
|
||||
-- `accounts(id) ← profiles.account_id` would normally try to
|
||||
-- delete the caller's profile too, but step 4 already moved
|
||||
-- them to the new account, so the cascade is a no-op.
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.redeem_invitation(
|
||||
p_token_hash TEXT
|
||||
) RETURNS UUID -- the joined account_id
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_caller_id UUID := auth.uid();
|
||||
v_inv account_invitations%ROWTYPE;
|
||||
v_old_account_id UUID;
|
||||
v_old_account_owner UUID;
|
||||
v_has_data BOOLEAN;
|
||||
BEGIN
|
||||
IF v_caller_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_inv
|
||||
FROM account_invitations
|
||||
WHERE token_hash = p_token_hash
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Invitation not found' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_inv.accepted_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Invitation has already been redeemed'
|
||||
USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF v_inv.expires_at <= NOW() THEN
|
||||
RAISE EXCEPTION 'Invitation has expired' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
-- Caller's current account + its owner.
|
||||
SELECT p.account_id, a.owner_user_id
|
||||
INTO v_old_account_id, v_old_account_owner
|
||||
FROM profiles p
|
||||
JOIN accounts a ON a.id = p.account_id
|
||||
WHERE p.user_id = v_caller_id;
|
||||
|
||||
IF v_old_account_id IS NULL THEN
|
||||
-- Defensive — every authenticated user has a profile post-017.
|
||||
RAISE EXCEPTION 'Caller has no profile' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- Edge case: the inviter sent themselves a link, or the
|
||||
-- caller is somehow already in the inviter's account.
|
||||
IF v_old_account_id = v_inv.account_id THEN
|
||||
RAISE EXCEPTION 'You are already a member of this account'
|
||||
USING ERRCODE = '23505';
|
||||
END IF;
|
||||
|
||||
-- Safety: the caller must be the SOLE OWNER of their current
|
||||
-- account (i.e. their fresh personal account from signup or a
|
||||
-- prior removal). Any other state means they're either:
|
||||
-- - a member of another shared account (joining a second
|
||||
-- would silently orphan their access to the first), or
|
||||
-- - the owner of an account with teammates (they'd abandon
|
||||
-- their team to join the inviter's).
|
||||
-- Either way, the safe answer is "make a different login".
|
||||
IF v_old_account_owner <> v_caller_id THEN
|
||||
RAISE EXCEPTION 'You are already in a shared account; sign up with a different email to join this one'
|
||||
USING ERRCODE = '23505';
|
||||
END IF;
|
||||
|
||||
-- Belt: even if they own their account, refuse if it has any
|
||||
-- domain data — joining would orphan their contacts, deals,
|
||||
-- broadcasts, automations, flows, templates, etc.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM contacts WHERE account_id = v_old_account_id
|
||||
UNION ALL SELECT 1 FROM conversations WHERE account_id = v_old_account_id
|
||||
UNION ALL SELECT 1 FROM broadcasts WHERE account_id = v_old_account_id
|
||||
UNION ALL SELECT 1 FROM automations WHERE account_id = v_old_account_id
|
||||
UNION ALL SELECT 1 FROM flows WHERE account_id = v_old_account_id
|
||||
UNION ALL SELECT 1 FROM pipelines WHERE account_id = v_old_account_id
|
||||
UNION ALL SELECT 1 FROM message_templates WHERE account_id = v_old_account_id
|
||||
UNION ALL SELECT 1 FROM tags WHERE account_id = v_old_account_id
|
||||
UNION ALL SELECT 1 FROM custom_fields WHERE account_id = v_old_account_id
|
||||
UNION ALL SELECT 1 FROM contact_notes WHERE account_id = v_old_account_id
|
||||
UNION ALL SELECT 1 FROM whatsapp_config WHERE account_id = v_old_account_id
|
||||
LIMIT 1
|
||||
) INTO v_has_data;
|
||||
|
||||
IF v_has_data THEN
|
||||
RAISE EXCEPTION 'Your account already contains data; sign up with a different email to join this one'
|
||||
USING ERRCODE = '23505';
|
||||
END IF;
|
||||
|
||||
-- Move the profile first so the cascade-on-delete of the old
|
||||
-- account doesn't try to nuke this user's profile too.
|
||||
UPDATE profiles
|
||||
SET account_id = v_inv.account_id,
|
||||
account_role = v_inv.role
|
||||
WHERE user_id = v_caller_id;
|
||||
|
||||
UPDATE account_invitations
|
||||
SET accepted_at = NOW(),
|
||||
accepted_by_user_id = v_caller_id
|
||||
WHERE id = v_inv.id;
|
||||
|
||||
-- Clean up the orphan personal account. Empty by the checks
|
||||
-- above, so this is purely housekeeping — no cascades fire
|
||||
-- because no other rows reference it.
|
||||
DELETE FROM accounts WHERE id = v_old_account_id;
|
||||
|
||||
RETURN v_inv.account_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.redeem_invitation(TEXT) OWNER TO postgres;
|
||||
REVOKE ALL ON FUNCTION public.redeem_invitation(TEXT) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.redeem_invitation(TEXT) TO authenticated;
|
||||
Loading…
Reference in New Issue
Block a user