mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
238 lines
9.1 KiB
PL/PgSQL
238 lines
9.1 KiB
PL/PgSQL
-- ============================================================
|
|
-- 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;
|