mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
commit
52bf985734
8 files changed
+252
-6
No files matched your search
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user