Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4d367ee
feat(config): default a new workspace's Reply-To to its owner, with a…
important-new Oct 11, 2026
c8e15cd
feat(email): absorb short rate limits, defer instead of fail on quota…
important-new Oct 11, 2026
9e2cb4d
feat(integrations): stepwise "test connection" for BYO email and Stripe
important-new Oct 11, 2026
eec2ea7
fix(auth): complete standalone TOTP sign-in through the /login page
important-new Oct 11, 2026
54bbe74
feat(settings): one "Test connection" button that runs a step-by-step…
important-new Oct 11, 2026
8421d45
fix(auth): give /login its own response schema; correct the token-rel…
important-new Oct 11, 2026
1bbaf4d
feat(integrations): step-by-step Stripe connection test behind one Te…
important-new Oct 11, 2026
57ec37b
feat(email): confirm a person with Turnstile instead of refusing clie…
important-new Oct 11, 2026
4ba0064
Merge branch 'fix/two-factor-login-relay' into feat/reply-to-and-emai…
important-new Oct 11, 2026
ab539ec
test: refresh the OpenAPI snapshot and two specs for the stepwise tes…
important-new Oct 11, 2026
cc44043
fix: submit the gated forms reliably, and satisfy the CI-tier gates
important-new Oct 11, 2026
d8d4113
feat(email): carry the human check through delayed report emails
important-new Oct 11, 2026
de721c3
fix: close review findings on the human check and the stepwise tests
important-new Oct 12, 2026
541b319
feat(email): the human check on the repair-share email, one token per…
important-new Oct 12, 2026
fa2ced9
fix: second review pass on the human check, and put the code back in …
important-new Oct 12, 2026
d579146
fix: close the second review's findings on the human check
important-new Oct 12, 2026
51d7885
fix: third review pass on the human check
important-new Oct 12, 2026
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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ OpenInspection runs as ONE Cloudflare Worker (cloudflare/react-router-hono-fulls
- **iat claim**: `signJwt()` auto-injects `iat: Math.floor(Date.now() / 1000)` when the caller omits it. Without `iat`, KV session invalidation (`pwchanged:{userId}`) cannot work.
- **No HS256 fallback**: There is NO legacy HS256 path. Pre-launch architectural choice — see rotation scripts and docs. The remaining `JWT_SECRET` env binding is KDF input only, never a JWT signing key. State it that way rather than listing its consumers: there are dozens of files reading it (secret crypto, signed-URL and session HMACs, token derivation), and every enumeration written here has gone stale.
- **Key rotation flow**: To rotate, provision `JWT_PRIVATE_KEY_V<N+1>` + `JWT_PUBLIC_KEY_V<N+1>` first (verify-only window), then flip `JWT_CURRENT_KID` to the new version. Old tokens remain verifiable until V<N> is retired.
- **Token NOT in response body — one carved-out exception.** Login, setup and join MUST NOT return the JWT to a browser; delivery is `Set-Cookie` (HttpOnly). The exception is the in-process BFF, which identifies itself with `x-token-relay: 1` and then receives `token` in the body, because Workers `fetch()` may strip `Set-Cookie` on a server-to-server hop and the BFF has to write its own session cookie. A browser never sends that header. Do not add a second exception without one here.
- **Token NOT in response body — one carved-out exception.** No auth endpoint returns the JWT to a browser; delivery is `Set-Cookie` (HttpOnly). The exception is login only — both the password step (`POST /api/auth/login`) and the second-factor step (`POST /api/auth/login/2fa`): a caller sending `x-token-relay: 1` also receives `token` in the body, because the in-process BFF client does not forward the API's `Set-Cookie` to the browser and the login page has to write its own session cookie (`LoginResponseSchema` / `Login2faResponseSchema` declare it). A browser never sends that header. Setup and join are NOT part of the exception: they never put the JWT in the body, whatever the headers, and their pages read the session JWT out of the `Set-Cookie` header on the in-process API response instead. Do not add a second exception without one here.
- **Cookie name**: Always use `__Host-inspector_token` (enforces `Secure`, `Path=/`, no `Domain`).
- **setCookie attributes**: Every `setCookie()` MUST include `httpOnly: true, secure: true, sameSite: 'Strict', path: '/'`.
- **deleteCookie secure**: Every `deleteCookie()` MUST include `{ path: '/', secure: true }`. Omitting `secure` on `__Host-` cookies throws a runtime exception.
Expand Down
137 changes: 137 additions & 0 deletions app/components/OutboundVerification.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";

let emitToken: ((t: string) => void) | null = null;
vi.mock("~/lib/turnstile", () => ({
useTurnstileWidget: (_k: unknown, _r: unknown, _s: unknown, onToken: (t: string) => void) => {
emitToken = onToken;
},
}));

import { OutboundVerificationProvider, useChallengeOnRefusal, useEnsureHuman } from "./OutboundVerification";

function Probe({ onResult }: { onResult: (ok: boolean) => void }) {
const ensureHuman = useEnsureHuman();
return <button type="button" onClick={async () => onResult(await ensureHuman())}>send</button>;
}

const setup = (windowOpen: boolean, siteKey: string | null = "site-key") => {
const onResult = vi.fn();
render(
<OutboundVerificationProvider windowOpen={windowOpen} siteKey={siteKey}>
<Probe onResult={onResult} />
</OutboundVerificationProvider>,
);
return onResult;
};

const click = async () => { await act(async () => { fireEvent.click(screen.getByText("send")); }); };

beforeEach(() => {
emitToken = null;
document.cookie = "oi-human-token=; Max-Age=0; Path=/";
});

describe("OutboundVerificationProvider", () => {
it("resolves true at once, with no dialog, outside the cooling window", async () => {
const onResult = setup(false);
await click();
expect(onResult).toHaveBeenCalledWith(true);
expect(screen.queryByRole("dialog")).toBeNull();
});

it("opens the challenge inside the window and resolves true once a token is solved, leaving it for the next request", async () => {
const onResult = setup(true);
await click();
expect(screen.getByRole("dialog")).toBeTruthy();
expect(onResult).not.toHaveBeenCalled();
await act(async () => emitToken!("0.token-value-1234567890"));
expect(onResult).toHaveBeenCalledWith(true);
expect(document.cookie).toContain("oi-human-token=");
expect(screen.queryByRole("dialog")).toBeNull();
});

it("resolves false and stores nothing when the person cancels", async () => {
const onResult = setup(true);
await click();
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Cancel" })); });
expect(onResult).toHaveBeenCalledWith(false);
expect(document.cookie).not.toContain("oi-human-token=0");
});

it("lets the server decide when there is no site key", async () => {
const onResult = setup(true, null);
await click();
expect(onResult).toHaveBeenCalledWith(true);
expect(screen.queryByRole("dialog")).toBeNull();
});

it("tells the first caller 'not verified' when a second request replaces its dialog", async () => {
const first = vi.fn();
const second = vi.fn();
function Two() {
const ensureHuman = useEnsureHuman();
return (
<>
<button type="button" onClick={async () => first(await ensureHuman())}>one</button>
<button type="button" onClick={async () => second(await ensureHuman())}>two</button>
</>
);
}
render(
<OutboundVerificationProvider windowOpen siteKey="site-key">
<Two />
</OutboundVerificationProvider>,
);
await act(async () => { fireEvent.click(screen.getByText("one")); });
await act(async () => { fireEvent.click(screen.getByText("two")); });
expect(first).toHaveBeenCalledWith(false);
await act(async () => emitToken!("0.token-value-1234567890"));
expect(second).toHaveBeenCalledWith(true);
});
});

describe("useChallengeOnRefusal", () => {
function Page({ data, resubmit }: { data?: { verifySiteKey?: string }; resubmit: () => void }) {
const challenge = useChallengeOnRefusal(data, "idle", resubmit);
return <div>{challenge.dialog}<span data-testid="blocked">{String(challenge.blocked)}</span></div>;
}

it("shows nothing for a normal response", () => {
render(<Page data={{}} resubmit={vi.fn()} />);
expect(screen.queryByRole("dialog")).toBeNull();
});

it("opens the check for a refusal and sends again once it is passed", async () => {
const resubmit = vi.fn();
render(<Page data={{ verifySiteKey: "k" }} resubmit={resubmit} />);
expect(screen.getByRole("dialog")).toBeTruthy();
await act(async () => emitToken!("0.token-value-1234567890"));
expect(resubmit).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).toBeNull();
});

it("does not send again when the person cancels", async () => {
const resubmit = vi.fn();
render(<Page data={{ verifySiteKey: "k" }} resubmit={resubmit} />);
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Cancel" })); });
expect(resubmit).not.toHaveBeenCalled();
});

it("does not loop: a refusal straight after a passed check is left to the page's error", async () => {
const resubmit = vi.fn();
const { rerender } = render(<Page data={{ verifySiteKey: "k" }} resubmit={resubmit} />);
await act(async () => emitToken!("0.token-value-1234567890"));
expect(resubmit).toHaveBeenCalledOnce();
// The same refusal comes back for the re-send: no second dialog, and the page is told
// the check could not be completed so it can show its own error.
expect(screen.getByTestId("blocked").textContent).toBe("false");
rerender(<Page data={{ verifySiteKey: "k" }} resubmit={resubmit} />);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.getByTestId("blocked").textContent).toBe("true");
// A later, separate refusal is a new attempt and asks again.
rerender(<Page data={{ verifySiteKey: "k" }} resubmit={resubmit} />);
expect(screen.getByRole("dialog")).toBeTruthy();
});
});
227 changes: 227 additions & 0 deletions app/components/OutboundVerification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { Button, Modal } from "@core/shared-ui";
import { setHumanToken } from "~/lib/human-token";
import { useTurnstileWidget } from "~/lib/turnstile";
import { m } from "~/paraglide/messages";

