feat(invite): open invites through a deep-linkable path

Emit invite links as a /invite/<token> path the iOS app intercepts as a
universal link to start a prefilled registration, with a browser fallback that
redirects to the register flow once the token shape checks out. The path is a
thin, side-effect-free redirect — no lookup, no logging, no enumeration oracle
— and carries the same no-store, no-index, no-referrer hardening as the other
secret-bearing path since the token rides in the URL.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:36:01 +02:00
commit 52bf985734
8 files changed
+252 -6

No files matched your search

+2 -1
View File
@@ -13451,7 +13451,8 @@ components:
description: Raw invite token (`hlv_<64hex>`). Shown exactly once — never persisted in plaintext.
url:
type: string
description: Composed registration deep link (`/auth/register?invite=…`).
description: Composed invite universal-link (`/invite/<hlv_token>`). On iOS it opens the app onto onboarding
registration; in a browser it redirects to `/auth/register?invite=…`.
required:
- id
- createdAt
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* v1.17.0 — `/invite/<hlv_token>` is the invite universal-link landing
* (iOS #16). It is a thin shape-validated redirect onto
* `/auth/register?invite=…` and carries no session, so an unauthenticated
* visitor (scanning the admin QR) must reach it without the auth-gate
* bounce to `/auth/login`. This guard locks the allowlist entry in place
* so a future refactor of `src/proxy.ts` cannot silently re-gate it.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
const VALID_TOKEN = `hlv_${"a".repeat(64)}`;
function makeRequest(
pathname: string,
cookies: Record<string, string> = {},
): NextRequest {
const cookieHeader = Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return new NextRequest(`http://localhost${pathname}`, {
headers: cookieHeader ? { cookie: cookieHeader } : {},
});
}
describe("proxy.ts /invite public-allowlist (v1.17.0)", () => {
it("does not 307 an unauthenticated /invite/<token> to /auth/login", () => {
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("admits a pending-onboarding session onto the invite landing", () => {
const res = proxy(
makeRequest(`/invite/${VALID_TOKEN}`, {
healthlog_session: "sess-1",
hl_onboarding: "pending",
}),
);
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("hardens the token-in-path surface against cache / index / referrer leaks", () => {
// Mirror the `/c/<token>` edge defence — the secret rides in the path,
// so no CDN may cache it and no crawler may index the URL.
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
expect(res.headers.get("Cache-Control")).toBe(
"no-store, no-cache, must-revalidate",
);
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
expect(res.headers.get("Referrer-Policy")).toBe("no-referrer");
});
it("still redirects unauthenticated requests for protected pages", () => {
// Negative check — confirms the fixture would catch a real
// regression. `/insights` is not public.
const res = proxy(makeRequest("/insights"));
expect([307, 308]).toContain(res.status);
expect(res.headers.get("location")).toMatch(/\/auth\/login/);
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.17.0 — invite universal-link landing (iOS #16).
*
* `/invite/<hlv_token>` is a thin, server-side redirect: it validates the
* `hlv_` shape, then bounces a browser onto the existing
* `/auth/register?invite=<token>` flow. These tests pin the security
* contract — the token shape gate, the carry-through to register, and the
* non-oracle behaviour where a malformed segment is indistinguishable
* from "no invite". The route never touches the database.
*/
const redirectMock = vi.fn((href: string) => {
// next/navigation `redirect()` throws a sentinel inside the
// server-component renderer to short-circuit rendering. Mimic it by
// throwing an error tagged with the href so each test can assert the
// exact redirect target.
const err = new Error(`__redirect__:${href}`);
(err as Error & { __redirect__: string }).__redirect__ = href;
throw err;
});
vi.mock("next/navigation", () => ({
redirect: (href: string) => redirectMock(href),
}));
import InviteLandingPage from "../page";
beforeEach(() => {
redirectMock.mockClear();
});
const VALID_TOKEN = `hlv_${"a".repeat(64)}`;
async function runRedirect(token: string): Promise<string> {
try {
await InviteLandingPage({ params: Promise.resolve({ token }) });
} catch (e) {
const tagged = e as Error & { __redirect__?: string };
if (tagged.__redirect__) return tagged.__redirect__;
throw e;
}
throw new Error("expected redirect, none thrown");
}
describe("<InviteLandingPage> universal-link landing", () => {
it("carries a well-formed token through to the register flow", async () => {
const href = await runRedirect(VALID_TOKEN);
expect(href).toBe(`/auth/register?invite=${VALID_TOKEN}`);
});
it("encodes the token segment into the register query string", async () => {
const href = await runRedirect(VALID_TOKEN);
const url = new URL(href, "https://h.example.com");
expect(url.pathname).toBe("/auth/register");
expect(url.searchParams.get("invite")).toBe(VALID_TOKEN);
});
it("drops a malformed token to plain register (no enumeration oracle)", async () => {
// Wrong prefix, wrong length, and outright garbage all land on the
// identical target — the page never reveals whether a token exists.
for (const bad of [
"not-a-token",
`hlk_${"a".repeat(64)}`,
`hlv_${"a".repeat(63)}`,
`hlv_${"G".repeat(64)}`,
]) {
redirectMock.mockClear();
const href = await runRedirect(bad);
expect(href).toBe("/auth/register");
}
});
});
+47
View File
@@ -0,0 +1,47 @@
import { redirect } from "next/navigation";
import { looksLikeInviteToken } from "@/lib/auth/invite-token";
/**
* v1.17.0 — invite universal-link landing (iOS #16).
*
* `https://<host>/invite/<hlv_token>` is the URL encoded in the admin
* invite QR and the path the AASA `["*"]` matcher hands to the iOS app:
* - On an installed iOS app the scene delegate intercepts the
* Universal Link before this page ever renders and starts onboarding
* registration with the token prefilled. This server component is the
* BROWSER fallback only.
* - In a browser, the page is a thin, safe redirect onto the existing
* `/auth/register?invite=<token>` flow, where the invite banner shows
* and the token rides the signup POST exactly as before.
*
* Security:
* - The `hlv_` shape is validated before the token is ever reflected
* into the redirect target — a malformed segment lands on plain
* `/auth/register` with no `?invite`, so the page can never echo
* attacker-controlled text into the URL.
* - The route does NOT touch the database and is NOT an enumeration
* oracle: a well-formed-but-unknown token and a well-formed-and-valid
* token redirect to the identical target. Whether a token is real is
* only ever decided at `POST /api/auth/register`, which keeps its
* existing uniform error semantics and rate limit.
* - The token is never logged here; the redirect is the only effect.
*
* The route is listed in `PUBLIC_PATHS` (`/invite/`) so an unauthenticated
* visitor reaches it without the auth-gate bounce to `/auth/login`.
*/
export default async function InviteLandingPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
if (looksLikeInviteToken(token)) {
redirect(`/auth/register?invite=${encodeURIComponent(token)}`);
}
// Malformed token: behave exactly like a visitor with no invite. No DB
// hit, no leak of whether any token exists.
redirect("/auth/register");
}
+37 -1
View File
@@ -8,7 +8,7 @@
* - `recordInviteConsumer` writes the `usedBy` stamp AND the
* redemption-ledger row in one transaction.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const findUnique = vi.fn();
const updateMany = vi.fn();
@@ -31,6 +31,7 @@ vi.mock("@/lib/db", () => ({
}));
import {
buildInviteUrl,
consumeInviteToken,
generateInviteToken,
looksLikeInviteToken,
@@ -129,3 +130,38 @@ describe("looksLikeInviteToken", () => {
expect(looksLikeInviteToken("hlv_" + "G".repeat(64))).toBe(false);
});
});
describe("buildInviteUrl", () => {
const SAVED = {
APP_URL: process.env.APP_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
};
beforeEach(() => {
delete process.env.APP_URL;
delete process.env.NEXT_PUBLIC_APP_URL;
});
afterEach(() => {
if (SAVED.APP_URL === undefined) delete process.env.APP_URL;
else process.env.APP_URL = SAVED.APP_URL;
if (SAVED.NEXT_PUBLIC_APP_URL === undefined)
delete process.env.NEXT_PUBLIC_APP_URL;
else process.env.NEXT_PUBLIC_APP_URL = SAVED.NEXT_PUBLIC_APP_URL;
});
it("encodes the invite universal-link path the iOS app intercepts", () => {
const token = generateInviteToken();
const url = buildInviteUrl(token, "https://health.example.com/api/x");
// The AASA-matched path shape — NOT the legacy ?invite query form.
expect(url).toBe(`https://health.example.com/invite/${token}`);
expect(new URL(url).pathname).toBe(`/invite/${token}`);
});
it("prefers the operator-configured origin over the request URL", () => {
process.env.APP_URL = "https://app.example.org";
const token = generateInviteToken();
const url = buildInviteUrl(token, "http://internal-host:3000/api/x");
expect(url).toBe(`https://app.example.org/invite/${token}`);
});
});
+11 -2
View File
@@ -36,7 +36,16 @@ export function looksLikeInviteToken(value: string): boolean {
}
/**
* Compose the registration deep link for a freshly minted invite.
* Compose the invite universal-link for a freshly minted invite.
*
* The URL is `https://<host>/invite/<hlv_token>` — a clean path the iOS
* scene delegate intercepts via the AASA `["*"]` matcher (issue #16): an
* installed app opens straight onto onboarding registration with the
* token prefilled; a browser hits the `/invite/<token>` web route, which
* is a thin redirect onto `/auth/register?invite=<token>`. The token is
* `hlv_` + 64 hex, so it is already path-safe; `encodeURIComponent`
* stays as a defensive belt against any future prefix change.
*
* Origin precedence mirrors the passkey RP-origin resolution: the
* operator-configured `APP_URL` / `NEXT_PUBLIC_APP_URL` win over the
* request origin (which may be an internal hostname behind the proxy).
@@ -59,7 +68,7 @@ export function buildInviteUrl(rawToken: string, requestUrl: string): string {
// try the next candidate
}
}
return `${origin}/auth/register?invite=${encodeURIComponent(rawToken)}`;
return `${origin}/invite/${encodeURIComponent(rawToken)}`;
}
export type InviteConsumeResult =
+1 -1
View File
@@ -152,7 +152,7 @@ export const adminInvitePaths: NonNullable<ZodOpenApiObject["paths"]> = {
url: z
.string()
.describe(
"Composed registration deep link (`/auth/register?invite=…`).",
"Composed invite universal-link (`/invite/<hlv_token>`). On iOS it opens the app onto onboarding registration; in a browser it redirects to `/auth/register?invite=…`.",
),
}),
"AdminInviteCreated",
+12 -1
View File
@@ -42,6 +42,12 @@ const PUBLIC_PATHS = [
// by a session cookie, so it must reach the page without an auth gate.
// The page renders a flat 404 for any unknown / revoked / expired token.
"/c/",
// v1.17.0 — `/invite/<hlv_token>` is the invite universal-link landing
// (iOS #16). It is a thin shape-validated redirect onto
// `/auth/register?invite=…`, carries no session, touches no database,
// and is not an enumeration oracle — so it must reach the page without
// an auth gate, like the `/auth/` register surface it forwards to.
"/invite/",
// `/onboarding` itself + its subroutes are matched exactly via
// `isPublicPath()` so we don't admit `/onboarding-export` etc.
"/robots.txt",
@@ -273,7 +279,12 @@ export function proxy(request: NextRequest) {
// the header, and the token must never reach a search index.
// - `Referrer-Policy: no-referrer` so the token-bearing URL is not
// leaked in the `Referer` of any outbound navigation from the page.
if (pathname.startsWith("/c/")) {
//
// v1.17.0 — `/invite/<hlv_token>` (iOS #16) carries an equally sensitive
// secret in the path, so it earns the same edge defence even though it is
// a pure server redirect: no shared proxy / CDN may cache it and no
// crawler may index the token-bearing URL.
if (pathname.startsWith("/c/") || pathname.startsWith("/invite/")) {
response.headers.set(
"Cache-Control",
"no-store, no-cache, must-revalidate",