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

+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",