Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"prom-client": "^15.1.3",
"swagger-ui-express": "^5.0.1",
"undici": "^6.27.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^4.4.3"
Expand All @@ -65,6 +66,7 @@
"@types/node": "^22.14.1",
"@types/pino-http": "^5.8.4",
"@types/supertest": "^7.2.0",
"@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"@vitest/coverage-v8": "^4.1.7",
Expand Down
74 changes: 74 additions & 0 deletions apps/api/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,77 @@ create index if not exists idx_user_api_keys_user

alter table public.user_api_keys enable row level security;

-- ── Developer platform: programmatic API keys ───────────────────────────────
-- Long-lived credentials a developer mints to call the API from scripts/CI.
-- We store only a SHA-256 hash of the secret (never the secret itself) plus a
-- short non-secret prefix for display. See apps/api/src/lib/apiKeys.ts.
create table if not exists public.api_keys (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
name text not null,
key_prefix text not null,
key_hash text not null unique,
scopes text[] not null default array['read','write'],
last_used_at timestamptz,
created_at timestamptz not null default now(),
revoked_at timestamptz
);

-- Lookup is by the non-secret prefix; partial index covers only live keys.
create index if not exists idx_api_keys_prefix_active
on public.api_keys(key_prefix)
where revoked_at is null;
create index if not exists idx_api_keys_user
on public.api_keys(user_id);

alter table public.api_keys enable row level security;

