feat: add product-aware deletion emails and subdomain deletion/downloads routes

- Extend DeletionForm with product discriminator and configurable cooldown cookie
- Extract env-free deletion email helpers into deletion-email.ts with Lineage/Nessa branding
- Make misc.sendDeletionRequestEmail mutation product-aware (subject, html content, cookie)
- Replace legacy /deletion/life-and-lineage page with 308 redirect to lineage subdomain
- Add Lineage and Nessa subdomain deletion route modules with shared content components
- Add Lineage downloads route modules with shared content
- Update privacy policy deletion link to lineage subdomain
This commit is contained in:
2026-07-23 12:32:14 -04:00
parent 5705c5add7
commit 59d129b46b
17 changed files with 1138 additions and 41 deletions

View File

@@ -0,0 +1,97 @@
/**
* Unit tests for the Lineage per-subdomain account-deletion page content
* (task 11).
*
* Asserts against pure constants exported from `deletion-content.ts` — no
* solid-js / router / DOM. Covers the task-11 acceptance matrix:
* - Product discriminator is `"lineage"` (selects Lineage-branded email).
* - Cooldown cookie name is the legacy `deletionRequestSent` so an in-flight
* cooldown survives the `/deletion/life-and-lineage` → subdomain redirect.
* - Cookie name matches the server-side `deletionCookieName("lineage")`.
* - Grace-period label + value mirror `LINEAGE_CONFIG.DELETION_GRACE_PERIOD_MS`.
* - Legacy redirect target points at the lineage subdomain deletion URL.
*/
import { describe, it, expect } from "bun:test";
import {
DELETION_PRODUCT_KEY,
DELETION_COOKIE_NAME,
DELETION_GRACE_PERIOD_MS,
DELETION_GRACE_PERIOD_LABEL,
PAGE_META,
LEGACY_DELETION_REDIRECT_TARGET
} from "~/routes/lineage/deletion-content";
import {
DELETION_PRODUCT_SCHEMA,
deletionCookieName
} from "~/server/api/routers/deletion-email";
describe("Lineage deletion — product discriminator", () => {
it("is \"lineage\" (selects Lineage-branded email)", () => {
expect(DELETION_PRODUCT_KEY).toBe("lineage");
});
it("is accepted by the server-side product schema", () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe(
true
);
});
});
describe("Lineage deletion — cooldown cookie", () => {
it("uses the legacy cookie name for redirect backward-compat", () => {
// An in-flight cooldown from the old /deletion/life-and-lineage route
// MUST be honored across the 308 redirect — keep the legacy cookie name.
expect(DELETION_COOKIE_NAME).toBe("deletionRequestSent");
});
it("matches the server-side deletionCookieName(\"lineage\")", () => {
expect(DELETION_COOKIE_NAME).toBe(
deletionCookieName(DELETION_PRODUCT_KEY)
);
});
});
describe("Lineage deletion — grace period", () => {
it("mirrors LINEAGE_CONFIG.DELETION_GRACE_PERIOD_MS (24h)", () => {
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
expect(DELETION_GRACE_PERIOD_MS).toBe(TWENTY_FOUR_HOURS_MS);
});
it("surfaces a human-readable 24-hour label in the copy", () => {
expect(DELETION_GRACE_PERIOD_LABEL).toBe("24-hour");
});
});
describe("Lineage deletion — PageHead inputs", () => {
it("passes the base title (suffix is appended by PageHead)", () => {
expect(PAGE_META.title).toBe("Account Deletion");
});
it("does not pre-bake the site suffix into the title", () => {
expect(PAGE_META.title).not.toContain("|");
});
it("description mentions the grace period + account data removal", () => {
const desc = PAGE_META.description.toLowerCase();
expect(desc).toContain("24-hour");
expect(desc).toContain("life and lineage");
});
});
describe("Lineage deletion — legacy redirect target", () => {
it("points at the lineage subdomain deletion URL", () => {
expect(LEGACY_DELETION_REDIRECT_TARGET).toBe(
"https://lineage.freno.me/deletion"
);
});
it("is an absolute https URL", () => {
expect(LEGACY_DELETION_REDIRECT_TARGET.startsWith("https://")).toBe(true);
});
it("does not reference the legacy /deletion/life-and-lineage path", () => {
expect(LEGACY_DELETION_REDIRECT_TARGET).not.toContain(
"deletion/life-and-lineage"
);
});
});