/**
* Inside a new company's first 24 hours, a message to a client goes out only
* after the sender proves they are a person. Every surface that sends one calls
* `await ensureHuman()` before submitting; it resolves true at once outside the
* window, and otherwise after a fresh Turnstile challenge is solved (the token
* is left for the next request, see `~/lib/human-token`). False means the
* person closed the dialog and the send should not go ahead.
*
* A new challenge every time is deliberate: a token redeems once.
*/
type EnsureHuman = () => Promise<boolean>;

const EnsureHumanContext = createContext<EnsureHuman>(async () => true);

export function useEnsureHuman(): EnsureHuman {
return useContext(EnsureHumanContext);
}

/**
* `onSubmit` for a native form whose submit has no handler of its own: holds
* the browser submit, runs the challenge, and re-fires it once the person is
* verified. A second press during the challenge is ignored. Cancelling submits
* nothing.
*/
export function useHumanGatedSubmit() {
const ensureHuman = useEnsureHuman();
const busy = useRef(false);
const pass = useRef(false);
return useCallback(
async (e: FormEvent<HTMLFormElement>) => {
if (pass.current) {
pass.current = false;
return;
}
e.preventDefault();
const form = e.currentTarget;
if (busy.current) return;
busy.current = true;
try {
if (!(await ensureHuman())) return;
// Let the submit event that started this finish dispatching first. When no
// challenge is needed the promise above is already resolved, so the code
// below would otherwise run INSIDE that dispatch, where the browser
// ignores a nested requestSubmit() and the form silently never posts.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
pass.current = true;
form.requestSubmit();
// The flag only bridges the re-fired event, which dispatches synchronously.
// If no event fired (validation now fails, form gone) it must not outlive this call.
pass.current = false;
} finally {
busy.current = false;
}
},
[ensureHuman],
);
}

