FRE-1670: publish /terms and the support address on the Nessa site

nessa.freno.me/terms was a live 404 although AppSettings.termsOfServiceURL
links it from Settings > Legal and the paywall. The document existed only on
an unmerged branch and had no host-dispatch route, so it could not serve on
the subdomain. Carry the Terms of Use page, add the /terms dispatcher, and
publish nessa-support@freno.me on terms, privacy, contact and deletion.

- src/routes/terms.tsx: host-aware dispatch for nessa; other sites 404.
- src/routes/nessa/terms.tsx: the Terms page; its contact section names the
  support address.
- privacy: add the controller clause and the support address.
- contact/deletion: show the support address; deletion also accepts a
  request by email.
- subdomain-dispatch.test.ts: list directories before filtering to .tsx
  files. The old predicate only ever received file names, so it asserted
  over an empty list and missed the missing /terms dispatch.
- support-contact.test.ts: guard that every contact route renders the one
  address the app shows.

App side unchanged: termsOfServiceURL stays https://nessa.freno.me/terms.
This commit is contained in:
2026-11-09 16:14:14 +00:00
committed by fixture
parent 97755042cb
commit 71bb9ec5df
8 changed files with 161 additions and 21 deletions

View File

@@ -1,6 +1,7 @@
import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import { SUPPORT_EMAIL } from "./content";
/**
* Nessa contact page (`nessa.freno.me/contact`).
@@ -16,14 +17,27 @@ import SubdomainHeader from "~/components/SubdomainHeader";
*
* Acceptance: `nessa.localhost:3000/contact` renders the contact form with
* Nessa branding; submissions email `michael@freno.me` with subject
* `[Nessa] Contact Request`.
* `[Nessa] Contact Request`. The support address shown by the app is also
* published here, so the contact route does not depend on the form alone
* (FRE-1670).
*/
export default function NessaContactPage() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<SubdomainHeader />
<ContactForm />
<ContactForm>
<p class="mt-8 text-center text-sm opacity-80">
You can also email us directly at{" "}
<a
href={`mailto:${SUPPORT_EMAIL}`}
class="text-blue hover-underline-animation"
>
{SUPPORT_EMAIL}
</a>
.
</p>
</ContactForm>
</>
);
}

View File

@@ -14,6 +14,15 @@ export const TAGLINE = "The fitness app that puts you first." as const;
export const SUBTITLE =
"Track, train, and connect — without the paywall." as const;
/**
* The support address the Nessa app shows (`SubscriptionView` /
* `HelpSupportView`). Every published Nessa document that offers a contact
* route names this same address, so a user who leaves the app to read the
* terms, privacy policy, contact page, or deletion page can still reach us
* (FRE-1670). Keep in step with `nessa-support@freno.me` in the app.
*/
export const SUPPORT_EMAIL = "nessa-support@freno.me" as const;
export const ICON_DEFAULT =
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-1024x1024.png" as const;

View File