View File

@@ -0,0 +1,71 @@
/**
* Pure content + metadata for the Lineage per-subdomain account-deletion page
* (task 11).
*
* Imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so the
* constants here can be unit-tested in `bun:test` without spinning up the
* router / MetaProvider, mirroring the `landing-content.ts` /
* `downloads-content.ts` pattern.
*
* Cross-task contracts encoded here:
* - `DELETION_PRODUCT_KEY` is the `product` discriminator passed to the
* generalized `misc.sendDeletionRequestEmail` mutation
* (`src/server/api/routers/misc.ts`) so the email copy + cooldown cookie
* are Lineage-branded. The legacy mutation default is also `"lineage"`,
* so the migrated page is backward-compatible with any in-flight cooldown.
* - `DELETION_COOKIE_NAME` is the cooldown cookie read/written by
* `DeletionForm` — kept as the original `deletionRequestSent` name so
* installed cooldown state from the legacy `/deletion/life-and-lineage`
* route is honored across the 308 redirect (no forced re-send).
* - `DELETION_GRACE_PERIOD_MS` mirrors `LINEAGE_CONFIG.DELETION_GRACE_PERIOD_MS`
* (24h) — the window during which a user may email michael@freno.me to
* cancel the deletion before the central account row + per-user Turso DB
* are dropped. Surfaced here as a pure constant so the page copy + tests
* can assert the grace window without importing the server-side config
* module (which validates ~30 secrets at import time).
* - `LEGACY_DELETION_REDIRECT_TARGET` is the canonical absolute URL the
* legacy `/deletion/life-and-lineage` route 308-redirects to.
*/
import type { PageHeadProps } from "~/components/page-head-meta";
/**
* Product discriminator for the generalized `sendDeletionRequestEmail`
* mutation. The Lineage flow is the original / default product.
*/
export const DELETION_PRODUCT_KEY = "lineage" as const;
/**
* Cooldown cookie name for the Lineage deletion request.
*
* Kept identical to the legacy cookie so an in-flight cooldown survives the
* `/deletion/life-and-lineage` → `lineage.freno.me/deletion` redirect.
*/
export const DELETION_COOKIE_NAME = "deletionRequestSent";
/**
* Grace period (ms) during which a Lineage account deletion can be cancelled
* by emailing michael@freno.me. Mirrors
* `LINEAGE_CONFIG.DELETION_GRACE_PERIOD_MS` (24h).
*/
export const DELETION_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000;
/**
* Human-readable grace-window copy interpolated into the deletion page body.
*/
export const DELETION_GRACE_PERIOD_LABEL = "24-hour";
/** Base page title (site suffix appended by PageHead). */
export const PAGE_META: PageHeadProps = {
title: "Account Deletion",
description:
"Request account deletion for Life and Lineage. All account data and remote saves are removed after a 24-hour grace period."
};
/**
* Canonical absolute URL the legacy `/deletion/life-and-lineage` route
* 308-redirects to (task 11). Kept here so tests can assert the redirect
* target without importing the route module (which would pull the server
* runtime).
*/
export const LEGACY_DELETION_REDIRECT_TARGET =
"https://lineage.freno.me/deletion";

View File

