Compare commits

...

11 Commits

89 changed files with 5064 additions and 146 deletions

View File

@@ -70,7 +70,6 @@ INFILL_BEARER_TOKEN="<rotate-at-infill-service>"
GITEA_URL="https://gitea.example.com"
GITEA_TOKEN="<rotate-in-gitea>"
GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / github_pat_...
REDIS_URL="redis://localhost:6379"
# ── Sentry (error tracking + source maps) ──
# Source maps upload — create a token at: Settings > Projects > freno-dev > Client Keys (DSN) > Auth Token

View File

@@ -86,7 +86,7 @@
This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration.
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`). The `vercel.json` host-based rewrites map each subdomain to its prefix.
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy``/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.)
- **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host.
- **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters).
- **Auth:** Host-scoped only — no cookie domain broadening.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

BIN
public/gaze/favicon/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

17
public/gaze/favicon/favicon.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 MiB

View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 MiB

View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 860 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

BIN
public/nessa/favicon/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -74,6 +74,11 @@
--color-base: #fbf1c7;
--color-mantle: #f3eac1;
--color-crust: #e7deb7;
/* Button text colors: white on dark bg variants (primary/danger),
theme text on light bg variants (secondary). */
--color-button-text: #ffffff;
--color-button-text-alt: var(--color-text);
}
@media (prefers-color-scheme: dark) {
@@ -104,6 +109,11 @@
--color-base: #1e1e2e;
--color-mantle: #141620;
--color-crust: #0e0f16;
/* Dark mode: dark text on lighter bg variants (primary/danger),
white text on dark bg variants (secondary). */
--color-button-text: var(--color-crust);
--color-button-text-alt: #ffffff;
}
@theme {
--color-rosewater: #efc9c2;
@@ -163,6 +173,8 @@
--color-base: #fbf1c7;
--color-mantle: #f3eac1;
--color-crust: #e7deb7;
--color-button-text: #ffffff;
--color-button-text-alt: var(--color-text);
}
:root.dark {
@@ -192,6 +204,8 @@
--color-base: #1e1e2e;
--color-mantle: #141620;
--color-crust: #0e0f16;
--color-button-text: var(--color-crust);
--color-button-text-alt: #ffffff;
}
:root {

View File

@@ -11,6 +11,7 @@ import {
import "./app.css";
import { LeftBar, RightBar } from "./components/Bars";
import { TerminalSplash } from "./components/TerminalSplash";
import SubdomainFooter from "./components/SubdomainFooter";
import { MetaProvider } from "@solidjs/meta";
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
import { BarsProvider, useBars } from "./context/bars";
@@ -199,21 +200,24 @@ function AppLayout(props: { children: any }) {
</Show>
<Show when={!isMainSite()}>
<div class="bg-base min-h-screen w-full overflow-x-hidden">
<div class="bg-base flex min-h-screen w-full flex-col overflow-x-hidden">
<noscript>
<div class="bg-yellow text-crust border-text fixed top-0 z-150 w-full border-b-2 p-4 text-center font-semibold">
JavaScript is disabled. Features will be limited.
</div>
</noscript>
<ErrorBoundary
fallback={(error, reset) => (
<ErrorBoundaryFallback error={error} reset={reset} />
)}
>
<Suspense fallback={<TerminalSplash inverse />}>
{props.children}
</Suspense>
</ErrorBoundary>
<div class="flex-1">
<ErrorBoundary
fallback={(error, reset) => (
<ErrorBoundaryFallback error={error} reset={reset} />
)}
>
<Suspense fallback={<TerminalSplash inverse />}>
{props.children}
</Suspense>
</ErrorBoundary>
</div>
<SubdomainFooter />
</div>
</Show>
</>

View File

@@ -4,7 +4,10 @@ import SunIcon from "./icons/SunIcon";
import { Typewriter } from "./Typewriter";
import { useDarkMode } from "~/context/darkMode";
export function DarkModeToggle() {
export function DarkModeToggle(
props: { shouldScale?: boolean } = { shouldScale: true }
) {
const shouldScale = props.shouldScale ?? true;
const { isDark, toggleDarkMode } = useDarkMode();
const [mounted, setMounted] = createSignal(false);
@@ -15,7 +18,7 @@ export function DarkModeToggle() {
return (
<button
onClick={toggleDarkMode}
class="hover:bg-surface0 flex w-full items-center gap-3 rounded-lg p-3 transition-all duration-200 ease-in-out hover:scale-105"
class={`hover:bg-surface0 flex w-full items-center gap-3 rounded-lg p-3 transition-all duration-200 ease-in-out ${shouldScale ? "hover:scale-105" : ""}`}
aria-label="Toggle dark mode"
>
<Show

View File

@@ -60,6 +60,42 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
});
// Subdomain routing is host-aware (root routes dispatch by useSite()), so
// useLocation() normally reports the public path. resolvePageHeadMeta also
// defensively strips a subdomain prefix from a prefixed pathname, so the
// canonical stays public even if the prefixed form ever appears.
it("nessa prefixed /nessa/contact → https://nessa.freno.me/contact", () => {
const meta = resolvePageHeadMeta(
BASE_PROPS,
SITE_CONFIG.nessa,
"/nessa/contact"
);
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
});
it("lineage prefixed /lineage/privacy → https://lineage.freno.me/privacy", () => {
const meta = resolvePageHeadMeta(
BASE_PROPS,
SITE_CONFIG.lineage,
"/lineage/privacy"
);
expect(meta.canonical).toBe("https://lineage.freno.me/privacy");
});
it("nessa prefixed root /nessa/ → https://nessa.freno.me/", () => {
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/nessa/");
expect(meta.canonical).toBe("https://nessa.freno.me/");
});
it("main path that happens to start with /nessa is NOT stripped (main site has no prefix)", () => {
const meta = resolvePageHeadMeta(
BASE_PROPS,
SITE_CONFIG.main,
"/nessa/contact"
);
expect(meta.canonical).toBe("https://freno.me/nessa/contact");
});
it("lineage → canonical starts with https://lineage.freno.me", () => {
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.lineage, "/");
expect(meta.canonical.startsWith("https://lineage.freno.me")).toBe(true);

View File

@@ -0,0 +1,23 @@
import { A } from "@solidjs/router";
import { buildMainSiteUrl } from "~/lib/site-context";
export default function SubdomainFooter() {
return (
<footer class="border-surface0 bg-surface0 relative z-10 border-t py-8">
<div class="relative flex items-center text-sm">
<A
href={buildMainSiteUrl()}
class="text-text/60 hover:text-text/80 mx-auto text-center underline underline-offset-4 transition-colors"
>
made with <span class="text-red-400">&lt;3</span>
</A>
<A
href={buildMainSiteUrl("/downloads")}
class="text-text/80 hover:text-text absolute right-4 underline underline-offset-4 transition-colors"
>
see more products
</A>
</div>
</footer>
);
}

View File

@@ -11,14 +11,18 @@
import { For, Show } from "solid-js";
import { A, useLocation } from "@solidjs/router";
import { useSite } from "~/context/SiteContext";
import { NAV_CONFIG, BACK_TO_FRENO } from "~/lib/nav-config";
import { useDarkMode } from "~/context/darkMode";
import { NAV_CONFIG } from "~/lib/nav-config";
import { DarkModeToggle } from "~/components/DarkModeToggle";
export default function SubdomainHeader() {
const site = useSite();
const location = useLocation();
const { isDark } = useDarkMode();
const brandName = () => site().displayName;
const brandColor = () => site().brandColor;
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const navItems = () =>
NAV_CONFIG[site().id].filter((item) => item.label !== "Home");
@@ -71,15 +75,7 @@ export default function SubdomainHeader() {
</Show>
)}
</For>
<a
href={BACK_TO_FRENO.href}
target="_blank"
rel="noopener noreferrer"
class="text-text/60 hover:text-text text-xs transition-colors"
>
{BACK_TO_FRENO.label}
</a>
<DarkModeToggle shouldScale={false} />
</nav>
</div>
</header>

View File

@@ -18,8 +18,11 @@ function sanitizeHtml(html: string): string {
)
.forEach((el) => el.remove());
// Remove event handler attributes and dangerous URLs from all elements
doc.querySelectorAll("[on*], [href], [style], [action]").forEach((el) => {
// Remove event handler attributes and dangerous URLs from all elements.
// NOTE: attribute-name wildcards (e.g. [on*]) are not valid CSS selectors and
// throw a SyntaxError on querySelectorAll, so we match all elements here and
// the per-attribute checks below handle on*/href/style/action filtering.
doc.querySelectorAll("*").forEach((el) => {
const attrs = Array.from(el.attributes);
attrs.forEach((attr) => {
const name = attr.name;

View File

@@ -9,11 +9,12 @@
* Rules:
* - `title` → `props.title + site.titleSuffix`
* - `canonical` → explicit `props.canonical` override wins; otherwise
* `https://${site.domain}${pathname}`. The pathname is the *browser* path
* (from `useLocation`), which is correct because vercel.json host rewrites
* target internal route prefixes (`/nessa`, `/lineage`, …) while leaving
* the public URL intact — so `nessa.freno.me/contact` reports pathname
* `/contact`, and the canonical is `https://nessa.freno.me/contact`.
* `https://${site.domain}${pathname}` where `pathname` is the *public*
* browser path. Subdomain routing is host-aware (root routes dispatch by
* `useSite()`, see `src/routes/index.tsx`/`privacy.tsx`/`deletion.tsx`),
* so `useLocation()` already reports the public path. The defensive
* prefix-strip below also handles any legacy prefixed form, so the canonical
* is always the public URL.
* - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`.
* - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall
* back to the base title (no suffix) / description (existing behavior).
@@ -54,7 +55,20 @@ export function resolvePageHeadMeta(
pathname: string
): ResolvedPageHeadMeta {
const title = `${props.title}${site.titleSuffix}`;
const canonical = props.canonical ?? `https://${site.domain}${pathname}`;
/**
* The canonical URL is the *public* browser URL, never any internal route
* prefix. Subdomain routing is host-aware (root routes dispatch by
* `useSite()`), so `useLocation()` returns the public path. The strip below
* is a defensive no-op for the public path and still yields the canonical
* `https://lineage.freno.me/privacy` if a prefixed form ever appears.
*/
const publicPath =
site.baseRoutePrefix &&
(pathname === site.baseRoutePrefix ||
pathname.startsWith(site.baseRoutePrefix + "/"))
? pathname.slice(site.baseRoutePrefix.length) || "/"
: pathname;
const canonical = props.canonical ?? `https://${site.domain}${publicPath}`;
const ogTitle = props.ogTitle ?? props.title;
const ogDescription = props.ogDescription ?? props.description;
const ogImage = props.ogImage ?? site.ogDefaultImage;

