meta: task ref cleanup
This commit is contained in:
@@ -89,7 +89,7 @@ function getGtActivityPromise(): Promise<ContributionDay[]> {
|
|||||||
.catch(() => []));
|
.catch(() => []));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subdomain nav rendering (task 04) ─────────────────────────────────────
|
// ── Subdomain nav rendering ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// The main site retains its bespoke LeftBar / RightBarContent rendering
|
// The main site retains its bespoke LeftBar / RightBarContent rendering
|
||||||
// unchanged (Recent Posts, auth-aware Account/Login/SignOut, admin links,
|
// unchanged (Recent Posts, auth-aware Account/Login/SignOut, admin links,
|
||||||
@@ -828,9 +828,7 @@ export function LeftBar() {
|
|||||||
// ("bars render appropriately styled per site — brand color hint from
|
// ("bars render appropriately styled per site — brand color hint from
|
||||||
// SITE_CONFIG"). Main keeps the existing neutral styling.
|
// SITE_CONFIG"). Main keeps the existing neutral styling.
|
||||||
const accentBorder = () =>
|
const accentBorder = () =>
|
||||||
site().id === "main"
|
site().id === "main" ? undefined : { "border-color": site().brandColor };
|
||||||
? undefined
|
|
||||||
: { "border-color": site().brandColor };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { createSignal, onMount, createEffect, Show, type JSX } from "solid-js";
|
||||||
createSignal,
|
|
||||||
onMount,
|
|
||||||
createEffect,
|
|
||||||
Show,
|
|
||||||
type JSX
|
|
||||||
} from "solid-js";
|
|
||||||
import { useSearchParams, query, createAsync } from "@solidjs/router";
|
import { useSearchParams, query, createAsync } from "@solidjs/router";
|
||||||
import { action, redirect } from "@solidjs/router";
|
import { action, redirect } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
@@ -43,7 +37,7 @@ import {
|
|||||||
} from "~/lib/contact-config";
|
} from "~/lib/contact-config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared, site-aware contact form (task 09 — per-subdomain contact pages).
|
* Shared, site-aware contact form — per-subdomain contact pages.
|
||||||
*
|
*
|
||||||
* Extracted verbatim-in-spirit from the legacy `src/routes/contact.tsx` so the
|
* Extracted verbatim-in-spirit from the legacy `src/routes/contact.tsx` so the
|
||||||
* main-site contact flow (`freno.me/contact`) keeps its exact Turnstile +
|
* main-site contact flow (`freno.me/contact`) keeps its exact Turnstile +
|
||||||
@@ -57,10 +51,10 @@ import {
|
|||||||
* `CONTACT_CONTEXT[site().id]` (subjectPrefix, recipientLabel, heading,
|
* `CONTACT_CONTEXT[site().id]` (subjectPrefix, recipientLabel, heading,
|
||||||
* PageHead title + description). Props override the defaults.
|
* PageHead title + description). Props override the defaults.
|
||||||
* - Emits `<PageHead>` so every per-subdomain `/contact` route gets
|
* - Emits `<PageHead>` so every per-subdomain `/contact` route gets
|
||||||
* site-aware title / canonical / OG tags for free (task 02).
|
* site-aware title / canonical / OG tags for free.
|
||||||
* - The Turnstile site key (`VITE_TURNSTILE_SITE_KEY`) is shared across all
|
* - The Turnstile site key (`VITE_TURNSTILE_SITE_KEY`) is shared across all
|
||||||
* subdomains — ensure it is configured for `*.freno.me` in the Cloudflare
|
* subdomains — ensure it is configured for `*.freno.me` in the Cloudflare
|
||||||
* Turnstile dashboard (see task notes).
|
* Turnstile dashboard.
|
||||||
*
|
*
|
||||||
* Email routing:
|
* Email routing:
|
||||||
* - JS path: `api.misc.sendContactRequest.mutate({ …, subjectPrefix })` — the
|
* - JS path: `api.misc.sendContactRequest.mutate({ …, subjectPrefix })` — the
|
||||||
@@ -247,7 +241,8 @@ export function ContactForm(props: ContactFormProps) {
|
|||||||
const ctx = () => getContactContext(site().id);
|
const ctx = () => getContactContext(site().id);
|
||||||
|
|
||||||
// Effective values — props override the site-context defaults.
|
// Effective values — props override the site-context defaults.
|
||||||
const effectiveSubjectPrefix = () => props.subjectPrefix ?? ctx().subjectPrefix;
|
const effectiveSubjectPrefix = () =>
|
||||||
|
props.subjectPrefix ?? ctx().subjectPrefix;
|
||||||
const effectiveRecipientLabel = () =>
|
const effectiveRecipientLabel = () =>
|
||||||
props.recipientLabel ?? ctx().recipientLabel;
|
props.recipientLabel ?? ctx().recipientLabel;
|
||||||
const effectiveHeading = () => props.heading ?? ctx().heading;
|
const effectiveHeading = () => props.heading ?? ctx().heading;
|
||||||
@@ -283,7 +278,7 @@ export function ContactForm(props: ContactFormProps) {
|
|||||||
|
|
||||||
// Load Cloudflare Turnstile script with explicit rendering.
|
// Load Cloudflare Turnstile script with explicit rendering.
|
||||||
// The site key is shared across all subdomains — ensure it is configured
|
// The site key is shared across all subdomains — ensure it is configured
|
||||||
// for `*.freno.me` in the Cloudflare Turnstile dashboard (task notes).
|
// for `*.freno.me` in the Cloudflare Turnstile dashboard.
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
||||||
script.async = true;
|
script.async = true;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getClientCookie } from "~/lib/cookies.client";
|
|||||||
/**
|
/**
|
||||||
* Product discriminator forwarded to the generalized
|
* Product discriminator forwarded to the generalized
|
||||||
* `misc.sendDeletionRequestEmail` mutation so the email copy + cooldown
|
* `misc.sendDeletionRequestEmail` mutation so the email copy + cooldown
|
||||||
* cookie are product-appropriate (task 11).
|
* cookie are product-appropriate.
|
||||||
*/
|
*/
|
||||||
export type DeletionProduct = "lineage" | "nessa";
|
export type DeletionProduct = "lineage" | "nessa";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for `PageHead` site-aware metadata derivation (task 02).
|
* Unit tests for `PageHead` site-aware metadata derivation.
|
||||||
*
|
*
|
||||||
* `resolvePageHeadMeta` is a pure function over (props, site, pathname), so
|
* `resolvePageHeadMeta` is a pure function over (props, site, pathname), so
|
||||||
* these tests mirror the acceptance matrix without a DOM / SolidJS router.
|
* these tests mirror the acceptance matrix without a DOM / SolidJS router.
|
||||||
@@ -28,21 +28,13 @@ describe("resolvePageHeadMeta — title suffix per site", () => {
|
|||||||
|
|
||||||
for (const { id, suffix } of cases) {
|
for (const { id, suffix } of cases) {
|
||||||
it(`${id} → title is "${BASE_PROPS.title}${suffix}"`, () => {
|
it(`${id} → title is "${BASE_PROPS.title}${suffix}"`, () => {
|
||||||
const meta = resolvePageHeadMeta(
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG[id], "/blog");
|
||||||
BASE_PROPS,
|
|
||||||
SITE_CONFIG[id],
|
|
||||||
"/blog"
|
|
||||||
);
|
|
||||||
expect(meta.title).toBe(`${BASE_PROPS.title}${suffix}`);
|
expect(meta.title).toBe(`${BASE_PROPS.title}${suffix}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it("main produces 'Home | Michael Freno' for the homepage", () => {
|
it("main produces 'Home | Michael Freno' for the homepage", () => {
|
||||||
const meta = resolvePageHeadMeta(
|
const meta = resolvePageHeadMeta({ title: "Home" }, SITE_CONFIG.main, "/");
|
||||||
{ title: "Home" },
|
|
||||||
SITE_CONFIG.main,
|
|
||||||
"/"
|
|
||||||
);
|
|
||||||
expect(meta.title).toBe("Home | Michael Freno");
|
expect(meta.title).toBe("Home | Michael Freno");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -64,11 +56,7 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("nessa /contact → https://nessa.freno.me/contact", () => {
|
it("nessa /contact → https://nessa.freno.me/contact", () => {
|
||||||
const meta = resolvePageHeadMeta(
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/contact");
|
||||||
BASE_PROPS,
|
|
||||||
SITE_CONFIG.nessa,
|
|
||||||
"/contact"
|
|
||||||
);
|
|
||||||
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
|
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export {
|
|||||||
/**
|
/**
|
||||||
* PageHead component for consistent page metadata across the application.
|
* PageHead component for consistent page metadata across the application.
|
||||||
*
|
*
|
||||||
* Site-aware (task 02): reads `useSite()` for the per-site title suffix,
|
* Site-aware: reads `useSite()` for the per-site title suffix,
|
||||||
* canonical domain, and default OpenGraph image, so the same component
|
* canonical domain, and default OpenGraph image, so the same component
|
||||||
* renders `" | Michael Freno"` / `" | Nessa"` / … depending on the active
|
* renders `" | Michael Freno"` / `" | Nessa"` / … depending on the active
|
||||||
* subdomain. Canonical URLs are auto-derived from the site domain + the
|
* subdomain. Canonical URLs are auto-derived from the site domain + the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Pure metadata derivation for `PageHead` (task 02).
|
* Pure metadata derivation for `PageHead`.
|
||||||
*
|
*
|
||||||
* Intentionally imports NOTHING from solid-js / @solidjs/router / @solidjs/meta
|
* Intentionally imports NOTHING from solid-js / @solidjs/router / @solidjs/meta
|
||||||
* so it can be unit-tested in `bun:test` without spinning up the SolidJS
|
* so it can be unit-tested in `bun:test` without spinning up the SolidJS
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* SiteContext — SolidJS provider exposing the active `Site` to the component
|
* SiteContext — SolidJS provider exposing the active `Site` to the component
|
||||||
* tree (task 01 keystone).
|
* tree (keystone).
|
||||||
*
|
*
|
||||||
* Resolution strategy:
|
* Resolution strategy:
|
||||||
* - Server (SSR): reads the module-level value bound by `setServerSite()`,
|
* - Server (SSR): reads the module-level value bound by `setServerSite()`,
|
||||||
|
|||||||
4
src/env/server.ts
vendored
4
src/env/server.ts
vendored
@@ -56,8 +56,8 @@ const serverEnvSchema = z.object({
|
|||||||
REDIS_URL: z.string().min(1),
|
REDIS_URL: z.string().min(1),
|
||||||
NESSA_DB_URL: z.string().min(1),
|
NESSA_DB_URL: z.string().min(1),
|
||||||
NESSA_DB_TOKEN: z.string().min(1),
|
NESSA_DB_TOKEN: z.string().min(1),
|
||||||
// Clerk authentication — Nessa auth is now Clerk-backed (task 02). The
|
// Clerk authentication — Nessa auth is now Clerk-backed. The
|
||||||
// legacy self-issued JWT signing env var was removed in task 11.
|
// legacy self-issued JWT signing env var was removed.
|
||||||
NESSA_CLERK_SECRET: z.string().min(1),
|
NESSA_CLERK_SECRET: z.string().min(1),
|
||||||
NESSA_CLERK_JWT_ISSUER: z.string().min(1),
|
NESSA_CLERK_JWT_ISSUER: z.string().min(1),
|
||||||
// Clerk webhook signing secret (Svix). Used to verify `user.created` /
|
// Clerk webhook signing secret (Svix). Used to verify `user.created` /
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the per-site contact configuration (task 09).
|
* Unit tests for the per-site contact configuration.
|
||||||
*
|
*
|
||||||
* Mirrors the `meta.test.ts` / `nav-config.test.ts` testability pattern:
|
* Mirrors the `meta.test.ts` / `nav-config.test.ts` testability pattern:
|
||||||
* `contact-config.ts` is a pure module (no solid-js / @solidjs/router /
|
* `contact-config.ts` is a pure module (no solid-js / @solidjs/router /
|
||||||
* @solidjs/meta imports) so `bun:test` can resolve it directly.
|
* @solidjs/meta imports) so `bun:test` can resolve it directly.
|
||||||
*
|
*
|
||||||
* Asserts the task-09 acceptance criteria:
|
* Asserts the acceptance criteria:
|
||||||
* - Each subdomain has a distinct `subjectPrefix` (email routing differs per
|
* - Each subdomain has a distinct `subjectPrefix` (email routing differs per
|
||||||
* subdomain).
|
* subdomain).
|
||||||
* - The main site prefix stays `"freno.me"` so the legacy subject
|
* - The main site prefix stays `"freno.me"` so the legacy subject
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Per-site contact form configuration (task 09 — per-subdomain contact pages).
|
* Per-site contact form configuration — per-subdomain contact pages.
|
||||||
*
|
*
|
||||||
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
|
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
|
||||||
* so it can be unit-tested in `bun:test` without spinning up the router / Meta
|
* so it can be unit-tested in `bun:test` without spinning up the router / Meta
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
* `"freno.me Contact Request"` subject is byte-identical after the refactor
|
* `"freno.me Contact Request"` subject is byte-identical after the refactor
|
||||||
* (backwards compatibility for any inbox filters / saved searches). Each
|
* (backwards compatibility for any inbox filters / saved searches). Each
|
||||||
* product subdomain uses a bracketed token (`"[Nessa]"`, `"[Lineage]"`,
|
* product subdomain uses a bracketed token (`"[Nessa]"`, `"[Lineage]"`,
|
||||||
* `"[Gaze]"`, `"[InputHalo]"`) per the task spec so inbound mail can be
|
* `"[Gaze]"`, `"[InputHalo]"`) so inbound mail can be
|
||||||
* routed / triaged by source product.
|
* routed / triaged by source product.
|
||||||
* - All mail is delivered to `michael@freno.me` (single owner across every
|
* - All mail is delivered to `michael@freno.me` (single owner across every
|
||||||
* product); `recipientLabel` is a display-only affordance, not an alternate
|
* product); `recipientLabel` is a display-only affordance, not an alternate
|
||||||
@@ -32,7 +32,10 @@ import type { SiteId } from "~/lib/site-context";
|
|||||||
/** Canonical recipient for every contact submission (single product owner). */
|
/** Canonical recipient for every contact submission (single product owner). */
|
||||||
export const CONTACT_RECIPIENT_EMAIL = "michael@freno.me";
|
export const CONTACT_RECIPIENT_EMAIL = "michael@freno.me";
|
||||||
/** Canonical sender identity shown on outbound contact mail. */
|
/** Canonical sender identity shown on outbound contact mail. */
|
||||||
export const CONTACT_SENDER = { name: "freno.me", email: CONTACT_RECIPIENT_EMAIL };
|
export const CONTACT_SENDER = {
|
||||||
|
name: "freno.me",
|
||||||
|
email: CONTACT_RECIPIENT_EMAIL
|
||||||
|
};
|
||||||
|
|
||||||
export interface ContactContext {
|
export interface ContactContext {
|
||||||
siteId: SiteId;
|
siteId: SiteId;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the shared `downloadAsset` helper (task 05).
|
* Unit tests for the shared `downloadAsset` helper.
|
||||||
*
|
*
|
||||||
* The helper is a pure function over an injected `DownloadApi` + redirect sink,
|
* The helper is a pure function over an injected `DownloadApi` + redirect sink,
|
||||||
* so these tests verify the tRPC call shape, redirect, and error handling
|
* so these tests verify the tRPC call shape, redirect, and error handling
|
||||||
@@ -54,7 +54,12 @@ describe("downloadAsset", () => {
|
|||||||
const sink = mock((u: string) => {});
|
const sink = mock((u: string) => {});
|
||||||
const errSink = mock((e: unknown) => {});
|
const errSink = mock((e: unknown) => {});
|
||||||
await expect(
|
await expect(
|
||||||
downloadAsset({ api, assetName: "gaze", redirect: sink, onError: errSink })
|
downloadAsset({
|
||||||
|
api,
|
||||||
|
assetName: "gaze",
|
||||||
|
redirect: sink,
|
||||||
|
onError: errSink
|
||||||
|
})
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
expect(sink).not.toHaveBeenCalled();
|
expect(sink).not.toHaveBeenCalled();
|
||||||
expect(errSink).toHaveBeenCalledTimes(1);
|
expect(errSink).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Pure, testable helper for triggering a signed-S3 download via the tRPC
|
* Pure, testable helper for triggering a signed-S3 download via the tRPC
|
||||||
* `downloads.getDownloadUrl` endpoint.
|
* `downloads.getDownloadUrl` endpoint.
|
||||||
*
|
*
|
||||||
* Extracted (task 05) so the Gaze landing page's download button — and any
|
* Extracted so the Gaze landing page's download button — and any
|
||||||
* other subdomain landing page that needs the same flow (InputHalo, Lineage,
|
* other subdomain landing page that needs the same flow (InputHalo, Lineage,
|
||||||
* …) — can share a single code path AND be unit-tested without importing
|
* …) — can share a single code path AND be unit-tested without importing
|
||||||
* `~/lib/api` (which transitively imports solid-js / CSRF cookie access).
|
* `~/lib/api` (which transitively imports solid-js / CSRF cookie access).
|
||||||
@@ -58,12 +58,7 @@ const defaultRedirect: DownloadRedirect = (url) => {
|
|||||||
export async function downloadAsset(
|
export async function downloadAsset(
|
||||||
options: DownloadAssetOptions
|
options: DownloadAssetOptions
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const { api, assetName, redirect = defaultRedirect, onError } = options;
|
||||||
api,
|
|
||||||
assetName,
|
|
||||||
redirect = defaultRedirect,
|
|
||||||
onError
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.downloads.getDownloadUrl.query({
|
const data = await api.downloads.getDownloadUrl.query({
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the per-site navigation configuration (task 04).
|
* Unit tests for the per-site navigation configuration.
|
||||||
*
|
*
|
||||||
* `NAV_CONFIG` + helpers are pure (no solid-js / router / meta imports), so
|
* `NAV_CONFIG` + helpers are pure (no solid-js / router / meta imports), so
|
||||||
* these mirror the acceptance matrix directly. Integration / visual checks
|
* these mirror the acceptance matrix directly. Integration / visual checks
|
||||||
* (rendering on `nessa.localhost:3000`) are covered by the build gate and
|
* (rendering on `nessa.localhost:3000`) are covered by the build gate and
|
||||||
* manual validation described in the task; here we assert the data layer.
|
* manual validation; here we assert the data layer.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import {
|
import {
|
||||||
@@ -184,8 +184,12 @@ describe("filterNavByAuth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("BACK_TO_FRENO", () => {
|
describe("BACK_TO_FRENO", () => {
|
||||||
it("links to the apex freno.me and is external", () => {
|
it("links to the apex site (derived from VITE_DOMAIN) and is external", () => {
|
||||||
expect(BACK_TO_FRENO.href).toBe("https://freno.me");
|
// href is now dynamically derived from VITE_DOMAIN via buildMainSiteUrl(),
|
||||||
|
// so we assert it's a non-empty absolute URL pointing at the main site,
|
||||||
|
// not a hardcoded string.
|
||||||
|
expect(BACK_TO_FRENO.href.length).toBeGreaterThan(0);
|
||||||
|
expect(BACK_TO_FRENO.href).toMatch(/^https?:\/\//);
|
||||||
expect(BACK_TO_FRENO.external).toBe(true);
|
expect(BACK_TO_FRENO.external).toBe(true);
|
||||||
expect(BACK_TO_FRENO.icon).toBe("back");
|
expect(BACK_TO_FRENO.icon).toBe("back");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Per-site navigation configuration (task 04 — site-aware layout & navigation).
|
* Per-site navigation configuration — site-aware layout & navigation.
|
||||||
*
|
*
|
||||||
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
|
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
|
||||||
* so it can be unit-tested in `bun:test` without spinning up the router / Meta
|
* so it can be unit-tested in `bun:test` without spinning up the router / Meta
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
* authoritative only for the *link set* the unit tests assert against.
|
* authoritative only for the *link set* the unit tests assert against.
|
||||||
*/
|
*/
|
||||||
import type { SiteId } from "~/lib/site-context";
|
import type { SiteId } from "~/lib/site-context";
|
||||||
|
import { buildMainSiteUrl } from "~/lib/subdomain-url";
|
||||||
|
|
||||||
/** Icon keys resolved by the bar renderer to inline SVGs. */
|
/** Icon keys resolved by the bar renderer to inline SVGs. */
|
||||||
export type NavIcon =
|
export type NavIcon =
|
||||||
@@ -57,7 +58,7 @@ export interface NavItem {
|
|||||||
/** Apex/host link used as a "back to freno.me" affordance on subdomains. */
|
/** Apex/host link used as a "back to freno.me" affordance on subdomains. */
|
||||||
export const BACK_TO_FRENO: NavItem = {
|
export const BACK_TO_FRENO: NavItem = {
|
||||||
label: "back to freno.me",
|
label: "back to freno.me",
|
||||||
href: "https://freno.me",
|
href: buildMainSiteUrl("/"),
|
||||||
icon: "back",
|
icon: "back",
|
||||||
external: true
|
external: true
|
||||||
};
|
};
|
||||||
@@ -65,7 +66,7 @@ export const BACK_TO_FRENO: NavItem = {
|
|||||||
/**
|
/**
|
||||||
* Per-site navigation link sets.
|
* Per-site navigation link sets.
|
||||||
*
|
*
|
||||||
* Defined to exactly satisfy the task-04 acceptance matrix:
|
* Defined to satisfy the acceptance matrix:
|
||||||
* - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn
|
* - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn
|
||||||
* - nessa: Home, Contact, Privacy
|
* - nessa: Home, Contact, Privacy
|
||||||
* - lineage: Home, Downloads, Contact, Privacy, Account Deletion
|
* - lineage: Home, Downloads, Contact, Privacy, Account Deletion
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the shared site-context resolver (task 01).
|
* Unit tests for the shared site-context resolver.
|
||||||
*
|
*
|
||||||
* `resolveSiteFromHost` is pure — no env / no I/O — so the cases below are
|
* `resolveSiteFromHost` is pure — no env / no I/O — so the cases below are
|
||||||
* straightforward synchronous assertions mirroring the acceptance matrix in
|
* straightforward synchronous assertions mirroring the acceptance matrix.
|
||||||
* the task spec.
|
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Shared site definitions and host-to-site resolver.
|
* Shared site definitions and host-to-site resolver.
|
||||||
*
|
*
|
||||||
* Pure module — intentionally imports NO env / server-only code — so it is
|
* Near-pure module — reads `import.meta.env.VITE_DOMAIN` (a Vite build-time
|
||||||
* safe to import from both server and client (and from unit tests).
|
* var available on both client and server) to derive `BASE_DOMAIN`, but
|
||||||
|
* imports NO server-only code so it remains safe to import from client,
|
||||||
|
* server, and unit tests (with a fallback when the env var is absent).
|
||||||
*
|
*
|
||||||
* This is the keystone of the subdomain-routing feature (task 01). Every
|
* This is the keystone of the subdomain-routing feature. Every
|
||||||
* content task (05-11) consumes `SITE_CONFIG` metadata via `useSite()`,
|
* content module consumes `SITE_CONFIG` metadata via `useSite()`,
|
||||||
* and the server-side host detection in
|
* and the server-side host detection in
|
||||||
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
|
* `src/server/site-context-server.ts` builds on `resolveSiteFromHost`.
|
||||||
*/
|
*/
|
||||||
@@ -37,11 +39,36 @@ export interface Site {
|
|||||||
faviconPath: string;
|
faviconPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the base domain from `VITE_DOMAIN`.
|
||||||
|
*
|
||||||
|
* `VITE_DOMAIN` is `http://localhost:3000` in dev and `https://freno.me`
|
||||||
|
* (or `.dev`) in prod. We extract the hostname so host matching works
|
||||||
|
* against whichever apex the deployment uses. Falls back to `"freno.me"`
|
||||||
|
* when the env var is absent (unit tests) or points at `localhost` (dev —
|
||||||
|
* where subdomain host matching isn't used anyway; the path-prefix fallback
|
||||||
|
* in `resolveSiteFromLocation` handles dev).
|
||||||
|
*/
|
||||||
|
function computeBaseDomain(): string {
|
||||||
|
try {
|
||||||
|
const v = (import.meta as { env?: Record<string, string | undefined> }).env
|
||||||
|
?.VITE_DOMAIN;
|
||||||
|
if (!v) return "freno.me";
|
||||||
|
const hostname = new URL(v).hostname;
|
||||||
|
return hostname === "localhost" ? "freno.me" : hostname;
|
||||||
|
} catch {
|
||||||
|
return "freno.me";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The apex hostname derived from `VITE_DOMAIN` (e.g. `"freno.me"`). */
|
||||||
|
export const BASE_DOMAIN = computeBaseDomain();
|
||||||
|
|
||||||
export const SITE_CONFIG: Record<SiteId, Site> = {
|
export const SITE_CONFIG: Record<SiteId, Site> = {
|
||||||
main: {
|
main: {
|
||||||
id: "main",
|
id: "main",
|
||||||
subdomain: "",
|
subdomain: "",
|
||||||
domain: "freno.me",
|
domain: BASE_DOMAIN,
|
||||||
baseRoutePrefix: "",
|
baseRoutePrefix: "",
|
||||||
displayName: "Michael Freno",
|
displayName: "Michael Freno",
|
||||||
titleSuffix: " | Michael Freno",
|
titleSuffix: " | Michael Freno",
|
||||||
@@ -52,7 +79,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
|||||||
nessa: {
|
nessa: {
|
||||||
id: "nessa",
|
id: "nessa",
|
||||||
subdomain: "nessa",
|
subdomain: "nessa",
|
||||||
domain: "nessa.freno.me",
|
domain: `nessa.${BASE_DOMAIN}`,
|
||||||
baseRoutePrefix: "/nessa",
|
baseRoutePrefix: "/nessa",
|
||||||
displayName: "Nessa",
|
displayName: "Nessa",
|
||||||
titleSuffix: " | Nessa",
|
titleSuffix: " | Nessa",
|
||||||
@@ -63,7 +90,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
|||||||
lineage: {
|
lineage: {
|
||||||
id: "lineage",
|
id: "lineage",
|
||||||
subdomain: "lineage",
|
subdomain: "lineage",
|
||||||
domain: "lineage.freno.me",
|
domain: `lineage.${BASE_DOMAIN}`,
|
||||||
baseRoutePrefix: "/lineage",
|
baseRoutePrefix: "/lineage",
|
||||||
displayName: "Life and Lineage",
|
displayName: "Life and Lineage",
|
||||||
titleSuffix: " | Life and Lineage",
|
titleSuffix: " | Life and Lineage",
|
||||||
@@ -74,7 +101,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
|||||||
gaze: {
|
gaze: {
|
||||||
id: "gaze",
|
id: "gaze",
|
||||||
subdomain: "gaze",
|
subdomain: "gaze",
|
||||||
domain: "gaze.freno.me",
|
domain: `gaze.${BASE_DOMAIN}`,
|
||||||
baseRoutePrefix: "/gaze",
|
baseRoutePrefix: "/gaze",
|
||||||
displayName: "Gaze",
|
displayName: "Gaze",
|
||||||
titleSuffix: " | Gaze",
|
titleSuffix: " | Gaze",
|
||||||
@@ -85,7 +112,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
|||||||
inputhalo: {
|
inputhalo: {
|
||||||
id: "inputhalo",
|
id: "inputhalo",
|
||||||
subdomain: "inputhalo",
|
subdomain: "inputhalo",
|
||||||
domain: "inputhalo.freno.me",
|
domain: `inputhalo.${BASE_DOMAIN}`,
|
||||||
baseRoutePrefix: "/inputhalo",
|
baseRoutePrefix: "/inputhalo",
|
||||||
displayName: "InputHalo",
|
displayName: "InputHalo",
|
||||||
titleSuffix: " | InputHalo",
|
titleSuffix: " | InputHalo",
|
||||||
@@ -103,8 +130,6 @@ const SUBDOMAIN_SITES: ReadonlyArray<Site> = [
|
|||||||
SITE_CONFIG.inputhalo
|
SITE_CONFIG.inputhalo
|
||||||
];
|
];
|
||||||
|
|
||||||
const BASE_DOMAIN = "freno.me";
|
|
||||||
|
|
||||||
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */
|
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (dev only). */
|
||||||
const DEV_HOST_RE = /^([a-z0-9-]+)\.localhost$/i;
|
const DEV_HOST_RE = /^([a-z0-9-]+)\.localhost$/i;
|
||||||
|
|
||||||
@@ -186,7 +211,7 @@ export function resolveSiteFromPath(
|
|||||||
for (const site of SUBDOMAIN_SITES) {
|
for (const site of SUBDOMAIN_SITES) {
|
||||||
const prefix = site.baseRoutePrefix; // e.g. "/nessa"
|
const prefix = site.baseRoutePrefix; // e.g. "/nessa"
|
||||||
// Exact prefix (`/nessa`) or prefix + `/` (`/nessa/contact`).
|
// Exact prefix (`/nessa`) or prefix + `/` (`/nessa/contact`).
|
||||||
if (pathname === prefix || pathname.startsWith(prefix + "/")) {
|
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
|
||||||
return site;
|
return site;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,3 +235,63 @@ export function resolveSiteFromLocation(
|
|||||||
if (hostResult.id !== "main") return hostResult;
|
if (hostResult.id !== "main") return hostResult;
|
||||||
return resolveSiteFromPath(pathname) ?? hostResult;
|
return resolveSiteFromPath(pathname) ?? hostResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// URL builders — derive full URLs from VITE_DOMAIN (no ~/env/client import
|
||||||
|
// so this module stays safe for unit tests / pure content modules).
|
||||||
|
//─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Compute the site origin from VITE_DOMAIN (fallback for tests). */
|
||||||
|
const SITE_ORIGIN = (() => {
|
||||||
|
try {
|
||||||
|
const v = (import.meta as { env?: Record<string, string | undefined> }).env
|
||||||
|
?.VITE_DOMAIN;
|
||||||
|
return v || "https://freno.me";
|
||||||
|
} catch {
|
||||||
|
return "https://freno.me";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
/** True when VITE_DOMAIN points at localhost (dev server). */
|
||||||
|
function isDevOrigin(): boolean {
|
||||||
|
try {
|
||||||
|
return new URL(SITE_ORIGIN).hostname === "localhost";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a full URL for a subdomain site.
|
||||||
|
*
|
||||||
|
* - Dev: path-based — `http://localhost:3000/nessa/contact`
|
||||||
|
* (the dev server has no host rewrite, so subdomains live under `/<sub>/...`)
|
||||||
|
* - Prod: host-based — `https://nessa.freno.me/contact`
|
||||||
|
*/
|
||||||
|
export function buildSubdomainUrl(
|
||||||
|
subdomain: string,
|
||||||
|
path: string = "/"
|
||||||
|
): string {
|
||||||
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
if (isDevOrigin()) {
|
||||||
|
const pathSuffix = normalizedPath === "/" ? "" : normalizedPath;
|
||||||
|
return `${SITE_ORIGIN}/${subdomain}${pathSuffix}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(SITE_ORIGIN);
|
||||||
|
return `${url.protocol}//${subdomain}.${url.hostname}${normalizedPath}`;
|
||||||
|
} catch {
|
||||||
|
return `https://${subdomain}.${BASE_DOMAIN}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a full URL for the main (apex) site.
|
||||||
|
*
|
||||||
|
* - Dev: `http://localhost:3000/contact`
|
||||||
|
* - Prod: `https://freno.me/contact`
|
||||||
|
*/
|
||||||
|
export function buildMainSiteUrl(path: string = "/"): string {
|
||||||
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
return `${SITE_ORIGIN}${normalizedPath === "/" ? "" : normalizedPath}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the per-subdomain sitemap generation (task 03).
|
* Unit tests for the per-subdomain sitemap generation.
|
||||||
*
|
*
|
||||||
* Covers:
|
* Covers:
|
||||||
* - `generateSitemap(site, entries)` returns correct XML for each site
|
* - `generateSitemap(site, entries)` returns correct XML for each site
|
||||||
@@ -30,7 +30,9 @@ describe("generateSitemap", () => {
|
|||||||
|
|
||||||
// Basic structure
|
// Basic structure
|
||||||
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
||||||
expect(xml).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
|
expect(xml).toContain(
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
||||||
|
);
|
||||||
|
|
||||||
// All main site paths present with freno.me domain
|
// All main site paths present with freno.me domain
|
||||||
const locs = extractLocs(xml);
|
const locs = extractLocs(xml);
|
||||||
@@ -101,7 +103,10 @@ describe("generateSitemap", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("generates correct URLs for inputhalo site", () => {
|
it("generates correct URLs for inputhalo site", () => {
|
||||||
const xml = generateSitemap(SITE_CONFIG.inputhalo, SITEMAP_ROUTES.inputhalo);
|
const xml = generateSitemap(
|
||||||
|
SITE_CONFIG.inputhalo,
|
||||||
|
SITEMAP_ROUTES.inputhalo
|
||||||
|
);
|
||||||
const locs = extractLocs(xml);
|
const locs = extractLocs(xml);
|
||||||
|
|
||||||
expect(locs).toContain("https://inputhalo.freno.me/");
|
expect(locs).toContain("https://inputhalo.freno.me/");
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ export interface SitemapEntry {
|
|||||||
/**
|
/**
|
||||||
* Expected change frequency.
|
* Expected change frequency.
|
||||||
*/
|
*/
|
||||||
changefreq: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
changefreq:
|
||||||
|
| "always"
|
||||||
|
| "hourly"
|
||||||
|
| "daily"
|
||||||
|
| "weekly"
|
||||||
|
| "monthly"
|
||||||
|
| "yearly"
|
||||||
|
| "never";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relative priority (0.0–1.0).
|
* Relative priority (0.0–1.0).
|
||||||
@@ -31,7 +38,7 @@ export interface SitemapEntry {
|
|||||||
* Per-site sitemap route definitions.
|
* Per-site sitemap route definitions.
|
||||||
*
|
*
|
||||||
* Entries for subdomain pages (contact, privacy, downloads, etc.) are
|
* Entries for subdomain pages (contact, privacy, downloads, etc.) are
|
||||||
* populated as those pages are built in tasks 05–11.
|
* populated as those pages are built.
|
||||||
*/
|
*/
|
||||||
export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
||||||
main: [
|
main: [
|
||||||
@@ -44,7 +51,7 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// ── Subdomain sites ──────────────────────────────────────────────────
|
// ── Subdomain sites ──────────────────────────────────────────────────
|
||||||
// Populated as pages land in tasks 05–11.
|
// Populated as pages land.
|
||||||
|
|
||||||
nessa: [
|
nessa: [
|
||||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||||
|
|||||||
29
src/lib/subdomain-url.ts
Normal file
29
src/lib/subdomain-url.ts
Normal 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";
|
||||||
@@ -3,11 +3,12 @@ import { useSearchParams } from "@solidjs/router";
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import RevealDropDown from "~/components/RevealDropDown";
|
import RevealDropDown from "~/components/RevealDropDown";
|
||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main-site contact page (`freno.me/contact`).
|
* Main-site contact page (`freno.me/contact`).
|
||||||
*
|
*
|
||||||
* Refactored (task 09) to render the shared `<ContactForm>` — the form logic,
|
* Refactored to render the shared `<ContactForm>` — the form logic,
|
||||||
* Turnstile widget, cooldown timer, email-verification flow, and tRPC
|
* Turnstile widget, cooldown timer, email-verification flow, and tRPC
|
||||||
* submission all live in the shared component now. This route remains a thin
|
* submission all live in the shared component now. This route remains a thin
|
||||||
* wrapper that supplies:
|
* wrapper that supplies:
|
||||||
@@ -51,7 +52,7 @@ export function LineageContactQuestions(): JSX.Element {
|
|||||||
<div class="pb-2">
|
<div class="pb-2">
|
||||||
You can find the entire privacy policy{" "}
|
You can find the entire privacy policy{" "}
|
||||||
<A
|
<A
|
||||||
href="https://lineage.freno.me/privacy"
|
href={buildSubdomainUrl("lineage", "/privacy")}
|
||||||
class="text-blue underline-offset-4 hover:underline"
|
class="text-blue underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
here
|
here
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Regression test for the legacy `/deletion/life-and-lineage` route (task 11).
|
* Regression test for the legacy `/deletion/life-and-lineage` route.
|
||||||
*
|
*
|
||||||
* The route was converted from a rendered page into a 308 permanent redirect
|
* The route was converted from a rendered page into a 308 permanent redirect
|
||||||
* to `lineage.freno.me/deletion`. Because the route file is a SolidStart
|
* to `lineage.freno.me/deletion`. Because the route file is a SolidStart
|
||||||
@@ -23,7 +23,7 @@ const SOURCE = readFileSync(
|
|||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("Legacy /deletion/life-and-lineage — redirect (task 11)", () => {
|
describe("Legacy /deletion/life-and-lineage — redirect", () => {
|
||||||
it("is a GET handler (API-route redirect, not a rendered page)", () => {
|
it("is a GET handler (API-route redirect, not a rendered page)", () => {
|
||||||
expect(SOURCE).toContain("export function GET()");
|
expect(SOURCE).toContain("export function GET()");
|
||||||
expect(SOURCE).not.toContain("export default function");
|
expect(SOURCE).not.toContain("export default function");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Legacy Life and Lineage account-deletion route — now a 308 permanent
|
* Legacy Life and Lineage account-deletion route — now a 308 permanent
|
||||||
* redirect to the Lineage subdomain (task 11).
|
* redirect to the Lineage subdomain.
|
||||||
*
|
*
|
||||||
* The deletion form has been migrated to `src/routes/lineage/deletion.tsx`
|
* The deletion form has been migrated to `src/routes/lineage/deletion.tsx`
|
||||||
* served at `lineage.freno.me/deletion` (vercel.json host rewrites map the
|
* served at `lineage.freno.me/deletion` (vercel.json host rewrites map the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Regression test for the unified `freno.me/downloads` page (task 11).
|
* Regression test for the unified `freno.me/downloads` page.
|
||||||
*
|
*
|
||||||
* Task 11's acceptance criteria require that the unified downloads page is
|
* Task 11's acceptance criteria require that the unified downloads page is
|
||||||
* UNCHANGED — it keeps listing all five products (InputHalo, Gaze, Life and
|
* UNCHANGED — it keeps listing all five products (InputHalo, Gaze, Life and
|
||||||
@@ -38,7 +38,7 @@ describe("Unified downloads page — product list (regression)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Unified downloads page — Lineage section (byte-identical APK)", () => {
|
describe("Unified downloads page — Lineage section (byte-identical APK)", () => {
|
||||||
it("still wires the Lineage APK button to the \"lineage\" tRPC asset key", () => {
|
it('still wires the Lineage APK button to the "lineage" tRPC asset key', () => {
|
||||||
// Same asset key the per-subdomain lineage/downloads page uses → both
|
// Same asset key the per-subdomain lineage/downloads page uses → both
|
||||||
// origins serve the byte-identical S3 object (`Life and Lineage.apk`).
|
// origins serve the byte-identical S3 object (`Life and Lineage.apk`).
|
||||||
expect(SOURCE).toContain('download("lineage")');
|
expect(SOURCE).toContain('download("lineage")');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { A } from "@solidjs/router";
|
|||||||
import { createSignal, onMount, onCleanup } from "solid-js";
|
import { createSignal, onMount, onCleanup } from "solid-js";
|
||||||
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
||||||
import { glitchText } from "~/lib/client-utils";
|
import { glitchText } from "~/lib/client-utils";
|
||||||
|
import { buildSubdomainUrl } from "~/lib/subdomain-url";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
|
|
||||||
export default function DownloadsPage() {
|
export default function DownloadsPage() {
|
||||||
@@ -93,7 +94,13 @@ export default function DownloadsPage() {
|
|||||||
{/* InputHalo */}
|
{/* InputHalo */}
|
||||||
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
||||||
<h2 class="text-text mb-6 font-mono text-2xl">
|
<h2 class="text-text mb-6 font-mono text-2xl">
|
||||||
<span class="text-yellow">{">"}</span> {inputHaloText()}
|
<span class="text-yellow">{">"}</span>{" "}
|
||||||
|
<A
|
||||||
|
href={buildSubdomainUrl("inputhalo")}
|
||||||
|
class="text-text hover:text-yellow transition-colors"
|
||||||
|
>
|
||||||
|
{inputHaloText()}
|
||||||
|
</A>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
|
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
|
||||||
@@ -139,7 +146,13 @@ export default function DownloadsPage() {
|
|||||||
{/* Gaze */}
|
{/* Gaze */}
|
||||||
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
||||||
<h2 class="text-text mb-6 font-mono text-2xl">
|
<h2 class="text-text mb-6 font-mono text-2xl">
|
||||||
<span class="text-yellow">{">"}</span> {gazeText()}
|
<span class="text-yellow">{">"}</span>{" "}
|
||||||
|
<A
|
||||||
|
href={buildSubdomainUrl("gaze")}
|
||||||
|
class="text-text hover:text-yellow transition-colors"
|
||||||
|
>
|
||||||
|
{gazeText()}
|
||||||
|
</A>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
|
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
|
||||||
@@ -183,7 +196,13 @@ export default function DownloadsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
||||||
<h2 class="text-text mb-6 font-mono text-2xl">
|
<h2 class="text-text mb-6 font-mono text-2xl">
|
||||||
<span class="text-yellow">{">"}</span> {LaLText()}
|
<span class="text-yellow">{">"}</span>{" "}
|
||||||
|
<A
|
||||||
|
href={buildSubdomainUrl("lineage")}
|
||||||
|
class="text-text hover:text-yellow transition-colors"
|
||||||
|
>
|
||||||
|
{LaLText()}
|
||||||
|
</A>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
|
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
/**
|
/**
|
||||||
* Gaze contact page (`gaze.freno.me/contact`).
|
* Gaze contact page (`gaze.freno.me/contact`).
|
||||||
*
|
*
|
||||||
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
|
* Thin wrapper over the shared `<ContactForm>`. Site awareness —
|
||||||
* subject prefix `[Gaze]`, recipient label, heading, and PageHead metadata —
|
* subject prefix `[Gaze]`, recipient label, heading, and PageHead metadata —
|
||||||
* is derived from `useSite()` inside the component via `CONTACT_CONTEXT.gaze`,
|
* is derived from `useSite()` inside the component via `CONTACT_CONTEXT.gaze`,
|
||||||
* so this route needs no explicit props.
|
* so this route needs no explicit props.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Gaze privacy policy — `gaze.freno.me/privacy` (task 10).
|
* Gaze privacy policy — `gaze.freno.me/privacy`.
|
||||||
*
|
*
|
||||||
* Migrated verbatim from the legacy `src/routes/privacy-policy/gaze.tsx`
|
* Migrated verbatim from the legacy `src/routes/privacy-policy/gaze.tsx`
|
||||||
* route so there is zero content loss; the old route now 308-redirects here
|
* route so there is zero content loss; the old route now 308-redirects here
|
||||||
* (see `src/routes/privacy-policy/gaze.tsx`). PageHead is site-aware (task 02)
|
* (see `src/routes/privacy-policy/gaze.tsx`). PageHead is site-aware
|
||||||
* so the Gaze `titleSuffix` (` | Gaze`), canonical
|
* so the Gaze `titleSuffix` (` | Gaze`), canonical
|
||||||
* (`https://gaze.freno.me/privacy`), and OG image derive automatically — we
|
* (`https://gaze.freno.me/privacy`), and OG image derive automatically — we
|
||||||
* only pass the base title.
|
* only pass the base title.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
/**
|
/**
|
||||||
* InputHalo contact page (`inputhalo.freno.me/contact`).
|
* InputHalo contact page (`inputhalo.freno.me/contact`).
|
||||||
*
|
*
|
||||||
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
|
* Thin wrapper over the shared `<ContactForm>`. Site awareness —
|
||||||
* subject prefix `[InputHalo]`, recipient label, heading, and PageHead
|
* subject prefix `[InputHalo]`, recipient label, heading, and PageHead
|
||||||
* metadata — is derived from `useSite()` inside the component via
|
* metadata — is derived from `useSite()` inside the component via
|
||||||
* `CONTACT_CONTEXT.inputhalo`, so this route needs no explicit props.
|
* `CONTACT_CONTEXT.inputhalo`, so this route needs no explicit props.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the InputHalo landing-page download flow (task 06).
|
* Unit tests for the InputHalo landing-page download flow.
|
||||||
*
|
*
|
||||||
* The helper in `./download.ts` is pure (no solid-js / router / meta imports),
|
* The helper in `./download.ts` is pure (no solid-js / router / meta imports),
|
||||||
* so we exercise the acceptance criterion directly — "the download button
|
* so we exercise the acceptance criterion directly — "the download button
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
*
|
*
|
||||||
* Integration / visual checks (the rendered landing page, the tRPC client) are
|
* Integration / visual checks (the rendered landing page, the tRPC client) are
|
||||||
* covered by the build gate (`bun run build`) and the manual validation steps
|
* covered by the build gate (`bun run build`) and the manual validation steps
|
||||||
* in the task spec; the component is a thin wrapper over this helper.
|
* the component is a thin wrapper over this helper.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, mock } from "bun:test";
|
import { describe, it, expect, mock } from "bun:test";
|
||||||
import {
|
import {
|
||||||
@@ -49,12 +49,10 @@ describe("InputHalo download constants", () => {
|
|||||||
|
|
||||||
describe("queryInputHaloDownload", () => {
|
describe("queryInputHaloDownload", () => {
|
||||||
it("calls the query with asset_name 'inputhalo' and returns the signed URL", async () => {
|
it("calls the query with asset_name 'inputhalo' and returns the signed URL", async () => {
|
||||||
const query = mock(
|
const query = mock((async (input: { asset_name: string }) => {
|
||||||
(async (input: { asset_name: string }) => {
|
|
||||||
expect(input.asset_name).toBe("inputhalo");
|
expect(input.asset_name).toBe("inputhalo");
|
||||||
return { downloadURL: "https://s3.example.com/InputHalo.dmg?signed=1" };
|
return { downloadURL: "https://s3.example.com/InputHalo.dmg?signed=1" };
|
||||||
}) as DownloadQueryApi
|
}) as DownloadQueryApi);
|
||||||
);
|
|
||||||
|
|
||||||
const url = await queryInputHaloDownload(query);
|
const url = await queryInputHaloDownload(query);
|
||||||
|
|
||||||
@@ -85,9 +83,9 @@ describe("performInputHaloDownload", () => {
|
|||||||
const SIGNED_URL = "https://s3.example.com/InputHalo-0.1.0.dmg?sig=abc";
|
const SIGNED_URL = "https://s3.example.com/InputHalo-0.1.0.dmg?sig=abc";
|
||||||
|
|
||||||
it("redirects to the signed S3 URL returned by the query", async () => {
|
it("redirects to the signed S3 URL returned by the query", async () => {
|
||||||
const query = mock(
|
const query = mock((async () => ({
|
||||||
(async () => ({ downloadURL: SIGNED_URL })) as DownloadQueryApi
|
downloadURL: SIGNED_URL
|
||||||
);
|
})) as DownloadQueryApi);
|
||||||
const redirect = mock((url: string) => url);
|
const redirect = mock((url: string) => url);
|
||||||
|
|
||||||
const ok = await performInputHaloDownload(query, redirect);
|
const ok = await performInputHaloDownload(query, redirect);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Pure, side-effect-free download orchestration for the InputHalo landing
|
* Pure, side-effect-free download orchestration for the InputHalo landing
|
||||||
* page (task 06).
|
* page.
|
||||||
*
|
*
|
||||||
* Extracted from the route component so the acceptance criterion —
|
* Extracted from the route component so the acceptance criterion —
|
||||||
* "download button calls `api.downloads.getDownloadUrl` with `'inputhalo'`
|
* "download button calls `api.downloads.getDownloadUrl` with `'inputhalo'`
|
||||||
@@ -36,9 +36,7 @@ export const INPUTHALO_ICON_DEFAULT =
|
|||||||
* Keeps the helper decoupled from the full `api` surface and testable with a
|
* Keeps the helper decoupled from the full `api` surface and testable with a
|
||||||
* stub.
|
* stub.
|
||||||
*/
|
*/
|
||||||
export interface DownloadQueryApi {
|
export type DownloadQueryApi = (input: { asset_name: string }) => Promise<{ downloadURL: string }>
|
||||||
(input: { asset_name: string }): Promise<{ downloadURL: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the signed S3 download URL for the InputHalo DMG.
|
* Resolve the signed S3 download URL for the InputHalo DMG.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { For, createSignal } from "solid-js";
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import { buildMainSiteUrl } from "~/lib/site-context";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
import { useDarkMode } from "~/context/darkMode";
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
@@ -274,7 +275,7 @@ export default function InputHaloLanding() {
|
|||||||
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
|
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
|
||||||
<span>{site().displayName}</span>
|
<span>{site().displayName}</span>
|
||||||
<A
|
<A
|
||||||
href="https://freno.me"
|
href={buildMainSiteUrl()}
|
||||||
class="hover:text-text underline-offset-4 hover:underline"
|
class="hover:text-text underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
freno.me
|
freno.me
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* InputHalo privacy policy — `inputhalo.freno.me/privacy` (task 10).
|
* InputHalo privacy policy — `inputhalo.freno.me/privacy`.
|
||||||
*
|
*
|
||||||
* Net-new privacy policy for the InputHalo subdomain. InputHalo is a macOS
|
* Net-new privacy policy for the InputHalo subdomain. InputHalo is a macOS
|
||||||
* menu bar application (`LSUIElement: true`,
|
* menu bar application (`LSUIElement: true`,
|
||||||
* `LSApplicationCategoryType: public.app-category.productivity`) — a
|
* `LSApplicationCategoryType: public.app-category.productivity`) — a
|
||||||
* productivity utility that lives in the system menu bar. Following the task
|
* productivity utility that lives in the system menu bar. Gaze's privacy policy is the template for macOS menu bar apps
|
||||||
* notes, Gaze's privacy policy is the template for macOS menu bar apps
|
|
||||||
* (both are local-only menu bar utilities), so this policy mirrors Gaze's
|
* (both are local-only menu bar utilities), so this policy mirrors Gaze's
|
||||||
* structure and language while describing InputHalo's own practices.
|
* structure and language while describing InputHalo's own practices.
|
||||||
*
|
*
|
||||||
@@ -16,7 +15,7 @@
|
|||||||
* - Settings and any cached state are stored locally using standard
|
* - Settings and any cached state are stored locally using standard
|
||||||
* macOS mechanisms and are never sent off-device.
|
* macOS mechanisms and are never sent off-device.
|
||||||
*
|
*
|
||||||
* PageHead is site-aware (task 02): only the base title is supplied; the
|
* PageHead is site-aware: only the base title is supplied; the
|
||||||
* ` | InputHalo` suffix, `https://inputhalo.freno.me/privacy` canonical, and
|
* ` | InputHalo` suffix, `https://inputhalo.freno.me/privacy` canonical, and
|
||||||
* OG image are derived automatically. Internal links use public
|
* OG image are derived automatically. Internal links use public
|
||||||
* subdomain-relative paths (`/contact`) consistent with nav-config.ts and
|
* subdomain-relative paths (`/contact`) consistent with nav-config.ts and
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
/**
|
/**
|
||||||
* Life and Lineage contact page (`lineage.freno.me/contact`).
|
* Life and Lineage contact page (`lineage.freno.me/contact`).
|
||||||
*
|
*
|
||||||
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
|
* Thin wrapper over the shared `<ContactForm>`. Site awareness —
|
||||||
* subject prefix `[Lineage]`, recipient label, heading, and PageHead metadata
|
* subject prefix `[Lineage]`, recipient label, heading, and PageHead metadata
|
||||||
* — is derived from `useSite()` inside the component via
|
* — is derived from `useSite()` inside the component via
|
||||||
* `CONTACT_CONTEXT.lineage`.
|
* `CONTACT_CONTEXT.lineage`.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the Lineage per-subdomain account-deletion page content
|
* Unit tests for the Lineage per-subdomain account-deletion page content
|
||||||
* (task 11).
|
* (see `./deletion.tsx`).
|
||||||
*
|
*
|
||||||
* Asserts against pure constants exported from `deletion-content.ts` — no
|
* Asserts against pure constants exported from `deletion-content.ts` — no
|
||||||
* solid-js / router / DOM. Covers the task-11 acceptance matrix:
|
* solid-js / router / DOM. Covers the acceptance matrix:
|
||||||
* - Product discriminator is `"lineage"` (selects Lineage-branded email).
|
* - Product discriminator is `"lineage"` (selects Lineage-branded email).
|
||||||
* - Cooldown cookie name is the legacy `deletionRequestSent` so an in-flight
|
* - Cooldown cookie name is the legacy `deletionRequestSent` so an in-flight
|
||||||
* cooldown survives the `/deletion/life-and-lineage` → subdomain redirect.
|
* cooldown survives the `/deletion/life-and-lineage` → subdomain redirect.
|
||||||
@@ -26,14 +26,14 @@ import {
|
|||||||
} from "~/server/api/routers/deletion-email";
|
} from "~/server/api/routers/deletion-email";
|
||||||
|
|
||||||
describe("Lineage deletion — product discriminator", () => {
|
describe("Lineage deletion — product discriminator", () => {
|
||||||
it("is \"lineage\" (selects Lineage-branded email)", () => {
|
it('is "lineage" (selects Lineage-branded email)', () => {
|
||||||
expect(DELETION_PRODUCT_KEY).toBe("lineage");
|
expect(DELETION_PRODUCT_KEY).toBe("lineage");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is accepted by the server-side product schema", () => {
|
it("is accepted by the server-side product schema", () => {
|
||||||
expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe(
|
expect(
|
||||||
true
|
DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success
|
||||||
);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,10 +44,8 @@ describe("Lineage deletion — cooldown cookie", () => {
|
|||||||
expect(DELETION_COOKIE_NAME).toBe("deletionRequestSent");
|
expect(DELETION_COOKIE_NAME).toBe("deletionRequestSent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches the server-side deletionCookieName(\"lineage\")", () => {
|
it('matches the server-side deletionCookieName("lineage")', () => {
|
||||||
expect(DELETION_COOKIE_NAME).toBe(
|
expect(DELETION_COOKIE_NAME).toBe(deletionCookieName(DELETION_PRODUCT_KEY));
|
||||||
deletionCookieName(DELETION_PRODUCT_KEY)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,14 +77,13 @@ describe("Lineage deletion — PageHead inputs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Lineage deletion — legacy redirect target", () => {
|
describe("Lineage deletion — legacy redirect target", () => {
|
||||||
it("points at the lineage subdomain deletion URL", () => {
|
it("points at the lineage subdomain deletion URL (derived from VITE_DOMAIN)", () => {
|
||||||
expect(LEGACY_DELETION_REDIRECT_TARGET).toBe(
|
// LEGACY_DELETION_REDIRECT_TARGET is now dynamically derived from
|
||||||
"https://lineage.freno.me/deletion"
|
// VITE_DOMAIN via buildSubdomainUrl("lineage", "/deletion"). Assert it
|
||||||
);
|
// is a valid absolute URL containing the lineage + deletion segments.
|
||||||
});
|
expect(LEGACY_DELETION_REDIRECT_TARGET).toMatch(/^https?:\/\//);
|
||||||
|
expect(LEGACY_DELETION_REDIRECT_TARGET).toContain("lineage");
|
||||||
it("is an absolute https URL", () => {
|
expect(LEGACY_DELETION_REDIRECT_TARGET).toContain("/deletion");
|
||||||
expect(LEGACY_DELETION_REDIRECT_TARGET.startsWith("https://")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not reference the legacy /deletion/life-and-lineage path", () => {
|
it("does not reference the legacy /deletion/life-and-lineage path", () => {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Pure content + metadata for the Lineage per-subdomain account-deletion page
|
* Pure content + metadata for the Lineage per-subdomain account-deletion page
|
||||||
* (task 11).
|
* (see `./deletion.tsx`).
|
||||||
*
|
*
|
||||||
* Imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so the
|
* Imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so the
|
||||||
* constants here can be unit-tested in `bun:test` without spinning up the
|
* constants here can be unit-tested in `bun:test` without spinning up the
|
||||||
* router / MetaProvider, mirroring the `landing-content.ts` /
|
* router / MetaProvider, mirroring the `landing-content.ts` /
|
||||||
* `downloads-content.ts` pattern.
|
* `downloads-content.ts` pattern.
|
||||||
*
|
*
|
||||||
* Cross-task contracts encoded here:
|
* Imports `buildSubdomainUrl` from `~/lib/site-context` (near-pure — reads
|
||||||
|
* `import.meta.env.VITE_DOMAIN`) so redirect targets are env-aware.
|
||||||
|
*
|
||||||
|
* Contracts encoded here:
|
||||||
* - `DELETION_PRODUCT_KEY` is the `product` discriminator passed to the
|
* - `DELETION_PRODUCT_KEY` is the `product` discriminator passed to the
|
||||||
* generalized `misc.sendDeletionRequestEmail` mutation
|
* generalized `misc.sendDeletionRequestEmail` mutation
|
||||||
* (`src/server/api/routers/misc.ts`) so the email copy + cooldown cookie
|
* (`src/server/api/routers/misc.ts`) so the email copy + cooldown cookie
|
||||||
@@ -32,6 +35,9 @@ import type { PageHeadProps } from "~/components/page-head-meta";
|
|||||||
* Product discriminator for the generalized `sendDeletionRequestEmail`
|
* Product discriminator for the generalized `sendDeletionRequestEmail`
|
||||||
* mutation. The Lineage flow is the original / default product.
|
* mutation. The Lineage flow is the original / default product.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||||
|
|
||||||
export const DELETION_PRODUCT_KEY = "lineage" as const;
|
export const DELETION_PRODUCT_KEY = "lineage" as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,9 +69,11 @@ export const PAGE_META: PageHeadProps = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonical absolute URL the legacy `/deletion/life-and-lineage` route
|
* Canonical absolute URL the legacy `/deletion/life-and-lineage` route
|
||||||
* 308-redirects to (task 11). Kept here so tests can assert the redirect
|
* 308-redirects to. Kept here so tests can assert the redirect
|
||||||
* target without importing the route module (which would pull the server
|
* target without importing the route module (which would pull the server
|
||||||
* runtime).
|
* runtime).
|
||||||
*/
|
*/
|
||||||
export const LEGACY_DELETION_REDIRECT_TARGET =
|
export const LEGACY_DELETION_REDIRECT_TARGET = buildSubdomainUrl(
|
||||||
"https://lineage.freno.me/deletion";
|
"lineage",
|
||||||
|
"/deletion"
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Lineage per-subdomain account-deletion page — `lineage.freno.me/deletion`
|
* Lineage per-subdomain account-deletion page — `lineage.freno.me/deletion`
|
||||||
* (task 11).
|
* (see `./deletion-content.ts`).
|
||||||
*
|
*
|
||||||
* Migrated from `src/routes/deletion/life-and-lineage.tsx` (which is now a
|
* Migrated from `src/routes/deletion/life-and-lineage.tsx` (which is now a
|
||||||
* 308 redirect to this public URL — see `LEGACY_DELETION_REDIRECT_TARGET`).
|
* 308 redirect to this public URL — see `LEGACY_DELETION_REDIRECT_TARGET`).
|
||||||
*
|
*
|
||||||
* Served at the public browser path `/deletion` (vercel.json host rewrites
|
* Served at the public browser path `/deletion` (vercel.json host rewrites
|
||||||
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
|
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
|
||||||
* browser URL clean — task 02 canonical rule). The nav-config "Account
|
* browser URL clean. The nav-config "Account
|
||||||
* Deletion" entry points at this path (task 04).
|
* Deletion" entry points at this path.
|
||||||
*
|
*
|
||||||
* Deletion flow:
|
* Deletion flow:
|
||||||
* - Reuses the shared `DeletionForm` component, now generalized to forward
|
* - Reuses the shared `DeletionForm` component, now generalized to forward
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
*
|
*
|
||||||
* Site-awareness:
|
* Site-awareness:
|
||||||
* - `<PageHead>` reads `useSite()` → lineage title suffix + canonical are
|
* - `<PageHead>` reads `useSite()` → lineage title suffix + canonical are
|
||||||
* derived automatically (task 02).
|
* derived automatically.
|
||||||
* - No auth — the deletion request is email-based (the requester may be
|
* - No auth — the deletion request is email-based (the requester may be
|
||||||
* locked out of their account), NOT an authenticated self-delete.
|
* locked out of their account), NOT an authenticated self-delete.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the Lineage per-subdomain downloads page content (task 11).
|
* Unit tests for the Lineage per-subdomain downloads page content.
|
||||||
*
|
*
|
||||||
* Mirrors the `landing-content.test.ts` pattern: assert against pure
|
* Mirrors the `landing-content.test.ts` pattern: assert against pure
|
||||||
* constants exported from `downloads-content.ts` (no solid-js / router /
|
* constants exported from `downloads-content.ts` (no solid-js / router /
|
||||||
* DOM). This covers the task-11 acceptance matrix that's structurally
|
* DOM). This covers the acceptance matrix that's structurally
|
||||||
* verifiable without rendering:
|
* verifiable without rendering:
|
||||||
* - APK asset key is `"lineage"` (the tRPC key the downloads router maps to
|
* - APK asset key is `"lineage"` (the tRPC key the downloads router maps to
|
||||||
* `Life and Lineage.apk`) — must match the unified downloads page's key
|
* `Life and Lineage.apk`) — must match the unified downloads page's key
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Pure content + metadata for the Lineage per-subdomain downloads page
|
* Pure content + metadata for the Lineage per-subdomain downloads page
|
||||||
* (task 11).
|
* (see `./downloads.tsx`).
|
||||||
*
|
*
|
||||||
* Mirrors the `landing-content.ts` / `page-head-meta.ts` / `nav-config.ts`
|
* Mirrors the `landing-content.ts` / `page-head-meta.ts` / `nav-config.ts`
|
||||||
* pattern: imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so
|
* pattern: imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* values; keeping them externalized means changes to the download target /
|
* values; keeping them externalized means changes to the download target /
|
||||||
* store link surface as test failures rather than silent regressions.
|
* store link surface as test failures rather than silent regressions.
|
||||||
*
|
*
|
||||||
* Cross-task contracts encoded here:
|
* Contracts encoded here:
|
||||||
* - `LINEAGE_DOWNLOAD_ASSET` is the tRPC `downloads.getDownloadUrl` asset key
|
* - `LINEAGE_DOWNLOAD_ASSET` is the tRPC `downloads.getDownloadUrl` asset key
|
||||||
* (`"lineage"`) → resolves to `Life and Lineage.apk` in
|
* (`"lineage"`) → resolves to `Life and Lineage.apk` in
|
||||||
* `src/server/api/routers/downloads.ts`. It MUST match the key used by the
|
* `src/server/api/routers/downloads.ts`. It MUST match the key used by the
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
* unified downloads page, so the store front is consistent across origins.
|
* unified downloads page, so the store front is consistent across origins.
|
||||||
* - `LINEAGE_DOWNLOADS_META` is consumed verbatim by `<PageHead>`; the
|
* - `LINEAGE_DOWNLOADS_META` is consumed verbatim by `<PageHead>`; the
|
||||||
* per-site title suffix (` | Life and Lineage`) is appended automatically
|
* per-site title suffix (` | Life and Lineage`) is appended automatically
|
||||||
* by `resolvePageHeadMeta` (task 02), so `title` here is the BASE title
|
* by `resolvePageHeadMeta`, so `title` here is the BASE title
|
||||||
* only — do NOT include the suffix.
|
* only — do NOT include the suffix.
|
||||||
*/
|
*/
|
||||||
import type { PageHeadProps } from "~/components/page-head-meta";
|
import type { PageHeadProps } from "~/components/page-head-meta";
|
||||||
@@ -55,7 +55,7 @@ export const LINEAGE_APP_STORE_URL =
|
|||||||
* Public browser path back to the Lineage landing page (subdomain-relative).
|
* Public browser path back to the Lineage landing page (subdomain-relative).
|
||||||
*
|
*
|
||||||
* vercel.json rewrites `lineage.freno.me/` → the internal `/lineage/` route
|
* vercel.json rewrites `lineage.freno.me/` → the internal `/lineage/` route
|
||||||
* prefix while leaving the browser URL clean (task 02 canonical rule).
|
* prefix while leaving the browser URL clean.
|
||||||
*/
|
*/
|
||||||
export const LINEAGE_HOME_HREF = "/";
|
export const LINEAGE_HOME_HREF = "/";
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Lineage per-subdomain downloads page — `lineage.freno.me/downloads`
|
* Lineage per-subdomain downloads page — `lineage.freno.me/downloads`
|
||||||
* (task 11).
|
* (see `./deletion-content.ts`).
|
||||||
*
|
*
|
||||||
* Served at the public browser path `/downloads` (vercel.json host rewrites
|
* Served at the public browser path `/downloads` (vercel.json host rewrites
|
||||||
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
|
* `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the
|
||||||
* browser URL clean — task 02 canonical rule). The nav-config "Downloads"
|
* browser URL clean. The nav-config "Downloads"
|
||||||
* entry and the landing page's Google Play badge both point at this path
|
* entry and the landing page's Google Play badge both point at this path.
|
||||||
* (tasks 04 + 08).
|
|
||||||
*
|
*
|
||||||
* Download surface (mirrors the Lineage section of the unified
|
* Download surface (mirrors the Lineage section of the unified
|
||||||
* `freno.me/downloads` page, byte-identical asset source):
|
* `freno.me/downloads` page, byte-identical asset source):
|
||||||
* - Android APK via tRPC `downloads.getDownloadUrl({ asset_name: "lineage" })`
|
* - Android APK via tRPC `downloads.getDownloadUrl({ asset_name: "lineage" })`
|
||||||
* → S3 signed URL for `Life and Lineage.apk`. Reuses the shared
|
* → S3 signed URL for `Life and Lineage.apk`. Reuses the shared
|
||||||
* `downloadAsset` helper (task 05) so the click → redirect → S3 flow is a
|
* `downloadAsset` helper so the click → redirect → S3 flow is a
|
||||||
* single code path shared with the Gaze landing page.
|
* single code path shared with the Gaze landing page.
|
||||||
* - iOS App Store link (`LINEAGE_APP_STORE_URL`) — absolute external URL,
|
* - iOS App Store link (`LINEAGE_APP_STORE_URL`) — absolute external URL,
|
||||||
* identical to the link surfaced on the landing page + unified downloads.
|
* identical to the link surfaced on the landing page + unified downloads.
|
||||||
@@ -20,7 +19,7 @@
|
|||||||
* Site-awareness:
|
* Site-awareness:
|
||||||
* - `<PageHead>` reads `useSite()` → the lineage `titleSuffix`
|
* - `<PageHead>` reads `useSite()` → the lineage `titleSuffix`
|
||||||
* (` | Life and Lineage`) + canonical `https://lineage.freno.me/downloads`
|
* (` | Life and Lineage`) + canonical `https://lineage.freno.me/downloads`
|
||||||
* are derived automatically (task 02); we pass only the base title here.
|
* are derived automatically; we pass only the base title here.
|
||||||
* - No auth — Lineage's mobile JWT (`LINEAGE_JWT_SECRET`) is for the mobile
|
* - No auth — Lineage's mobile JWT (`LINEAGE_JWT_SECRET`) is for the mobile
|
||||||
* app's API calls, not the web downloads page.
|
* app's API calls, not the web downloads page.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the Lineage landing page content (task 08).
|
* Unit tests for the Lineage landing page content.
|
||||||
*
|
*
|
||||||
* Mirrors the `page-head-meta.ts` / `nav-config.ts` pattern: assert against
|
* Mirrors the `page-head-meta.ts` / `nav-config.ts` pattern: assert against
|
||||||
* pure constants exported from `landing-content.ts` (no solid-js / router /
|
* pure constants exported from `landing-content.ts` (no solid-js / router /
|
||||||
* DOM). This covers the task-08 acceptance matrix that's structurally
|
* DOM). This covers the acceptance matrix that's structurally
|
||||||
* verifiable without rendering:
|
* verifiable without rendering:
|
||||||
* - App Store link is present and correct
|
* - App Store link is present and correct
|
||||||
* - Google Play / downloads link targets the subdomain `/downloads` path
|
* - Google Play / downloads link targets the subdomain `/downloads` path
|
||||||
* (public browser path, NOT the vercel-rewritten `/lineage/downloads`)
|
* (public browser path, NOT the vercel-rewritten `/lineage/downloads`)
|
||||||
* - Feature highlights cover: dark fantasy, mobile, remote saves, PvP
|
* - Feature highlights cover: dark fantasy, mobile, remote saves, PvP
|
||||||
* - PageHead base title + description (suffix is added by PageHead, task 02)
|
* - PageHead base title + description (suffix is added by PageHead)
|
||||||
* - Legacy `/marketing/life-and-lineage` redirect target points at the
|
* - Legacy `/marketing/life-and-lineage` redirect target points at the
|
||||||
* Lineage subdomain.
|
* Lineage subdomain.
|
||||||
*
|
*
|
||||||
@@ -46,7 +46,7 @@ describe("Lineage landing — downloads link", () => {
|
|||||||
it("targets the subdomain-relative public browser path", () => {
|
it("targets the subdomain-relative public browser path", () => {
|
||||||
// NOT `/lineage/downloads` (the internal vercel-rewrite prefix) — vercel
|
// NOT `/lineage/downloads` (the internal vercel-rewrite prefix) — vercel
|
||||||
// rewrites `lineage.freno.me/downloads` → `/lineage/downloads` while
|
// rewrites `lineage.freno.me/downloads` → `/lineage/downloads` while
|
||||||
// leaving the browser URL clean, matching the canonical rule from task 02.
|
// leaving the browser URL clean, matching the canonical rule.
|
||||||
expect(DOWNLOADS_HREF).toBe("/downloads");
|
expect(DOWNLOADS_HREF).toBe("/downloads");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,9 +109,7 @@ describe("Lineage landing — feature highlights", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("covers the dark-fantasy / mobile / saves / PvP themes", () => {
|
it("covers the dark-fantasy / mobile / saves / PvP themes", () => {
|
||||||
const blob = FEATURES.map(
|
const blob = FEATURES.map((f) => `${f.title} ${f.description}`)
|
||||||
(f) => `${f.title} ${f.description}`
|
|
||||||
)
|
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
expect(blob).toContain("dark fantasy");
|
expect(blob).toContain("dark fantasy");
|
||||||
@@ -122,12 +120,16 @@ describe("Lineage landing — feature highlights", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Lineage landing — legacy redirect target", () => {
|
describe("Lineage landing — legacy redirect target", () => {
|
||||||
it("points at the lineage subdomain apex", () => {
|
it("points at the lineage subdomain (derived from VITE_DOMAIN)", () => {
|
||||||
expect(LEGACY_REDIRECT_TARGET).toBe("https://lineage.freno.me");
|
// LEGACY_REDIRECT_TARGET is now dynamically derived from VITE_DOMAIN
|
||||||
|
// via buildSubdomainUrl("lineage"). In dev it's path-based
|
||||||
|
// (http://localhost:3000/lineage); in prod it's host-based
|
||||||
|
// (https://lineage.freno.me). Assert it's a valid absolute URL.
|
||||||
|
expect(LEGACY_REDIRECT_TARGET).toMatch(/^https?:\/\/[^/]+\/[a-z]+$/i);
|
||||||
|
expect(LEGACY_REDIRECT_TARGET).toContain("lineage");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is an https absolute URL with no trailing path", () => {
|
it("has no trailing slash", () => {
|
||||||
expect(LEGACY_REDIRECT_TARGET.startsWith("https://")).toBe(true);
|
|
||||||
expect(LEGACY_REDIRECT_TARGET.endsWith("/")).toBe(false);
|
expect(LEGACY_REDIRECT_TARGET.endsWith("/")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Pure content + metadata for the Lineage subdomain landing page (task 08).
|
* Pure content + metadata for the Lineage subdomain landing page.
|
||||||
*
|
*
|
||||||
* Intentionally imports NOTHING from solid-js / @solidjs/router / @solidjs/meta
|
* Intentionally imports NOTHING from solid-js / @solidjs/router / @solidjs/meta
|
||||||
* so the constant set here can be unit-tested in `bun:test` without spinning
|
* so the constant set here can be unit-tested in `bun:test` without spinning
|
||||||
@@ -12,27 +12,29 @@
|
|||||||
* title/description) is asserted against in `landing-content.test.ts` without
|
* title/description) is asserted against in `landing-content.test.ts` without
|
||||||
* a DOM render.
|
* a DOM render.
|
||||||
*
|
*
|
||||||
* Cross-task contracts encoded here:
|
* Contracts encoded here:
|
||||||
* - `APP_STORE_URL` is the canonical App Store link the marketing page has
|
* - `APP_STORE_URL` is the canonical App Store link the marketing page has
|
||||||
* always surfaced (kept stable across the migration).
|
* always surfaced (kept stable across the migration).
|
||||||
* - `DOWNLOADS_HREF` is the **public browser path** on the lineage subdomain
|
* - `DOWNLOADS_HREF` is the **public browser path** on the lineage subdomain
|
||||||
* (`/downloads`), NOT the internal vercel-rewritten prefix `/lineage/downloads`.
|
* (`/downloads`), NOT the internal vercel-rewritten prefix `/lineage/downloads`.
|
||||||
* This matches the canonical-URL rule from task 02 and the nav-config rule
|
* This matches the canonical-URL rule and the nav-config rule
|
||||||
* from task 04: vercel.json maps `lineage.freno.me/downloads` →
|
* from the spec: vercel.json maps `lineage.freno.me/downloads` →
|
||||||
* `/lineage/downloads` server-side while the browser sees `/downloads`.
|
* `/lineage/downloads` server-side while the browser sees `/downloads`.
|
||||||
* Task 11 will create the matching `src/routes/lineage/downloads.tsx`.
|
* Task 11 will create the matching `src/routes/lineage/downloads.tsx`.
|
||||||
* - `PAGE_META` is consumed verbatim by `<PageHead>`; the per-site title
|
* - `PAGE_META` is consumed verbatim by `<PageHead>`; the per-site title
|
||||||
* suffix (` | Life and Lineage`) is appended automatically by
|
* suffix (` | Life and Lineage`) is appended automatically by
|
||||||
* `resolvePageHeadMeta` (task 02), so the `title` here is the BASE title
|
* `resolvePageHeadMeta`, so the `title` here is the BASE title
|
||||||
* only — do NOT include the suffix.
|
* only — do NOT include the suffix.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||||
|
|
||||||
/** Apple App Store link — surfaced unchanged from the legacy marketing page. */
|
/** Apple App Store link — surfaced unchanged from the legacy marketing page. */
|
||||||
export const APP_STORE_URL =
|
export const APP_STORE_URL =
|
||||||
"https://apps.apple.com/us/app/life-and-lineage/id6737252442";
|
"https://apps.apple.com/us/app/life-and-lineage/id6737252442";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public browser path to the per-subdomain downloads page (task 11).
|
* Public browser path to the per-subdomain downloads page.
|
||||||
* Subdomain-relative: renders `lineage.freno.me/downloads` in the browser.
|
* Subdomain-relative: renders `lineage.freno.me/downloads` in the browser.
|
||||||
*/
|
*/
|
||||||
export const DOWNLOADS_HREF = "/downloads";
|
export const DOWNLOADS_HREF = "/downloads";
|
||||||
@@ -99,4 +101,4 @@ export const FEATURES: readonly LineageFeature[] = [
|
|||||||
* so tests can assert the redirect target without importing the route module
|
* so tests can assert the redirect target without importing the route module
|
||||||
* (which would pull in the server runtime).
|
* (which would pull in the server runtime).
|
||||||
*/
|
*/
|
||||||
export const LEGACY_REDIRECT_TARGET = "https://lineage.freno.me";
|
export const LEGACY_REDIRECT_TARGET = buildSubdomainUrl("lineage");
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Life and Lineage privacy policy — `lineage.freno.me/privacy` (task 10).
|
* Life and Lineage privacy policy — `lineage.freno.me/privacy`.
|
||||||
*
|
*
|
||||||
* Migrated verbatim from the legacy
|
* Migrated verbatim from the legacy
|
||||||
* `src/routes/privacy-policy/life-and-lineage.tsx` route so there is zero
|
* `src/routes/privacy-policy/life-and-lineage.tsx` route so there is zero
|
||||||
* content loss; the old route now 308-redirects here (see
|
* content loss; the old route now 308-redirects here (see
|
||||||
* `src/routes/privacy-policy/life-and-lineage.tsx`). PageHead is site-aware
|
* `src/routes/privacy-policy/life-and-lineage.tsx`). PageHead is site-aware
|
||||||
* (task 02) so the Lineage `titleSuffix` (` | Life and Lineage`), canonical
|
* so the Lineage `titleSuffix` (` | Life and Lineage`), canonical
|
||||||
* (`https://lineage.freno.me/privacy`), and OG image derive automatically —
|
* (`https://lineage.freno.me/privacy`), and OG image derive automatically —
|
||||||
* we only pass the base title.
|
* we only pass the base title.
|
||||||
*
|
*
|
||||||
* Per task instructions, the account-deletion reference now points at the
|
* Per instructions, the account-deletion reference now points at the
|
||||||
* Lineage subdomain's deletion flow, served at the **public subdomain-relative
|
* Lineage subdomain's deletion flow, served at the **public subdomain-relative
|
||||||
* path** `/deletion` (vercel.json rewrites to `/lineage/deletion`). The
|
* path** `/deletion` (vercel.json rewrites to `/lineage/deletion`). The
|
||||||
* contact link similarly uses `/contact` (public subdomain path), consistent
|
* contact link similarly uses `/contact` (public subdomain path), consistent
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy `/marketing/gaze` route — redirected (task 05) to the new Gaze
|
* Legacy `/marketing/gaze` route — redirected to the new Gaze
|
||||||
* subdomain landing page at `gaze.freno.me`.
|
* subdomain landing page.
|
||||||
*
|
*
|
||||||
* Kept as a permanent 308 redirect so existing inbound links keep resolving
|
* Kept as a permanent 308 redirect so existing inbound links keep resolving
|
||||||
* to the canonical Gaze marketing home.
|
* to the canonical Gaze marketing home.
|
||||||
@@ -14,7 +16,7 @@ export default function GazeMarketingRedirect(): never {
|
|||||||
throw new Response(null, {
|
throw new Response(null, {
|
||||||
status: 308,
|
status: 308,
|
||||||
headers: {
|
headers: {
|
||||||
Location: "https://gaze.freno.me",
|
Location: buildSubdomainUrl("gaze"),
|
||||||
"Cache-Control": "public, max-age=86400"
|
"Cache-Control": "public, max-age=86400"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Legacy Life and Lineage marketing route — now a 308 permanent redirect to
|
* Legacy Life and Lineage marketing route — now a 308 permanent redirect to
|
||||||
* the Lineage subdomain (task 08).
|
* the Lineage subdomain.
|
||||||
*
|
*
|
||||||
* The marketing content has been migrated to `src/routes/lineage/index.tsx`
|
* The marketing content has been migrated to `src/routes/lineage/index.tsx`
|
||||||
* served at `lineage.freno.me` (vercel.json host rewrites map the subdomain to
|
* served at `lineage.freno.me` (vercel.json host rewrites map the subdomain to
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
/**
|
/**
|
||||||
* Nessa contact page (`nessa.freno.me/contact`).
|
* Nessa contact page (`nessa.freno.me/contact`).
|
||||||
*
|
*
|
||||||
* Thin wrapper over the shared `<ContactForm>` (task 09). Site awareness —
|
* Thin wrapper over the shared `<ContactForm>`. Site awareness —
|
||||||
* subject prefix `[Nessa]`, recipient label, heading, and PageHead metadata —
|
* subject prefix `[Nessa]`, recipient label, heading, and PageHead metadata —
|
||||||
* is derived from `useSite()` inside the component via
|
* is derived from `useSite()` inside the component via
|
||||||
* `CONTACT_CONTEXT.nessa`, so this route needs no explicit props.
|
* `CONTACT_CONTEXT.nessa`, so this route needs no explicit props.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
export const TAGLINE = "The fitness app that puts you first." as const;
|
export const TAGLINE = "The fitness app that puts you first." as const;
|
||||||
export const SUBTITLE =
|
export const SUBTITLE =
|
||||||
"Tired of Strava's paywalls and price hikes? Track, train, and connect — without the paywall." as const;
|
"Track, train, and connect — without the paywall." as const;
|
||||||
|
|
||||||
export const ICON_DEFAULT =
|
export const ICON_DEFAULT =
|
||||||
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
|
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
|
||||||
@@ -152,35 +152,21 @@ export const PRICING: readonly PricingTier[] = [
|
|||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export interface ComparisonRow {
|
export const COMPARISON: readonly { feature: string; nessa: string }[] = [
|
||||||
feature: string;
|
{ feature: "Segment leaderboards", nessa: "Free forever" },
|
||||||
strava: string;
|
{ feature: "Privacy", nessa: "On-device first" },
|
||||||
nessa: string;
|
{ feature: "Premium price", nessa: "From $4.99/mo" },
|
||||||
}
|
{ feature: "Apple Watch", nessa: "Native experience" }
|
||||||
|
|
||||||
export const COMPARISON: readonly ComparisonRow[] = [
|
|
||||||
{
|
|
||||||
feature: "Segment leaderboards",
|
|
||||||
strava: "Paywalled",
|
|
||||||
nessa: "Free forever"
|
|
||||||
},
|
|
||||||
{ feature: "Privacy", strava: "Server-side data", nessa: "On-device first" },
|
|
||||||
{ feature: "Premium price", strava: "$23.99/mo", nessa: "From $4.99/mo" },
|
|
||||||
{
|
|
||||||
feature: "Apple Watch",
|
|
||||||
strava: "Companion app",
|
|
||||||
nessa: "Native experience"
|
|
||||||
}
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const WHY_NESSA = [
|
export const WHY_NESSA = [
|
||||||
{
|
{
|
||||||
title: "Segment leaderboards free forever",
|
title: "Segment leaderboards free forever",
|
||||||
body: "Strava's most complained-about paywall is included in Nessa's free tier."
|
body: "The features other apps gate behind a subscription are included in Nessa's free tier."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Half the price of Strava",
|
title: "Affordable premium",
|
||||||
body: "Premium tiers at 50–60% of Strava's cost, with no surprise paywalls."
|
body: "Premium tiers start at $4.99/mo with no surprise paywalls."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Privacy-first",
|
title: "Privacy-first",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the Nessa per-subdomain account-deletion page content
|
* Unit tests for the Nessa per-subdomain account-deletion page content
|
||||||
* (task 11).
|
* (see `./deletion.tsx`).
|
||||||
*
|
*
|
||||||
* Asserts against pure constants exported from `deletion-content.ts` — no
|
* Asserts against pure constants exported from `deletion-content.ts` — no
|
||||||
* solid-js / router / DOM. Covers the task-11 acceptance matrix for the
|
* solid-js / router / DOM. Covers the acceptance matrix for the
|
||||||
* Nessa deletion flow:
|
* Nessa deletion flow:
|
||||||
* - Product discriminator is `"nessa"` (selects Nessa-branded email).
|
* - Product discriminator is `"nessa"` (selects Nessa-branded email).
|
||||||
* - Cooldown cookie name is Nessa-specific + matches the server-side
|
* - Cooldown cookie name is Nessa-specific + matches the server-side
|
||||||
@@ -26,21 +26,21 @@ import {
|
|||||||
describe("Nessa deletion — assessment outcome", () => {
|
describe("Nessa deletion — assessment outcome", () => {
|
||||||
it("defines a product discriminator (deletion flow IS implemented)", () => {
|
it("defines a product discriminator (deletion flow IS implemented)", () => {
|
||||||
// Nessa stores user data (nessa.ts: users, workouts, workoutPlans, … +
|
// Nessa stores user data (nessa.ts: users, workouts, workoutPlans, … +
|
||||||
// nessa-community.ts: clubs, clubMemberships). Per task 11 spec step 5,
|
// nessa-community.ts: clubs, clubMemberships).
|
||||||
// a deletion flow IS needed — this page provides it.
|
// a deletion flow IS needed — this page provides it.
|
||||||
expect(typeof DELETION_PRODUCT_KEY).toBe("string");
|
expect(typeof DELETION_PRODUCT_KEY).toBe("string");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Nessa deletion — product discriminator", () => {
|
describe("Nessa deletion — product discriminator", () => {
|
||||||
it("is \"nessa\" (selects Nessa-branded email)", () => {
|
it('is "nessa" (selects Nessa-branded email)', () => {
|
||||||
expect(DELETION_PRODUCT_KEY).toBe("nessa");
|
expect(DELETION_PRODUCT_KEY).toBe("nessa");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is accepted by the server-side product schema", () => {
|
it("is accepted by the server-side product schema", () => {
|
||||||
expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe(
|
expect(
|
||||||
true
|
DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success
|
||||||
);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,10 +49,8 @@ describe("Nessa deletion — cooldown cookie", () => {
|
|||||||
expect(DELETION_COOKIE_NAME).toBe("nessaDeletionRequestSent");
|
expect(DELETION_COOKIE_NAME).toBe("nessaDeletionRequestSent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches the server-side deletionCookieName(\"nessa\")", () => {
|
it('matches the server-side deletionCookieName("nessa")', () => {
|
||||||
expect(DELETION_COOKIE_NAME).toBe(
|
expect(DELETION_COOKIE_NAME).toBe(deletionCookieName(DELETION_PRODUCT_KEY));
|
||||||
deletionCookieName(DELETION_PRODUCT_KEY)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT collide with the Lineage cooldown cookie", () => {
|
it("does NOT collide with the Lineage cooldown cookie", () => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Pure content + metadata for the Nessa per-subdomain account-deletion page
|
* Pure content + metadata for the Nessa per-subdomain account-deletion page
|
||||||
* (task 11).
|
* (see `./deletion.tsx`).
|
||||||
*
|
*
|
||||||
* Mirrors the `lineage/deletion-content.ts` pattern: imports NOTHING from
|
* Mirrors the `lineage/deletion-content.ts` pattern: imports NOTHING from
|
||||||
* solid-js / @solidjs/router / @solidjs/meta so the constants here can be
|
* solid-js / @solidjs/router / @solidjs/meta so the constants here can be
|
||||||
* unit-tested in `bun:test` without spinning up the router / MetaProvider.
|
* unit-tested in `bun:test` without spinning up the router / MetaProvider.
|
||||||
*
|
*
|
||||||
* Nessa deletion assessment (see task 11 spec, step 5):
|
* Nessa deletion assessment:
|
||||||
* - Nessa DOES store user data. `src/server/api/routers/nessa.ts` defines
|
* - Nessa DOES store user data. `src/server/api/routers/nessa.ts` defines
|
||||||
* per-user tables (`users`, `authProviders`, `workouts`, `workoutPlans`,
|
* per-user tables (`users`, `authProviders`, `workouts`, `workoutPlans`,
|
||||||
* `planExercises`, `planSets`, `routePoints`, `exerciseLibrary`) backed
|
* `planExercises`, `planSets`, `routePoints`, `exerciseLibrary`) backed
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
* scope for the subdomain-routing feature); the email-request flow gives
|
* scope for the subdomain-routing feature); the email-request flow gives
|
||||||
* users a real, immediate deletion path today.
|
* users a real, immediate deletion path today.
|
||||||
*
|
*
|
||||||
* Cross-task contracts:
|
* Contracts:
|
||||||
* - `DELETION_PRODUCT_KEY = "nessa"` selects Nessa branding + the
|
* - `DELETION_PRODUCT_KEY = "nessa"` selects Nessa branding + the
|
||||||
* `nessaDeletionRequestSent` cooldown cookie (server-side
|
* `nessaDeletionRequestSent` cooldown cookie (server-side
|
||||||
* `deletionCookieName("nessa")`).
|
* `deletionCookieName("nessa")`).
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Nessa per-subdomain account-deletion page — `nessa.freno.me/deletion`
|
* Nessa per-subdomain account-deletion page — `nessa.freno.me/deletion`
|
||||||
* (task 11).
|
* (see `./deletion-content.ts`).
|
||||||
*
|
*
|
||||||
* Served at the public browser path `/deletion` (vercel.json host rewrites
|
* Served at the public browser path `/deletion` (vercel.json host rewrites
|
||||||
* `nessa.freno.me/*` → the internal `/nessa/*` route prefix, leaving the
|
* `nessa.freno.me/*` → the internal `/nessa/*` route prefix, leaving the
|
||||||
* browser URL clean — task 02 canonical rule).
|
* browser URL clean.
|
||||||
*
|
*
|
||||||
* Nessa deletion assessment (see `./deletion-content.ts` for the full
|
* Nessa deletion assessment (see `./deletion-content.ts` for the full
|
||||||
* rationale): Nessa stores user data (`users`, `workouts`, `workoutPlans`,
|
* rationale): Nessa stores user data (`users`, `workouts`, `workoutPlans`,
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
* request is email-based (the requester may be locked out of their Clerk
|
* request is email-based (the requester may be locked out of their Clerk
|
||||||
* session), NOT an authenticated self-delete. The nav-config does NOT list
|
* session), NOT an authenticated self-delete. The nav-config does NOT list
|
||||||
* a Nessa deletion link by default, so this page is reachable by direct URL
|
* a Nessa deletion link by default, so this page is reachable by direct URL
|
||||||
* + from the Nessa privacy policy (task-provided).
|
* + from the Nessa privacy policy.
|
||||||
*
|
*
|
||||||
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
|
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Serves `nessa.freno.me/` (and falls back from `src/routes/index.tsx`'s
|
* Serves `nessa.freno.me/` (and falls back from `src/routes/index.tsx`'s
|
||||||
* `useSite()` branch in dev). Reflects Nessa's actual product: a
|
* `useSite()` branch in dev). Reflects Nessa's actual product: a
|
||||||
* privacy-first Strava-alternative fitness app with segment leaderboards,
|
* privacy-first fitness app with segment leaderboards,
|
||||||
* clubs, challenges, Apple Watch support, and Free / Plus / Pro pricing tiers.
|
* clubs, challenges, Apple Watch support, and Free / Plus / Pro pricing tiers.
|
||||||
*
|
*
|
||||||
* Content is sourced from `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`
|
* Content is sourced from `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`
|
||||||
@@ -16,6 +16,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
import { useDarkMode } from "~/context/darkMode";
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
import { useSite } from "~/context/SiteContext";
|
import { useSite } from "~/context/SiteContext";
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
|
import { buildMainSiteUrl } from "~/lib/site-context";
|
||||||
import { NESSA_LANDING_META } from "./meta";
|
import { NESSA_LANDING_META } from "./meta";
|
||||||
import {
|
import {
|
||||||
TAGLINE,
|
TAGLINE,
|
||||||
@@ -158,7 +159,7 @@ export default function NessaLanding() {
|
|||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<div class="mb-12 text-center">
|
<div class="mb-12 text-center">
|
||||||
<h2 class="text-3xl font-bold md:text-4xl">
|
<h2 class="text-3xl font-bold md:text-4xl">
|
||||||
Premium features, half the price of Strava
|
Premium features, affordable pricing
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg">
|
<p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg">
|
||||||
Choose the plan that fits your training.
|
Choose the plan that fits your training.
|
||||||
@@ -229,20 +230,20 @@ export default function NessaLanding() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ─── Comparison vs Strava ─────────────────────────────────── */}
|
{/* ─── Feature highlights ─────────────────────────────────── */}
|
||||||
<section class="relative z-10 px-4 py-16">
|
<section class="relative z-10 px-4 py-16">
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="mx-auto max-w-4xl">
|
||||||
<h2 class="text-center text-3xl font-bold md:text-4xl">
|
<h2 class="text-center text-3xl font-bold md:text-4xl">
|
||||||
See how we compare
|
What you get with Nessa
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-text/70 mt-3 text-center text-base md:text-lg">
|
<p class="text-text/70 mt-3 text-center text-base md:text-lg">
|
||||||
Get more for less with Nessa.
|
Free features other apps charge for, plus affordable premium
|
||||||
|
tiers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="border-surface0 mt-8 overflow-hidden rounded-2xl border-2">
|
<div class="border-surface0 mt-8 overflow-hidden rounded-2xl border-2">
|
||||||
<div class="bg-surface0/60 grid grid-cols-3 px-6 py-4 text-sm font-semibold">
|
<div class="bg-surface0/60 grid grid-cols-2 px-6 py-4 text-sm font-semibold">
|
||||||
<span>Feature</span>
|
<span>Feature</span>
|
||||||
<span class="text-center">Strava</span>
|
|
||||||
<span class="text-center" style={{ color: brandColor() }}>
|
<span class="text-center" style={{ color: brandColor() }}>
|
||||||
Nessa
|
Nessa
|
||||||
</span>
|
</span>
|
||||||
@@ -250,13 +251,12 @@ export default function NessaLanding() {
|
|||||||
<For each={COMPARISON}>
|
<For each={COMPARISON}>
|
||||||
{(row, idx) => (
|
{(row, idx) => (
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-3 px-6 py-4 text-sm"
|
class="grid grid-cols-2 px-6 py-4 text-sm"
|
||||||
classList={{
|
classList={{
|
||||||
"bg-surface0/20": idx() % 2 === 1
|
"bg-surface0/20": idx() % 2 === 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{row.feature}</span>
|
<span>{row.feature}</span>
|
||||||
<span class="text-text/70 text-center">{row.strava}</span>
|
|
||||||
<span
|
<span
|
||||||
class="text-center font-medium"
|
class="text-center font-medium"
|
||||||
style={{ color: brandColor() }}
|
style={{ color: brandColor() }}
|
||||||
@@ -348,7 +348,7 @@ export default function NessaLanding() {
|
|||||||
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
|
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
|
||||||
<span>{site().displayName}</span>
|
<span>{site().displayName}</span>
|
||||||
<A
|
<A
|
||||||
href="https://freno.me"
|
href={buildMainSiteUrl()}
|
||||||
class="hover:text-text underline-offset-4 hover:underline"
|
class="hover:text-text underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
freno.me
|
freno.me
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Mirrors the `page-head-meta.ts` testability pattern — `nessa/meta.ts` is a
|
* Mirrors the `page-head-meta.ts` testability pattern — `nessa/meta.ts` is a
|
||||||
* pure module (no solid-js / @solidjs/router / @solidjs/meta imports) so
|
* pure module (no solid-js / @solidjs/router / @solidjs/meta imports) so
|
||||||
* `bun:test` can resolve it. Asserts the metadata matches Nessa's actual
|
* `bun:test` can resolve it. Asserts the metadata matches Nessa's actual
|
||||||
* product positioning as a privacy-first fitness / Strava-alternative app
|
* product positioning as a privacy-first fitness app
|
||||||
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
|
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
@@ -72,13 +72,13 @@ describe("Nessa landing page — PageHead metadata", () => {
|
|||||||
expect(meta.description?.toLowerCase()).toContain("challenges");
|
expect(meta.description?.toLowerCase()).toContain("challenges");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ogDescription mentions free leaderboards and Strava comparison", () => {
|
it("ogDescription mentions free leaderboards and affordable pricing", () => {
|
||||||
const meta = resolvePageHeadMeta(
|
const meta = resolvePageHeadMeta(
|
||||||
NESSA_LANDING_META,
|
NESSA_LANDING_META,
|
||||||
SITE_CONFIG.nessa,
|
SITE_CONFIG.nessa,
|
||||||
"/"
|
"/"
|
||||||
);
|
);
|
||||||
expect(meta.ogDescription?.toLowerCase()).toContain("leaderboards");
|
expect(meta.ogDescription?.toLowerCase()).toContain("leaderboards");
|
||||||
expect(meta.ogDescription?.toLowerCase()).toContain("strava");
|
expect(meta.ogDescription?.toLowerCase()).toContain("affordable");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* spinning up the router / MetaProvider / DOM, mirroring the
|
* spinning up the router / MetaProvider / DOM, mirroring the
|
||||||
* `page-head-meta.ts` / `nav-config.ts` testability pattern.
|
* `page-head-meta.ts` / `nav-config.ts` testability pattern.
|
||||||
*
|
*
|
||||||
* Nessa is positioned as a privacy-first fitness app and Strava alternative
|
* Nessa is positioned as a privacy-first fitness app
|
||||||
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
|
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
|
||||||
* The description still mentions community features (clubs, challenges) because
|
* The description still mentions community features (clubs, challenges) because
|
||||||
* those are real free-tier capabilities, but it now leads with the product's
|
* those are real free-tier capabilities, but it now leads with the product's
|
||||||
@@ -28,5 +28,5 @@ export const NESSA_LANDING_META: PageHeadProps = {
|
|||||||
"Nessa is the fitness app that puts you first. Track running, cycling, swimming and more; compete on free segment leaderboards; and connect with friends through clubs, community challenges, and a social feed — all while keeping your data on your device.",
|
"Nessa is the fitness app that puts you first. Track running, cycling, swimming and more; compete on free segment leaderboards; and connect with friends through clubs, community challenges, and a social feed — all while keeping your data on your device.",
|
||||||
ogTitle: "Nessa — The fitness app that puts you first",
|
ogTitle: "Nessa — The fitness app that puts you first",
|
||||||
ogDescription:
|
ogDescription:
|
||||||
"A privacy-first fitness app with segment leaderboards free forever, social clubs, community challenges, Apple Watch support, and premium tiers for less than Strava."
|
"A privacy-first fitness app with segment leaderboards free forever, social clubs, community challenges, Apple Watch support, and affordable premium tiers."
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Nessa privacy policy — `nessa.freno.me/privacy` (task 10).
|
* Nessa privacy policy — `nessa.freno.me/privacy`.
|
||||||
*
|
*
|
||||||
* Net-new privacy policy for the Nessa subdomain. Modeled on the Life and
|
* Net-new privacy policy for the Nessa subdomain. Modeled on the Life and
|
||||||
* Lineage policy (the template for products with user accounts, per the task
|
* Lineage policy (the template for products with user accounts) but scoped to Nessa's real data practices:
|
||||||
* notes) but scoped to Nessa's real data practices:
|
|
||||||
*
|
*
|
||||||
* - Authentication: user accounts are managed by Clerk
|
* - Authentication: user accounts are managed by Clerk
|
||||||
* (`src/server/nessa-auth.ts` verifies Clerk session JWTs via the Clerk
|
* (`src/server/nessa-auth.ts` verifies Clerk session JWTs via the Clerk
|
||||||
@@ -17,13 +16,13 @@
|
|||||||
* (`NessaConnectionFactory` in `src/server/db-connections.ts`), separate
|
* (`NessaConnectionFactory` in `src/server/db-connections.ts`), separate
|
||||||
* from the freno.me main DB and the Lineage DB.
|
* from the freno.me main DB and the Lineage DB.
|
||||||
*
|
*
|
||||||
* PageHead is site-aware (task 02): only the base title is supplied; the
|
* PageHead is site-aware: only the base title is supplied; the
|
||||||
* ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image
|
* ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image
|
||||||
* are derived automatically. Internal links use public subdomain-relative
|
* are derived automatically. Internal links use public subdomain-relative
|
||||||
* paths (`/contact`) consistent with nav-config.ts and page-head-meta.ts.
|
* paths (`/contact`) consistent with nav-config.ts and page-head-meta.ts.
|
||||||
*
|
*
|
||||||
* Nessa does not yet ship a dedicated account-deletion form route; per the
|
* Nessa does not yet ship a dedicated account-deletion form route; per the
|
||||||
* task notes ("reference the deletion flow if Nessa has user accounts"),
|
* notes ("reference the deletion flow if Nessa has user accounts"),
|
||||||
* account/data deletion is initiated by contacting us — Clerk user records
|
* account/data deletion is initiated by contacting us — Clerk user records
|
||||||
* and the associated Nessa community content are then purged manually until a
|
* and the associated Nessa community content are then purged manually until a
|
||||||
* self-serve flow is built.
|
* self-serve flow is built.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy Gaze privacy policy route — now a 308 permanent redirect to the Gaze
|
* Legacy Gaze privacy policy route — now a 308 permanent redirect to the Gaze
|
||||||
* subdomain (task 10).
|
* subdomain.
|
||||||
*
|
*
|
||||||
* The privacy policy content has been migrated to
|
* The privacy policy content has been migrated to
|
||||||
* `src/routes/gaze/privacy.tsx`, served at `gaze.freno.me/privacy`
|
* `src/routes/gaze/privacy.tsx`, served at `gaze.freno.me/privacy`
|
||||||
@@ -8,7 +10,7 @@
|
|||||||
* `/gaze/*` prefix). Keeping this route as a permanent (308) server-side
|
* `/gaze/*` prefix). Keeping this route as a permanent (308) server-side
|
||||||
* redirect — rather than a client `<Navigate>` — preserves SEO equity and
|
* redirect — rather than a client `<Navigate>` — preserves SEO equity and
|
||||||
* gives installed / linked URLs a stable resolution path, mirroring how the
|
* gives installed / linked URLs a stable resolution path, mirroring how the
|
||||||
* legacy Life and Lineage marketing page was redirected in task 08.
|
* legacy Life and Lineage marketing page was redirected.
|
||||||
*
|
*
|
||||||
* Implemented as a SolidStart API route (`GET` handler returning a Response)
|
* Implemented as a SolidStart API route (`GET` handler returning a Response)
|
||||||
* so the redirect happens before any rendering; the route no longer ships a
|
* so the redirect happens before any rendering; the route no longer ships a
|
||||||
@@ -18,7 +20,7 @@ export function GET() {
|
|||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 308,
|
status: 308,
|
||||||
headers: {
|
headers: {
|
||||||
Location: "https://gaze.freno.me/privacy",
|
Location: buildSubdomainUrl("gaze", "/privacy"),
|
||||||
"Cache-Control": "public, max-age=0, must-revalidate"
|
"Cache-Control": "public, max-age=0, must-revalidate"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy Life and Lineage privacy policy route — now a 308 permanent redirect
|
* Legacy Life and Lineage privacy policy route — now a 308 permanent redirect
|
||||||
* to the Lineage subdomain (task 10).
|
* to the Lineage subdomain.
|
||||||
*
|
*
|
||||||
* The privacy policy content has been migrated to
|
* The privacy policy content has been migrated to
|
||||||
* `src/routes/lineage/privacy.tsx`, served at `lineage.freno.me/privacy`
|
* `src/routes/lineage/privacy.tsx`, served at `lineage.freno.me/privacy`
|
||||||
@@ -8,7 +10,7 @@
|
|||||||
* `/lineage/*` prefix). Keeping this route as a permanent (308) server-side
|
* `/lineage/*` prefix). Keeping this route as a permanent (308) server-side
|
||||||
* redirect — rather than a client `<Navigate>` — preserves SEO equity and
|
* redirect — rather than a client `<Navigate>` — preserves SEO equity and
|
||||||
* gives installed / linked URLs a stable resolution path, mirroring how the
|
* gives installed / linked URLs a stable resolution path, mirroring how the
|
||||||
* legacy Life and Lineage marketing page was redirected in task 08.
|
* legacy Life and Lineage marketing page was redirected.
|
||||||
*
|
*
|
||||||
* Implemented as a SolidStart API route (`GET` handler returning a Response)
|
* Implemented as a SolidStart API route (`GET` handler returning a Response)
|
||||||
* so the redirect happens before any rendering; the route no longer ships a
|
* so the redirect happens before any rendering; the route no longer ships a
|
||||||
@@ -18,7 +20,7 @@ export function GET() {
|
|||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 308,
|
status: 308,
|
||||||
headers: {
|
headers: {
|
||||||
Location: "https://lineage.freno.me/privacy",
|
Location: buildSubdomainUrl("lineage", "/privacy"),
|
||||||
"Cache-Control": "public, max-age=0, must-revalidate"
|
"Cache-Control": "public, max-age=0, must-revalidate"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Host-aware sitemap.xml route handler (task 03).
|
* Host-aware sitemap.xml route handler.
|
||||||
*
|
*
|
||||||
* Reads the `Host` header to determine the active site, then generates a
|
* Reads the `Host` header to determine the active site, then generates a
|
||||||
* sitemap scoped to that site's routes with canonical URLs from the
|
* sitemap scoped to that site's routes with canonical URLs from the
|
||||||
* corresponding domain.
|
* corresponding domain.
|
||||||
*/
|
*/
|
||||||
import { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { getSiteFromEvent } from "~/server/site-context-server";
|
import { getSiteFromEvent } from "~/server/site-context-server";
|
||||||
import { SITEMAP_ROUTES } from "~/lib/sitemap-routes";
|
import { SITEMAP_ROUTES } from "~/lib/sitemap-routes";
|
||||||
import { generateSitemap } from "~/lib/sitemap-generate";
|
import { generateSitemap } from "~/lib/sitemap-generate";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the generalized account-deletion-request email helpers
|
* Unit tests for the generalized account-deletion-request email helpers
|
||||||
* (task 11).
|
* (see `misc.ts`).
|
||||||
*
|
*
|
||||||
* These are the pure, env-free helpers consumed by the
|
* These are the pure, env-free helpers consumed by the
|
||||||
* `misc.sendDeletionRequestEmail` tRPC mutation (re-exported from `misc.ts`).
|
* `misc.sendDeletionRequestEmail` tRPC mutation (re-exported from `misc.ts`).
|
||||||
@@ -25,11 +25,11 @@ import {
|
|||||||
} from "~/server/api/routers/deletion-email";
|
} from "~/server/api/routers/deletion-email";
|
||||||
|
|
||||||
describe("DELETION_PRODUCT_SCHEMA", () => {
|
describe("DELETION_PRODUCT_SCHEMA", () => {
|
||||||
it("accepts \"lineage\"", () => {
|
it('accepts "lineage"', () => {
|
||||||
expect(DELETION_PRODUCT_SCHEMA.safeParse("lineage").success).toBe(true);
|
expect(DELETION_PRODUCT_SCHEMA.safeParse("lineage").success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts \"nessa\"", () => {
|
it('accepts "nessa"', () => {
|
||||||
expect(DELETION_PRODUCT_SCHEMA.safeParse("nessa").success).toBe(true);
|
expect(DELETION_PRODUCT_SCHEMA.safeParse("nessa").success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,9 +50,7 @@ describe("deletionCookieName", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns distinct names per product", () => {
|
it("returns distinct names per product", () => {
|
||||||
expect(deletionCookieName("lineage")).not.toBe(
|
expect(deletionCookieName("lineage")).not.toBe(deletionCookieName("nessa"));
|
||||||
deletionCookieName("nessa")
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Pure helpers for the generalized account-deletion-request email flow
|
* Pure helpers for the generalized account-deletion-request email flow
|
||||||
* (task 11).
|
* (see `misc.ts`).
|
||||||
*
|
*
|
||||||
* Extracted from `src/server/api/routers/misc.ts` so they can be unit-tested
|
* Extracted from `src/server/api/routers/misc.ts` so they can be unit-tested
|
||||||
* in `bun:test` WITHOUT importing `~/env/server` (which validates ~30 secrets
|
* in `bun:test` WITHOUT importing `~/env/server` (which validates ~30 secrets
|
||||||
@@ -49,7 +49,7 @@ export interface DeletionEmailContent {
|
|||||||
* The operator email identifies the request name + requester email; the user
|
* The operator email identifies the request name + requester email; the user
|
||||||
* email identifies the account being deleted + the 24h cancellation window.
|
* email identifies the account being deleted + the 24h cancellation window.
|
||||||
* The `product` discriminator switches branding between Lineage (the original
|
* The `product` discriminator switches branding between Lineage (the original
|
||||||
* flow) and Nessa (task 11 — Nessa stores user data in its own Turso DB).
|
* flow) and Nessa (Nessa stores user data in its own Turso DB).
|
||||||
*
|
*
|
||||||
* `email` is interpolated verbatim into the HTML bodies. It has already been
|
* `email` is interpolated verbatim into the HTML bodies. It has already been
|
||||||
* validated as a well-formed email by the tRPC input schema, and Sendinblue
|
* validated as a well-formed email by the tRPC input schema, and Sendinblue
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ mock.module("~/env/server", () => ({
|
|||||||
TURSO_DB_API_TOKEN: "test-token",
|
TURSO_DB_API_TOKEN: "test-token",
|
||||||
NESSA_DB_URL: "libsql://nessa-test.turso.io",
|
NESSA_DB_URL: "libsql://nessa-test.turso.io",
|
||||||
NESSA_DB_TOKEN: "test-token",
|
NESSA_DB_TOKEN: "test-token",
|
||||||
// Clerk env vars (required after migration in task 02)
|
// Clerk env vars (required after migration)
|
||||||
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
||||||
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
||||||
},
|
},
|
||||||
@@ -61,9 +61,8 @@ mock.module("~/env/server", () => ({
|
|||||||
|
|
||||||
// Import after env mock is registered. These are the real verification
|
// Import after env mock is registered. These are the real verification
|
||||||
// functions used by web and Lineage surfaces respectively.
|
// functions used by web and Lineage surfaces respectively.
|
||||||
const { verifyAuthToken, verifyLineageAuthToken } = await import(
|
const { verifyAuthToken, verifyLineageAuthToken } =
|
||||||
"~/server/auth"
|
await import("~/server/auth");
|
||||||
);
|
|
||||||
// Issuer/audience claims the Lineage router stamps onto its tokens.
|
// Issuer/audience claims the Lineage router stamps onto its tokens.
|
||||||
const { LINEAGE_CONFIG } = await import("~/config");
|
const { LINEAGE_CONFIG } = await import("~/config");
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* p8-001 / p8-008 regression tests — S3 procedure lockdown & input sanitization.
|
* p8-001 / p8-008 regression tests — S3 procedure lockdown & input sanitization.
|
||||||
*
|
*
|
||||||
* These tests verify the security remediation from task 02 without standing up
|
* These tests verify the security remediation without standing up
|
||||||
* the full tRPC router (which requires S3 / env / database / vinxi-runtime
|
* the full tRPC router (which requires S3 / env / database / vinxi-runtime
|
||||||
* mocking that is unreliable under `bun test`). They follow the proven pattern
|
* mocking that is unreliable under `bun test`). They follow the proven pattern
|
||||||
* from task 03 (p8-002): direct unit tests of the authz/sanitization helpers
|
* (p8-002): direct unit tests of the authz/sanitization helpers
|
||||||
* plus a static source-code audit that the previously-`publicProcedure` S3
|
* plus a static source-code audit that the previously-`publicProcedure` S3
|
||||||
* endpoints are now `csrfProtectedProcedure` (i.e. no longer anonymous).
|
* endpoints are now `csrfProtectedProcedure` (i.e. no longer anonymous).
|
||||||
*
|
*
|
||||||
@@ -136,7 +136,9 @@ describe("p8-001 / p8-008 static source audit", () => {
|
|||||||
for (const proc of S3_PROCEDURES) {
|
for (const proc of S3_PROCEDURES) {
|
||||||
it(`${proc} is not declared as publicProcedure`, () => {
|
it(`${proc} is not declared as publicProcedure`, () => {
|
||||||
// Match the procedure declaration line and ensure it is not publicProcedure.
|
// Match the procedure declaration line and ensure it is not publicProcedure.
|
||||||
const re = new RegExp(`\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure|nessaProcedure)`);
|
const re = new RegExp(
|
||||||
|
`\\b${proc}\\s*:\\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure|adminProcedure|nessaProcedure)`
|
||||||
|
);
|
||||||
const m = SOURCE.match(re);
|
const m = SOURCE.match(re);
|
||||||
expect(m, `${proc} declaration not found`).not.toBeNull();
|
expect(m, `${proc} declaration not found`).not.toBeNull();
|
||||||
expect(m![1]).not.toBe("publicProcedure");
|
expect(m![1]).not.toBe("publicProcedure");
|
||||||
@@ -144,7 +146,9 @@ describe("p8-001 / p8-008 static source audit", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("getDownloadUrl (Sparkle updater) remains the only public S3 endpoint", () => {
|
it("getDownloadUrl (Sparkle updater) remains the only public S3 endpoint", () => {
|
||||||
const m = SOURCE.match(/\bgetDownloadUrl\s*:\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure)/);
|
const m = SOURCE.match(
|
||||||
|
/\bgetDownloadUrl\s*:\s*(publicProcedure|csrfProtectedProcedure|protectedProcedure)/
|
||||||
|
);
|
||||||
expect(m, "getDownloadUrl declaration not found").not.toBeNull();
|
expect(m, "getDownloadUrl declaration not found").not.toBeNull();
|
||||||
expect(m![1]).toBe("publicProcedure");
|
expect(m![1]).toBe("publicProcedure");
|
||||||
});
|
});
|
||||||
@@ -153,7 +157,9 @@ describe("p8-001 / p8-008 static source audit", () => {
|
|||||||
// Both simpleDeleteImage and deleteImage must call the ownership guard.
|
// Both simpleDeleteImage and deleteImage must call the ownership guard.
|
||||||
const deleteBlocks = SOURCE.split(/(\bsimpleDeleteImage:|\bdeleteImage:)/);
|
const deleteBlocks = SOURCE.split(/(\bsimpleDeleteImage:|\bdeleteImage:)/);
|
||||||
// Count occurrences of the ownership call within the delete mutation bodies.
|
// Count occurrences of the ownership call within the delete mutation bodies.
|
||||||
const occurrences = (SOURCE.match(/assertS3KeyOwnership\(input\.key/g) || []).length;
|
const occurrences = (
|
||||||
|
SOURCE.match(/assertS3KeyOwnership\(input\.key/g) || []
|
||||||
|
).length;
|
||||||
expect(occurrences).toBeGreaterThanOrEqual(2);
|
expect(occurrences).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Account-deletion request email (task 11 — product-aware)
|
// Account-deletion request email — product-aware
|
||||||
// ============================================================
|
// ============================================================
|
||||||
//
|
//
|
||||||
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
|
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
|
||||||
@@ -368,7 +368,7 @@ export const miscRouter = createTRPCRouter({
|
|||||||
turnstileToken: z.string().min(1, "Please complete the security check"),
|
turnstileToken: z.string().min(1, "Please complete the security check"),
|
||||||
/**
|
/**
|
||||||
* Per-site subject prefix injected into the outbound email subject
|
* Per-site subject prefix injected into the outbound email subject
|
||||||
* (task 09). Defaults to `"freno.me"` so existing callers (pre-task-09
|
* Defaults to `"freno.me"` so existing callers
|
||||||
* main-site contact form) keep emitting the byte-identical legacy
|
* main-site contact form) keep emitting the byte-identical legacy
|
||||||
* subject `"freno.me Contact Request"`.
|
* subject `"freno.me Contact Request"`.
|
||||||
*/
|
*/
|
||||||
@@ -499,7 +499,7 @@ export const miscRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
/** Product discriminator (task 11) — defaults to "lineage" for backward compat. */
|
/** Product discriminator — defaults to "lineage" for backward compat. */
|
||||||
product: DELETION_PRODUCT_SCHEMA.default("lineage")
|
product: DELETION_PRODUCT_SCHEMA.default("lineage")
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,9 +65,15 @@ function initSchema() {
|
|||||||
db = new Database(":memory:");
|
db = new Database(":memory:");
|
||||||
db.run("PRAGMA foreign_keys = ON");
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
db.run("CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)");
|
db.run(
|
||||||
db.run("CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)");
|
"CREATE TABLE clubMemberships (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, role TEXT, joinedAt TEXT)"
|
||||||
db.run("CREATE TABLE clubChallenges (id TEXT PRIMARY KEY, clubId TEXT, title TEXT, description TEXT, goalType TEXT, goalValue REAL, startDate TEXT, endDate TEXT, createdBy TEXT, status TEXT, createdAt TEXT, updatedAt TEXT)");
|
);
|
||||||
|
db.run(
|
||||||
|
"CREATE TABLE clubPosts (id TEXT PRIMARY KEY, clubId TEXT, userId TEXT, content TEXT, postType TEXT, challengeId TEXT, createdAt TEXT, updatedAt TEXT)"
|
||||||
|
);
|
||||||
|
db.run(
|
||||||
|
"CREATE TABLE clubChallenges (id TEXT PRIMARY KEY, clubId TEXT, title TEXT, description TEXT, goalType TEXT, goalValue REAL, startDate TEXT, endDate TEXT, createdBy TEXT, status TEXT, createdAt TEXT, updatedAt TEXT)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function seed() {
|
function seed() {
|
||||||
@@ -86,7 +92,17 @@ function seed() {
|
|||||||
// Challenge CH in club C, created by A.
|
// Challenge CH in club C, created by A.
|
||||||
db.run(
|
db.run(
|
||||||
"INSERT INTO clubChallenges (id, clubId, title, description, goalType, goalValue, startDate, endDate, createdBy, status, createdAt, updatedAt) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))",
|
"INSERT INTO clubChallenges (id, clubId, title, description, goalType, goalValue, startDate, endDate, createdBy, status, createdAt, updatedAt) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))",
|
||||||
[CHALLENGE_CH, CLUB_C, "Run 5k", "distance", 5000, "2025-01-01", "2025-12-31", USER_A, "active"]
|
[
|
||||||
|
CHALLENGE_CH,
|
||||||
|
CLUB_C,
|
||||||
|
"Run 5k",
|
||||||
|
"distance",
|
||||||
|
5000,
|
||||||
|
"2025-01-01",
|
||||||
|
"2025-12-31",
|
||||||
|
USER_A,
|
||||||
|
"active"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +142,9 @@ describe("p8-003: resolveClubIdFromPost", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws NOT_FOUND for a missing post", async () => {
|
it("throws NOT_FOUND for a missing post", async () => {
|
||||||
expect(await errCode(resolveClubIdFromPost(conn, "no-such-post"))).toBe("NOT_FOUND");
|
expect(await errCode(resolveClubIdFromPost(conn, "no-such-post"))).toBe(
|
||||||
|
"NOT_FOUND"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,17 +154,23 @@ describe("p8-003: resolveClubIdFromChallenge", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws NOT_FOUND for a missing challenge", async () => {
|
it("throws NOT_FOUND for a missing challenge", async () => {
|
||||||
expect(await errCode(resolveClubIdFromChallenge(conn, "no-such-challenge"))).toBe("NOT_FOUND");
|
expect(
|
||||||
|
await errCode(resolveClubIdFromChallenge(conn, "no-such-challenge"))
|
||||||
|
).toBe("NOT_FOUND");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("p8-003: requireClubMembership", () => {
|
describe("p8-003: requireClubMembership", () => {
|
||||||
it("passes silently for a member", async () => {
|
it("passes silently for a member", async () => {
|
||||||
await expect(requireClubMembership(conn, CLUB_C, USER_A)).resolves.toBeUndefined();
|
await expect(
|
||||||
|
requireClubMembership(conn, CLUB_C, USER_A)
|
||||||
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws FORBIDDEN for a non-member", async () => {
|
it("throws FORBIDDEN for a non-member", async () => {
|
||||||
expect(await errCode(requireClubMembership(conn, CLUB_C, USER_B))).toBe("FORBIDDEN");
|
expect(await errCode(requireClubMembership(conn, CLUB_C, USER_B))).toBe(
|
||||||
|
"FORBIDDEN"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,23 +184,31 @@ describe("p8-003: endpoint authorization sequences (resolve → require)", () =>
|
|||||||
// social.getPost / addComment / comments / like / unlike
|
// social.getPost / addComment / comments / like / unlike
|
||||||
it("getPost/addComment/comments/like/unlike: non-member B rejected with FORBIDDEN", async () => {
|
it("getPost/addComment/comments/like/unlike: non-member B rejected with FORBIDDEN", async () => {
|
||||||
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
|
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||||
|
"FORBIDDEN"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getPost/addComment/comments/like/unlike: member A allowed", async () => {
|
it("getPost/addComment/comments/like/unlike: member A allowed", async () => {
|
||||||
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||||
await expect(requireClubMembership(conn, clubId, USER_A)).resolves.toBeUndefined();
|
await expect(
|
||||||
|
requireClubMembership(conn, clubId, USER_A)
|
||||||
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
// challenges.leave / challenges.submitProgress
|
// challenges.leave / challenges.submitProgress
|
||||||
it("challenges.leave / submitProgress: non-member B rejected with FORBIDDEN", async () => {
|
it("challenges.leave / submitProgress: non-member B rejected with FORBIDDEN", async () => {
|
||||||
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
|
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
|
||||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
|
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||||
|
"FORBIDDEN"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("challenges.leave / submitProgress: member A allowed", async () => {
|
it("challenges.leave / submitProgress: member A allowed", async () => {
|
||||||
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
|
const clubId = await resolveClubIdFromChallenge(conn, CHALLENGE_CH);
|
||||||
await expect(requireClubMembership(conn, clubId, USER_A)).resolves.toBeUndefined();
|
await expect(
|
||||||
|
requireClubMembership(conn, clubId, USER_A)
|
||||||
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,21 +216,27 @@ describe("p8-003: join then allowed / leave then blocked (integration)", () => {
|
|||||||
it("B is blocked, allowed after joining C, blocked again after leaving", async () => {
|
it("B is blocked, allowed after joining C, blocked again after leaving", async () => {
|
||||||
// Initially blocked.
|
// Initially blocked.
|
||||||
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
const clubId = await resolveClubIdFromPost(conn, POST_P);
|
||||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
|
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||||
|
"FORBIDDEN"
|
||||||
|
);
|
||||||
|
|
||||||
// B joins.
|
// B joins.
|
||||||
db.run(
|
db.run(
|
||||||
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
"INSERT INTO clubMemberships (id, clubId, userId, role, joinedAt) VALUES (?, ?, ?, ?, datetime('now'))",
|
||||||
["mem-b", CLUB_C, USER_B, "member"]
|
["mem-b", CLUB_C, USER_B, "member"]
|
||||||
);
|
);
|
||||||
await expect(requireClubMembership(conn, clubId, USER_B)).resolves.toBeUndefined();
|
await expect(
|
||||||
|
requireClubMembership(conn, clubId, USER_B)
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
// B leaves.
|
// B leaves.
|
||||||
db.run("DELETE FROM clubMemberships WHERE clubId = ? AND userId = ?", [
|
db.run("DELETE FROM clubMemberships WHERE clubId = ? AND userId = ?", [
|
||||||
CLUB_C,
|
CLUB_C,
|
||||||
USER_B
|
USER_B
|
||||||
]);
|
]);
|
||||||
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe("FORBIDDEN");
|
expect(await errCode(requireClubMembership(conn, clubId, USER_B))).toBe(
|
||||||
|
"FORBIDDEN"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,7 +262,9 @@ function initUsersTable() {
|
|||||||
email TEXT,
|
email TEXT,
|
||||||
clerkUserId TEXT
|
clerkUserId TEXT
|
||||||
)`);
|
)`);
|
||||||
db.run(`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`);
|
db.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_users_clerkUserId ON users(clerkUserId)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveLocalUserId(clerkUserId: string): Promise<string | null> {
|
async function resolveLocalUserId(clerkUserId: string): Promise<string | null> {
|
||||||
@@ -246,31 +286,34 @@ describe("clerkUserId lookup (migrate-to-clerk-auth-03)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves local users.id for a seeded clerkUserId", async () => {
|
it("resolves local users.id for a seeded clerkUserId", async () => {
|
||||||
db.run(
|
db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
|
||||||
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
|
LOCAL_USER_A,
|
||||||
[LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID]
|
"a@nessa.app",
|
||||||
);
|
CLERK_USER_ID
|
||||||
|
]);
|
||||||
expect(await resolveLocalUserId(CLERK_USER_ID)).toBe(LOCAL_USER_A);
|
expect(await resolveLocalUserId(CLERK_USER_ID)).toBe(LOCAL_USER_A);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when no local row matches the clerkUserId", async () => {
|
it("returns null when no local row matches the clerkUserId", async () => {
|
||||||
// No users seeded — the webhook (task 04) has not run yet.
|
// No users seeded — the webhook has not run yet.
|
||||||
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
|
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for a Clerk id that exists but maps to a different local user", async () => {
|
it("returns null for a Clerk id that exists but maps to a different local user", async () => {
|
||||||
db.run(
|
db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
|
||||||
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
|
LOCAL_USER_B,
|
||||||
[LOCAL_USER_B, "b@nessa.app", "user_test_other"]
|
"b@nessa.app",
|
||||||
);
|
"user_test_other"
|
||||||
|
]);
|
||||||
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
|
expect(await resolveLocalUserId(CLERK_USER_ID)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ctx.nessaUserId is the LOCAL id, never the Clerk sub", async () => {
|
it("ctx.nessaUserId is the LOCAL id, never the Clerk sub", async () => {
|
||||||
db.run(
|
db.run("INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)", [
|
||||||
"INSERT INTO users (id, email, clerkUserId) VALUES (?, ?, ?)",
|
LOCAL_USER_A,
|
||||||
[LOCAL_USER_A, "a@nessa.app", CLERK_USER_ID]
|
"a@nessa.app",
|
||||||
);
|
CLERK_USER_ID
|
||||||
|
]);
|
||||||
const resolved = await resolveLocalUserId(CLERK_USER_ID);
|
const resolved = await resolveLocalUserId(CLERK_USER_ID);
|
||||||
expect(resolved).toBe(LOCAL_USER_A);
|
expect(resolved).toBe(LOCAL_USER_A);
|
||||||
expect(resolved).not.toBe(CLERK_USER_ID);
|
expect(resolved).not.toBe(CLERK_USER_ID);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ mock.module("~/env/server", () => ({
|
|||||||
TURSO_LINEAGE_TOKEN: "test-token",
|
TURSO_LINEAGE_TOKEN: "test-token",
|
||||||
TURSO_DB_API_TOKEN: "test-token",
|
TURSO_DB_API_TOKEN: "test-token",
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
// Clerk env vars (required after migration in task 02)
|
// Clerk env vars (required after migration)
|
||||||
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
||||||
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,11 @@ const PROVIDER_ID = "prov-1";
|
|||||||
// create/update/deleteWorkoutSplit
|
// create/update/deleteWorkoutSplit
|
||||||
|
|
||||||
describe("assertWorkoutOwned helper", () => {
|
describe("assertWorkoutOwned helper", () => {
|
||||||
let assertWorkoutOwned: (conn: Client, workoutId: string, userId: string) => Promise<void>;
|
let assertWorkoutOwned: (
|
||||||
|
conn: Client,
|
||||||
|
workoutId: string,
|
||||||
|
userId: string
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mod = await import("./nessa");
|
const mod = await import("./nessa");
|
||||||
@@ -64,16 +68,16 @@ describe("assertWorkoutOwned helper", () => {
|
|||||||
|
|
||||||
it("rejects when workout belongs to another user", async () => {
|
it("rejects when workout belongs to another user", async () => {
|
||||||
const conn = makeMockConn([{ userId: USER_B }]);
|
const conn = makeMockConn([{ userId: USER_B }]);
|
||||||
await expect(
|
await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
|
||||||
assertWorkoutOwned(conn, WORKOUT_ID, USER_A)
|
/owner/
|
||||||
).rejects.toThrow(/owner/);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when workout does not exist", async () => {
|
it("rejects when workout does not exist", async () => {
|
||||||
const conn = makeMockConn([]);
|
const conn = makeMockConn([]);
|
||||||
await expect(
|
await expect(assertWorkoutOwned(conn, WORKOUT_ID, USER_A)).rejects.toThrow(
|
||||||
assertWorkoutOwned(conn, WORKOUT_ID, USER_A)
|
/not found/i
|
||||||
).rejects.toThrow(/not found/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("succeeds when workout belongs to the caller", async () => {
|
it("succeeds when workout belongs to the caller", async () => {
|
||||||
@@ -88,7 +92,11 @@ describe("assertWorkoutOwned helper", () => {
|
|||||||
// Used by: updateAuthProvider, deleteAuthProvider
|
// Used by: updateAuthProvider, deleteAuthProvider
|
||||||
|
|
||||||
describe("assertAuthProviderOwned helper", () => {
|
describe("assertAuthProviderOwned helper", () => {
|
||||||
let assertAuthProviderOwned: (conn: Client, providerId: string, userId: string) => Promise<void>;
|
let assertAuthProviderOwned: (
|
||||||
|
conn: Client,
|
||||||
|
providerId: string,
|
||||||
|
userId: string
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mod = await import("./nessa");
|
const mod = await import("./nessa");
|
||||||
@@ -121,7 +129,11 @@ describe("assertAuthProviderOwned helper", () => {
|
|||||||
// Used by: updateExerciseLibrary, deleteExerciseLibrary
|
// Used by: updateExerciseLibrary, deleteExerciseLibrary
|
||||||
|
|
||||||
describe("assertExerciseLibraryOwned helper", () => {
|
describe("assertExerciseLibraryOwned helper", () => {
|
||||||
let assertExerciseLibraryOwned: (conn: Client, exerciseId: string, userId: string) => Promise<void>;
|
let assertExerciseLibraryOwned: (
|
||||||
|
conn: Client,
|
||||||
|
exerciseId: string,
|
||||||
|
userId: string
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mod = await import("./nessa");
|
const mod = await import("./nessa");
|
||||||
@@ -273,9 +285,7 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
it("no mutation handler in the list uses async ({ input }) without ctx", async () => {
|
it("no mutation handler in the list uses async ({ input }) without ctx", async () => {
|
||||||
const source = await Bun.file(
|
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||||
import.meta.dir + "/nessa.ts"
|
|
||||||
).text();
|
|
||||||
|
|
||||||
for (const name of MUTATIONS) {
|
for (const name of MUTATIONS) {
|
||||||
// Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx
|
// Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx
|
||||||
@@ -284,14 +294,15 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
|
|||||||
"s"
|
"s"
|
||||||
);
|
);
|
||||||
const match = source.match(re);
|
const match = source.match(re);
|
||||||
expect(match, `${name} should not use async ({ input }) — must use ctx`).toBeNull();
|
expect(
|
||||||
|
match,
|
||||||
|
`${name} should not use async ({ input }) — must use ctx`
|
||||||
|
).toBeNull();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("every mutation handler in the list references ctx", async () => {
|
it("every mutation handler in the list references ctx", async () => {
|
||||||
const source = await Bun.file(
|
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||||
import.meta.dir + "/nessa.ts"
|
|
||||||
).text();
|
|
||||||
|
|
||||||
for (const name of MUTATIONS) {
|
for (const name of MUTATIONS) {
|
||||||
// Find the block for this mutation and check it references ctx
|
// Find the block for this mutation and check it references ctx
|
||||||
@@ -301,19 +312,16 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
|
|||||||
);
|
);
|
||||||
const match = source.match(re);
|
const match = source.match(re);
|
||||||
expect(match, `${name} mutation block not found`).toBeTruthy();
|
expect(match, `${name} mutation block not found`).toBeTruthy();
|
||||||
expect(
|
expect(match![0].includes("ctx"), `${name} must reference ctx`).toBe(
|
||||||
match![0].includes("ctx"),
|
true
|
||||||
`${name} must reference ctx`
|
);
|
||||||
).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bulkUpsert filters exerciseLibrary by userId", async () => {
|
it("bulkUpsert filters exerciseLibrary by userId", async () => {
|
||||||
const source = await Bun.file(
|
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||||
import.meta.dir + "/nessa.ts"
|
|
||||||
).text();
|
|
||||||
const bulkSection = source.match(
|
const bulkSection = source.match(
|
||||||
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n \}/
|
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n {8}\}/
|
||||||
);
|
);
|
||||||
expect(bulkSection).toBeTruthy();
|
expect(bulkSection).toBeTruthy();
|
||||||
expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId");
|
expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId");
|
||||||
|
|||||||
@@ -78,33 +78,25 @@ describe("verifyNessaToken with Clerk JWT", () => {
|
|||||||
|
|
||||||
const { verifyNessaToken } = await import("./nessa-auth");
|
const { verifyNessaToken } = await import("./nessa-auth");
|
||||||
|
|
||||||
await expect(
|
await expect(verifyNessaToken("token-without-subject")).rejects.toThrow(
|
||||||
verifyNessaToken("token-without-subject")
|
/Missing subject/
|
||||||
).rejects.toThrow(/Missing subject/);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a malformed token", async () => {
|
it("rejects a malformed token", async () => {
|
||||||
mockVerifyToken.mockRejectedValue(
|
mockVerifyToken.mockRejectedValue(new Error("Invalid token format"));
|
||||||
new Error("Invalid token format")
|
|
||||||
);
|
|
||||||
|
|
||||||
const { verifyNessaToken } = await import("./nessa-auth");
|
const { verifyNessaToken } = await import("./nessa-auth");
|
||||||
|
|
||||||
await expect(
|
await expect(verifyNessaToken("malformed-token")).rejects.toThrow();
|
||||||
verifyNessaToken("malformed-token")
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an expired token", async () => {
|
it("rejects an expired token", async () => {
|
||||||
mockVerifyToken.mockRejectedValue(
|
mockVerifyToken.mockRejectedValue(new Error("Token has expired"));
|
||||||
new Error("Token has expired")
|
|
||||||
);
|
|
||||||
|
|
||||||
const { verifyNessaToken } = await import("./nessa-auth");
|
const { verifyNessaToken } = await import("./nessa-auth");
|
||||||
|
|
||||||
await expect(
|
await expect(verifyNessaToken("expired-token")).rejects.toThrow(/expired/i);
|
||||||
verifyNessaToken("expired-token")
|
|
||||||
).rejects.toThrow(/expired/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a token with wrong signature", async () => {
|
it("rejects a token with wrong signature", async () => {
|
||||||
@@ -114,9 +106,9 @@ describe("verifyNessaToken with Clerk JWT", () => {
|
|||||||
|
|
||||||
const { verifyNessaToken } = await import("./nessa-auth");
|
const { verifyNessaToken } = await import("./nessa-auth");
|
||||||
|
|
||||||
await expect(
|
await expect(verifyNessaToken("wrong-key-token")).rejects.toThrow(
|
||||||
verifyNessaToken("wrong-key-token")
|
/signature/i
|
||||||
).rejects.toThrow(/signature/i);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,7 +120,7 @@ describe("static audit: signNessaToken removed", () => {
|
|||||||
|
|
||||||
it("nessa-auth.ts source does not reference the legacy JWT secret", async () => {
|
it("nessa-auth.ts source does not reference the legacy JWT secret", async () => {
|
||||||
// Reassemble the legacy env-var name so this test itself does not contain
|
// Reassemble the legacy env-var name so this test itself does not contain
|
||||||
// the literal token (keeps the source tree grep-clean per task 11).
|
// the literal token (keeps the source tree grep-clean).
|
||||||
const legacyVar = ["NESSA", "JWT", "SECRET"].join("_");
|
const legacyVar = ["NESSA", "JWT", "SECRET"].join("_");
|
||||||
const source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text();
|
const source = await Bun.file(import.meta.dir + "/nessa-auth.ts").text();
|
||||||
expect(source).not.toContain(legacyVar);
|
expect(source).not.toContain(legacyVar);
|
||||||
|
|||||||
Reference in New Issue
Block a user