/**
* For a page with no session context (a public share page, the agent portal): the
* action reports `verifySiteKey` when the server refused the send because the
* company is inside its first 24 hours. This shows the challenge for that
* response and calls `resubmit` once the person has passed it. Render the
* returned element anywhere in the component.
*/
export function useChallengeOnRefusal(
data: { verifySiteKey?: string } | undefined,
state: string,
resubmit: () => void,
): { dialog: ReactNode; blocked: boolean } {
const [open, setOpen] = useState<{ id: number; siteKey: string } | null>(null);
// True once the check was passed and the re-send was refused anyway: the page should
// show its own error then (and only then, not behind the dialog on the first refusal).
const [blocked, setBlocked] = useState(false);
const handled = useRef<unknown>(null);
const counter = useRef(0);
// When the page last sent again after a passed check. A refusal right after that is
// the same refusal; one long after it is a new attempt (the re-send may never have run).
const retriedAt = useRef(0);
useEffect(() => {
if (state !== "idle" || !data || data === handled.current) return;
handled.current = data;
if (!data.verifySiteKey) {
retriedAt.current = 0;
setBlocked(false);
return;
}
// Refused again straight after a passed check: asking again would loop forever
// (the server cannot accept this token). Stop and let the page show its error.
if (Date.now() - retriedAt.current < RETRY_WINDOW_MS) {
retriedAt.current = 0;
setBlocked(true);
return;
}
setBlocked(false);
counter.current += 1;
setOpen({ id: counter.current, siteKey: data.verifySiteKey });
}, [data, state]);
const dialog = !open ? null : (
<VerifyDialog
key={open.id}
step={open.id}
siteKey={open.siteKey}
onDone={(ok) => {
// A late callback from a dialog that has since been replaced is ignored.
if (counter.current !== open.id) return;
setOpen(null);
if (ok) {
retriedAt.current = Date.now();
resubmit();
}
}}
/>
);
return { dialog, blocked };
}

