diff --git a/CHANGELOG.md b/CHANGELOG.md index 226e7e1..c10edc2 100644 --- a/CHANGELOG.md +++ b/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 as ". + - `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 diff --git a/src/app/api/account/invitations/[id]/route.ts b/src/app/api/account/invitations/[id]/route.ts new file mode 100644 index 0000000..7679b8a --- /dev/null +++ b/src/app/api/account/invitations/[id]/route.ts @@ -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); + } +} diff --git a/src/app/api/account/invitations/route.ts b/src/app/api/account/invitations/route.ts new file mode 100644 index 0000000..d7e901e --- /dev/null +++ b/src/app/api/account/invitations/route.ts @@ -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); + } +} diff --git a/src/app/api/invitations/[token]/peek/route.ts b/src/app/api/invitations/[token]/peek/route.ts new file mode 100644 index 0000000..ef25a4f --- /dev/null +++ b/src/app/api/invitations/[token]/peek/route.ts @@ -0,0 +1,87 @@ +// ============================================================ +// GET /api/invitations/[token]/peek +// +// Public — no auth required. Lets the /join/ page render +// "You're being invited to as " 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); +} diff --git a/src/app/api/invitations/[token]/redeem/route.ts b/src/app/api/invitations/[token]/redeem/route.ts new file mode 100644 index 0000000..538e6fe --- /dev/null +++ b/src/app/api/invitations/[token]/redeem/route.ts @@ -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 }); +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index a6f905a..90daecf 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -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 diff --git a/supabase/migrations/019_invitation_rpcs.sql b/supabase/migrations/019_invitation_rpcs.sql new file mode 100644 index 0000000..9e093b6 --- /dev/null +++ b/supabase/migrations/019_invitation_rpcs.sql @@ -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/ page +-- calls this to render "You're being invited to as +-- " 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 +-- 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/ 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;