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(() => []));
}
// ── Subdomain nav rendering (task 04) ─────────────────────────────────────
// ── Subdomain nav rendering ──────────────────────────────────────────────
//
// The main site retains its bespoke LeftBar / RightBarContent rendering
// unchanged (Recent Posts, auth-aware Account/Login/SignOut, admin links,
@@ -578,170 +578,170 @@ function MainLeftBarContent() {
<div class="text-text flex flex-1 flex-col px-4 pb-4 text-xl font-bold">
<div class="flex flex-col py-8">
<span class="text-lg font-semibold">Recent Posts</span>
<div class="flex max-h-[50dvh] flex-col gap-3 pt-4">
<Show
when={recentPosts()}
fallback={
<For each={[1, 2, 3]}>
{() => (
<div class="flex w-52 flex-col">
<div class="relative overflow-hidden">
<SkeletonBox class="float-right ml-2 h-12 w-16" />
<div class="flex flex-col">
<SkeletonText class="h-6 w-full" />
<SkeletonText class="mt-1.5 h-6 w-2/3" />
<div class="flex max-h-[50dvh] flex-col gap-3 pt-4">
<Show
when={recentPosts()}
fallback={
<For each={[1, 2, 3]}>
{() => (
<div class="flex w-52 flex-col">
<div class="relative overflow-hidden">
<SkeletonBox class="float-right ml-2 h-12 w-16" />
<div class="flex flex-col">
<SkeletonText class="h-6 w-full" />
<SkeletonText class="mt-1.5 h-6 w-2/3" />
</div>
</div>
<SkeletonText class="mt-1.5 h-6 w-40" />
<SkeletonText class="mt-1.5 h-4 w-1/2" />
</div>
<SkeletonText class="mt-1.5 h-6 w-40" />
<SkeletonText class="mt-1.5 h-4 w-1/2" />
</div>
)}
</For>
}
>
<For each={recentPosts()}>
{(post) => (
<a
href={`/blog/${post.title}`}
onClick={handleLinkClick}
class="hover:text-subtext0 block w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105 hover:font-bold"
>
<Typewriter class="flex flex-col" keepAlive={false}>
<div class="relative overflow-hidden">
<img
src={getThumbnailUrl(post.banner_photo)}
alt="post-cover"
class="float-right mb-1 ml-2 h-12 w-16 rounded object-cover"
onError={(e) => {
const img = e.currentTarget;
if (
img.src !==
(post.banner_photo || "/blueprint.jpg")
) {
img.src = post.banner_photo || "/blueprint.jpg";
}
}}
/>
<span class="inline wrap-break-word hyphens-auto">
{insertSoftHyphens(post.title.replace(/_/g, " "))}
</span>
</div>
<span class="text-subtext0 clear-both text-sm">
{new Date(post.date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric"
})}
</span>
</Typewriter>
</a>
)}
</For>
}
>
<For each={recentPosts()}>
{(post) => (
<a
href={`/blog/${post.title}`}
onClick={handleLinkClick}
class="hover:text-subtext0 block w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105 hover:font-bold"
>
<Typewriter class="flex flex-col" keepAlive={false}>
<div class="relative overflow-hidden">
<img
src={getThumbnailUrl(post.banner_photo)}
alt="post-cover"
class="float-right mb-1 ml-2 h-12 w-16 rounded object-cover"
onError={(e) => {
const img = e.currentTarget;
if (
img.src !==
(post.banner_photo || "/blueprint.jpg")
) {
img.src = post.banner_photo || "/blueprint.jpg";
}
}}
/>
<span class="inline wrap-break-word hyphens-auto">
{insertSoftHyphens(post.title.replace(/_/g, " "))}
</span>
</div>
<span class="text-subtext0 clear-both text-sm">
{new Date(post.date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric"
})}
</span>
</Typewriter>
</a>
)}
</For>
</Show>
</Show>
</div>
</div>
</div>
<div class="mt-auto">
<Typewriter keepAlive={false}>
<ul class="flex flex-col gap-4 pt-6">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/" onClick={handleLinkClick}>
Home
</a>
</li>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/blog" onClick={handleLinkClick}>
Blog
</a>
</li>
<Show when={isMounted() && isAdmin()}>
<div class="mt-auto">
<Typewriter keepAlive={false}>
<ul class="flex flex-col gap-4 pt-6">
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/analytics" onClick={handleLinkClick}>
Analytics
<a href="/" onClick={handleLinkClick}>
Home
</a>
</li>
</Show>
<li
class="hover:text-subtext0 w-fit cursor-pointer transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"
onClick={() => {
navigate("/account");
handleLinkClick();
}}
>
<Show
when={isMounted() && isAuthenticated()}
fallback={
<a href="/login" onClick={handleLinkClick}>
Login
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/blog" onClick={handleLinkClick}>
Blog
</a>
</li>
<Show when={isMounted() && isAdmin()}>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<a href="/analytics" onClick={handleLinkClick}>
Analytics
</a>
}
>
<A href="/account" onClick={handleLinkClick}>
Account
<Show when={email()}>
<span class="text-subtext0 text-sm font-normal">
{" "}
({email()})
</span>
</Show>
</A>
</li>
</Show>
</li>
<Show when={isMounted() && isAuthenticated()}>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<button
onClick={handleSignOut}
disabled={signOutLoading()}
class="text-left disabled:opacity-50"
<li
class="hover:text-subtext0 w-fit cursor-pointer transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"
onClick={() => {
navigate("/account");
handleLinkClick();
}}
>
<Show
when={isMounted() && isAuthenticated()}
fallback={
<a href="/login" onClick={handleLinkClick}>
Login
</a>
}
>
{signOutLoading() ? "Signing Out..." : "Sign Out"}
</button>
<A href="/account" onClick={handleLinkClick}>
Account
<Show when={email()}>
<span class="text-subtext0 text-sm font-normal">
{" "}
({email()})
</span>
</Show>
</A>
</Show>
</li>
</Show>
</ul>
</Typewriter>
<Show when={isMounted() && isAuthenticated()}>
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
<button
onClick={handleSignOut}
disabled={signOutLoading()}
class="text-left disabled:opacity-50"
>
{signOutLoading() ? "Signing Out..." : "Sign Out"}
</button>
</li>
</Show>
</ul>
</Typewriter>
<ul class="pt-4 pb-6">
<li
class="hover:text-subtext0 w-fit transition-all duration-500 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"
classList={{
"opacity-0 pointer-events-none": !getLostVisible(),
"opacity-100": getLostVisible()
}}
>
<button
onClick={() => {
const lostUrls = [
"/dev/null",
"/segfault",
"/void",
"/404",
"/lost-and-still-lost"
];
const randomUrl =
lostUrls[Math.floor(Math.random() * lostUrls.length)];
navigate(randomUrl);
handleLinkClick();
<ul class="pt-4 pb-6">
<li
class="hover:text-subtext0 w-fit transition-all duration-500 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold"
classList={{
"opacity-0 pointer-events-none": !getLostVisible(),
"opacity-100": getLostVisible()
}}
class="text-left font-mono transition-opacity duration-75"
style={{ "will-change": "contents" }}
>
{getLostText()}
</button>
</li>
</ul>
<button
onClick={() => {
const lostUrls = [
"/dev/null",
"/segfault",
"/void",
"/404",
"/lost-and-still-lost"
];
const randomUrl =
lostUrls[Math.floor(Math.random() * lostUrls.length)];
navigate(randomUrl);
handleLinkClick();
}}
class="text-left font-mono transition-opacity duration-75"
style={{ "will-change": "contents" }}
>
{getLostText()}
</button>
</li>
</ul>
<hr class="border-overlay0 -mx-4 my-auto" />
<div class="my-auto">
<DarkModeToggle />
</div>
<hr class="border-overlay0 -mx-4 my-auto" />
<div class="my-auto">
<DarkModeToggle />
</div>
<div class="border-overlay0 -mx-4 border-t pt-8 md:hidden">
<RightBarContent />
<div class="border-overlay0 -mx-4 border-t pt-8 md:hidden">
<RightBarContent />
</div>
</div>
</div>
</div>
</>
);
}
@@ -828,9 +828,7 @@ export function LeftBar() {
// ("bars render appropriately styled per site — brand color hint from
// SITE_CONFIG"). Main keeps the existing neutral styling.
const accentBorder = () =>
site().id === "main"
? undefined
: { "border-color": site().brandColor };
site().id === "main" ? undefined : { "border-color": site().brandColor };
return (
<nav

View File

@@ -1,10 +1,4 @@
import {
createSignal,
onMount,
createEffect,
Show,
type JSX
} from "solid-js";
import { createSignal, onMount, createEffect, Show, type JSX } from "solid-js";
import { useSearchParams, query, createAsync } from "@solidjs/router";
import { action, redirect } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
@@ -43,7 +37,7 @@ import {
} 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
* main-site contact flow (`freno.me/contact`) keeps its exact Turnstile +
@@ -57,10 +51,10 @@ import {
* `CONTACT_CONTEXT[site().id]` (subjectPrefix, recipientLabel, heading,
* PageHead title + description). Props override the defaults.
* - 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
* subdomains — ensure it is configured for `*.freno.me` in the Cloudflare
* Turnstile dashboard (see task notes).
* Turnstile dashboard.
*
* Email routing:
* - JS path: `api.misc.sendContactRequest.mutate({ …, subjectPrefix })` — the
@@ -247,7 +241,8 @@ export function ContactForm(props: ContactFormProps) {
const ctx = () => getContactContext(site().id);
// Effective values — props override the site-context defaults.
const effectiveSubjectPrefix = () => props.subjectPrefix ?? ctx().subjectPrefix;
const effectiveSubjectPrefix = () =>
props.subjectPrefix ?? ctx().subjectPrefix;
const effectiveRecipientLabel = () =>
props.recipientLabel ?? ctx().recipientLabel;
const effectiveHeading = () => props.heading ?? ctx().heading;
@@ -283,7 +278,7 @@ export function ContactForm(props: ContactFormProps) {
// Load Cloudflare Turnstile script with explicit rendering.
// 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");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;

View File

@@ -6,7 +6,7 @@ import { getClientCookie } from "~/lib/cookies.client";
/**
* Product discriminator forwarded to the generalized
* `misc.sendDeletionRequestEmail` mutation so the email copy + cooldown
* cookie are product-appropriate (task 11).
* cookie are product-appropriate.
*/
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
* 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) {
it(`${id} → title is "${BASE_PROPS.title}${suffix}"`, () => {
const meta = resolvePageHeadMeta(
BASE_PROPS,
SITE_CONFIG[id],
"/blog"
);
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG[id], "/blog");
expect(meta.title).toBe(`${BASE_PROPS.title}${suffix}`);
});
}
it("main produces 'Home | Michael Freno' for the homepage", () => {
const meta = resolvePageHeadMeta(
{ title: "Home" },
SITE_CONFIG.main,
"/"
);
const meta = resolvePageHeadMeta({ title: "Home" }, SITE_CONFIG.main, "/");
expect(meta.title).toBe("Home | Michael Freno");
});
});
@@ -64,11 +56,7 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
});
it("nessa /contact → https://nessa.freno.me/contact", () => {
const meta = resolvePageHeadMeta(
BASE_PROPS,
SITE_CONFIG.nessa,
"/contact"
);
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/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.
*
* 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
* renders `" | Michael Freno"` / `" | Nessa"` / … depending on the active
* 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
* 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
* tree (task 01 keystone).
* tree (keystone).
*
* Resolution strategy:
* - 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),
NESSA_DB_URL: z.string().min(1),
NESSA_DB_TOKEN: z.string().min(1),
// Clerk authentication — Nessa auth is now Clerk-backed (task 02). The
// legacy self-issued JWT signing env var was removed in task 11.
// Clerk authentication — Nessa auth is now Clerk-backed. The
// legacy self-issued JWT signing env var was removed.
NESSA_CLERK_SECRET: z.string().min(1),
NESSA_CLERK_JWT_ISSUER: z.string().min(1),
// 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:
* `contact-config.ts` is a pure module (no solid-js / @solidjs/router /
* @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
* subdomain).
* - 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 —
* 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
* (backwards compatibility for any inbox filters / saved searches). Each
* 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.
* - All mail is delivered to `michael@freno.me` (single owner across every
* 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). */
export const CONTACT_RECIPIENT_EMAIL = "michael@freno.me";
/** 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 {
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,
* so these tests verify the tRPC call shape, redirect, and error handling
@@ -54,7 +54,12 @@ describe("downloadAsset", () => {
const sink = mock((u: string) => {});
const errSink = mock((e: unknown) => {});
await expect(
downloadAsset({ api, assetName: "gaze", redirect: sink, onError: errSink })
downloadAsset({
api,
assetName: "gaze",
redirect: sink,
onError: errSink
})
).resolves.toBeUndefined();
expect(sink).not.toHaveBeenCalled();
expect(errSink).toHaveBeenCalledTimes(1);

View File

@@ -2,7 +2,7 @@
* Pure, testable helper for triggering a signed-S3 download via the tRPC
* `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,
* …) — can share a single code path AND be unit-tested without importing
* `~/lib/api` (which transitively imports solid-js / CSRF cookie access).
@@ -58,12 +58,7 @@ const defaultRedirect: DownloadRedirect = (url) => {
export async function downloadAsset(
options: DownloadAssetOptions
): Promise<void> {
const {
api,
assetName,
redirect = defaultRedirect,
onError
} = options;
const { api, assetName, redirect = defaultRedirect, onError } = options;
try {
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
* these mirror the acceptance matrix directly. Integration / visual checks
* (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 {
@@ -184,8 +184,12 @@ describe("filterNavByAuth", () => {
});
describe("BACK_TO_FRENO", () => {
it("links to the apex freno.me and is external", () => {
expect(BACK_TO_FRENO.href).toBe("https://freno.me");
it("links to the apex site (derived from VITE_DOMAIN) and is external", () => {
// 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.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 —
* 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.
*/
import type { SiteId } from "~/lib/site-context";
import { buildMainSiteUrl } from "~/lib/subdomain-url";
/** Icon keys resolved by the bar renderer to inline SVGs. */
export type NavIcon =
@@ -57,7 +58,7 @@ export interface NavItem {
/** Apex/host link used as a "back to freno.me" affordance on subdomains. */
export const BACK_TO_FRENO: NavItem = {
label: "back to freno.me",
href: "https://freno.me",
href: buildMainSiteUrl("/"),
icon: "back",
external: true
};
@@ -65,7 +66,7 @@ export const BACK_TO_FRENO: NavItem = {
/**
* 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
* - nessa: Home, Contact, Privacy
* - 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
* straightforward synchronous assertions mirroring the acceptance matrix in
* the task spec.
* straightforward synchronous assertions mirroring the acceptance matrix.
*/
import { describe, it, expect } from "bun:test";
import {

View File

@@ -1,11 +1,13 @@
/**
* Shared site definitions and host-to-site resolver.
*
* Pure module — intentionally imports NO env / server-only code — so it is
* safe to import from both server and client (and from unit tests).
* Near-pure module — reads `import.meta.env.VITE_DOMAIN` (a Vite build-time
* 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
* content task (05-11) consumes `SITE_CONFIG` metadata via `useSite()`,
* This is the keystone of the subdomain-routing feature. Every
* content module consumes `SITE_CONFIG` metadata via `useSite()`,
* and the server-side host detection in
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
*/
@@ -37,11 +39,36 @@ export interface Site {
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> = {
main: {
id: "main",
subdomain: "",
domain: "freno.me",
domain: BASE_DOMAIN,
baseRoutePrefix: "",
displayName: "Michael Freno",
titleSuffix: " | Michael Freno",
@@ -52,7 +79,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
nessa: {
id: "nessa",
subdomain: "nessa",
domain: "nessa.freno.me",
domain: `nessa.${BASE_DOMAIN}`,
baseRoutePrefix: "/nessa",
displayName: "Nessa",
titleSuffix: " | Nessa",
@@ -63,7 +90,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
lineage: {
id: "lineage",
subdomain: "lineage",
domain: "lineage.freno.me",
domain: `lineage.${BASE_DOMAIN}`,
baseRoutePrefix: "/lineage",
displayName: "Life and Lineage",
titleSuffix: " | Life and Lineage",
@@ -74,7 +101,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
gaze: {
id: "gaze",
subdomain: "gaze",
domain: "gaze.freno.me",
domain: `gaze.${BASE_DOMAIN}`,
baseRoutePrefix: "/gaze",
displayName: "Gaze",
titleSuffix: " | Gaze",
@@ -85,7 +112,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
inputhalo: {
id: "inputhalo",
subdomain: "inputhalo",
domain: "inputhalo.freno.me",
domain: `inputhalo.${BASE_DOMAIN}`,
baseRoutePrefix: "/inputhalo",
displayName: "InputHalo",
titleSuffix: " | InputHalo",
@@ -103,8 +130,6 @@ const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
SITE_CONFIG.inputhalo
];
const BASE_DOMAIN = "freno.me";
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */
const DEV_HOST_RE = /^([a-z0-9-]+)\.localhost$/i;
@@ -186,7 +211,7 @@ export function resolveSiteFromPath(
for (const site of SUBDOMAIN_SITES) {
const prefix = site.baseRoutePrefix; // e.g. "/nessa"
// Exact prefix (`/nessa`) or prefix + `/` (`/nessa/contact`).
if (pathname === prefix || pathname.startsWith(prefix + "/")) {
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
return site;
}
}
@@ -210,3 +235,63 @@ export function resolveSiteFromLocation(
if (hostResult.id !== "main") return 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:
* - `generateSitemap(site, entries)` returns correct XML for each site
@@ -30,7 +30,9 @@ describe("generateSitemap", () => {
// Basic structure
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
const locs = extractLocs(xml);
@@ -101,7 +103,10 @@ describe("generateSitemap", () => {
});
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);
expect(locs).toContain("https://inputhalo.freno.me/");

View File

@@ -19,7 +19,14 @@ export interface SitemapEntry {
/**
* Expected change frequency.
*/
changefreq: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
changefreq:
| "always"
| "hourly"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "never";
/**
* Relative priority (0.01.0).
@@ -31,7 +38,7 @@ export interface SitemapEntry {
* Per-site sitemap route definitions.
*
* 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[]> = {
main: [
@@ -44,7 +51,7 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
],
// ── Subdomain sites ──────────────────────────────────────────────────
// Populated as pages land in tasks 0511.
// Populated as pages land.
nessa: [
{ 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 RevealDropDown from "~/components/RevealDropDown";
import { ContactForm } from "~/components/ContactForm";
import { buildSubdomainUrl } from "~/lib/site-context";
/**
* 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
* submission all live in the shared component now. This route remains a thin
* wrapper that supplies:
@@ -51,7 +52,7 @@ export function LineageContactQuestions(): JSX.Element {
<div class="pb-2">
You can find the entire privacy policy{" "}
<A
href="https://lineage.freno.me/privacy"
href={buildSubdomainUrl("lineage", "/privacy")}
class="text-blue underline-offset-4 hover:underline"
>
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
* to `lineage.freno.me/deletion`. Because the route file is a SolidStart
@@ -23,7 +23,7 @@ const SOURCE = readFileSync(
"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)", () => {
expect(SOURCE).toContain("export function GET()");
expect(SOURCE).not.toContain("export default function");

View File

@@ -1,6 +1,6 @@
/**
* 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`
* 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
* 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)", () => {
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
// origins serve the byte-identical S3 object (`Life and Lineage.apk`).
expect(SOURCE).toContain('download("lineage")');

View File

@@ -3,6 +3,7 @@ import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup } from "solid-js";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import { glitchText } from "~/lib/client-utils";
import { buildSubdomainUrl } from "~/lib/subdomain-url";
import Button from "~/components/ui/Button";
export default function DownloadsPage() {
@@ -93,7 +94,13 @@ export default function DownloadsPage() {
{/* InputHalo */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {inputHaloText()}
<span class="text-yellow">{">"}</span>{" "}
<A
href={buildSubdomainUrl("inputhalo")}
class="text-text hover:text-yellow transition-colors"
>
{inputHaloText()}
</A>
</h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
@@ -139,7 +146,13 @@ export default function DownloadsPage() {
{/* Gaze */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {gazeText()}
<span class="text-yellow">{">"}</span>{" "}
<A
href={buildSubdomainUrl("gaze")}
class="text-text hover:text-yellow transition-colors"
>
{gazeText()}
</A>
</h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
@@ -183,7 +196,13 @@ export default function DownloadsPage() {
</div>
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {LaLText()}
<span class="text-yellow">{">"}</span>{" "}
<A
href={buildSubdomainUrl("lineage")}
class="text-text hover:text-yellow transition-colors"
>
{LaLText()}
</A>
</h2>
<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`).
*
* 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 —
* is derived from `useSite()` inside the component via `CONTACT_CONTEXT.gaze`,
* 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`
* 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
* (`https://gaze.freno.me/privacy`), and OG image derive automatically — we
* only pass the base title.

View File

@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
/**
* 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
* metadata — is derived from `useSite()` inside the component via
* `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),
* 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
* 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 {
@@ -49,12 +49,10 @@ describe("InputHalo download constants", () => {
describe("queryInputHaloDownload", () => {
it("calls the query with asset_name 'inputhalo' and returns the signed URL", async () => {
const query = mock(
(async (input: { asset_name: string }) => {
expect(input.asset_name).toBe("inputhalo");
return { downloadURL: "https://s3.example.com/InputHalo.dmg?signed=1" };
}) as DownloadQueryApi
);
const query = mock((async (input: { asset_name: string }) => {
expect(input.asset_name).toBe("inputhalo");
return { downloadURL: "https://s3.example.com/InputHalo.dmg?signed=1" };
}) as DownloadQueryApi);
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";
it("redirects to the signed S3 URL returned by the query", async () => {
const query = mock(
(async () => ({ downloadURL: SIGNED_URL })) as DownloadQueryApi
);
const query = mock((async () => ({
downloadURL: SIGNED_URL
})) as DownloadQueryApi);
const redirect = mock((url: string) => url);
const ok = await performInputHaloDownload(query, redirect);

View File

@@ -1,6 +1,6 @@
/**
* Pure, side-effect-free download orchestration for the InputHalo landing
* page (task 06).
* page.
*
* Extracted from the route component so the acceptance criterion —
* "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
* stub.
*/
export interface DownloadQueryApi {
(input: { asset_name: string }): Promise<{ downloadURL: string }>;
}
export type DownloadQueryApi = (input: { asset_name: string }) => Promise<{ downloadURL: string }>
/**
* 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 { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import { buildMainSiteUrl } from "~/lib/site-context";
import Button from "~/components/ui/Button";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
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">
<span>{site().displayName}</span>
<A
href="https://freno.me"
href={buildMainSiteUrl()}
class="hover:text-text underline-offset-4 hover:underline"
>
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
* menu bar application (`LSUIElement: true`,
* `LSApplicationCategoryType: public.app-category.productivity`) — a
* productivity utility that lives in the system menu bar. Following the task
* notes, Gaze&apos;s privacy policy is the template for macOS menu bar apps
* productivity utility that lives in the system menu bar. 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
* structure and language while describing InputHalo&apos;s own practices.
*
@@ -16,7 +15,7 @@
* - Settings and any cached state are stored locally using standard
* 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
* OG image are derived automatically. Internal links use public
* 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`).
*
* 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
* — is derived from `useSite()` inside the component via
* `CONTACT_CONTEXT.lineage`.

View File

@@ -1,9 +1,9 @@
/**
* 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
* 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).
* - Cooldown cookie name is the legacy `deletionRequestSent` so an in-flight
* cooldown survives the `/deletion/life-and-lineage` → subdomain redirect.
@@ -26,14 +26,14 @@ import {
} from "~/server/api/routers/deletion-email";
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");
});
it("is accepted by the server-side product schema", () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe(
true
);
expect(
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");
});
it("matches the server-side deletionCookieName(\"lineage\")", () => {
expect(DELETION_COOKIE_NAME).toBe(
deletionCookieName(DELETION_PRODUCT_KEY)
);
it('matches the server-side deletionCookieName("lineage")', () => {
expect(DELETION_COOKIE_NAME).toBe(deletionCookieName(DELETION_PRODUCT_KEY));
});
});
@@ -79,14 +77,13 @@ describe("Lineage deletion — PageHead inputs", () => {
});
describe("Lineage deletion — legacy redirect target", () => {
it("points at the lineage subdomain deletion URL", () => {
expect(LEGACY_DELETION_REDIRECT_TARGET).toBe(
"https://lineage.freno.me/deletion"
);
});
it("is an absolute https URL", () => {
expect(LEGACY_DELETION_REDIRECT_TARGET.startsWith("https://")).toBe(true);
it("points at the lineage subdomain deletion URL (derived from VITE_DOMAIN)", () => {
// LEGACY_DELETION_REDIRECT_TARGET is now dynamically derived from
// 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");
expect(LEGACY_DELETION_REDIRECT_TARGET).toContain("/deletion");
});
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
* (task 11).
* (see `./deletion.tsx`).
*
* Imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so the
* constants here can be unit-tested in `bun:test` without spinning up the
* router / MetaProvider, mirroring the `landing-content.ts` /
* `downloads-content.ts` pattern.
*
* Cross-task contracts encoded here:
* 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
* generalized `misc.sendDeletionRequestEmail` mutation
* (`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`
* mutation. The Lineage flow is the original / default product.
*/
import { buildSubdomainUrl } from "~/lib/site-context";
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
* 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
* runtime).
*/
export const LEGACY_DELETION_REDIRECT_TARGET =
"https://lineage.freno.me/deletion";
export const LEGACY_DELETION_REDIRECT_TARGET = buildSubdomainUrl(
"lineage",
"/deletion"
);

View File

@@ -1,14 +1,14 @@
/**
* 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
* 308 redirect to this public URL — see `LEGACY_DELETION_REDIRECT_TARGET`).
*
* Served at the public browser path `/deletion` (vercel.json host rewrites
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
* browser URL clean — task 02 canonical rule). The nav-config "Account
* Deletion" entry points at this path (task 04).
* browser URL clean. The nav-config "Account
* Deletion" entry points at this path.
*
* Deletion flow:
* - Reuses the shared `DeletionForm` component, now generalized to forward
@@ -25,7 +25,7 @@
*
* Site-awareness:
* - `<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
* 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
* 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:
* - 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

View File

@@ -1,6 +1,6 @@
/**
* 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`
* 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 /
* 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"`) → resolves to `Life and Lineage.apk` in
* `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.
* - `LINEAGE_DOWNLOADS_META` is consumed verbatim by `<PageHead>`; the
* per-site title suffix (` | Life and Lineage`) is appended automatically
* by `resolvePageHeadMeta` (task 02), so `title` here is the BASE title
* by `resolvePageHeadMeta`, so `title` here is the BASE title
* only — do NOT include the suffix.
*/
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).
*
* 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 = "/";

View File

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

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
* 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:
* - App Store link is present and correct
* - Google Play / downloads link targets the subdomain `/downloads` path
* (public browser path, NOT the vercel-rewritten `/lineage/downloads`)
* - 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
* Lineage subdomain.
*
@@ -46,7 +46,7 @@ describe("Lineage landing — downloads link", () => {
it("targets the subdomain-relative public browser path", () => {
// NOT `/lineage/downloads` (the internal vercel-rewrite prefix) — vercel
// 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");
});
@@ -109,9 +109,7 @@ describe("Lineage landing — feature highlights", () => {
});
it("covers the dark-fantasy / mobile / saves / PvP themes", () => {
const blob = FEATURES.map(
(f) => `${f.title} ${f.description}`
)
const blob = FEATURES.map((f) => `${f.title} ${f.description}`)
.join(" ")
.toLowerCase();
expect(blob).toContain("dark fantasy");
@@ -122,12 +120,16 @@ describe("Lineage landing — feature highlights", () => {
});
describe("Lineage landing — legacy redirect target", () => {
it("points at the lineage subdomain apex", () => {
expect(LEGACY_REDIRECT_TARGET).toBe("https://lineage.freno.me");
it("points at the lineage subdomain (derived from VITE_DOMAIN)", () => {
// 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", () => {
expect(LEGACY_REDIRECT_TARGET.startsWith("https://")).toBe(true);
it("has no trailing slash", () => {
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
* 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
* a DOM render.
*
* Cross-task contracts encoded here:
* Contracts encoded here:
* - `APP_STORE_URL` is the canonical App Store link the marketing page has
* always surfaced (kept stable across the migration).
* - `DOWNLOADS_HREF` is the **public browser path** on the lineage subdomain
* (`/downloads`), NOT the internal vercel-rewritten prefix `/lineage/downloads`.
* This matches the canonical-URL rule from task 02 and the nav-config rule
* from task 04: vercel.json maps `lineage.freno.me/downloads` →
* This matches the canonical-URL rule and the nav-config rule
* from the spec: vercel.json maps `lineage.freno.me/downloads` →
* `/lineage/downloads` server-side while the browser sees `/downloads`.
* Task 11 will create the matching `src/routes/lineage/downloads.tsx`.
* - `PAGE_META` is consumed verbatim by `<PageHead>`; the per-site title
* 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.
*/
import { buildSubdomainUrl } from "~/lib/site-context";
/** Apple App Store link — surfaced unchanged from the legacy marketing page. */
export const APP_STORE_URL =
"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.
*/
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
* (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
* `src/routes/privacy-policy/life-and-lineage.tsx` route so there is zero
* content loss; the old route now 308-redirects here (see
* `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 —
* 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
* path** `/deletion` (vercel.json rewrites to `/lineage/deletion`). The
* 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
* subdomain landing page at `gaze.freno.me`.
* Legacy `/marketing/gaze` route — redirected to the new Gaze
* subdomain landing page.
*
* Kept as a permanent 308 redirect so existing inbound links keep resolving
* to the canonical Gaze marketing home.
@@ -14,7 +16,7 @@ export default function GazeMarketingRedirect(): never {
throw new Response(null, {
status: 308,
headers: {
Location: "https://gaze.freno.me",
Location: buildSubdomainUrl("gaze"),
"Cache-Control": "public, max-age=86400"
}
});

View File

@@ -1,6 +1,6 @@
/**
* 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`
* 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`).
*
* 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 —
* is derived from `useSite()` inside the component via
* `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 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 =
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
@@ -152,35 +152,21 @@ export const PRICING: readonly PricingTier[] = [
}
] as const;
export interface ComparisonRow {
feature: string;
strava: string;
nessa: string;
}
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"
}
export const COMPARISON: readonly { feature: string; nessa: string }[] = [
{ feature: "Segment leaderboards", nessa: "Free forever" },
{ feature: "Privacy", nessa: "On-device first" },
{ feature: "Premium price", nessa: "From $4.99/mo" },
{ feature: "Apple Watch", nessa: "Native experience" }
] as const;
export const WHY_NESSA = [
{
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",
body: "Premium tiers at 5060% of Strava's cost, with no surprise paywalls."
title: "Affordable premium",
body: "Premium tiers start at $4.99/mo with no surprise paywalls."
},
{
title: "Privacy-first",

View File

@@ -1,9 +1,9 @@
/**
* 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
* 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:
* - Product discriminator is `"nessa"` (selects Nessa-branded email).
* - Cooldown cookie name is Nessa-specific + matches the server-side
@@ -26,21 +26,21 @@ import {
describe("Nessa deletion — assessment outcome", () => {
it("defines a product discriminator (deletion flow IS implemented)", () => {
// Nessa stores user data (nessa.ts: users, workouts, workoutPlans, … +
// nessa-community.ts: clubs, clubMemberships). Per task 11 spec step 5,
// nessa-community.ts: clubs, clubMemberships).
// a deletion flow IS needed — this page provides it.
expect(typeof DELETION_PRODUCT_KEY).toBe("string");
});
});
describe("Nessa deletion — product discriminator", () => {
it("is \"nessa\" (selects Nessa-branded email)", () => {
it('is "nessa" (selects Nessa-branded email)', () => {
expect(DELETION_PRODUCT_KEY).toBe("nessa");
});
it("is accepted by the server-side product schema", () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe(
true
);
expect(
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");
});
it("matches the server-side deletionCookieName(\"nessa\")", () => {
expect(DELETION_COOKIE_NAME).toBe(
deletionCookieName(DELETION_PRODUCT_KEY)
);
it('matches the server-side deletionCookieName("nessa")', () => {
expect(DELETION_COOKIE_NAME).toBe(deletionCookieName(DELETION_PRODUCT_KEY));
});
it("does NOT collide with the Lineage cooldown cookie", () => {

View File

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

View File

@@ -1,10 +1,10 @@
/**
* 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
* `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
* 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
* session), NOT an authenticated self-delete. The nav-config does NOT list
* a Nessa deletion link by default, so this page is reachable by direct URL
* + from the Nessa privacy policy (task-provided).
* + from the Nessa privacy policy.
*
* 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
* `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.
*
* 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 { useSite } from "~/context/SiteContext";
import { A } from "@solidjs/router";
import { buildMainSiteUrl } from "~/lib/site-context";
import { NESSA_LANDING_META } from "./meta";
import {
TAGLINE,
@@ -158,7 +159,7 @@ export default function NessaLanding() {
<div class="mx-auto max-w-6xl">
<div class="mb-12 text-center">
<h2 class="text-3xl font-bold md:text-4xl">
Premium features, half the price of Strava
Premium features, affordable pricing
</h2>
<p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg">
Choose the plan that fits your training.
@@ -229,20 +230,20 @@ export default function NessaLanding() {
</div>
</section>
{/* ─── Comparison vs Strava ─────────────────────────────────── */}
{/* ─── Feature highlights ─────────────────────────────────── */}
<section class="relative z-10 px-4 py-16">
<div class="mx-auto max-w-4xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">
See how we compare
What you get with Nessa
</h2>
<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>
<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 class="text-center">Strava</span>
<span class="text-center" style={{ color: brandColor() }}>
Nessa
</span>
@@ -250,13 +251,12 @@ export default function NessaLanding() {
<For each={COMPARISON}>
{(row, idx) => (
<div
class="grid grid-cols-3 px-6 py-4 text-sm"
class="grid grid-cols-2 px-6 py-4 text-sm"
classList={{
"bg-surface0/20": idx() % 2 === 1
}}
>
<span>{row.feature}</span>
<span class="text-text/70 text-center">{row.strava}</span>
<span
class="text-center font-medium"
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">
<span>{site().displayName}</span>
<A
href="https://freno.me"
href={buildMainSiteUrl()}
class="hover:text-text underline-offset-4 hover:underline"
>
freno.me

View File

@@ -4,7 +4,7 @@
* Mirrors the `page-head-meta.ts` testability pattern — `nessa/meta.ts` is a
* pure module (no solid-js / @solidjs/router / @solidjs/meta imports) so
* `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`).
*/
import { describe, it, expect } from "bun:test";
@@ -72,13 +72,13 @@ describe("Nessa landing page — PageHead metadata", () => {
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(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
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
* `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`).
* The description still mentions community features (clubs, challenges) because
* 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.",
ogTitle: "Nessa — The fitness app that puts you first",
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
* Lineage policy (the template for products with user accounts, per the task
* notes) but scoped to Nessa's real data practices:
* Lineage policy (the template for products with user accounts) but scoped to Nessa's real data practices:
*
* - Authentication: user accounts are managed by 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
* 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
* are derived automatically. Internal links use public subdomain-relative
* 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
* 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
* and the associated Nessa community content are then purged manually until a
* 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
* subdomain (task 10).
* subdomain.
*
* The privacy policy content has been migrated to
* `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
* redirect — rather than a client `<Navigate>` — preserves SEO equity and
* 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)
* so the redirect happens before any rendering; the route no longer ships a
@@ -18,7 +20,7 @@ export function GET() {
return new Response(null, {
status: 308,
headers: {
Location: "https://gaze.freno.me/privacy",
Location: buildSubdomainUrl("gaze", "/privacy"),
"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
* to the Lineage subdomain (task 10).
* to the Lineage subdomain.
*
* The privacy policy content has been migrated to
* `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
* redirect — rather than a client `<Navigate>` — preserves SEO equity and
* 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)
* so the redirect happens before any rendering; the route no longer ships a
@@ -18,7 +20,7 @@ export function GET() {
return new Response(null, {
status: 308,
headers: {
Location: "https://lineage.freno.me/privacy",
Location: buildSubdomainUrl("lineage", "/privacy"),
"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
* sitemap scoped to that site's routes with canonical URLs from the
* corresponding domain.
*/
import { APIEvent } from "@solidjs/start/server";
import type { APIEvent } from "@solidjs/start/server";
import { getSiteFromEvent } from "~/server/site-context-server";
import { SITEMAP_ROUTES } from "~/lib/sitemap-routes";
import { generateSitemap } from "~/lib/sitemap-generate";

View File

@@ -1,6 +1,6 @@
/**
* 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
* `misc.sendDeletionRequestEmail` tRPC mutation (re-exported from `misc.ts`).
@@ -25,11 +25,11 @@ import {
} from "~/server/api/routers/deletion-email";
describe("DELETION_PRODUCT_SCHEMA", () => {
it("accepts \"lineage\"", () => {
it('accepts "lineage"', () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse("lineage").success).toBe(true);
});
it("accepts \"nessa\"", () => {
it('accepts "nessa"', () => {
expect(DELETION_PRODUCT_SCHEMA.safeParse("nessa").success).toBe(true);
});
@@ -50,9 +50,7 @@ describe("deletionCookieName", () => {
});
it("returns distinct names per product", () => {
expect(deletionCookieName("lineage")).not.toBe(
deletionCookieName("nessa")
);
expect(deletionCookieName("lineage")).not.toBe(deletionCookieName("nessa"));
});
});

View File

@@ -1,6 +1,6 @@
/**
* 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
* 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
* email identifies the account being deleted + the 24h cancellation window.
* The `product` discriminator switches branding between Lineage (the original
* flow) and Nessa (task 11 — Nessa stores user data in its own Turso DB).
* flow) and Nessa (Nessa stores user data in its own Turso DB).
*
* `email` is interpolated verbatim into the HTML bodies. It has already been
* validated as a well-formed email by the tRPC input schema, and Sendinblue

View File

@@ -50,7 +50,7 @@ mock.module("~/env/server", () => ({
TURSO_DB_API_TOKEN: "test-token",
NESSA_DB_URL: "libsql://nessa-test.turso.io",
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_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
// functions used by web and Lineage surfaces respectively.
const { verifyAuthToken, verifyLineageAuthToken } = await import(
"~/server/auth"
);
const { verifyAuthToken, verifyLineageAuthToken } =
await import("~/server/auth");
// Issuer/audience claims the Lineage router stamps onto its tokens.
const { LINEAGE_CONFIG } = await import("~/config");

View File

@@ -1,10 +1,10 @@
/**
* 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
* 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
* 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) {
it(`${proc} is not declared as 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);
expect(m, `${proc} declaration not found`).not.toBeNull();
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", () => {
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![1]).toBe("publicProcedure");
});
@@ -153,7 +157,9 @@ describe("p8-001 / p8-008 static source audit", () => {
// Both simpleDeleteImage and deleteImage must call the ownership guard.
const deleteBlocks = SOURCE.split(/(\bsimpleDeleteImage:|\bdeleteImage:)/);
// 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);
});
});

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-
@@ -368,7 +368,7 @@ export const miscRouter = createTRPCRouter({
turnstileToken: z.string().min(1, "Please complete the security check"),
/**
* 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
* subject `"freno.me Contact Request"`.
*/
@@ -499,7 +499,7 @@ export const miscRouter = createTRPCRouter({
.input(
z.object({
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")
})
)

View File

@@ -65,9 +65,15 @@ function initSchema() {
db = new Database(":memory:");
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("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)");
db.run(
"CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt 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() {
@@ -86,7 +92,17 @@ function seed() {
// Challenge CH in club C, created by A.
db.run(
"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 () => {
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 () => {
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", () => {
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 () => {
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
it("getPost/addComment/comments/like/unlike: non-member B rejected with FORBIDDEN", async () => {
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 () => {
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
it("challenges.leave / submitProgress: non-member B rejected with FORBIDDEN", async () => {
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 () => {
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 () => {
// Initially blocked.
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.
db.run(
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
["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.
db.run("DELETE FROM clubMemberships WHERE clubId = ? AND userId = ?", [
CLUB_C,
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,
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> {
@@ -246,31 +286,34 @@ describe("clerkUserId lookup (migrate-to-clerk-auth-03)", () => {
});
it("resolves local users.id for a seeded clerkUserId", async () => {
db.run(
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
[LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID]
);
db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
LOCAL_USER_A,
"a@nessa.app",
CLERK_USER_ID
]);
expect(await resolveLocalUserId(CLERK_USER_ID)).toBe(LOCAL_USER_A);
});
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();
});
it("returns null for a Clerk id that exists but maps to a different local user", async () => {
db.run(
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
[LOCAL_USER_B, "b@nessa.app", "user_test_other"]
);
db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
LOCAL_USER_B,
"b@nessa.app",
"user_test_other"
]);
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
});
it("ctx.nessaUserId is the LOCAL id, never the Clerk sub", async () => {
db.run(
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
[LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID]
);
db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
LOCAL_USER_A,
"a@nessa.app",
CLERK_USER_ID
]);
const resolved = await resolveLocalUserId(CLERK_USER_ID);
expect(resolved).toBe(LOCAL_USER_A);
expect(resolved).not.toBe(CLERK_USER_ID);

View File

@@ -23,7 +23,7 @@ mock.module("~/env/server", () => ({
TURSO_LINEAGE_TOKEN: "test-token",
TURSO_DB_API_TOKEN: "test-token",
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_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
},
@@ -55,7 +55,11 @@ const PROVIDER_ID = "prov-1";
// create/update/deleteWorkoutSplit
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 () => {
const mod = await import("./nessa");
@@ -64,16 +68,16 @@ describe("assertWorkoutOwned helper", () => {
it("rejects when workout belongs to another user", async () => {
const conn = makeMockConn([{ userId: USER_B }]);
await expect(
assertWorkoutOwned(conn, WORKOUT_ID, USER_A)
).rejects.toThrow(/owner/);
await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
/owner/
);
});
it("rejects when workout does not exist", async () => {
const conn = makeMockConn([]);
await expect(
assertWorkoutOwned(conn, WORKOUT_ID, USER_A)
).rejects.toThrow(/not found/i);
await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
/not found/i
);
});
it("succeeds when workout belongs to the caller", async () => {
@@ -88,7 +92,11 @@ describe("assertWorkoutOwned helper", () => {
// Used by: updateAuthProvider, deleteAuthProvider
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 () => {
const mod = await import("./nessa");
@@ -121,7 +129,11 @@ describe("assertAuthProviderOwned helper", () => {
// Used by: updateExerciseLibrary, deleteExerciseLibrary
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 () => {
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 () => {
const source = await Bun.file(
import.meta.dir + "/nessa.ts"
).text();
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
for (const name of MUTATIONS) {
// Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx
@@ -284,14 +294,15 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
"s"
);
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 () => {
const source = await Bun.file(
import.meta.dir + "/nessa.ts"
).text();
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
for (const name of MUTATIONS) {
// 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);
expect(match, `${name} mutation block not found`).toBeTruthy();
expect(
match![0].includes("ctx"),
`${name} must reference ctx`
).toBe(true);
expect(match![0].includes("ctx"), `${name} must reference ctx`).toBe(
true
);
}
});
it("bulkUpsert filters exerciseLibrary by userId", async () => {
const source = await Bun.file(
import.meta.dir + "/nessa.ts"
).text();
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
const bulkSection = source.match(
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n \}/
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n {8}\}/
);
expect(bulkSection).toBeTruthy();
expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId");

View File

@@ -78,33 +78,25 @@ describe("verifyNessaToken with Clerk JWT", () => {
const { verifyNessaToken } = await import("./nessa-auth");
await expect(
verifyNessaToken("token-without-subject")
).rejects.toThrow(/Missing subject/);
await expect(verifyNessaToken("token-without-subject")).rejects.toThrow(
/Missing subject/
);
});
it("rejects a malformed token", async () => {
mockVerifyToken.mockRejectedValue(
new Error("Invalid token format")
);
mockVerifyToken.mockRejectedValue(new Error("Invalid token format"));
const { verifyNessaToken } = await import("./nessa-auth");
await expect(
verifyNessaToken("malformed-token")
).rejects.toThrow();
await expect(verifyNessaToken("malformed-token")).rejects.toThrow();
});
it("rejects an expired token", async () => {
mockVerifyToken.mockRejectedValue(
new Error("Token has expired")
);
mockVerifyToken.mockRejectedValue(new Error("Token has expired"));
const { verifyNessaToken } = await import("./nessa-auth");
await expect(
verifyNessaToken("expired-token")
).rejects.toThrow(/expired/i);
await expect(verifyNessaToken("expired-token")).rejects.toThrow(/expired/i);
});
it("rejects a token with wrong signature", async () => {
@@ -114,9 +106,9 @@ describe("verifyNessaToken with Clerk JWT", () => {
const { verifyNessaToken } = await import("./nessa-auth");
await expect(
verifyNessaToken("wrong-key-token")
).rejects.toThrow(/signature/i);
await expect(verifyNessaToken("wrong-key-token")).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 () => {
// 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 source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text();
expect(source).not.toContain(legacyVar);