-- ── Developer platform: webhook endpoints ───────────────────────────────────
-- Where to POST events, which events to send, and the per-endpoint HMAC secret.
create table if not exists public.webhook_endpoints (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
url text not null,
encrypted_secret text not null,
secret_iv text not null,
secret_tag text not null,
enabled boolean not null default true,
event_types text[] not null default array[]::text[],
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

create index if not exists idx_webhook_endpoints_user
on public.webhook_endpoints(user_id);

alter table public.webhook_endpoints enable row level security;

-- ── Developer platform: webhook deliveries ──────────────────────────────────
-- One row per attempt-set: the event, payload, status and recorded response.
create table if not exists public.webhook_deliveries (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
endpoint_id uuid not null references public.webhook_endpoints(id) on delete cascade,
event_type text not null,
payload jsonb not null default '{}'::jsonb,
status text not null default 'pending'
check (status in ('pending', 'succeeded', 'failed')),
attempts integer not null default 0,
response_status integer,
response_body text,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
delivered_at timestamptz
);

create index if not exists idx_webhook_deliveries_endpoint
on public.webhook_deliveries(endpoint_id, created_at desc);
create index if not exists idx_webhook_deliveries_user
on public.webhook_deliveries(user_id, created_at desc);

alter table public.webhook_deliveries enable row level security;

create table if not exists public.user_mcp_connectors (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
Expand Down Expand Up @@ -1357,6 +1428,9 @@ revoke all on public.tabular_cells from anon, authenticated;
revoke all on public.tabular_review_chats from anon, authenticated;
revoke all on public.tabular_review_chat_messages from anon, authenticated;
revoke all on public.user_api_keys from anon, authenticated;
revoke all on public.api_keys from anon, authenticated;
revoke all on public.webhook_endpoints from anon, authenticated;
revoke all on public.webhook_deliveries from anon, authenticated;
revoke all on public.user_mcp_connectors from anon, authenticated;
revoke all on public.user_mcp_oauth_tokens from anon, authenticated;
revoke all on public.user_mcp_oauth_states from anon, authenticated;
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/__tests__/integration/chat.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ vi.mock("../../lib/supabase", () => ({
// JWT path. requireMfaIfEnrolled must be exported too — userRouter (mounted by
// the app factory) imports it at module load.
vi.mock("../../middleware/auth", () => ({
requireUserSession: (_req: unknown, _res: unknown, next: () => void) =>
next(),
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ vi.mock("../../lib/supabase", () => ({

// Authenticated as u1 for every request (mirrors the other route tests).
vi.mock("../../middleware/auth", () => ({
requireUserSession: (_req: unknown, _res: unknown, next: () => void) =>
next(),
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ vi.mock("../../lib/supabase", () => ({
}));

vi.mock("../../middleware/auth", () => ({
requireUserSession: (_req: unknown, _res: unknown, next: () => void) =>
next(),
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/__tests__/integration/orgs.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ vi.mock("../../lib/supabase", () => ({
// requireAuth double: enforces a bearer token (so 401 is testable) and, when
// present, seeds the standard res.locals identity.
vi.mock("../../middleware/auth", () => ({
requireUserSession: (_req: unknown, _res: unknown, next: () => void) =>
next(),
requireAuth: (
req: { headers: Record<string, unknown> },
res: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ vi.mock("../../lib/supabase", () => ({
}));

vi.mock("../../middleware/auth", () => ({
requireUserSession: (_req: unknown, _res: unknown, next: () => void) =>
next(),
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/__tests__/integration/projects.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ vi.mock("../../lib/supabase", () => ({
}));

vi.mock("../../middleware/auth", () => ({
requireUserSession: (_req: unknown, _res: unknown, next: () => void) =>
next(),
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/__tests__/integration/tabular.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ vi.mock("../../lib/supabase", () => ({
}));

vi.mock("../../middleware/auth", () => ({
requireUserSession: (_req: unknown, _res: unknown, next: () => void) =>
next(),
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/__tests__/integration/user.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ vi.mock("../../lib/supabase", () => ({
// guard so we can drive both the satisfied (next()) and rejected
// (403 mfa_verification_required) paths.
vi.mock("../../middleware/auth", () => ({
requireUserSession: (_req: unknown, _res: unknown, next: () => void) =>
next(),
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
Expand Down
40 changes: 40 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { userRouter } from "./modules/user/user.routes";
import { downloadsRouter } from "./modules/downloads/downloads.routes";
import { caseLawRouter } from "./modules/case-law/caseLaw.routes";
import { guestRouter } from "./modules/auth/auth.routes";
import { apiKeysRouter } from "./routes/apiKeys";
import { webhooksRouter } from "./routes/webhooks";
import swaggerUi from "swagger-ui-express";
import { openApiDocument } from "./lib/openapi";
import { getAdminClient } from "./lib/supabase";
import { checkStorageReady } from "./lib/storage";
import { env } from "./lib/env";
Expand Down Expand Up @@ -202,6 +206,42 @@ app.use("/download", downloadsRouter);
app.use("/case-law", caseLawRouter);
app.use("/auth", guestRouter);

// ── Developer platform ──────────────────────────────────────────────────────
app.use("/v1/api-keys", apiKeysRouter);
app.use("/v1/webhooks", webhooksRouter);

// Machine-readable API contract — see lib/openapi.ts.
app.get("/openapi.json", (_req, res) => res.json(openApiDocument));

// Human-readable interactive docs. Swagger UI injects inline scripts/styles and
// loads images as data: URIs, all of which the strict global CSP (default-src
// 'none') would block. We relax the policy for this subtree ONLY — everything
// served here is first-party Swagger UI, no third-party origins.
app.use(
"/docs",
(
_req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
res.setHeader(
"Content-Security-Policy",
[
"default-src 'none'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"connect-src 'self'",
].join("; "),
);
next();
},
swaggerUi.serve,
swaggerUi.setup(openApiDocument, {
customSiteTitle: "Mike API reference",
}),
);

app.get("/health", (_req, res) => res.json({ ok: true }));

app.get("/ready", async (_req, res) => {
Expand Down
114 changes: 114 additions & 0 deletions apps/api/src/core/apiKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import crypto from "crypto";

/**
* Programmatic API-key primitives — pure crypto, no database or env access so
* they can be unit-tested in isolation and reused anywhere.
*
* SECURITY MODEL (why it looks like this):
* - A Mike API key is a *bearer secret*: whoever holds the string can act as
* the user. We therefore treat it exactly like a password.
* - We NEVER store the raw key. We store a SHA-256 hash of it. If the
* database leaks, the hashes cannot be replayed against the API because the
* server only ever compares hashes — it never needs the original.
* (SHA-256 — not bcrypt/argon2 — is appropriate here because the secret has
* ~238 bits of entropy. Slow password hashes exist to defeat brute force of
* low-entropy human passwords; a 40-char random base62 string is not
* brute-forceable, so a fast hash is the correct, cheaper choice.)
* - We also store a short, non-secret PREFIX (e.g. `mike_sk_Ab3xK9`) so the
* UI can show users which key is which without ever revealing the secret.
* - Verification uses a constant-time comparison (see `verifyApiKeyHash`) to
* avoid leaking, via response timing, how many leading bytes of a guessed
* hash were correct.
*/

/** Human-readable, greppable prefix. `sk` = "secret key" (Stripe convention). */
export const API_KEY_PREFIX = "mike_sk_";

/** Number of random characters in the secret body. 40 base62 chars ≈ 238 bits. */
const SECRET_BODY_LENGTH = 40;

/**
* How many characters of the random body we keep in the stored, non-secret
* prefix. Enough to disambiguate keys in a list UI, far too few to guess the
* rest of the secret.
*/
const PREFIX_BODY_CHARS = 6;

const BASE62 =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

/**
* Cryptographically-secure random base62 string.
*
* We map random bytes into the 62-char alphabet using rejection sampling:
* bytes >= 248 are discarded so the remaining range (0..247) is an exact
* multiple of 62. Without this, `byte % 62` would make the first few
* characters of the alphabet slightly more likely (modulo bias).
*/
export function randomBase62(length: number): string {
let result = "";
while (result.length < length) {
const bytes = crypto.randomBytes(length);
for (const byte of bytes) {
if (result.length >= length) break;
if (byte < 248) result += BASE62[byte % 62];
}
}
return result;
}

export type GeneratedApiKey = {
/** The full secret, shown to the user exactly once. */
token: string;
/** Short non-secret identifier stored in the clear for display + lookup. */
prefix: string;
/** SHA-256 hex digest of the token — the only representation we persist. */
hash: string;
};

/** Returns true if a bearer credential is a Mike API key (vs a Supabase JWT). */
export function isApiKeyToken(value: string): boolean {
return value.startsWith(API_KEY_PREFIX);
}

/** SHA-256 hex digest of the full key. Deterministic — same input, same hash. */
export function hashApiKey(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}

/**
* The non-secret prefix we store and index on. Looking a key up by prefix lets
* us fetch the (usually single) candidate row cheaply, then constant-time
* compare the full hash — instead of scanning every hash in the table.
*/
export function apiKeyPrefix(token: string): string {
return token.slice(0, API_KEY_PREFIX.length + PREFIX_BODY_CHARS);
}

/** Mint a brand-new key. The caller persists `{prefix, hash}` and returns `token` once. */
export function generateApiKey(): GeneratedApiKey {
const token = `${API_KEY_PREFIX}${randomBase62(SECRET_BODY_LENGTH)}`;
return { token, prefix: apiKeyPrefix(token), hash: hashApiKey(token) };
}

/**
* Constant-time check that `token` hashes to `expectedHash`.
*
* WHAT TIMING-SAFE COMPARE DEFENDS AGAINST: a naive `a === b` on strings
* returns as soon as it finds a differing byte. An attacker measuring response
* latency could therefore discover the secret one byte at a time. crypto's
* `timingSafeEqual` always compares the full buffers, so the time taken does
* not depend on *where* the mismatch is.
*/
export function verifyApiKeyHash(token: string, expectedHash: string): boolean {
const actual = Buffer.from(hashApiKey(token), "hex");
let expected: Buffer;
try {
expected = Buffer.from(expectedHash, "hex");
} catch {
return false;
}
// Length guard: timingSafeEqual throws if the buffers differ in length.
if (actual.length !== expected.length) return false;
return crypto.timingSafeEqual(actual, expected);
}
50 changes: 50 additions & 0 deletions apps/api/src/core/webhookSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import crypto from "crypto";

/**
* Webhook payload signing — pure crypto so it can be unit-tested and copied
* verbatim into a receiver's codebase.
*
* WHAT AN HMAC SIGNATURE PROVES: an HMAC (Hash-based Message Authentication
* Code) is computed from the request body AND a shared secret. Because only
* Mike and the endpoint owner know the secret, a valid signature proves two
* things at once:
* 1. Authenticity — the request really came from Mike (nobody else can
* produce the signature without the secret).
* 2. Integrity — the body was not modified in transit (any change alters the
* signature).
* It is NOT encryption: the payload is still plaintext JSON. It is a tamper-
* evident seal, the same idea behind the download tokens elsewhere in this
* codebase (`core/downloadTokens.ts`).
*/

/** Prefix for generated endpoint secrets — `whsec` = "webhook secret". */
export const WEBHOOK_SECRET_PREFIX = "whsec_";

/** Mint a random per-endpoint signing secret. */
export function generateWebhookSecret(): string {
return `${WEBHOOK_SECRET_PREFIX}${crypto.randomBytes(24).toString("hex")}`;
}

/** Hex-encoded HMAC-SHA256 of the exact body bytes under the endpoint secret. */
export function signWebhookPayload(payload: string, secret: string): string {
return crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex");
}

/**
* Constant-time verification a receiver can use. Exposed so docs can point at a
* canonical implementation and so it is covered by the same tests as signing.
*
* The constant-time compare matters for the same reason as API-key hashes: a
* byte-by-byte `===` would let an attacker forge a signature incrementally by
* timing the rejection.
*/
export function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
): boolean {
const expected = Buffer.from(signWebhookPayload(payload, secret), "utf8");
const provided = Buffer.from(signature, "utf8");
if (expected.length !== provided.length) return false;
return crypto.timingSafeEqual(expected, provided);
}
Loading
Loading