/** How long a refusal after a re-send counts as the answer to that re-send. */
const RETRY_WINDOW_MS = 90_000;

interface Pending {
id: number;
resolve: (ok: boolean) => void;
}

export function OutboundVerificationProvider({
windowOpen,
siteKey,
children,
}: {
/** True only while the cooling window is open (the server says so). */
windowOpen: boolean;
siteKey: string | null | undefined;
children: ReactNode;
}) {
const [pending, setPending] = useState<Pending | null>(null);
// The request on screen, held outside React state so settling it is never a side
// effect of a state updater (which React may run more than once).
const pendingRef = useRef<Pending | null>(null);
const counter = useRef(0);

const ensureHuman = useCallback<EnsureHuman>(() => {
// Without a site key the widget cannot render; let the server decide, it
// refuses with the same code if it needed the token.
if (!windowOpen || !siteKey) return Promise.resolve(true);
return new Promise<boolean>((resolve) => {
counter.current += 1;
const id = counter.current;
// A second request while a dialog is open replaces it; the first caller is
// told "not verified" instead of waiting forever.
pendingRef.current?.resolve(false);
const next = { id, resolve };
pendingRef.current = next;
setPending(next);
});
}, [windowOpen, siteKey]);

const finish = useCallback((ok: boolean, id: number) => {
const current = pendingRef.current;
// A late callback from a dialog that has since been replaced must not settle its successor.
if (!current || current.id !== id) return;
pendingRef.current = null;
setPending(null);
current.resolve(ok);
}, []);

return (
<EnsureHumanContext.Provider value={ensureHuman}>
{children}
{pending && (
<VerifyDialog key={pending.id} step={pending.id} siteKey={siteKey} onDone={(ok) => finish(ok, pending.id)} />
)}
</EnsureHumanContext.Provider>
);
}

function VerifyDialog({
step,
siteKey,
onDone,
}: {
step: number;
siteKey: string | null | undefined;
onDone: (ok: boolean) => void;
}) {
const widgetRef = useRef<HTMLDivElement | null>(null);
const [loadFailed, setLoadFailed] = useState(false);

useTurnstileWidget(
siteKey,
widgetRef,
step,
(token) => {
setHumanToken(token);
onDone(true);
},
{ onLoadFailed: () => setLoadFailed(true) },
);

return (
<Modal
open
onClose={() => onDone(false)}
title={m.outbound_verify_title()}
size="sm"
footer={
<Button variant="secondary" onClick={() => onDone(false)}>
{m.outbound_verify_cancel()}
</Button>
}
>
<p className="text-sm text-ih-fg-2 mb-4">{m.outbound_verify_body()}</p>
<div ref={widgetRef} className="min-h-[65px] flex justify-center" />
{loadFailed && (
<p role="alert" className="mt-3 text-sm text-ih-bad-fg">
{m.outbound_verify_load_failed()}
</p>
)}
</Modal>
);
}
Loading
Loading