@@ -0,0 +1,71 @@
/**
* Lineage per-subdomain account-deletion page — `lineage.freno.me/deletion`
* (task 11).
*
* Migrated from `src/routes/deletion/life-and-lineage.tsx` (which is now a
* 308 redirect to this public URL — see `LEGACY_DELETION_REDIRECT_TARGET`).
*
* Served at the public browser path `/deletion` (vercel.json host rewrites
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
* browser URL clean — task 02 canonical rule). The nav-config "Account
* Deletion" entry points at this path (task 04).
*
* Deletion flow:
* - Reuses the shared `DeletionForm` component, now generalized to forward
* a `product` discriminator to the `misc.sendDeletionRequestEmail`
* mutation. For Lineage we pass `product="lineage"` + the legacy
* cooldown cookie name (`deletionRequestSent`) so an in-flight cooldown
* from the old `/deletion/life-and-lineage` route is honored across the
* 308 redirect (no forced re-send).
* - On the server, the mutation sends a Lineage-branded email to
* michael@freno.me + the requester; Mike then manually drops the central
* account row + the user's per-user Turso remote-save DB after the 24h
* grace window (`LINEAGE_CONFIG.DELETION_GRACE_PERIOD_MS`). This is the
* SAME flow the legacy page used — only the URL + branding moved.
*
* Site-awareness:
* - `<PageHead>` reads `useSite()` → lineage title suffix + canonical are
* derived automatically (task 02).
* - No auth — the deletion request is email-based (the requester may be
* locked out of their account), NOT an authenticated self-delete.
*
* Acceptance: `lineage.localhost:3000/deletion` renders the deletion form;
* the form posts to the correct tRPC mutation (Lineage-branded email).
*/
import { PageHead } from "~/components/PageHead";
import DeletionForm from "~/components/DeletionForm";
import {
DELETION_PRODUCT_KEY,
DELETION_COOKIE_NAME,
DELETION_GRACE_PERIOD_LABEL,
PAGE_META
} from "~/routes/lineage/deletion-content";
export default function LineageDeletionPage() {
return (
<>
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
<div class="pt-20">
<div class="mx-auto p-4 md:p-6 lg:p-12">
<div class="text-text w-full justify-center">
<div class="text-xl">
<em>What will happen</em>:
</div>
Once you send, if a match to the email provided is found in our
system, a {DELETION_GRACE_PERIOD_LABEL} grace period is started
where you can request a cancellation of the account deletion. Once
the grace period ends, the account&apos;s entry in our central
database will be completely removed, and your individual database
storing your remote saves will also be deleted. No data related to
the account is retained in any way.
</div>
<DeletionForm
product={DELETION_PRODUCT_KEY}
cookieName={DELETION_COOKIE_NAME}
/>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,92 @@
/**
* Unit tests for the Lineage per-subdomain downloads page content (task 11).
*
* Mirrors the `landing-content.test.ts` pattern: assert against pure
* constants exported from `downloads-content.ts` (no solid-js / router /
* DOM). This covers the task-11 acceptance matrix that's structurally
* verifiable without rendering:
* - APK asset key is `"lineage"` (the tRPC key the downloads router maps to
* `Life and Lineage.apk`) — must match the unified downloads page's key
* so the APK is byte-identical from both origins.
* - App Store link is the canonical Life and Lineage App Store URL.
* - PageHead base title + description (suffix appended by PageHead).
* - Back-to-home link is the subdomain-relative public browser path.
*/
import { describe, it, expect } from "bun:test";
import {
LINEAGE_DOWNLOAD_ASSET,
LINEAGE_APK_BUTTON_LABEL,
LINEAGE_APP_STORE_URL,
LINEAGE_HOME_HREF,
PAGE_META
} from "~/routes/lineage/downloads-content";
import { APP_STORE_URL as LANDING_APP_STORE_URL } from "~/routes/lineage/landing-content";
describe("Lineage downloads — APK asset", () => {
it("uses the tRPC key the downloads router maps to the lineage APK", () => {
// src/server/api/routers/downloads.ts: assets["lineage"] = "Life and Lineage.apk"
expect(LINEAGE_DOWNLOAD_ASSET).toBe("lineage");
});
it("matches the asset key used by the unified freno.me/downloads page", () => {
// Regression guard: the unified page calls download("lineage") for the
// same S3 object — both origins must serve the identical APK.
expect(LINEAGE_DOWNLOAD_ASSET).toBe("lineage");
});
});
describe("Lineage downloads — button label", () => {
it("surfaces the APK file extension in the CTA", () => {
expect(LINEAGE_APK_BUTTON_LABEL).toBe("download.apk");
});
});
describe("Lineage downloads — App Store link", () => {
it("matches the canonical Life and Lineage App Store URL", () => {
expect(LINEAGE_APP_STORE_URL).toBe(
"https://apps.apple.com/us/app/life-and-lineage/id6737252442"
);
});
it("is an absolute https URL", () => {
expect(LINEAGE_APP_STORE_URL.startsWith("https://")).toBe(true);
});
it("matches the App Store URL surfaced on the landing page", () => {
// landing-content.ts exports APP_STORE_URL — same canonical link.
expect(LINEAGE_APP_STORE_URL).toBe(LANDING_APP_STORE_URL);
});
});
describe("Lineage downloads — back-to-home link", () => {
it("targets the subdomain-relative public browser path", () => {
// NOT `/lineage/` (the internal vercel-rewrite prefix) — vercel rewrites
// `lineage.freno.me/` → `/lineage/` while leaving the browser URL clean.
expect(LINEAGE_HOME_HREF).toBe("/");
});
it("does not leak the internal route prefix", () => {
expect(LINEAGE_HOME_HREF).not.toContain("/lineage");
});
});
describe("Lineage downloads — PageHead inputs", () => {
it("passes the base title (suffix is appended by PageHead)", () => {
expect(PAGE_META.title).toBe("Downloads");
});
it("does not pre-bake the site suffix into the title", () => {
expect(PAGE_META.title).not.toContain("|");
});
it("carries a non-empty description", () => {
expect(PAGE_META.description.length).toBeGreaterThan(0);
});
it("description mentions the product + both store fronts", () => {
const desc = PAGE_META.description.toLowerCase();
expect(desc).toContain("life and lineage");
expect(desc).toContain("apk");
expect(desc).toContain("app store");
});
});

View File

@@ -0,0 +1,67 @@
/**
* Pure content + metadata for the Lineage per-subdomain downloads page
* (task 11).
*
* Mirrors the `landing-content.ts` / `page-head-meta.ts` / `nav-config.ts`
* pattern: imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so
* the constants here can be unit-tested in `bun:test` without spinning up the
* router / MetaProvider — and so the acceptance matrix (asset key, App Store
* URL, PageHead inputs) is asserted against structurally without a DOM render.
*
* The render layer (`./downloads.tsx`) is a thin JSX consumer of these
* values; keeping them externalized means changes to the download target /
* store link surface as test failures rather than silent regressions.
*
* Cross-task contracts encoded here:
* - `LINEAGE_DOWNLOAD_ASSET` is the tRPC `downloads.getDownloadUrl` asset key
* (`"lineage"`) → resolves to `Life and Lineage.apk` in
* `src/server/api/routers/downloads.ts`. It MUST match the key used by the
* unified `freno.me/downloads` page so the APK served is byte-identical
* from both origins (single S3 source of truth).
* - `LINEAGE_APP_STORE_URL` is the canonical App Store link — kept identical
* to the value surfaced on the landing page (`landing-content.ts`) and the
* unified downloads page, so the store front is consistent across origins.
* - `LINEAGE_DOWNLOADS_META` is consumed verbatim by `<PageHead>`; the
* per-site title suffix (` | Life and Lineage`) is appended automatically
* by `resolvePageHeadMeta` (task 02), so `title` here is the BASE title
* only — do NOT include the suffix.
*/
import type { PageHeadProps } from "~/components/page-head-meta";
/**
* tRPC `downloads.getDownloadUrl` asset key for the Lineage Android APK.
*
* Maps to `Life and Lineage.apk` in the downloads router's `assets` table.
* Shared with the unified `freno.me/downloads` page (no separate asset path).
*/
export const LINEAGE_DOWNLOAD_ASSET = "lineage" as const;
/**
* Android download CTA copy.
*
* Kept in sync with the unified downloads page's Lineage section so the
* button label is consistent across origins.
*/
export const LINEAGE_APK_BUTTON_LABEL = "download.apk";
/**
* Apple App Store link — identical to `APP_STORE_URL` in `landing-content.ts`
* (single source of truth: the canonical Life and Lineage App Store URL).
*/
export const LINEAGE_APP_STORE_URL =
"https://apps.apple.com/us/app/life-and-lineage/id6737252442";
/**
* Public browser path back to the Lineage landing page (subdomain-relative).
*
* vercel.json rewrites `lineage.freno.me/` → the internal `/lineage/` route
* prefix while leaving the browser URL clean (task 02 canonical rule).
*/
export const LINEAGE_HOME_HREF = "/";
/** Base page title (site suffix appended by PageHead). */
export const PAGE_META: PageHeadProps = {
title: "Downloads",
description:
"Download Life and Lineage — Android APK or on the App Store for iOS. A dark fantasy adventure mobile game."
};

View File

@@ -0,0 +1,146 @@
/**
* Lineage per-subdomain downloads page — `lineage.freno.me/downloads`
* (task 11).
*
* Served at the public browser path `/downloads` (vercel.json host rewrites
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
* browser URL clean — task 02 canonical rule). The nav-config "Downloads"
* entry and the landing page's Google Play badge both point at this path
* (tasks 04 + 08).
*
* Download surface (mirrors the Lineage section of the unified
* `freno.me/downloads` page, byte-identical asset source):
* - Android APK via tRPC `downloads.getDownloadUrl({ asset_name: "lineage" })`
* → S3 signed URL for `Life and Lineage.apk`. Reuses the shared
* `downloadAsset` helper (task 05) so the click → redirect → S3 flow is a
* single code path shared with the Gaze landing page.
* - iOS App Store link (`LINEAGE_APP_STORE_URL`) — absolute external URL,
* identical to the link surfaced on the landing page + unified downloads.
*
* Site-awareness:
* - `<PageHead>` reads `useSite()` → the lineage `titleSuffix`
* (` | Life and Lineage`) + canonical `https://lineage.freno.me/downloads`
* are derived automatically (task 02); we pass only the base title here.
* - No auth — Lineage's mobile JWT (`LINEAGE_JWT_SECRET`) is for the mobile
* app's API calls, not the web downloads page.
*
* Acceptance: `lineage.localhost:3000/downloads` renders APK + App Store;
* clicking APK redirects to an S3 signed URL; the unified downloads page is
* unchanged (regression check).
*/
import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup } from "solid-js";
import { PageHead } from "~/components/PageHead";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import Button from "~/components/ui/Button";
import { glitchText } from "~/lib/client-utils";
import { downloadAsset } from "~/lib/download-asset";
import {
LINEAGE_DOWNLOAD_ASSET,
LINEAGE_APK_BUTTON_LABEL,
LINEAGE_APP_STORE_URL,
LINEAGE_HOME_HREF,
PAGE_META
} from "~/routes/lineage/downloads-content";
export default function LineageDownloadsPage() {
const [title, setTitle] = createSignal("Life and Lineage");
const [loading, setLoading] = createSignal(false);
const handleDownload = () => {
if (loading()) return;
setLoading(true);
import("~/lib/api")
.then(({ api }) =>
downloadAsset({
api,
assetName: LINEAGE_DOWNLOAD_ASSET,
onError: (error) => {
console.error("Lineage download error:", error);
alert("Failed to initiate download. Please try again.");
}
})
)
.finally(() => setLoading(false));
};
onMount(() => {
const interval = glitchText(title(), setTitle);
onCleanup(() => clearInterval(interval));
});
return (
<>
<PageHead
title={PAGE_META.title}
description={PAGE_META.description}
/>
<div class="bg-base relative min-h-screen overflow-hidden px-4 pt-[15vh] pb-12 md:px-8">
{/* Subtle scanline effect — consistent with the unified downloads page. */}
<div class="pointer-events-none absolute inset-0 opacity-5">
<div
class="h-full w-full"
style={{
"background-image":
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.2) 2px, rgba(0,0,0,0.2) 4px)"
}}
/>
</div>
<div class="relative z-10 mx-auto max-w-3xl">
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {title()}
</h2>
<div class="flex flex-col gap-8 sm:flex-row sm:justify-around">
{/* Android APK via tRPC → S3 signed URL */}
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: android
</span>
<Button
variant="download"
size="lg"
loading={loading()}
onClick={handleDownload}
>
{LINEAGE_APK_BUTTON_LABEL}
</Button>
<span class="text-subtext1 max-w-xs text-center text-xs italic">
# android build not optimized
</span>
</div>
{/* iOS App Store */}
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: ios
</span>
<A
class="transition-all duration-200 ease-out hover:scale-105 active:scale-95"
href={LINEAGE_APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
>
<DownloadOnAppStore size={50} />
</A>
</div>
</div>
</div>
{/* Secondary CTA → landing page */}
<p class="mt-12 text-center text-sm text-subtext0">
<A
href={LINEAGE_HOME_HREF}
class="underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
>
← back to Life and Lineage
</A>
</p>
</div>
</div>
</>
);
}