Skip to content
Merged
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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Decisions worth knowing, with the reasons:
- **Forwarding is a queue the web app drains.** An endpoint sends to up to 10 rows of `endpoint_destinations` (migration 00063), each with its own kinds, filter, format, templates, throttle, delay and secret. `capture_webhook()` queues a delivery row (table `email_deliveries`, which holds HTTP requests too since migration 00058; the old names stay because a rename would need compatibility views across a deploy) in the capture's own transaction for each destination that takes the kind, so a captured request is never lost before it is queued; the delay is the first `next_attempt_at`. Filters are evaluated by the worker, not in SQL, because they share the rule evaluator (`packages/sdk/src/rules.ts`) with the dashboard's preview; a turned-down delivery is settled as `skipped`, and `bypass_filter` marks a hand redelivery to one destination. The throttle is taken when queueing for a destination without a filter (no row at all for the rest) and by the worker once the filter passed for one with a filter, so turned-down requests never use up the slot. Destination URLs on the receiver's own host are refused when saved and when sent: a delivery would be captured and forwarded again, by up to 10 destinations each time. The endpoint's old `forward_*` columns and `notification_url` still read and write through `legacy_role` (`forward`, `notify`) destinations, so the API and older SDKs keep working; notifications are chat destinations (throttle 1 s, no retries) and the receiver no longer sends any. The worker runs in the web app, not the receiver (`lib/forwarding/worker.ts`, started from `instrumentation.ts`), because the email JSON is built by one function (`buildEmailJson()` in `packages/sdk/src/email/json.ts`) shared with the dashboard's JSON tab; a Rust copy would drift, and the code extraction relies on regex lookbehind Rust's `regex` lacks. Formats: an HTTP request goes out as received (`lib/forwarding/relay.ts`: the same method, the stored headers minus hop-by-hop ones, the exact bytes, `requests.query_raw`, plus `webhooks-cc-*` metadata headers signed with the forwarding secret; never `webhook-*`, which Standard Webhooks senders use themselves), an email as signed JSON, either kind as the owner's JSON template (`{{ placeholders }}` filled from the request, signed like the JSON format) or as a chat message when the URL is a Slack or Discord webhook (`lib/forwarding/chat.ts`, pinned by `lib/forwarding/chat-vectors.json`). Claims are leased and re-checked under `FOR UPDATE SKIP LOCKED`, so several processes can run it and a crash only delays a retry. Deliveries go through the notify-proxy Worker when `NOTIFY_PROXY_URL` is set (`X-Proxy-Mode: forward` for JSON, `relay` for requests as received), so deploy the Worker before a web release that depends on a change to it; without the proxy the web app applies the receiver's SSRF rules and pins DNS.
- **One rule language and one placeholder syntax.** Response rules (which answer a sender gets) and forwarding filters share the conditions in `packages/sdk/src/rules.ts`; mock responses and forwarded payloads share the `{{ path | filter }}` placeholders in `packages/sdk/src/placeholders.ts`. The receiver evaluates rules and fills in mock responses in Rust (`handlers/rules.rs`, `placeholders.rs`, `values.rs`), the web app with the SDK; both run `packages/sdk/conformance/rules.json` and `placeholders.json`, so change them together and regenerate the vectors. Regular expressions are RE2 on both sides (the `regex` crate, `re2js` in `lib/rules.ts`), never JavaScript's `RegExp`: they run against whatever anyone sends, and RE2 cannot backtrack. Placeholders only fill in values, with no loops or conditions (rules choose), so rendering always finishes, and an unknown first key is sent as written so old bodies never change.
- **One app serves two sites.** webhooks.cc and mailhooks.cc (the mail-first front door) are the same Next app, accounts, endpoints and billing (`lib/site.ts`). The site comes from the Host: `proxy.ts` resolves it against the two configured origins only (`NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_MAILHOOKS_URL`, a build argument; unset means one site), passes it on in `x-whk-site`, lets mailhooks.cc answer only its own paths (the rest 308 to webhooks.cc) and keeps `app/mailhooks` off webhooks.cc; `next.config.ts` serves `app/mailhooks` at the mail host's root with host rewrites, so those pages stay static, and only the app's own layouts read the site from the request. Sessions stay per host (Supabase cookies have no domain), so the header's site switcher carries a session over (`lib/site-switch.ts`): the signed-in page asks `POST /api/site-switch` with its bearer token for a one-minute grant naming the account; `/auth/switch/start`, on the site switched to, sets a nonce cookie (`__Host-` over https); `/auth/switch/issue`, on the site switched from, makes a ticket only for the account the grant names and the session cookie there holds, sealing a GoTrue magic-link token (admin `generateLink`, nothing sent; for an existing user GoTrue stores it as the recovery token, so a switch cancels a pending password-reset link and holds off another for GoTrue's minute) with the nonce's hash for two minutes; `/auth/switch/finish` checks the cookie against the ticket and verifies the token, and uses up a ticket it refuses. The grant means no other page can have a ticket made for someone by sending their browser to issue; the nonce means a ticket signs no one in from another browser. An endpoint's `site` (migration 00078: `webhooks`, `mailhooks` or `both`) only decides which dashboard lists it, never what it captures; the dashboard sends the site it creates on, and existing endpoints and those made through the API without one are `both`. Everything that hands a browser back returns to the site it started on: `/auth/confirm` sends a mailhooks.cc link opened on webhooks.cc (GoTrue's site URL) to mailhooks.cc before verifying, `resolveRedirectBase` only ever answers one of the two origins, checkouts and invites return to the request's site (`returnOrigin`), and emails sent by background jobs link to `users.home_site`, the site the person last signed in on. The OAuth server, agent identities and the MCP server keep webhooks.cc as their issuer on both. Locally, run the two sites as `webhooks.localhost` and `mailhooks.localhost`: Next turns a redirect to its own `localhost` origin into a relative one, which loops between the sites.
- **Staff, support tickets and the operations center** (migration 00087). Staff are the auth user ids in `staff`, inserted by hand with psql, never in a migration and never matched by email; a trigger on `users` keeps a staff account on Business without a subscription, so a revoked subscription or the period job never moves it. `/ops` (webhooks.cc only) and every `/api/ops` route answer 404 to anyone else, accept a session only, and need two-factor sign-in (`aal2`, `lib/staff.ts`); any account with an authenticator app has its aal1 sessions refused everywhere (`lib/mfa.ts`), and `RequireAuth` (or `MfaGate` on pages that sign in on their own) asks for the code. GoTrue has no recovery codes, so the app keeps hashed ones in `mfa_recovery_codes` (`lib/mfa-recovery.ts`): a code cannot raise a session to aal2, so redeeming one removes the account's authenticators instead, as does the staff reset in `/ops`, and the owner is emailed either way. Tickets (`/support` on both sites) belong to the person who opened them, never their team; clients have no access to the tables, and the server never returns notes to users. Staff see an account's metadata freely but its captured contents only through a read-only view session opened from a ticket whose owner gave access (until 7 days after it closes, withdrawable), and each look is written to `support_access_log`, which the user reads under Account, Support access; there is deliberately no way to sign in as a user or write as one. Emails to users carry staff replies in full but never the user's own words; alerts to `SUPPORT_ALERT_EMAIL` carry no ticket contents. The overview reads `ops_daily_metrics`, which the web app fills hourly (`lib/ops/metrics.ts`), leaving out staff and `users.internal` accounts, never counting `requests` on page load.
- **Staff, support tickets and the operations center** (migration 00087). Staff are the auth user ids in `staff`, inserted by hand with psql, never in a migration and never matched by email; a trigger on `users` keeps a staff account on Business without a subscription, so a revoked subscription or the period job never moves it. `/ops` (webhooks.cc only) and every `/api/ops` route answer 404 to anyone else, accept a session only, and need two-factor sign-in (`aal2`, `lib/staff.ts`); any account with an authenticator app has its aal1 sessions refused everywhere (`lib/mfa.ts`), and `RequireAuth` (or `MfaGate` on pages that sign in on their own) asks for the code. GoTrue has no recovery codes, so the app keeps hashed ones in `mfa_recovery_codes` (`lib/mfa-recovery.ts`): a code cannot raise a session to aal2, so redeeming one removes the account's authenticators instead, as does the staff reset in `/ops`, and the owner is emailed either way. A site switch signs in with a fresh magic-link session, which GoTrue rates aal1 and cannot raise, so when the session it started from had passed the second factor the switch lists the new session in `mfa_trusted_sessions` (migration 00088), and every aal2 check accepts it (`lib/mfa-session.ts`, `session_mfa_ok()`); the row goes with its session. Tickets (`/support` on both sites) belong to the person who opened them, never their team; clients have no access to the tables, and the server never returns notes to users. Staff see an account's metadata freely but its captured contents only through a read-only view session opened from a ticket whose owner gave access (until 7 days after it closes, withdrawable), and each look is written to `support_access_log`, which the user reads under Account, Support access; there is deliberately no way to sign in as a user or write as one. Emails to users carry staff replies in full but never the user's own words; alerts to `SUPPORT_ALERT_EMAIL` carry no ticket contents. The overview reads `ops_daily_metrics`, which the web app fills hourly (`lib/ops/metrics.ts`), leaving out staff and `users.internal` accounts, never counting `requests` on page load.
- **Guest endpoint creation is bot-gated.** `POST /api/go/endpoint` returns 403 for crawler or missing user agents, and the landing page only auto-creates an endpoint after a human input signal. Browsers with `navigator.webdriver` get a manual create button, so Playwright and agents must click it and tests must send a browser user agent.
- **Site totals survive deletes.** `site_stats.total_webhooks` is `sum(endpoints.request_count) + deleted_webhooks`; an AFTER DELETE trigger on `endpoints` (migration 00038) moves the counts of deleted rows into `deleted_webhooks`. Do not add another accumulate step to any delete path.
- **State changes leave an audit trail; webhook traffic leaves only counts.** Routes that change account, team, billing, API key, or endpoint state call `auditUserAction()` from `lib/audit.ts`, and the Polar webhook calls `auditPolarEvent()`; both write `audit_events` (kept a year) and print one `[audit]` line, and never throw. Add the call when you add such a route. Password changes never pass through a route (the browser talks to GoTrue directly), so a trigger on `auth.users` records them instead (migration 00061), with no `[audit]` line. Individual webhooks are never logged: `capture_webhook()` upserts per-endpoint daily counts into `endpoint_daily_stats`, which outlives request retention and endpoint deletion.
Expand Down
5 changes: 3 additions & 2 deletions apps/web/app/api/account/mfa/recovery-codes/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { authenticateSessionRequest, extractBearerToken } from "@/lib/api-auth";
import { auditUserAction } from "@/lib/audit";
import { hasVerifiedFactor, sessionAal } from "@/lib/mfa";
import { hasVerifiedFactor } from "@/lib/mfa";
import { passedSecondFactor } from "@/lib/mfa-session";
import { remainingRecoveryCodes, replaceRecoveryCodes } from "@/lib/mfa-recovery";
import { reportError } from "@/lib/observability";
import { checkRateLimitByKey } from "@/lib/rate-limit";
Expand Down Expand Up @@ -38,7 +39,7 @@ export async function POST(request: Request) {
const {
data: { user },
} = await createAdminClient().auth.getUser(token);
if (!user || !hasVerifiedFactor(user) || sessionAal(token) !== "aal2") {
if (!user || !hasVerifiedFactor(user) || !(await passedSecondFactor(user, token))) {
return Response.json(
{ error: "Turn on two-factor sign-in first, then make recovery codes." },
{ status: 409 }
Expand Down
26 changes: 26 additions & 0 deletions apps/web/app/api/account/mfa/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { extractBearerToken } from "@/lib/api-auth";
import { owesSecondFactor } from "@/lib/mfa-session";
import { createAdminClient } from "@/lib/supabase/admin";

/**
* Whether this session still owes the authenticator code. The browser's own
* reading (GoTrue's assurance level) does not know about sessions a site
* switch carried the second factor to (migration 00088), so the code prompt
* asks here before it shows. Takes a session that owes the code, which every
* other session route refuses.
*/
export async function GET(request: Request) {
const token = extractBearerToken(request);
if (!token || token.startsWith("whcc_")) {
return Response.json({ error: "Sign in first." }, { status: 401 });
}
const {
data: { user },
error,
} = await createAdminClient().auth.getUser(token);
if (error || !user) return Response.json({ error: "Sign in first." }, { status: 401 });
return Response.json(
{ owesCode: await owesSecondFactor(user, token) },
{ headers: { "Cache-Control": "no-store" } }
);
}
4 changes: 2 additions & 2 deletions apps/web/app/api/auth/device-authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { parseJsonBody } from "@/lib/request-validation";
import { createClient } from "@/lib/supabase/server";
import { authorizeDeviceCodeForUser } from "@/lib/supabase/device-auth";
import { reportError } from "@/lib/observability";
import { needsSecondFactor } from "@/lib/mfa";
import { owesSecondFactor } from "@/lib/mfa-session";

export async function POST(request: Request) {
const rateLimited = await checkRateLimit(request, "device-authorize", 10);
Expand Down Expand Up @@ -32,7 +32,7 @@ export async function POST(request: Request) {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session || needsSecondFactor(user, session.access_token)) {
if (!session || (await owesSecondFactor(user, session.access_token))) {
return Response.json({ error: "Enter your two-factor code first." }, { status: 401 });
}

Expand Down
13 changes: 12 additions & 1 deletion apps/web/app/api/site-switch/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { authenticateSessionRequest } from "@/lib/api-auth";
import { authenticateSessionRequest, extractBearerToken } from "@/lib/api-auth";
import { passedSecondFactor } from "@/lib/mfa-session";
import { createAdminClient } from "@/lib/supabase/admin";
import { sanitizeNextPath } from "@/lib/auth-redirect";
import { parseJsonBody } from "@/lib/request-validation";
import { checkRateLimitByKeyWithInfo } from "@/lib/rate-limit";
Expand All @@ -10,6 +12,14 @@ import {
} from "@/lib/site-switch";
import { SITE_HEADER, otherSite, parseSite, siteOrigin, twoSites } from "@/lib/site";

/** Whether the asking session passed the second factor (its account's factors included). */
async function switchingSessionPassed(token: string): Promise<boolean> {
const {
data: { user },
} = await createAdminClient().auth.getUser(token);
return !!user && (await passedSecondFactor(user, token));
}

/** Grants per account per minute: a person clicks a few; a script would ask for many. */
const GRANTS_PER_MINUTE = 10;

Expand Down Expand Up @@ -47,6 +57,7 @@ export async function POST(request: Request) {
userId: auth.userId,
expires: Date.now() + GRANT_LIFETIME_MS,
consent: parseCarriedConsent(body.consent),
secondFactor: await switchingSessionPassed(extractBearerToken(request)!),
})
);
start.searchParams.set("next", next);
Expand Down
6 changes: 6 additions & 0 deletions apps/web/app/auth/switch/finish/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { siteForHost } from "@/lib/site";
import { createAdminClient } from "@/lib/supabase/admin";
import { rememberHomeSite } from "@/lib/supabase/home-site";
import { createClient } from "@/lib/supabase/server";
import { trustSession } from "@/lib/mfa-session";

/**
* Uses up the token of a ticket this browser may not have: a session made
Expand Down Expand Up @@ -79,6 +80,11 @@ export async function GET(request: Request) {
token_hash: ticket.tokenHash,
});
if (error || !data?.user) return done(failed);
// The session this switch started from had passed the second factor, so
// this one counts as having passed it too (migration 00088).
if (ticket.secondFactor === true && data.session) {
await trustSession(data.session.access_token, data.user.id);
}

await rememberHomeSite(data.user.id, here);
await auditUserAction(request, data.user.id, {
Expand Down
10 changes: 10 additions & 0 deletions apps/web/app/auth/switch/issue/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { otherSite, siteForHost, siteOrigin, twoSites } from "@/lib/site";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
import { passedSecondFactor } from "@/lib/mfa-session";

/** Tickets per account per hour: far above any person's, well below a script's. */
const SWITCHES_PER_HOUR = 30;
Expand Down Expand Up @@ -71,6 +72,14 @@ export async function GET(request: Request) {
// address: nothing to carry over.
if (data.user?.id !== user.id) throw new Error("generateLink answered for another account");

// The factor carries only when this browser's session here passed it too.
const {
data: { session },
} = await supabase.auth.getSession();
const secondFactor =
grant.secondFactor === true &&
!!session &&
(await passedSecondFactor(user, session.access_token));
const ticket = sealTicket({
tokenHash,
userId: user.id,
Expand All @@ -79,6 +88,7 @@ export async function GET(request: Request) {
next,
expires: Date.now() + TICKET_LIFETIME_MS,
consent: grant.consent,
secondFactor,
});
await auditUserAction(request, user.id, {
action: "account.site_switch",
Expand Down
Loading
Loading