feat(api): public API groundwork — scoped API keys + /api/v1/me

Adds the machine-to-machine auth layer the public REST API needs so
external scripts and automations can drive wacrm (issue #245). This is
the groundwork release; data endpoints land one at a time on top of it.

- Migration 026: account-scoped `api_keys` table. SHA-256 hash at rest
  (plaintext shown once), display `key_prefix`, `scopes[]`, RLS via
  `is_account_member` (members read, admin+ manage).
- `src/lib/api-keys/`: key gen/hash (wacrm_live_ prefix, timing-safe),
  scope vocabulary, service-role auth-path store.
- `requireApiKey(request, scope?)` (src/lib/auth/api-context.ts):
  bearer parse -> hash lookup -> liveness -> per-key rate limit ->
  scopes-only authz. Returns a service-role-scoped account context.
- `/api/v1` envelope ({data} / {error:{code,message}}) + GET /api/v1/me
  reference endpoint that exercises the whole stack.
- Dashboard: admin+ key management routes + Settings -> API keys tab.
- Docs (docs/public-api.md), CHANGELOG, README, .env note.

Checks: typecheck clean, lint 0 errors, full suite green (+27 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-23 15:35:43 +03:00
parent 12438dca78
commit 456321a2f2
No known key found for this signature in database
20 changed files with 1743 additions and 2 deletions

View File

@ -7,8 +7,14 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# Supabase service-role key. Bypasses RLS; used only by server-side # Supabase service-role key. Bypasses RLS; used only by server-side
# routes (the webhook and automation engine). Keep this secret — # routes (the webhook, automation engine, and the public API key
# never paste it into client code. # auth path — see docs/public-api.md). Keep this secret — never
# paste it into client code.
#
# Note: the public API (/api/v1) needs no new env var. API keys are
# created in the dashboard (Settings → API keys) and stored hashed
# in the database; this service-role key is what lets the auth path
# look a presented key up without a user session.
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# WhatsApp token encryption (64 hex chars = 32 bytes, AES-256-GCM). # WhatsApp token encryption (64 hex chars = 32 bytes, AES-256-GCM).

View File

@ -23,6 +23,20 @@ as the sole owner of their own account and sees identical data, and a
solo owner who never invites anyone sees the same single-user app they solo owner who never invites anyone sees the same single-user app they
always did. always did.
### Added
- **Public REST API (`/api/v1`) — groundwork.** A scoped, revocable
**API key** system so you can drive wacrm from your own scripts and
automations. Create keys under **Settings → API keys** (admin+),
grant only the scopes each integration needs, and authenticate with
`Authorization: Bearer <key>`. Keys are account-scoped and stored
hashed (plaintext shown once). This release ships the auth layer,
scopes, per-key rate limiting, the management UI, and a
`GET /api/v1/me` probe to verify a key; the data endpoints
(`messages`, `contacts`, …) follow one at a time. See
`docs/public-api.md`. **Migration required:** apply
`supabase/migrations/026_api_keys.sql`. ([#245](https://github.com/ArnasDon/wacrm/issues/245))
### Changed ### Changed
- **Tenancy moves from per-user to per-account.** RLS on every - **Tenancy moves from per-user to per-account.** RLS on every

View File

@ -40,6 +40,9 @@ clone or fork it to run your own CRM.
is account-scoped, so one shared inbox can be staffed by a whole is account-scoped, so one shared inbox can be staffed by a whole
team. Solo use stays single-user with zero setup. team. Solo use stays single-user with zero setup.
- **Account management** — email, password, avatar, global sign-out. - **Account management** — email, password, avatar, global sign-out.
- **Public REST API** (`/api/v1`) with scoped, revocable API keys —
build your own automations on top of your CRM. See
[docs/public-api.md](./docs/public-api.md).
## Why fork this? ## Why fork this?

130
docs/public-api.md Normal file
View File

@ -0,0 +1,130 @@
# Public API (`/api/v1`)
The public API lets you drive your wacrm instance from your own
scripts and automations — send messages, manage contacts, launch
broadcasts — without going through the dashboard UI.
> **Status:** groundwork release. Authentication, scopes, rate
> limiting, and the `GET /api/v1/me` probe ship now. The data
> endpoints (`messages`, `contacts`, …) land one at a time in
> follow-up releases — see [Roadmap](#roadmap).
## Authentication
Every request authenticates with an **API key**, sent as a bearer
token:
```
Authorization: Bearer wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Keys are **account-scoped**: a key acts on exactly one account, the
one it was created in. There is no cross-account access.
### Creating a key
In the dashboard: **Settings → API keys → New API key**. Only
**admins and owners** can create keys.
1. Give the key a name (after the integration that will use it).
2. Grant the **scopes** it needs — nothing more (see below).
3. Copy the key. **The full key is shown exactly once.** wacrm
stores only a SHA-256 hash, so it can never be shown again. If you
lose it, revoke it and create a new one.
### Revoking a key
**Settings → API keys → Revoke.** Revocation is effective on the
key's next request. Revoked keys stay in the list as an audit trail.
## Scopes
A key can do only what its scopes allow — independent of who created
it. Grant the minimum.
| Scope | Allows |
| -------------------- | ---------------------------------------- |
| `messages:send` | Send WhatsApp messages |
| `messages:read` | Read messages and delivery status |
| `contacts:read` | List and read contacts |
| `contacts:write` | Create and update contacts |
| `conversations:read` | List and read conversations |
| `broadcasts:send` | Launch broadcast campaigns |
A key with **no scopes** still authenticates and can call
`GET /api/v1/me` — useful for verifying a key works.
## Response envelope
Every response uses one of two shapes:
```jsonc
// success
{ "data": { /* ... */ } }
// failure
{ "error": { "code": "forbidden", "message": "This API key is missing the 'messages:send' scope" } }
```
Branch on `error.code` (stable); `error.message` is for humans and
may be reworded.
| Status | `code` | Meaning |
| ------ | -------------- | ------------------------------------------------ |
| 401 | `unauthorized` | Missing / malformed / unknown / revoked / expired key |
| 403 | `forbidden` | Valid key, but missing the required scope |
| 429 | `rate_limited` | Per-key rate limit exceeded |
| 400 | `bad_request` | Malformed input |
| 404 | `not_found` | No such resource |
| 500 | `internal` | Server error |
## Rate limits
Requests are limited **per key**: **120 requests per minute**. On a
`429`, these headers tell you when to retry:
- `Retry-After` — seconds until the window resets
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
> The limiter is in-memory and **per process**. A single-instance
> deploy (the common case for a self-hosted fork) is fine as-is. If
> you scale to multiple instances, swap the limiter for a shared
> store (Redis/Upstash) — see the note at the top of
> `src/lib/rate-limit.ts`. The limit is otherwise unenforced across
> instances.
## Endpoints
### `GET /api/v1/me`
Returns the account a key is bound to and the scopes it carries.
Requires only a valid key (no scope). Use it to verify a key works
and to discover its scopes.
```bash
curl https://your-crm.example.com/api/v1/me \
-H "Authorization: Bearer wacrm_live_xxx"
```
```json
{
"data": {
"account": { "id": "…", "name": "Acme Inc" },
"key": { "id": "…", "scopes": ["messages:send"] }
}
}
```
## Roadmap
Planned endpoints, shipping one per release (tracked in
[#245](https://github.com/ArnasDon/wacrm/issues/245)):
- `POST /api/v1/messages` — send a message to a phone number
(`messages:send`)
- `GET/POST /api/v1/contacts`, `GET/PATCH /api/v1/contacts/{id}`
(`contacts:read` / `contacts:write`)
- `GET /api/v1/conversations` (`conversations:read`)
- `POST /api/v1/broadcasts` (`broadcasts:send`)
- Outbound event webhooks (so automations can react to inbound
messages)

View File

@ -15,6 +15,7 @@ import { TemplateManager } from '@/components/settings/template-manager';
import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel'; import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel';
import { DealsSettings } from '@/components/settings/deals-settings'; import { DealsSettings } from '@/components/settings/deals-settings';
import { MembersTab } from '@/components/settings/members-tab'; import { MembersTab } from '@/components/settings/members-tab';
import { ApiKeysSettings } from '@/components/settings/api-keys-settings';
import { import {
resolveSection, resolveSection,
type SettingsSection, type SettingsSection,
@ -59,6 +60,7 @@ export default function SettingsPage() {
fields: <FieldsAndTagsPanel />, fields: <FieldsAndTagsPanel />,
deals: <DealsSettings />, deals: <DealsSettings />,
members: <MembersTab />, members: <MembersTab />,
api: <ApiKeysSettings />,
}; };
return ( return (

View File

@ -0,0 +1,71 @@
// ============================================================
// DELETE /api/account/api-keys/[id] — revoke a key.
//
// Soft revoke: sets `revoked_at` rather than deleting the row, so
// the key's name/prefix stay visible in the roster as an audit
// trail ("this key existed and was turned off") and so the auth
// path's liveness check (`findActiveKeyByHash` filters revoked
// rows) starts rejecting it immediately. Admin+, enforced here and
// by the `api_keys_update` RLS policy.
//
// Revocation is effective on the next request: once `revoked_at` is
// set, `findActiveKeyByHash` returns null and the key 401s.
// ============================================================
import { NextResponse } from 'next/server';
import { requireRole, toErrorResponse } from '@/lib/auth/account';
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit';
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireRole('admin');
const limit = checkRateLimit(
`admin:apiKeyRevoke:${ctx.userId}`,
RATE_LIMITS.adminAction
);
if (!limit.success) return rateLimitResponse(limit);
const { id } = await params;
// Scope the update by account_id as well as id so an admin can
// never revoke another account's key by guessing a UUID. (RLS
// already enforces this; the explicit filter is belt-and-braces
// and makes the "0 rows updated → 404" path precise.)
const { data, error } = await ctx.supabase
.from('api_keys')
.update({ revoked_at: new Date().toISOString() })
.eq('id', id)
.eq('account_id', ctx.accountId)
.is('revoked_at', null)
.select('id')
.maybeSingle();
if (error) {
console.error('[DELETE /api/account/api-keys/[id]] error:', error);
return NextResponse.json(
{ error: 'Failed to revoke API key' },
{ status: 500 }
);
}
if (!data) {
// Either no such key in this account, or it was already revoked.
return NextResponse.json(
{ error: 'API key not found or already revoked' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@ -0,0 +1,159 @@
// ============================================================
// /api/account/api-keys
//
// GET — list this account's API keys (safe columns only).
// POST — mint a new key.
//
// These are the *dashboard* endpoints for managing keys, so they
// authenticate the normal way (cookie session) and go through the
// RLS client. Listing is open to any member (viewer+) — the roster
// is not secret; the secret (the key itself) is never in it. Minting
// is admin+ (a key hands out capabilities), enforced by both
// `requireRole('admin')` here and the `api_keys_insert` RLS policy.
//
// IMPORTANT: the plaintext key is returned exactly ONCE, in the POST
// response. We persist only its SHA-256 hash, so neither GET nor any
// future endpoint can resurface it — same one-time-reveal contract
// as invite links. If the admin loses it, they revoke and re-issue.
// ============================================================
import { NextResponse } from 'next/server';
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account';
import { generateApiKey } from '@/lib/api-keys/keys';
import { normalizeScopes } from '@/lib/api-keys/scopes';
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit';
const MAX_NAME_LEN = 80;
// Hard ceiling on caller-supplied expiry (1 year), mirroring the
// invite-link clamp. NULL/absent = never expires.
const MAX_EXPIRY_DAYS = 365;
// Columns safe to expose. `key_hash` is deliberately excluded — it
// never leaves the server.
const SAFE_COLUMNS =
'id, name, key_prefix, scopes, last_used_at, expires_at, revoked_at, created_at';
export async function GET() {
try {
// Any member can view the roster (RLS allows it); we just need a
// resolved account context.
const ctx = await getCurrentAccount();
const { data, error } = await ctx.supabase
.from('api_keys')
.select(SAFE_COLUMNS)
.eq('account_id', ctx.accountId)
.order('created_at', { ascending: false });
if (error) {
console.error('[GET /api/account/api-keys] fetch error:', error);
return NextResponse.json(
{ error: 'Failed to load API keys' },
{ status: 500 }
);
}
return NextResponse.json({ keys: data ?? [] });
} catch (err) {
return toErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireRole('admin');
const limit = checkRateLimit(
`admin:apiKeyCreate:${ctx.userId}`,
RATE_LIMITS.adminAction
);
if (!limit.success) return rateLimitResponse(limit);
const body = (await request.json().catch(() => null)) as {
name?: unknown;
scopes?: unknown;
expiresInDays?: unknown;
} | null;
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
if (!rawName) {
return NextResponse.json(
{ error: "'name' is required" },
{ status: 400 }
);
}
if (rawName.length > MAX_NAME_LEN) {
return NextResponse.json(
{ error: `Name must be ${MAX_NAME_LEN} characters or fewer` },
{ status: 400 }
);
}
// Scopes default to none if omitted — that yields a key that can
// only call the scope-free endpoints (e.g. GET /api/v1/me).
const scopes = normalizeScopes(body?.scopes ?? []);
if (scopes === null) {
return NextResponse.json(
{ error: "'scopes' must be an array of known scope strings" },
{ status: 400 }
);
}
let expiresAt: string | null = null;
const rawExpiry = body?.expiresInDays;
if (
typeof rawExpiry === 'number' &&
Number.isFinite(rawExpiry) &&
rawExpiry > 0
) {
const days = Math.min(Math.floor(rawExpiry), MAX_EXPIRY_DAYS);
expiresAt = new Date(
Date.now() + days * 24 * 60 * 60 * 1000
).toISOString();
}
const { plaintext, hash, prefix } = generateApiKey();
const { data, error } = await ctx.supabase
.from('api_keys')
.insert({
account_id: ctx.accountId,
created_by: ctx.userId,
name: rawName,
key_prefix: prefix,
key_hash: hash,
scopes,
expires_at: expiresAt,
})
.select(SAFE_COLUMNS)
.single();
if (error || !data) {
console.error('[POST /api/account/api-keys] insert error:', error);
return NextResponse.json(
{ error: 'Failed to create API key' },
{ status: 500 }
);
}
return NextResponse.json(
{
key: data,
// Plaintext — shown to the admin exactly once.
plaintext,
},
{ status: 201 }
);
} catch (err) {
return toErrorResponse(err);
}
}

View File

@ -0,0 +1,31 @@
// ============================================================
// GET /api/v1/me — public API identity probe.
//
// The reference endpoint for the public API: it requires nothing
// but a valid key (no scope), and returns the account the key is
// bound to plus the scopes it carries. Integrators use it to verify
// their key works and to discover what it's allowed to do before
// wiring up real calls.
//
// It also exercises the entire public-API stack end to end — bearer
// parse → hash lookup → liveness → rate limit → envelope — so a
// green response here means the plumbing every future endpoint
// depends on is sound.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { getAccountName } from '@/lib/api-keys/store';
import { ok, toApiErrorResponse } from '@/lib/api/v1/respond';
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request);
const name = await getAccountName(ctx.accountId);
return ok({
account: { id: ctx.accountId, name },
key: { id: ctx.keyId, scopes: ctx.scopes },
});
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@ -0,0 +1,481 @@
'use client';
// ============================================================
// ApiKeysSettings — Settings → API keys
//
// Manage the credentials that authenticate the public REST API
// (`/api/v1/*`). Any member sees the roster (read-only); admin+ can
// mint and revoke (gated by <RequireRole min="admin"> here and the
// admin-only API routes + RLS on the server).
//
// One-time reveal: a freshly-minted key's plaintext is shown ONCE in
// the creation dialog. After it closes, only the prefix remains —
// the server stores just the hash. The UI states this explicitly so
// the absence of a "copy again" button reads as intentional, not a
// bug (same lesson as the invite-link flow).
// ============================================================
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RequireRole } from '@/components/auth/require-role';
import { useAuth } from '@/hooks/use-auth';
import {
API_SCOPES,
SCOPE_DESCRIPTIONS,
type ApiScope,
} from '@/lib/api-keys/scopes';
import { SettingsPanelHead } from './settings-panel-head';
interface ApiKey {
id: string;
name: string;
key_prefix: string;
scopes: string[];
last_used_at: string | null;
expires_at: string | null;
revoked_at: string | null;
created_at: string;
}
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function keyStatus(k: ApiKey): 'active' | 'revoked' | 'expired' {
if (k.revoked_at) return 'revoked';
if (k.expires_at && new Date(k.expires_at).getTime() <= Date.now())
return 'expired';
return 'active';
}
export function ApiKeysSettings() {
const { canEditSettings } = useAuth();
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [revoking, setRevoking] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const res = await fetch('/api/account/api-keys', { cache: 'no-store' });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to load API keys');
return;
}
const data = (await res.json()) as { keys: ApiKey[] };
setKeys(data.keys);
} catch (err) {
console.error('[ApiKeysSettings] load error:', err);
toast.error('Could not reach the server');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function handleRevoke(key: ApiKey) {
setRevoking(key.id);
try {
const res = await fetch(`/api/account/api-keys/${key.id}`, {
method: 'DELETE',
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to revoke key');
return;
}
toast.success(`Revoked "${key.name}"`);
// Reflect the revoke locally without a refetch.
setKeys((prev) =>
prev.map((k) =>
k.id === key.id ? { ...k, revoked_at: new Date().toISOString() } : k
)
);
} catch (err) {
console.error('[ApiKeysSettings] revoke error:', err);
toast.error('Could not reach the server');
} finally {
setRevoking(null);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary size-6 animate-spin" />
</div>
);
}
return (
<section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead
title="API keys"
description={
<>
Keys authenticate the public REST API (
<code className="text-xs">/api/v1</code>) so you can build your own
automations. Send them as{' '}
<code className="text-xs">Authorization: Bearer &lt;key&gt;</code>.
</>
}
action={
<RequireRole min="admin">
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New API key
</Button>
</RequireRole>
}
/>
{keys.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
<KeyRound className="text-muted-foreground size-6" />
<p className="text-muted-foreground mt-2 text-sm">
No API keys yet.
</p>
{canEditSettings ? (
<p className="text-muted-foreground mt-1 text-xs">
Click <span className="text-foreground">New API key</span> to
create one.
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Ask an admin to create one.
</p>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<ul className="divide-border divide-y">
{keys.map((k) => {
const status = keyStatus(k);
const inactive = status !== 'active';
return (
<li
key={k.id}
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={`truncate text-sm font-medium ${
inactive
? 'text-muted-foreground line-through'
: 'text-foreground'
}`}
>
{k.name}
</span>
{status === 'revoked' && (
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
Revoked
</Badge>
)}
{status === 'expired' && (
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
Expired
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 font-mono text-xs">
{k.key_prefix}…
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{k.scopes.length === 0 ? (
<span className="text-muted-foreground text-xs">
No scopes
</span>
) : (
k.scopes.map((s) => (
<Badge
key={s}
className="border-border bg-muted text-muted-foreground text-[10px]"
>
{s}
</Badge>
))
)}
</div>
<p className="text-muted-foreground mt-1.5 text-xs">
Created {fmtDate(k.created_at)}
{' · '}
{k.last_used_at
? `last used ${fmtDate(k.last_used_at)}`
: 'never used'}
{k.expires_at && status !== 'expired'
? ` · expires ${fmtDate(k.expires_at)}`
: ''}
</p>
</div>
{status === 'active' && (
<RequireRole min="admin">
<Button
variant="outline"
size="sm"
onClick={() => handleRevoke(k)}
disabled={revoking === k.id}
className="self-start border-red-500/40 bg-red-500/10 text-red-300 hover:border-red-500/60 hover:bg-red-500/20 hover:text-red-200 sm:self-auto"
>
{revoking === k.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
Revoke
</Button>
</RequireRole>
)}
</li>
);
})}
</ul>
</CardContent>
</Card>
)}
<CreateKeyDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={load}
/>
</section>
);
}
// ------------------------------------------------------------
// Create dialog — form → one-time plaintext reveal.
// ------------------------------------------------------------
function CreateKeyDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}) {
const [name, setName] = useState('');
const [scopes, setScopes] = useState<ApiScope[]>([]);
const [submitting, setSubmitting] = useState(false);
// Once set, we switch from the form to the reveal view.
const [createdKey, setCreatedKey] = useState<string | null>(null);
function reset() {
setName('');
setScopes([]);
setSubmitting(false);
setCreatedKey(null);
}
function toggleScope(scope: ApiScope, checked: boolean) {
setScopes((prev) =>
checked ? [...prev, scope] : prev.filter((s) => s !== scope)
);
}
async function handleCreate() {
const trimmed = name.trim();
if (!trimmed) {
toast.error('Give the key a name');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/account/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed, scopes }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
toast.error(payload.error || 'Failed to create key');
return;
}
setCreatedKey(payload.plaintext as string);
onCreated();
} catch (err) {
console.error('[CreateKeyDialog] create error:', err);
toast.error('Could not reach the server');
} finally {
setSubmitting(false);
}
}
async function copyKey() {
if (!createdKey) return;
try {
await navigator.clipboard.writeText(createdKey);
toast.success('API key copied');
} catch {
toast.error('Copy failed — select and copy manually');
}
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) reset();
onOpenChange(next);
}}
>
<DialogContent className="border-border bg-popover sm:max-w-md">
{createdKey ? (
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">
Copy your API key
</DialogTitle>
<DialogDescription className="text-muted-foreground">
This is the only time the full key is shown. Store it somewhere
safe — if you lose it, revoke it and create a new one.
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label className="text-muted-foreground">API key</Label>
<div className="flex gap-2">
<Input
readOnly
value={createdKey}
className="font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
<Button type="button" variant="outline" onClick={copyKey}>
<Copy className="size-4" />
Copy
</Button>
</div>
</div>
<DialogFooter>
<Button
onClick={() => {
reset();
onOpenChange(false);
}}
>
Done
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">
New API key
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Name it after the integration that will use it, and grant only
the scopes it needs.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="api-key-name" className="text-muted-foreground">
Name
</Label>
<Input
id="api-key-name"
value={name}
maxLength={80}
placeholder="e.g. Zapier automation"
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Scopes</Label>
<div className="border-border space-y-2 rounded-md border p-3">
{API_SCOPES.map((scope) => (
<label
key={scope}
className="flex cursor-pointer items-start gap-2.5"
>
<Checkbox
checked={scopes.includes(scope)}
onCheckedChange={(checked) =>
toggleScope(scope, checked === true)
}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="text-foreground block font-mono text-xs">
{scope}
</span>
<span className="text-muted-foreground block text-xs">
{SCOPE_DESCRIPTIONS[scope]}
</span>
</span>
</label>
))}
</div>
<p className="text-muted-foreground text-xs">
A key with no scopes can still call{' '}
<code className="text-[11px]">GET /api/v1/me</code> to verify
it works.
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
reset();
onOpenChange(false);
}}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button onClick={handleCreate} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating…
</>
) : (
'Create key'
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -1,6 +1,7 @@
import { import {
Coins, Coins,
FileText, FileText,
KeyRound,
LayoutGrid, LayoutGrid,
Palette, Palette,
PlugZap, PlugZap,
@ -29,6 +30,7 @@ export const SETTINGS_SECTIONS = [
'fields', 'fields',
'deals', 'deals',
'members', 'members',
'api',
] as const; ] as const;
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]; export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
@ -53,6 +55,7 @@ export const SECTION_META: Record<SettingsSection, SectionMeta> = {
fields: { id: 'fields', label: 'Fields & tags', icon: Tags, group: 'workspace' }, fields: { id: 'fields', label: 'Fields & tags', icon: Tags, group: 'workspace' },
deals: { id: 'deals', label: 'Deals & currency', icon: Coins, group: 'workspace' }, deals: { id: 'deals', label: 'Deals & currency', icon: Coins, group: 'workspace' },
members: { id: 'members', label: 'Team members', icon: UsersRound, group: 'workspace' }, members: { id: 'members', label: 'Team members', icon: UsersRound, group: 'workspace' },
api: { id: 'api', label: 'API keys', icon: KeyRound, group: 'workspace' },
}; };
export const RAIL_GROUPS: { label: string | null; group: SectionMeta['group'] }[] = [ export const RAIL_GROUPS: { label: string | null; group: SectionMeta['group'] }[] = [

View File

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import {
API_KEY_PREFIX,
generateApiKey,
hashApiKey,
looksLikeApiKey,
timingSafeHexEqual,
} from './keys';
describe('generateApiKey', () => {
it('returns a prefixed plaintext, a hash, and a display prefix', () => {
const { plaintext, hash, prefix } = generateApiKey();
expect(plaintext.startsWith(API_KEY_PREFIX)).toBe(true);
expect(plaintext.length).toBeGreaterThan(API_KEY_PREFIX.length + 20);
// SHA-256 hex is 64 chars.
expect(hash).toMatch(/^[0-9a-f]{64}$/);
// Display prefix is the literal prefix + 8 body chars.
expect(prefix.startsWith(API_KEY_PREFIX)).toBe(true);
expect(prefix.length).toBe(API_KEY_PREFIX.length + 8);
// The display prefix is a true prefix of the plaintext.
expect(plaintext.startsWith(prefix)).toBe(true);
});
it('never repeats a key (entropy sanity check)', () => {
const seen = new Set<string>();
for (let i = 0; i < 200; i++) seen.add(generateApiKey().plaintext);
expect(seen.size).toBe(200);
});
it('hash matches an independent hashApiKey of the plaintext', () => {
const { plaintext, hash } = generateApiKey();
expect(hashApiKey(plaintext)).toBe(hash);
});
});
describe('hashApiKey', () => {
it('is deterministic', () => {
expect(hashApiKey('wacrm_live_abc')).toBe(hashApiKey('wacrm_live_abc'));
});
it('differs for different inputs', () => {
expect(hashApiKey('wacrm_live_abc')).not.toBe(hashApiKey('wacrm_live_abd'));
});
});
describe('looksLikeApiKey', () => {
it('accepts a well-formed key', () => {
expect(looksLikeApiKey(generateApiKey().plaintext)).toBe(true);
});
it('rejects the bare prefix, empty, and foreign tokens', () => {
expect(looksLikeApiKey(API_KEY_PREFIX)).toBe(false);
expect(looksLikeApiKey('')).toBe(false);
expect(looksLikeApiKey('some-invite-token')).toBe(false);
});
});
describe('timingSafeHexEqual', () => {
it('is true for identical digests', () => {
const h = hashApiKey('wacrm_live_xyz');
expect(timingSafeHexEqual(h, h)).toBe(true);
});
it('is false for different digests', () => {
expect(timingSafeHexEqual(hashApiKey('a'), hashApiKey('b'))).toBe(false);
});
it('is false (not throwing) on length mismatch', () => {
expect(timingSafeHexEqual('ab', 'abcd')).toBe(false);
});
});

93
src/lib/api-keys/keys.ts Normal file
View File

@ -0,0 +1,93 @@
// ============================================================
// API key generation + hashing — pure, server-side, no Supabase.
//
// Mirrors the invite-token utilities in `src/lib/auth/invitations.ts`:
// the DB stores only the SHA-256 hash, the plaintext is shown to the
// creator exactly once. See migration 026 for the rationale.
//
// Why SHA-256 (not bcrypt/argon2)
// API keys are full-entropy random strings (32 CSPRNG bytes), not
// user-chosen passwords. There is no dictionary to attack and no
// rainbow table that helps, so a slow KDF buys nothing — it would
// only slow the per-request auth lookup. A fast hash with a UNIQUE
// index is the correct, indexable choice for opaque secrets.
//
// Why the `wacrm_live_` prefix
// - Self-identifying: a leaked string is instantly recognisable as
// a wacrm key (handy for secret-scanners like GitGuardian).
// - Forward-compatible: leaves room for a `wacrm_test_` variant if
// a sandbox mode is ever added, without reshaping the format.
// ============================================================
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
/** Secret prefix on every key. Part of the plaintext, not a secret. */
export const API_KEY_PREFIX = 'wacrm_live_';
/**
* Length of the non-secret display prefix stored in `key_prefix` and
* shown in the dashboard: the literal prefix plus the first 8 chars
* of the random body. Enough to tell two keys apart at a glance,
* far too little to brute-force the remaining ~248 bits.
*/
const DISPLAY_BODY_CHARS = 8;
export interface GeneratedApiKey {
/** Plaintext key — return to the creator ONCE, never persist. */
plaintext: string;
/** SHA-256 hex digest. Persist this in `api_keys.key_hash`. */
hash: string;
/** Non-secret display string. Persist this in `api_keys.key_prefix`. */
prefix: string;
}
/**
* Generate a fresh API key + its hash + its display prefix. Call
* once per key creation; the plaintext is shown to the admin in the
* creation modal and never again.
*/
export function generateApiKey(): GeneratedApiKey {
// 32 bytes of CSPRNG entropy. base64url keeps it URL/header-safe
// and shorter than hex (43 vs 64 chars).
const body = randomBytes(32).toString('base64url');
const plaintext = `${API_KEY_PREFIX}${body}`;
return {
plaintext,
hash: hashApiKey(plaintext),
prefix: `${API_KEY_PREFIX}${body.slice(0, DISPLAY_BODY_CHARS)}`,
};
}
/**
* Deterministic SHA-256 of a plaintext key. Used at auth time to
* look up the matching `api_keys` row by `key_hash`. Pure — same
* input always produces the same output.
*/
export function hashApiKey(plaintext: string): string {
return createHash('sha256').update(plaintext).digest('hex');
}
/**
* Structural check that a string looks like one of our keys before
* we bother hashing + hitting the DB. Cheap reject for obviously
* malformed `Authorization` headers (e.g. a stale invite token).
*/
export function looksLikeApiKey(value: string): boolean {
return (
value.startsWith(API_KEY_PREFIX) && value.length > API_KEY_PREFIX.length
);
}
/**
* Constant-time comparison of two hex digests. The lookup is by an
* indexed UNIQUE column so an attacker can't easily probe timing,
* but comparing the hashes in constant time anyway costs nothing and
* removes the question. Returns false on any length mismatch (the
* underlying `timingSafeEqual` throws on unequal lengths).
*/
export function timingSafeHexEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'hex');
const bufB = Buffer.from(b, 'hex');
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}

View File

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
API_SCOPES,
SCOPE_DESCRIPTIONS,
hasScope,
isApiScope,
normalizeScopes,
} from './scopes';
describe('isApiScope', () => {
it('accepts every declared scope', () => {
for (const s of API_SCOPES) expect(isApiScope(s)).toBe(true);
});
it('rejects unknown strings and non-strings', () => {
expect(isApiScope('messages:delete')).toBe(false);
expect(isApiScope('')).toBe(false);
expect(isApiScope(null)).toBe(false);
expect(isApiScope(42)).toBe(false);
});
});
describe('normalizeScopes', () => {
it('passes a valid list through, de-duplicated', () => {
expect(
normalizeScopes(['messages:send', 'messages:send', 'contacts:read'])
).toEqual(['messages:send', 'contacts:read']);
});
it('treats an empty array as valid (key with no scopes)', () => {
expect(normalizeScopes([])).toEqual([]);
});
it('returns null if any entry is not a known scope', () => {
expect(normalizeScopes(['messages:send', 'bogus'])).toBeNull();
});
it('returns null for non-array input', () => {
expect(normalizeScopes('messages:send')).toBeNull();
expect(normalizeScopes(undefined)).toBeNull();
});
});
describe('hasScope', () => {
it('is true when the scope is present', () => {
expect(hasScope(['messages:send', 'contacts:read'], 'contacts:read')).toBe(
true
);
});
it('is false when the scope is absent or the list is empty', () => {
expect(hasScope(['messages:send'], 'contacts:read')).toBe(false);
expect(hasScope([], 'messages:send')).toBe(false);
});
});
describe('SCOPE_DESCRIPTIONS', () => {
it('has a description for every scope', () => {
for (const s of API_SCOPES) {
expect(SCOPE_DESCRIPTIONS[s]).toBeTruthy();
}
});
});

View File

@ -0,0 +1,73 @@
// ============================================================
// API key scopes — pure, unit-testable, no I/O.
//
// Authorization for the public API is *scopes-only*: a key's
// capabilities are defined entirely by the scopes granted to it at
// creation, independent of the role of the user who minted it. (We
// still gate *key creation* at admin+, so only trusted members can
// hand out capabilities — see the management routes.)
//
// A scope is `<resource>:<action>`. Endpoints declare the single
// scope they require; `requireApiKey(request, scope)` enforces it.
// Adding a capability = one entry here + the endpoint that checks
// it. No migration needed (the DB stores scopes as a free `text[]`).
// ============================================================
export const API_SCOPES = [
'messages:send',
'messages:read',
'contacts:read',
'contacts:write',
'conversations:read',
'broadcasts:send',
] as const;
export type ApiScope = (typeof API_SCOPES)[number];
/** Human-readable descriptions, surfaced in the key-creation UI. */
export const SCOPE_DESCRIPTIONS: Record<ApiScope, string> = {
'messages:send': 'Send WhatsApp messages',
'messages:read': 'Read messages and their delivery status',
'contacts:read': 'List and read contacts',
'contacts:write': 'Create and update contacts',
'conversations:read': 'List and read conversations',
'broadcasts:send': 'Launch broadcast campaigns',
};
/** Type-narrow an unknown value into a valid `ApiScope`. */
export function isApiScope(value: unknown): value is ApiScope {
return (
typeof value === 'string' &&
(API_SCOPES as readonly string[]).includes(value)
);
}
/**
* Validate and de-duplicate a caller-supplied scope list. Returns
* the cleaned list, or `null` if any entry is not a known scope
* (callers turn that into a 400). An empty input is valid — it
* yields a key that authenticates but can't do anything beyond the
* scope-free endpoints (e.g. `GET /api/v1/me`).
*/
export function normalizeScopes(input: unknown): ApiScope[] | null {
if (!Array.isArray(input)) return null;
const out: ApiScope[] = [];
for (const entry of input) {
if (!isApiScope(entry)) return null;
if (!out.includes(entry)) out.push(entry);
}
return out;
}
/**
* True iff `granted` contains `required`. The single source of
* truth for "is this key allowed to do X?" — both `requireApiKey`
* and any future inline check should call this rather than poking
* at the array directly.
*/
export function hasScope(
granted: readonly string[],
required: ApiScope
): boolean {
return granted.includes(required);
}

94
src/lib/api-keys/store.ts Normal file
View File

@ -0,0 +1,94 @@
// ============================================================
// API key store — the *auth-path* data access for public API keys.
//
// Only the read side lives here, and deliberately so: it runs with
// the service-role client because a public-API caller has no Supabase
// session, so RLS (which keys off `auth.uid()`) can't scope the
// lookup. The management side (list / create / revoke) runs in the
// dashboard under a real cookie session and goes through the RLS
// client *inline* in the route handlers — same pattern as
// `/api/account/invitations`. Keeping the RLS-bypassing surface tiny
// and read-only here makes it easy to audit.
// ============================================================
import { supabaseAdmin } from '@/lib/flows/admin-client';
/** Shape of an `api_keys` row as the auth path consumes it. */
export interface ApiKeyRow {
id: string;
account_id: string;
created_by: string | null;
name: string;
scopes: string[];
expires_at: string | null;
revoked_at: string | null;
}
/**
* Look up an *active* key by its SHA-256 hash. Returns null if no
* row matches, or if the matching row is revoked or expired — so
* callers never have to re-check liveness. Uses the service-role
* client (RLS-bypassing); the hash is the only credential, so this
* is the moment that establishes the caller's account.
*/
export async function findActiveKeyByHash(
hash: string
): Promise<ApiKeyRow | null> {
const { data, error } = await supabaseAdmin()
.from('api_keys')
.select('id, account_id, created_by, name, scopes, expires_at, revoked_at')
.eq('key_hash', hash)
.maybeSingle();
if (error) {
console.error('[api-keys/store] lookup error:', error.message);
return null;
}
if (!data) return null;
// Liveness checks in JS rather than SQL so the failure modes are
// explicit and the index stays a simple equality lookup.
if (data.revoked_at) return null;
if (data.expires_at && new Date(data.expires_at).getTime() <= Date.now()) {
return null;
}
return data as ApiKeyRow;
}
/**
* Fetch the account name for a resolved key, so `/api/v1/me` and any
* future endpoint can echo it without a second round trip in the
* route. Service-role; the key already proved account membership.
*/
export async function getAccountName(
accountId: string
): Promise<string | null> {
const { data, error } = await supabaseAdmin()
.from('accounts')
.select('name')
.eq('id', accountId)
.maybeSingle();
if (error || !data) return null;
return (data.name as string) ?? null;
}
/**
* Best-effort `last_used_at` bump. Fire-and-forget from the auth
* path — a failed update just means the "last used" column lags;
* it must never fail the request the caller is actually making.
*/
export function touchLastUsed(id: string): void {
void supabaseAdmin()
.from('api_keys')
.update({ last_used_at: new Date().toISOString() })
.eq('id', id)
.then(({ error }) => {
if (error) {
console.warn(
'[api-keys/store] last_used_at bump failed:',
error.message
);
}
});
}

106
src/lib/api/v1/respond.ts Normal file
View File

@ -0,0 +1,106 @@
// ============================================================
// Public API (v1) response envelope.
//
// Every `/api/v1/*` route speaks one shape so external integrators
// can write a single response parser:
//
// success → { "data": <payload> }
// failure → { "error": { "code": "<machine_code>", "message": "<human>" } }
//
// `code` is a stable, machine-matchable string (clients branch on
// it); `message` is human-facing and may be reworded freely. This is
// intentionally distinct from the internal `{ error: string }` shape
// used by the dashboard's own `/api/*` routes — the public contract
// is versioned and shouldn't inherit internal wording changes.
// ============================================================
import { NextResponse } from 'next/server';
import type { RateLimitResult } from '@/lib/rate-limit';
export type ApiErrorCode =
| 'unauthorized' // missing / malformed / unknown / revoked / expired key
| 'forbidden' // valid key, but missing the required scope
| 'rate_limited' // per-key budget exhausted
| 'bad_request' // malformed input
| 'not_found'
| 'internal';
/**
* Typed error a route (or `requireApiKey`) can throw and have mapped
* to the envelope by `toApiErrorResponse`. Carries an HTTP status, a
* machine code, and optional extra headers (used for the rate-limit
* `Retry-After` / `X-RateLimit-*` set).
*/
export class ApiError extends Error {
readonly code: ApiErrorCode;
readonly status: number;
readonly headers?: Record<string, string>;
constructor(
code: ApiErrorCode,
message: string,
status: number,
headers?: Record<string, string>
) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
this.headers = headers;
}
}
/** 401 — no usable credential. */
export function unauthorized(message = 'Missing or invalid API key'): ApiError {
return new ApiError('unauthorized', message, 401);
}
/** 403 — authenticated, but the key lacks the scope this route needs. */
export function forbidden(message: string): ApiError {
return new ApiError('forbidden', message, 403);
}
/** 400 — bad input. */
export function badRequest(message: string): ApiError {
return new ApiError('bad_request', message, 400);
}
/** 429 — built from a `checkRateLimit` miss, with the standard headers. */
export function rateLimited(result: RateLimitResult): ApiError {
const retryAfter = Math.max(1, Math.ceil((result.reset - Date.now()) / 1000));
return new ApiError(
'rate_limited',
'Rate limit exceeded for this API key',
429,
{
'Retry-After': String(retryAfter),
'X-RateLimit-Limit': String(result.limit),
'X-RateLimit-Remaining': String(result.remaining),
'X-RateLimit-Reset': String(Math.ceil(result.reset / 1000)),
}
);
}
/** Success envelope: `{ data: <payload> }`. */
export function ok<T>(data: T, status = 200): NextResponse {
return NextResponse.json({ data }, { status });
}
/**
* Map any thrown value to the failure envelope. `ApiError` keeps its
* code/status/headers; anything else collapses to a generic 500 so we
* never leak internal error text onto the public wire.
*/
export function toApiErrorResponse(err: unknown): NextResponse {
if (err instanceof ApiError) {
return NextResponse.json(
{ error: { code: err.code, message: err.message } },
{ status: err.status, headers: err.headers }
);
}
console.error('[api/v1] uncategorized error:', err);
return NextResponse.json(
{ error: { code: 'internal', message: 'Internal server error' } },
{ status: 500 }
);
}

View File

@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { generateApiKey } from "@/lib/api-keys/keys";
import type { ApiKeyRow } from "@/lib/api-keys/store";
import { ApiError } from "@/lib/api/v1/respond";
import { __resetRateLimitForTests, RATE_LIMITS } from "@/lib/rate-limit";
// Mock the service-role client factory — requireApiKey only stashes
// the returned client in the context; tests never call through it.
vi.mock("@/lib/flows/admin-client", () => ({
supabaseAdmin: () => ({ __isMockAdminClient: true }),
}));
// Mock the store so we control which row a hash resolves to.
const findActiveKeyByHash = vi.fn<(hash: string) => Promise<ApiKeyRow | null>>();
const touchLastUsed = vi.fn();
vi.mock("@/lib/api-keys/store", () => ({
findActiveKeyByHash: (hash: string) => findActiveKeyByHash(hash),
touchLastUsed: (id: string) => touchLastUsed(id),
}));
// Import AFTER the mocks are registered.
const { requireApiKey } = await import("./api-context");
const KEY = generateApiKey().plaintext;
function reqWith(authHeader?: string): Request {
return new Request("https://crm.example.com/api/v1/me", {
headers: authHeader ? { authorization: authHeader } : {},
});
}
function row(overrides: Partial<ApiKeyRow> = {}): ApiKeyRow {
return {
id: "key-1",
account_id: "acct-1",
created_by: "user-1",
name: "Test key",
scopes: ["messages:send"],
expires_at: null,
revoked_at: null,
...overrides,
};
}
beforeEach(() => {
__resetRateLimitForTests();
findActiveKeyByHash.mockReset();
touchLastUsed.mockReset();
});
afterEach(() => {
__resetRateLimitForTests();
});
async function expectApiError(p: Promise<unknown>, code: string, status: number) {
await expect(p).rejects.toBeInstanceOf(ApiError);
await p.catch((e: unknown) => {
const err = e as ApiError;
expect(err.code).toBe(code);
expect(err.status).toBe(status);
});
}
describe("requireApiKey", () => {
it("401s when no Authorization header is present", async () => {
await expectApiError(requireApiKey(reqWith()), "unauthorized", 401);
expect(findActiveKeyByHash).not.toHaveBeenCalled();
});
it("401s on a token that doesn't look like a wacrm key", async () => {
await expectApiError(
requireApiKey(reqWith("Bearer some-invite-token")),
"unauthorized",
401,
);
expect(findActiveKeyByHash).not.toHaveBeenCalled();
});
it("401s when the key is unknown / revoked / expired (store returns null)", async () => {
findActiveKeyByHash.mockResolvedValue(null);
await expectApiError(
requireApiKey(reqWith(`Bearer ${KEY}`)),
"unauthorized",
401,
);
});
it("returns a context for a valid key with no scope required", async () => {
findActiveKeyByHash.mockResolvedValue(row());
const ctx = await requireApiKey(reqWith(`Bearer ${KEY}`));
expect(ctx.authType).toBe("api_key");
expect(ctx.accountId).toBe("acct-1");
expect(ctx.keyId).toBe("key-1");
expect(ctx.scopes).toEqual(["messages:send"]);
expect(touchLastUsed).toHaveBeenCalledWith("key-1");
});
it("accepts a bare key without the 'Bearer ' prefix", async () => {
findActiveKeyByHash.mockResolvedValue(row());
const ctx = await requireApiKey(reqWith(KEY));
expect(ctx.accountId).toBe("acct-1");
});
it("403s when the key lacks the required scope", async () => {
findActiveKeyByHash.mockResolvedValue(row({ scopes: ["contacts:read"] }));
await expectApiError(
requireApiKey(reqWith(`Bearer ${KEY}`), "messages:send"),
"forbidden",
403,
);
});
it("passes when the key has the required scope", async () => {
findActiveKeyByHash.mockResolvedValue(row({ scopes: ["messages:send"] }));
const ctx = await requireApiKey(reqWith(`Bearer ${KEY}`), "messages:send");
expect(ctx.accountId).toBe("acct-1");
});
it("429s once the per-key budget is exhausted", async () => {
findActiveKeyByHash.mockResolvedValue(row());
// Burn the whole window.
for (let i = 0; i < RATE_LIMITS.publicApi.limit; i++) {
await requireApiKey(reqWith(`Bearer ${KEY}`));
}
await expectApiError(
requireApiKey(reqWith(`Bearer ${KEY}`)),
"rate_limited",
429,
);
});
});

118
src/lib/auth/api-context.ts Normal file
View File

@ -0,0 +1,118 @@
// ============================================================
// Public API authentication — resolve a request's API key into an
// account context.
//
// This is the machine-to-machine counterpart of `getCurrentAccount`
// (cookie session → account). Where the dashboard authenticates a
// human via Supabase cookies, the public API authenticates a caller
// via `Authorization: Bearer wacrm_live_…`.
//
// Calling convention — every `/api/v1` route does:
//
// try {
// const ctx = await requireApiKey(request, "messages:send");
// // ctx.supabase — service-role client (no user session exists)
// // ctx.accountId — the key's account; scope every query by it
// // ctx.scopes — granted scopes
// // ctx.keyId — for logging / the rate-limit bucket
// } catch (err) {
// return toApiErrorResponse(err); // maps ApiError → envelope
// }
//
// Why a service-role client: an API caller has no Supabase session,
// so there's no `auth.uid()` for RLS to match. The key lookup itself
// establishes the account; from there every downstream query MUST be
// explicitly filtered by `ctx.accountId` (the same discipline the
// dashboard's send route already follows). The key never escalates
// past its own account because the account is fixed at lookup time.
// ============================================================
import type { SupabaseClient } from '@supabase/supabase-js';
import { supabaseAdmin } from '@/lib/flows/admin-client';
import { findActiveKeyByHash, touchLastUsed } from '@/lib/api-keys/store';
import { hashApiKey, looksLikeApiKey } from '@/lib/api-keys/keys';
import { hasScope, type ApiScope } from '@/lib/api-keys/scopes';
import { forbidden, rateLimited, unauthorized } from '@/lib/api/v1/respond';
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit';
export interface ApiKeyContext {
/** Discriminant — lets shared logic tell key auth from cookie auth. */
authType: 'api_key';
/** Service-role Supabase client. RLS-bypassing; scope by accountId. */
supabase: SupabaseClient;
/** The account this key belongs to. */
accountId: string;
/** The key row id — for audit logging and the rate-limit bucket. */
keyId: string;
/** Scopes granted to this key. */
scopes: string[];
/** Who minted the key (null if that user was later removed). */
createdBy: string | null;
}
/**
* Extract the bearer token from the `Authorization` header.
* Tolerates the `Bearer ` prefix being absent (some clients send the
* bare key) but requires the value to look like one of our keys.
*/
function extractKey(request: Request): string | null {
const header = request.headers.get('authorization');
if (!header) return null;
const value = header.startsWith('Bearer ')
? header.slice('Bearer '.length).trim()
: header.trim();
return value.length > 0 ? value : null;
}
/**
* Authenticate a public-API request and (optionally) enforce a
* single scope. Throws an `ApiError` (mapped to the envelope by
* `toApiErrorResponse`) on any failure:
*
* 401 unauthorized — no key, malformed, unknown, revoked, expired
* 403 forbidden — valid key without the required scope
* 429 rate_limited — per-key budget exhausted
*
* On success, bumps `last_used_at` (fire-and-forget) and returns the
* account context.
*/
export async function requireApiKey(
request: Request,
scope?: ApiScope
): Promise<ApiKeyContext> {
const presented = extractKey(request);
if (!presented || !looksLikeApiKey(presented)) {
throw unauthorized();
}
const row = await findActiveKeyByHash(hashApiKey(presented));
if (!row) {
// Covers unknown, revoked, and expired keys alike — we don't
// distinguish them on the wire so a probe can't learn whether a
// key ever existed.
throw unauthorized();
}
// Rate-limit per key, before the scope check, so an unauthorized-
// scope caller still can't hammer the endpoint for free.
const limit = checkRateLimit(`apikey:${row.id}`, RATE_LIMITS.publicApi);
if (!limit.success) {
throw rateLimited(limit);
}
if (scope && !hasScope(row.scopes, scope)) {
throw forbidden(`This API key is missing the '${scope}' scope`);
}
touchLastUsed(row.id);
return {
authType: 'api_key',
supabase: supabaseAdmin(),
accountId: row.account_id,
keyId: row.id,
scopes: row.scopes,
createdBy: row.created_by,
};
}

View File

@ -141,6 +141,13 @@ export const RATE_LIMITS = {
* while still bounding accidental abuse from a script run in a * while still bounding accidental abuse from a script run in a
* loop or a compromised admin session spamming role flips. */ * loop or a compromised admin session spamming role flips. */
adminAction: { limit: 30, windowMs: 60_000 }, adminAction: { limit: 30, windowMs: 60_000 },
/** Public REST API (`/api/v1/*`), keyed per API key. 120/min ≈ 2
* req/s sustained — comfortable for a polling integration or an
* automation firing on inbound events, while bounding a runaway
* script. Like every bucket here it's per-process; a multi-
* instance deploy needs the Redis swap described at the top of
* this file (the per-key call sites don't change). */
publicApi: { limit: 120, windowMs: 60_000 },
} as const; } as const;
/** Test-only helper. Clears the in-memory state so unit tests don't /** Test-only helper. Clears the in-memory state so unit tests don't

View File

@ -0,0 +1,84 @@
-- ============================================================
-- 026_api_keys.sql — Public API credentials (groundwork)
--
-- Adds the `api_keys` table backing the public REST API
-- (`/api/v1/*`). A key authenticates a *machine* caller (a script,
-- an n8n/Zapier-style automation, a cron) against one account, the
-- same way the cookie session authenticates a *human* in the
-- dashboard.
--
-- Design notes
-- - Account-scoped, never user-scoped. A key belongs to the
-- account; `created_by` only records who minted it (audit), and
-- is ON DELETE SET NULL so removing a teammate doesn't cascade-
-- delete the keys their automations still depend on.
-- - We store only the SHA-256 *hash* of the key, never plaintext.
-- A leaked DB snapshot (backup, log, support export) therefore
-- can't be replayed against the API — the caller would need the
-- original key, which is returned exactly once at creation. Same
-- pattern as `account_invitations.token_hash` (migration 017/019).
-- - `key_prefix` is a short, non-secret display string
-- (`wacrm_live_a1b2c3d4`) so the dashboard can show "which key
-- is this" in a list without ever resurfacing the secret.
-- - Authorization is by `scopes[]` (scopes-only model), resolved
-- in the application layer (`src/lib/api-keys/scopes.ts`). The
-- DB doesn't constrain the scope vocabulary — a future scope is
-- a code change, not a migration.
--
-- RLS
-- `api_keys` is a settings-class table: any member may *read* the
-- roster of keys for their account; only admin+ may create/revoke
-- (mirrors the `tags` / `custom_fields` policies in 017). The
-- public-API auth path itself reads keys with the service-role
-- client (RLS-bypassing) because an API caller has no Supabase
-- session and therefore no `auth.uid()` for a policy to match.
--
-- Idempotent — safe to run multiple times. Table uses IF NOT
-- EXISTS; policies are dropped before recreate (Postgres has no
-- CREATE POLICY IF NOT EXISTS).
-- ============================================================
CREATE TABLE IF NOT EXISTS api_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
name text NOT NULL,
key_prefix text NOT NULL, -- display only, e.g. "wacrm_live_a1b2c3d4"
key_hash text NOT NULL UNIQUE, -- SHA-256 hex of the full plaintext key
scopes text[] NOT NULL DEFAULT '{}',
last_used_at timestamptz,
expires_at timestamptz, -- NULL = never expires
revoked_at timestamptz, -- NULL = active
created_at timestamptz NOT NULL DEFAULT now()
);
-- account_id: every "list this account's keys" query filters on it.
CREATE INDEX IF NOT EXISTS api_keys_account_id_idx ON api_keys (account_id);
-- key_hash: the hot path is the per-request auth lookup by hash. The
-- UNIQUE constraint already creates an index, but spell it out so the
-- intent (this is the lookup key) is documented and survives a future
-- drop of the UNIQUE constraint.
CREATE INDEX IF NOT EXISTS api_keys_key_hash_idx ON api_keys (key_hash);
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
-- SELECT: any member of the account (viewer+) can see the roster.
-- key_hash is in the table but the dashboard never selects it.
DROP POLICY IF EXISTS api_keys_select ON api_keys;
CREATE POLICY api_keys_select ON api_keys FOR SELECT
USING (is_account_member(account_id));
-- INSERT / UPDATE / DELETE: admin+ only (settings-class). Revoking a
-- key is an UPDATE that sets `revoked_at`; we keep DELETE available
-- too for operators who'd rather hard-delete.
DROP POLICY IF EXISTS api_keys_insert ON api_keys;
CREATE POLICY api_keys_insert ON api_keys FOR INSERT
WITH CHECK (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS api_keys_update ON api_keys;
CREATE POLICY api_keys_update ON api_keys FOR UPDATE
USING (is_account_member(account_id, 'admin'));
DROP POLICY IF EXISTS api_keys_delete ON api_keys;
CREATE POLICY api_keys_delete ON api_keys FOR DELETE
USING (is_account_member(account_id, 'admin'));