Merge pull request #169 from ArnasDon/feat/account-sharing-server-helpers

feat(auth): account-sharing server helpers + types
This commit is contained in:
Arnas Donauskas 2026-05-31 13:53:15 +02:00 committed by GitHub
commit 0d95272999
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 727 additions and 0 deletions

173
src/lib/auth/account.ts Normal file
View File

@ -0,0 +1,173 @@
// ============================================================
// Server-side account context — for API routes and server
// components. Reads the caller's profile + account in one round
// trip and verifies role on demand.
//
// IMPORTANT: this module is server-only. It imports the Supabase
// SSR client (`@/lib/supabase/server`), which reads `next/headers`
// cookies. Importing it from a client component will fail at
// build time with the standard Next.js "You're importing a
// component that needs `next/headers`" error — that's the
// boundary check; we don't need the `server-only` package.
//
// Calling convention
// ------------------
// API routes don't need to redo `supabase.auth.getUser()` — they
// receive a fully-loaded context from `requireRole`:
//
// try {
// const ctx = await requireRole("admin");
// // ctx.supabase — the SSR client (RLS scoped to this user)
// // ctx.userId — auth.uid()
// // ctx.accountId / ctx.role / ctx.account
// } catch (err) {
// return errorResponse(err); // see toErrorResponse() below
// }
// ============================================================
import { NextResponse } from "next/server";
import type { SupabaseClient } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/server";
import { hasMinRole, isAccountRole, type AccountRole } from "./roles";
// ------------------------------------------------------------
// Errors
//
// Custom classes so API routes can map a single `catch` to the
// right HTTP status without sprinkling 401/403 strings everywhere.
// ------------------------------------------------------------
export class UnauthorizedError extends Error {
readonly status = 401 as const;
constructor(message = "Unauthorized") {
super(message);
this.name = "UnauthorizedError";
}
}
export class ForbiddenError extends Error {
readonly status = 403 as const;
constructor(message = "Forbidden") {
super(message);
this.name = "ForbiddenError";
}
}
/**
* Convert one of the typed errors above (or anything else) into a
* `NextResponse`. Routes can do:
*
* } catch (err) {
* return toErrorResponse(err);
* }
*
* Unknown errors collapse to 500 with the generic message — we
* never leak `err.message` for non-classified errors to keep
* server internals out of the wire.
*/
export function toErrorResponse(err: unknown): NextResponse {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: err.message }, { status: err.status });
}
console.error("[toErrorResponse] uncategorized error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
// ------------------------------------------------------------
// Account context
// ------------------------------------------------------------
export interface AccountContext {
/** Supabase SSR client, RLS scoped to the calling user. */
supabase: SupabaseClient;
/** `auth.uid()` for the caller. Always defined when this resolves. */
userId: string;
/** Caller's account_id from their profile row. */
accountId: string;
/** Caller's role within their account. */
role: AccountRole;
/** Lightweight account meta — id + name. */
account: { id: string; name: string };
}
/**
* Resolve the caller's user + account + role in one round trip.
*
* Throws `UnauthorizedError` if there's no Supabase session.
* Throws `ForbiddenError` if the profile is missing account
* fields (shouldn't happen post-017 migration; defensive guard
* against profile rows that pre-date the backfill or were
* inserted by hand).
*
* Use `requireRole(min)` instead when the route also needs a
* minimum-role check — it's a thin wrapper over this.
*/
export async function getCurrentAccount(): Promise<AccountContext> {
const supabase = await createClient();
const {
data: { user },
error: userErr,
} = await supabase.auth.getUser();
if (userErr || !user) {
throw new UnauthorizedError();
}
// Selecting through the FK gives us the account name in one
// query — `account:accounts!inner(id,name)` is Supabase's
// explicit-join syntax. `!inner` so a NULL account_id (which
// shouldn't exist) yields no row and trips the guard below
// rather than silently returning a half-populated profile.
const { data, error } = await supabase
.from("profiles")
.select("account_id, account_role, account:accounts!inner(id, name)")
.eq("user_id", user.id)
.maybeSingle();
if (error) {
console.error("[getCurrentAccount] profile fetch error:", error);
throw new ForbiddenError("Could not load account context");
}
if (!data || !data.account_id || !data.account_role || !data.account) {
// Pre-migration profile, or a manual insert that skipped the
// signup trigger. The user is authenticated but the app has
// no way to scope their queries — treat as forbidden.
throw new ForbiddenError("Profile is not linked to an account");
}
if (!isAccountRole(data.account_role)) {
// The DB enum should make this impossible, but a future
// migration that broadens the enum without updating TS would
// hit this — surface it rather than silently widening.
throw new ForbiddenError(`Unknown account role: ${data.account_role}`);
}
// Supabase's typed client returns related rows as an array even
// for `!inner` single-record joins; normalise to a single object.
const accountRow = Array.isArray(data.account) ? data.account[0] : data.account;
return {
supabase,
userId: user.id,
accountId: data.account_id,
role: data.account_role,
account: { id: accountRow.id, name: accountRow.name },
};
}
/**
* Resolve the caller's account context and enforce a minimum role.
*
* Throws `UnauthorizedError` / `ForbiddenError` as documented on
* `getCurrentAccount`, plus `ForbiddenError("Insufficient role")`
* when the caller is below `min`.
*/
export async function requireRole(min: AccountRole): Promise<AccountContext> {
const ctx = await getCurrentAccount();
if (!hasMinRole(ctx.role, min)) {
throw new ForbiddenError(
`This action requires the '${min}' role or higher`,
);
}
return ctx;
}

View File

@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import {
clampExpiryDays,
DEFAULT_INVITE_EXPIRY_DAYS,
generateInviteToken,
hashInviteToken,
inviteExpiresAt,
inviteUrl,
MAX_INVITE_EXPIRY_DAYS,
} from "./invitations";
describe("generateInviteToken", () => {
it("returns a 43-character base64url token (32 raw bytes)", () => {
const { token } = generateInviteToken();
expect(token).toHaveLength(43);
// base64url alphabet: A-Z a-z 0-9 - _ (no +, /, or =)
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("returns a 64-char hex hash matching SHA-256 of the token", () => {
const { token, hash } = generateInviteToken();
expect(hash).toHaveLength(64);
expect(hash).toMatch(/^[0-9a-f]+$/);
expect(hash).toBe(hashInviteToken(token));
});
it("produces distinct tokens across calls", () => {
// 32 bytes of CSPRNG entropy — a collision in 1000 draws would
// be a thermodynamic miracle. This is a sanity guard for "did
// someone accidentally swap randomBytes for a constant?".
const seen = new Set<string>();
for (let i = 0; i < 1000; i++) {
seen.add(generateInviteToken().token);
}
expect(seen.size).toBe(1000);
});
});
describe("hashInviteToken", () => {
it("is deterministic for the same input", () => {
expect(hashInviteToken("hello")).toBe(hashInviteToken("hello"));
});
it("differs for different inputs", () => {
expect(hashInviteToken("a")).not.toBe(hashInviteToken("b"));
});
it("matches a known SHA-256 hex digest", () => {
// Known fixture — `sha256("invite-token-abc")` hex digest.
// If this assertion ever flips, the hash function changed and
// every stored token_hash in the DB is suddenly orphaned.
expect(hashInviteToken("invite-token-abc")).toBe(
"51481b404112f61a4e1171ff116d52068c429737863181bef089df7cb607352f",
);
});
});
describe("inviteUrl", () => {
it("joins path correctly with no trailing slash", () => {
expect(inviteUrl("abc", "https://wacrm.example")).toBe(
"https://wacrm.example/join/abc",
);
});
it("tolerates a trailing slash on baseUrl", () => {
expect(inviteUrl("abc", "https://wacrm.example/")).toBe(
"https://wacrm.example/join/abc",
);
});
it("tolerates multiple trailing slashes", () => {
expect(inviteUrl("abc", "https://wacrm.example///")).toBe(
"https://wacrm.example/join/abc",
);
});
it("preserves the entire token verbatim — including base64url symbols", () => {
// The token may contain `-` and `_`. Both are URL-safe; the
// function must NOT percent-encode them.
expect(inviteUrl("a-b_c", "https://x")).toBe("https://x/join/a-b_c");
});
});
describe("clampExpiryDays", () => {
it("defaults to DEFAULT_INVITE_EXPIRY_DAYS when undefined", () => {
expect(clampExpiryDays(undefined)).toBe(DEFAULT_INVITE_EXPIRY_DAYS);
});
it("defaults when given a non-finite value", () => {
// Non-finite values (NaN, ±Infinity) are always programmer errors,
// never legitimate input. We collapse them to the safe default
// rather than to MAX — a buggy Infinity passing through and
// silently producing a year-long invite would be worse than a
// default 7-day one that the admin can re-issue.
expect(clampExpiryDays(NaN)).toBe(DEFAULT_INVITE_EXPIRY_DAYS);
expect(clampExpiryDays(Infinity)).toBe(DEFAULT_INVITE_EXPIRY_DAYS);
expect(clampExpiryDays(-Infinity)).toBe(DEFAULT_INVITE_EXPIRY_DAYS);
});
it("rejects zero / negative", () => {
expect(clampExpiryDays(0)).toBe(DEFAULT_INVITE_EXPIRY_DAYS);
expect(clampExpiryDays(-5)).toBe(DEFAULT_INVITE_EXPIRY_DAYS);
});
it("clamps above MAX_INVITE_EXPIRY_DAYS", () => {
expect(clampExpiryDays(99999)).toBe(MAX_INVITE_EXPIRY_DAYS);
});
it("passes valid values through", () => {
expect(clampExpiryDays(1)).toBe(1);
expect(clampExpiryDays(7)).toBe(7);
expect(clampExpiryDays(30)).toBe(30);
});
it("floors fractional days", () => {
expect(clampExpiryDays(7.9)).toBe(7);
});
});
describe("inviteExpiresAt", () => {
it("adds the requested days to `now`", () => {
const now = new Date("2026-01-01T00:00:00Z");
const out = inviteExpiresAt(7, now);
expect(out.toISOString()).toBe("2026-01-08T00:00:00.000Z");
});
it("uses the default when expiresInDays is omitted", () => {
const now = new Date("2026-01-01T00:00:00Z");
const out = inviteExpiresAt(undefined, now);
const expected = new Date(
now.getTime() + DEFAULT_INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
);
expect(out.toISOString()).toBe(expected.toISOString());
});
it("respects the max clamp", () => {
const now = new Date("2026-01-01T00:00:00Z");
const out = inviteExpiresAt(99999, now);
const expected = new Date(
now.getTime() + MAX_INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
);
expect(out.toISOString()).toBe(expected.toISOString());
});
});

101
src/lib/auth/invitations.ts Normal file
View File

@ -0,0 +1,101 @@
// ============================================================
// Invitation token utilities — pure, server-side, no Supabase.
//
// Why we hash tokens at rest
// --------------------------
// The DB stores only `account_invitations.token_hash` (SHA-256
// of the random token), never the plaintext. A leaked DB snapshot
// (logs, backups, support exports) therefore can't be used to
// redeem invites — the attacker would need the original token,
// which is returned exactly once at creation time.
//
// Why 32 bytes
// ------------
// 32 bytes of CSPRNG entropy is the standard for opaque session-
// style tokens. base64url-encodes to a 43-char string, fits
// comfortably in a URL, and is well past the practical brute-
// force boundary even with SHA-256 collisions (256 bits >> any
// realistic adversary).
//
// Why base64url (not hex)
// -----------------------
// URL-safe and shorter than hex. `crypto.randomBytes(32).toString
// ('base64url')` lands at 43 characters; hex would be 64.
// ============================================================
import { createHash, randomBytes } from "node:crypto";
/** Default invite link lifetime if the caller doesn't specify. */
export const DEFAULT_INVITE_EXPIRY_DAYS = 7;
/** Hard ceiling on user-supplied `expiresInDays` (1 year). */
export const MAX_INVITE_EXPIRY_DAYS = 365;
export interface GeneratedToken {
/** Plaintext token — return to the creator ONCE, never persist. */
token: string;
/** SHA-256 hex digest of the token. Persist this in the DB. */
hash: string;
}
/**
* Generate a fresh invite token + its hash. Call once per invite
* creation; the plaintext is shown to the admin in the UI and
* embedded in the shareable link, the hash is stored in
* `account_invitations.token_hash`.
*/
export function generateInviteToken(): GeneratedToken {
const token = randomBytes(32).toString("base64url");
return { token, hash: hashInviteToken(token) };
}
/**
* Deterministic SHA-256 of a plaintext token. Used at redeem time
* to look up the matching `account_invitations` row by `token_hash`.
* Pure function — same input always produces the same output.
*/
export function hashInviteToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
/**
* Build the public invite URL the admin will share. The token is
* carried in the path (not the query) so referrer-policy noise
* and browser autocomplete don't trip up token preservation.
*
* `baseUrl` must NOT have a trailing slash. The function tolerates
* one anyway (so callers can pass `NEXT_PUBLIC_APP_URL` verbatim
* without sweating slash hygiene).
*/
export function inviteUrl(token: string, baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/join/${token}`;
}
/**
* Compute the `expires_at` timestamp for a new invite.
*
* - Clamps `expiresInDays` to `[1, MAX_INVITE_EXPIRY_DAYS]`.
* - Falls back to `DEFAULT_INVITE_EXPIRY_DAYS` for missing input.
* - `now` is injectable so tests don't need timer mocking.
*/
export function inviteExpiresAt(
expiresInDays: number | undefined,
now: Date = new Date(),
): Date {
const days = clampExpiryDays(expiresInDays);
const ms = days * 24 * 60 * 60 * 1000;
return new Date(now.getTime() + ms);
}
/** Exposed for tests and for the API route that echoes the clamped value back. */
export function clampExpiryDays(expiresInDays: number | undefined): number {
if (
expiresInDays === undefined ||
!Number.isFinite(expiresInDays) ||
expiresInDays <= 0
) {
return DEFAULT_INVITE_EXPIRY_DAYS;
}
return Math.min(Math.floor(expiresInDays), MAX_INVITE_EXPIRY_DAYS);
}

130
src/lib/auth/roles.test.ts Normal file
View File

@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import {
ACCOUNT_ROLES,
type AccountRole,
canDeleteAccount,
canEditSettings,
canManageMembers,
canSendMessages,
canTransferOwnership,
canViewOnly,
hasMinRole,
isAccountRole,
roleRank,
} from "./roles";
describe("roleRank", () => {
it("orders owner > admin > agent > viewer", () => {
expect(roleRank("owner")).toBeGreaterThan(roleRank("admin"));
expect(roleRank("admin")).toBeGreaterThan(roleRank("agent"));
expect(roleRank("agent")).toBeGreaterThan(roleRank("viewer"));
});
it("matches the SQL helper's numeric mapping", () => {
// Keep these in lockstep with `is_account_member`'s CASE expression
// in supabase/migrations/017_account_sharing.sql — any change here
// means the SQL helper needs the same change.
expect(roleRank("owner")).toBe(4);
expect(roleRank("admin")).toBe(3);
expect(roleRank("agent")).toBe(2);
expect(roleRank("viewer")).toBe(1);
});
});
describe("hasMinRole", () => {
it("returns true when role meets the threshold", () => {
expect(hasMinRole("owner", "viewer")).toBe(true);
expect(hasMinRole("admin", "agent")).toBe(true);
expect(hasMinRole("agent", "agent")).toBe(true);
});
it("returns false when role is below the threshold", () => {
expect(hasMinRole("viewer", "agent")).toBe(false);
expect(hasMinRole("agent", "admin")).toBe(false);
expect(hasMinRole("admin", "owner")).toBe(false);
});
// The full matrix — useful as a regression net if anyone reshuffles
// the rank table.
it.each<[AccountRole, AccountRole, boolean]>([
["owner", "owner", true],
["owner", "admin", true],
["owner", "agent", true],
["owner", "viewer", true],
["admin", "owner", false],
["admin", "admin", true],
["admin", "agent", true],
["admin", "viewer", true],
["agent", "owner", false],
["agent", "admin", false],
["agent", "agent", true],
["agent", "viewer", true],
["viewer", "owner", false],
["viewer", "admin", false],
["viewer", "agent", false],
["viewer", "viewer", true],
])("%s vs min %s → %s", (role, min, expected) => {
expect(hasMinRole(role, min)).toBe(expected);
});
});
describe("isAccountRole", () => {
it("accepts every value in ACCOUNT_ROLES", () => {
for (const role of ACCOUNT_ROLES) {
expect(isAccountRole(role)).toBe(true);
}
});
it("rejects garbage / case mismatch / non-strings", () => {
expect(isAccountRole("Owner")).toBe(false);
expect(isAccountRole("")).toBe(false);
expect(isAccountRole(null)).toBe(false);
expect(isAccountRole(undefined)).toBe(false);
expect(isAccountRole(123)).toBe(false);
expect(isAccountRole("superuser")).toBe(false);
});
});
describe("capability predicates", () => {
it("canManageMembers: admin+ only", () => {
expect(canManageMembers("owner")).toBe(true);
expect(canManageMembers("admin")).toBe(true);
expect(canManageMembers("agent")).toBe(false);
expect(canManageMembers("viewer")).toBe(false);
});
it("canEditSettings: admin+ only", () => {
expect(canEditSettings("owner")).toBe(true);
expect(canEditSettings("admin")).toBe(true);
expect(canEditSettings("agent")).toBe(false);
expect(canEditSettings("viewer")).toBe(false);
});
it("canSendMessages: agent+ only", () => {
expect(canSendMessages("owner")).toBe(true);
expect(canSendMessages("admin")).toBe(true);
expect(canSendMessages("agent")).toBe(true);
expect(canSendMessages("viewer")).toBe(false);
});
it("canViewOnly: viewer only", () => {
expect(canViewOnly("owner")).toBe(false);
expect(canViewOnly("admin")).toBe(false);
expect(canViewOnly("agent")).toBe(false);
expect(canViewOnly("viewer")).toBe(true);
});
it("canDeleteAccount: owner only", () => {
expect(canDeleteAccount("owner")).toBe(true);
expect(canDeleteAccount("admin")).toBe(false);
expect(canDeleteAccount("agent")).toBe(false);
expect(canDeleteAccount("viewer")).toBe(false);
});
it("canTransferOwnership: owner only", () => {
expect(canTransferOwnership("owner")).toBe(true);
expect(canTransferOwnership("admin")).toBe(false);
expect(canTransferOwnership("agent")).toBe(false);
expect(canTransferOwnership("viewer")).toBe(false);
});
});

109
src/lib/auth/roles.ts Normal file
View File

@ -0,0 +1,109 @@
// ============================================================
// Account role helpers — pure, unit-testable, no I/O.
//
// Mirrors the `account_role_enum` Postgres type from migration
// 017_account_sharing.sql. The hierarchy is intentionally a flat
// ordinal (owner=4 … viewer=1) — it matches the same CASE
// expression the `is_account_member(account_id, min_role)` SQL
// helper uses, so server-side TypeScript guards and database-side
// RLS speak the same language.
//
// Predicates (`canManageMembers`, `canEditSettings`, …) are the
// single source of truth for "what can this role do?" — both
// API route guards and UI gates should call them rather than
// open-coding their own role checks. That keeps role-policy
// changes a one-file diff.
// ============================================================
export type AccountRole = "owner" | "admin" | "agent" | "viewer";
/** Ordered list of every valid role, lowest privilege first. */
export const ACCOUNT_ROLES: readonly AccountRole[] = [
"viewer",
"agent",
"admin",
"owner",
] as const;
/**
* Numeric rank of a role. Higher = more privileged. Mirrors the
* CASE expression in `is_account_member` so JS/SQL stay aligned.
*/
export function roleRank(role: AccountRole): number {
switch (role) {
case "owner":
return 4;
case "admin":
return 3;
case "agent":
return 2;
case "viewer":
return 1;
}
}
/**
* True iff `role` is at least as privileged as `min`. Use this
* for any "user has at least admin" / "at least agent" checks.
*/
export function hasMinRole(role: AccountRole, min: AccountRole): boolean {
return roleRank(role) >= roleRank(min);
}
/** Type-narrow an unknown string into a valid `AccountRole`. */
export function isAccountRole(value: unknown): value is AccountRole {
return (
typeof value === "string" &&
(ACCOUNT_ROLES as readonly string[]).includes(value)
);
}
// ============================================================
// Capability predicates
//
// Every UI gate and API route guard should call one of these
// instead of comparing role strings inline. Adding a capability
// = one new predicate here + one call site change per consumer.
// ============================================================
/** Owner / admin: invite, remove, change roles. */
export function canManageMembers(role: AccountRole): boolean {
return hasMinRole(role, "admin");
}
/**
* Owner / admin: edit account-wide settings (WhatsApp config,
* message templates, pipelines, tags, custom fields, account
* name). Excludes per-user settings like avatar or own password.
*/
export function canEditSettings(role: AccountRole): boolean {
return hasMinRole(role, "admin");
}
/**
* Owner / admin / agent: write operational data — send messages,
* create contacts, move deals, run broadcasts, edit automations.
* Viewers are read-only.
*/
export function canSendMessages(role: AccountRole): boolean {
return hasMinRole(role, "agent");
}
/**
* Viewer: read-only across everything. Provided as a positive
* predicate so UI gates read naturally (`if (canViewOnly(role))`
* shows the "Read-only" tooltip without inverting `canSendMessages`).
*/
export function canViewOnly(role: AccountRole): boolean {
return role === "viewer";
}
/** Owner only: irreversible destructive operations. */
export function canDeleteAccount(role: AccountRole): boolean {
return role === "owner";
}
/** Owner only: hand the account to another member. */
export function canTransferOwnership(role: AccountRole): boolean {
return role === "owner";
}

View File

@ -1,9 +1,18 @@
import type { AccountRole } from "@/lib/auth/roles";
export interface Profile {
id: string;
user_id: string;
full_name: string;
email: string;
avatar_url?: string;
/**
* Legacy free-form role column from migration 001. Never read
* by the app since 017_account_sharing.sql introduced the typed
* `account_role` enum. Flagged for removal in a later cleanup
* migration — kept on the type so existing destructures don't
* break.
*/
role: string;
/**
* Opted-in beta feature keys for this account. The column survives
@ -13,9 +22,70 @@ export interface Profile {
* the `profiles` row.
*/
beta_features?: string[];
/**
* Account this profile is a member of. Added by
* `017_account_sharing.sql`; NOT NULL in the DB post-backfill.
* Optional on the type only because older serialised payloads
* (cached client state, test fixtures) may not have it yet.
*/
account_id?: string;
/**
* Caller's role within their account. Source of truth for every
* role-gated UI / API check — call `hasMinRole` from
* `@/lib/auth/roles` rather than comparing this string directly.
*/
account_role?: AccountRole;
created_at: string;
}
// ============================================================
// Account-sharing entities (017_account_sharing.sql)
// ============================================================
export interface Account {
id: string;
name: string;
/** auth.users.id of the immutable owner. */
owner_user_id: string;
created_at: string;
updated_at: string;
}
/**
* Hydrated member row for the Settings → Members tab. Combines
* the profile and its account_role for a single member of the
* caller's account. Sensitive fields (email) are populated only
* when the caller has admin+ — agents and viewers see name +
* avatar + role only.
*/
export interface AccountMember {
user_id: string;
full_name: string;
email: string | null;
avatar_url: string | null;
role: AccountRole;
joined_at: string;
}
/**
* Outstanding invite link row. `token_hash` is intentionally
* absent — it lives only in the DB and on the server. The
* plaintext token is returned once at creation time and surfaced
* via the invite URL; never re-emitted.
*/
export interface AccountInvitation {
id: string;
account_id: string;
/** Roles offered via invite — owner is never offered. */
role: Exclude<AccountRole, "owner">;
created_by_user_id: string | null;
label: string | null;
created_at: string;
expires_at: string;
accepted_at: string | null;
accepted_by_user_id: string | null;
}
export interface Contact {
id: string;
user_id: string;