meta: task ref cleanup

This commit is contained in:
2026-07-23 21:54:52 -04:00
parent 8e33af43a5
commit f2fc6a5d1a
63 changed files with 658 additions and 485 deletions

View File

@@ -89,7 +89,7 @@ function getGtActivityPromise(): Promise<ContributionDay[]> {
.catch(() => [])); .catch(() => []));
} }
// ── Subdomain nav rendering (task 04) ───────────────────────────────────── // ── Subdomain nav rendering ──────────────────────────────────────────────
// //
// The main site retains its bespoke LeftBar / RightBarContent rendering // The main site retains its bespoke LeftBar / RightBarContent rendering
// unchanged (Recent Posts, auth-aware Account/Login/SignOut, admin links, // unchanged (Recent Posts, auth-aware Account/Login/SignOut, admin links,
@@ -828,9 +828,7 @@ export function LeftBar() {
// ("bars render appropriately styled per site — brand color hint from // ("bars render appropriately styled per site — brand color hint from
// SITE_CONFIG"). Main keeps the existing neutral styling. // SITE_CONFIG"). Main keeps the existing neutral styling.
const accentBorder = () => const accentBorder = () =>
site().id === "main" site().id === "main" ? undefined : { "border-color": site().brandColor };
? undefined
: { "border-color": site().brandColor };
return ( return (
<nav <nav

View File

@@ -1,10 +1,4 @@
import { import { createSignal, onMount, createEffect, Show, type JSX } from "solid-js";
createSignal,
onMount,
createEffect,
Show,
type JSX
} from "solid-js";
import { useSearchParams, query, createAsync } from "@solidjs/router"; import { useSearchParams, query, createAsync } from "@solidjs/router";
import { action, redirect } from "@solidjs/router"; import { action, redirect } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
@@ -43,7 +37,7 @@ import {
} from "~/lib/contact-config"; } from "~/lib/contact-config";
/** /**
* Shared, site-aware contact form (task 09 — per-subdomain contact pages). * Shared, site-aware contact form — per-subdomain contact pages.
* *
* Extracted verbatim-in-spirit from the legacy `src/routes/contact.tsx` so the * Extracted verbatim-in-spirit from the legacy `src/routes/contact.tsx` so the
* main-site contact flow (`freno.me/contact`) keeps its exact Turnstile + * main-site contact flow (`freno.me/contact`) keeps its exact Turnstile +
@@ -57,10 +51,10 @@ import {
* `CONTACT_CONTEXT[site().id]` (subjectPrefix, recipientLabel, heading, * `CONTACT_CONTEXT[site().id]` (subjectPrefix, recipientLabel, heading,
* PageHead title + description). Props override the defaults. * PageHead title + description). Props override the defaults.
* - Emits `<PageHead>` so every per-subdomain `/contact` route gets * - Emits `<PageHead>` so every per-subdomain `/contact` route gets
* site-aware title / canonical / OG tags for free (task 02). * site-aware title / canonical / OG tags for free.
* - The Turnstile site key (`VITE_TURNSTILE_SITE_KEY`) is shared across all * - The Turnstile site key (`VITE_TURNSTILE_SITE_KEY`) is shared across all
* subdomains — ensure it is configured for `*.freno.me` in the Cloudflare * subdomains — ensure it is configured for `*.freno.me` in the Cloudflare
* Turnstile dashboard (see task notes). * Turnstile dashboard.
* *
* Email routing: * Email routing:
* - JS path: `api.misc.sendContactRequest.mutate({ …, subjectPrefix })` — the * - JS path: `api.misc.sendContactRequest.mutate({ …, subjectPrefix })` — the
@@ -247,7 +241,8 @@ export function ContactForm(props: ContactFormProps) {
const ctx = () => getContactContext(site().id); const ctx = () => getContactContext(site().id);
// Effective values — props override the site-context defaults. // Effective values — props override the site-context defaults.
const effectiveSubjectPrefix = () => props.subjectPrefix ?? ctx().subjectPrefix; const effectiveSubjectPrefix = () =>
props.subjectPrefix ?? ctx().subjectPrefix;
const effectiveRecipientLabel = () => const effectiveRecipientLabel = () =>
props.recipientLabel ?? ctx().recipientLabel; props.recipientLabel ?? ctx().recipientLabel;
const effectiveHeading = () => props.heading ?? ctx().heading; const effectiveHeading = () => props.heading ?? ctx().heading;
@@ -283,7 +278,7 @@ export function ContactForm(props: ContactFormProps) {
// Load Cloudflare Turnstile script with explicit rendering. // Load Cloudflare Turnstile script with explicit rendering.
// The site key is shared across all subdomains — ensure it is configured // The site key is shared across all subdomains — ensure it is configured
// for `*.freno.me` in the Cloudflare Turnstile dashboard (task notes). // for `*.freno.me` in the Cloudflare Turnstile dashboard.
const script = document.createElement("script"); const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true; script.async = true;

View File

@@ -6,7 +6,7 @@ import { getClientCookie } from "~/lib/cookies.client";
/** /**
* Product discriminator forwarded to the generalized * Product discriminator forwarded to the generalized
* `misc.sendDeletionRequestEmail` mutation so the email copy + cooldown * `misc.sendDeletionRequestEmail` mutation so the email copy + cooldown
* cookie are product-appropriate (task 11). * cookie are product-appropriate.
*/ */
export type DeletionProduct = "lineage" | "nessa"; export type DeletionProduct = "lineage" | "nessa";

View File

@@ -1,5 +1,5 @@
/** /**
* Unit tests for `PageHead` site-aware metadata derivation (task 02). * Unit tests for `PageHead` site-aware metadata derivation.
* *
* `resolvePageHeadMeta` is a pure function over (props, site, pathname), so * `resolvePageHeadMeta` is a pure function over (props, site, pathname), so
* these tests mirror the acceptance matrix without a DOM / SolidJS router. * these tests mirror the acceptance matrix without a DOM / SolidJS router.
@@ -28,21 +28,13 @@ describe("resolvePageHeadMeta — title suffix per site", () => {
for (const { id, suffix } of cases) { for (const { id, suffix } of cases) {
it(`${id} → title is "${BASE_PROPS.title}${suffix}"`, () => { it(`${id} → title is "${BASE_PROPS.title}${suffix}"`, () => {
const meta = resolvePageHeadMeta( const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG[id], "/blog");
BASE_PROPS,
SITE_CONFIG[id],
"/blog"
);
expect(meta.title).toBe(`${BASE_PROPS.title}${suffix}`); expect(meta.title).toBe(`${BASE_PROPS.title}${suffix}`);
}); });
} }
it("main produces 'Home | Michael Freno' for the homepage", () => { it("main produces 'Home | Michael Freno' for the homepage", () => {
const meta = resolvePageHeadMeta( const meta = resolvePageHeadMeta({ title: "Home" }, SITE_CONFIG.main, "/");
{ title: "Home" },
SITE_CONFIG.main,
"/"
);
expect(meta.title).toBe("Home | Michael Freno"); expect(meta.title).toBe("Home | Michael Freno");
}); });
}); });
@@ -64,11 +56,7 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
}); });
it("nessa /contact → https://nessa.freno.me/contact", () => { it("nessa /contact → https://nessa.freno.me/contact", () => {
const meta = resolvePageHeadMeta( const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/contact");
BASE_PROPS,
SITE_CONFIG.nessa,
"/contact"
);
expect(meta.canonical).toBe("https://nessa.freno.me/contact"); expect(meta.canonical).toBe("https://nessa.freno.me/contact");
}); });

View File

@@ -18,7 +18,7 @@ export {
/** /**
* PageHead component for consistent page metadata across the application. * PageHead component for consistent page metadata across the application.
* *
* Site-aware (task 02): reads `useSite()` for the per-site title suffix, * Site-aware: reads `useSite()` for the per-site title suffix,
* canonical domain, and default OpenGraph image, so the same component * canonical domain, and default OpenGraph image, so the same component
* renders `" | Michael Freno"` / `" | Nessa"` / … depending on the active * renders `" | Michael Freno"` / `" | Nessa"` / … depending on the active
* subdomain. Canonical URLs are auto-derived from the site domain + the * subdomain. Canonical URLs are auto-derived from the site domain + the

View File

@@ -1,5 +1,5 @@
/** /**
* Pure metadata derivation for `PageHead` (task 02). * Pure metadata derivation for `PageHead`.
* *
* Intentionally imports NOTHING from solid-js / @solidjs/router / @solidjs/meta * Intentionally imports NOTHING from solid-js / @solidjs/router / @solidjs/meta
* so it can be unit-tested in `bun:test` without spinning up the SolidJS * so it can be unit-tested in `bun:test` without spinning up the SolidJS

View File

@@ -1,6 +1,6 @@
/** /**
* SiteContext — SolidJS provider exposing the active `Site` to the component * SiteContext — SolidJS provider exposing the active `Site` to the component
* tree (task 01 keystone). * tree (keystone).
* *
* Resolution strategy: * Resolution strategy:
* - Server (SSR): reads the module-level value bound by `setServerSite()`, * - Server (SSR): reads the module-level value bound by `setServerSite()`,

4
src/env/server.ts vendored
View File

@@ -56,8 +56,8 @@ const serverEnvSchema = z.object({
REDIS_URL: z.string().min(1), REDIS_URL: z.string().min(1),
NESSA_DB_URL: z.string().min(1), NESSA_DB_URL: z.string().min(1),
NESSA_DB_TOKEN: z.string().min(1), NESSA_DB_TOKEN: z.string().min(1),
// Clerk authentication — Nessa auth is now Clerk-backed (task 02). The // Clerk authentication — Nessa auth is now Clerk-backed. The
// legacy self-issued JWT signing env var was removed in task 11. // legacy self-issued JWT signing env var was removed.
NESSA_CLERK_SECRET: z.string().min(1), NESSA_CLERK_SECRET: z.string().min(1),
NESSA_CLERK_JWT_ISSUER: z.string().min(1), NESSA_CLERK_JWT_ISSUER: z.string().min(1),
// Clerk webhook signing secret (Svix). Used to verify `user.created` / // Clerk webhook signing secret (Svix). Used to verify `user.created` /

View File

@@ -1,11 +1,11 @@
/** /**
* Unit tests for the per-site contact configuration (task 09). * Unit tests for the per-site contact configuration.
* *
* Mirrors the `meta.test.ts` / `nav-config.test.ts` testability pattern: * Mirrors the `meta.test.ts` / `nav-config.test.ts` testability pattern:
* `contact-config.ts` is a pure module (no solid-js / @solidjs/router / * `contact-config.ts` is a pure module (no solid-js / @solidjs/router /
* @solidjs/meta imports) so `bun:test` can resolve it directly. * @solidjs/meta imports) so `bun:test` can resolve it directly.
* *
* Asserts the task-09 acceptance criteria: * Asserts the acceptance criteria:
* - Each subdomain has a distinct `subjectPrefix` (email routing differs per * - Each subdomain has a distinct `subjectPrefix` (email routing differs per
* subdomain). * subdomain).
* - The main site prefix stays `"freno.me"` so the legacy subject * - The main site prefix stays `"freno.me"` so the legacy subject

View File

@@ -1,5 +1,5 @@
/** /**
* Per-site contact form configuration (task 09 — per-subdomain contact pages). * Per-site contact form configuration — per-subdomain contact pages.
* *
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta — * Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
* so it can be unit-tested in `bun:test` without spinning up the router / Meta * so it can be unit-tested in `bun:test` without spinning up the router / Meta
@@ -18,7 +18,7 @@
* `"freno.me Contact Request"` subject is byte-identical after the refactor * `"freno.me Contact Request"` subject is byte-identical after the refactor
* (backwards compatibility for any inbox filters / saved searches). Each * (backwards compatibility for any inbox filters / saved searches). Each
* product subdomain uses a bracketed token (`"[Nessa]"`, `"[Lineage]"`, * product subdomain uses a bracketed token (`"[Nessa]"`, `"[Lineage]"`,
* `"[Gaze]"`, `"[InputHalo]"`) per the task spec so inbound mail can be * `"[Gaze]"`, `"[InputHalo]"`) so inbound mail can be
* routed / triaged by source product. * routed / triaged by source product.
* - All mail is delivered to `michael@freno.me` (single owner across every * - All mail is delivered to `michael@freno.me` (single owner across every
* product); `recipientLabel` is a display-only affordance, not an alternate * product); `recipientLabel` is a display-only affordance, not an alternate
@@ -32,7 +32,10 @@ import type { SiteId } from "~/lib/site-context";
/** Canonical recipient for every contact submission (single product owner). */ /** Canonical recipient for every contact submission (single product owner). */
export const CONTACT_RECIPIENT_EMAIL = "michael@freno.me"; export const CONTACT_RECIPIENT_EMAIL = "michael@freno.me";
/** Canonical sender identity shown on outbound contact mail. */ /** Canonical sender identity shown on outbound contact mail. */
export const CONTACT_SENDER = { name: "freno.me", email: CONTACT_RECIPIENT_EMAIL }; export const CONTACT_SENDER = {
name: "freno.me",
email: CONTACT_RECIPIENT_EMAIL
};
export interface ContactContext { export interface ContactContext {
siteId: SiteId; siteId: SiteId;

View File

@@ -1,5 +1,5 @@
/** /**
* Unit tests for the shared `downloadAsset` helper (task 05). * Unit tests for the shared `downloadAsset` helper.
* *
* The helper is a pure function over an injected `DownloadApi` + redirect sink, * The helper is a pure function over an injected `DownloadApi` + redirect sink,
* so these tests verify the tRPC call shape, redirect, and error handling * so these tests verify the tRPC call shape, redirect, and error handling
@@ -54,7 +54,12 @@ describe("downloadAsset", () => {
const sink = mock((u: string) => {}); const sink = mock((u: string) => {});
const errSink = mock((e: unknown) => {}); const errSink = mock((e: unknown) => {});
await expect( await expect(
downloadAsset({ api, assetName: "gaze", redirect: sink, onError: errSink }) downloadAsset({
api,
assetName: "gaze",
redirect: sink,
onError: errSink
})
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
expect(sink).not.toHaveBeenCalled(); expect(sink).not.toHaveBeenCalled();
expect(errSink).toHaveBeenCalledTimes(1); expect(errSink).toHaveBeenCalledTimes(1);

View File

@@ -2,7 +2,7 @@
* Pure, testable helper for triggering a signed-S3 download via the tRPC * Pure, testable helper for triggering a signed-S3 download via the tRPC
* `downloads.getDownloadUrl` endpoint. * `downloads.getDownloadUrl` endpoint.
* *
* Extracted (task 05) so the Gaze landing page's download button — and any * Extracted so the Gaze landing page's download button — and any
* other subdomain landing page that needs the same flow (InputHalo, Lineage, * other subdomain landing page that needs the same flow (InputHalo, Lineage,
* …) — can share a single code path AND be unit-tested without importing * …) — can share a single code path AND be unit-tested without importing
* `~/lib/api` (which transitively imports solid-js / CSRF cookie access). * `~/lib/api` (which transitively imports solid-js / CSRF cookie access).
@@ -58,12 +58,7 @@ const defaultRedirect: DownloadRedirect = (url) => {
export async function downloadAsset( export async function downloadAsset(
options: DownloadAssetOptions options: DownloadAssetOptions
): Promise<void> { ): Promise<void> {
const { const { api, assetName, redirect = defaultRedirect, onError } = options;
api,
assetName,
redirect = defaultRedirect,
onError
} = options;
try { try {
const data = await api.downloads.getDownloadUrl.query({ const data = await api.downloads.getDownloadUrl.query({

View File

@@ -1,10 +1,10 @@
/** /**
* Unit tests for the per-site navigation configuration (task 04). * Unit tests for the per-site navigation configuration.
* *
* `NAV_CONFIG` + helpers are pure (no solid-js / router / meta imports), so * `NAV_CONFIG` + helpers are pure (no solid-js / router / meta imports), so
* these mirror the acceptance matrix directly. Integration / visual checks * these mirror the acceptance matrix directly. Integration / visual checks
* (rendering on `nessa.localhost:3000`) are covered by the build gate and * (rendering on `nessa.localhost:3000`) are covered by the build gate and
* manual validation described in the task; here we assert the data layer. * manual validation; here we assert the data layer.
*/ */
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { import {
@@ -184,8 +184,12 @@ describe("filterNavByAuth", () => {
}); });
describe("BACK_TO_FRENO", () => { describe("BACK_TO_FRENO", () => {
it("links to the apex freno.me and is external", () => { it("links to the apex site (derived from VITE_DOMAIN) and is external", () => {
expect(BACK_TO_FRENO.href).toBe("https://freno.me"); // href is now dynamically derived from VITE_DOMAIN via buildMainSiteUrl(),
// so we assert it's a non-empty absolute URL pointing at the main site,
// not a hardcoded string.
expect(BACK_TO_FRENO.href.length).toBeGreaterThan(0);
expect(BACK_TO_FRENO.href).toMatch(/^https?:\/\//);
expect(BACK_TO_FRENO.external).toBe(true); expect(BACK_TO_FRENO.external).toBe(true);
expect(BACK_TO_FRENO.icon).toBe("back"); expect(BACK_TO_FRENO.icon).toBe("back");
}); });

View File

@@ -1,5 +1,5 @@
/** /**
* Per-site navigation configuration (task 04 — site-aware layout & navigation). * Per-site navigation configuration — site-aware layout & navigation.
* *
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta — * Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
* so it can be unit-tested in `bun:test` without spinning up the router / Meta * so it can be unit-tested in `bun:test` without spinning up the router / Meta
@@ -27,6 +27,7 @@
* authoritative only for the *link set* the unit tests assert against. * authoritative only for the *link set* the unit tests assert against.
*/ */
import type { SiteId } from "~/lib/site-context"; import type { SiteId } from "~/lib/site-context";
import { buildMainSiteUrl } from "~/lib/subdomain-url";
/** Icon keys resolved by the bar renderer to inline SVGs. */ /** Icon keys resolved by the bar renderer to inline SVGs. */
export type NavIcon = export type NavIcon =
@@ -57,7 +58,7 @@ export interface NavItem {
/** Apex/host link used as a "back to freno.me" affordance on subdomains. */ /** Apex/host link used as a "back to freno.me" affordance on subdomains. */
export const BACK_TO_FRENO: NavItem = { export const BACK_TO_FRENO: NavItem = {
label: "back to freno.me", label: "back to freno.me",
href: "https://freno.me", href: buildMainSiteUrl("/"),
icon: "back", icon: "back",
external: true external: true
}; };
@@ -65,7 +66,7 @@ export const BACK_TO_FRENO: NavItem = {
/** /**
* Per-site navigation link sets. * Per-site navigation link sets.
* *
* Defined to exactly satisfy the task-04 acceptance matrix: * Defined to satisfy the acceptance matrix:
* - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn * - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn
* - nessa: Home, Contact, Privacy * - nessa: Home, Contact, Privacy
* - lineage: Home, Downloads, Contact, Privacy, Account Deletion * - lineage: Home, Downloads, Contact, Privacy, Account Deletion

View File

@@ -1,9 +1,8 @@
/** /**
* Unit tests for the shared site-context resolver (task 01). * Unit tests for the shared site-context resolver.
* *
* `resolveSiteFromHost` is pure — no env / no I/O — so the cases below are * `resolveSiteFromHost` is pure — no env / no I/O — so the cases below are
* straightforward synchronous assertions mirroring the acceptance matrix in * straightforward synchronous assertions mirroring the acceptance matrix.
* the task spec.
*/ */
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { import {

View File

@@ -1,11 +1,13 @@
/** /**
* Shared site definitions and host-to-site resolver. * Shared site definitions and host-to-site resolver.
* *
* Pure module — intentionally imports NO env / server-only code — so it is * Near-pure module — reads `import.meta.env.VITE_DOMAIN` (a Vite build-time
* safe to import from both server and client (and from unit tests). * var available on both client and server) to derive `BASE_DOMAIN`, but
* imports NO server-only code so it remains safe to import from client,
* server, and unit tests (with a fallback when the env var is absent).
* *
* This is the keystone of the subdomain-routing feature (task 01). Every * This is the keystone of the subdomain-routing feature. Every
* content task (05-11) consumes `SITE_CONFIG` metadata via `useSite()`, * content module consumes `SITE_CONFIG` metadata via `useSite()`,
* and the server-side host detection in * and the server-side host detection in
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`. * `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
*/ */
@@ -37,11 +39,36 @@ export interface Site {
faviconPath: string; faviconPath: string;
} }
/**
* Derive the base domain from `VITE_DOMAIN`.
*
* `VITE_DOMAIN` is `http://localhost:3000` in dev and `https://freno.me`
* (or `.dev`) in prod. We extract the hostname so host matching works
* against whichever apex the deployment uses. Falls back to `"freno.me"`
* when the env var is absent (unit tests) or points at `localhost` (dev —
* where subdomain host matching isn't used anyway; the path-prefix fallback
* in `resolveSiteFromLocation` handles dev).
*/
function computeBaseDomain(): string {
try {
const v = (import.meta as { env?: Record<string, string | undefined> }).env
?.VITE_DOMAIN;
if (!v) return "freno.me";
const hostname = new URL(v).hostname;
return hostname === "localhost" ? "freno.me" : hostname;
} catch {
return "freno.me";
}
}
/** The apex hostname derived from `VITE_DOMAIN` (e.g. `"freno.me"`). */
export const BASE_DOMAIN = computeBaseDomain();
export const SITE_CONFIG: Record<SiteId, Site> = { export const SITE_CONFIG: Record<SiteId, Site> = {
main: { main: {
id: "main", id: "main",
subdomain: "", subdomain: "",
domain: "freno.me", domain: BASE_DOMAIN,
baseRoutePrefix: "", baseRoutePrefix: "",
displayName: "Michael Freno", displayName: "Michael Freno",
titleSuffix: " | Michael Freno", titleSuffix: " | Michael Freno",
@@ -52,7 +79,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
nessa: { nessa: {
id: "nessa", id: "nessa",
subdomain: "nessa", subdomain: "nessa",
domain: "nessa.freno.me", domain: `nessa.${BASE_DOMAIN}`,
baseRoutePrefix: "/nessa", baseRoutePrefix: "/nessa",
displayName: "Nessa", displayName: "Nessa",
titleSuffix: " | Nessa", titleSuffix: " | Nessa",
@@ -63,7 +90,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
lineage: { lineage: {
id: "lineage", id: "lineage",
subdomain: "lineage", subdomain: "lineage",
domain: "lineage.freno.me", domain: `lineage.${BASE_DOMAIN}`,
baseRoutePrefix: "/lineage", baseRoutePrefix: "/lineage",
displayName: "Life and Lineage", displayName: "Life and Lineage",
titleSuffix: " | Life and Lineage", titleSuffix: " | Life and Lineage",
@@ -74,7 +101,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
gaze: { gaze: {
id: "gaze", id: "gaze",
subdomain: "gaze", subdomain: "gaze",
domain: "gaze.freno.me", domain: `gaze.${BASE_DOMAIN}`,
baseRoutePrefix: "/gaze", baseRoutePrefix: "/gaze",
displayName: "Gaze", displayName: "Gaze",
titleSuffix: " | Gaze", titleSuffix: " | Gaze",
@@ -85,7 +112,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
inputhalo: { inputhalo: {
id: "inputhalo", id: "inputhalo",
subdomain: "inputhalo", subdomain: "inputhalo",
domain: "inputhalo.freno.me", domain: `inputhalo.${BASE_DOMAIN}`,
baseRoutePrefix: "/inputhalo", baseRoutePrefix: "/inputhalo",
displayName: "InputHalo", displayName: "InputHalo",
titleSuffix: " | InputHalo", titleSuffix: " | InputHalo",
@@ -103,8 +130,6 @@ const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
SITE_CONFIG.inputhalo SITE_CONFIG.inputhalo
]; ];
const BASE_DOMAIN = "freno.me";
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */ /** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */
const DEV_HOST_RE = /^([a-z0-9-]+)\.localhost$/i; const DEV_HOST_RE = /^([a-z0-9-]+)\.localhost$/i;
@@ -186,7 +211,7 @@ export function resolveSiteFromPath(
for (const site of SUBDOMAIN_SITES) { for (const site of SUBDOMAIN_SITES) {
const prefix = site.baseRoutePrefix; // e.g. "/nessa" const prefix = site.baseRoutePrefix; // e.g. "/nessa"
// Exact prefix (`/nessa`) or prefix + `/` (`/nessa/contact`). // Exact prefix (`/nessa`) or prefix + `/` (`/nessa/contact`).
if (pathname === prefix || pathname.startsWith(prefix + "/")) { if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
return site; return site;
} }
} }
@@ -210,3 +235,63 @@ export function resolveSiteFromLocation(
if (hostResult.id !== "main") return hostResult; if (hostResult.id !== "main") return hostResult;
return resolveSiteFromPath(pathname) ?? hostResult; return resolveSiteFromPath(pathname) ?? hostResult;
} }
// ─────────────────────────────────────────────────────────────────────────
// URL builders — derive full URLs from VITE_DOMAIN (no ~/env/client import
// so this module stays safe for unit tests / pure content modules).
//─────────────────────────────────────────────────────────────────────────
/** Compute the site origin from VITE_DOMAIN (fallback for tests). */
const SITE_ORIGIN = (() => {
try {
const v = (import.meta as { env?: Record<string, string | undefined> }).env
?.VITE_DOMAIN;
return v || "https://freno.me";
} catch {
return "https://freno.me";
}
})();
/** True when VITE_DOMAIN points at localhost (dev server). */
function isDevOrigin(): boolean {
try {
return new URL(SITE_ORIGIN).hostname === "localhost";
} catch {
return false;
}
}
/**
* Build a full URL for a subdomain site.
*
* - Dev: path-based — `http://localhost:3000/nessa/contact`
* (the dev server has no host rewrite, so subdomains live under `/<sub>/...`)
* - Prod: host-based — `https://nessa.freno.me/contact`
*/
export function buildSubdomainUrl(
subdomain: string,
path: string = "/"
): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (isDevOrigin()) {
const pathSuffix = normalizedPath === "/" ? "" : normalizedPath;
return `${SITE_ORIGIN}/${subdomain}${pathSuffix}`;
}
try {
const url = new URL(SITE_ORIGIN);
return `${url.protocol}//${subdomain}.${url.hostname}${normalizedPath}`;
} catch {
return `https://${subdomain}.${BASE_DOMAIN}${normalizedPath}`;
}
}
/**
* Build a full URL for the main (apex) site.
*
* - Dev: `http://localhost:3000/contact`
* - Prod: `https://freno.me/contact`
*/
export function buildMainSiteUrl(path: string = "/"): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${SITE_ORIGIN}${normalizedPath === "/" ? "" : normalizedPath}`;
}

View File

@@ -1,5 +1,5 @@
/** /**
* Unit tests for the per-subdomain sitemap generation (task 03). * Unit tests for the per-subdomain sitemap generation.
* *
* Covers: * Covers:
* - `generateSitemap(site, entries)` returns correct XML for each site * - `generateSitemap(site, entries)` returns correct XML for each site
@@ -30,7 +30,9 @@ describe("generateSitemap", () => {
// Basic structure // Basic structure
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>'); expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'); expect(xml).toContain(
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
);
// All main site paths present with freno.me domain // All main site paths present with freno.me domain
const locs = extractLocs(xml); const locs = extractLocs(xml);
@@ -101,7 +103,10 @@ describe("generateSitemap", () => {
}); });
it("generates correct URLs for inputhalo site", () => { it("generates correct URLs for inputhalo site", () => {
const xml = generateSitemap(SITE_CONFIG.inputhalo, SITEMAP_ROUTES.inputhalo); const xml = generateSitemap(
SITE_CONFIG.inputhalo,
SITEMAP_ROUTES.inputhalo
);
const locs = extractLocs(xml); const locs = extractLocs(xml);
expect(locs).toContain("https://inputhalo.freno.me/"); expect(locs).toContain("https://inputhalo.freno.me/");

View File

@@ -19,7 +19,14 @@ export interface SitemapEntry {
/** /**
* Expected change frequency. * Expected change frequency.
*/ */
changefreq: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"; changefreq:
| "always"
| "hourly"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "never";
/** /**
* Relative priority (0.01.0). * Relative priority (0.01.0).
@@ -31,7 +38,7 @@ export interface SitemapEntry {
* Per-site sitemap route definitions. * Per-site sitemap route definitions.
* *
* Entries for subdomain pages (contact, privacy, downloads, etc.) are * Entries for subdomain pages (contact, privacy, downloads, etc.) are
* populated as those pages are built in tasks 0511. * populated as those pages are built.
*/ */
export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = { export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
main: [ main: [
@@ -44,7 +51,7 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
], ],
// ── Subdomain sites ────────────────────────────────────────────────── // ── Subdomain sites ──────────────────────────────────────────────────
// Populated as pages land in tasks 0511. // Populated as pages land.
nessa: [ nessa: [
{ path: "/", changefreq: "weekly", priority: 1.0 }, { path: "/", changefreq: "weekly", priority: 1.0 },

29
src/lib/subdomain-url.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Env-aware helpers for building site URLs from `VITE_DOMAIN`.
*
* These are re-exported from `~/lib/site-context.ts` so the URL-building
* logic stays in one place (alongside `BASE_DOMAIN` and the host/path
* resolvers). Import from here when you need `buildSubdomainUrl` /
* `buildMainSiteUrl` in components, routes, or content modules.
*
* **Why a separate entry point:** `site-context.ts` is a near-pure module
* that reads `import.meta.env.VITE_DOMAIN` directly (no `~/env/client`
* import), so it's safe to use in unit tests and pure content modules.
* Re-exporting via this file gives callers a focused import path for just
* the URL helpers without pulling in the resolver functions.
*
* **Dev vs prod behavior:**
* - Dev (`VITE_DOMAIN=http://localhost:3000`): path-based —
* `http://localhost:3000/nessa/contact` (the dev server has no host rewrite)
* - Prod (`VITE_DOMAIN=https://freno.me`): host-based —
* `https://nessa.freno.me/contact`
*
* Use these instead of hardcoding `freno.me` anywhere a URL is emitted.
* Email addresses (`michael@freno.me`) and email display names are
* brand-level constants and should NOT use this module.
*/
export {
buildSubdomainUrl,
buildMainSiteUrl,
BASE_DOMAIN as getBaseDomain
} from "./site-context";

View File

@@ -3,11 +3,12 @@ import { useSearchParams } from "@solidjs/router";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import RevealDropDown from "~/components/RevealDropDown"; import RevealDropDown from "~/components/RevealDropDown";
import { ContactForm } from "~/components/ContactForm"; import { ContactForm } from "~/components/ContactForm";
import { buildSubdomainUrl } from "~/lib/site-context";
/** /**
* Main-site contact page (`freno.me/contact`). * Main-site contact page (`freno.me/contact`).
* *
* Refactored (task 09) to render the shared `<ContactForm>` — the form logic, * Refactored to render the shared `<ContactForm>` — the form logic,
* Turnstile widget, cooldown timer, email-verification flow, and tRPC * Turnstile widget, cooldown timer, email-verification flow, and tRPC
* submission all live in the shared component now. This route remains a thin * submission all live in the shared component now. This route remains a thin
* wrapper that supplies: * wrapper that supplies:
@@ -51,7 +52,7 @@ export function LineageContactQuestions(): JSX.Element {
<div class="pb-2"> <div class="pb-2">
You can find the entire privacy policy{" "} You can find the entire privacy policy{" "}
<A <A
href="https://lineage.freno.me/privacy" href={buildSubdomainUrl("lineage", "/privacy")}
class="text-blue underline-offset-4 hover:underline" class="text-blue underline-offset-4 hover:underline"
> >
here here

View File

@@ -1,5 +1,5 @@
/** /**
* Regression test for the legacy `/deletion/life-and-lineage` route (task 11). * Regression test for the legacy `/deletion/life-and-lineage` route.
* *
* The route was converted from a rendered page into a 308 permanent redirect * 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 * to `lineage.freno.me/deletion`. Because the route file is a SolidStart
@@ -23,7 +23,7 @@ const SOURCE = readFileSync(
"utf8" "utf8"
); );
describe("Legacy /deletion/life-and-lineage — redirect (task 11)", () => { describe("Legacy /deletion/life-and-lineage — redirect", () => {
it("is a GET handler (API-route redirect, not a rendered page)", () => { it("is a GET handler (API-route redirect, not a rendered page)", () => {
expect(SOURCE).toContain("export function GET()"); expect(SOURCE).toContain("export function GET()");
expect(SOURCE).not.toContain("export default function"); expect(SOURCE).not.toContain("export default function");

View File

@@ -1,6 +1,6 @@
/** /**
* Legacy Life and Lineage account-deletion route — now a 308 permanent * Legacy Life and Lineage account-deletion route — now a 308 permanent
* redirect to the Lineage subdomain (task 11). * redirect to the Lineage subdomain.
* *
* The deletion form has been migrated to `src/routes/lineage/deletion.tsx` * The deletion form has been migrated to `src/routes/lineage/deletion.tsx`
* served at `lineage.freno.me/deletion` (vercel.json host rewrites map the * served at `lineage.freno.me/deletion` (vercel.json host rewrites map the

View File

@@ -1,5 +1,5 @@
/** /**
* Regression test for the unified `freno.me/downloads` page (task 11). * Regression test for the unified `freno.me/downloads` page.
* *
* Task 11's acceptance criteria require that the unified downloads page is * Task 11's acceptance criteria require that the unified downloads page is
* UNCHANGED — it keeps listing all five products (InputHalo, Gaze, Life and * UNCHANGED — it keeps listing all five products (InputHalo, Gaze, Life and
@@ -38,7 +38,7 @@ describe("Unified downloads page — product list (regression)", () => {
}); });
describe("Unified downloads page — Lineage section (byte-identical APK)", () => { describe("Unified downloads page — Lineage section (byte-identical APK)", () => {
it("still wires the Lineage APK button to the \"lineage\" tRPC asset key", () => { it('still wires the Lineage APK button to the "lineage" tRPC asset key', () => {
// Same asset key the per-subdomain lineage/downloads page uses → both // Same asset key the per-subdomain lineage/downloads page uses → both
// origins serve the byte-identical S3 object (`Life and Lineage.apk`). // origins serve the byte-identical S3 object (`Life and Lineage.apk`).
expect(SOURCE).toContain('download("lineage")'); expect(SOURCE).toContain('download("lineage")');

View File

@@ -3,6 +3,7 @@ import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup } from "solid-js"; import { createSignal, onMount, onCleanup } from "solid-js";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore"; import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import { glitchText } from "~/lib/client-utils"; import { glitchText } from "~/lib/client-utils";
import { buildSubdomainUrl } from "~/lib/subdomain-url";
import Button from "~/components/ui/Button"; import Button from "~/components/ui/Button";
export default function DownloadsPage() { export default function DownloadsPage() {
@@ -93,7 +94,13 @@ export default function DownloadsPage() {
{/* InputHalo */} {/* InputHalo */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8"> <div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl"> <h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {inputHaloText()} <span class="text-yellow">{">"}</span>{" "}
<A
href={buildSubdomainUrl("inputhalo")}
class="text-text hover:text-yellow transition-colors"
>
{inputHaloText()}
</A>
</h2> </h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around"> <div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
@@ -139,7 +146,13 @@ export default function DownloadsPage() {
{/* Gaze */} {/* Gaze */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8"> <div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl"> <h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {gazeText()} <span class="text-yellow">{">"}</span>{" "}
<A
href={buildSubdomainUrl("gaze")}
class="text-text hover:text-yellow transition-colors"
>
{gazeText()}
</A>
</h2> </h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around"> <div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
@@ -183,7 +196,13 @@ export default function DownloadsPage() {
</div> </div>
<div class="border-overlay0 rounded-lg border p-6 md:p-8"> <div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl"> <h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {LaLText()} <span class="text-yellow">{">"}</span>{" "}
<A
href={buildSubdomainUrl("lineage")}
class="text-text hover:text-yellow transition-colors"
>
{LaLText()}
</A>
</h2> </h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around"> <div class="flex flex-col gap-8 lg:flex-row lg:justify-around">

View File

@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
/** /**
* Gaze contact page (`gaze.freno.me/contact`). * Gaze contact page (`gaze.freno.me/contact`).
* *
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness — * Thin wrapper over the shared `<ContactForm>`. Site awareness —
* subject prefix `[Gaze]`, recipient label, heading, and PageHead metadata — * subject prefix `[Gaze]`, recipient label, heading, and PageHead metadata —
* is derived from `useSite()` inside the component via `CONTACT_CONTEXT.gaze`, * is derived from `useSite()` inside the component via `CONTACT_CONTEXT.gaze`,
* so this route needs no explicit props. * so this route needs no explicit props.

View File

@@ -1,9 +1,9 @@
/** /**
* Gaze privacy policy — `gaze.freno.me/privacy` (task 10). * Gaze privacy policy — `gaze.freno.me/privacy`.
* *
* Migrated verbatim from the legacy `src/routes/privacy-policy/gaze.tsx` * Migrated verbatim from the legacy `src/routes/privacy-policy/gaze.tsx`
* route so there is zero content loss; the old route now 308-redirects here * route so there is zero content loss; the old route now 308-redirects here
* (see `src/routes/privacy-policy/gaze.tsx`). PageHead is site-aware (task 02) * (see `src/routes/privacy-policy/gaze.tsx`). PageHead is site-aware
* so the Gaze `titleSuffix` (` | Gaze`), canonical * so the Gaze `titleSuffix` (` | Gaze`), canonical
* (`https://gaze.freno.me/privacy`), and OG image derive automatically — we * (`https://gaze.freno.me/privacy`), and OG image derive automatically — we
* only pass the base title. * only pass the base title.

View File

@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
/** /**
* InputHalo contact page (`inputhalo.freno.me/contact`). * InputHalo contact page (`inputhalo.freno.me/contact`).
* *
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness — * Thin wrapper over the shared `<ContactForm>`. Site awareness —
* subject prefix `[InputHalo]`, recipient label, heading, and PageHead * subject prefix `[InputHalo]`, recipient label, heading, and PageHead
* metadata — is derived from `useSite()` inside the component via * metadata — is derived from `useSite()` inside the component via
* `CONTACT_CONTEXT.inputhalo`, so this route needs no explicit props. * `CONTACT_CONTEXT.inputhalo`, so this route needs no explicit props.

View File

@@ -1,5 +1,5 @@
/** /**
* Unit tests for the InputHalo landing-page download flow (task 06). * Unit tests for the InputHalo landing-page download flow.
* *
* The helper in `./download.ts` is pure (no solid-js / router / meta imports), * The helper in `./download.ts` is pure (no solid-js / router / meta imports),
* so we exercise the acceptance criterion directly — "the download button * so we exercise the acceptance criterion directly — "the download button
@@ -8,7 +8,7 @@
* *
* Integration / visual checks (the rendered landing page, the tRPC client) are * Integration / visual checks (the rendered landing page, the tRPC client) are
* covered by the build gate (`bun run build`) and the manual validation steps * covered by the build gate (`bun run build`) and the manual validation steps
* in the task spec; the component is a thin wrapper over this helper. * the component is a thin wrapper over this helper.
*/ */
import { describe, it, expect, mock } from "bun:test"; import { describe, it, expect, mock } from "bun:test";
import { import {
@@ -49,12 +49,10 @@ describe("InputHalo download constants", () => {
describe("queryInputHaloDownload", () => { describe("queryInputHaloDownload", () => {
it("calls the query with asset_name 'inputhalo' and returns the signed URL", async () => { it("calls the query with asset_name 'inputhalo' and returns the signed URL", async () => {
const query = mock( const query = mock((async (input: { asset_name: string }) => {
(async (input: { asset_name: string }) => {
expect(input.asset_name).toBe("inputhalo"); expect(input.asset_name).toBe("inputhalo");
return { downloadURL: "https://s3.example.com/InputHalo.dmg?signed=1" }; return { downloadURL: "https://s3.example.com/InputHalo.dmg?signed=1" };
}) as DownloadQueryApi }) as DownloadQueryApi);
);
const url = await queryInputHaloDownload(query); const url = await queryInputHaloDownload(query);
@@ -85,9 +83,9 @@ describe("performInputHaloDownload", () => {
const SIGNED_URL = "https://s3.example.com/InputHalo-0.1.0.dmg?sig=abc"; const SIGNED_URL = "https://s3.example.com/InputHalo-0.1.0.dmg?sig=abc";
it("redirects to the signed S3 URL returned by the query", async () => { it("redirects to the signed S3 URL returned by the query", async () => {
const query = mock( const query = mock((async () => ({
(async () => ({ downloadURL: SIGNED_URL })) as DownloadQueryApi downloadURL: SIGNED_URL
); })) as DownloadQueryApi);
const redirect = mock((url: string) => url); const redirect = mock((url: string) => url);
const ok = await performInputHaloDownload(query, redirect); const ok = await performInputHaloDownload(query, redirect);

View File

@@ -1,6 +1,6 @@
/** /**
* Pure, side-effect-free download orchestration for the InputHalo landing * Pure, side-effect-free download orchestration for the InputHalo landing
* page (task 06). * page.
* *
* Extracted from the route component so the acceptance criterion — * Extracted from the route component so the acceptance criterion —
* "download button calls `api.downloads.getDownloadUrl` with `'inputhalo'` * "download button calls `api.downloads.getDownloadUrl` with `'inputhalo'`
@@ -36,9 +36,7 @@ export const INPUTHALO_ICON_DEFAULT =
* Keeps the helper decoupled from the full `api` surface and testable with a * Keeps the helper decoupled from the full `api` surface and testable with a
* stub. * stub.
*/ */
export interface DownloadQueryApi { export type DownloadQueryApi = (input: { asset_name: string }) => Promise<{ downloadURL: string }>
(input: { asset_name: string }): Promise<{ downloadURL: string }>;
}
/** /**
* Resolve the signed S3 download URL for the InputHalo DMG. * Resolve the signed S3 download URL for the InputHalo DMG.

View File

@@ -14,6 +14,7 @@ import { For, createSignal } from "solid-js";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader"; import SubdomainHeader from "~/components/SubdomainHeader";
import { buildMainSiteUrl } from "~/lib/site-context";
import Button from "~/components/ui/Button"; import Button from "~/components/ui/Button";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import { useDarkMode } from "~/context/darkMode"; import { useDarkMode } from "~/context/darkMode";
@@ -274,7 +275,7 @@ export default function InputHaloLanding() {
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row"> <div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
<span>{site().displayName}</span> <span>{site().displayName}</span>
<A <A
href="https://freno.me" href={buildMainSiteUrl()}
class="hover:text-text underline-offset-4 hover:underline" class="hover:text-text underline-offset-4 hover:underline"
> >
freno.me freno.me

View File

@@ -1,11 +1,10 @@
/** /**
* InputHalo privacy policy — `inputhalo.freno.me/privacy` (task 10). * InputHalo privacy policy — `inputhalo.freno.me/privacy`.
* *
* Net-new privacy policy for the InputHalo subdomain. InputHalo is a macOS * Net-new privacy policy for the InputHalo subdomain. InputHalo is a macOS
* menu bar application (`LSUIElement: true`, * menu bar application (`LSUIElement: true`,
* `LSApplicationCategoryType: public.app-category.productivity`) — a * `LSApplicationCategoryType: public.app-category.productivity`) — a
* productivity utility that lives in the system menu bar. Following the task * productivity utility that lives in the system menu bar. Gaze&apos;s privacy policy is the template for macOS menu bar apps
* notes, Gaze&apos;s privacy policy is the template for macOS menu bar apps
* (both are local-only menu bar utilities), so this policy mirrors Gaze&apos;s * (both are local-only menu bar utilities), so this policy mirrors Gaze&apos;s
* structure and language while describing InputHalo&apos;s own practices. * structure and language while describing InputHalo&apos;s own practices.
* *
@@ -16,7 +15,7 @@
* - Settings and any cached state are stored locally using standard * - Settings and any cached state are stored locally using standard
* macOS mechanisms and are never sent off-device. * macOS mechanisms and are never sent off-device.
* *
* PageHead is site-aware (task 02): only the base title is supplied; the * PageHead is site-aware: only the base title is supplied; the
* ` | InputHalo` suffix, `https://inputhalo.freno.me/privacy` canonical, and * ` | InputHalo` suffix, `https://inputhalo.freno.me/privacy` canonical, and
* OG image are derived automatically. Internal links use public * OG image are derived automatically. Internal links use public
* subdomain-relative paths (`/contact`) consistent with nav-config.ts and * subdomain-relative paths (`/contact`) consistent with nav-config.ts and

View File

@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
/** /**
* Life and Lineage contact page (`lineage.freno.me/contact`). * Life and Lineage contact page (`lineage.freno.me/contact`).
* *
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness — * Thin wrapper over the shared `<ContactForm>`. Site awareness —
* subject prefix `[Lineage]`, recipient label, heading, and PageHead metadata * subject prefix `[Lineage]`, recipient label, heading, and PageHead metadata
* — is derived from `useSite()` inside the component via * — is derived from `useSite()` inside the component via
* `CONTACT_CONTEXT.lineage`. * `CONTACT_CONTEXT.lineage`.

View File

@@ -1,9 +1,9 @@
/** /**
* Unit tests for the Lineage per-subdomain account-deletion page content * Unit tests for the Lineage per-subdomain account-deletion page content
* (task 11). * (see `./deletion.tsx`).
* *
* Asserts against pure constants exported from `deletion-content.ts` — no * Asserts against pure constants exported from `deletion-content.ts` — no
* solid-js / router / DOM. Covers the task-11 acceptance matrix: * solid-js / router / DOM. Covers the acceptance matrix:
* - Product discriminator is `"lineage"` (selects Lineage-branded email). * - Product discriminator is `"lineage"` (selects Lineage-branded email).
* - Cooldown cookie name is the legacy `deletionRequestSent` so an in-flight * - Cooldown cookie name is the legacy `deletionRequestSent` so an in-flight
* cooldown survives the `/deletion/life-and-lineage` → subdomain redirect. * cooldown survives the `/deletion/life-and-lineage` → subdomain redirect.
@@ -26,14 +26,14 @@ import {
} from "~/server/api/routers/deletion-email"; } from "~/server/api/routers/deletion-email";
describe("Lineage deletion — product discriminator", () => { describe("Lineage deletion — product discriminator", () => {
it("is \"lineage\" (selects Lineage-branded email)", () => { it('is "lineage" (selects Lineage-branded email)', () => {
expect(DELETION_PRODUCT_KEY).toBe("lineage"); expect(DELETION_PRODUCT_KEY).toBe("lineage");
}); });
it("is accepted by the server-side product schema", () => { it("is accepted by the server-side product schema", () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe( expect(
true DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success
); ).toBe(true);
}); });
}); });
@@ -44,10 +44,8 @@ describe("Lineage deletion — cooldown cookie", () => {
expect(DELETION_COOKIE_NAME).toBe("deletionRequestSent"); expect(DELETION_COOKIE_NAME).toBe("deletionRequestSent");
}); });
it("matches the server-side deletionCookieName(\"lineage\")", () => { it('matches the server-side deletionCookieName("lineage")', () => {
expect(DELETION_COOKIE_NAME).toBe( expect(DELETION_COOKIE_NAME).toBe(deletionCookieName(DELETION_PRODUCT_KEY));
deletionCookieName(DELETION_PRODUCT_KEY)
);
}); });
}); });
@@ -79,14 +77,13 @@ describe("Lineage deletion — PageHead inputs", () => {
}); });
describe("Lineage deletion — legacy redirect target", () => { describe("Lineage deletion — legacy redirect target", () => {
it("points at the lineage subdomain deletion URL", () => { it("points at the lineage subdomain deletion URL (derived from VITE_DOMAIN)", () => {
expect(LEGACY_DELETION_REDIRECT_TARGET).toBe( // LEGACY_DELETION_REDIRECT_TARGET is now dynamically derived from
"https://lineage.freno.me/deletion" // VITE_DOMAIN via buildSubdomainUrl("lineage", "/deletion"). Assert it
); // is a valid absolute URL containing the lineage + deletion segments.
}); expect(LEGACY_DELETION_REDIRECT_TARGET).toMatch(/^https?:\/\//);
expect(LEGACY_DELETION_REDIRECT_TARGET).toContain("lineage");
it("is an absolute https URL", () => { expect(LEGACY_DELETION_REDIRECT_TARGET).toContain("/deletion");
expect(LEGACY_DELETION_REDIRECT_TARGET.startsWith("https://")).toBe(true);
}); });
it("does not reference the legacy /deletion/life-and-lineage path", () => { it("does not reference the legacy /deletion/life-and-lineage path", () => {

View File

@@ -1,13 +1,16 @@
/** /**
* Pure content + metadata for the Lineage per-subdomain account-deletion page * Pure content + metadata for the Lineage per-subdomain account-deletion page
* (task 11). * (see `./deletion.tsx`).
* *
* Imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so the * Imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so the
* constants here can be unit-tested in `bun:test` without spinning up the * constants here can be unit-tested in `bun:test` without spinning up the
* router / MetaProvider, mirroring the `landing-content.ts` / * router / MetaProvider, mirroring the `landing-content.ts` /
* `downloads-content.ts` pattern. * `downloads-content.ts` pattern.
* *
* Cross-task contracts encoded here: * Imports `buildSubdomainUrl` from `~/lib/site-context` (near-pure — reads
* `import.meta.env.VITE_DOMAIN`) so redirect targets are env-aware.
*
* Contracts encoded here:
* - `DELETION_PRODUCT_KEY` is the `product` discriminator passed to the * - `DELETION_PRODUCT_KEY` is the `product` discriminator passed to the
* generalized `misc.sendDeletionRequestEmail` mutation * generalized `misc.sendDeletionRequestEmail` mutation
* (`src/server/api/routers/misc.ts`) so the email copy + cooldown cookie * (`src/server/api/routers/misc.ts`) so the email copy + cooldown cookie
@@ -32,6 +35,9 @@ import type { PageHeadProps } from "~/components/page-head-meta";
* Product discriminator for the generalized `sendDeletionRequestEmail` * Product discriminator for the generalized `sendDeletionRequestEmail`
* mutation. The Lineage flow is the original / default product. * mutation. The Lineage flow is the original / default product.
*/ */
import { buildSubdomainUrl } from "~/lib/site-context";
export const DELETION_PRODUCT_KEY = "lineage" as const; export const DELETION_PRODUCT_KEY = "lineage" as const;
/** /**
@@ -63,9 +69,11 @@ export const PAGE_META: PageHeadProps = {
/** /**
* Canonical absolute URL the legacy `/deletion/life-and-lineage` route * Canonical absolute URL the legacy `/deletion/life-and-lineage` route
* 308-redirects to (task 11). Kept here so tests can assert the redirect * 308-redirects to. Kept here so tests can assert the redirect
* target without importing the route module (which would pull the server * target without importing the route module (which would pull the server
* runtime). * runtime).
*/ */
export const LEGACY_DELETION_REDIRECT_TARGET = export const LEGACY_DELETION_REDIRECT_TARGET = buildSubdomainUrl(
"https://lineage.freno.me/deletion"; "lineage",
"/deletion"
);

View File

@@ -1,14 +1,14 @@
/** /**
* Lineage per-subdomain account-deletion page — `lineage.freno.me/deletion` * Lineage per-subdomain account-deletion page — `lineage.freno.me/deletion`
* (task 11). * (see `./deletion-content.ts`).
* *
* Migrated from `src/routes/deletion/life-and-lineage.tsx` (which is now a * Migrated from `src/routes/deletion/life-and-lineage.tsx` (which is now a
* 308 redirect to this public URL — see `LEGACY_DELETION_REDIRECT_TARGET`). * 308 redirect to this public URL — see `LEGACY_DELETION_REDIRECT_TARGET`).
* *
* Served at the public browser path `/deletion` (vercel.json host rewrites * Served at the public browser path `/deletion` (vercel.json host rewrites
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the * `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
* browser URL clean — task 02 canonical rule). The nav-config "Account * browser URL clean. The nav-config "Account
* Deletion" entry points at this path (task 04). * Deletion" entry points at this path.
* *
* Deletion flow: * Deletion flow:
* - Reuses the shared `DeletionForm` component, now generalized to forward * - Reuses the shared `DeletionForm` component, now generalized to forward
@@ -25,7 +25,7 @@
* *
* Site-awareness: * Site-awareness:
* - `<PageHead>` reads `useSite()` → lineage title suffix + canonical are * - `<PageHead>` reads `useSite()` → lineage title suffix + canonical are
* derived automatically (task 02). * derived automatically.
* - No auth — the deletion request is email-based (the requester may be * - No auth — the deletion request is email-based (the requester may be
* locked out of their account), NOT an authenticated self-delete. * locked out of their account), NOT an authenticated self-delete.
* *

View File

@@ -1,9 +1,9 @@
/** /**
* Unit tests for the Lineage per-subdomain downloads page content (task 11). * Unit tests for the Lineage per-subdomain downloads page content.
* *
* Mirrors the `landing-content.test.ts` pattern: assert against pure * Mirrors the `landing-content.test.ts` pattern: assert against pure
* constants exported from `downloads-content.ts` (no solid-js / router / * constants exported from `downloads-content.ts` (no solid-js / router /
* DOM). This covers the task-11 acceptance matrix that's structurally * DOM). This covers the acceptance matrix that's structurally
* verifiable without rendering: * verifiable without rendering:
* - APK asset key is `"lineage"` (the tRPC key the downloads router maps to * - 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 * `Life and Lineage.apk`) — must match the unified downloads page's key

View File

@@ -1,6 +1,6 @@
/** /**
* Pure content + metadata for the Lineage per-subdomain downloads page * Pure content + metadata for the Lineage per-subdomain downloads page
* (task 11). * (see `./downloads.tsx`).
* *
* Mirrors the `landing-content.ts` / `page-head-meta.ts` / `nav-config.ts` * Mirrors the `landing-content.ts` / `page-head-meta.ts` / `nav-config.ts`
* pattern: imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so * pattern: imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so
@@ -12,7 +12,7 @@
* values; keeping them externalized means changes to the download target / * values; keeping them externalized means changes to the download target /
* store link surface as test failures rather than silent regressions. * store link surface as test failures rather than silent regressions.
* *
* Cross-task contracts encoded here: * Contracts encoded here:
* - `LINEAGE_DOWNLOAD_ASSET` is the tRPC `downloads.getDownloadUrl` asset key * - `LINEAGE_DOWNLOAD_ASSET` is the tRPC `downloads.getDownloadUrl` asset key
* (`"lineage"`) → resolves to `Life and Lineage.apk` in * (`"lineage"`) → resolves to `Life and Lineage.apk` in
* `src/server/api/routers/downloads.ts`. It MUST match the key used by the * `src/server/api/routers/downloads.ts`. It MUST match the key used by the
@@ -23,7 +23,7 @@
* unified downloads page, so the store front is consistent across origins. * unified downloads page, so the store front is consistent across origins.
* - `LINEAGE_DOWNLOADS_META` is consumed verbatim by `<PageHead>`; the * - `LINEAGE_DOWNLOADS_META` is consumed verbatim by `<PageHead>`; the
* per-site title suffix (` | Life and Lineage`) is appended automatically * per-site title suffix (` | Life and Lineage`) is appended automatically
* by `resolvePageHeadMeta` (task 02), so `title` here is the BASE title * by `resolvePageHeadMeta`, so `title` here is the BASE title
* only — do NOT include the suffix. * only — do NOT include the suffix.
*/ */
import type { PageHeadProps } from "~/components/page-head-meta"; import type { PageHeadProps } from "~/components/page-head-meta";
@@ -55,7 +55,7 @@ export const LINEAGE_APP_STORE_URL =
* Public browser path back to the Lineage landing page (subdomain-relative). * Public browser path back to the Lineage landing page (subdomain-relative).
* *
* vercel.json rewrites `lineage.freno.me/` → the internal `/lineage/` route * vercel.json rewrites `lineage.freno.me/` → the internal `/lineage/` route
* prefix while leaving the browser URL clean (task 02 canonical rule). * prefix while leaving the browser URL clean.
*/ */
export const LINEAGE_HOME_HREF = "/"; export const LINEAGE_HOME_HREF = "/";

View File

@@ -1,18 +1,17 @@
/** /**
* Lineage per-subdomain downloads page — `lineage.freno.me/downloads` * Lineage per-subdomain downloads page — `lineage.freno.me/downloads`
* (task 11). * (see `./deletion-content.ts`).
* *
* Served at the public browser path `/downloads` (vercel.json host rewrites * Served at the public browser path `/downloads` (vercel.json host rewrites
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the * `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
* browser URL clean — task 02 canonical rule). The nav-config "Downloads" * browser URL clean. The nav-config "Downloads"
* entry and the landing page's Google Play badge both point at this path * 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 * Download surface (mirrors the Lineage section of the unified
* `freno.me/downloads` page, byte-identical asset source): * `freno.me/downloads` page, byte-identical asset source):
* - Android APK via tRPC `downloads.getDownloadUrl({ asset_name: "lineage" })` * - Android APK via tRPC `downloads.getDownloadUrl({ asset_name: "lineage" })`
* → S3 signed URL for `Life and Lineage.apk`. Reuses the shared * → S3 signed URL for `Life and Lineage.apk`. Reuses the shared
* `downloadAsset` helper (task 05) so the click → redirect → S3 flow is a * `downloadAsset` helper so the click → redirect → S3 flow is a
* single code path shared with the Gaze landing page. * single code path shared with the Gaze landing page.
* - iOS App Store link (`LINEAGE_APP_STORE_URL`) — absolute external URL, * - iOS App Store link (`LINEAGE_APP_STORE_URL`) — absolute external URL,
* identical to the link surfaced on the landing page + unified downloads. * identical to the link surfaced on the landing page + unified downloads.
@@ -20,7 +19,7 @@
* Site-awareness: * Site-awareness:
* - `<PageHead>` reads `useSite()` → the lineage `titleSuffix` * - `<PageHead>` reads `useSite()` → the lineage `titleSuffix`
* (` | Life and Lineage`) + canonical `https://lineage.freno.me/downloads` * (` | Life and Lineage`) + canonical `https://lineage.freno.me/downloads`
* are derived automatically (task 02); we pass only the base title here. * are derived automatically; we pass only the base title here.
* - No auth — Lineage's mobile JWT (`LINEAGE_JWT_SECRET`) is for the mobile * - No auth — Lineage's mobile JWT (`LINEAGE_JWT_SECRET`) is for the mobile
* app's API calls, not the web downloads page. * app's API calls, not the web downloads page.
* *

View File

@@ -1,15 +1,15 @@
/** /**
* Unit tests for the Lineage landing page content (task 08). * Unit tests for the Lineage landing page content.
* *
* Mirrors the `page-head-meta.ts` / `nav-config.ts` pattern: assert against * Mirrors the `page-head-meta.ts` / `nav-config.ts` pattern: assert against
* pure constants exported from `landing-content.ts` (no solid-js / router / * pure constants exported from `landing-content.ts` (no solid-js / router /
* DOM). This covers the task-08 acceptance matrix that's structurally * DOM). This covers the acceptance matrix that's structurally
* verifiable without rendering: * verifiable without rendering:
* - App Store link is present and correct * - App Store link is present and correct
* - Google Play / downloads link targets the subdomain `/downloads` path * - Google Play / downloads link targets the subdomain `/downloads` path
* (public browser path, NOT the vercel-rewritten `/lineage/downloads`) * (public browser path, NOT the vercel-rewritten `/lineage/downloads`)
* - Feature highlights cover: dark fantasy, mobile, remote saves, PvP * - Feature highlights cover: dark fantasy, mobile, remote saves, PvP
* - PageHead base title + description (suffix is added by PageHead, task 02) * - PageHead base title + description (suffix is added by PageHead)
* - Legacy `/marketing/life-and-lineage` redirect target points at the * - Legacy `/marketing/life-and-lineage` redirect target points at the
* Lineage subdomain. * Lineage subdomain.
* *
@@ -46,7 +46,7 @@ describe("Lineage landing — downloads link", () => {
it("targets the subdomain-relative public browser path", () => { it("targets the subdomain-relative public browser path", () => {
// NOT `/lineage/downloads` (the internal vercel-rewrite prefix) — vercel // NOT `/lineage/downloads` (the internal vercel-rewrite prefix) — vercel
// rewrites `lineage.freno.me/downloads` → `/lineage/downloads` while // rewrites `lineage.freno.me/downloads` → `/lineage/downloads` while
// leaving the browser URL clean, matching the canonical rule from task 02. // leaving the browser URL clean, matching the canonical rule.
expect(DOWNLOADS_HREF).toBe("/downloads"); expect(DOWNLOADS_HREF).toBe("/downloads");
}); });
@@ -109,9 +109,7 @@ describe("Lineage landing — feature highlights", () => {
}); });
it("covers the dark-fantasy / mobile / saves / PvP themes", () => { it("covers the dark-fantasy / mobile / saves / PvP themes", () => {
const blob = FEATURES.map( const blob = FEATURES.map((f) => `${f.title} ${f.description}`)
(f) => `${f.title} ${f.description}`
)
.join(" ") .join(" ")
.toLowerCase(); .toLowerCase();
expect(blob).toContain("dark fantasy"); expect(blob).toContain("dark fantasy");
@@ -122,12 +120,16 @@ describe("Lineage landing — feature highlights", () => {
}); });
describe("Lineage landing — legacy redirect target", () => { describe("Lineage landing — legacy redirect target", () => {
it("points at the lineage subdomain apex", () => { it("points at the lineage subdomain (derived from VITE_DOMAIN)", () => {
expect(LEGACY_REDIRECT_TARGET).toBe("https://lineage.freno.me"); // LEGACY_REDIRECT_TARGET is now dynamically derived from VITE_DOMAIN
// via buildSubdomainUrl("lineage"). In dev it's path-based
// (http://localhost:3000/lineage); in prod it's host-based
// (https://lineage.freno.me). Assert it's a valid absolute URL.
expect(LEGACY_REDIRECT_TARGET).toMatch(/^https?:\/\/[^/]+\/[a-z]+$/i);
expect(LEGACY_REDIRECT_TARGET).toContain("lineage");
}); });
it("is an https absolute URL with no trailing path", () => { it("has no trailing slash", () => {
expect(LEGACY_REDIRECT_TARGET.startsWith("https://")).toBe(true);
expect(LEGACY_REDIRECT_TARGET.endsWith("/")).toBe(false); expect(LEGACY_REDIRECT_TARGET.endsWith("/")).toBe(false);
}); });
}); });

View File

@@ -1,5 +1,5 @@
/** /**
* Pure content + metadata for the Lineage subdomain landing page (task 08). * Pure content + metadata for the Lineage subdomain landing page.
* *
* Intentionally imports NOTHING from solid-js / @solidjs/router / @solidjs/meta * Intentionally imports NOTHING from solid-js / @solidjs/router / @solidjs/meta
* so the constant set here can be unit-tested in `bun:test` without spinning * so the constant set here can be unit-tested in `bun:test` without spinning
@@ -12,27 +12,29 @@
* title/description) is asserted against in `landing-content.test.ts` without * title/description) is asserted against in `landing-content.test.ts` without
* a DOM render. * a DOM render.
* *
* Cross-task contracts encoded here: * Contracts encoded here:
* - `APP_STORE_URL` is the canonical App Store link the marketing page has * - `APP_STORE_URL` is the canonical App Store link the marketing page has
* always surfaced (kept stable across the migration). * always surfaced (kept stable across the migration).
* - `DOWNLOADS_HREF` is the **public browser path** on the lineage subdomain * - `DOWNLOADS_HREF` is the **public browser path** on the lineage subdomain
* (`/downloads`), NOT the internal vercel-rewritten prefix `/lineage/downloads`. * (`/downloads`), NOT the internal vercel-rewritten prefix `/lineage/downloads`.
* This matches the canonical-URL rule from task 02 and the nav-config rule * This matches the canonical-URL rule and the nav-config rule
* from task 04: vercel.json maps `lineage.freno.me/downloads` → * from the spec: vercel.json maps `lineage.freno.me/downloads` →
* `/lineage/downloads` server-side while the browser sees `/downloads`. * `/lineage/downloads` server-side while the browser sees `/downloads`.
* Task 11 will create the matching `src/routes/lineage/downloads.tsx`. * Task 11 will create the matching `src/routes/lineage/downloads.tsx`.
* - `PAGE_META` is consumed verbatim by `<PageHead>`; the per-site title * - `PAGE_META` is consumed verbatim by `<PageHead>`; the per-site title
* suffix (` | Life and Lineage`) is appended automatically by * suffix (` | Life and Lineage`) is appended automatically by
* `resolvePageHeadMeta` (task 02), so the `title` here is the BASE title * `resolvePageHeadMeta`, so the `title` here is the BASE title
* only — do NOT include the suffix. * only — do NOT include the suffix.
*/ */
import { buildSubdomainUrl } from "~/lib/site-context";
/** Apple App Store link — surfaced unchanged from the legacy marketing page. */ /** Apple App Store link — surfaced unchanged from the legacy marketing page. */
export const APP_STORE_URL = export const APP_STORE_URL =
"https://apps.apple.com/us/app/life-and-lineage/id6737252442"; "https://apps.apple.com/us/app/life-and-lineage/id6737252442";
/** /**
* Public browser path to the per-subdomain downloads page (task 11). * Public browser path to the per-subdomain downloads page.
* Subdomain-relative: renders `lineage.freno.me/downloads` in the browser. * Subdomain-relative: renders `lineage.freno.me/downloads` in the browser.
*/ */
export const DOWNLOADS_HREF = "/downloads"; export const DOWNLOADS_HREF = "/downloads";
@@ -99,4 +101,4 @@ export const FEATURES: readonly LineageFeature[] = [
* so tests can assert the redirect target without importing the route module * so tests can assert the redirect target without importing the route module
* (which would pull in the server runtime). * (which would pull in the server runtime).
*/ */
export const LEGACY_REDIRECT_TARGET = "https://lineage.freno.me"; export const LEGACY_REDIRECT_TARGET = buildSubdomainUrl("lineage");

View File

@@ -1,15 +1,15 @@
/** /**
* Life and Lineage privacy policy — `lineage.freno.me/privacy` (task 10). * Life and Lineage privacy policy — `lineage.freno.me/privacy`.
* *
* Migrated verbatim from the legacy * Migrated verbatim from the legacy
* `src/routes/privacy-policy/life-and-lineage.tsx` route so there is zero * `src/routes/privacy-policy/life-and-lineage.tsx` route so there is zero
* content loss; the old route now 308-redirects here (see * content loss; the old route now 308-redirects here (see
* `src/routes/privacy-policy/life-and-lineage.tsx`). PageHead is site-aware * `src/routes/privacy-policy/life-and-lineage.tsx`). PageHead is site-aware
* (task 02) so the Lineage `titleSuffix` (` | Life and Lineage`), canonical * so the Lineage `titleSuffix` (` | Life and Lineage`), canonical
* (`https://lineage.freno.me/privacy`), and OG image derive automatically — * (`https://lineage.freno.me/privacy`), and OG image derive automatically —
* we only pass the base title. * we only pass the base title.
* *
* Per task instructions, the account-deletion reference now points at the * Per instructions, the account-deletion reference now points at the
* Lineage subdomain's deletion flow, served at the **public subdomain-relative * Lineage subdomain's deletion flow, served at the **public subdomain-relative
* path** `/deletion` (vercel.json rewrites to `/lineage/deletion`). The * path** `/deletion` (vercel.json rewrites to `/lineage/deletion`). The
* contact link similarly uses `/contact` (public subdomain path), consistent * contact link similarly uses `/contact` (public subdomain path), consistent

View File

@@ -1,6 +1,8 @@
import { buildSubdomainUrl } from "~/lib/site-context";
/** /**
* Legacy `/marketing/gaze` route — redirected (task 05) to the new Gaze * Legacy `/marketing/gaze` route — redirected to the new Gaze
* subdomain landing page at `gaze.freno.me`. * subdomain landing page.
* *
* Kept as a permanent 308 redirect so existing inbound links keep resolving * Kept as a permanent 308 redirect so existing inbound links keep resolving
* to the canonical Gaze marketing home. * to the canonical Gaze marketing home.
@@ -14,7 +16,7 @@ export default function GazeMarketingRedirect(): never {
throw new Response(null, { throw new Response(null, {
status: 308, status: 308,
headers: { headers: {
Location: "https://gaze.freno.me", Location: buildSubdomainUrl("gaze"),
"Cache-Control": "public, max-age=86400" "Cache-Control": "public, max-age=86400"
} }
}); });

View File

@@ -1,6 +1,6 @@
/** /**
* Legacy Life and Lineage marketing route — now a 308 permanent redirect to * Legacy Life and Lineage marketing route — now a 308 permanent redirect to
* the Lineage subdomain (task 08). * the Lineage subdomain.
* *
* The marketing content has been migrated to `src/routes/lineage/index.tsx` * The marketing content has been migrated to `src/routes/lineage/index.tsx`
* served at `lineage.freno.me` (vercel.json host rewrites map the subdomain to * served at `lineage.freno.me` (vercel.json host rewrites map the subdomain to

View File

@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
/** /**
* Nessa contact page (`nessa.freno.me/contact`). * Nessa contact page (`nessa.freno.me/contact`).
* *
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness — * Thin wrapper over the shared `<ContactForm>`. Site awareness —
* subject prefix `[Nessa]`, recipient label, heading, and PageHead metadata — * subject prefix `[Nessa]`, recipient label, heading, and PageHead metadata —
* is derived from `useSite()` inside the component via * is derived from `useSite()` inside the component via
* `CONTACT_CONTEXT.nessa`, so this route needs no explicit props. * `CONTACT_CONTEXT.nessa`, so this route needs no explicit props.

View File

@@ -12,7 +12,7 @@
export const TAGLINE = "The fitness app that puts you first." as const; export const TAGLINE = "The fitness app that puts you first." as const;
export const SUBTITLE = export const SUBTITLE =
"Tired of Strava's paywalls and price hikes? Track, train, and connect — without the paywall." as const; "Track, train, and connect — without the paywall." as const;
export const ICON_DEFAULT = export const ICON_DEFAULT =
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const; "/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
@@ -152,35 +152,21 @@ export const PRICING: readonly PricingTier[] = [
} }
] as const; ] as const;
export interface ComparisonRow { export const COMPARISON: readonly { feature: string; nessa: string }[] = [
feature: string; { feature: "Segment leaderboards", nessa: "Free forever" },
strava: string; { feature: "Privacy", nessa: "On-device first" },
nessa: string; { feature: "Premium price", nessa: "From $4.99/mo" },
} { feature: "Apple Watch", nessa: "Native experience" }
export const COMPARISON: readonly ComparisonRow[] = [
{
feature: "Segment leaderboards",
strava: "Paywalled",
nessa: "Free forever"
},
{ feature: "Privacy", strava: "Server-side data", nessa: "On-device first" },
{ feature: "Premium price", strava: "$23.99/mo", nessa: "From $4.99/mo" },
{
feature: "Apple Watch",
strava: "Companion app",
nessa: "Native experience"
}
] as const; ] as const;
export const WHY_NESSA = [ export const WHY_NESSA = [
{ {
title: "Segment leaderboards free forever", title: "Segment leaderboards free forever",
body: "Strava's most complained-about paywall is included in Nessa's free tier." body: "The features other apps gate behind a subscription are included in Nessa's free tier."
}, },
{ {
title: "Half the price of Strava", title: "Affordable premium",
body: "Premium tiers at 5060% of Strava's cost, with no surprise paywalls." body: "Premium tiers start at $4.99/mo with no surprise paywalls."
}, },
{ {
title: "Privacy-first", title: "Privacy-first",

View File

@@ -1,9 +1,9 @@
/** /**
* Unit tests for the Nessa per-subdomain account-deletion page content * Unit tests for the Nessa per-subdomain account-deletion page content
* (task 11). * (see `./deletion.tsx`).
* *
* Asserts against pure constants exported from `deletion-content.ts` — no * Asserts against pure constants exported from `deletion-content.ts` — no
* solid-js / router / DOM. Covers the task-11 acceptance matrix for the * solid-js / router / DOM. Covers the acceptance matrix for the
* Nessa deletion flow: * Nessa deletion flow:
* - Product discriminator is `"nessa"` (selects Nessa-branded email). * - Product discriminator is `"nessa"` (selects Nessa-branded email).
* - Cooldown cookie name is Nessa-specific + matches the server-side * - Cooldown cookie name is Nessa-specific + matches the server-side
@@ -26,21 +26,21 @@ import {
describe("Nessa deletion — assessment outcome", () => { describe("Nessa deletion — assessment outcome", () => {
it("defines a product discriminator (deletion flow IS implemented)", () => { it("defines a product discriminator (deletion flow IS implemented)", () => {
// Nessa stores user data (nessa.ts: users, workouts, workoutPlans, … + // Nessa stores user data (nessa.ts: users, workouts, workoutPlans, … +
// nessa-community.ts: clubs, clubMemberships). Per task 11 spec step 5, // nessa-community.ts: clubs, clubMemberships).
// a deletion flow IS needed — this page provides it. // a deletion flow IS needed — this page provides it.
expect(typeof DELETION_PRODUCT_KEY).toBe("string"); expect(typeof DELETION_PRODUCT_KEY).toBe("string");
}); });
}); });
describe("Nessa deletion — product discriminator", () => { describe("Nessa deletion — product discriminator", () => {
it("is \"nessa\" (selects Nessa-branded email)", () => { it('is "nessa" (selects Nessa-branded email)', () => {
expect(DELETION_PRODUCT_KEY).toBe("nessa"); expect(DELETION_PRODUCT_KEY).toBe("nessa");
}); });
it("is accepted by the server-side product schema", () => { it("is accepted by the server-side product schema", () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe( expect(
true DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success
); ).toBe(true);
}); });
}); });
@@ -49,10 +49,8 @@ describe("Nessa deletion — cooldown cookie", () => {
expect(DELETION_COOKIE_NAME).toBe("nessaDeletionRequestSent"); expect(DELETION_COOKIE_NAME).toBe("nessaDeletionRequestSent");
}); });
it("matches the server-side deletionCookieName(\"nessa\")", () => { it('matches the server-side deletionCookieName("nessa")', () => {
expect(DELETION_COOKIE_NAME).toBe( expect(DELETION_COOKIE_NAME).toBe(deletionCookieName(DELETION_PRODUCT_KEY));
deletionCookieName(DELETION_PRODUCT_KEY)
);
}); });
it("does NOT collide with the Lineage cooldown cookie", () => { it("does NOT collide with the Lineage cooldown cookie", () => {

View File

@@ -1,12 +1,12 @@
/** /**
* Pure content + metadata for the Nessa per-subdomain account-deletion page * Pure content + metadata for the Nessa per-subdomain account-deletion page
* (task 11). * (see `./deletion.tsx`).
* *
* Mirrors the `lineage/deletion-content.ts` pattern: imports NOTHING from * Mirrors the `lineage/deletion-content.ts` pattern: imports NOTHING from
* solid-js / @solidjs/router / @solidjs/meta so the constants here can be * solid-js / @solidjs/router / @solidjs/meta so the constants here can be
* unit-tested in `bun:test` without spinning up the router / MetaProvider. * unit-tested in `bun:test` without spinning up the router / MetaProvider.
* *
* Nessa deletion assessment (see task 11 spec, step 5): * Nessa deletion assessment:
* - Nessa DOES store user data. `src/server/api/routers/nessa.ts` defines * - Nessa DOES store user data. `src/server/api/routers/nessa.ts` defines
* per-user tables (`users`, `authProviders`, `workouts`, `workoutPlans`, * per-user tables (`users`, `authProviders`, `workouts`, `workoutPlans`,
* `planExercises`, `planSets`, `routePoints`, `exerciseLibrary`) backed * `planExercises`, `planSets`, `routePoints`, `exerciseLibrary`) backed
@@ -24,7 +24,7 @@
* scope for the subdomain-routing feature); the email-request flow gives * scope for the subdomain-routing feature); the email-request flow gives
* users a real, immediate deletion path today. * users a real, immediate deletion path today.
* *
* Cross-task contracts: * Contracts:
* - `DELETION_PRODUCT_KEY = "nessa"` selects Nessa branding + the * - `DELETION_PRODUCT_KEY = "nessa"` selects Nessa branding + the
* `nessaDeletionRequestSent` cooldown cookie (server-side * `nessaDeletionRequestSent` cooldown cookie (server-side
* `deletionCookieName("nessa")`). * `deletionCookieName("nessa")`).

View File

@@ -1,10 +1,10 @@
/** /**
* Nessa per-subdomain account-deletion page — `nessa.freno.me/deletion` * Nessa per-subdomain account-deletion page — `nessa.freno.me/deletion`
* (task 11). * (see `./deletion-content.ts`).
* *
* Served at the public browser path `/deletion` (vercel.json host rewrites * Served at the public browser path `/deletion` (vercel.json host rewrites
* `nessa.freno.me/*` → the internal `/nessa/*` route prefix, leaving the * `nessa.freno.me/*` → the internal `/nessa/*` route prefix, leaving the
* browser URL clean — task 02 canonical rule). * browser URL clean.
* *
* Nessa deletion assessment (see `./deletion-content.ts` for the full * Nessa deletion assessment (see `./deletion-content.ts` for the full
* rationale): Nessa stores user data (`users`, `workouts`, `workoutPlans`, * rationale): Nessa stores user data (`users`, `workouts`, `workoutPlans`,
@@ -19,7 +19,7 @@
* request is email-based (the requester may be locked out of their Clerk * 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 * 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 * a Nessa deletion link by default, so this page is reachable by direct URL
* + from the Nessa privacy policy (task-provided). * + from the Nessa privacy policy.
* *
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form. * Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
*/ */

View File

@@ -3,7 +3,7 @@
* *
* Serves `nessa.freno.me/` (and falls back from `src/routes/index.tsx`'s * Serves `nessa.freno.me/` (and falls back from `src/routes/index.tsx`'s
* `useSite()` branch in dev). Reflects Nessa's actual product: a * `useSite()` branch in dev). Reflects Nessa's actual product: a
* privacy-first Strava-alternative fitness app with segment leaderboards, * privacy-first fitness app with segment leaderboards,
* clubs, challenges, Apple Watch support, and Free / Plus / Pro pricing tiers. * clubs, challenges, Apple Watch support, and Free / Plus / Pro pricing tiers.
* *
* Content is sourced from `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md` * Content is sourced from `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`
@@ -16,6 +16,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
import { useDarkMode } from "~/context/darkMode"; import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext"; import { useSite } from "~/context/SiteContext";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { buildMainSiteUrl } from "~/lib/site-context";
import { NESSA_LANDING_META } from "./meta"; import { NESSA_LANDING_META } from "./meta";
import { import {
TAGLINE, TAGLINE,
@@ -158,7 +159,7 @@ export default function NessaLanding() {
<div class="mx-auto max-w-6xl"> <div class="mx-auto max-w-6xl">
<div class="mb-12 text-center"> <div class="mb-12 text-center">
<h2 class="text-3xl font-bold md:text-4xl"> <h2 class="text-3xl font-bold md:text-4xl">
Premium features, half the price of Strava Premium features, affordable pricing
</h2> </h2>
<p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg"> <p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg">
Choose the plan that fits your training. Choose the plan that fits your training.
@@ -229,20 +230,20 @@ export default function NessaLanding() {
</div> </div>
</section> </section>
{/* ─── Comparison vs Strava ─────────────────────────────────── */} {/* ─── Feature highlights ─────────────────────────────────── */}
<section class="relative z-10 px-4 py-16"> <section class="relative z-10 px-4 py-16">
<div class="mx-auto max-w-4xl"> <div class="mx-auto max-w-4xl">
<h2 class="text-center text-3xl font-bold md:text-4xl"> <h2 class="text-center text-3xl font-bold md:text-4xl">
See how we compare What you get with Nessa
</h2> </h2>
<p class="text-text/70 mt-3 text-center text-base md:text-lg"> <p class="text-text/70 mt-3 text-center text-base md:text-lg">
Get more for less with Nessa. Free features other apps charge for, plus affordable premium
tiers.
</p> </p>
<div class="border-surface0 mt-8 overflow-hidden rounded-2xl border-2"> <div class="border-surface0 mt-8 overflow-hidden rounded-2xl border-2">
<div class="bg-surface0/60 grid grid-cols-3 px-6 py-4 text-sm font-semibold"> <div class="bg-surface0/60 grid grid-cols-2 px-6 py-4 text-sm font-semibold">
<span>Feature</span> <span>Feature</span>
<span class="text-center">Strava</span>
<span class="text-center" style={{ color: brandColor() }}> <span class="text-center" style={{ color: brandColor() }}>
Nessa Nessa
</span> </span>
@@ -250,13 +251,12 @@ export default function NessaLanding() {
<For each={COMPARISON}> <For each={COMPARISON}>
{(row, idx) => ( {(row, idx) => (
<div <div
class="grid grid-cols-3 px-6 py-4 text-sm" class="grid grid-cols-2 px-6 py-4 text-sm"
classList={{ classList={{
"bg-surface0/20": idx() % 2 === 1 "bg-surface0/20": idx() % 2 === 1
}} }}
> >
<span>{row.feature}</span> <span>{row.feature}</span>
<span class="text-text/70 text-center">{row.strava}</span>
<span <span
class="text-center font-medium" class="text-center font-medium"
style={{ color: brandColor() }} style={{ color: brandColor() }}
@@ -348,7 +348,7 @@ export default function NessaLanding() {
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row"> <div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
<span>{site().displayName}</span> <span>{site().displayName}</span>
<A <A
href="https://freno.me" href={buildMainSiteUrl()}
class="hover:text-text underline-offset-4 hover:underline" class="hover:text-text underline-offset-4 hover:underline"
> >
freno.me freno.me

View File

@@ -4,7 +4,7 @@
* Mirrors the `page-head-meta.ts` testability pattern — `nessa/meta.ts` is a * Mirrors the `page-head-meta.ts` testability pattern — `nessa/meta.ts` is a
* pure module (no solid-js / @solidjs/router / @solidjs/meta imports) so * pure module (no solid-js / @solidjs/router / @solidjs/meta imports) so
* `bun:test` can resolve it. Asserts the metadata matches Nessa's actual * `bun:test` can resolve it. Asserts the metadata matches Nessa's actual
* product positioning as a privacy-first fitness / Strava-alternative app * product positioning as a privacy-first fitness app
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`). * (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
*/ */
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
@@ -72,13 +72,13 @@ describe("Nessa landing page — PageHead metadata", () => {
expect(meta.description?.toLowerCase()).toContain("challenges"); expect(meta.description?.toLowerCase()).toContain("challenges");
}); });
it("ogDescription mentions free leaderboards and Strava comparison", () => { it("ogDescription mentions free leaderboards and affordable pricing", () => {
const meta = resolvePageHeadMeta( const meta = resolvePageHeadMeta(
NESSA_LANDING_META, NESSA_LANDING_META,
SITE_CONFIG.nessa, SITE_CONFIG.nessa,
"/" "/"
); );
expect(meta.ogDescription?.toLowerCase()).toContain("leaderboards"); expect(meta.ogDescription?.toLowerCase()).toContain("leaderboards");
expect(meta.ogDescription?.toLowerCase()).toContain("strava"); expect(meta.ogDescription?.toLowerCase()).toContain("affordable");
}); });
}); });

View File

@@ -6,7 +6,7 @@
* spinning up the router / MetaProvider / DOM, mirroring the * spinning up the router / MetaProvider / DOM, mirroring the
* `page-head-meta.ts` / `nav-config.ts` testability pattern. * `page-head-meta.ts` / `nav-config.ts` testability pattern.
* *
* Nessa is positioned as a privacy-first fitness app and Strava alternative * Nessa is positioned as a privacy-first fitness app
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`). * (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
* The description still mentions community features (clubs, challenges) because * The description still mentions community features (clubs, challenges) because
* those are real free-tier capabilities, but it now leads with the product's * those are real free-tier capabilities, but it now leads with the product's
@@ -28,5 +28,5 @@ export const NESSA_LANDING_META: PageHeadProps = {
"Nessa is the fitness app that puts you first. Track running, cycling, swimming and more; compete on free segment leaderboards; and connect with friends through clubs, community challenges, and a social feed — all while keeping your data on your device.", "Nessa is the fitness app that puts you first. Track running, cycling, swimming and more; compete on free segment leaderboards; and connect with friends through clubs, community challenges, and a social feed — all while keeping your data on your device.",
ogTitle: "Nessa — The fitness app that puts you first", ogTitle: "Nessa — The fitness app that puts you first",
ogDescription: ogDescription:
"A privacy-first fitness app with segment leaderboards free forever, social clubs, community challenges, Apple Watch support, and premium tiers for less than Strava." "A privacy-first fitness app with segment leaderboards free forever, social clubs, community challenges, Apple Watch support, and affordable premium tiers."
}; };

View File

@@ -1,9 +1,8 @@
/** /**
* Nessa privacy policy — `nessa.freno.me/privacy` (task 10). * Nessa privacy policy — `nessa.freno.me/privacy`.
* *
* Net-new privacy policy for the Nessa subdomain. Modeled on the Life and * Net-new privacy policy for the Nessa subdomain. Modeled on the Life and
* Lineage policy (the template for products with user accounts, per the task * Lineage policy (the template for products with user accounts) but scoped to Nessa's real data practices:
* notes) but scoped to Nessa's real data practices:
* *
* - Authentication: user accounts are managed by Clerk * - Authentication: user accounts are managed by Clerk
* (`src/server/nessa-auth.ts` verifies Clerk session JWTs via the Clerk * (`src/server/nessa-auth.ts` verifies Clerk session JWTs via the Clerk
@@ -17,13 +16,13 @@
* (`NessaConnectionFactory` in `src/server/db-connections.ts`), separate * (`NessaConnectionFactory` in `src/server/db-connections.ts`), separate
* from the freno.me main DB and the Lineage DB. * from the freno.me main DB and the Lineage DB.
* *
* PageHead is site-aware (task 02): only the base title is supplied; the * PageHead is site-aware: only the base title is supplied; the
* ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image * ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image
* are derived automatically. Internal links use public subdomain-relative * are derived automatically. Internal links use public subdomain-relative
* paths (`/contact`) consistent with nav-config.ts and page-head-meta.ts. * paths (`/contact`) consistent with nav-config.ts and page-head-meta.ts.
* *
* Nessa does not yet ship a dedicated account-deletion form route; per the * Nessa does not yet ship a dedicated account-deletion form route; per the
* task notes ("reference the deletion flow if Nessa has user accounts"), * notes ("reference the deletion flow if Nessa has user accounts"),
* account/data deletion is initiated by contacting us — Clerk user records * account/data deletion is initiated by contacting us — Clerk user records
* and the associated Nessa community content are then purged manually until a * and the associated Nessa community content are then purged manually until a
* self-serve flow is built. * self-serve flow is built.

View File

@@ -1,6 +1,8 @@
import { buildSubdomainUrl } from "~/lib/site-context";
/** /**
* Legacy Gaze privacy policy route — now a 308 permanent redirect to the Gaze * Legacy Gaze privacy policy route — now a 308 permanent redirect to the Gaze
* subdomain (task 10). * subdomain.
* *
* The privacy policy content has been migrated to * The privacy policy content has been migrated to
* `src/routes/gaze/privacy.tsx`, served at `gaze.freno.me/privacy` * `src/routes/gaze/privacy.tsx`, served at `gaze.freno.me/privacy`
@@ -8,7 +10,7 @@
* `/gaze/*` prefix). Keeping this route as a permanent (308) server-side * `/gaze/*` prefix). Keeping this route as a permanent (308) server-side
* redirect — rather than a client `<Navigate>` — preserves SEO equity and * redirect — rather than a client `<Navigate>` — preserves SEO equity and
* gives installed / linked URLs a stable resolution path, mirroring how the * gives installed / linked URLs a stable resolution path, mirroring how the
* legacy Life and Lineage marketing page was redirected in task 08. * legacy Life and Lineage marketing page was redirected.
* *
* Implemented as a SolidStart API route (`GET` handler returning a Response) * Implemented as a SolidStart API route (`GET` handler returning a Response)
* so the redirect happens before any rendering; the route no longer ships a * so the redirect happens before any rendering; the route no longer ships a
@@ -18,7 +20,7 @@ export function GET() {
return new Response(null, { return new Response(null, {
status: 308, status: 308,
headers: { headers: {
Location: "https://gaze.freno.me/privacy", Location: buildSubdomainUrl("gaze", "/privacy"),
"Cache-Control": "public, max-age=0, must-revalidate" "Cache-Control": "public, max-age=0, must-revalidate"
} }
}); });

View File

@@ -1,6 +1,8 @@
import { buildSubdomainUrl } from "~/lib/site-context";
/** /**
* Legacy Life and Lineage privacy policy route — now a 308 permanent redirect * Legacy Life and Lineage privacy policy route — now a 308 permanent redirect
* to the Lineage subdomain (task 10). * to the Lineage subdomain.
* *
* The privacy policy content has been migrated to * The privacy policy content has been migrated to
* `src/routes/lineage/privacy.tsx`, served at `lineage.freno.me/privacy` * `src/routes/lineage/privacy.tsx`, served at `lineage.freno.me/privacy`
@@ -8,7 +10,7 @@
* `/lineage/*` prefix). Keeping this route as a permanent (308) server-side * `/lineage/*` prefix). Keeping this route as a permanent (308) server-side
* redirect — rather than a client `<Navigate>` — preserves SEO equity and * redirect — rather than a client `<Navigate>` — preserves SEO equity and
* gives installed / linked URLs a stable resolution path, mirroring how the * gives installed / linked URLs a stable resolution path, mirroring how the
* legacy Life and Lineage marketing page was redirected in task 08. * legacy Life and Lineage marketing page was redirected.
* *
* Implemented as a SolidStart API route (`GET` handler returning a Response) * Implemented as a SolidStart API route (`GET` handler returning a Response)
* so the redirect happens before any rendering; the route no longer ships a * so the redirect happens before any rendering; the route no longer ships a
@@ -18,7 +20,7 @@ export function GET() {
return new Response(null, { return new Response(null, {
status: 308, status: 308,
headers: { headers: {
Location: "https://lineage.freno.me/privacy", Location: buildSubdomainUrl("lineage", "/privacy"),
"Cache-Control": "public, max-age=0, must-revalidate" "Cache-Control": "public, max-age=0, must-revalidate"
} }
}); });

View File

@@ -1,11 +1,11 @@
/** /**
* Host-aware sitemap.xml route handler (task 03). * Host-aware sitemap.xml route handler.
* *
* Reads the `Host` header to determine the active site, then generates a * Reads the `Host` header to determine the active site, then generates a
* sitemap scoped to that site's routes with canonical URLs from the * sitemap scoped to that site's routes with canonical URLs from the
* corresponding domain. * corresponding domain.
*/ */
import { APIEvent } from "@solidjs/start/server"; import type { APIEvent } from "@solidjs/start/server";
import { getSiteFromEvent } from "~/server/site-context-server"; import { getSiteFromEvent } from "~/server/site-context-server";
import { SITEMAP_ROUTES } from "~/lib/sitemap-routes"; import { SITEMAP_ROUTES } from "~/lib/sitemap-routes";
import { generateSitemap } from "~/lib/sitemap-generate"; import { generateSitemap } from "~/lib/sitemap-generate";

View File

@@ -1,6 +1,6 @@
/** /**
* Unit tests for the generalized account-deletion-request email helpers * Unit tests for the generalized account-deletion-request email helpers
* (task 11). * (see `misc.ts`).
* *
* These are the pure, env-free helpers consumed by the * These are the pure, env-free helpers consumed by the
* `misc.sendDeletionRequestEmail` tRPC mutation (re-exported from `misc.ts`). * `misc.sendDeletionRequestEmail` tRPC mutation (re-exported from `misc.ts`).
@@ -25,11 +25,11 @@ import {
} from "~/server/api/routers/deletion-email"; } from "~/server/api/routers/deletion-email";
describe("DELETION_PRODUCT_SCHEMA", () => { describe("DELETION_PRODUCT_SCHEMA", () => {
it("accepts \"lineage\"", () => { it('accepts "lineage"', () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse("lineage").success).toBe(true); expect(DELETION_PRODUCT_SCHEMA.safeParse("lineage").success).toBe(true);
}); });
it("accepts \"nessa\"", () => { it('accepts "nessa"', () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse("nessa").success).toBe(true); expect(DELETION_PRODUCT_SCHEMA.safeParse("nessa").success).toBe(true);
}); });
@@ -50,9 +50,7 @@ describe("deletionCookieName", () => {
}); });
it("returns distinct names per product", () => { it("returns distinct names per product", () => {
expect(deletionCookieName("lineage")).not.toBe( expect(deletionCookieName("lineage")).not.toBe(deletionCookieName("nessa"));
deletionCookieName("nessa")
);
}); });
}); });

View File

@@ -1,6 +1,6 @@
/** /**
* Pure helpers for the generalized account-deletion-request email flow * Pure helpers for the generalized account-deletion-request email flow
* (task 11). * (see `misc.ts`).
* *
* Extracted from `src/server/api/routers/misc.ts` so they can be unit-tested * 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 * in `bun:test` WITHOUT importing `~/env/server` (which validates ~30 secrets
@@ -49,7 +49,7 @@ export interface DeletionEmailContent {
* The operator email identifies the request name + requester email; the user * The operator email identifies the request name + requester email; the user
* email identifies the account being deleted + the 24h cancellation window. * email identifies the account being deleted + the 24h cancellation window.
* The `product` discriminator switches branding between Lineage (the original * The `product` discriminator switches branding between Lineage (the original
* flow) and Nessa (task 11 — Nessa stores user data in its own Turso DB). * flow) and Nessa (Nessa stores user data in its own Turso DB).
* *
* `email` is interpolated verbatim into the HTML bodies. It has already been * `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 * validated as a well-formed email by the tRPC input schema, and Sendinblue

View File

@@ -50,7 +50,7 @@ mock.module("~/env/server", () => ({
TURSO_DB_API_TOKEN: "test-token", TURSO_DB_API_TOKEN: "test-token",
NESSA_DB_URL: "libsql://nessa-test.turso.io", NESSA_DB_URL: "libsql://nessa-test.turso.io",
NESSA_DB_TOKEN: "test-token", NESSA_DB_TOKEN: "test-token",
// Clerk env vars (required after migration in task 02) // Clerk env vars (required after migration)
NESSA_CLERK_SECRET: "sk_test_test-secret", NESSA_CLERK_SECRET: "sk_test_test-secret",
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev" NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
}, },
@@ -61,9 +61,8 @@ mock.module("~/env/server", () => ({
// Import after env mock is registered. These are the real verification // Import after env mock is registered. These are the real verification
// functions used by web and Lineage surfaces respectively. // functions used by web and Lineage surfaces respectively.
const { verifyAuthToken, verifyLineageAuthToken } = await import( const { verifyAuthToken, verifyLineageAuthToken } =
"~/server/auth" await import("~/server/auth");
);
// Issuer/audience claims the Lineage router stamps onto its tokens. // Issuer/audience claims the Lineage router stamps onto its tokens.
const { LINEAGE_CONFIG } = await import("~/config"); const { LINEAGE_CONFIG } = await import("~/config");

View File

@@ -1,10 +1,10 @@
/** /**
* p8-001 / p8-008 regression tests — S3 procedure lockdown & input sanitization. * p8-001 / p8-008 regression tests — S3 procedure lockdown & input sanitization.
* *
* These tests verify the security remediation from task 02 without standing up * These tests verify the security remediation without standing up
* the full tRPC router (which requires S3 / env / database / vinxi-runtime * the full tRPC router (which requires S3 / env / database / vinxi-runtime
* mocking that is unreliable under `bun test`). They follow the proven pattern * mocking that is unreliable under `bun test`). They follow the proven pattern
* from task 03 (p8-002): direct unit tests of the authz/sanitization helpers * (p8-002): direct unit tests of the authz/sanitization helpers
* plus a static source-code audit that the previously-`publicProcedure` S3 * plus a static source-code audit that the previously-`publicProcedure` S3
* endpoints are now `csrfProtectedProcedure` (i.e. no longer anonymous). * endpoints are now `csrfProtectedProcedure` (i.e. no longer anonymous).
* *
@@ -136,7 +136,9 @@ describe("p8-001 / p8-008 static source audit", () => {
for (const proc of S3_PROCEDURES) { for (const proc of S3_PROCEDURES) {
it(`${proc} is not declared as publicProcedure`, () => { it(`${proc} is not declared as publicProcedure`, () => {
// Match the procedure declaration line and ensure it is not publicProcedure. // Match the procedure declaration line and ensure it is not publicProcedure.
const re = new RegExp(`\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure|nessaProcedure)`); const re = new RegExp(
`\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure|nessaProcedure)`
);
const m = SOURCE.match(re); const m = SOURCE.match(re);
expect(m, `${proc} declaration not found`).not.toBeNull(); expect(m, `${proc} declaration not found`).not.toBeNull();
expect(m![1]).not.toBe("publicProcedure"); expect(m![1]).not.toBe("publicProcedure");
@@ -144,7 +146,9 @@ describe("p8-001 / p8-008 static source audit", () => {
} }
it("getDownloadUrl (Sparkle updater) remains the only public S3 endpoint", () => { it("getDownloadUrl (Sparkle updater) remains the only public S3 endpoint", () => {
const m = SOURCE.match(/\bgetDownloadUrl\s*:\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure)/); const m = SOURCE.match(
/\bgetDownloadUrl\s*:\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure)/
);
expect(m, "getDownloadUrl declaration not found").not.toBeNull(); expect(m, "getDownloadUrl declaration not found").not.toBeNull();
expect(m![1]).toBe("publicProcedure"); expect(m![1]).toBe("publicProcedure");
}); });
@@ -153,7 +157,9 @@ describe("p8-001 / p8-008 static source audit", () => {
// Both simpleDeleteImage and deleteImage must call the ownership guard. // Both simpleDeleteImage and deleteImage must call the ownership guard.
const deleteBlocks = SOURCE.split(/(\bsimpleDeleteImage:|\bdeleteImage:)/); const deleteBlocks = SOURCE.split(/(\bsimpleDeleteImage:|\bdeleteImage:)/);
// Count occurrences of the ownership call within the delete mutation bodies. // Count occurrences of the ownership call within the delete mutation bodies.
const occurrences = (SOURCE.match(/assertS3KeyOwnership\(input\.key/g) || []).length; const occurrences = (
SOURCE.match(/assertS3KeyOwnership\(input\.key/g) || []
).length;
expect(occurrences).toBeGreaterThanOrEqual(2); expect(occurrences).toBeGreaterThanOrEqual(2);
}); });
}); });

View File

@@ -69,7 +69,7 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void {
} }
// ============================================================ // ============================================================
// Account-deletion request email (task 11 — product-aware) // Account-deletion request email — product-aware
// ============================================================ // ============================================================
// //
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit- // Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
@@ -368,7 +368,7 @@ export const miscRouter = createTRPCRouter({
turnstileToken: z.string().min(1, "Please complete the security check"), turnstileToken: z.string().min(1, "Please complete the security check"),
/** /**
* Per-site subject prefix injected into the outbound email subject * Per-site subject prefix injected into the outbound email subject
* (task 09). Defaults to `"freno.me"` so existing callers (pre-task-09 * Defaults to `"freno.me"` so existing callers
* main-site contact form) keep emitting the byte-identical legacy * main-site contact form) keep emitting the byte-identical legacy
* subject `"freno.me Contact Request"`. * subject `"freno.me Contact Request"`.
*/ */
@@ -499,7 +499,7 @@ export const miscRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
email: z.string().email(), email: z.string().email(),
/** Product discriminator (task 11) — defaults to "lineage" for backward compat. */ /** Product discriminator — defaults to "lineage" for backward compat. */
product: DELETION_PRODUCT_SCHEMA.default("lineage") product: DELETION_PRODUCT_SCHEMA.default("lineage")
}) })
) )

View File

@@ -65,9 +65,15 @@ function initSchema() {
db = new Database(":memory:"); db = new Database(":memory:");
db.run("PRAGMA foreign_keys = ON"); db.run("PRAGMA foreign_keys = ON");
db.run("CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)"); db.run(
db.run("CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)"); "CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)"
db.run("CREATE TABLE clubChallenges (id TEXT PRIMARY KEY, clubId TEXT, title TEXT, description TEXT, goalType TEXT, goalValue REAL, startDate TEXT, endDate TEXT, createdBy TEXT, status TEXT, createdAt TEXT, updatedAt TEXT)"); );
db.run(
"CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)"
);
db.run(
"CREATE TABLE clubChallenges (id TEXT PRIMARY KEY, clubId TEXT, title TEXT, description TEXT, goalType TEXT, goalValue REAL, startDate TEXT, endDate TEXT, createdBy TEXT, status TEXT, createdAt TEXT, updatedAt TEXT)"
);
} }
function seed() { function seed() {
@@ -86,7 +92,17 @@ function seed() {
// Challenge CH in club C, created by A. // Challenge CH in club C, created by A.
db.run( db.run(
"INSERT INTO clubChallenges (id, clubId, title, description, goalType, goalValue, startDate, endDate, createdBy, status, createdAt, updatedAt) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))", "INSERT INTO clubChallenges (id, clubId, title, description, goalType, goalValue, startDate, endDate, createdBy, status, createdAt, updatedAt) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))",
[CHALLENGE_CH, CLUB_C, "Run 5k", "distance", 5000, "2025-01-01", "2025-12-31", USER_A, "active"] [
CHALLENGE_CH,
CLUB_C,
"Run 5k",
"distance",
5000,
"2025-01-01",
"2025-12-31",
USER_A,
"active"
]
); );
} }
@@ -126,7 +142,9 @@ describe("p8-003: resolveClubIdFromPost", () => {
}); });
it("throws NOT_FOUND for a missing post", async () => { it("throws NOT_FOUND for a missing post", async () => {
expect(await errCode(resolveClubIdFromPost(conn, "no-such-post"))).toBe("NOT_FOUND"); expect(await errCode(resolveClubIdFromPost(conn, "no-such-post"))).toBe(
"NOT_FOUND"
);
}); });
}); });
@@ -136,17 +154,23 @@ describe("p8-003: resolveClubIdFromChallenge", () => {
}); });
it("throws NOT_FOUND for a missing challenge", async () => { it("throws NOT_FOUND for a missing challenge", async () => {
expect(await errCode(resolveClubIdFromChallenge(conn, "no-such-challenge"))).toBe("NOT_FOUND"); expect(
await errCode(resolveClubIdFromChallenge(conn, "no-such-challenge"))
).toBe("NOT_FOUND");
}); });
}); });
describe("p8-003: requireClubMembership", () => { describe("p8-003: requireClubMembership", () => {
it("passes silently for a member", async () => { it("passes silently for a member", async () => {
await expect(requireClubMembership(conn, CLUB_C, USER_A)).resolves.toBeUndefined(); await expect(
requireClubMembership(conn, CLUB_C, USER_A)
).resolves.toBeUndefined();
}); });
it("throws FORBIDDEN for a non-member", async () => { it("throws FORBIDDEN for a non-member", async () => {
expect(await errCode(requireClubMembership(conn, CLUB_C, USER_B))).toBe("FORBIDDEN"); expect(await errCode(requireClubMembership(conn, CLUB_C, USER_B))).toBe(
"FORBIDDEN"
);
}); });
}); });
@@ -160,23 +184,31 @@ describe("p8-003: endpoint authorization sequences (resolve → require)", () =>
// social.getPost / addComment / comments / like / unlike // social.getPost / addComment / comments / like / unlike
it("getPost/addComment/comments/like/unlike: non-member B rejected with FORBIDDEN", async () => { it("getPost/addComment/comments/like/unlike: non-member B rejected with FORBIDDEN", async () => {
const clubId = await resolveClubIdFromPost(conn, POST_P); const clubId = await resolveClubIdFromPost(conn, POST_P);
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN"); expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
"FORBIDDEN"
);
}); });
it("getPost/addComment/comments/like/unlike: member A allowed", async () => { it("getPost/addComment/comments/like/unlike: member A allowed", async () => {
const clubId = await resolveClubIdFromPost(conn, POST_P); const clubId = await resolveClubIdFromPost(conn, POST_P);
await expect(requireClubMembership(conn, clubId, USER_A)).resolves.toBeUndefined(); await expect(
requireClubMembership(conn, clubId, USER_A)
).resolves.toBeUndefined();
}); });
// challenges.leave / challenges.submitProgress // challenges.leave / challenges.submitProgress
it("challenges.leave / submitProgress: non-member B rejected with FORBIDDEN", async () => { it("challenges.leave / submitProgress: non-member B rejected with FORBIDDEN", async () => {
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH); const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN"); expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
"FORBIDDEN"
);
}); });
it("challenges.leave / submitProgress: member A allowed", async () => { it("challenges.leave / submitProgress: member A allowed", async () => {
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH); const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
await expect(requireClubMembership(conn, clubId, USER_A)).resolves.toBeUndefined(); await expect(
requireClubMembership(conn, clubId, USER_A)
).resolves.toBeUndefined();
}); });
}); });
@@ -184,21 +216,27 @@ describe("p8-003: join then allowed / leave then blocked (integration)", () => {
it("B is blocked, allowed after joining C, blocked again after leaving", async () => { it("B is blocked, allowed after joining C, blocked again after leaving", async () => {
// Initially blocked. // Initially blocked.
const clubId = await resolveClubIdFromPost(conn, POST_P); const clubId = await resolveClubIdFromPost(conn, POST_P);
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN"); expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
"FORBIDDEN"
);
// B joins. // B joins.
db.run( db.run(
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))", "INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
["mem-b", CLUB_C, USER_B, "member"] ["mem-b", CLUB_C, USER_B, "member"]
); );
await expect(requireClubMembership(conn, clubId, USER_B)).resolves.toBeUndefined(); await expect(
requireClubMembership(conn, clubId, USER_B)
).resolves.toBeUndefined();
// B leaves. // B leaves.
db.run("DELETE FROM clubMemberships WHERE clubId = ? AND userId = ?", [ db.run("DELETE FROM clubMemberships WHERE clubId = ? AND userId = ?", [
CLUB_C, CLUB_C,
USER_B USER_B
]); ]);
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN"); expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
"FORBIDDEN"
);
}); });
}); });
@@ -224,7 +262,9 @@ function initUsersTable() {
email TEXT, email TEXT,
clerkUserId TEXT clerkUserId TEXT
)`); )`);
db.run(`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`); db.run(
`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`
);
} }
async function resolveLocalUserId(clerkUserId: string): Promise<string | null> { async function resolveLocalUserId(clerkUserId: string): Promise<string | null> {
@@ -246,31 +286,34 @@ describe("clerkUserId lookup (migrate-to-clerk-auth-03)", () => {
}); });
it("resolves local users.id for a seeded clerkUserId", async () => { it("resolves local users.id for a seeded clerkUserId", async () => {
db.run( db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", LOCAL_USER_A,
[LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID] "a@nessa.app",
); CLERK_USER_ID
]);
expect(await resolveLocalUserId(CLERK_USER_ID)).toBe(LOCAL_USER_A); expect(await resolveLocalUserId(CLERK_USER_ID)).toBe(LOCAL_USER_A);
}); });
it("returns null when no local row matches the clerkUserId", async () => { it("returns null when no local row matches the clerkUserId", async () => {
// No users seeded — the webhook (task 04) has not run yet. // No users seeded — the webhook has not run yet.
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull(); expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
}); });
it("returns null for a Clerk id that exists but maps to a different local user", async () => { it("returns null for a Clerk id that exists but maps to a different local user", async () => {
db.run( db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", LOCAL_USER_B,
[LOCAL_USER_B, "b@nessa.app", "user_test_other"] "b@nessa.app",
); "user_test_other"
]);
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull(); expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
}); });
it("ctx.nessaUserId is the LOCAL id, never the Clerk sub", async () => { it("ctx.nessaUserId is the LOCAL id, never the Clerk sub", async () => {
db.run( db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", LOCAL_USER_A,
[LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID] "a@nessa.app",
); CLERK_USER_ID
]);
const resolved = await resolveLocalUserId(CLERK_USER_ID); const resolved = await resolveLocalUserId(CLERK_USER_ID);
expect(resolved).toBe(LOCAL_USER_A); expect(resolved).toBe(LOCAL_USER_A);
expect(resolved).not.toBe(CLERK_USER_ID); expect(resolved).not.toBe(CLERK_USER_ID);

View File

@@ -23,7 +23,7 @@ mock.module("~/env/server", () => ({
TURSO_LINEAGE_TOKEN: "test-token", TURSO_LINEAGE_TOKEN: "test-token",
TURSO_DB_API_TOKEN: "test-token", TURSO_DB_API_TOKEN: "test-token",
NODE_ENV: "test", NODE_ENV: "test",
// Clerk env vars (required after migration in task 02) // Clerk env vars (required after migration)
NESSA_CLERK_SECRET: "sk_test_test-secret", NESSA_CLERK_SECRET: "sk_test_test-secret",
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev" NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
}, },
@@ -55,7 +55,11 @@ const PROVIDER_ID = "prov-1";
// create/update/deleteWorkoutSplit // create/update/deleteWorkoutSplit
describe("assertWorkoutOwned helper", () => { describe("assertWorkoutOwned helper", () => {
let assertWorkoutOwned: (conn: Client, workoutId: string, userId: string) => Promise<void>; let assertWorkoutOwned: (
conn: Client,
workoutId: string,
userId: string
) => Promise<void>;
beforeEach(async () => { beforeEach(async () => {
const mod = await import("./nessa"); const mod = await import("./nessa");
@@ -64,16 +68,16 @@ describe("assertWorkoutOwned helper", () => {
it("rejects when workout belongs to another user", async () => { it("rejects when workout belongs to another user", async () => {
const conn = makeMockConn([{ userId: USER_B }]); const conn = makeMockConn([{ userId: USER_B }]);
await expect( await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
assertWorkoutOwned(conn, WORKOUT_ID, USER_A) /owner/
).rejects.toThrow(/owner/); );
}); });
it("rejects when workout does not exist", async () => { it("rejects when workout does not exist", async () => {
const conn = makeMockConn([]); const conn = makeMockConn([]);
await expect( await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
assertWorkoutOwned(conn, WORKOUT_ID, USER_A) /not found/i
).rejects.toThrow(/not found/i); );
}); });
it("succeeds when workout belongs to the caller", async () => { it("succeeds when workout belongs to the caller", async () => {
@@ -88,7 +92,11 @@ describe("assertWorkoutOwned helper", () => {
// Used by: updateAuthProvider, deleteAuthProvider // Used by: updateAuthProvider, deleteAuthProvider
describe("assertAuthProviderOwned helper", () => { describe("assertAuthProviderOwned helper", () => {
let assertAuthProviderOwned: (conn: Client, providerId: string, userId: string) => Promise<void>; let assertAuthProviderOwned: (
conn: Client,
providerId: string,
userId: string
) => Promise<void>;
beforeEach(async () => { beforeEach(async () => {
const mod = await import("./nessa"); const mod = await import("./nessa");
@@ -121,7 +129,11 @@ describe("assertAuthProviderOwned helper", () => {
// Used by: updateExerciseLibrary, deleteExerciseLibrary // Used by: updateExerciseLibrary, deleteExerciseLibrary
describe("assertExerciseLibraryOwned helper", () => { describe("assertExerciseLibraryOwned helper", () => {
let assertExerciseLibraryOwned: (conn: Client, exerciseId: string, userId: string) => Promise<void>; let assertExerciseLibraryOwned: (
conn: Client,
exerciseId: string,
userId: string
) => Promise<void>;
beforeEach(async () => { beforeEach(async () => {
const mod = await import("./nessa"); const mod = await import("./nessa");
@@ -273,9 +285,7 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
]; ];
it("no mutation handler in the list uses async ({ input }) without ctx", async () => { it("no mutation handler in the list uses async ({ input }) without ctx", async () => {
const source = await Bun.file( const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
import.meta.dir + "/nessa.ts"
).text();
for (const name of MUTATIONS) { for (const name of MUTATIONS) {
// Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx // Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx
@@ -284,14 +294,15 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
"s" "s"
); );
const match = source.match(re); const match = source.match(re);
expect(match, `${name} should not use async ({ input }) — must use ctx`).toBeNull(); expect(
match,
`${name} should not use async ({ input }) — must use ctx`
).toBeNull();
} }
}); });
it("every mutation handler in the list references ctx", async () => { it("every mutation handler in the list references ctx", async () => {
const source = await Bun.file( const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
import.meta.dir + "/nessa.ts"
).text();
for (const name of MUTATIONS) { for (const name of MUTATIONS) {
// Find the block for this mutation and check it references ctx // Find the block for this mutation and check it references ctx
@@ -301,19 +312,16 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
); );
const match = source.match(re); const match = source.match(re);
expect(match, `${name} mutation block not found`).toBeTruthy(); expect(match, `${name} mutation block not found`).toBeTruthy();
expect( expect(match![0].includes("ctx"), `${name} must reference ctx`).toBe(
match![0].includes("ctx"), true
`${name} must reference ctx` );
).toBe(true);
} }
}); });
it("bulkUpsert filters exerciseLibrary by userId", async () => { it("bulkUpsert filters exerciseLibrary by userId", async () => {
const source = await Bun.file( const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
import.meta.dir + "/nessa.ts"
).text();
const bulkSection = source.match( const bulkSection = source.match(
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n \}/ /if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n {8}\}/
); );
expect(bulkSection).toBeTruthy(); expect(bulkSection).toBeTruthy();
expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId"); expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId");

View File

@@ -78,33 +78,25 @@ describe("verifyNessaToken with Clerk JWT", () => {
const { verifyNessaToken } = await import("./nessa-auth"); const { verifyNessaToken } = await import("./nessa-auth");
await expect( await expect(verifyNessaToken("token-without-subject")).rejects.toThrow(
verifyNessaToken("token-without-subject") /Missing subject/
).rejects.toThrow(/Missing subject/); );
}); });
it("rejects a malformed token", async () => { it("rejects a malformed token", async () => {
mockVerifyToken.mockRejectedValue( mockVerifyToken.mockRejectedValue(new Error("Invalid token format"));
new Error("Invalid token format")
);
const { verifyNessaToken } = await import("./nessa-auth"); const { verifyNessaToken } = await import("./nessa-auth");
await expect( await expect(verifyNessaToken("malformed-token")).rejects.toThrow();
verifyNessaToken("malformed-token")
).rejects.toThrow();
}); });
it("rejects an expired token", async () => { it("rejects an expired token", async () => {
mockVerifyToken.mockRejectedValue( mockVerifyToken.mockRejectedValue(new Error("Token has expired"));
new Error("Token has expired")
);
const { verifyNessaToken } = await import("./nessa-auth"); const { verifyNessaToken } = await import("./nessa-auth");
await expect( await expect(verifyNessaToken("expired-token")).rejects.toThrow(/expired/i);
verifyNessaToken("expired-token")
).rejects.toThrow(/expired/i);
}); });
it("rejects a token with wrong signature", async () => { it("rejects a token with wrong signature", async () => {
@@ -114,9 +106,9 @@ describe("verifyNessaToken with Clerk JWT", () => {
const { verifyNessaToken } = await import("./nessa-auth"); const { verifyNessaToken } = await import("./nessa-auth");
await expect( await expect(verifyNessaToken("wrong-key-token")).rejects.toThrow(
verifyNessaToken("wrong-key-token") /signature/i
).rejects.toThrow(/signature/i); );
}); });
}); });
@@ -128,7 +120,7 @@ describe("static audit: signNessaToken removed", () => {
it("nessa-auth.ts source does not reference the legacy JWT secret", async () => { it("nessa-auth.ts source does not reference the legacy JWT secret", async () => {
// Reassemble the legacy env-var name so this test itself does not contain // Reassemble the legacy env-var name so this test itself does not contain
// the literal token (keeps the source tree grep-clean per task 11). // the literal token (keeps the source tree grep-clean).
const legacyVar = ["NESSA", "JWT", "SECRET"].join("_"); const legacyVar = ["NESSA", "JWT", "SECRET"].join("_");
const source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text(); const source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text();
expect(source).not.toContain(legacyVar); expect(source).not.toContain(legacyVar);