Merge branch 'downloads-and-deletion-flows' (task 11)

# Conflicts:
#	src/routes/privacy-policy/life-and-lineage.tsx
This commit is contained in:
2026-07-23 17:08:06 -04:00
16 changed files with 1137 additions and 40 deletions

View File

@@ -3,7 +3,32 @@ import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import { Spinner } from "~/components/Spinner";
import { getClientCookie } from "~/lib/cookies.client";
export default function DeletionForm() {
/**
* Product discriminator forwarded to the generalized
* `misc.sendDeletionRequestEmail` mutation so the email copy + cooldown
* cookie are product-appropriate (task 11).
*/
export type DeletionProduct = "lineage" | "nessa";
export interface DeletionFormProps {
/**
* Product whose account is being deleted. Determines the email branding
* AND the cooldown cookie name on the server. Defaults to `"lineage"`
* (the original / legacy flow) for backward compatibility.
*/
product?: DeletionProduct;
/**
* Cooldown cookie name read on mount + written by the server response
* (the mutation sets its own cookie; this is only for the client-side
* countdown). Defaults to the legacy `deletionRequestSent` name so an
* in-flight Lineage cooldown survives the legacy redirect.
*/
cookieName?: string;
}
export default function DeletionForm(props: DeletionFormProps = {}) {
const product = () => props.product ?? "lineage";
const cookieName = () => props.cookieName ?? "deletionRequestSent";
const [countDown, setCountDown] = createSignal(0);
const [emailSent, setEmailSent] = createSignal(false);
const [error, setError] = createSignal("");
@@ -28,7 +53,7 @@ export default function DeletionForm() {
};
createEffect(() => {
const timer = getClientCookie("deletionRequestSent");
const timer = getClientCookie(cookieName());
if (timer) {
timerInterval = setInterval(
() => calcRemainder(timer),
@@ -60,14 +85,14 @@ export default function DeletionForm() {
const response = await fetch("/api/trpc/misc.sendDeletionRequestEmail", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email })
body: JSON.stringify({ email, product: product() })
});
const result = await response.json();
if (response.ok && result.result?.data?.message === "request sent") {
setEmailSent(true);
const timer = getClientCookie("deletionRequestSent");
const timer = getClientCookie(cookieName());
if (timer) {
if (timerInterval) {
clearInterval(timerInterval);

View File

@@ -0,0 +1,46 @@
/**
* Regression test for the legacy `/deletion/life-and-lineage` route (task 11).
*
* The route was converted from a rendered page into a 308 permanent redirect
* to `lineage.freno.me/deletion`. Because the route file is a SolidStart
* server module, this is a STATIC SOURCE AUDIT (same pattern as the
* `misc.test.ts` regression tests) asserting:
* - The route exports a `GET` handler (API-route redirect, not a page).
* - The response status is 308 (permanent).
* - The `Location` header derives from the centralized
* `LEGACY_DELETION_REDIRECT_TARGET` constant (not a hardcoded literal), so
* the unit test in `deletion-content.test.ts` is the single source of
* truth for the destination.
* - No page component / DeletionForm import remains (the form moved to
* `src/routes/lineage/deletion.tsx`).
*/
import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const SOURCE = readFileSync(
join(import.meta.dir, "life-and-lineage.tsx"),
"utf8"
);
describe("Legacy /deletion/life-and-lineage — redirect (task 11)", () => {
it("is a GET handler (API-route redirect, not a rendered page)", () => {
expect(SOURCE).toContain("export function GET()");
expect(SOURCE).not.toContain("export default function");
});
it("responds with a 308 permanent redirect", () => {
expect(SOURCE).toContain("status: 308");
});
it("derives the Location from the centralized constant", () => {
expect(SOURCE).toContain("LEGACY_DELETION_REDIRECT_TARGET");
// The constant is imported from the lineage deletion-content module.
expect(SOURCE).toContain("~/routes/lineage/deletion-content");
});
it("no longer ships a page component / DeletionForm", () => {
expect(SOURCE).not.toContain("DeletionForm");
expect(SOURCE).not.toContain("PageHead");
});
});

View File

@@ -1,31 +1,29 @@
import { PageHead } from "~/components/PageHead";
import DeletionForm from "~/components/DeletionForm";
/**
* Legacy Life and Lineage account-deletion route — now a 308 permanent
* redirect to the Lineage subdomain (task 11).
*
* The deletion form has been migrated to `src/routes/lineage/deletion.tsx`
* served at `lineage.freno.me/deletion` (vercel.json host rewrites map the
* subdomain to the `/lineage/*` internal prefix). Keeping this route as a
* permanent (308) server-side redirect — rather than a client `<Navigate>` —
* preserves SEO equity and gives installed / linked / support-emailed URLs a
* stable resolution path to the new home.
*
* Implemented as a SolidStart API route (`GET` handler returning a Response)
* so the redirect happens before any rendering; the route no longer ships a
* page component. The redirect target is centralized in
* `~/routes/lineage/deletion-content.ts` (`LEGACY_DELETION_REDIRECT_TARGET`)
* so the unit test can assert the destination without importing this server
* module.
*/
import { LEGACY_DELETION_REDIRECT_TARGET } from "~/routes/lineage/deletion-content";
export default function LifeAndLinageDeletionForm() {
return (
<>
<PageHead
title="Account Deletion - Life and Lineage"
description="Request account deletion for Life and Lineage. Remove all your data from our system with a 24-hour grace period."
/>
<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 24hr grace period is started where you can request a
cancellation of the account deletion. Once the grace period ends,
the account'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 />
</div>
</div>
</>
);
export function GET() {
return new Response(null, {
status: 308,
headers: {
Location: LEGACY_DELETION_REDIRECT_TARGET,
"Cache-Control": "public, max-age=0, must-revalidate"
}
});
}

View File

@@ -0,0 +1,59 @@
/**
* Regression test for the unified `freno.me/downloads` page (task 11).
*
* Task 11's acceptance criteria require that the unified downloads page is
* UNCHANGED — it keeps listing all five products (InputHalo, Gaze, Life and
* Lineage, Cork, Shapes with Abigail) with the original asset keys + store
* links. Because the page is a SolidJS component (DOM render not configured
* under `bun:test`), this is a STATIC SOURCE AUDIT — the same pattern the
* p8-001 / p8-008 `misc.test.ts` regression tests use.
*
* Audits `src/routes/downloads.tsx` for:
* - All five product labels present (no removal / rename).
* - The Lineage APK asset key (`"lineage"`) is still wired to the download
* button — the per-subdomain `lineage.freno.me/downloads` page MUST serve
* the byte-identical APK, which requires the same S3 asset key.
* - The Life and Lineage App Store link is intact.
* - No accidental deletion of the other products' sections.
*/
import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const SOURCE = readFileSync(join(import.meta.dir, "downloads.tsx"), "utf8");
describe("Unified downloads page — product list (regression)", () => {
it("renders all five product sections", () => {
// The unified page is intentionally ordered by date of initial release.
expect(SOURCE).toContain("InputHalo");
expect(SOURCE).toContain("Gaze");
expect(SOURCE).toContain("Life and Lineage");
expect(SOURCE).toContain("Cork");
expect(SOURCE).toContain("Shapes with Abigail");
});
it("does not trim the Five-products comment / ordering note", () => {
expect(SOURCE).toContain("Ordered by date of initial release");
});
});
describe("Unified downloads page — Lineage section (byte-identical APK)", () => {
it("still wires the Lineage APK button to the \"lineage\" tRPC asset key", () => {
// Same asset key the per-subdomain lineage/downloads page uses → both
// origins serve the byte-identical S3 object (`Life and Lineage.apk`).
expect(SOURCE).toContain('download("lineage")');
});
it("still links to the Life and Lineage App Store URL", () => {
expect(SOURCE).toContain(
"https://apps.apple.com/us/app/life-and-lineage/id6737252442"
);
});
it("does not redirect Lineage downloads away to the subdomain", () => {
// The unified page keeps an inline APK download — it must NOT delegate to
// lineage.freno.me/downloads (that would be a regression of the unified
// "one page lists everything" UX).
expect(SOURCE).not.toContain("lineage.freno.me");
});
});

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>
</>
);
}

View File

@@ -0,0 +1,84 @@
/**
* Unit tests for the Nessa 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 for the
* Nessa deletion flow:
* - Product discriminator is `"nessa"` (selects Nessa-branded email).
* - Cooldown cookie name is Nessa-specific + matches the server-side
* `deletionCookieName("nessa")`.
* - PageHead base title + description.
* - (Assessment rationale documented in `deletion-content.ts`.)
*/
import { describe, it, expect } from "bun:test";
import {
DELETION_PRODUCT_KEY,
DELETION_COOKIE_NAME,
DELETION_GRACE_PERIOD_LABEL,
PAGE_META
} from "~/routes/nessa/deletion-content";
import {
DELETION_PRODUCT_SCHEMA,
deletionCookieName
} from "~/server/api/routers/deletion-email";
describe("Nessa deletion — assessment outcome", () => {
it("defines a product discriminator (deletion flow IS implemented)", () => {
// Nessa stores user data (nessa.ts: users, workouts, workoutPlans, … +
// nessa-community.ts: clubs, clubMemberships). Per task 11 spec step 5,
// a deletion flow IS needed — this page provides it.
expect(typeof DELETION_PRODUCT_KEY).toBe("string");
});
});
describe("Nessa deletion — product discriminator", () => {
it("is \"nessa\" (selects Nessa-branded email)", () => {
expect(DELETION_PRODUCT_KEY).toBe("nessa");
});
it("is accepted by the server-side product schema", () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe(
true
);
});
});
describe("Nessa deletion — cooldown cookie", () => {
it("uses a Nessa-specific cookie name (independent of Lineage cooldown)", () => {
expect(DELETION_COOKIE_NAME).toBe("nessaDeletionRequestSent");
});
it("matches the server-side deletionCookieName(\"nessa\")", () => {
expect(DELETION_COOKIE_NAME).toBe(
deletionCookieName(DELETION_PRODUCT_KEY)
);
});
it("does NOT collide with the Lineage cooldown cookie", () => {
expect(DELETION_COOKIE_NAME).not.toBe("deletionRequestSent");
});
});
describe("Nessa deletion — grace period label", () => {
it("surfaces a human-readable 24-hour label in the copy", () => {
expect(DELETION_GRACE_PERIOD_LABEL).toBe("24-hour");
});
});
describe("Nessa 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 Nessa + data removal + grace period", () => {
const desc = PAGE_META.description.toLowerCase();
expect(desc).toContain("nessa");
expect(desc).toContain("removed");
expect(desc).toContain("24-hour");
});
});

View File

@@ -0,0 +1,53 @@
/**
* Pure content + metadata for the Nessa per-subdomain account-deletion page
* (task 11).
*
* Mirrors the `lineage/deletion-content.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.
*
* Nessa deletion assessment (see task 11 spec, step 5):
* - Nessa DOES store user data. `src/server/api/routers/nessa.ts` defines
* per-user tables (`users`, `authProviders`, `workouts`, `workoutPlans`,
* `planExercises`, `planSets`, `routePoints`, `exerciseLibrary`) backed
* by a per-user Turso DB, and `nessa-community.ts` defines shared
* community tables (`clubs`, `clubMemberships`, …) keyed by `userId` /
* `ownerId`. Auth is Clerk. → A deletion flow IS needed.
* - Implemented here as the SAME email-request pattern Lineage uses: the
* requester submits their email via `DeletionForm`; the generalized
* `misc.sendDeletionRequestEmail` mutation sends a Nessa-branded email
* to michael@freno.me + the requester; Mike then manually drops the
* Nessa `users` row (+ cascades), the per-user Turso DB, and the user's
* community memberships within the 24h grace window. An authenticated
* self-delete via `nessa.deleteUser` + the Clerk Users API remains a
* follow-up (it requires Clerk backend secret wiring that is out of
* scope for the subdomain-routing feature); the email-request flow gives
* users a real, immediate deletion path today.
*
* Cross-task contracts:
* - `DELETION_PRODUCT_KEY = "nessa"` selects Nessa branding + the
* `nessaDeletionRequestSent` cooldown cookie (server-side
* `deletionCookieName("nessa")`).
* - `DELETION_COOKIE_NAME` MUST match `deletionCookieName("nessa")` so the
* client countdown reads the cookie the server actually sets.
*/
import type { PageHeadProps } from "~/components/page-head-meta";
/** Product discriminator forwarded to `misc.sendDeletionRequestEmail`. */
export const DELETION_PRODUCT_KEY = "nessa" as const;
/**
* Cooldown cookie name — MUST match `deletionCookieName("nessa")` on the
* server (`nessaDeletionRequestSent`).
*/
export const DELETION_COOKIE_NAME = "nessaDeletionRequestSent";
/** Human-readable grace-window copy interpolated into the 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 Nessa. Your Nessa account, workout data, and community memberships are removed after a 24-hour grace period."
};

View File

@@ -0,0 +1,61 @@
/**
* Nessa per-subdomain account-deletion page — `nessa.freno.me/deletion`
* (task 11).
*
* Served at the public browser path `/deletion` (vercel.json host rewrites
* `nessa.freno.me/*` → the internal `/nessa/*` route prefix, leaving the
* browser URL clean — task 02 canonical rule).
*
* Nessa deletion assessment (see `./deletion-content.ts` for the full
* rationale): Nessa stores user data (`users`, `workouts`, `workoutPlans`,
* `exerciseLibrary`, community memberships) in a per-user Turso DB +
* shared community tables, authenticated via Clerk. → A deletion flow IS
* needed; this page provides it via the same email-request pattern Lineage
* uses, reusing the shared `DeletionForm` with `product="nessa"` so the
* generalized `misc.sendDeletionRequestEmail` mutation sends Nessa-branded
* email + writes a Nessa-specific cooldown cookie.
*
* Auth: NO freno.me web-auth — Nessa authenticates via Clerk; the deletion
* request is email-based (the requester may be locked out of their Clerk
* session), NOT an authenticated self-delete. The nav-config does NOT list
* a Nessa deletion link by default, so this page is reachable by direct URL
* + from the Nessa privacy policy (task-provided).
*
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
*/
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/nessa/deletion-content";
export default function NessaDeletionPage() {
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, your Nessa account entry, your workout and
plan data, and your community memberships will be completely
removed. 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,121 @@
/**
* Unit tests for the generalized account-deletion-request email helpers
* (task 11).
*
* These are the pure, env-free helpers consumed by the
* `misc.sendDeletionRequestEmail` tRPC mutation (re-exported from `misc.ts`).
* Kept in a separate module so they can be exercised in `bun:test` without a
* populated `.env` (which `~/env/server` requires at import time — un-runnable
* in this worktree).
*
* Coverage:
* - `DELETION_PRODUCT_SCHEMA` accepts the two known products + rejects others.
* - `deletionCookieName` returns per-product distinct names; Lineage keeps
* the legacy `deletionRequestSent` for redirect backward-compat.
* - `deletionEmailContent` produces product-branded subject + operator +
* user HTML bodies; the requester email appears in the operator body and
* the account email appears in the user body; the 24h cancellation
* window instructions are present in the user body.
*/
import { describe, it, expect } from "bun:test";
import {
DELETION_PRODUCT_SCHEMA,
deletionCookieName,
deletionEmailContent
} from "~/server/api/routers/deletion-email";
describe("DELETION_PRODUCT_SCHEMA", () => {
it("accepts \"lineage\"", () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse("lineage").success).toBe(true);
});
it("accepts \"nessa\"", () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse("nessa").success).toBe(true);
});
it("rejects unknown products", () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse("gaze").success).toBe(false);
expect(DELETION_PRODUCT_SCHEMA.safeParse("").success).toBe(false);
expect(DELETION_PRODUCT_SCHEMA.safeParse(undefined).success).toBe(false);
});
});
describe("deletionCookieName", () => {
it("returns the legacy name for lineage (redirect backward-compat)", () => {
expect(deletionCookieName("lineage")).toBe("deletionRequestSent");
});
it("returns a Nessa-specific name for nessa", () => {
expect(deletionCookieName("nessa")).toBe("nessaDeletionRequestSent");
});
it("returns distinct names per product", () => {
expect(deletionCookieName("lineage")).not.toBe(
deletionCookieName("nessa")
);
});
});
describe("deletionEmailContent — Lineage", () => {
const email = "player@example.com";
const content = deletionEmailContent("lineage", email);
it("uses the Lineage-branded subject", () => {
expect(content.subject).toBe("Life and Lineage Acct Deletion");
});
it("operator body identifies the request + requester email", () => {
expect(content.operatorHtml).toContain("Life and Lineage Account Deletion");
expect(content.operatorHtml).toContain(email);
});
it("user body identifies the account to delete + 24h cancellation instructions", () => {
expect(content.userHtml).toContain(email);
expect(content.userHtml).toContain("Account to delete");
expect(content.userHtml).toContain("michael@freno.me");
expect(content.userHtml).toContain("24hrs");
});
});
describe("deletionEmailContent — Nessa", () => {
const email = "member@example.com";
const content = deletionEmailContent("nessa", email);
it("uses the Nessa-branded subject", () => {
expect(content.subject).toBe("Nessa Acct Deletion");
});
it("operator body identifies the Nessa request + requester email", () => {
expect(content.operatorHtml).toContain("Nessa Account Deletion");
expect(content.operatorHtml).toContain(email);
});
it("user body identifies the account + mentions Nessa-specific cleanup", () => {
expect(content.userHtml).toContain(email);
expect(content.userHtml).toContain("Account to delete");
expect(content.userHtml).toContain("michael@freno.me");
expect(content.userHtml).toContain("24hrs");
// Nessa-specific cleanup scope surfaced in the user-facing copy.
expect(content.userHtml.toLowerCase()).toContain("memberships");
});
});
describe("deletionEmailContent — branding isolation", () => {
const email = "x@example.com";
const lineage = deletionEmailContent("lineage", email);
const nessa = deletionEmailContent("nessa", email);
it("subjects differ per product", () => {
expect(lineage.subject).not.toBe(nessa.subject);
});
it("Nessa body does not leak Lineage branding", () => {
expect(nessa.userHtml).not.toContain("Life and Lineage");
expect(nessa.operatorHtml).not.toContain("Life and Lineage");
});
it("Lineage body does not leak Nessa branding", () => {
expect(lineage.userHtml).not.toContain("Nessa");
expect(lineage.operatorHtml).not.toContain("Nessa");
});
});