@@ -33,6 +33,7 @@ import {
DELETION_GRACE_PERIOD_LABEL,
PAGE_META
} from "~/routes/nessa/deletion-content";
import { SUPPORT_EMAIL } from "./content";
export default function NessaDeletionPage() {
return (
@@ -54,6 +55,18 @@ export default function NessaDeletionPage() {
removed. No data related to the account is retained in any way.
</div>
<div class="text-text mt-4 w-full justify-center">
Prefer email? Send your deletion request from the address on your
Nessa account to{" "}
<a
href={`mailto:${SUPPORT_EMAIL}?subject=Account%20Deletion%20Request`}
class="text-blue hover-underline-animation"
>
{SUPPORT_EMAIL}
</a>
; the same {DELETION_GRACE_PERIOD_LABEL} grace period applies.
</div>
<DeletionForm
product={DELETION_PRODUCT_KEY}
cookieName={DELETION_COOKIE_NAME}

View File

@@ -30,6 +30,7 @@ import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import { SUPPORT_EMAIL } from "./content";
export default function NessaPrivacyPolicy() {
return (
@@ -174,15 +175,29 @@ export default function NessaPrivacyPolicy() {
<span class="-ml-4 pr-2">5.</span> Contact Us
</div>
<div class="pb-2 pl-4">
<div class="-ml-6">(a) Reaching Out:</div> If there are any
questions or comments regarding this privacy policy, you can
<div class="-ml-6">(a) Data Controller:</div> Nessa is operated by
FrenoCorp. For the purposes of applicable data-protection law,
FrenoCorp is the controller of the personal information described
in this policy.
<div class="pt-2 pb-2">
<div class="-ml-6">(b) Reaching Out:</div> If there are any
questions or comments regarding this privacy policy, or you want
to access, correct, or delete your personal information, you can
contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation">
here
</A>
through the contact form
</A>{" "}
or email{" "}
<a
href={`mailto:${SUPPORT_EMAIL}`}
class="text-blue hover-underline-animation"
>
{SUPPORT_EMAIL}
</a>
.
</div>
</div>
</div>
</ol>
</div>
</>

View File

@@ -0,0 +1,36 @@
/**
* Regression guard for the published Nessa support address (FRE-1670).
*
* The app tells users to mail `nessa-support@freno.me` from the subscription
* screen and Help & Support. Before this guard, no published Nessa page named
* that address: a user who left the app to read the terms, privacy policy,
* contact page, or deletion page found no way to reach support. That is a
* privacy-policy contact gap (GDPR/CCPA-style) and an App Review question
* waiting to happen.
*
* The address lives in one place (`SUPPORT_EMAIL`), and every page that
* offers a contact route must render it. Reading the route sources is
* deliberate: the defect is a page that exists but does not mention the
* address, which no runtime assertion over a single page can observe.
*/
import { describe, it, expect } from "bun:test";
import { readFileSync } from "fs";
import { SUPPORT_EMAIL } from "./content";
const CONTACT_ROUTE_FILES = [
"src/routes/nessa/terms.tsx",
"src/routes/nessa/privacy.tsx",
"src/routes/nessa/contact.tsx",
"src/routes/nessa/deletion.tsx"
];
describe("Nessa published support address", () => {
it("is the address the app shows", () => {
expect(SUPPORT_EMAIL).toBe("nessa-support@freno.me");
});
it.each(CONTACT_ROUTE_FILES)("%s renders it", (file) => {
const source = readFileSync(file, "utf8");
expect(source).toContain("SUPPORT_EMAIL");
});
});

View File

@@ -26,6 +26,7 @@ import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import { SUPPORT_EMAIL } from "./content";
export default function NessaTermsOfUse() {
return (
@@ -38,7 +39,7 @@ export default function NessaTermsOfUse() {
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">Nessa&apos;s Terms of Use</div>
<div class="py-2">Last Updated: November 7, 2026</div>
<div class="py-2">Last Updated: November 9, 2026</div>
<div class="py-2">
Welcome to Nessa (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
These terms of use govern your access to and use of the Nessa app and
@@ -434,8 +435,15 @@ export default function NessaTermsOfUse() {
questions or comments about these terms, want to report content,
or need help with a subscription, you can contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation">
here
</A>
through the contact form
</A>{" "}
or email{" "}
<a
href={`mailto:${SUPPORT_EMAIL}`}
class="text-blue hover-underline-animation"
>
{SUPPORT_EMAIL}
</a>
.
</div>
</div>

View File

@@ -14,7 +14,10 @@ import { describe, it, expect } from "bun:test";
import { readdirSync, existsSync, statSync } from "fs";
function isRouteFile(name: string): boolean {
return name.endsWith(".tsx") || name.endsWith(".ts");
// Page routes are components; the `.ts` files beside them are pure content /
// metadata modules (content.ts, meta.ts, *-content.ts) with no default
// export the router can render, so they are not dispatch targets.
return name.endsWith(".tsx");
}
function listRouteFiles(dir: string): string[] {
@@ -25,6 +28,20 @@ function listRouteFiles(dir: string): string[] {
}
}
function listDirectories(dir: string): string[] {
try {
return readdirSync(dir).filter((name) => {
try {
return statSync(`${dir}/${name}`).isDirectory();
} catch {
return false;
}
});
} catch {
return [];
}
}
function hasDispatchRoute(routeName: string, routesDir: string): boolean {
// Check for src/routes/<name>.tsx
if (existsSync(`${routesDir}/${routeName}.tsx`)) {
@@ -41,14 +58,12 @@ describe("subdomain routing dispatch invariant", () => {
const routesDir = "./src/routes";
it("every subdomain route has a top-level dispatch route", () => {
const prefixDirs = listRouteFiles(routesDir)
.filter((name) => {
try {
return statSync(`${routesDir}/${name}`).isDirectory();
} catch {
return false;
}
})
// List directory entries before filtering to route files. The previous
// shape filtered with `listRouteFiles` first, which only ever returns
// `.tsx`/`.ts` names, so `statSync(...).isDirectory()` was never true
// and the assertion ran over an empty list — it passed while `/terms`
// had no dispatch file (FRE-1670).
const prefixDirs = listDirectories(routesDir)
// Exclude non-product subdomains
.filter((name) =>
["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(name)

30
src/routes/terms.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NessaTermsOfUse from "./nessa/terms";
/**
* Host-aware terms-of-use route — `/terms`.
*
* The public browser path `/terms` is what the Nessa app links from
* Settings › Legal and from the subscription paywall
* (`AppSettings.termsOfServiceURL`). Production Vercel deployments do not
* apply the `vercel.json` host rewrites (the Nitro Build Output API config
* supersedes them), so the subdomain page needs this top-level dispatch file
* to be reachable — without it `nessa.freno.me/terms` is a 404 even though
* `src/routes/nessa/terms.tsx` exists.
*
* Only Nessa publishes terms of use today. Every other site — and the main
* site — falls back to the 404 handler rather than borrowing another
* product's legal document.
*/
export default function TermsPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nessa"}>
<NessaTermsOfUse />
</Match>
</Switch>
);
}