mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
12438dca78
commit
456321a2f2
@ -7,8 +7,14 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
|
||||
# Supabase service-role key. Bypasses RLS; used only by server-side
|
||||
# routes (the webhook and automation engine). Keep this secret —
|
||||
# never paste it into client code.
|
||||
# routes (the webhook, automation engine, and the public API key
|
||||
# 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
|
||||
|
||||
# WhatsApp token encryption (64 hex chars = 32 bytes, AES-256-GCM).
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@ -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
|
||||
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
|
||||
|
||||
- **Tenancy moves from per-user to per-account.** RLS on every
|
||||
|
||||
@ -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
|
||||
team. Solo use stays single-user with zero setup.
|
||||
- **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?
|
||||
|
||||
|
||||
130
docs/public-api.md
Normal file
130
docs/public-api.md
Normal 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)
|
||||
@ -15,6 +15,7 @@ import { TemplateManager } from '@/components/settings/template-manager';
|
||||
import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel';
|
||||
import { DealsSettings } from '@/components/settings/deals-settings';
|
||||
import { MembersTab } from '@/components/settings/members-tab';
|
||||
import { ApiKeysSettings } from '@/components/settings/api-keys-settings';
|
||||
import {
|
||||
resolveSection,
|
||||
type SettingsSection,
|
||||
@ -59,6 +60,7 @@ export default function SettingsPage() {
|
||||
fields: <FieldsAndTagsPanel />,
|
||||
deals: <DealsSettings />,
|
||||
members: <MembersTab />,
|
||||
api: <ApiKeysSettings />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
71
src/app/api/account/api-keys/[id]/route.ts
Normal file
71
src/app/api/account/api-keys/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
159
src/app/api/account/api-keys/route.ts
Normal file
159
src/app/api/account/api-keys/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/app/api/v1/me/route.ts
Normal file
31
src/app/api/v1/me/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
481
src/components/settings/api-keys-settings.tsx
Normal file
481
src/components/settings/api-keys-settings.tsx
Normal 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 <key></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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
Coins,
|
||||
FileText,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
Palette,
|
||||
PlugZap,
|
||||
@ -29,6 +30,7 @@ export const SETTINGS_SECTIONS = [
|
||||
'fields',
|
||||
'deals',
|
||||
'members',
|
||||
'api',
|
||||
] as const;
|
||||
|
||||
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' },
|
||||
deals: { id: 'deals', label: 'Deals & currency', icon: Coins, 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'] }[] = [
|
||||
|
||||
71
src/lib/api-keys/keys.test.ts
Normal file
71
src/lib/api-keys/keys.test.ts
Normal 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
93
src/lib/api-keys/keys.ts
Normal 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);
|
||||
}
|
||||
63
src/lib/api-keys/scopes.test.ts
Normal file
63
src/lib/api-keys/scopes.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
73
src/lib/api-keys/scopes.ts
Normal file
73
src/lib/api-keys/scopes.ts
Normal 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
94
src/lib/api-keys/store.ts
Normal 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
106
src/lib/api/v1/respond.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
132
src/lib/auth/api-context.test.ts
Normal file
132
src/lib/auth/api-context.test.ts
Normal 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
118
src/lib/auth/api-context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -141,6 +141,13 @@ export const RATE_LIMITS = {
|
||||
* while still bounding accidental abuse from a script run in a
|
||||
* loop or a compromised admin session spamming role flips. */
|
||||
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;
|
||||
|
||||
/** Test-only helper. Clears the in-memory state so unit tests don't
|
||||
|
||||
84
supabase/migrations/026_api_keys.sql
Normal file
84
supabase/migrations/026_api_keys.sql
Normal 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'));
|
||||
Loading…
Reference in New Issue
Block a user