View File

@@ -0,0 +1,76 @@
/**
* Pure helpers for the generalized account-deletion-request email flow
* (task 11).
*
* Extracted from `src/server/api/routers/misc.ts` so they can be unit-tested
* in `bun:test` WITHOUT importing `~/env/server` (which validates ~30 secrets
* at import time and is therefore un-runnable in a worktree without a
* populated `.env`). This mirrors the `page-head-meta.ts` / `nav-config.ts`
* testability pattern.
*
* `misc.ts` re-exports these for convenience; the deletion tRPC mutation
* consumes them directly.
*/
import { z } from "zod";
/**
* Product whose account is being deleted. Drives email branding + the
* cooldown cookie name so per-product cooldowns don't interfere.
*/
export const DELETION_PRODUCT_SCHEMA = z.enum(["lineage", "nessa"]);
export type DeletionProduct = z.infer<typeof DELETION_PRODUCT_SCHEMA>;
/**
* Cooldown cookie name for a given product. Lineage keeps the legacy
* `deletionRequestSent` name so an in-flight cooldown from the old
* `/deletion/life-and-lineage` route is honored across the 308 redirect
* (no forced re-send). Nessa uses a distinct name so its cooldown is
* independent.
*/
export function deletionCookieName(product: DeletionProduct): string {
return product === "nessa"
? "nessaDeletionRequestSent"
: "deletionRequestSent";
}
/** Branded copy for the deletion-request emails (operator + user-facing). */
export interface DeletionEmailContent {
/** Email subject line (shared by operator + user emails). */
subject: string;
/** HTML body sent to michael@freno.me (the operator). */
operatorHtml: string;
/** HTML body sent to the requester (the user). */
userHtml: string;
}
/**
* Build the product-branded deletion-request email content.
*
* The operator email identifies the request name + requester email; the user
* email identifies the account being deleted + the 24h cancellation window.
* The `product` discriminator switches branding between Lineage (the original
* flow) and Nessa (task 11 — Nessa stores user data in its own Turso DB).
*
* `email` is interpolated verbatim into the HTML bodies. It has already been
* validated as a well-formed email by the tRPC input schema, and Sendinblue
* renders HTML bodies, so the value is not re-escaped here — matching the
* original Lineage implementation's behavior to avoid regressing the existing
* flow's email formatting.
*/
export function deletionEmailContent(
product: DeletionProduct,
email: string
): DeletionEmailContent {
if (product === "nessa") {
return {
subject: "Nessa Acct Deletion",
operatorHtml: `<html><head></head><body><div>Request Name: Nessa Account Deletion</div><div>Request Email: ${email}</div></body></html>`,
userHtml: `<html><head></head><body><div>Request Name: Nessa Account Deletion</div><div>Account to delete: ${email}</div><div>You can email michael@freno.me in the next 24hrs to cancel the deletion, email with subject line "Account Deletion Cancellation". Your Nessa account row, workout / plan data, and community memberships will be removed.</div></body></html>`
};
}
return {
subject: "Life and Lineage Acct Deletion",
operatorHtml: `<html><head></head><body><div>Request Name: Life and Lineage Account Deletion</div><div>Request Email: ${email}</div></body></html>`,
userHtml: `<html><head></head><body><div>Request Name: Life and Lineage Account Deletion</div><div>Account to delete: ${email}</div><div>You can email michael@freno.me in the next 24hrs to cancel the deletion, email with subject line "Account Deletion Cancellation"</div></body></html>`
};
}