View File

@@ -1,11 +1,113 @@
import { JSX, splitProps, Show, createSignal, createEffect } from "solid-js";
import {
type JSX,
splitProps,
Show,
createSignal,
createEffect
} from "solid-js";
import { Spinner } from "~/components/Spinner";
/** Parse a hex color string like `#f9e2af` into `[r, g, b]` in 0..255. */
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace(/^#/, "");
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16)
];
}
/** Convert `[r, g, b]` 0..255 to `[h, s, l]` — h in 0..360, s and l in 0..1. */
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rn:
h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
break;
case gn:
h = ((bn - rn) / d + 2) / 6;
break;
case bn:
h = ((rn - gn) / d + 4) / 6;
break;
}
}
return [h * 360, s, l];
}
/** Convert `[h, s, l]` (h in 0..360, s and l in 0..1) to `[r, g, b]` 0..255. */
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
return Math.round(255 * (l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)));
};
return [f(0), f(8), f(4)];
}
/**
* Compute the relative luminance (WCAG) of an sRGB color, given linear
* channel values in 0..1.
*/
function luminance(r: number, g: number, b: number): number {
return (
0.2126 * (r > 0.04045 ? ((r + 0.055) / 1.055) ** 2.4 : r / 12.92) +
0.7152 * (g > 0.04045 ? ((g + 0.055) / 1.055) ** 2.4 : g / 12.92) +
0.0722 * (b > 0.04045 ? ((b + 0.055) / 1.055) ** 2.4 : b / 12.92)
);
}
/**
* Given a background color, return a pair of `{ bg, text }` CSS color
* strings that guarantee WCAG AA contrast.
*
* Light backgrounds (luminance > 0.4) are darkened in HSL space so the
* result is dark enough for white text. Dark backgrounds are kept as-is
* with white text. This is used by the download variant whose background
* is a product brand color that can be arbitrarily light.
*/
function adjustForContrast(color: string): { bg: string; text: string } {
const [r, g, b] = hexToRgb(color);
const [h, s, l] = rgbToHsl(r, g, b);
const lum = luminance(r / 255, g / 255, b / 255);
if (lum > 0.4) {
// Darken: keep hue and saturation, clamp lightness so the result is
// dark enough for white text (l ~ 0.35 → luminance ~ 0.17, contrast
// with white ≈ 6:1).
const dl = Math.min(l, 0.35);
const [dr, dg, db] = hslToRgb(h, s, dl);
return {
bg: `rgb(${dr}, ${dg}, ${db})`,
text: "#ffffff"
};
}
return {
bg: `rgb(${r}, ${g}, ${b})`,
text: "#ffffff"
};
}
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost" | "download";
size?: "sm" | "md" | "lg";
loading?: boolean;
fullWidth?: boolean;
/**
* Override the variant's background color with an explicit CSS color.
* Used by download pages to theme the button with the product brand color.
*/
color?: string;
}
export default function Button(props: ButtonProps) {
@@ -16,7 +118,8 @@ export default function Button(props: ButtonProps) {
"fullWidth",
"class",
"children",
"disabled"
"disabled",
"color"
]);
let contentRef: HTMLSpanElement | undefined;
@@ -53,8 +156,8 @@ export default function Button(props: ButtonProps) {
: "bg-surface0 hover:brightness-125 active:scale-90";
case "download":
return isDisabledOrLoading
? "bg-green text-base cursor-not-allowed brightness-75"
: "bg-green text-base hover:brightness-125 active:scale-90";
? "cursor-not-allowed brightness-75"
: "hover:brightness-125 active:scale-90";
case "danger":
return isDisabledOrLoading
? "bg-red cursor-not-allowed brightness-75"
@@ -68,6 +171,20 @@ export default function Button(props: ButtonProps) {
}
};
/** Compute background + text color for the download variant. */
const downloadColors = () => {
const isDisabledOrLoading = local.disabled || local.loading;
if (isDisabledOrLoading) {
return { bg: "var(--color-base)", text: "#ffffff" };
}
// When no explicit color is given, fall back to the theme blue
// (a dark color whose contrast with white is already fine). Only
// pass a hex string to adjustForContrast — CSS variable values can't
// be parsed numerically.
if (!local.color) return { bg: "var(--color-blue)", text: "#ffffff" };
return adjustForContrast(local.color);
};
const sizeClasses = () => {
switch (size()) {
case "sm":
@@ -83,11 +200,25 @@ export default function Button(props: ButtonProps) {
const widthClass = () => (local.fullWidth ? "w-full" : "");
const buttonStyle = (): JSX.CSSProperties => {
if (variant() === "download") {
const { bg, text } = downloadColors();
return { background: bg, color: text };
}
// Theme-aware text: white on dark bg variants (primary/danger),
// theme text on light bg variants (secondary). The CSS variables
// flip between light/dark modes so the contrast stays good in both.
if (variant() === "secondary")
return { color: "var(--color-button-text-alt)" };
return { color: "var(--color-button-text)" };
};
return (
<button
{...others}
disabled={local.disabled || local.loading}
class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${widthClass()} ${local.class || ""}`}
style={buttonStyle()}
>
<Show
when={local.loading}

2
src/env/server.ts vendored
View File

@@ -53,7 +53,6 @@ const serverEnvSchema = z.object({
VITE_WEBSOCKET: z.string().min(1),
VITE_INFILL_ENDPOINT: z.string().min(1),
INFILL_BEARER_TOKEN: z.string().min(1),
REDIS_URL: z.string().min(1),
NESSA_DB_URL: z.string().min(1),
NESSA_DB_TOKEN: z.string().min(1),
// Clerk authentication — Nessa auth is now Clerk-backed. The
@@ -171,7 +170,6 @@ export const getMissingEnvVars = (): string[] => {
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
"VITE_GITHUB_CLIENT_ID",
"VITE_WEBSOCKET",
"REDIS_URL",
"NESSA_DB_URL",
"NESSA_DB_TOKEN",
"NESSA_CLERK_SECRET",

View File

@@ -1,4 +1,4 @@
import { createSignal, onMount, onCleanup, Accessor } from "solid-js";
import { createSignal, onMount, onCleanup, type Accessor } from "solid-js";
export const MOBILE_BREAKPOINT = 768;
@@ -8,8 +8,9 @@ export const MOBILE_BREAKPOINT = 768;
* @returns Accessor for current window width
*/
export function createWindowWidth(debounceMs?: number): Accessor<number> {
const initialWidth = typeof window !== "undefined" ? window.innerWidth : 1024;
const [width, setWidth] = createSignal(initialWidth);
// Use a static default so SSR and initial client render agree; the
// real value is set in onMount (after hydration) to avoid mismatches.
const [width, setWidth] = createSignal(1024);
onMount(() => {
setWidth(window.innerWidth);

View File

@@ -33,6 +33,8 @@ export interface Site {
titleSuffix: string;
/** Hex brand color used for theming accents / OG image backgrounds. */
brandColor: string;
/** Dark mode variant of the brand color (used when dark mode is active). */
brandColorDark?: string;
/** Default OpenGraph image path (resolved against the site root). */
ogDefaultImage: string;
/** Favicon path for this site. */
@@ -83,9 +85,10 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/nessa",
displayName: "Nessa",
titleSuffix: " | Nessa",
brandColor: "#cba6f7",
brandColor: "#527640",
brandColorDark: "#6CA86C",
ogDefaultImage: "/nessa/og-default.png",
faviconPath: "/nessa/favicon.ico"
faviconPath: "/nessa/favicon/favicon.ico"
},
lineage: {
id: "lineage",
@@ -94,9 +97,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/lineage",
displayName: "Life and Lineage",
titleSuffix: " | Life and Lineage",
brandColor: "#a6e3a1",
brandColor: "#a13536",
ogDefaultImage: "/lineage/og-default.png",
faviconPath: "/lineage/favicon.ico"
faviconPath: "/lineage/favicon/favicon.ico"
},
gaze: {
id: "gaze",
@@ -105,9 +108,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/gaze",
displayName: "Gaze",
titleSuffix: " | Gaze",
brandColor: "#f9e2af",
brandColor: "#002cff",
ogDefaultImage: "/gaze/og-default.png",
faviconPath: "/gaze/favicon.ico"
faviconPath: "/gaze/favicon/favicon.ico"
},
inputhalo: {
id: "inputhalo",
@@ -116,9 +119,9 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
baseRoutePrefix: "/inputhalo",
displayName: "InputHalo",
titleSuffix: " | InputHalo",
brandColor: "#f38ba8",
brandColor: "#41a5ff",
ogDefaultImage: "/inputhalo/og-default.png",
faviconPath: "/inputhalo/favicon.ico"
faviconPath: "/inputhalo/favicon/favicon.ico"
}
};

View File

@@ -0,0 +1,525 @@
{
"childhood_events": [
{
"id": "school_presentation",
"ageRange": { "min": 6, "max": 12 },
"category": "school",
"icon": "📚",
"title": "A Big Day at School",
"description": "It's your turn to give a presentation at school. All the other kids are watching.",
"weight": 1.0,
"cooldownTicks": 12,
"outcomes": [
{
"label": "Speak with confidence",
"description": "Stand tall and give an excellent presentation.",
"effects": [
{ "kind": "sanity", "amount": 10 },
{ "kind": "experience", "amount": 5 },
{ "kind": "notification", "message": "The teacher praises your bravery!" }
]
},
{
"label": "Mumble through it nervously",
"effects": [
{ "kind": "sanity", "amount": -2 },
{ "kind": "experience", "amount": 2 },
{ "kind": "notification", "message": "It's over... You can breathe again." }
]
}
]
},
{
"id": "playground_friends",
"ageRange": { "min": 5, "max": 12 },
"category": "friendship",
"icon": "👫",
"title": "Making New Friends",
"description": "Some kids at the playground wave you over to join their game.",
"weight": 1.2,
"cooldownTicks": 10,
"outcomes": [
{
"label": "Join them eagerly",
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "notification", "message": "You make friends and have a wonderful time!" }
]
},
{
"label": "Watch from afar",
"effects": [
{ "kind": "sanity", "amount": 3 },
{ "kind": "notification", "message": "You enjoy the game from the sidelines." }
]
}
]
},
{
"id": "family_picnic",
"ageRange": { "min": 4, "max": 14 },
"category": "family",
"icon": "🧺",
"title": "A Family Picnic",
"description": "Your family has organized a picnic at the park. Everyone brings their favorite foods.",
"weight": 0.9,
"cooldownTicks": 16,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 20 },
{ "kind": "health", "amount": 5 },
{ "kind": "notification", "message": "A perfect day with family, laughing and eating by the river." }
]
}
},
{
"id": "lost_toy",
"ageRange": { "min": 4, "max": 10 },
"category": "mishap",
"icon": "🧸",
"title": "Oh No, Lost My Toy!",
"description": "You can't find your favorite stuffed animal anywhere.",
"weight": 0.7,
"cooldownTicks": 20,
"outcomes": [
{
"label": "Search everywhere",
"effects": [
{ "kind": "sanity", "amount": 5 },
{ "kind": "notification", "message": "Found it behind the bed! You're so relieved." }
]
},
{
"label": "Ask family for help",
"effects": [
{ "kind": "sanity", "amount": 8 },
{ "kind": "notification", "message": "Mom finds it in the closet. What a relief!" }
]
}
]
},
{
"id": "birthday_party",
"ageRange": { "min": 5, "max": 15 },
"category": "milestone",
"icon": "🎂",
"title": "Your Birthday!",
"description": "Today is your special day! There's cake, presents, and all your friends.",
"weight": 1.5,
"cooldownTicks": 52,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 25 },
{ "kind": "health", "amount": 10 },
{ "kind": "gold", "amount": 15 },
{ "kind": "notification", "message": "The best day of the year! Cake, presents, and all your friends cheering!" }
]
}
},
{
"id": "reading_first_book",
"ageRange": { "min": 6, "max": 9 },
"category": "achievement",
"icon": "📖",
"title": "Reading Your First Book!",
"description": "You've learned to read! You can finally read your first book by yourself.",
"weight": 1.0,
"cooldownTicks": 30,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "experience", "amount": 10 },
{ "kind": "notification", "message": "The world of stories is now yours!" }
]
}
},
{
"id": "garden_discovery",
"ageRange": { "min": 4, "max": 12 },
"category": "exploration",
"icon": "🌻",
"title": "A Secret Garden",
"description": "You find a hidden garden behind the old barn. Butterflies flutter everywhere.",
"weight": 0.8,
"cooldownTicks": 14,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 12 },
{ "kind": "notification", "message": "Your own secret world, filled with flowers and butterflies." }
]
}
},
{
"id": "learning_riding",
"ageRange": { "min": 8, "max": 14 },
"category": "skill",
"icon": "🐴",
"title": "Learning to Ride",
"description": "Your parents sign you up for riding lessons. The horse is bigger than you thought!",
"weight": 0.9,
"cooldownTicks": 18,
"outcomes": [
{
"label": "Be brave and ride",
"effects": [
{ "kind": "sanity", "amount": 12 },
{ "kind": "experience", "amount": 8 },
{ "kind": "notification", "message": "You learn to sit tall and control the horse!" }
]
},
{
"label": "Just pet the horse",
"effects": [
{ "kind": "sanity", "amount": 5 },
{ "kind": "notification", "message": "The horse is gentle and friendly. Maybe next time." }
]
}
]
},
{
"id": "first_pet",
"ageRange": { "min": 6, "max": 12 },
"category": "family",
"icon": "🐱",
"title": "You Get a Pet!",
"description": "Your parents bring home a new kitten for you!",
"weight": 1.3,
"cooldownTicks": 52,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 30 },
{ "kind": "notification", "message": "A fluffy new friend to love and care for!" }
]
}
},
{
"id": "rain_puddles",
"ageRange": { "min": 3, "max": 10 },
"category": "play",
"icon": "🌧️",
"title": "Jumping in Puddles",
"description": "It's raining! The street is full of puddles to jump in.",
"weight": 1.5,
"seasons": ["spring", "autumn"],
"cooldownTicks": 8,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 18 },
{ "kind": "health", "amount": 3 },
{ "kind": "notification", "message": "SPLASH! The best feeling in the world!" }
]
}
},
{
"id": "snowman_building",
"ageRange": { "min": 4, "max": 14 },
"category": "play",
"icon": "⛄",
"title": "Building a Snowman",
"description": "It's snowing! Your friends want to build the biggest snowman ever.",
"weight": 1.2,
"seasons": ["winter"],
"cooldownTicks": 12,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 20 },
{ "kind": "health", "amount": 5 },
{ "kind": "notification", "message": "You build an enormous snowman with a carrot nose!" }
]
}
},
{
"id": "firefly_catching",
"ageRange": { "min": 5, "max": 12 },
"category": "play",
"icon": "✨",
"title": "Catching Fireflies",
"description": "On a warm summer evening, fireflies light up the garden.",
"weight": 1.0,
"seasons": ["summer"],
"cooldownTicks": 14,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "notification", "message": "Little lights dance around you in the darkness." }
]
}
},
{
"id": "scarecrow_encounter",
"ageRange": { "min": 5, "max": 12 },
"category": "mystery",
"icon": "🌾",
"title": "The Scarecrow Seems to Move",
"description": "You're walking past the fields and you think the scarecrow has turned.",
"weight": 0.6,
"cooldownTicks": 20,
"outcomes": [
{
"label": "Investigate bravely",
"effects": [
{ "kind": "sanity", "amount": 5 },
{ "kind": "notification", "message": "It was just the wind. Phew!" }
]
},
{
"label": "Run home quickly",
"effects": [
{ "kind": "sanity", "amount": -5 },
{ "kind": "notification", "message": "Better safe than sorry!" }
]
}
]
},
{
"id": "sharing_treats",
"ageRange": { "min": 4, "max": 12 },
"category": "friendship",
"icon": "🍪",
"title": "Sharing Treats",
"description": "You have a special cookie. There's one other kid who looks hungry.",
"weight": 1.0,
"cooldownTicks": 10,
"outcomes": [
{
"label": "Share the cookie",
"effects": [
{ "kind": "sanity", "amount": 12 },
{ "kind": "notification", "message": "The other kid thanks you and smiles. Sharing is nice." }
]
},
{
"label": "Eat it yourself",
"effects": [
{ "kind": "health", "amount": 3 },
{ "kind": "notification", "message": "Cookies are delicious!" }
]
}
]
},
{
"id": "helping_elder",
"ageRange": { "min": 8, "max": 14 },
"category": "kindness",
"icon": "👴",
"title": "An Elder Needs Help",
"description": "An old man is struggling to carry his groceries. He looks like he needs some help.",
"weight": 0.9,
"cooldownTicks": 16,
"outcomes": [
{
"label": "Help carry the groceries",
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "gold", "amount": 5 },
{ "kind": "notification", "message": "The old man thanks you and gives you a coin." }
]
},
{
"label": "Keep walking",
"effects": [
{ "kind": "sanity", "amount": -3 },
{ "kind": "notification", "message": "Maybe next time..." }
]
}
]
},
{
"id": "lost_dog",
"ageRange": { "min": 6, "max": 14 },
"category": "encounter",
"icon": "🐕",
"title": "A Lost Dog",
"description": "You find a puppy wandering alone, looking sad.",
"weight": 0.8,
"cooldownTicks": 18,
"outcomes": [
{
"label": "Find the owner",
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "gold", "amount": 10 },
{ "kind": "notification", "message": "The happy puppy's family thanks you!" }
]
},
{
"label": "Give it some food",
"effects": [
{ "kind": "sanity", "amount": 8 },
{ "kind": "notification", "message": "The puppy wags its tail gratefully." }
]
}
]
},
{
"id": "first_job_internship",
"ageRange": { "min": 13, "max": 16 },
"category": "work",
"icon": "🏪",
"title": "Helping at the Shop",
"description": "The shopkeeper lets you help out for a few hours. It's your first real work experience.",
"weight": 0.8,
"cooldownTicks": 20,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 8 },
{ "kind": "gold", "amount": 15 },
{ "kind": "experience", "amount": 10 },
{ "kind": "notification", "message": "You earn your first real wages!" }
]
}
},
{
"id": "dungeon_adventure",
"ageRange": { "min": 13, "max": 17 },
"category": "adventure",
"icon": "⚔️",
"title": "The Old Dungeon",
"description": "The older kids challenge you to peek into the old dungeon entrance.",
"weight": 0.7,
"cooldownTicks": 22,
"outcomes": [
{
"label": "Enter the dungeon",
"effects": [
{ "kind": "sanity", "amount": 15 },
{ "kind": "experience", "amount": 15 },
{ "kind": "notification", "message": "You explore the dark passages and find treasure!" }
]
},
{
"label": "Stay outside and watch",
"effects": [
{ "kind": "sanity", "amount": 3 },
{ "kind": "notification", "message": "A little too scary for now." }
]
}
]
},
{
"id": "reading_library",
"ageRange": { "min": 12, "max": 17 },
"category": "learning",
"icon": "📚",
"title": "Reading at the Library",
"description": "You spend an afternoon reading books at the town library.",
"weight": 0.9,
"cooldownTicks": 14,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 10 },
{ "kind": "experience", "amount": 15 },
{ "kind": "notification", "message": "The world expands through the power of reading." }
]
}
},
{
"id": "first_crush",
"ageRange": { "min": 13, "max": 17 },
"category": "romance",
"icon": "💕",
"title": "A Butterflies in Your Stomach",
"description": "You see someone at school and suddenly your heart starts racing.",
"weight": 1.0,
"cooldownTicks": 24,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 12 },
{ "kind": "notification", "message": "Oh no... Is this what people mean by 'crushes'?" }
]
}
},
{
"id": "harvest_festival",
"ageRange": { "min": 8, "max": 17 },
"category": "community",
"icon": "🎪",
"title": "The Autumn Harvest",
"description": "The whole town celebrates the harvest festival with games and food.",
"weight": 1.2,
"seasons": ["autumn"],
"cooldownTicks": 52,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 25 },
{ "kind": "health", "amount": 10 },
{ "kind": "notification", "message": "Pumpkin pie, hayrides, and apple cider! The best festival!" }
]
}
},
{
"id": "midsummer_fire",
"ageRange": { "min": 12, "max": 17 },
"category": "community",
"icon": "🔥",
"title": "The Midsummer Bonfire",
"description": "All the teens gather around the bonfire, telling stories and roasting marshmallows.",
"weight": 1.0,
"seasons": ["summer"],
"cooldownTicks": 52,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 20 },
{ "kind": "health", "amount": 5 },
{ "kind": "notification", "message": "Stories of monsters and adventure by the crackling fire." }
]
}
},
{
"id": "first_dance",
"ageRange": { "min": 14, "max": 17 },
"category": "social",
"icon": "💃",
"title": "The First Dance",
"description": "You're invited to the teen dance at the community hall.",
"weight": 0.9,
"cooldownTicks": 40,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 20 },
{ "kind": "notification", "message": "Twirling and laughter under the fairy lights!" }
]
}
},
{
"id": "winter_solstice",
"ageRange": { "min": 6, "max": 17 },
"category": "community",
"icon": "🕯️",
"title": "Winter Solstice",
"description": "The family gathers to celebrate the shortest day with warm food and stories.",
"weight": 1.0,
"seasons": ["winter"],
"cooldownTicks": 52,
"autoOutcome": {
"effects": [
{ "kind": "sanity", "amount": 20 },
{ "kind": "health", "amount": 5 },
{ "kind": "notification", "message": "Candles, warmth, and the promise of spring to come." }
]
}
}
],
"coming_of_age_event": {
"id": "coming_of_age",
"age": 18,
"icon": "🎉",
"title": "Coming of Age!",
"description": "You are now a full adult with access to everything the world offers — jobs, dungeons, PVP, leadership roles, and more!",
"permanent_effects": {
"sanity": 30,
"health": 15,
"gold": 100,
"experience": 25,
"skillPoint": 2,
"notification": "You step into adulthood with promise and adventure ahead!"
},
"unlocks": [
"Full Adult Status",
"All Jobs",
"All Activities",
"PVP Arena",
"Leadership Roles"
]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
// Shared REST handler for the Lineage REST shim.
//
// Each route file calls `rest()` with a function that receives a typed tRPC
// caller (scoped to `caller.lineage.*`) and the SolidStart APIEvent. The
// handler maps `TRPCError` codes → HTTP status + `{ message }` body (the
// legacy client parses `result.message` on non-OK responses), and returns
// the procedure's return value as JSON on success.
import type { APIEvent } from "@solidjs/start/server";
import { TRPCError } from "@trpc/server";
import { createServerCaller } from "~/server/api/root";
const codeToStatus: Record<string, number> = {
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
TIMEOUT: 408,
PAYLOAD_TOO_LARGE: 413,
METHOD_NOT_SUPPORTED: 405,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500
};
type Caller = Awaited<ReturnType<typeof createServerCaller>>;
export async function rest(
fn: (caller: Caller, event: APIEvent) => Promise<unknown>,
event: APIEvent
): Promise<Response> {
try {
const caller = await createServerCaller(event);
const result = await fn(caller, event);
return new Response(JSON.stringify(result), {
status: 200,
headers: { "content-type": "application/json" }
});
} catch (e) {
if (e instanceof TRPCError) {
const status = codeToStatus[e.code] ?? 500;
return new Response(JSON.stringify({ message: e.message }), {
status,
headers: { "content-type": "application/json" }
});
}
console.error("Lineage REST shim error:", e);
return new Response(JSON.stringify({ message: "Internal server error" }), {
status: 500,
headers: { "content-type": "application/json" }
});
}
}
/** Extract the Bearer token from the Authorization header. */
export function bearerToken(event: APIEvent): string | null {
const auth = event.request.headers.get("authorization") ?? "";
const m = auth.match(/^Bearer\s+(.+)$/i);
return m?.[1]?.trim() ?? null;
}
/** Parse the JSON request body. */
export async function jsonBody<T = any>(event: APIEvent): Promise<T> {
return await event.request.json();
}

View File

@@ -0,0 +1,8 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "./_lib";
export const POST = (event: APIEvent) =>
rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.misc.analytics(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.appleGetEmail(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.appleRegistration(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.database.databaseCreds(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.database.deletionCancel(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.database.deletionCheck(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.database.deletionInit(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.emailLogin(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest, bearerToken } from "../../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
const token = bearerToken(event);
return caller.lineage.auth.refreshToken({ token: token ?? "" });
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.refreshVerification(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.emailRegistration(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.emailVerification(input);
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.auth.googleRegistration(input);
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.attacks();
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.conditions();
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.dungeons();
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.enemies();
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.items();
}, event);

View File

@@ -0,0 +1,6 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.jsonService.misc();
}, event);

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const GET = (event: APIEvent) =>
rest(async (caller) => {
return caller.lineage.jsonService.quest_events();
}, event);

View File

@@ -0,0 +1,22 @@
// Plain-text route for the Lineage offline secret.
//
// `misc.offlineSecret` is a tRPC `.query()` returning `{ secret: "…" }`. But
// the Lineage client's `IAPStore.fetchOfflineSecret` does
// `await response.text()` and uses the raw string directly as the decryption
// key. trpc-openapi always JSON-encodes responses, so exposing it via the openapi
// shim would yield `'{"secret":"…"}'` as text and break decryption.
//
// This dedicated route returns the secret verbatim as `text/plain`, matching
// exactly what the legacy endpoint returned. Auth: none (the legacy endpoint
// had none either — the secret is a server-side decryption key shared to all
// clients; it rotates with `LINEAGE_OFFLINE_SERIALIZATION_SECRET`).
import type { APIEvent } from "@solidjs/start/server";
import { env } from "~/env/server";
export const GET = (event: APIEvent) => {
return new Response(env.LINEAGE_OFFLINE_SERIALIZATION_SECRET, {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" }
});
};

View File

@@ -0,0 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.pvp.battleResult(input);
}, event);

View File

@@ -0,0 +1,13 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
// GET /api/lineage/pvp — retrieve opponents
export const GET = (event: APIEvent) => rest(async (caller) => {
return caller.lineage.pvp.getOpponents();
}, event);
// POST /api/lineage/pvp — register/update player character
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.pvp.registerCharacter(input);
}, event);

View File

@@ -1,9 +1,14 @@
import { Show, type JSX } from "solid-js";
import { Show, Switch, Match, type JSX } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import { A } from "@solidjs/router";
import RevealDropDown from "~/components/RevealDropDown";
import { ContactForm } from "~/components/ContactForm";
import { buildSubdomainUrl } from "~/lib/site-context";
import { useSite } from "~/context/SiteContext";
import NessaContactPage from "./nessa/contact";
import LineageContactPage from "./lineage/contact";
import GazeContactPage from "./gaze/contact";
import InputHaloContactPage from "./inputhalo/contact";
/**
* Main-site contact page (`freno.me/contact`).
@@ -115,7 +120,7 @@ export function LineageContactQuestions(): JSX.Element {
);
}
export default function ContactPage() {
function MainContactPage() {
const [searchParams] = useSearchParams();
const viewer = () => searchParams.viewer ?? "default";
@@ -131,3 +136,38 @@ export default function ContactPage() {
</ContactForm>
);
}
/**
* Host-aware contact route handler.
*
* SolidStart's file router cannot match on host, and the previous in-app
* middleware rewrite (`/contact` → `/<prefix>/contact`, server-only) diverged
* the SSR path from the browser URL — SolidStart's client `Router` derives its
* route purely from `window.location.pathname`, so the client matched the root
* route while the server rendered the subdomain route → hydration mismatch.
*
* The fix mirrors `src/routes/index.tsx`: resolve the public path (`/contact`)
* on BOTH server and client, then dispatch to the right subdomain contact
* component via `useSite()`. Server and client render the same component for
* the same path → hydration matches; nav links (public paths) and the
* canonical URL (public path) keep working unchanged.
*/
export default function ContactPage(): JSX.Element {
const site = useSite();
return (
<Switch fallback={<MainContactPage />}>
<Match when={site().id === "nessa"}>
<NessaContactPage />
</Match>
<Match when={site().id === "lineage"}>
<LineageContactPage />
</Match>
<Match when={site().id === "gaze"}>
<GazeContactPage />
</Match>
<Match when={site().id === "inputhalo"}>
<InputHaloContactPage />
</Match>
</Switch>
);
}

41
src/routes/deletion.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NessaDeletionPage from "./nessa/deletion";
import LineageDeletionPage from "./lineage/deletion";
/**
* Host-aware account-deletion route — `/deletion`.
*
* The public browser path `/deletion` serves each subdomain's per-product
* account-deletion flow. This mirrors `src/routes/index.tsx`'s host-aware
* dispatch: the server and client both resolve the public path (`/deletion`)
* and select the matching subdomain component via `useSite()`, so the two
* render the same component for the same path and hydration matches. This
* replaces the previous server-only path rewrite (`/deletion` →
* `/<prefix>/deletion`), which diverged the SSR path from the browser URL and
* caused hydration mismatches (SolidStart's client `Router` matches on
* `window.location.pathname`, not the rewritten server path).
*
* Only Nessa and Lineage ship a dedicated deletion page. Gaze, InputHalo, and
* the main site fall back to the 404 handler — preserving their pre-rewrite
* behavior (Gaze/InputHalo have no account system; the main site never had a
* `/deletion` route).
*
* Legacy `/deletion/life-and-lineage` 308-redirects to the Lineage subdomain's
* `https://lineage.freno.me/deletion` (see
* `src/routes/lineage/deletion-content.ts` → `LEGACY_DELETION_REDIRECT_TARGET`).
*/
export default function DeletionPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nessa"}>
<NessaDeletionPage />
</Match>
<Match when={site().id === "lineage"}>
<LineageDeletionPage />
</Match>
</Switch>
);
}

View File

@@ -1,12 +1,16 @@
import { PageHead } from "~/components/PageHead";
import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup } from "solid-js";
import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
import { glitchText } from "~/lib/client-utils";
import { buildSubdomainUrl } from "~/lib/subdomain-url";
import Button from "~/components/ui/Button";
import { useSite } from "~/context/SiteContext";
import LineageDownloadsPage from "./lineage/downloads";
import GazeDownloadsPage from "./gaze/downloads";
import InputHaloDownloadsPage from "./inputhalo/downloads";
export default function DownloadsPage() {
function MainDownloadsPage() {
const [LaLText, setLaLText] = createSignal("Life and Lineage");
const [SwAText, setSwAText] = createSignal("Shapes with Abigail!");
const [corkText, setCorkText] = createSignal("Cork");
@@ -103,7 +107,7 @@ export default function DownloadsPage() {
</A>
</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:flex-wrap lg:justify-around">
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS (14.6+)
@@ -155,7 +159,7 @@ export default function DownloadsPage() {
</A>
</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:flex-wrap lg:justify-around">
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS (14.6+)
@@ -205,7 +209,7 @@ export default function DownloadsPage() {
</A>
</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:flex-wrap lg:justify-around">
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: android
@@ -266,7 +270,7 @@ export default function DownloadsPage() {
<span class="text-yellow">{">"}</span> {SwAText()}
</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:flex-wrap lg:justify-around">
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: android
@@ -300,3 +304,30 @@ export default function DownloadsPage() {
</>
);
}
/**
* Host-aware downloads route handler.
*
* Dispatches to the per-subdomain download surface based on the resolved
* site (see `src/routes/index.tsx` for the pattern). Nessa has no dedicated
* downloads page, so it falls back to the unified downloads listing —
* matching the pre-middleware behavior. Resolving the public path on both
* server and client avoids the hydration mismatch the path-rewriting
* middleware caused.
*/
export default function DownloadsPage() {
const site = useSite();
return (
<Switch fallback={<MainDownloadsPage />}>
<Match when={site().id === "lineage"}>
<LineageDownloadsPage />
</Match>
<Match when={site().id === "gaze"}>
<GazeDownloadsPage />
</Match>
<Match when={site().id === "inputhalo"}>
<InputHaloDownloadsPage />
</Match>
</Switch>
);
}

View File

@@ -14,6 +14,7 @@ import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext";
import { downloadAsset } from "~/lib/download-asset";
@@ -22,8 +23,12 @@ const GAZE_MIN_MACOS = "14.6";
export default function GazeDownloadsPage() {
const site = useSite();
const { isDark } = useDarkMode();
const [loading, setLoading] = createSignal(false);
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const handleDownload = () => {
if (loading()) return;
setLoading(true);
@@ -85,6 +90,7 @@ export default function GazeDownloadsPage() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={handleDownload}
>

View File

@@ -4,6 +4,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext";
import { downloadAsset } from "~/lib/download-asset";
const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498";
@@ -33,9 +34,13 @@ const FEATURES = [
] as const;
export default function GazeLanding() {
const site = useSite();
const { isDark } = useDarkMode();
const [loading, setLoading] = createSignal(false);
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const handleDownload = () => {
if (loading()) return;
setLoading(true);
@@ -56,7 +61,7 @@ export default function GazeLanding() {
return (
<>
<PageHead
title="Eye & posture health reminder for macOS"
title="Home"
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."
ogImage="/look-away.png"
ogTitle="Gaze — Eye and posture health reminder for macOS"
@@ -100,6 +105,7 @@ export default function GazeLanding() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={handleDownload}
>
@@ -157,6 +163,7 @@ export default function GazeLanding() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={handleDownload}
>

View File

@@ -33,6 +33,8 @@ export default function InputHaloDownloadsPage() {
const iconSrc = () =>
isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT;
const brandColor = () => site().brandColor;
const download = () => {
if (loading()) return;
setLoading(true);
@@ -89,6 +91,7 @@ export default function InputHaloDownloadsPage() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={download}
>

View File

@@ -14,7 +14,6 @@ import { For, createSignal } from "solid-js";
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import { buildMainSiteUrl } from "~/lib/site-context";
import Button from "~/components/ui/Button";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import { useDarkMode } from "~/context/darkMode";
@@ -100,7 +99,7 @@ export default function InputHaloLanding() {
return (
<>
<PageHead
title="InputHalo"
title="Home"
description="A polished macOS menu bar app that visualizes keyboard presses, mouse clicks, cursor halos, and scroll events on screen — for streamers, presenters, and developers."
/>
@@ -152,6 +151,7 @@ export default function InputHaloLanding() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={download}
>
@@ -250,6 +250,7 @@ export default function InputHaloLanding() {
<Button
variant="download"
size="lg"
color={brandColor()}
loading={loading()}
onClick={download}
>
@@ -269,19 +270,6 @@ export default function InputHaloLanding() {
</p>
</div>
</section>
{/* ─── Footer ─────────────────────────────────────────────── */}
<footer class="border-surface0 relative z-10 border-t px-4 py-10">
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
<span>{site().displayName}</span>
<A
href={buildMainSiteUrl()}
class="hover:text-text underline-offset-4 hover:underline"
>
freno.me
</A>
</div>
</footer>
</main>
</>
);

View File

@@ -1,5 +1,6 @@
import { ContactForm } from "~/components/ContactForm";
import SubdomainHeader from "~/components/SubdomainHeader";
import { LineageContactQuestions } from "../contact";
/**
* Life and Lineage contact page (`lineage.freno.me/contact`).
@@ -24,7 +25,9 @@ export default function LineageContactPage() {
return (
<>
<SubdomainHeader />
<ContactForm />
<ContactForm>
<LineageContactQuestions />
</ContactForm>
</>
);
}

View File

@@ -73,7 +73,7 @@ describe("Lineage landing — asset paths", () => {
describe("Lineage landing — PageHead inputs", () => {
it("passes the base title (suffix is appended by PageHead)", () => {
expect(PAGE_META.title).toBe("Life and Lineage");
expect(PAGE_META.title).toBe("Home");
});
it("does not pre-bake the site suffix into the title", () => {

View File

@@ -54,7 +54,7 @@ export const SCREENSHOT_ASSETS = {
/** Base page title (site suffix appended by PageHead). */
export const PAGE_META = {
title: "Life and Lineage",
title: "Home",
description:
"A dark fantasy adventure mobile game. Download Life and Lineage on the App Store and Google Play."
} as const;

View File

@@ -54,7 +54,8 @@ export default function NessaLanding() {
const { isDark } = useDarkMode();
const iconSrc = () => (isDark() ? ICON_DARK : ICON_DEFAULT);
const brandColor = () => site().brandColor;
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
return (
<>
@@ -106,7 +107,7 @@ export default function NessaLanding() {
</A>
<A
href="/contact"
class="border-surface2 hover:bg-surface0/40 rounded-full border-2 px-8 py-3 text-base font-semibold transition-colors"
class="border-surface2 hover:bg-surface0/40 text-accent rounded-full border-2 px-8 py-3 font-semibold transition-colors"
>
Request beta access
</A>
@@ -342,19 +343,6 @@ export default function NessaLanding() {
</div>
</div>
</section>
{/* ─── Footer ───────────────────────────────────────────────── */}
<footer class="border-surface0 relative z-10 border-t px-4 py-10">
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
<span>{site().displayName}</span>
<A
href={buildMainSiteUrl()}
class="hover:text-text underline-offset-4 hover:underline"
>
freno.me
</A>
</div>
</footer>
</main>
</>
);

View File

@@ -13,13 +13,13 @@ import { SITE_CONFIG } from "~/lib/site-context";
import { NESSA_LANDING_META } from "./meta";
describe("Nessa landing page — PageHead metadata", () => {
it("title composes to 'Nessa | Nessa' (base title + nessa titleSuffix)", () => {
it("title composes to 'Home | Nessa' (base title + nessa titleSuffix)", () => {
const meta = resolvePageHeadMeta(
NESSA_LANDING_META,
SITE_CONFIG.nessa,
"/"
);
expect(meta.title).toBe("Nessa | Nessa");
expect(meta.title).toBe("Home | Nessa");
});
it("title contains the substring 'Nessa'", () => {

View File

@@ -23,7 +23,7 @@ import type { PageHeadProps } from "~/components/page-head-meta";
* no-suffix fallback) — this matches the marketing-copy intent of the card.
*/
export const NESSA_LANDING_META: PageHeadProps = {
title: "Nessa",
title: "Home",
description:
"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",

46
src/routes/privacy.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NessaPrivacyPolicy from "./nessa/privacy";
import LineagePrivacyPolicy from "./lineage/privacy";
import GazePrivacyPolicy from "./gaze/privacy";
import InputHaloPrivacyPolicy from "./inputhalo/privacy";
/**
* Host-aware privacy policy route — `/privacy`.
*
* The public browser path `/privacy` serves each subdomain's own privacy
* policy. This mirrors `src/routes/index.tsx`'s host-aware dispatch: the
* server and client both resolve the public path (`/privacy`) and select the
* matching subdomain component via `useSite()`, so the two render the same
* component for the same path and hydration matches. This replaces the
* previous server-only path rewrite (`/privacy` → `/<prefix>/privacy`), which
* diverged the SSR path from the browser URL and caused hydration mismatches
* (SolidStart's client `Router` matches on `window.location.pathname`, not the
* rewritten server path).
*
* The main site has no `/privacy` page (it uses `/privacy-policy/*`), so
* `main` falls back to the 404 handler — preserving its pre-rewrite behavior.
*
* Legacy `/privacy-policy/<product>` routes 308-redirect to the corresponding
* subdomain `/privacy` (see `src/routes/privacy-policy/*.tsx`).
*/
export default function PrivacyPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nessa"}>
<NessaPrivacyPolicy />
</Match>
<Match when={site().id === "lineage"}>
<LineagePrivacyPolicy />
</Match>
<Match when={site().id === "gaze"}>
<GazePrivacyPolicy />
</Match>
<Match when={site().id === "inputhalo"}>
<InputHaloPrivacyPolicy />
</Match>
</Switch>
);
}

View File

@@ -3,6 +3,11 @@ import { auditRouter } from "./routers/audit";
import { analyticsRouter } from "./routers/analytics";
import { databaseRouter } from "./routers/database";
import { lineageRouter } from "./routers/lineage";
// Re-exported so the REST shim (src/routes/api/lineage/[...path].ts) can mount
// ONLY the lineage sub-router as the trpc-openapi REST surface — not the
// whole appRouter. Keeps nessa/community/auth/audit routers unreachable via
// `/api/lineage/*`.
export { lineageRouter };
import { miscRouter } from "./routers/misc";
import { userRouter } from "./routers/user";
import { blogRouter } from "./routers/blog";

View File

@@ -6,7 +6,11 @@ import {
} from "~/server/utils";
import { env } from "~/env/server";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, publicProcedure, csrfProtectedProcedure } from "~/server/api/utils";
import {
createTRPCRouter,
publicProcedure,
csrfProtectedProcedure
} from "~/server/api/utils";
import {
fetchWithTimeout,
checkResponse,
@@ -16,8 +20,78 @@ import {
} from "~/server/fetch-utils";
export const lineageDatabaseRouter = createTRPCRouter({
// credentials endpoint removed (p8-008): was exposing persistent DB tokens to clients.
// Database access should be proxied through tRPC server-side procedures.
// Per-user DB credentials endpoint.
//
// Returns the caller's OWN remote Turso DB name + token so the client can
// open a direct libsql connection (game data syncs across devices). This is
// safe to expose to the authenticated user: the token grants full access only
// to that user's own DB, equivalent to an on-device SQLite that travels
// between devices. The generic p8-008 "exposing persistent DB tokens"
// concern does not apply — there is no cross-user escalation possible.
//
// REST surface: POST /api/lineage/database/creds (auth via Bearer header).
databaseCreds: csrfProtectedProcedure
.input(
z.object({
email: z.string().email(),
provider: z.enum(["email", "apple", "google"])
})
)
.mutation(async ({ input, ctx }) => {
const { email, provider } = input;
// Bearer auth token comes from the Authorization header, exactly as the
// legacy REST endpoint read it. For `email` it's a Lineage JWT; for
// `apple` the apple_user_string; for `google` a Google id token.
const authHeader =
(ctx.event.nativeEvent.node?.req?.headers?.authorization as
| string
| undefined) ??
(ctx.event.request?.headers?.get("authorization") as
| string
| undefined);
const authToken = authHeader?.replace(/^Bearer\s+/i, "").trim();
if (!authToken) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Missing Authorization header"
});
}
const conn = LineageConnectionFactory();
const res = await conn.execute({
sql: `SELECT * FROM User WHERE email = ? AND provider = ? LIMIT 1`,
args: [email, provider]
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found"
});
}
const userRow = res.rows[0];
const valid = await validateLineageRequest({
auth_token: authToken,
userRow
});
if (!valid) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Verification"
});
}
// The legacy endpoint returned `{ db_name, db_token }`; the User row
// stores these as `database_name` / `database_token`.
return {
db_name: userRow.database_name,
db_token: userRow.database_token
};
}),
deletionInit: csrfProtectedProcedure
.input(

View File

@@ -51,6 +51,10 @@ import healthOptions from "~/lineage-json/misc-route/healthOptions.json";
import sanityOptions from "~/lineage-json/misc-route/sanityOptions.json";
import pvpRewards from "~/lineage-json/misc-route/pvpRewards.json";
import questTemplates from "~/lineage-json/quest-event-route/questTemplates.json";
import childhoodEvents from "~/lineage-json/quest-event-route/childhoodEvents.json";
import overworldEvents from "~/lineage-json/quest-event-route/overworldEvents.json";
export const lineageJsonServiceRouter = createTRPCRouter({
attacks: publicProcedure.query(() => {
return {
@@ -131,5 +135,14 @@ export const lineageJsonServiceRouter = createTRPCRouter({
sanityOptions,
pvpRewards
};
}),
quest_events: publicProcedure.query(() => {
return {
ok: true,
questTemplates,
childhoodEvents,
overworldEvents
};
})
});

View File

@@ -1,4 +1,9 @@
import { createTRPCRouter, publicProcedure, adminProcedure, csrfProtectedProcedure } from "../../utils";
import {
createTRPCRouter,
publicProcedure,
adminProcedure,
csrfProtectedProcedure
} from "../../utils";
import { z } from "zod";
import { LineageConnectionFactory } from "~/server/utils";
import { env } from "~/env/server";
@@ -15,7 +20,7 @@ export const lineageMiscRouter = createTRPCRouter({
proficiencies: z.record(z.unknown()),
jobs: z.record(z.unknown()),
resistanceTable: z.record(z.unknown()),
damageTable: z.record(z.unknown()),
damageTable: z.record(z.unknown())
})
)
.mutation(async ({ input }) => {
@@ -27,7 +32,7 @@ export const lineageMiscRouter = createTRPCRouter({
proficiencies,
jobs,
resistanceTable,
damageTable,
damageTable
} = input;
const conn = LineageConnectionFactory();
@@ -47,8 +52,8 @@ export const lineageMiscRouter = createTRPCRouter({
JSON.stringify(proficiencies),
JSON.stringify(jobs),
JSON.stringify(resistanceTable),
JSON.stringify(damageTable),
],
JSON.stringify(damageTable)
]
});
return { success: true, status: 200 };
@@ -56,7 +61,7 @@ export const lineageMiscRouter = createTRPCRouter({
console.error("Analytics error:", e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to store analytics",
message: "Failed to store analytics"
});
}
}),
@@ -69,7 +74,7 @@ export const lineageMiscRouter = createTRPCRouter({
if (!token) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Missing token in body",
message: "Missing token in body"
});
}
@@ -80,11 +85,17 @@ export const lineageMiscRouter = createTRPCRouter({
if (res.rows.length > 0) {
const queryUpdate =
"UPDATE Token SET last_updated_at = datetime('now') WHERE token = ?";
const resUpdate = await conn.execute({ sql: queryUpdate, args: [token] });
const resUpdate = await conn.execute({
sql: queryUpdate,
args: [token]
});
return { success: true, action: "updated", result: resUpdate };
} else {
const queryInsert = "INSERT INTO Token (token) VALUES (?)";
const resInsert = await conn.execute({ sql: queryInsert, args: [token] });
const resInsert = await conn.execute({
sql: queryInsert,
args: [token]
});
return { success: true, action: "inserted", result: resInsert };
}
}),
@@ -99,7 +110,7 @@ export const lineageMiscRouter = createTRPCRouter({
try {
const res = await conn.execute({
sql: `SELECT * FROM Analytics`,
args: [],
args: []
});
const rows = res.rows.map((row: any) => ({
@@ -110,7 +121,7 @@ export const lineageMiscRouter = createTRPCRouter({
proficiencies: safeJsonParse(row.proficiencies),
jobs: safeJsonParse(row.jobs),
resistanceTable: safeJsonParse(row.resistanceTable),
damageTable: safeJsonParse(row.damageTable),
damageTable: safeJsonParse(row.damageTable)
}));
return { success: true, players: rows };
@@ -118,7 +129,7 @@ export const lineageMiscRouter = createTRPCRouter({
console.error("Failed to fetch lineage analytics:", e);
return { success: false, players: [] };
}
}),
})
});
function safeJsonParse(val: unknown): unknown {

View File

@@ -1,4 +1,8 @@
import { createTRPCRouter, publicProcedure, csrfProtectedProcedure } from "../../utils";
import {
createTRPCRouter,
publicProcedure,
csrfProtectedProcedure
} from "../../utils";
import { z } from "zod";
import { LineageConnectionFactory } from "~/server/utils";
import { TRPCError } from "@trpc/server";
@@ -17,7 +21,7 @@ const characterSchema = z.object({
resistanceTable: z.string(),
damageTable: z.string(),
attackStrings: z.string(),
knownSpells: z.string(),
knownSpells: z.string()
});
export const lineagePvpRouter = createTRPCRouter({
@@ -27,7 +31,7 @@ export const lineagePvpRouter = createTRPCRouter({
character: characterSchema,
linkID: z.string(),
pushToken: z.string().optional(),
pushCurrentlyEnabled: z.boolean().optional(),
pushCurrentlyEnabled: z.boolean().optional()
})
)
.mutation(async ({ input }) => {
@@ -37,7 +41,7 @@ export const lineagePvpRouter = createTRPCRouter({
const conn = LineageConnectionFactory();
const res = await conn.execute({
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
args: [linkID],
args: [linkID]
});
if (res.rows.length === 0) {
@@ -78,8 +82,8 @@ export const lineagePvpRouter = createTRPCRouter({
character.attackStrings,
character.knownSpells,
pushToken,
pushCurrentlyEnabled,
],
pushCurrentlyEnabled
]
});
return {
@@ -87,7 +91,7 @@ export const lineagePvpRouter = createTRPCRouter({
winCount: 0,
lossCount: 0,
tokenRedemptionCount: 0,
status: 201,
status: 201
};
} else {
await conn.execute({
@@ -126,8 +130,8 @@ export const lineagePvpRouter = createTRPCRouter({
character.knownSpells,
pushToken,
pushCurrentlyEnabled,
linkID,
],
linkID
]
});
return {
@@ -135,24 +139,25 @@ export const lineagePvpRouter = createTRPCRouter({
winCount: res.rows[0].winCount as number,
lossCount: res.rows[0].lossCount as number,
tokenRedemptionCount: res.rows[0].tokenRedemptionCount as number,
status: 200,
status: 200
};
}
} catch (e) {
console.error(e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to register character",
message: "Failed to register character"
});
}
}),
getOpponents: publicProcedure.query(async () => {
const conn = LineageConnectionFactory();
getOpponents: publicProcedure
.query(async () => {
const conn = LineageConnectionFactory();
try {
const res = await conn.execute(
`
try {
const res = await conn.execute(
`
SELECT playerClass,
blessing,
name,
@@ -174,27 +179,27 @@ export const lineagePvpRouter = createTRPCRouter({
ORDER BY RANDOM()
LIMIT 3
`
);
);
return {
ok: true,
characters: res.rows,
status: 200,
};
} catch (e) {
console.error(e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get opponents",
});
}
}),
return {
ok: true,
characters: res.rows,
status: 200
};
} catch (e) {
console.error(e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get opponents"
});
}
}),
battleResult: csrfProtectedProcedure
.input(
z.object({
winnerLinkID: z.string(),
loserLinkID: z.string(),
loserLinkID: z.string()
})
)
.mutation(async ({ input }) => {
@@ -211,19 +216,19 @@ export const lineagePvpRouter = createTRPCRouter({
lossCount = lossCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END
WHERE linkID IN (?, ?)
`,
args: [winnerLinkID, loserLinkID, winnerLinkID, loserLinkID],
args: [winnerLinkID, loserLinkID, winnerLinkID, loserLinkID]
});
return {
ok: true,
status: 200,
status: 200
};
} catch (e) {
console.error(e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to record battle result",
message: "Failed to record battle result"
});
}
}),
})
});

View File

@@ -181,4 +181,3 @@ const csrfProtection = t.middleware(async ({ ctx, next }) => {
// CSRF-protected procedure
export const csrfProtectedProcedure = t.procedure.use(csrfProtection);
export { csrfProtection };