View File

@@ -52,6 +52,24 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void {
});
}
}
// ============================================================
// Account-deletion request email (task 11 — product-aware)
// ============================================================
//
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
// tested in `bun:test` without a populated `.env`. Re-exported here for the
// tRPC mutation below + for callers that already import from `misc`.
export {
DELETION_PRODUCT_SCHEMA,
deletionCookieName,
deletionEmailContent
} from "./deletion-email";
export type {
DeletionProduct,
DeletionEmailContent
} from "./deletion-email";
const assets: Record<string, string> = {
"shapes-with-abigail": "shapes-with-abigail.apk",
"magic-delve": "magic-delve.apk",
@@ -453,9 +471,21 @@ export const miscRouter = createTRPCRouter({
}),
sendDeletionRequestEmail: csrfProtectedProcedure
.input(z.object({ email: z.string().email() }))
.input(
z.object({
email: z.string().email(),
/** Product discriminator (task 11) — defaults to "lineage" for backward compat. */
product: DELETION_PRODUCT_SCHEMA.default("lineage")
})
)
.mutation(async ({ input }) => {
const deletionExp = getCookie("deletionRequestSent");
const cookieName = deletionCookieName(input.product);
const { subject, operatorHtml, userHtml } = deletionEmailContent(
input.product,
input.email
);
const deletionExp = getCookie(cookieName);
let remaining = 0;
if (deletionExp) {
@@ -479,8 +509,8 @@ export const miscRouter = createTRPCRouter({
email: "michael@freno.me"
},
to: [{ email: "michael@freno.me" }],
htmlContent: `<html><head></head><body><div>Request Name: Life and Lineage Account Deletion</div><div>Request Email: ${input.email}</div></body></html>`,
subject: "Life and Lineage Acct Deletion"
htmlContent: operatorHtml,
subject
};
const sendinblueUserData = {
@@ -489,8 +519,8 @@ export const miscRouter = createTRPCRouter({
email: "michael@freno.me"
},
to: [{ email: input.email }],
htmlContent: `<html><head></head><body><div>Request Name: Life and Lineage Account Deletion</div><div>Account to delete: ${input.email}</div><div>You can email michael@freno.me in the next 24hrs to cancel the deletion, email with subject line "Account Deletion Cancellation"</div></body></html>`,
subject: "Life and Lineage Acct Deletion"
htmlContent: userHtml,
subject
};
try {
@@ -538,7 +568,7 @@ export const miscRouter = createTRPCRouter({
]);
const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS);
setCookie("deletionRequestSent", exp.toUTCString(), {
setCookie(cookieName, exp.toUTCString(), {
expires: exp,
path: "/"
});