diff --git a/apps/web/package.json b/apps/web/package.json index ac805cb0..c81dff3b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -53,6 +53,7 @@ "@tripwire/auth": "workspace:*", "@tripwire/core": "workspace:*", "@tripwire/db": "workspace:*", + "@tripwire/email": "workspace:*", "@tripwire/env": "workspace:*", "@tripwire/feedback": "workspace:*", "@tripwire/github": "workspace:*", diff --git a/apps/web/scripts/backfill-access-status.ts b/apps/web/scripts/backfill-access-status.ts new file mode 100644 index 00000000..107234ec --- /dev/null +++ b/apps/web/scripts/backfill-access-status.ts @@ -0,0 +1,42 @@ +/** + * One-time backfill: move every pre-existing user to `approved` so the + * access gate doesn't lock out people who were already using Tripwire. + * + * Run this in the same window as the schema push (rollout step 1), BEFORE + * turning on ACCESS_GATE_ENABLED. Newly added `access_status` columns default + * to "pending", so without this every existing user would be gated. + * + * Only approves users created before the script started, so it stays safe to + * re-run even after real pending signups exist. + * + * pnpm --filter @tripwire/web exec tsx scripts/backfill-access-status.ts + */ +import { and, eq, lte } from "drizzle-orm" +import { db } from "@tripwire/db/client" +import { user as userTable } from "@tripwire/db" + +async function main() { + const cutoff = new Date() + + const updated = await db + .update(userTable) + .set({ accessStatus: "approved", updatedAt: new Date() }) + .where( + and( + eq(userTable.accessStatus, "pending"), + lte(userTable.createdAt, cutoff) + ) + ) + .returning({ id: userTable.id }) + + console.log( + `[backfill-access-status] approved ${updated.length} pre-existing user(s).` + ) +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error("[backfill-access-status] failed:", err) + process.exit(1) + }) diff --git a/apps/web/scripts/reset-access-status.ts b/apps/web/scripts/reset-access-status.ts new file mode 100644 index 00000000..c4363f06 --- /dev/null +++ b/apps/web/scripts/reset-access-status.ts @@ -0,0 +1,77 @@ +/** + * Reverse of backfill-access-status: move every `approved` user back to + * `pending` (e.g. after everyone was approved by mistake). Clears the review + * metadata so they read as un-reviewed; leaves `waitlistedAt` intact. + * + * SAFE BY DEFAULT: dry-run unless `--apply` is passed. Keeps admins approved + * unless `--include-admins` is passed (so you don't lock yourself out of the + * admin panel once the gate is on). + * + * # see what it would do + * pnpm --filter @tripwire/web exec tsx scripts/reset-access-status.ts + * # actually do it (non-admins only) + * pnpm --filter @tripwire/web exec tsx scripts/reset-access-status.ts --apply + * # include admins too + * pnpm --filter @tripwire/web exec tsx scripts/reset-access-status.ts --apply --include-admins + */ +import { and, eq, ne, sql } from "drizzle-orm" +import { db } from "@tripwire/db/client" +import { user as userTable } from "@tripwire/db" + +const APPLY = process.argv.includes("--apply") +const INCLUDE_ADMINS = process.argv.includes("--include-admins") + +async function main() { + const [breakdown] = await db + .select({ + total: sql`count(*)::int`, + approved: sql`count(*) filter (where ${userTable.accessStatus} = 'approved')::int`, + pending: sql`count(*) filter (where ${userTable.accessStatus} = 'pending')::int`, + rejected: sql`count(*) filter (where ${userTable.accessStatus} = 'rejected')::int`, + approvedAdmins: sql`count(*) filter (where ${userTable.accessStatus} = 'approved' and ${userTable.role} = 'admin')::int`, + }) + .from(userTable) + + console.log("[reset-access-status] current:", breakdown) + + // Target: approved users, excluding admins unless --include-admins. + const where = INCLUDE_ADMINS + ? eq(userTable.accessStatus, "approved") + : and(eq(userTable.accessStatus, "approved"), ne(userTable.role, "admin")) + + if (!APPLY) { + const targets = await db + .select({ id: userTable.id }) + .from(userTable) + .where(where) + console.log( + `[reset-access-status] DRY RUN — would reset ${targets.length} user(s) to "pending"` + + `${INCLUDE_ADMINS ? " (including admins)" : " (admins kept approved)"}.` + + ` Re-run with --apply to execute.` + ) + return + } + + const updated = await db + .update(userTable) + .set({ + accessStatus: "pending", + accessReviewedAt: null, + accessReviewedBy: null, + updatedAt: new Date(), + }) + .where(where) + .returning({ id: userTable.id }) + + console.log( + `[reset-access-status] reset ${updated.length} user(s) to "pending"` + + `${INCLUDE_ADMINS ? " (including admins)" : " (admins kept approved)"}.` + ) +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error("[reset-access-status] failed:", err) + process.exit(1) + }) diff --git a/apps/web/scripts/waitlist-migration-email.ts b/apps/web/scripts/waitlist-migration-email.ts new file mode 100644 index 00000000..0a589e27 --- /dev/null +++ b/apps/web/scripts/waitlist-migration-email.ts @@ -0,0 +1,63 @@ +/** + * One-time campaign: email everyone on the legacy `waitlist` table asking + * them to sign in with GitHub and claim their spot (rollout step 4). + * + * Idempotent per address (Email SDK idempotency key), so a re-run inside the + * same provider window won't double-send. Retire the `waitlist` table and + * router after the 30-day claim window (rollout step 5). + * + * pnpm --filter @tripwire/web exec tsx scripts/waitlist-migration-email.ts + */ +import { db } from "@tripwire/db/client" +import { waitlist } from "@tripwire/db" +import { email, isEmailConfigured, EMAIL_FROM } from "@tripwire/email" +import { env } from "@tripwire/env/server" + +async function main() { + if (!isEmailConfigured) { + console.error("[waitlist-migration] RESEND_API_KEY unset — aborting.") + process.exit(1) + } + + const rows = await db.select({ email: waitlist.email }).from(waitlist) + console.log(`[waitlist-migration] sending to ${rows.length} address(es)…`) + + let sent = 0 + let failed = 0 + for (const row of rows) { + try { + await email.send( + { + from: EMAIL_FROM, + to: row.email, + subject: "Claim your Tripwire spot — sign in with GitHub", + text: [ + "Tripwire access now runs through GitHub sign-in.", + "", + `Sign in with GitHub to claim the spot you reserved with ${row.email}:`, + "", + `${env.APP_URL}/login`, + "", + "Your early signup is flagged for priority review.", + "", + "— The Tripwire team", + ].join("\n"), + }, + { idempotencyKey: `waitlist-migration:${row.email.toLowerCase()}` } + ) + sent++ + } catch (err) { + failed++ + console.error(`[waitlist-migration] failed for ${row.email}:`, err) + } + } + + console.log(`[waitlist-migration] done — ${sent} sent, ${failed} failed.`) +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error("[waitlist-migration] failed:", err) + process.exit(1) + }) diff --git a/apps/web/src/components/layout/app/shell/access-gate.tsx b/apps/web/src/components/layout/app/shell/access-gate.tsx new file mode 100644 index 00000000..6391730a --- /dev/null +++ b/apps/web/src/components/layout/app/shell/access-gate.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from "react" +import { useQuery } from "@tanstack/react-query" +import { useTRPC } from "#/integrations/trpc/react" +import { AccessPendingScreen } from "#/components/layout/auth/access-pending-screen" + +function GateSpinner() { + return ( +
+
+
+ ) +} + +/** + * Access boundary. For a gated, not-yet-approved user this REPLACES the app + * shell with the waitlist screen — the dashboard (and its repo/workspace + * queries) never mount, so there's no flash of "Install Tripwire…" before a + * redirect. Sits under `AuthProvider` (needs the session) and above the + * workspace/chat providers. + * + * The decision comes from `auth.me.gateEnabled` — the SAME server-side flag + * evaluation the API gate (`approvedProcedure`) uses, including the env + * fallback — so the client can never disagree with what the server enforces. + * The server remains the real boundary; this only prevents the flash. + */ +export function AccessGate({ children }: { children: ReactNode }) { + const trpc = useTRPC() + const me = useQuery( + trpc.auth.me.queryOptions(undefined, { staleTime: 30_000 }) + ) + + const data = me.data + + // Approved users are never gated — render the shell immediately. + if (data?.accessStatus === "approved") return <>{children} + + // Still resolving — hold on a neutral loader, never the shell. + if (me.isLoading) return + + // Not logged in (AuthProvider handles the redirect to /login). + if (!data) return <>{children} + + // Gate on (status is already known non-approved here) → replace the + // dashboard with the waitlist screen. + if (data.gateEnabled) { + return ( + + ) + } + + // Gate off → render the shell as normal. + return <>{children} +} diff --git a/apps/web/src/components/layout/app/shell/app-shell.tsx b/apps/web/src/components/layout/app/shell/app-shell.tsx index a4d97fd4..6e3be5c3 100644 --- a/apps/web/src/components/layout/app/shell/app-shell.tsx +++ b/apps/web/src/components/layout/app/shell/app-shell.tsx @@ -7,17 +7,22 @@ import { InstallGitHubPrompt } from "#/components/layout/app/shell/install-githu import { AskSidePanel } from "#/components/layout/app/chat/ask-side-panel" import { WorkspaceProvider, useWorkspace } from "#/providers/workspace-context" import { ChatProvider, useAIChat } from "#/providers/chat-context" +import { AccessGate } from "#/components/layout/app/shell/access-gate" import { useRequestNotifications } from "#/hooks/use-request-notifications" import { useOnboardingRedirect } from "#/hooks/use-onboarding-redirect" export function AppShell() { return ( - - - - - + {/* Access gate first: a gated, not-yet-approved user gets the waitlist + screen INSTEAD of the shell, so the dashboard never mounts. */} + + + + + + + ) } diff --git a/apps/web/src/components/layout/auth/access-pending-screen.tsx b/apps/web/src/components/layout/auth/access-pending-screen.tsx new file mode 100644 index 00000000..3752f7ad --- /dev/null +++ b/apps/web/src/components/layout/auth/access-pending-screen.tsx @@ -0,0 +1,53 @@ +import { authClient } from "@tripwire/auth/client" +import { TripwireLogo } from "@tripwire/ui/icons/tripwire-logo" +import type { AccessStatus } from "@tripwire/db" + +/** + * Minimal full-screen waitlist / access-status screen. Shared by the `/queue` + * route and the in-shell `AccessGate` boundary so both render identically. + */ +export function AccessPendingScreen({ + email, + image, + status, +}: { + email?: string | null + image?: string | null + status: AccessStatus +}) { + const rejected = status === "rejected" + + return ( +
+
+ + +
+

+ {rejected ? "Not this time" : "You're on the waitlist"} +

+

+ {rejected + ? "Your access request wasn't approved. Thanks for your interest in Tripwire — feel free to check back as we open up more broadly." + : "You applied with GitHub — you're in line for the closed beta. We review requests manually and will email you the moment you're approved."} +

+
+ +
+ {image ? ( + + ) : null} + {email ? {email} : null} + · + +
+
+
+ ) +} diff --git a/apps/web/src/components/layout/auth/login-page.tsx b/apps/web/src/components/layout/auth/login-page.tsx index 2b5f7410..635b1abd 100644 --- a/apps/web/src/components/layout/auth/login-page.tsx +++ b/apps/web/src/components/layout/auth/login-page.tsx @@ -26,9 +26,9 @@ export function LoginPage() { queryFn: async () => { const { data } = await authClient.signIn.social({ provider: "github", - callbackURL: "/rules", - // New-user creation is blocked server-side; failures land back here - // so we can show the "sign-ups paused" note instead of a raw error. + // Both new and returning users land here; the access gate routes + // pending/rejected users to /queue and approved users onward. + callbackURL: "/home", errorCallbackURL: "/login", disableRedirect: true, }) @@ -52,12 +52,11 @@ export function LoginPage() {
{error ? (

- We're not taking new sign-ups right now. If you already have access, - log in below. + Something went wrong signing you in. Try again below.

) : ( - - Already have access? + + Sign in with GitHub to request access )} {error ? null : (

- New sign-ups are paused for now — check back soon. + New here? Access is manually approved — we'll email you once you're + in.

)}
diff --git a/apps/web/src/components/layout/landing/evil-eye.tsx b/apps/web/src/components/layout/landing/evil-eye.tsx deleted file mode 100644 index 1edee6d4..00000000 --- a/apps/web/src/components/layout/landing/evil-eye.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { Renderer, Program, Mesh, Triangle, Texture } from "ogl" -import { useEffect, useRef } from "react" - -function hexToVec3(hex: string): [number, number, number] { - const h = hex.replace("#", "") - return [ - parseInt(h.slice(0, 2), 16) / 255, - parseInt(h.slice(2, 4), 16) / 255, - parseInt(h.slice(4, 6), 16) / 255, - ] -} - -function generateNoiseTexture(size = 256) { - const data = new Uint8Array(size * size * 4) - - function hash(x: number, y: number, s: number) { - let n = x * 374761393 + y * 668265263 + s * 1274126177 - n = Math.imul(n ^ (n >>> 13), 1274126177) - return ((n ^ (n >>> 16)) >>> 0) / 4294967296 - } - - function noise(px: number, py: number, freq: number, seed: number) { - const fx = (px / size) * freq - const fy = (py / size) * freq - const ix = Math.floor(fx) - const iy = Math.floor(fy) - const tx = fx - ix - const ty = fy - iy - const w = freq | 0 - const v00 = hash(((ix % w) + w) % w, ((iy % w) + w) % w, seed) - const v10 = hash((((ix + 1) % w) + w) % w, ((iy % w) + w) % w, seed) - const v01 = hash(((ix % w) + w) % w, (((iy + 1) % w) + w) % w, seed) - const v11 = hash((((ix + 1) % w) + w) % w, (((iy + 1) % w) + w) % w, seed) - return ( - v00 * (1 - tx) * (1 - ty) + - v10 * tx * (1 - ty) + - v01 * (1 - tx) * ty + - v11 * tx * ty - ) - } - - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - let v = 0 - let amp = 0.4 - let totalAmp = 0 - for (let o = 0; o < 8; o++) { - const f = 32 * (1 << o) - v += amp * noise(x, y, f, o * 31) - totalAmp += amp - amp *= 0.65 - } - v /= totalAmp - v = (v - 0.5) * 2.2 + 0.5 - v = Math.max(0, Math.min(1, v)) - const val = Math.round(v * 255) - const i = (y * size + x) * 4 - data[i] = val - data[i + 1] = val - data[i + 2] = val - data[i + 3] = 255 - } - } - - return data -} - -const vertexShader = ` -attribute vec2 uv; -attribute vec2 position; -varying vec2 vUv; -void main() { - vUv = uv; - gl_Position = vec4(position, 0, 1); -} -` - -const fragmentShader = ` -precision highp float; - -uniform float uTime; -uniform vec3 uResolution; -uniform sampler2D uNoiseTexture; -uniform float uPupilSize; -uniform float uIrisWidth; -uniform float uGlowIntensity; -uniform float uIntensity; -uniform float uScale; -uniform float uNoiseScale; -uniform vec2 uMouse; -uniform float uPupilFollow; -uniform float uFlameSpeed; -uniform vec3 uEyeColor; -uniform vec3 uBgColor; - -void main() { - vec2 uv = (gl_FragCoord.xy * 2.0 - uResolution.xy) / uResolution.y; - uv /= uScale; - float ft = uTime * uFlameSpeed; - - float polarRadius = length(uv) * 2.0; - float polarAngle = (2.0 * atan(uv.x, uv.y)) / 6.28 * 0.3; - vec2 polarUv = vec2(polarRadius, polarAngle); - - vec4 noiseA = texture2D(uNoiseTexture, polarUv * vec2(0.2, 7.0) * uNoiseScale + vec2(-ft * 0.1, 0.0)); - vec4 noiseB = texture2D(uNoiseTexture, polarUv * vec2(0.3, 4.0) * uNoiseScale + vec2(-ft * 0.2, 0.0)); - vec4 noiseC = texture2D(uNoiseTexture, polarUv * vec2(0.1, 5.0) * uNoiseScale + vec2(-ft * 0.1, 0.0)); - - float distanceMask = 1.0 - length(uv); - - float innerRing = clamp(-1.0 * ((distanceMask - 0.7) / uIrisWidth), 0.0, 1.0); - innerRing = (innerRing * distanceMask - 0.2) / 0.28; - innerRing += noiseA.r - 0.5; - innerRing *= 1.3; - innerRing = clamp(innerRing, 0.0, 1.0); - - float outerRing = clamp(-1.0 * ((distanceMask - 0.5) / 0.2), 0.0, 1.0); - outerRing = (outerRing * distanceMask - 0.1) / 0.38; - outerRing += noiseC.r - 0.5; - outerRing *= 1.3; - outerRing = clamp(outerRing, 0.0, 1.0); - - innerRing += outerRing; - - float innerEye = distanceMask - 0.1 * 2.0; - innerEye *= noiseB.r * 2.0; - - vec2 pupilOffset = uMouse * uPupilFollow * 0.12; - vec2 pupilUv = uv - pupilOffset; - float pupil = 1.0 - length(pupilUv * vec2(9.0, 2.3)); - pupil *= uPupilSize; - pupil = clamp(pupil, 0.0, 1.0); - pupil /= 0.35; - - float outerEyeGlow = 1.0 - length(uv * vec2(0.5, 1.5)); - outerEyeGlow = clamp(outerEyeGlow + 0.5, 0.0, 1.0); - outerEyeGlow += noiseC.r - 0.5; - float outerBgGlow = outerEyeGlow; - outerEyeGlow = pow(outerEyeGlow, 2.0); - outerEyeGlow += distanceMask; - outerEyeGlow *= uGlowIntensity; - outerEyeGlow = clamp(outerEyeGlow, 0.0, 1.0); - outerEyeGlow *= pow(1.0 - distanceMask, 2.0) * 2.5; - - outerBgGlow += distanceMask; - outerBgGlow = pow(outerBgGlow, 0.5); - outerBgGlow *= 0.15; - - vec3 color = uEyeColor * uIntensity * clamp(max(innerRing + innerEye, outerEyeGlow + outerBgGlow) - pupil, 0.0, 3.0); - color += uBgColor; - - gl_FragColor = vec4(color, 1.0); -} -` - -interface EvilEyeProps { - eyeColor?: string - intensity?: number - pupilSize?: number - irisWidth?: number - glowIntensity?: number - scale?: number - noiseScale?: number - pupilFollow?: number - flameSpeed?: number - backgroundColor?: string -} - -export default function EvilEye({ - eyeColor = "#FF6F37", - intensity = 1.5, - pupilSize = 0.6, - irisWidth = 0.25, - glowIntensity = 0.35, - scale = 0.8, - noiseScale = 1.0, - pupilFollow = 1.0, - flameSpeed = 1.0, - backgroundColor = "#000000", -}: EvilEyeProps) { - const containerRef = useRef(null) - - useEffect(() => { - if (!containerRef.current) return - const container = containerRef.current - const renderer = new Renderer({ alpha: true, premultipliedAlpha: false }) - const gl = renderer.gl - gl.clearColor(0, 0, 0, 0) - - const noiseData = generateNoiseTexture(256) - const noiseTexture = new Texture(gl, { - image: noiseData, - width: 256, - height: 256, - generateMipmaps: false, - flipY: false, - }) - noiseTexture.minFilter = gl.LINEAR - noiseTexture.magFilter = gl.LINEAR - noiseTexture.wrapS = gl.REPEAT - noiseTexture.wrapT = gl.REPEAT - - const mouse = { x: 0, y: 0, tx: 0, ty: 0 } - - function onMouseMove(e: MouseEvent) { - const rect = container.getBoundingClientRect() - mouse.tx = ((e.clientX - rect.left) / rect.width) * 2 - 1 - mouse.ty = -(((e.clientY - rect.top) / rect.height) * 2 - 1) - } - - function onMouseLeave() { - mouse.tx = 0 - mouse.ty = 0 - } - - container.addEventListener("mousemove", onMouseMove) - container.addEventListener("mouseleave", onMouseLeave) - - let program: Program - - function resize() { - renderer.setSize(container.offsetWidth, container.offsetHeight) - if (program) { - program.uniforms.uResolution.value = [ - gl.canvas.width, - gl.canvas.height, - gl.canvas.width / gl.canvas.height, - ] - } - } - window.addEventListener("resize", resize) - resize() - - const geometry = new Triangle(gl) - program = new Program(gl, { - vertex: vertexShader, - fragment: fragmentShader, - uniforms: { - uTime: { value: 0 }, - uResolution: { - value: [ - gl.canvas.width, - gl.canvas.height, - gl.canvas.width / gl.canvas.height, - ], - }, - uNoiseTexture: { value: noiseTexture }, - uPupilSize: { value: pupilSize }, - uIrisWidth: { value: irisWidth }, - uGlowIntensity: { value: glowIntensity }, - uIntensity: { value: intensity }, - uScale: { value: scale }, - uNoiseScale: { value: noiseScale }, - uMouse: { value: [0, 0] }, - uPupilFollow: { value: pupilFollow }, - uFlameSpeed: { value: flameSpeed }, - uEyeColor: { value: hexToVec3(eyeColor) }, - uBgColor: { value: hexToVec3(backgroundColor) }, - }, - }) - - const mesh = new Mesh(gl, { geometry, program }) - container.appendChild(gl.canvas) - - let animationFrameId: number - - function update(time: number) { - animationFrameId = requestAnimationFrame(update) - mouse.x += (mouse.tx - mouse.x) * 0.05 - mouse.y += (mouse.ty - mouse.y) * 0.05 - program.uniforms.uMouse.value = [mouse.x, mouse.y] - program.uniforms.uTime.value = time * 0.001 - renderer.render({ scene: mesh }) - } - animationFrameId = requestAnimationFrame(update) - - return () => { - cancelAnimationFrame(animationFrameId) - window.removeEventListener("resize", resize) - container.removeEventListener("mousemove", onMouseMove) - container.removeEventListener("mouseleave", onMouseLeave) - if (gl.canvas.parentNode === container) { - container.removeChild(gl.canvas) - } - gl.getExtension("WEBGL_lose_context")?.loseContext() - } - }, [ - eyeColor, - intensity, - pupilSize, - irisWidth, - glowIntensity, - scale, - noiseScale, - pupilFollow, - flameSpeed, - backgroundColor, - ]) - - return
-} diff --git a/apps/web/src/components/layout/landing/faulty-terminal.tsx b/apps/web/src/components/layout/landing/faulty-terminal.tsx deleted file mode 100644 index 64670f77..00000000 --- a/apps/web/src/components/layout/landing/faulty-terminal.tsx +++ /dev/null @@ -1,537 +0,0 @@ -import { Renderer, Program, Mesh, Color, Triangle, Texture } from "ogl" -import { useCallback, useEffect, useMemo, useRef } from "react" - -const vertexShader = ` -attribute vec2 position; -attribute vec2 uv; -varying vec2 vUv; -void main() { - vUv = uv; - gl_Position = vec4(position, 0.0, 1.0); -} -` - -const fragmentShader = ` -precision mediump float; - -varying vec2 vUv; - -uniform float iTime; -uniform vec3 iResolution; -uniform float uScale; - -uniform vec2 uGridMul; -uniform float uDigitSize; -uniform float uScanlineIntensity; -uniform float uGlitchAmount; -uniform float uFlickerAmount; -uniform float uNoiseAmp; -uniform float uChromaticAberration; -uniform float uDither; -uniform float uCurvature; -uniform vec3 uTint; -uniform vec2 uMouse; -uniform float uMouseStrength; -uniform float uUseMouse; -uniform sampler2D uMaskTex; -uniform vec2 uMaskHalfSize; -uniform float uUseMask; -uniform float uPageLoadProgress; -uniform float uUsePageLoadAnimation; -uniform float uBrightness; -uniform sampler2D uGameTex; -uniform float uGameMix; - -float time; - -float hash21(vec2 p){ - p = fract(p * 234.56); - p += dot(p, p + 34.56); - return fract(p.x * p.y); -} - -float noise(vec2 p) -{ - return sin(p.x * 10.0) * sin(p.y * (3.0 + sin(time * 0.090909))) + 0.2; -} - -mat2 rotate(float angle) -{ - float c = cos(angle); - float s = sin(angle); - return mat2(c, -s, s, c); -} - -float fbm(vec2 p) -{ - p *= 1.1; - float f = 0.0; - float amp = 0.5 * uNoiseAmp; - - mat2 modify0 = rotate(time * 0.02); - f += amp * noise(p); - p = modify0 * p * 2.0; - amp *= 0.454545; - - mat2 modify1 = rotate(time * 0.02); - f += amp * noise(p); - p = modify1 * p * 2.0; - amp *= 0.454545; - - mat2 modify2 = rotate(time * 0.08); - f += amp * noise(p); - - return f; -} - -float pattern(vec2 p, out vec2 q, out vec2 r) { - vec2 offset1 = vec2(1.0); - vec2 offset0 = vec2(0.0); - mat2 rot01 = rotate(0.1 * time); - mat2 rot1 = rotate(0.1); - - q = vec2(fbm(p + offset1), fbm(rot01 * p + offset1)); - r = vec2(fbm(rot1 * q + offset0), fbm(q + offset0)); - return fbm(p + r); -} - -float digit(vec2 p){ - vec2 grid = uGridMul * 15.0; - vec2 s = floor(p * grid) / grid; - p = p * grid; - vec2 q, r; - float intensity = pattern(s * 0.1, q, r) * 1.3 - 0.03; - - if(uUseMouse > 0.5){ - vec2 mouseWorld = uMouse * uScale; - - if(uUseMask > 0.5){ - vec2 d = s - mouseWorld; - vec2 muv = d / (uMaskHalfSize * 2.0) + 0.5; - if(muv.x >= 0.0 && muv.x <= 1.0 && muv.y >= 0.0 && muv.y <= 1.0){ - float maskVal = texture2D(uMaskTex, muv).r; - intensity += maskVal * uMouseStrength * 10.0; - } - } else { - float distToMouse = distance(s, mouseWorld); - float mouseInfluence = exp(-distToMouse * 8.0) * uMouseStrength * 10.0; - intensity += mouseInfluence; - - float ripple = sin(distToMouse * 20.0 - iTime * 5.0) * 0.1 * mouseInfluence; - intensity += ripple; - } - } - - if(uUsePageLoadAnimation > 0.5){ - float cellRandom = fract(sin(dot(s, vec2(12.9898, 78.233))) * 43758.5453); - float cellDelay = cellRandom * 0.8; - float cellProgress = clamp((uPageLoadProgress - cellDelay) / 0.2, 0.0, 1.0); - - float fadeAlpha = smoothstep(0.0, 1.0, cellProgress); - intensity *= fadeAlpha; - } - - p = fract(p); - p *= uDigitSize; - - float px5 = p.x * 5.0; - float py5 = (1.0 - p.y) * 5.0; - float x = fract(px5); - float y = fract(py5); - - float i = floor(py5) - 2.0; - float j = floor(px5) - 2.0; - float n = i * i + j * j; - float f = n * 0.0625; - - float isOn = step(0.1, intensity - f); - float brightness = isOn * (0.2 + y * 0.8) * (0.75 + x * 0.25); - - return step(0.0, p.x) * step(p.x, 1.0) * step(0.0, p.y) * step(p.y, 1.0) * brightness; -} - -float onOff(float a, float b, float c) -{ - return step(c, sin(iTime + a * cos(iTime * b))) * uFlickerAmount; -} - -float displace(vec2 look) -{ - float y = look.y - mod(iTime * 0.25, 1.0); - float window = 1.0 / (1.0 + 50.0 * y * y); - return sin(look.y * 20.0 + iTime) * 0.0125 * onOff(4.0, 2.0, 0.8) * (1.0 + cos(iTime * 60.0)) * window; -} - -vec3 getColor(vec2 p){ - - float bar = step(mod(p.y + time * 20.0, 1.0), 0.2) * 0.4 + 1.0; - bar *= uScanlineIntensity; - - float displacement = displace(p); - p.x += displacement; - - if (uGlitchAmount != 1.0) { - float extra = displacement * (uGlitchAmount - 1.0); - p.x += extra; - } - - float middle = digit(p); - - const float off = 0.002; - float sum = digit(p + vec2(-off, -off)) + digit(p + vec2(0.0, -off)) + digit(p + vec2(off, -off)) + - digit(p + vec2(-off, 0.0)) + digit(p + vec2(0.0, 0.0)) + digit(p + vec2(off, 0.0)) + - digit(p + vec2(-off, off)) + digit(p + vec2(0.0, off)) + digit(p + vec2(off, off)); - - vec3 baseColor = vec3(0.9) * middle + sum * 0.1 * vec3(1.0) * bar; - return baseColor; -} - -vec2 barrel(vec2 uv){ - vec2 c = uv * 2.0 - 1.0; - float r2 = dot(c, c); - c *= 1.0 + uCurvature * r2; - return c * 0.5 + 0.5; -} - -void main() { - time = iTime * 0.333333; - vec2 uv = vUv; - - if(uCurvature != 0.0){ - uv = barrel(uv); - } - - vec2 p = uv * uScale; - vec3 col = getColor(p); - - if(uChromaticAberration != 0.0){ - vec2 ca = vec2(uChromaticAberration) / iResolution.xy; - col.r = getColor(p + ca).r; - col.b = getColor(p - ca).b; - } - - if(uGameMix > 0.0){ - vec2 gameUv = uv; - vec4 game = texture2D(uGameTex, gameUv); - col = mix(col * 0.15, game.rgb * 1.5, game.a * uGameMix); - } - - col *= uTint; - col *= uBrightness; - - if(uDither > 0.0){ - float rnd = hash21(gl_FragCoord.xy); - col += (rnd - 0.5) * (uDither * 0.003922); - } - - gl_FragColor = vec4(col, 1.0); -} -` - -function hexToRgb(hex: string): [number, number, number] { - let h = hex.replace("#", "").trim() - if (h.length === 3) - h = h - .split("") - .map((c) => c + c) - .join("") - const num = parseInt(h, 16) - return [ - ((num >> 16) & 255) / 255, - ((num >> 8) & 255) / 255, - (num & 255) / 255, - ] -} - -interface CursorMaskLayer { - path: string - viewBox: readonly [number, number] - rect: readonly [number, number, number, number] - mode: "add" | "subtract" -} - -interface CursorMask { - viewBox: readonly [number, number] - width?: number - layers: readonly CursorMaskLayer[] -} - -interface FaultyTerminalProps { - scale?: number - gridMul?: [number, number] - digitSize?: number - timeScale?: number - pause?: boolean - scanlineIntensity?: number - glitchAmount?: number - flickerAmount?: number - noiseAmp?: number - chromaticAberration?: number - dither?: number | boolean - curvature?: number - tint?: string - mouseReact?: boolean - mouseStrength?: number - cursorMask?: CursorMask - pageLoadAnimation?: boolean - brightness?: number - gameCanvas?: HTMLCanvasElement | null - gameMix?: number - className?: string - style?: React.CSSProperties -} - -export default function FaultyTerminal({ - scale = 1, - gridMul = [2, 1], - digitSize = 1.5, - timeScale = 0.3, - pause = false, - scanlineIntensity = 0.3, - glitchAmount = 1, - flickerAmount = 1, - noiseAmp = 0, - chromaticAberration = 0, - dither = 0, - curvature = 0.2, - tint = "#ffffff", - mouseReact = true, - mouseStrength = 0.2, - cursorMask, - pageLoadAnimation = true, - brightness = 1, - gameCanvas = null, - gameMix = 0, - className = "", - style = {}, -}: FaultyTerminalProps) { - const containerRef = useRef(null) - const programRef = useRef(null) - const rendererRef = useRef(null) - const mouseRef = useRef({ x: 0.5, y: 0.5 }) - const smoothMouseRef = useRef({ x: 0.5, y: 0.5 }) - const frozenTimeRef = useRef(0) - const rafRef = useRef(0) - const loadAnimationStartRef = useRef(0) - const timeOffsetRef = useRef(Math.random() * 100) - const gameCanvasRef = useRef(null) - const gameMixRef = useRef(0) - gameCanvasRef.current = gameCanvas - gameMixRef.current = gameMix - - const tintVec = hexToRgb(tint) - const ditherValue = typeof dither === "boolean" ? (dither ? 1 : 0) : dither - const dpr = - typeof window !== "undefined" - ? Math.min(window.devicePixelRatio || 1, 2) - : 1 - - const maskKey = cursorMask ? JSON.stringify(cursorMask) : "" - const maskHalfSize = useMemo(() => { - if (!cursorMask) return new Float32Array([0, 0]) - const [vbW, vbH] = cursorMask.viewBox - const w = cursorMask.width ?? 1.4 - const h = (w * vbH) / vbW - return new Float32Array([w / 2, h / 2]) - }, [maskKey]) - - const handleMouseMove = useCallback((e: MouseEvent) => { - const ctn = containerRef.current - if (!ctn) return - const rect = ctn.getBoundingClientRect() - const x = (e.clientX - rect.left) / rect.width - const y = 1 - (e.clientY - rect.top) / rect.height - if (x < 0 || x > 1 || y < 0 || y > 1) return - mouseRef.current = { x, y } - }, []) - - useEffect(() => { - const ctn = containerRef.current - if (!ctn) return - - const renderer = new Renderer({ dpr }) - rendererRef.current = renderer - const gl = renderer.gl - gl.clearColor(0, 0, 0, 1) - - const maskCanvas = document.createElement("canvas") - if (cursorMask) { - const [vbW, vbH] = cursorMask.viewBox - const pixelW = 512 - const pixelH = Math.max(1, Math.round((pixelW * vbH) / vbW)) - maskCanvas.width = pixelW - maskCanvas.height = pixelH - const ctx = maskCanvas.getContext("2d") - if (ctx) { - const sx = pixelW / vbW - const sy = pixelH / vbH - for (const layer of cursorMask.layers) { - ctx.save() - ctx.fillStyle = layer.mode === "add" ? "white" : "black" - const [rx, ry, rw, rh] = layer.rect - const [pw, ph] = layer.viewBox - ctx.translate(rx * sx, ry * sy) - ctx.scale((rw / pw) * sx, (rh / ph) * sy) - ctx.fill(new Path2D(layer.path)) - ctx.restore() - } - } - } else { - maskCanvas.width = 1 - maskCanvas.height = 1 - } - const maskTexture = new Texture(gl, { - image: maskCanvas, - width: maskCanvas.width, - height: maskCanvas.height, - generateMipmaps: false, - }) - - const geometry = new Triangle(gl) - const program = new Program(gl, { - vertex: vertexShader, - fragment: fragmentShader, - uniforms: { - iTime: { value: 0 }, - iResolution: { - value: new Color( - gl.canvas.width, - gl.canvas.height, - gl.canvas.width / gl.canvas.height - ), - }, - uScale: { value: scale }, - uGridMul: { value: new Float32Array(gridMul) }, - uDigitSize: { value: digitSize }, - uScanlineIntensity: { value: scanlineIntensity }, - uGlitchAmount: { value: glitchAmount }, - uFlickerAmount: { value: flickerAmount }, - uNoiseAmp: { value: noiseAmp }, - uChromaticAberration: { value: chromaticAberration }, - uDither: { value: ditherValue }, - uCurvature: { value: curvature }, - uTint: { value: new Color(tintVec[0], tintVec[1], tintVec[2]) }, - uMouse: { value: new Float32Array([0.5, 0.5]) }, - uMouseStrength: { value: mouseStrength }, - uUseMouse: { value: mouseReact ? 1 : 0 }, - uMaskTex: { value: maskTexture }, - uMaskHalfSize: { value: maskHalfSize }, - uUseMask: { value: cursorMask ? 1 : 0 }, - uPageLoadProgress: { value: pageLoadAnimation ? 0 : 1 }, - uUsePageLoadAnimation: { value: pageLoadAnimation ? 1 : 0 }, - uBrightness: { value: brightness }, - uGameTex: { value: new Texture(gl) }, - uGameMix: { value: 0 }, - }, - }) - programRef.current = program - - const mesh = new Mesh(gl, { geometry, program }) - - function resize() { - if (!ctn || !renderer) return - renderer.setSize(ctn.offsetWidth, ctn.offsetHeight) - program.uniforms.iResolution.value = new Color( - gl.canvas.width, - gl.canvas.height, - gl.canvas.width / gl.canvas.height - ) - } - - const resizeObserver = new ResizeObserver(() => resize()) - resizeObserver.observe(ctn) - resize() - - const update = (t: number) => { - rafRef.current = requestAnimationFrame(update) - - if (pageLoadAnimation && loadAnimationStartRef.current === 0) { - loadAnimationStartRef.current = t - } - - if (!pause) { - const elapsed = (t * 0.001 + timeOffsetRef.current) * timeScale - program.uniforms.iTime.value = elapsed - frozenTimeRef.current = elapsed - } else { - program.uniforms.iTime.value = frozenTimeRef.current - } - - if (pageLoadAnimation && loadAnimationStartRef.current > 0) { - const animationElapsed = t - loadAnimationStartRef.current - program.uniforms.uPageLoadProgress.value = Math.min( - animationElapsed / 2000, - 1 - ) - } - - if (mouseReact) { - const sm = smoothMouseRef.current - const m = mouseRef.current - sm.x += (m.x - sm.x) * 0.08 - sm.y += (m.y - sm.y) * 0.08 - const mu = program.uniforms.uMouse.value as Float32Array - mu[0] = sm.x - mu[1] = sm.y - } - - if (gameCanvasRef.current && gameMixRef.current > 0) { - const gameTex = program.uniforms.uGameTex.value as InstanceType< - typeof Texture - > - gameTex.image = gameCanvasRef.current - gameTex.needsUpdate = true - program.uniforms.uGameMix.value = gameMixRef.current - } else { - program.uniforms.uGameMix.value = 0 - } - - renderer.render({ scene: mesh }) - } - rafRef.current = requestAnimationFrame(update) - ctn.appendChild(gl.canvas) - - if (mouseReact) window.addEventListener("mousemove", handleMouseMove) - - return () => { - cancelAnimationFrame(rafRef.current) - resizeObserver.disconnect() - if (mouseReact) window.removeEventListener("mousemove", handleMouseMove) - if (gl.canvas.parentElement === ctn) ctn.removeChild(gl.canvas) - gl.getExtension("WEBGL_lose_context")?.loseContext() - loadAnimationStartRef.current = 0 - timeOffsetRef.current = Math.random() * 100 - } - }, [ - dpr, - pause, - timeScale, - scale, - gridMul, - digitSize, - scanlineIntensity, - glitchAmount, - flickerAmount, - noiseAmp, - chromaticAberration, - ditherValue, - curvature, - tintVec, - mouseReact, - mouseStrength, - pageLoadAnimation, - brightness, - handleMouseMove, - maskKey, - maskHalfSize, - cursorMask, - ]) - - return ( -
- ) -} diff --git a/apps/web/src/components/layout/landing/header.tsx b/apps/web/src/components/layout/landing/header.tsx deleted file mode 100644 index 7df90c35..00000000 --- a/apps/web/src/components/layout/landing/header.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Link } from "@tanstack/react-router" -import type { AuthClientSession } from "@tripwire/auth/client" -import { TripwireLogo } from "@tripwire/ui/icons/tripwire-logo" - -// The terminal's barrel curvature makes the top edge of the visible screen bow -// upward in the middle (corners recede). Both side groups translate slightly -// down to sit where the curved screen edge actually is at the corners, and -// rotate outward so each group's baseline aligns with the curve tangent at its -// position. Contents stay crisp because we never touch pixels — only layout. -const SIDE_DROP_PX = 4 -const SIDE_TILT_DEG = 1.6 - -export function LandingHeader({ session }: { session: AuthClientSession }) { - return ( -
-
- - - tripwire - -
-
- {session ? ( - <> - - Welcome back - - - dashboard - - - ) : ( - <> - - Already have access? - - - login - - - )} -
-
- ) -} diff --git a/apps/web/src/components/layout/landing/space-invaders.tsx b/apps/web/src/components/layout/landing/space-invaders.tsx deleted file mode 100644 index 74d0add7..00000000 --- a/apps/web/src/components/layout/landing/space-invaders.tsx +++ /dev/null @@ -1,434 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from "react" -import { - TRIPWIRE_EYE_OUTER_PATH, - TRIPWIRE_EYE_OUTER_VIEWBOX, - TRIPWIRE_EYE_SOCKET_PATH, - TRIPWIRE_EYE_SOCKET_VIEWBOX, - TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER, - TRIPWIRE_EYE_PUPIL_PATH, - TRIPWIRE_EYE_PUPIL_VIEWBOX, - TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER, -} from "@tripwire/ui/icons/tripwire-eye" - -const PLAYER_W = 32 -const PLAYER_H = 16 -const BULLET_W = 2 -const BULLET_H = 8 -const INVADER_W = 24 -const INVADER_H = 16 -const INVADER_COLS = 8 -const INVADER_ROWS = 4 -const INVADER_GAP_X = 14 -const INVADER_GAP_Y = 12 -const INVADER_SPEED_BASE = 0.5 -const BULLET_SPEED = 7 -const ENEMY_BULLET_SPEED = 3 -const SHOOT_COOLDOWN = 180 -const ENEMY_SHOOT_INTERVAL = 1000 - -const GREEN = "#A7EF9E" -const GREEN_DIM = "rgba(167, 239, 158, 0.25)" -const GREEN_MID = "rgba(167, 239, 158, 0.5)" -const RED = "#F56D5D" -const LASER_RED = "#FF3333" -const LASER_GLOW = "rgba(255, 51, 51, 0.3)" - -interface Entity { - x: number - y: number - w: number - h: number - alive: boolean -} -interface Bullet extends Entity { - dy: number -} - -function createInvaders(w: number): Entity[] { - const invaders: Entity[] = [] - const totalW = INVADER_COLS * (INVADER_W + INVADER_GAP_X) - INVADER_GAP_X - const startX = (w - totalW) / 2 - for (let row = 0; row < INVADER_ROWS; row++) { - for (let col = 0; col < INVADER_COLS; col++) { - invaders.push({ - x: startX + col * (INVADER_W + INVADER_GAP_X), - y: 80 + row * (INVADER_H + INVADER_GAP_Y), - w: INVADER_W, - h: INVADER_H, - alive: true, - }) - } - } - return invaders -} - -// Pre-build Path2D objects for the eye (lazy-init to avoid SSR crash) -let eyeOuterPath: Path2D | null = null -let eyeSocketPath: Path2D | null = null -let eyePupilPath: Path2D | null = null - -function getEyePaths() { - if (!eyeOuterPath) { - eyeOuterPath = new Path2D(TRIPWIRE_EYE_OUTER_PATH) - eyeSocketPath = new Path2D(TRIPWIRE_EYE_SOCKET_PATH) - eyePupilPath = new Path2D(TRIPWIRE_EYE_PUPIL_PATH) - } - return { - eyeOuterPath, - eyeSocketPath: eyeSocketPath!, - eyePupilPath: eyePupilPath!, - } -} - -const EYE_SCALE = PLAYER_W / TRIPWIRE_EYE_OUTER_VIEWBOX[0] // fit to player width -const EYE_H = TRIPWIRE_EYE_OUTER_VIEWBOX[1] * EYE_SCALE - -function drawPlayer(ctx: CanvasRenderingContext2D, x: number, y: number) { - const paths = getEyePaths() - ctx.save() - ctx.translate(x, y - EYE_H / 2 + PLAYER_H / 2) - ctx.scale(EYE_SCALE, EYE_SCALE) - - ctx.fillStyle = GREEN - ctx.fill(paths.eyeOuterPath) - - ctx.save() - ctx.translate( - TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER[0], - TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER[1] - ) - const sx = - TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER[2] / TRIPWIRE_EYE_SOCKET_VIEWBOX[0] - const sy = - TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER[3] / TRIPWIRE_EYE_SOCKET_VIEWBOX[1] - ctx.scale(sx, sy) - ctx.fillStyle = "#111" - ctx.fill(paths.eyeSocketPath) - ctx.restore() - - ctx.save() - ctx.translate( - TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER[0], - TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER[1] - ) - const px = TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER[2] / TRIPWIRE_EYE_PUPIL_VIEWBOX[0] - const py = TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER[3] / TRIPWIRE_EYE_PUPIL_VIEWBOX[1] - ctx.scale(px, py) - ctx.fillStyle = RED - ctx.fill(paths.eyePupilPath) - ctx.restore() - - ctx.restore() -} - -function drawInvader( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - frame: number -) { - ctx.fillStyle = GREEN - if (frame % 2 === 0) { - ctx.fillRect(x + 8, y, 8, 4) - ctx.fillRect(x + 4, y + 4, 16, 4) - ctx.fillRect(x, y + 8, 24, 4) - ctx.fillRect(x + 4, y + 12, 4, 4) - ctx.fillRect(x + 16, y + 12, 4, 4) - } else { - ctx.fillRect(x + 8, y, 8, 4) - ctx.fillRect(x + 4, y + 4, 16, 4) - ctx.fillRect(x, y + 8, 24, 4) - ctx.fillRect(x, y + 12, 4, 4) - ctx.fillRect(x + 20, y + 12, 4, 4) - } -} - -function drawExplosion( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - t: number -) { - ctx.fillStyle = t > 0.5 ? GREEN_DIM : GREEN - const s = 4 + t * 14 - const cx = x + 12 - const cy = y + 8 - for (let i = 0; i < 8; i++) { - const angle = (i / 8) * Math.PI * 2 - ctx.fillRect( - cx + Math.cos(angle) * s - 1, - cy + Math.sin(angle) * s - 1, - 2, - 2 - ) - } -} - -// The game renders to an offscreen canvas that gets fed into FaultyTerminal's shader as a texture. -// No visible DOM element — the terminal IS the display. -export function useSpaceInvaders( - active: boolean, - onExit: () => void -): HTMLCanvasElement | null { - const [canvas, setCanvas] = useState(null) - const canvasRef = useRef(null) - const stateRef = useRef<{ - w: number - h: number - player: { x: number; y: number } - invaders: Entity[] - bullets: Bullet[] - enemyBullets: Bullet[] - explosions: { x: number; y: number; t: number }[] - keys: Set - invaderDir: number - invaderSpeed: number - animFrame: number - frameCount: number - lastShot: number - lastEnemyShot: number - score: number - lives: number - gameOver: boolean - wave: number - } | null>(null) - - const resetWave = useCallback(() => { - const s = stateRef.current - if (!s) return - s.invaders = createInvaders(s.w) - s.enemyBullets = [] - s.invaderDir = 1 - s.invaderSpeed = INVADER_SPEED_BASE + (s.wave - 1) * 0.15 - s.animFrame = 0 - }, []) - - useEffect(() => { - if (!active) { - canvasRef.current = null - stateRef.current = null - setCanvas(null) - return - } - - const W = window.innerWidth - const H = window.innerHeight - const c = document.createElement("canvas") - c.width = W - c.height = H - canvasRef.current = c - setCanvas(c) - const ctx = c.getContext("2d")! - - const s = { - w: W, - h: H, - player: { x: W / 2 - PLAYER_W / 2, y: H - 80 }, - invaders: createInvaders(W), - bullets: [] as Bullet[], - enemyBullets: [] as Bullet[], - explosions: [] as { x: number; y: number; t: number }[], - keys: new Set(), - invaderDir: 1, - invaderSpeed: INVADER_SPEED_BASE, - animFrame: 0, - frameCount: 0, - lastShot: 0, - lastEnemyShot: 0, - score: 0, - lives: 3, - gameOver: false, - wave: 1, - } - stateRef.current = s - - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onExit() - return - } - s.keys.add(e.key) - if (e.key === " " || e.key.startsWith("Arrow")) e.preventDefault() - } - const onKeyUp = (e: KeyboardEvent) => s.keys.delete(e.key) - window.addEventListener("keydown", onKey) - window.addEventListener("keyup", onKeyUp) - - let raf: number - const loop = () => { - raf = requestAnimationFrame(loop) - s.frameCount++ - - ctx.clearRect(0, 0, W, H) - - if (s.gameOver) { - ctx.fillStyle = GREEN - ctx.font = "bold 28px Geist, system-ui, monospace" - ctx.textAlign = "center" - ctx.fillText("GAME OVER", W / 2, H / 2 - 30) - ctx.font = "16px Geist, system-ui, monospace" - ctx.fillStyle = GREEN_MID - ctx.fillText(`Score: ${s.score} · Wave: ${s.wave}`, W / 2, H / 2 + 5) - ctx.font = "13px Geist, system-ui, monospace" - ctx.fillStyle = GREEN_DIM - ctx.fillText("SPACE to restart · ESC to exit", W / 2, H / 2 + 35) - if (s.keys.has(" ")) { - s.score = 0 - s.lives = 3 - s.wave = 1 - s.player.x = W / 2 - PLAYER_W / 2 - s.bullets = [] - s.gameOver = false - resetWave() - } - return - } - - const spd = 5 - if (s.keys.has("ArrowLeft")) s.player.x = Math.max(0, s.player.x - spd) - if (s.keys.has("ArrowRight")) - s.player.x = Math.min(W - PLAYER_W, s.player.x + spd) - - const now = performance.now() - if (s.keys.has(" ") && now - s.lastShot > SHOOT_COOLDOWN) { - s.lastShot = now - const pupilCenterX = - (TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER[0] + - TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER[2] / 2) * - EYE_SCALE - const pupilCenterY = - TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER[1] * EYE_SCALE - - EYE_H / 2 + - PLAYER_H / 2 - s.bullets.push({ - x: s.player.x + pupilCenterX - BULLET_W / 2, - y: s.player.y + pupilCenterY - BULLET_H, - w: BULLET_W, - h: BULLET_H * 2, - alive: true, - dy: -BULLET_SPEED, - }) - } - - for (const b of s.bullets) { - b.y += b.dy - if (b.y < -10) b.alive = false - } - for (const b of s.enemyBullets) { - b.y += b.dy - if (b.y > H + 10) b.alive = false - } - s.bullets = s.bullets.filter((b) => b.alive) - s.enemyBullets = s.enemyBullets.filter((b) => b.alive) - - let hitEdge = false - const alive = s.invaders.filter((i) => i.alive) - for (const inv of alive) { - inv.x += s.invaderDir * s.invaderSpeed - if (inv.x <= 10 || inv.x + inv.w >= W - 10) hitEdge = true - } - if (hitEdge) { - s.invaderDir *= -1 - for (const inv of alive) inv.y += 10 - } - if (s.frameCount % 25 === 0) s.animFrame++ - - if (now - s.lastEnemyShot > ENEMY_SHOOT_INTERVAL && alive.length > 0) { - s.lastEnemyShot = now - const shooter = alive[Math.floor(Math.random() * alive.length)] - s.enemyBullets.push({ - x: shooter.x + shooter.w / 2 - 1, - y: shooter.y + shooter.h, - w: 2, - h: 6, - alive: true, - dy: ENEMY_BULLET_SPEED, - }) - } - - for (const b of s.bullets) { - for (const inv of s.invaders) { - if (!inv.alive || !b.alive) continue - if ( - b.x < inv.x + inv.w && - b.x + b.w > inv.x && - b.y < inv.y + inv.h && - b.y + b.h > inv.y - ) { - b.alive = false - inv.alive = false - s.score += 10 - s.explosions.push({ x: inv.x, y: inv.y, t: 0 }) - } - } - } - - for (const b of s.enemyBullets) { - if ( - b.x < s.player.x + PLAYER_W && - b.x + b.w > s.player.x && - b.y < s.player.y + PLAYER_H && - b.y + b.h > s.player.y - ) { - b.alive = false - s.lives-- - if (s.lives <= 0) s.gameOver = true - } - } - - for (const inv of alive) { - if (inv.y + inv.h >= s.player.y) s.gameOver = true - } - - if (alive.length === 0) { - s.wave++ - resetWave() - } - - s.explosions = s.explosions.filter((e) => { - e.t += 0.04 - return e.t < 1 - }) - - drawPlayer(ctx, s.player.x, s.player.y) - for (const inv of s.invaders) { - if (inv.alive) drawInvader(ctx, inv.x, inv.y, s.animFrame) - } - for (const b of s.bullets) { - ctx.fillStyle = LASER_GLOW - ctx.fillRect(b.x - 2, b.y, b.w + 4, b.h) - ctx.fillStyle = LASER_RED - ctx.fillRect(b.x, b.y, b.w, b.h) - } - ctx.fillStyle = GREEN - for (const b of s.enemyBullets) ctx.fillRect(b.x, b.y, b.w, b.h) - for (const e of s.explosions) drawExplosion(ctx, e.x, e.y, e.t) - - ctx.fillStyle = GREEN - ctx.font = "13px Geist, system-ui, monospace" - ctx.textAlign = "left" - ctx.fillText(`SCORE ${s.score}`, 20, 40) - ctx.textAlign = "right" - ctx.fillText(`WAVE ${s.wave}`, W - 20, 40) - ctx.textAlign = "center" - for (let i = 0; i < s.lives; i++) { - ctx.fillRect(W / 2 - 20 + i * 14, 32, 8, 8) - } - ctx.fillStyle = GREEN_DIM - ctx.font = "10px Geist, system-ui, monospace" - ctx.fillText("ARROWS move · SPACE shoot · ESC exit", W / 2, H - 16) - } - - raf = requestAnimationFrame(loop) - return () => { - cancelAnimationFrame(raf) - window.removeEventListener("keydown", onKey) - window.removeEventListener("keyup", onKeyUp) - canvasRef.current = null - stateRef.current = null - setCanvas(null) - } - }, [active, onExit, resetWave]) - - return canvas -} diff --git a/apps/web/src/components/layout/landing/tripwire-features.tsx b/apps/web/src/components/layout/landing/tripwire-features.tsx deleted file mode 100644 index a8934d97..00000000 --- a/apps/web/src/components/layout/landing/tripwire-features.tsx +++ /dev/null @@ -1,899 +0,0 @@ -import { useState, useEffect } from "react" -import { motion, AnimatePresence } from "framer-motion" - -const easeOut = [0.23, 1, 0.32, 1] as const - -function SlopDetection() { - const lines = [ - { text: "fix: resolve race condition in pool", bad: false }, - { text: "refactor: extract auth middleware", bad: false }, - { text: "As an AI language model, I can", bad: true }, - { text: "feat: add rate limiting to /api", bad: false }, - { text: "Certainly! Here is a comprehen", bad: true }, - { text: "fix: edge case in parser.ts", bad: false }, - ] - const [cursor, setCursor] = useState(-1) - - useEffect(() => { - let i = -1 - let timer: ReturnType - const step = () => { - i++ - if (i >= lines.length) { - timer = setTimeout(() => { - i = -1 - setCursor(-1) - timer = setTimeout(step, 600) - }, 2000) - return - } - setCursor(i) - timer = setTimeout(step, 500) - } - timer = setTimeout(step, 400) - return () => clearTimeout(timer) - }, []) - - return ( -
- {lines.map((l, i) => { - const scanned = i <= cursor - const hit = scanned && l.bad - const scanning = i === cursor - return ( - - - {l.text} - - - ) - })} -
- ) -} - -function ProfilePicture() { - const items = [ - { real: true, img: "https://avatars.githubusercontent.com/u/75869731?v=4" }, - { real: false, img: "https://avatars.githubusercontent.com/u/200853?v=4" }, - { real: true, img: "https://avatars.githubusercontent.com/u/13007539?v=4" }, - { - real: true, - img: "https://avatars.githubusercontent.com/u/140507264?v=4", - }, - { real: false, img: "https://avatars.githubusercontent.com/u/423536?v=4" }, - { real: true, img: "https://avatars.githubusercontent.com/u/14241866?v=4" }, - { real: true, img: "https://avatars.githubusercontent.com/u/68947960?v=4" }, - { real: false, img: "https://avatars.githubusercontent.com/u/1002943?v=4" }, - { real: true, img: "https://avatars.githubusercontent.com/u/6751787?v=4" }, - ] - const [hovered, setHovered] = useState(null) - - return ( -
- {items.map((it, i) => { - const isHov = hovered === i - return ( - setHovered(i)} - onMouseLeave={() => setHovered(null)} - className="relative h-10 w-10 cursor-default overflow-hidden rounded" - animate={{ - borderColor: isHov - ? it.real - ? "rgba(255,255,255,0.18)" - : "rgba(255,255,255,0.12)" - : "#2a2a2a", - scale: isHov ? 1.04 : 1, - opacity: !it.real && !isHov ? 0.45 : 1, - }} - style={{ borderWidth: 1, borderStyle: "solid" }} - transition={{ duration: 0.16, ease: easeOut }} - > - - - {isHov && ( - - - {it.real ? "✓" : "✕"} - - - )} - - {!it.real && ( -
- )} - - ) - })} -
- ) -} - -function LanguageGate() { - const [lang, setLang] = useState("en") - const data: Record = { - en: [ - { t: "fix: resolve race condition", ok: true }, - { t: "修复数据库连接池泄漏问题", ok: false }, - { t: "feat: add retry with backoff", ok: true }, - { t: "corriger le bug de pagination", ok: false }, - ], - es: [ - { t: "fix: resolve race condition", ok: false }, - { t: "corregir error de validación", ok: true }, - { t: "añadir pruebas unitarias", ok: true }, - { t: "修复数据库连接池泄漏问题", ok: false }, - ], - } - - return ( -
-
- {["en", "es"].map((l) => ( - setLang(l)} - className="cursor-pointer rounded px-3 py-1 font-mono text-[11px] font-medium tracking-wide uppercase outline-none" - animate={{ - borderColor: lang === l ? "#3a3a3a" : "#2a2a2a", - backgroundColor: lang === l ? "#262525" : "rgba(0,0,0,0)", - color: - lang === l ? "rgba(255,255,255,0.7)" : "rgba(255,255,255,0.2)", - }} - whileTap={{ scale: 0.97 }} - style={{ borderWidth: 1, borderStyle: "solid" }} - transition={{ duration: 0.16, ease: easeOut }} - > - {l} - - ))} -
-
- - {data[lang].map((s, i) => ( - - - {s.t} - - - ))} - -
-
- ) -} - -function PRThreshold() { - const [min, setMin] = useState(5) - const users = [ - { n: "alice", v: 12 }, - { n: "bob", v: 3 }, - { n: "charlie", v: 8 }, - { n: "newbie", v: 1 }, - { n: "diana", v: 5 }, - { n: "eve", v: 0 }, - ] - const maxV = 12 - - return ( -
-
- - min - - setMin(Number(e.target.value))} - className="h-px flex-1 cursor-pointer appearance-none rounded-sm bg-[#262525] outline-none [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:border-[1.5px] [&::-webkit-slider-thumb]:border-[#1b1b1b] [&::-webkit-slider-thumb]:bg-white/35" - /> - - {min} - -
-
- {users.map((u) => { - const ok = u.v >= min - return ( -
- - {u.n} - -
- - -
- - {u.v} - -
- ) - })} -
-
- ) -} - -function AccountAge() { - const [day, setDay] = useState(0) - const limit = 30 - - useEffect(() => { - const interval = setInterval( - () => setDay((p) => (p >= 36 ? 0 : p + 1)), - 100 - ) - return () => clearInterval(interval) - }, []) - - const capped = Math.min(day, limit) - const blocked = day < limit - const pct = (capped / limit) * 100 - - return ( -
-
- - {capped} - - - / {limit}d - -
-
- -
- - {blocked ? "BLOCKED" : "ALLOWED"} - -
- ) -} - -function MaxPrsPerDay() { - const [limit, setLimit] = useState(3) - const prs = [ - { user: "alice", count: 2, time: "10:32 AM" }, - { user: "bob", count: 4, time: "11:15 AM" }, - { user: "charlie", count: 1, time: "2:45 PM" }, - { user: "diana", count: 3, time: "4:20 PM" }, - ] - - return ( -
-
- - limit - - setLimit(Number(e.target.value))} - className="h-px flex-1 cursor-pointer appearance-none rounded-sm bg-[#262525] outline-none [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:border-[1.5px] [&::-webkit-slider-thumb]:border-[#1b1b1b] [&::-webkit-slider-thumb]:bg-white/35" - /> - - {limit} - -
-
- {prs.map((p) => { - const ok = p.count <= limit - return ( -
- - {p.user} - -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- - {p.time} - -
- ) - })} -
-
- ) -} - -function MaxFilesChanged() { - const [limit, setLimit] = useState(20) - const prs = [ - { title: "fix: typo in readme", files: 1 }, - { title: "feat: add new auth flow", files: 12 }, - { title: "refactor: entire codebase", files: 47 }, - { title: "chore: update deps", files: 3 }, - { title: "feat: new dashboard", files: 28 }, - ] - const maxFiles = 50 - - return ( -
-
- - max - - setLimit(Number(e.target.value))} - className="h-px flex-1 cursor-pointer appearance-none rounded-sm bg-[#262525] outline-none [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:border-[1.5px] [&::-webkit-slider-thumb]:border-[#1b1b1b] [&::-webkit-slider-thumb]:bg-white/35" - /> - - {limit} - -
-
- {prs.map((p) => { - const ok = p.files <= limit - return ( -
- - {p.title} - -
- - -
- - {p.files} - -
- ) - })} -
-
- ) -} - -function RepoActivityMinimum() { - const [min, setMin] = useState(3) - const users = [ - { name: "alice", repos: 12, stars: 45 }, - { name: "newbie", repos: 0, stars: 0 }, - { name: "charlie", repos: 5, stars: 8 }, - { name: "bot123", repos: 1, stars: 0 }, - { name: "diana", repos: 8, stars: 23 }, - ] - - return ( -
-
- - min repos - - setMin(Number(e.target.value))} - className="h-px flex-1 cursor-pointer appearance-none rounded-sm bg-[#262525] outline-none [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:border-[1.5px] [&::-webkit-slider-thumb]:border-[#1b1b1b] [&::-webkit-slider-thumb]:bg-white/35" - /> - - {min} - -
-
- {users.map((u) => { - const ok = u.repos >= min - return ( -
- - {u.name} - -
- {Array.from({ length: Math.min(u.repos, 10) }).map((_, i) => ( - - ))} - {u.repos === 0 && ( - - none - - )} -
- - {u.repos} - -
- ) - })} -
-
- ) -} - -function RequireProfileReadme() { - const users = [ - { name: "alice", hasReadme: true, bio: "Building cool stuff" }, - { name: "bob", hasReadme: false, bio: "" }, - { name: "charlie", hasReadme: true, bio: "Open source maintainer" }, - { name: "newuser", hasReadme: false, bio: "" }, - ] - const [hovered, setHovered] = useState(null) - - return ( -
- {users.map((u) => ( - setHovered(u.name)} - onMouseLeave={() => setHovered(null)} - className="flex cursor-default items-center gap-2 rounded px-2 py-1.5" - animate={{ - backgroundColor: - hovered === u.name ? "rgba(255,255,255,0.02)" : "transparent", - }} - transition={{ duration: 0.15 }} - > - - {u.hasReadme ? "MD" : "?"} - -
- - {u.name} - - {u.hasReadme && u.bio && ( - - {u.bio} - - )} -
- - {u.hasReadme ? "✓" : "✕"} - -
- ))} -
- ) -} - -type AllowBlockUser = { name: string; side: string } - -function AllowBlockColumn({ - label, - side, - users, - onToggle, -}: { - label: string - side: string - users: AllowBlockUser[] - onToggle: (name: string) => void -}) { - return ( -
- - {label} - - - {users - .filter((u) => u.side === side) - .map((u) => ( - onToggle(u.name)} - className="flex cursor-pointer items-center gap-1.5 rounded border border-[#2a2a2a] bg-transparent px-2 py-1 text-left font-mono text-[11px] text-white/35 transition-[border-color] duration-150 outline-none hover:border-[#3a3a3a] active:scale-[0.97]" - > - - {u.name} - - ))} - -
- ) -} - -function AllowBlock() { - const [users, setUsers] = useState([ - { name: "t3dotgg", side: "allow" }, - { name: "ripgrim", side: "allow" }, - { name: "cody-labs-ai", side: "block" }, - { name: "huangwei0903", side: "block" }, - ]) - - const toggle = (name: string) => - setUsers((p) => - p.map((u) => - u.name === name - ? { ...u, side: u.side === "allow" ? "block" : "allow" } - : u - ) - ) - - return ( -
- -
- -
- ) -} - -const FEATURES = [ - { - title: "AI slop detection", - description: - "Pattern-match automated contributions and flag them before merge", - content: , - }, - { - title: "Require profile picture", - description: "Block contributors using GitHub's default silhouette", - content: , - }, - { - title: "Language gate", - description: "Only accept contributions in your chosen language", - content: , - }, - { - title: "PR threshold", - description: "Minimum merged PRs before someone can contribute", - content: , - }, - { - title: "Account age", - description: "Block accounts created too recently from contributing", - content: , - }, - { - title: "Max PRs per day", - description: "Rate limit PRs per user to prevent spam floods", - content: , - }, - { - title: "Max files changed", - description: "Reject PRs that modify too many files at once", - content: , - }, - { - title: "Repo activity", - description: "Require contributors to have public repository history", - content: , - }, - { - title: "Profile README", - description: "Contributors must have a GitHub profile README", - content: , - }, - { - title: "Allow & block lists", - description: "Per-user overrides for all your rules", - content: , - }, -] - -function CarouselCard({ - title, - description, - children, -}: { - title: string - description: string - children: React.ReactNode -}) { - return ( -
-
-

- {title} -

-

- {description} -

-
-
{children}
-
- ) -} - -export function TripwireFeatures() { - const [paused, setPaused] = useState(false) - - // Double the items for seamless loop - const track = [...FEATURES, ...FEATURES] - - return ( -
- {/* header */} -
-

- rules -

-

- guardrails that run on every contribution, automatically -

-
- - {/* carousel container */} -
setPaused(true)} - onMouseLeave={() => setPaused(false)} - > - {/* Edge blur — left */} -
- {/* Edge blur — right */} -
- - {/* Scrolling track */} -
- {track.map((f, i) => ( - - {f.content} - - ))} -
-
- - {/* Keyframes injected via style tag */} - -
- ) -} diff --git a/apps/web/src/components/layout/landing/waitlist-form.tsx b/apps/web/src/components/layout/landing/waitlist-form.tsx deleted file mode 100644 index b3fe8307..00000000 --- a/apps/web/src/components/layout/landing/waitlist-form.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useState } from "react" -import { useMutation } from "@tanstack/react-query" -import { useTRPC } from "#/integrations/trpc/react" -import { Button } from "@tripwire/ui/button" - -export function WaitlistForm() { - const [email, setEmail] = useState("") - const [status, setStatus] = useState<"idle" | "success" | "error">("idle") - const [errorMessage, setErrorMessage] = useState("") - const trpc = useTRPC() - - const joinWaitlist = useMutation( - trpc.waitlist.join.mutationOptions({ - onSuccess: () => { - setStatus("success") - setEmail("") - }, - onError: (err) => { - setStatus("error") - setErrorMessage(err.message) - }, - }) - ) - - function handleSubmit(e: React.FormEvent) { - e.preventDefault() - if (!email) return - setStatus("idle") - joinWaitlist.mutate({ email }) - } - - return ( -
-
-

- catch slop before it catches up with you -

- - {status === "success" ? ( -
- You're on the list! -
- ) : ( -
- setEmail(e.target.value)} - placeholder="enter email" - className="h-7 w-full rounded-[10px] border border-white/[0.08] bg-white/[0.026] px-2 text-sm text-white placeholder:text-[#999999] focus:border-white/20 focus:outline-none" - /> - -
- )} - - {status === "error" && ( -
{errorMessage}
- )} -
-
- ) -} diff --git a/apps/web/src/inngest/access-approved.ts b/apps/web/src/inngest/access-approved.ts new file mode 100644 index 00000000..572e0f26 --- /dev/null +++ b/apps/web/src/inngest/access-approved.ts @@ -0,0 +1,74 @@ +import { eq } from "drizzle-orm" +import { db } from "@tripwire/db/client" +import { user as userTable } from "@tripwire/db" +import { email, isEmailConfigured, EmailValidationError } from "@tripwire/email" +import { accessApprovedEmail } from "@tripwire/email/templates" +import { inngest } from "./client" + +/** + * Sends the "you're in" email when an access request is approved. Retryable + * (transient send failures) and idempotent at two layers: Inngest dedupes on + * the event's `userId`, and the Email SDK is handed a stable idempotency key + * so a retried send never delivers a second copy. A bulk approve therefore + * can't double-email even if the job re-runs. + */ +export const sendAccessApprovedEmail = inngest.createFunction( + { + id: "access-approved-email", + triggers: [{ event: "access/approved" }], + idempotency: "event.data.userId", + retries: 3, + }, + async ({ event, step }) => { + const { userId } = event.data + + const recipient = await step.run("load-user", async () => { + const [row] = await db + .select({ + email: userTable.email, + name: userTable.name, + accessStatus: userTable.accessStatus, + }) + .from(userTable) + .where(eq(userTable.id, userId)) + .limit(1) + return row ?? null + }) + + // Guard against a race where the user was rejected/deleted between the + // event firing and this step. Only email people who are actually approved. + if (!recipient || recipient.accessStatus !== "approved") { + return { sent: false, reason: "not-approved" } + } + + if (!isEmailConfigured) { + console.warn( + `[access-approved-email] RESEND_API_KEY unset — skipping email to ${recipient.email}` + ) + return { sent: false, reason: "email-not-configured" } + } + + const result = await step.run("send", async () => { + try { + const res = await email.send( + accessApprovedEmail({ to: recipient.email, name: recipient.name }), + { idempotencyKey: `access-approved:${userId}` } + ) + return { provider: res.provider, id: res.id ?? res.messageId ?? null } + } catch (err) { + // Validation errors (bad address, unsupported field) are permanent — + // retrying wastes attempts, so swallow them into a terminal result. + if (err instanceof EmailValidationError) { + console.error( + `[access-approved-email] validation error for ${recipient.email}:`, + err.message + ) + return { provider: null, id: null, failed: true as const } + } + throw err + } + }) + + return { sent: !("failed" in result), ...result } + } +) diff --git a/apps/web/src/inngest/client.ts b/apps/web/src/inngest/client.ts index 0a461e66..4eef5bae 100644 --- a/apps/web/src/inngest/client.ts +++ b/apps/web/src/inngest/client.ts @@ -33,8 +33,16 @@ export type VisibilityScoreUserRequestedEvent = { } } +export type AccessApprovedEvent = { + name: "access/approved" + data: { + userId: string + } +} + export type Events = { "research/run.requested": ResearchRunRequestedEvent "visibility/sync.requested": VisibilitySyncRequestedEvent "visibility/score-user.requested": VisibilityScoreUserRequestedEvent + "access/approved": AccessApprovedEvent } diff --git a/apps/web/src/integrations/trpc/init.ts b/apps/web/src/integrations/trpc/init.ts index b0635000..6d930180 100644 --- a/apps/web/src/integrations/trpc/init.ts +++ b/apps/web/src/integrations/trpc/init.ts @@ -4,7 +4,13 @@ import { EvlogError } from "evlog" import { and, eq } from "drizzle-orm" import { auth } from "@tripwire/auth" import { db } from "@tripwire/db/client" -import { member } from "@tripwire/db" +import { member, user as userTable } from "@tripwire/db" +import type { AccessStatus } from "@tripwire/db" +import { + accessDenialFor, + resolveEffectiveStatus, +} from "@tripwire/auth/access" +import { isAccessGateEnabled } from "#/lib/access-gate-flag" // Repo/event/request/org ownership checks live in @tripwire/core so the // tool registry can use them without importing tRPC. They throw EvlogError; @@ -21,7 +27,13 @@ export { export interface TRPCContext { headers: Headers - user: { id: string; name: string; email: string; role?: string | null } | null + user: { + id: string + name: string + email: string + role?: string | null + accessStatus?: AccessStatus | null + } | null /** * The Better Auth active organization id from `session.activeOrganizationId`. * Null when the user has no active org set, or when there's no session at @@ -43,7 +55,14 @@ export async function createContext(opts: { return { headers: opts.headers, - user: session?.user ?? null, + user: session?.user + ? { + ...session.user, + accessStatus: + (session.user as { accessStatus?: AccessStatus | null }) + .accessStatus ?? null, + } + : null, activeOrgId: session?.session?.activeOrganizationId ?? null, } } @@ -92,6 +111,49 @@ const authMiddleware = t.middleware(async ({ ctx, next }) => { export const authedProcedure = t.procedure.use(authMiddleware) +// Resolve the authoritative access status for a logged-in user. Trusts the +// session copy when it already reads "approved" (the common path, no query). +// Otherwise re-reads from the DB so a user approved mid-session isn't blocked +// while their cookie-cached session is still stale (see PRD "session +// staleness" risk). +function resolveAccessStatus(userId: string, sessionStatus: AccessStatus | null | undefined): Promise { + return resolveEffectiveStatus(sessionStatus, async () => { + const [row] = await db + .select({ accessStatus: userTable.accessStatus }) + .from(userTable) + .where(eq(userTable.id, userId)) + .limit(1) + return row?.accessStatus ?? null + }) +} + +// Gate: blocks pending/rejected users. No-op when the feature flag is off, so +// the schema + backfill can ship before the queue is enforced. +const accessMiddleware = t.middleware(async ({ ctx, next }) => { + if (!ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be logged in to perform this action", + }) + } + if (await isAccessGateEnabled({ userId: ctx.user.id, email: ctx.user.email })) { + const status = await resolveAccessStatus(ctx.user.id, ctx.user.accessStatus) + const denial = accessDenialFor(status) + if (denial) { + throw new TRPCError(denial) + } + } + return next({ ctx: { ...ctx, user: ctx.user } }) +}) + +/** + * Authenticated AND access-approved. Use for any `_app` data. When the + * access gate flag is off this behaves exactly like `authedProcedure`. + */ +export const approvedProcedure = t.procedure + .use(authMiddleware) + .use(accessMiddleware) + // Middleware that requires admin role const adminMiddleware = t.middleware(async ({ ctx, next }) => { if (!ctx.user) { @@ -114,7 +176,11 @@ const adminMiddleware = t.middleware(async ({ ctx, next }) => { }) }) -export const adminProcedure = t.procedure.use(adminMiddleware) +// Admin routes are also gated by access status (defense-in-depth: a pending +// user should never reach _admin even if role somehow said otherwise). +export const adminProcedure = t.procedure + .use(accessMiddleware) + .use(adminMiddleware) // Middleware that requires an active organization. Wraps authedProcedure's // auth check with two more guarantees: @@ -181,4 +247,4 @@ const orgMiddleware = t.middleware(async ({ ctx, next }) => { * rules, events, billing, etc.) — `ctx.activeOrgId` is guaranteed * non-null inside the resolver. */ -export const orgProcedure = t.procedure.use(orgMiddleware) +export const orgProcedure = t.procedure.use(accessMiddleware).use(orgMiddleware) diff --git a/apps/web/src/integrations/trpc/router.ts b/apps/web/src/integrations/trpc/router.ts index bc2d36d1..1c452ea7 100644 --- a/apps/web/src/integrations/trpc/router.ts +++ b/apps/web/src/integrations/trpc/router.ts @@ -22,6 +22,7 @@ import { moderationRouter } from "./routers/moderation" import { onboardingRouter } from "./routers/onboarding" import { authRouter } from "./routers/auth" import { githubSignalsRouter } from "./routers/github-signals" +import { accessRequestsRouter } from "./routers/access-requests" export const trpcRouter = createTRPCRouter({ orgs: orgsRouter, @@ -47,6 +48,7 @@ export const trpcRouter = createTRPCRouter({ onboarding: onboardingRouter, auth: authRouter, githubSignals: githubSignalsRouter, + accessRequests: accessRequestsRouter, }) export type TRPCRouter = typeof trpcRouter diff --git a/apps/web/src/integrations/trpc/routers/access-requests.ts b/apps/web/src/integrations/trpc/routers/access-requests.ts new file mode 100644 index 00000000..6ac06fb0 --- /dev/null +++ b/apps/web/src/integrations/trpc/routers/access-requests.ts @@ -0,0 +1,198 @@ +import { z } from "zod" +import { and, asc, eq, ilike, inArray, or, sql } from "drizzle-orm" +import { adminProcedure } from "../init" +import { trpcError } from "../error" +import { db } from "@tripwire/db/client" +import { user as userTable, ACCESS_STATUSES } from "@tripwire/db" +import { fetchPublicUser } from "@tripwire/github" +import { inngest } from "#/inngest/client" + +import type { TRPCRouterRecord } from "@trpc/server" + +const statusEnum = z.enum(ACCESS_STATUSES) + +export const accessRequestsRouter = { + /** + * Paginated access-request list for the admin queue. Defaults to pending, + * ordered so the oldest waiting requests surface first (waitlist claimants + * ahead of fresh signups). Search matches name/email — the GitHub login + * isn't stored on the user row, so per-row username lookups go through + * `githubMeta` instead. + */ + list: adminProcedure + .input( + z.object({ + status: statusEnum.default("pending"), + search: z.string().trim().max(200).optional(), + limit: z.number().int().min(1).max(100).default(25), + offset: z.number().int().min(0).default(0), + }) + ) + .query(async ({ input }) => { + const filters = [eq(userTable.accessStatus, input.status)] + if (input.search) { + const term = `%${input.search}%` + const match = or( + ilike(userTable.name, term), + ilike(userTable.email, term) + ) + if (match) filters.push(match) + } + const where = and(...filters) + + const [{ total }] = await db + .select({ total: sql`count(*)::int` }) + .from(userTable) + .where(where) + + const rows = await db + .select({ + id: userTable.id, + name: userTable.name, + email: userTable.email, + image: userTable.image, + githubId: userTable.githubId, + accessStatus: userTable.accessStatus, + accessReviewedAt: userTable.accessReviewedAt, + waitlistedAt: userTable.waitlistedAt, + createdAt: userTable.createdAt, + }) + .from(userTable) + .where(where) + // Waitlist claimants first (oldest join date), then oldest signups. + .orderBy( + sql`${userTable.waitlistedAt} asc nulls last`, + asc(userTable.createdAt) + ) + .limit(input.limit) + .offset(input.offset) + + return { items: rows, total, limit: input.limit, offset: input.offset } + }), + + /** + * Lazily-loaded GitHub metadata for one request row: login, avatar, and + * account age. Uses the unauthenticated public API keyed on the numeric + * GitHub id, so the queue list renders without blocking on 60/hr-limited + * calls. Returns null when the id is missing or the lookup fails. + */ + githubMeta: adminProcedure + .input(z.object({ githubId: z.number().int().positive() })) + .query(async ({ input }) => { + const user = await fetchPublicUser(String(input.githubId)) + if (!user) return null + return { + login: user.login, + avatarUrl: user.avatar_url, + htmlUrl: user.html_url, + accountCreatedAt: user.created_at, + } + }), + + approve: adminProcedure + .input(z.object({ userId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const approved = await approveOne(input.userId, ctx.user.id) + if (!approved) { + throw trpcError({ + code: "access.user_not_found", + status: 404, + message: "That user no longer exists.", + }) + } + return { approved: approved.newlyApproved } + }), + + reject: adminProcedure + .input(z.object({ userId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const [row] = await db + .update(userTable) + .set({ + accessStatus: "rejected", + accessReviewedAt: new Date(), + accessReviewedBy: ctx.user.id, + updatedAt: new Date(), + }) + .where(eq(userTable.id, input.userId)) + .returning({ id: userTable.id }) + if (!row) { + throw trpcError({ + code: "access.user_not_found", + status: 404, + message: "That user no longer exists.", + }) + } + return { rejected: true } + }), + + bulkApprove: adminProcedure + .input(z.object({ userIds: z.array(z.string().min(1)).min(1).max(200) })) + .mutation(async ({ ctx, input }) => { + // Flip only rows that aren't already approved, so re-running a bulk + // action doesn't re-stamp reviewer/time or re-enqueue emails. + const flipped = await db + .update(userTable) + .set({ + accessStatus: "approved", + accessReviewedAt: new Date(), + accessReviewedBy: ctx.user.id, + updatedAt: new Date(), + }) + .where( + and( + inArray(userTable.id, input.userIds), + sql`${userTable.accessStatus} <> 'approved'` + ) + ) + .returning({ id: userTable.id }) + + // One event per newly-approved user. The Inngest job + Email SDK both + // key on the user id, so a retry of this send never double-emails. + await Promise.all( + flipped.map((row) => + inngest + .send({ name: "access/approved", data: { userId: row.id } }) + .catch((err) => + console.error("[access] failed to enqueue approval email:", err) + ) + ) + ) + + return { approvedCount: flipped.length } + }), +} satisfies TRPCRouterRecord + +async function approveOne( + userId: string, + reviewerId: string +): Promise<{ newlyApproved: boolean } | null> { + const [existing] = await db + .select({ accessStatus: userTable.accessStatus }) + .from(userTable) + .where(eq(userTable.id, userId)) + .limit(1) + if (!existing) return null + + if (existing.accessStatus === "approved") { + return { newlyApproved: false } + } + + await db + .update(userTable) + .set({ + accessStatus: "approved", + accessReviewedAt: new Date(), + accessReviewedBy: reviewerId, + updatedAt: new Date(), + }) + .where(eq(userTable.id, userId)) + + await inngest + .send({ name: "access/approved", data: { userId } }) + .catch((err) => + console.error("[access] failed to enqueue approval email:", err) + ) + + return { newlyApproved: true } +} diff --git a/apps/web/src/integrations/trpc/routers/auth.ts b/apps/web/src/integrations/trpc/routers/auth.ts index b62fbdb6..41b85e90 100644 --- a/apps/web/src/integrations/trpc/routers/auth.ts +++ b/apps/web/src/integrations/trpc/routers/auth.ts @@ -2,6 +2,7 @@ import { authedProcedure, publicProcedure } from "../init" import { db } from "@tripwire/db/client" import { eq } from "drizzle-orm" import { user as userTable } from "@tripwire/db" +import { isAccessGateEnabled } from "#/lib/access-gate-flag" import type { TRPCRouterRecord } from "@trpc/server" export const authRouter = { @@ -19,11 +20,18 @@ export const authRouter = { image: userTable.image, role: userTable.role, githubId: userTable.githubId, + accessStatus: userTable.accessStatus, }) .from(userTable) .where(eq(userTable.id, ctx.user.id)) .limit(1) if (!row) return null + // Server-authoritative gate decision (Databuddy flag + env fallback) so the + // client boundary matches what the API actually enforces — no drift. + const gateEnabled = await isAccessGateEnabled({ + userId: row.id, + email: row.email, + }) return { id: row.id, name: row.name, @@ -32,6 +40,8 @@ export const authRouter = { role: row.role ?? null, isAdmin: row.role === "admin", githubId: parseGithubId(row.githubId), + accessStatus: row.accessStatus, + gateEnabled, } }), @@ -45,10 +55,15 @@ export const authRouter = { image: userTable.image, role: userTable.role, githubId: userTable.githubId, + accessStatus: userTable.accessStatus, }) .from(userTable) .where(eq(userTable.id, ctx.user.id)) .limit(1) + const gateEnabled = await isAccessGateEnabled({ + userId: row.id, + email: row.email, + }) return { id: row.id, name: row.name, @@ -57,6 +72,8 @@ export const authRouter = { role: row.role ?? null, isAdmin: row.role === "admin", githubId: parseGithubId(row.githubId), + accessStatus: row.accessStatus, + gateEnabled, } }), } satisfies TRPCRouterRecord diff --git a/apps/web/src/lib/access-gate-flag.ts b/apps/web/src/lib/access-gate-flag.ts new file mode 100644 index 00000000..890ce292 --- /dev/null +++ b/apps/web/src/lib/access-gate-flag.ts @@ -0,0 +1,34 @@ +import { createServerFlagsManager } from "@databuddy/sdk/node" +import { env } from "@tripwire/env/server" +import { isTruthy } from "@tripwire/env/boolean" +import { gateFromFlag } from "@tripwire/auth/access" +import { DATABUDDY_CLIENT_ID, FLAGS } from "#/lib/databuddy" + +/** + * Server-side evaluation of the closed-beta access gate. **Server-only** — this + * imports `@databuddy/sdk/node`; never import it into a client bundle. + * + * Databuddy's `access-gate` flag is the primary control (toggle it from the + * dashboard, no redeploy). If Databuddy is unreachable or the flag doesn't + * exist yet, we fall back to the `ACCESS_GATE_ENABLED` env var — a local + * kill-switch so the security boundary never hard-depends on an external + * service (per the chosen fallback policy). + */ +const flags = createServerFlagsManager({ clientId: DATABUDDY_CLIENT_ID }) + +function envFallback(): boolean { + return isTruthy(env.ACCESS_GATE_ENABLED) +} + +export async function isAccessGateEnabled(user?: { + userId?: string + email?: string +}): Promise { + try { + const flag = await flags.getFlag(FLAGS.accessGate, user) + return gateFromFlag(flag, envFallback()) + } catch { + // Network / SDK error — getFlag rejects. Fall back to the env kill-switch. + return gateFromFlag(null, envFallback()) + } +} diff --git a/apps/web/src/lib/access-gate.test.ts b/apps/web/src/lib/access-gate.test.ts new file mode 100644 index 00000000..7f0dff7f --- /dev/null +++ b/apps/web/src/lib/access-gate.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest" +import { + accessDenialFor, + applySignupAccessDefaults, + canTrustSessionStatus, + gateFromFlag, + resolveEffectiveStatus, +} from "@tripwire/auth/access" + +// These exercise the real decision logic used by the signup create hook +// (packages/auth) and the tRPC access gate (integrations/trpc/init.ts), kept +// pure so they run without a database. See the four required invariants. + +describe("waitlist access gate", () => { + describe("signup defaults — invariant 1 (default) & 2 (server-assigned only)", () => { + it("defaults a new GitHub signup to pending", () => { + const out = applySignupAccessDefaults({ email: "a@b.com" }, null) + expect(out.accessStatus).toBe("pending") + }) + + it("ignores any client-supplied accessStatus — cannot self-approve", () => { + const out = applySignupAccessDefaults( + { + email: "a@b.com", + accessStatus: "approved", + accessReviewedBy: "self", + } as Record, + null, + ) + expect(out.accessStatus).toBe("pending") + }) + + it("stamps the pre-launch waitlist timestamp when present", () => { + const t = new Date("2025-01-01T00:00:00Z") + expect(applySignupAccessDefaults({ email: "a@b.com" }, t).waitlistedAt).toBe(t) + }) + }) + + describe("API gate rejection — invariant 3", () => { + it("denies pending sessions with FORBIDDEN", () => { + expect(accessDenialFor("pending")).toMatchObject({ code: "FORBIDDEN" }) + }) + + it("denies rejected sessions with FORBIDDEN", () => { + expect(accessDenialFor("rejected")).toMatchObject({ code: "FORBIDDEN" }) + }) + + it("lets approved sessions through", () => { + expect(accessDenialFor("approved")).toBeNull() + }) + }) + + describe("promotion visible without reauth — invariant 4", () => { + it("re-reads the DB when the cached session is not yet approved", async () => { + const read = vi.fn().mockResolvedValue("approved") + // Session cookie still says "pending", but the admin just promoted them. + const status = await resolveEffectiveStatus("pending", read) + expect(status).toBe("approved") + expect(read).toHaveBeenCalledOnce() + }) + + it("trusts an approved session without hitting the DB", async () => { + const read = vi.fn().mockResolvedValue("pending") + const status = await resolveEffectiveStatus("approved", read) + expect(status).toBe("approved") + expect(read).not.toHaveBeenCalled() + }) + + it("defaults to pending when the user row is missing", async () => { + const status = await resolveEffectiveStatus("pending", async () => null) + expect(status).toBe("pending") + }) + + it("canTrustSessionStatus only trusts approved", () => { + expect(canTrustSessionStatus("approved")).toBe(true) + expect(canTrustSessionStatus("pending")).toBe(false) + expect(canTrustSessionStatus("rejected")).toBe(false) + expect(canTrustSessionStatus(null)).toBe(false) + }) + }) + + describe("gate flag resolution — Databuddy primary, env fallback", () => { + it("trusts a resolved flag (enabled) regardless of env", () => { + expect(gateFromFlag({ enabled: true, reason: "RULE_MATCH" }, false)).toBe(true) + expect(gateFromFlag({ enabled: false, reason: "RULE_MATCH" }, true)).toBe(false) + }) + + it("falls back to env when the flag doesn't exist yet (NOT_FOUND)", () => { + expect(gateFromFlag({ enabled: false, reason: "NOT_FOUND" }, true)).toBe(true) + expect(gateFromFlag({ enabled: false, reason: "NOT_FOUND" }, false)).toBe(false) + }) + + it("falls back to env on Databuddy error / pending session", () => { + expect(gateFromFlag({ enabled: false, reason: "ERROR" }, true)).toBe(true) + expect(gateFromFlag({ enabled: false, reason: "SESSION_PENDING" }, true)).toBe(true) + }) + + it("falls back to env when the lookup threw (null flag)", () => { + expect(gateFromFlag(null, true)).toBe(true) + expect(gateFromFlag(null, false)).toBe(false) + }) + }) +}) diff --git a/apps/web/src/lib/databuddy.ts b/apps/web/src/lib/databuddy.ts new file mode 100644 index 00000000..09c2c668 --- /dev/null +++ b/apps/web/src/lib/databuddy.ts @@ -0,0 +1,12 @@ +/** + * Public Databuddy project client id — safe to ship to the browser. Shared by + * the analytics `` component and the server flags manager + * (`access-gate-flag.ts`) so both evaluate against the same project. + */ +export const DATABUDDY_CLIENT_ID = "09661145-7249-45d9-a9e3-f1a93e9c7266" + +/** Feature-flag keys we evaluate. */ +export const FLAGS = { + /** Closed-beta access gate. On → pending/rejected users are blocked. */ + accessGate: "access-gate", +} as const diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 35fc1afb..57b7e561 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as VouchedRouteImport } from './routes/vouched' +import { Route as QueueRouteImport } from './routes/queue' import { Route as LoginRouteImport } from './routes/login' import { Route as DitherKitRouteImport } from './routes/dither-kit' import { Route as AppRouteImport } from './routes/_app' @@ -18,6 +19,8 @@ import { Route as OnboardingRouteRouteImport } from './routes/onboarding/route' import { Route as IndexRouteImport } from './routes/index' import { Route as RCliEventRouteImport } from './routes/r/cli-event' import { Route as RNameRouteImport } from './routes/r/$name' +import { Route as OauthWaitlistRouteImport } from './routes/oauth/waitlist' +import { Route as OauthPopupCallbackRouteImport } from './routes/oauth/popup-callback' import { Route as OauthConsentRouteImport } from './routes/oauth/consent' import { Route as ApiMcpRouteImport } from './routes/api/mcp' import { Route as ApiInngestRouteImport } from './routes/api/inngest' @@ -61,6 +64,7 @@ import { Route as AppOrgHandleIntegrationsRouteImport } from './routes/_app/$org import { Route as AppOrgHandleInsightsRouteImport } from './routes/_app/$orgHandle/insights' import { Route as AppOrgHandleHomeRouteImport } from './routes/_app/$orgHandle/home' import { Route as AdminAdminReputationRouteImport } from './routes/_admin/admin/reputation' +import { Route as AdminAdminAccessRequestsRouteImport } from './routes/_admin/admin/access-requests' import { Route as Char91DotwellKnownChar93OauthProtectedResourceSplatRouteImport } from './routes/[.well-known]/oauth-protected-resource/$' import { Route as Char91DotwellKnownChar93OauthAuthorizationServerSplatRouteImport } from './routes/[.well-known]/oauth-authorization-server/$' import { Route as AppOrgHandleRulesRouteRouteImport } from './routes/_app/$orgHandle/rules/route' @@ -88,6 +92,11 @@ const VouchedRoute = VouchedRouteImport.update({ path: '/vouched', getParentRoute: () => rootRouteImport, } as any) +const QueueRoute = QueueRouteImport.update({ + id: '/queue', + path: '/queue', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -126,6 +135,16 @@ const RNameRoute = RNameRouteImport.update({ path: '/r/$name', getParentRoute: () => rootRouteImport, } as any) +const OauthWaitlistRoute = OauthWaitlistRouteImport.update({ + id: '/oauth/waitlist', + path: '/oauth/waitlist', + getParentRoute: () => rootRouteImport, +} as any) +const OauthPopupCallbackRoute = OauthPopupCallbackRouteImport.update({ + id: '/oauth/popup-callback', + path: '/oauth/popup-callback', + getParentRoute: () => rootRouteImport, +} as any) const OauthConsentRoute = OauthConsentRouteImport.update({ id: '/oauth/consent', path: '/oauth/consent', @@ -344,6 +363,12 @@ const AdminAdminReputationRoute = AdminAdminReputationRouteImport.update({ path: '/admin/reputation', getParentRoute: () => AdminRoute, } as any) +const AdminAdminAccessRequestsRoute = + AdminAdminAccessRequestsRouteImport.update({ + id: '/admin/access-requests', + path: '/admin/access-requests', + getParentRoute: () => AdminRoute, + } as any) const Char91DotwellKnownChar93OauthProtectedResourceSplatRoute = Char91DotwellKnownChar93OauthProtectedResourceSplatRouteImport.update({ id: '/$', @@ -467,6 +492,7 @@ export interface FileRoutesByFullPath { '/onboarding': typeof OnboardingRouteRouteWithChildren '/dither-kit': typeof DitherKitRoute '/login': typeof LoginRoute + '/queue': typeof QueueRoute '/vouched': typeof VouchedRoute '/.well-known/oauth-authorization-server': typeof Char91DotwellKnownChar93OauthAuthorizationServerRouteWithChildren '/.well-known/oauth-protected-resource': typeof Char91DotwellKnownChar93OauthProtectedResourceRouteWithChildren @@ -483,11 +509,14 @@ export interface FileRoutesByFullPath { '/api/inngest': typeof ApiInngestRoute '/api/mcp': typeof ApiMcpRoute '/oauth/consent': typeof OauthConsentRoute + '/oauth/popup-callback': typeof OauthPopupCallbackRoute + '/oauth/waitlist': typeof OauthWaitlistRoute '/r/$name': typeof RNameRoute '/r/cli-event': typeof RCliEventRoute '/$orgHandle/rules': typeof AppOrgHandleRulesRouteRouteWithChildren '/.well-known/oauth-authorization-server/$': typeof Char91DotwellKnownChar93OauthAuthorizationServerSplatRoute '/.well-known/oauth-protected-resource/$': typeof Char91DotwellKnownChar93OauthProtectedResourceSplatRoute + '/admin/access-requests': typeof AdminAdminAccessRequestsRoute '/admin/reputation': typeof AdminAdminReputationRoute '/$orgHandle/home': typeof AppOrgHandleHomeRoute '/$orgHandle/insights': typeof AppOrgHandleInsightsRoute @@ -540,6 +569,7 @@ export interface FileRoutesByTo { '/onboarding': typeof OnboardingRouteRouteWithChildren '/dither-kit': typeof DitherKitRoute '/login': typeof LoginRoute + '/queue': typeof QueueRoute '/vouched': typeof VouchedRoute '/.well-known/oauth-authorization-server': typeof Char91DotwellKnownChar93OauthAuthorizationServerRouteWithChildren '/.well-known/oauth-protected-resource': typeof Char91DotwellKnownChar93OauthProtectedResourceRouteWithChildren @@ -556,10 +586,13 @@ export interface FileRoutesByTo { '/api/inngest': typeof ApiInngestRoute '/api/mcp': typeof ApiMcpRoute '/oauth/consent': typeof OauthConsentRoute + '/oauth/popup-callback': typeof OauthPopupCallbackRoute + '/oauth/waitlist': typeof OauthWaitlistRoute '/r/$name': typeof RNameRoute '/r/cli-event': typeof RCliEventRoute '/.well-known/oauth-authorization-server/$': typeof Char91DotwellKnownChar93OauthAuthorizationServerSplatRoute '/.well-known/oauth-protected-resource/$': typeof Char91DotwellKnownChar93OauthProtectedResourceSplatRoute + '/admin/access-requests': typeof AdminAdminAccessRequestsRoute '/admin/reputation': typeof AdminAdminReputationRoute '/$orgHandle/home': typeof AppOrgHandleHomeRoute '/$orgHandle/insights': typeof AppOrgHandleInsightsRoute @@ -615,6 +648,7 @@ export interface FileRoutesById { '/_app': typeof AppRouteWithChildren '/dither-kit': typeof DitherKitRoute '/login': typeof LoginRoute + '/queue': typeof QueueRoute '/vouched': typeof VouchedRoute '/.well-known/oauth-authorization-server': typeof Char91DotwellKnownChar93OauthAuthorizationServerRouteWithChildren '/.well-known/oauth-protected-resource': typeof Char91DotwellKnownChar93OauthProtectedResourceRouteWithChildren @@ -631,11 +665,14 @@ export interface FileRoutesById { '/api/inngest': typeof ApiInngestRoute '/api/mcp': typeof ApiMcpRoute '/oauth/consent': typeof OauthConsentRoute + '/oauth/popup-callback': typeof OauthPopupCallbackRoute + '/oauth/waitlist': typeof OauthWaitlistRoute '/r/$name': typeof RNameRoute '/r/cli-event': typeof RCliEventRoute '/_app/$orgHandle/rules': typeof AppOrgHandleRulesRouteRouteWithChildren '/.well-known/oauth-authorization-server/$': typeof Char91DotwellKnownChar93OauthAuthorizationServerSplatRoute '/.well-known/oauth-protected-resource/$': typeof Char91DotwellKnownChar93OauthProtectedResourceSplatRoute + '/_admin/admin/access-requests': typeof AdminAdminAccessRequestsRoute '/_admin/admin/reputation': typeof AdminAdminReputationRoute '/_app/$orgHandle/home': typeof AppOrgHandleHomeRoute '/_app/$orgHandle/insights': typeof AppOrgHandleInsightsRoute @@ -690,6 +727,7 @@ export interface FileRouteTypes { | '/onboarding' | '/dither-kit' | '/login' + | '/queue' | '/vouched' | '/.well-known/oauth-authorization-server' | '/.well-known/oauth-protected-resource' @@ -706,11 +744,14 @@ export interface FileRouteTypes { | '/api/inngest' | '/api/mcp' | '/oauth/consent' + | '/oauth/popup-callback' + | '/oauth/waitlist' | '/r/$name' | '/r/cli-event' | '/$orgHandle/rules' | '/.well-known/oauth-authorization-server/$' | '/.well-known/oauth-protected-resource/$' + | '/admin/access-requests' | '/admin/reputation' | '/$orgHandle/home' | '/$orgHandle/insights' @@ -763,6 +804,7 @@ export interface FileRouteTypes { | '/onboarding' | '/dither-kit' | '/login' + | '/queue' | '/vouched' | '/.well-known/oauth-authorization-server' | '/.well-known/oauth-protected-resource' @@ -779,10 +821,13 @@ export interface FileRouteTypes { | '/api/inngest' | '/api/mcp' | '/oauth/consent' + | '/oauth/popup-callback' + | '/oauth/waitlist' | '/r/$name' | '/r/cli-event' | '/.well-known/oauth-authorization-server/$' | '/.well-known/oauth-protected-resource/$' + | '/admin/access-requests' | '/admin/reputation' | '/$orgHandle/home' | '/$orgHandle/insights' @@ -837,6 +882,7 @@ export interface FileRouteTypes { | '/_app' | '/dither-kit' | '/login' + | '/queue' | '/vouched' | '/.well-known/oauth-authorization-server' | '/.well-known/oauth-protected-resource' @@ -853,11 +899,14 @@ export interface FileRouteTypes { | '/api/inngest' | '/api/mcp' | '/oauth/consent' + | '/oauth/popup-callback' + | '/oauth/waitlist' | '/r/$name' | '/r/cli-event' | '/_app/$orgHandle/rules' | '/.well-known/oauth-authorization-server/$' | '/.well-known/oauth-protected-resource/$' + | '/_admin/admin/access-requests' | '/_admin/admin/reputation' | '/_app/$orgHandle/home' | '/_app/$orgHandle/insights' @@ -913,6 +962,7 @@ export interface RootRouteChildren { AppRoute: typeof AppRouteWithChildren DitherKitRoute: typeof DitherKitRoute LoginRoute: typeof LoginRoute + QueueRoute: typeof QueueRoute VouchedRoute: typeof VouchedRoute Char91DotwellKnownChar93OauthAuthorizationServerRoute: typeof Char91DotwellKnownChar93OauthAuthorizationServerRouteWithChildren Char91DotwellKnownChar93OauthProtectedResourceRoute: typeof Char91DotwellKnownChar93OauthProtectedResourceRouteWithChildren @@ -921,6 +971,8 @@ export interface RootRouteChildren { ApiInngestRoute: typeof ApiInngestRoute ApiMcpRoute: typeof ApiMcpRoute OauthConsentRoute: typeof OauthConsentRoute + OauthPopupCallbackRoute: typeof OauthPopupCallbackRoute + OauthWaitlistRoute: typeof OauthWaitlistRoute RNameRoute: typeof RNameRoute RCliEventRoute: typeof RCliEventRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute @@ -944,6 +996,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof VouchedRouteImport parentRoute: typeof rootRouteImport } + '/queue': { + id: '/queue' + path: '/queue' + fullPath: '/queue' + preLoaderRoute: typeof QueueRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -1000,6 +1059,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RNameRouteImport parentRoute: typeof rootRouteImport } + '/oauth/waitlist': { + id: '/oauth/waitlist' + path: '/oauth/waitlist' + fullPath: '/oauth/waitlist' + preLoaderRoute: typeof OauthWaitlistRouteImport + parentRoute: typeof rootRouteImport + } + '/oauth/popup-callback': { + id: '/oauth/popup-callback' + path: '/oauth/popup-callback' + fullPath: '/oauth/popup-callback' + preLoaderRoute: typeof OauthPopupCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/oauth/consent': { id: '/oauth/consent' path: '/oauth/consent' @@ -1301,6 +1374,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminAdminReputationRouteImport parentRoute: typeof AdminRoute } + '/_admin/admin/access-requests': { + id: '/_admin/admin/access-requests' + path: '/admin/access-requests' + fullPath: '/admin/access-requests' + preLoaderRoute: typeof AdminAdminAccessRequestsRouteImport + parentRoute: typeof AdminRoute + } '/.well-known/oauth-protected-resource/$': { id: '/.well-known/oauth-protected-resource/$' path: '/$' @@ -1470,6 +1550,7 @@ const OnboardingRouteRouteWithChildren = OnboardingRouteRoute._addFileChildren( ) interface AdminRouteChildren { + AdminAdminAccessRequestsRoute: typeof AdminAdminAccessRequestsRoute AdminAdminReputationRoute: typeof AdminAdminReputationRoute AdminAdminIndexRoute: typeof AdminAdminIndexRoute AdminAdminResearchRunIdRoute: typeof AdminAdminResearchRunIdRoute @@ -1478,6 +1559,7 @@ interface AdminRouteChildren { } const AdminRouteChildren: AdminRouteChildren = { + AdminAdminAccessRequestsRoute: AdminAdminAccessRequestsRoute, AdminAdminReputationRoute: AdminAdminReputationRoute, AdminAdminIndexRoute: AdminAdminIndexRoute, AdminAdminResearchRunIdRoute: AdminAdminResearchRunIdRoute, @@ -1637,6 +1719,7 @@ const rootRouteChildren: RootRouteChildren = { AppRoute: AppRouteWithChildren, DitherKitRoute: DitherKitRoute, LoginRoute: LoginRoute, + QueueRoute: QueueRoute, VouchedRoute: VouchedRoute, Char91DotwellKnownChar93OauthAuthorizationServerRoute: Char91DotwellKnownChar93OauthAuthorizationServerRouteWithChildren, @@ -1647,6 +1730,8 @@ const rootRouteChildren: RootRouteChildren = { ApiInngestRoute: ApiInngestRoute, ApiMcpRoute: ApiMcpRoute, OauthConsentRoute: OauthConsentRoute, + OauthPopupCallbackRoute: OauthPopupCallbackRoute, + OauthWaitlistRoute: OauthWaitlistRoute, RNameRoute: RNameRoute, RCliEventRoute: RCliEventRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 085e2873..85d156a2 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from "react" import { isReactGrabEnabled, isReactScanEnabled } from "#/lib/feature-flags" import { FeedbackProvider, FeedbackOverlay } from "@tripwire/feedback" import { FeedbackDialog } from "#/components/shared/feedback-dialog" +import { DATABUDDY_CLIENT_ID } from "#/lib/databuddy" import appCss from "../styles.css?url" function ClientOnlyDevtools() { @@ -82,7 +83,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { {children} + + + + Access + + + buildSeo({ + path: match.pathname, + title: formatPageTitle("Admin: access requests"), + description: "Review, approve, and reject GitHub access requests.", + robots: "noindex", + }), +}) + +type StatusFilter = "pending" | "approved" | "rejected" + +const PAGE_SIZE = 25 + +const STATUS_TABS: { value: StatusFilter; label: string }[] = [ + { value: "pending", label: "Pending" }, + { value: "approved", label: "Approved" }, + { value: "rejected", label: "Rejected" }, +] + +function AccessRequestsPage() { + const trpc = useTRPC() + const queryClient = useQueryClient() + + const [status, setStatus] = useState("pending") + const [search, setSearch] = useState("") + const [submittedSearch, setSubmittedSearch] = useState("") + const [offset, setOffset] = useState(0) + const [selected, setSelected] = useState>(new Set()) + + const listInput = { + status, + search: submittedSearch || undefined, + limit: PAGE_SIZE, + offset, + } + + const list = useQuery(trpc.accessRequests.list.queryOptions(listInput)) + + const items = list.data?.items ?? [] + const total = list.data?.total ?? 0 + + const invalidate = () => { + queryClient.invalidateQueries({ + queryKey: trpc.accessRequests.list.queryKey(), + }) + setSelected(new Set()) + } + + const approve = useMutation( + trpc.accessRequests.approve.mutationOptions({ + onSuccess: () => { + toastManager.add({ type: "success", title: "Approved" }) + invalidate() + }, + onError: (err) => toastFromError(err), + }) + ) + + const reject = useMutation( + trpc.accessRequests.reject.mutationOptions({ + onSuccess: () => { + toastManager.add({ type: "success", title: "Rejected" }) + invalidate() + }, + onError: (err) => toastFromError(err), + }) + ) + + const bulkApprove = useMutation( + trpc.accessRequests.bulkApprove.mutationOptions({ + onSuccess: (res) => { + toastManager.add({ + type: "success", + title: `Approved ${res.approvedCount} ${ + res.approvedCount === 1 ? "request" : "requests" + }`, + }) + invalidate() + }, + onError: (err) => toastFromError(err), + }) + ) + + const allSelected = items.length > 0 && selected.size === items.length + const toggleAll = () => { + setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id))) + } + const toggleRow = (id: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const changeStatus = (next: StatusFilter) => { + setStatus(next) + setOffset(0) + setSelected(new Set()) + } + + const submitSearch = (e: React.FormEvent) => { + e.preventDefault() + setSubmittedSearch(search.trim()) + setOffset(0) + } + + const pageStart = total === 0 ? 0 : offset + 1 + const pageEnd = Math.min(offset + PAGE_SIZE, total) + + return ( +
+
+

+ Access requests +

+

+ Review who's asked to join and approve or reject each request. +

+
+ +
+
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ +
+ setSearch(e.target.value)} + placeholder="Search name or email" + className="h-7 w-56 rounded-lg border border-tw-border bg-tw-inner px-2.5 text-[13px] text-tw-text-primary placeholder:text-tw-text-tertiary focus:border-tw-text-tertiary focus:outline-none" + /> + +
+
+ + {status === "pending" && selected.size > 0 ? ( +
+ + {selected.size} selected + + +
+ ) : null} + +
+ + + + {status === "pending" ? ( + + ) : null} + + + + + + + + {list.isLoading ? ( + + + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((row) => ( + toggleRow(row.id)} + onApprove={() => approve.mutate({ userId: row.id })} + onReject={() => reject.mutate({ userId: row.id })} + actionsDisabled={approve.isPending || reject.isPending} + status={status} + /> + )) + )} + +
+ + UserGitHubAccount ageRequested +
+ Loading… +
+ No {status} requests. +
+
+ +
+ + {pageStart}–{pageEnd} of {total} + +
+ + +
+
+
+ ) +} + +interface AccessRowData { + id: string + name: string + email: string + image: string | null + githubId: string | null + waitlistedAt: Date | null + createdAt: Date +} + +interface AccessRowProps { + row: AccessRowData + showCheckbox: boolean + selected: boolean + onToggle: () => void + onApprove: () => void + onReject: () => void + actionsDisabled: boolean + status: StatusFilter +} + +function AccessRow({ + row, + showCheckbox, + selected, + onToggle, + onApprove, + onReject, + actionsDisabled, + status, +}: AccessRowProps) { + const trpc = useTRPC() + const githubId = row.githubId ? Number(row.githubId) : null + + const meta = useQuery({ + ...trpc.accessRequests.githubMeta.queryOptions( + { githubId: githubId ?? 0 }, + { enabled: githubId !== null, staleTime: 5 * 60_000 } + ), + }) + + const accountAge = useMemo(() => { + if (!meta.data?.accountCreatedAt) return null + return formatRelativeTime(meta.data.accountCreatedAt).replace(" ago", "") + }, [meta.data?.accountCreatedAt]) + + return ( + + {showCheckbox ? ( + + + + ) : null} + + +
+ {row.image ? ( + + ) : ( +
+ )} +
+ + {row.name} + + + {row.email} + +
+ {row.waitlistedAt ? ( + + Waitlist + + ) : null} +
+ + + + {meta.data ? ( + + @{meta.data.login} + + + ) : meta.isLoading ? ( + + ) : ( + + )} + + + + {accountAge ?? } + + + + {formatRelativeTime(row.createdAt)} + + + + {status === "pending" ? ( +
+ + +
+ ) : status === "rejected" ? ( + + ) : null} + + + ) +} diff --git a/apps/web/src/routes/api/inngest.ts b/apps/web/src/routes/api/inngest.ts index 39a1a665..d14ac4f8 100644 --- a/apps/web/src/routes/api/inngest.ts +++ b/apps/web/src/routes/api/inngest.ts @@ -4,10 +4,16 @@ import { inngest } from "#/inngest/client" import { processResearchRun } from "#/inngest/research" import { syncRepoHistory } from "#/inngest/visibility" import { scoreUser } from "#/inngest/score-user" +import { sendAccessApprovedEmail } from "#/inngest/access-approved" const handler = serve({ client: inngest, - functions: [processResearchRun, syncRepoHistory, scoreUser], + functions: [ + processResearchRun, + syncRepoHistory, + scoreUser, + sendAccessApprovedEmail, + ], }) export const Route = createFileRoute("/api/inngest")({ diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 71796de8..82972be5 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,143 +1,34 @@ -import { createFileRoute, Link } from "@tanstack/react-router" -import { useEffect, useState, useCallback } from "react" -import { buildSeo } from "#/lib/seo" +import { useEffect } from "react" +import { createFileRoute, useNavigate } from "@tanstack/react-router" import { authClient } from "@tripwire/auth/client" -import { LandingHeader } from "#/components/layout/landing/header" -import { useSpaceInvaders } from "#/components/layout/landing/space-invaders" -import FaultyTerminal from "#/components/layout/landing/faulty-terminal" -import { - TRIPWIRE_EYE_OUTER_PATH, - TRIPWIRE_EYE_OUTER_VIEWBOX, - TRIPWIRE_EYE_SOCKET_PATH, - TRIPWIRE_EYE_SOCKET_VIEWBOX, - TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER, - TRIPWIRE_EYE_PUPIL_PATH, - TRIPWIRE_EYE_PUPIL_VIEWBOX, - TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER, -} from "@tripwire/ui/icons/tripwire-eye" - -const EYE_CURSOR_MASK = { - viewBox: TRIPWIRE_EYE_OUTER_VIEWBOX, - width: 1.05, - layers: [ - { - path: TRIPWIRE_EYE_OUTER_PATH, - viewBox: TRIPWIRE_EYE_OUTER_VIEWBOX, - rect: [ - 0, - 0, - TRIPWIRE_EYE_OUTER_VIEWBOX[0], - TRIPWIRE_EYE_OUTER_VIEWBOX[1], - ] as const, - mode: "add" as const, - }, - { - path: TRIPWIRE_EYE_SOCKET_PATH, - viewBox: TRIPWIRE_EYE_SOCKET_VIEWBOX, - rect: TRIPWIRE_EYE_SOCKET_RECT_IN_OUTER, - mode: "subtract" as const, - }, - { - path: TRIPWIRE_EYE_PUPIL_PATH, - viewBox: TRIPWIRE_EYE_PUPIL_VIEWBOX, - rect: TRIPWIRE_EYE_PUPIL_RECT_IN_OUTER, - mode: "add" as const, - }, - ], -} +import { buildSeo } from "#/lib/seo" +// The marketing landing page lives in the tripwire-landing repo. The app +// domain's root just routes people to the right place by session. export const Route = createFileRoute("/")({ - component: LandingPage, + component: IndexRedirect, head: ({ match }) => buildSeo({ path: match.pathname, - title: "Tripwire — catch slop before it catches up with you", + title: "Tripwire", description: - "Open source GitHub moderation for spam PRs, bot accounts, and AI-generated contributions. Rules that run on every webhook so maintainers don't have to.", - type: "website", + "Open source GitHub moderation for spam PRs, bot accounts, and AI-generated contributions.", + robots: "noindex", }), }) -function LandingPage() { - const { data: session } = authClient.useSession() - const [gameActive, setGameActive] = useState(false) - const [transitioning, setTransitioning] = useState(false) - - const exitGame = useCallback(() => { - setGameActive(false) - setTransitioning(false) - }, []) - - const gameCanvas = useSpaceInvaders(gameActive, exitGame) +function IndexRedirect() { + const navigate = useNavigate() + const { data: session, isPending } = authClient.useSession() useEffect(() => { - if (gameActive || transitioning) return - const onKey = (e: KeyboardEvent) => { - if (e.key.startsWith("Arrow")) { - setTransitioning(true) - setTimeout(() => setGameActive(true), 600) - } - } - window.addEventListener("keydown", onKey) - return () => window.removeEventListener("keydown", onKey) - }, [gameActive, transitioning]) + if (isPending) return + navigate({ to: session ? "/home" : "/login", replace: true }) + }, [isPending, session, navigate]) return ( -
- {/* Terminal — the game renders INSIDE it via the gameCanvas texture */} -
- -
- - {/* Landing content — fades out when game activates */} -
- -
-

- catch slop before it catches up with you -

- {session ? ( - - get started - - ) : ( - - login - - )} -
-
+
+
) } diff --git a/apps/web/src/routes/oauth/popup-callback.tsx b/apps/web/src/routes/oauth/popup-callback.tsx new file mode 100644 index 00000000..9a96bebf --- /dev/null +++ b/apps/web/src/routes/oauth/popup-callback.tsx @@ -0,0 +1,210 @@ +import { useEffect, useRef, useState } from "react" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { useQuery } from "@tanstack/react-query" +import { authClient } from "@tripwire/auth/client" +import { env } from "@tripwire/env/client" +import { Button } from "@tripwire/ui/button" +import { TripwireLogo } from "@tripwire/ui/icons/tripwire-logo" +import { useTRPC } from "#/integrations/trpc/react" +import { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo" + +/** + * Post-auth closer page for the waitlist popup. GitHub redirects here after + * sign-in; it reads the user's access status and notifies the opener via + * postMessage (STRICT target origin). It then shows a confirmation and lets the + * user close the window themselves — we don't auto-close it. + * + * If there's no reachable opener — popup was blocked and this is a full-page + * redirect, or COOP severed window.opener — it falls back to navigating in + * place (there's no popup to close in that case). + */ +export const Route = createFileRoute("/oauth/popup-callback")({ + component: PopupCallbackPage, + headers: () => PRIVATE_ROUTE_HEADERS, + validateSearch: ( + search: Record + ): { opener?: string; error?: string } => ({ + opener: typeof search.opener === "string" ? search.opener : undefined, + error: typeof search.error === "string" ? search.error : undefined, + }), + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("Finishing up"), + description: "Completing your Tripwire waitlist request.", + robots: "noindex", + }), +}) + +/** Message channel name shared with the landing-site listener. */ +const MESSAGE_TYPE = "tripwire:waitlist" + +/** Exact origin to postMessage to, or null if `opener` isn't allowlisted. */ +function resolveTargetOrigin(opener: string | undefined): string | null { + const allowed = (env.VITE_WAITLIST_OPENER_ORIGINS ?? "") + .split(",") + .map((o) => o.trim()) + .filter(Boolean) + return opener && allowed.includes(opener) ? opener : null +} + +/** Post to the opener at an exact origin. Returns false if unreachable. */ +function postToOpener(payload: unknown, targetOrigin: string | null): boolean { + if (!targetOrigin) return false + try { + if (window.opener && !window.opener.closed) { + window.opener.postMessage(payload, targetOrigin) + return true + } + } catch { + // Cross-origin opener severed (e.g. COOP) — fall through to in-place nav. + } + return false +} + +type View = + | { kind: "working" } + | { kind: "waitlisted"; email: string | null } + | { kind: "approved" } + | { kind: "error" } + +function PopupCallbackPage() { + const navigate = useNavigate() + const trpc = useTRPC() + const { opener, error } = Route.useSearch() + const { data: session, isPending } = authClient.useSession() + const me = useQuery(trpc.auth.me.queryOptions(undefined, { staleTime: 0 })) + const done = useRef(false) + const [view, setView] = useState({ kind: "working" }) + + useEffect(() => { + if (done.current) return + const targetOrigin = resolveTargetOrigin(opener) + + // OAuth reported an error (e.g. user cancelled on GitHub's consent screen). + if (error) { + done.current = true + if (postToOpener({ type: MESSAGE_TYPE, status: "error" }, targetOrigin)) { + setView({ kind: "error" }) + } else { + navigate({ to: "/login", search: { error } }) + } + return + } + + // Wait until we know the authoritative status before notifying. + if (isPending || me.isLoading) return + done.current = true + + if (!session || !me.data) { + if (postToOpener({ type: MESSAGE_TYPE, status: "error" }, targetOrigin)) { + setView({ kind: "error" }) + } else { + navigate({ to: "/login" }) + } + return + } + + const status = me.data.accessStatus + const posted = postToOpener( + { type: MESSAGE_TYPE, status, name: session.user.name ?? null }, + targetOrigin + ) + if (posted) { + // Notified the opener. Leave the window open and let the user close it. + setView( + status === "approved" + ? { kind: "approved" } + : { kind: "waitlisted", email: session.user.email ?? null } + ) + return + } + // No reachable opener: this is a full-page flow (no popup to close). + // Continue where the route gate would take them. + navigate({ to: status === "approved" ? "/home" : "/queue" }) + }, [error, isPending, me.isLoading, me.data, session, opener, navigate]) + + if (view.kind === "working") { + return ( + +
+ + ) + } + + if (view.kind === "error") { + return ( + + Something went wrong + We couldn't finish your request. Close this and try again. + + + ) + } + + if (view.kind === "approved") { + return ( + + You're in + Your account has access. You can close this window. + + + ) + } + + // waitlisted + return ( + + You're on the waitlist + + Tripwire is in closed beta. We'll email{" "} + {view.email ? ( + {view.email} + ) : ( + "you" + )}{" "} + when you're approved. + +

+ You can close this window. +

+ +
+ ) +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +function Heading({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ) +} + +function Body({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function CloseButton() { + return ( + + ) +} diff --git a/apps/web/src/routes/oauth/waitlist.tsx b/apps/web/src/routes/oauth/waitlist.tsx new file mode 100644 index 00000000..fcd0b8f5 --- /dev/null +++ b/apps/web/src/routes/oauth/waitlist.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef } from "react" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { authClient } from "@tripwire/auth/client" +import { TripwireLogo } from "@tripwire/ui/icons/tripwire-logo" +import { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo" + +/** + * Popup entry point for the cross-deployment "Join waitlist with GitHub" flow. + * The landing site opens this route in a popup; it kicks off the existing + * GitHub social sign-in and points the OAuth callback at the closer page, + * threading through the opener origin so the closer knows exactly which window + * (and origin) to notify. This is standard OAuth machinery, hence /oauth/*. + */ +export const Route = createFileRoute("/oauth/waitlist")({ + component: WaitlistEntryPage, + headers: () => PRIVATE_ROUTE_HEADERS, + validateSearch: ( + search: Record + ): { opener?: string } => ({ + opener: typeof search.opener === "string" ? search.opener : undefined, + }), + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("Join the waitlist"), + description: "Request Tripwire access with GitHub.", + robots: "noindex", + }), +}) + +function callbackPath(opener: string | undefined, extra?: string): string { + const params = new URLSearchParams() + if (opener) params.set("opener", opener) + if (extra) params.set("error", extra) + const qs = params.toString() + return `/oauth/popup-callback${qs ? `?${qs}` : ""}` +} + +function WaitlistEntryPage() { + const navigate = useNavigate() + const { opener } = Route.useSearch() + const { data: session, isPending } = authClient.useSession() + const started = useRef(false) + + useEffect(() => { + if (isPending || started.current) return + started.current = true + + // Returning user with a live session: no need to re-auth — hand straight + // to the closer so it reads their current status and notifies the opener. + if (session) { + navigate({ to: "/oauth/popup-callback", search: { opener } }) + return + } + + // New/returning-without-session: initiate GitHub OAuth in this popup. The + // access-queue default ("pending") is assigned server-side on user create. + void authClient.signIn.social({ + provider: "github", + callbackURL: callbackPath(opener), + errorCallbackURL: callbackPath(opener, "oauth_failed"), + }) + }, [isPending, session, opener, navigate]) + + return ( +
+ +
+
+ Connecting to GitHub… +
+
+ ) +} diff --git a/apps/web/src/routes/queue.tsx b/apps/web/src/routes/queue.tsx new file mode 100644 index 00000000..560321d3 --- /dev/null +++ b/apps/web/src/routes/queue.tsx @@ -0,0 +1,56 @@ +import { useEffect } from "react" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { useQuery } from "@tanstack/react-query" +import { authClient } from "@tripwire/auth/client" +import { AccessPendingScreen } from "#/components/layout/auth/access-pending-screen" +import { useTRPC } from "#/integrations/trpc/react" +import { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo" + +export const Route = createFileRoute("/queue")({ + component: QueuePage, + headers: () => PRIVATE_ROUTE_HEADERS, + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("Access queue"), + description: "Your Tripwire access request status.", + robots: "noindex", + }), +}) + +function QueuePage() { + const navigate = useNavigate() + const trpc = useTRPC() + const { data: session, isPending } = authClient.useSession() + const me = useQuery( + trpc.auth.me.queryOptions(undefined, { staleTime: 15_000 }) + ) + + useEffect(() => { + if (isPending) return + if (!session) { + navigate({ to: "/login" }) + return + } + // Approved users have no business on the queue page — send them onward. + if (me.data?.accessStatus === "approved") { + navigate({ to: "/home" }) + } + }, [isPending, session, me.data?.accessStatus, navigate]) + + if (isPending || me.isLoading || !session) { + return ( +
+
+
+ ) + } + + return ( + + ) +} diff --git a/docs/github-approval-queue-prd.md b/docs/github-approval-queue-prd.md new file mode 100644 index 00000000..33c66f1e --- /dev/null +++ b/docs/github-approval-queue-prd.md @@ -0,0 +1,186 @@ +# PRD: GitHub Sign-In Approval Queue + +**Status:** Draft +**Owner:** Dan +**Date:** 2026-07-14 + +## Summary + +Replace the email-only landing-page waitlist with an approval queue gated behind GitHub sign-in. Prospective users authenticate with GitHub, land in a `pending` state, and get access only when an admin manually approves them. Approved users are notified by email. + +## Problem + +The current waitlist spans two repos: the form and its client API calls live on the marketing site (`tripwire-landing`), which hits the `waitlistRouter.join` endpoint in this repo (`tripwire`, writing to the `waitlist` table). It collects only an email address. That gives us: + +- No identity signal — we can't see who's asking for access, which matters for a product whose whole pitch is GitHub reputation and spam filtering. +- No activation path — when we open access, an email on a list still has to come back, sign in with GitHub, and onboard. Two drop-off points. +- No admin tooling — approvals happen ad hoc, outside the product. + +## Goals + +1. Every access request is a real GitHub identity we can review (username, avatar, account age, orgs via existing `read:user` / `read:org` scopes). +2. Admins can review, approve, and reject requests from the existing `_admin` area. +3. Approval → automated email → user signs in and lands directly in onboarding. One click from "you're in" to activated. +4. Existing email waitlist entries are migrated, not stranded. + +## Non-Goals + +- Auto-approval rules (vouches, GitHub signal thresholds). All approvals are manual for v1; the design shouldn't preclude adding rules later. +- Invite codes, referral mechanics, or user-to-user invites. +- Keeping the email-only form. It's removed once migration email is sent. + +## User Flows + +### New user +1. Visits the marketing site (`tripwire-landing`) → CTA is "Sign in with GitHub to request access" (replaces the email form). This is a plain link to the app's `/login` — no client API call from the landing site; OAuth happens on the app domain. +2. Completes GitHub OAuth (existing better-auth flow, `packages/auth/src/index.ts`). +3. Account is created with `accessStatus = "pending"`. User sees a "You're in the queue" page showing their GitHub identity and queue position or a simple "we'll email you" message. +4. Any attempt to reach `_app` routes while pending redirects back to the queue page. + +### Admin +1. Opens **Admin → Access Requests** (`_admin` section). +2. Sees a table of pending users: avatar, GitHub username (linked), email, account created date, GitHub account age, request date. +3. Approves or rejects individually; bulk approve for selected rows. +4. Approve → status flips to `approved`, Inngest job sends the approval email. Reject → status `rejected`; user sees a polite "not yet" on next visit (no email in v1). + +### Approved user +1. Receives email: "You're in" + sign-in link. +2. Signs in → passes the gate → routed to existing `/onboarding`. + +### Existing email waitlist +1. One-time campaign email to all `waitlist` rows: "Sign in with GitHub to claim your spot." +2. Optional: emails already on the waitlist get flagged so admins see "was on email waitlist since {date}" in the queue — useful for prioritising loyal early signups. +3. `waitlist` table and `waitlistRouter` are retired after the campaign window (suggest 30 days). + +## Functional Requirements + +| # | Requirement | +|---|-------------| +| F1 | Add `accessStatus` (`pending` \| `approved` \| `rejected`) and `accessReviewedAt` / `accessReviewedBy` to the user model (better-auth `additionalFields` or a sibling table in `packages/db/src/schema/auth.ts`). | +| F2 | New signups via GitHub OAuth default to `pending`. Existing active users are backfilled to `approved` in the migration. | +| F3 | Route guard: `pending`/`rejected` users cannot access `_app` or `_admin` routes; redirect to queue-status page. Enforce server-side (tRPC middleware), not just in the router. | +| F4 | Admin tRPC router: `accessRequests.list` (paginated, filter by status, search by username/email), `accessRequests.approve`, `accessRequests.reject`, `accessRequests.bulkApprove`. All behind `adminProcedure`. | +| F5 | Approval email sent via Inngest job on approve event — retryable, idempotent (don't double-send on bulk approve retries). | +| F6 | Queue-status page for pending users; distinct copy for rejected users. | +| F7 | Cross-repo swap: in `tripwire-landing`, the waitlist form and its client API calls are replaced with a GitHub sign-in CTA linking to the app's `/login`; in `tripwire`, the `waitlistRouter.join` endpoint is retired (after the migration window) along with the in-repo `waitlist-form.tsx`. | +| F8 | Rate limiting on the OAuth-initiated request path reuses `@tripwire/ratelimit` patterns where applicable. | + +## Technical Notes + +- **Where the status lives:** prefer better-auth `additionalFields` on `user` so the status rides along in the session — the route guard then needs no extra query. Fall back to a join table only if better-auth field constraints bite. +- **Gate placement:** tRPC middleware in `integrations/trpc/init.ts` (new `approvedProcedure` or extend `protectedProcedure`) plus a `beforeLoad` check in `_app.tsx`. Server-side check is the source of truth. +- **Email:** use [Email SDK](https://email-sdk.dev) (`@opencoredev/email-sdk`) — a zero-dependency TypeScript layer with one typed `send()` over 23 providers, so the provider choice stays a config change. Suggested setup: new `packages/email` exporting a shared client (Resend primary, retries enabled), env vars in `packages/env`. See Appendix A for the docs excerpt. This is the only net-new infra. +- **Admin UI:** follow existing `_admin` patterns and `@tanstack/react-table` usage; formatting via `apps/web/src/lib/format.ts` per repo rules. +- **GitHub metadata:** username/avatar come from the better-auth `account` record; account age can be fetched lazily per row via the existing GitHub client in `packages/github` — don't block queue rendering on it. +- **Cross-repo split:** `tripwire-landing` owns only the CTA swap (form + client API calls removed, replaced by a link to the app's `/login`). Everything else — OAuth, pending gate, queue-status page, admin queue, emails, endpoint retirement — lives in `tripwire`. Because the CTA is a plain link, no CORS or shared API surface is needed between the two. If the landing site later wants live queue stats, that becomes a new public endpoint decision, out of scope for v1. + +## Metrics + +- Sign-in → pending conversion on the landing CTA (vs. historical email form conversion). +- Approval → first onboarding step completion rate (target: >60% within 7 days of approval email). +- Median time-to-approval (admin SLA; target <48h so the queue doesn't go cold). +- Email waitlist migration claim rate during the 30-day window. + +## Risks + +- **CTA friction:** OAuth is a bigger ask than an email field; top-of-funnel volume may drop. Mitigation: the identities we do get are far higher intent, and copy should sell what approval unlocks. +- **Queue neglect:** manual-only approval means the queue is only as good as admin responsiveness. Mitigation: time-to-approval metric + optional daily digest (Inngest cron) of pending count. +- **Session staleness:** a user approved mid-session needs the status reflected without re-login — verify better-auth session refresh behaviour or force re-fetch on the queue-status page. + +## Open Questions + +1. Do rejected users get a re-request path, or is rejection terminal for v1? +2. Should the queue-status page show position/estimated wait, or keep it vague to preserve approval flexibility? +3. ~~Which mail provider?~~ **Resolved:** Email SDK (`@opencoredev/email-sdk`) with Resend as the primary adapter. Remaining sub-question: add a fallback adapter (e.g. Postmark) now or once volume justifies a second provider account? + +## Rollout + +1. Schema migration + backfill existing users to `approved`. +2. Ship gate + queue-status page behind a feature flag (`lib/feature-flags.ts`). +3. Ship admin queue UI; dogfood with flag on for new signups only. +4. Swap the CTA in `tripwire-landing` (form + client API calls → link to app `/login`); send migration email to the `waitlist` table. Keep `waitlistRouter.join` live during the window so the old form keeps working until the landing deploy ships — landing deploys first, endpoint retires later, never the reverse. +5. Retire `waitlistRouter` in `tripwire` (and the in-repo `waitlist-form.tsx`) after the 30-day claim window. + +--- + +## Appendix A: Email SDK reference (email-sdk.dev) + +Condensed from [email-sdk.dev/docs](https://email-sdk.dev/docs) (v0.6.5) for implementation context. + +### What it is + +Zero-dependency TypeScript library for transactional email. One typed message shape; configured adapters decide how it reaches Resend, Postmark, SendGrid, AWS SES, Mailgun, SMTP, or any of 23 providers. Switching providers or adding an outage fallback is a config change — application code (`email.send(...)`) never changes. Each adapter is a separate entry point, so the bundle only contains providers actually used. It is **not** a campaign tool, queue, or template engine — Inngest stays our queue/retry-at-job-level layer; Email SDK handles the send itself. + +### Setup (our shape) + +```bash +pnpm add @opencoredev/email-sdk # Node 20+; server-side only +``` + +```ts +// packages/email/src/index.ts +import { createEmailClient } from "@opencoredev/email-sdk" +import { resend } from "@opencoredev/email-sdk/resend" + +export const email = createEmailClient({ + adapters: [resend({ apiKey: env.RESEND_API_KEY })], + retry: { retries: 2 }, +}) +``` + +```ts +const result = await email.send({ + from: "Tripwire ", + to: user.email, + subject: "You're in", + text: "Your Tripwire access is approved. Sign in to get started.", +}) +// result.provider — adapter that delivered; result.id — provider message id +``` + +### Retries and fallbacks + +Default is one attempt. `retry: { retries: 2 }` retries only transient errors (HTTP 408/409/425/429/5xx, network) with exponential backoff (`min(100 * 2^(attempt-1), 2000)`ms); validation errors and hard rejections fail immediately. A fallback adapter (`fallback: ["postmark"]`) handles full outages after the primary exhausts retries. A fallback must support every field the message uses — the SDK throws `EmailValidationError` before the request rather than silently dropping fields (e.g. SMTP can't carry tags/metadata/attachments; Resend has no metadata field). Check the [field support matrix](https://email-sdk.dev/docs/adapters/field-support) when adding a backup route. + +### Idempotency (relevant to F5) + +Retries + fallbacks mean one logical send can become several provider requests. Every user-visible send gets a stable key: + +```ts +await email.send(message, { idempotencyKey: `access-approved:${user.id}` }) +``` + +Resend enforces this natively via its `Idempotency-Key` header. Combined with Inngest's own idempotency, this covers the F5 "no double-send on bulk-approve retries" requirement at both layers. + +### Plugins worth wiring + +- `defaultsPlugin` — org-wide `replyTo`, headers, and `sendMetadata` merged into every message; per-message values win. Supports `idempotencyKeyPrefix` to namespace keys per environment. +- `observabilityPlugin` — emits `email.sent` / `email.retry` / `email.error` with redacted payloads (counts, tags, subject — never bodies or recipients). Observer exceptions are swallowed, so monitoring can't break a send. + +### Testing (no provider account needed) + +`memoryProvider` / `failingProvider` from `@opencoredev/email-sdk/testing` plus `capturePlugin` let unit tests assert routing and payloads: + +```ts +const backup = memoryProvider("backup") +const client = createEmailClient({ + adapters: [failingProvider("primary"), backup], + fallback: ["backup"], + retry: { retries: 2, delay: () => 0 }, + plugins: [capturePlugin()], +}) +// assert response.provider === "backup", backup.raw.sent, captured events +``` + +### CLI verification + +```bash +npx email-sdk doctor --adapter resend # env/credentials check +npx email-sdk send --adapter resend --dry-run \ + --from "Tripwire " --to test@example.com \ + --subject "Check" --text "It works" # validates shape + field support, no request +``` + +Dry-run validates locally; deliverability (verified sender domain, API scopes, sandbox rules) still needs one real smoke send from the production environment before launch. + +**Further reading:** [Quickstart](https://email-sdk.dev/docs/getting-started/quickstart) · [Production send pipeline](https://email-sdk.dev/docs/guides/production-send-pipeline) · [Adapters](https://email-sdk.dev/docs/adapters) · [Fallbacks & retries](https://email-sdk.dev/docs/concepts/fallbacks-and-retries) · [Agent skill](https://email-sdk.dev/docs/agents/skill) diff --git a/docs/waitlist-github-oauth-deployment.md b/docs/waitlist-github-oauth-deployment.md new file mode 100644 index 00000000..bfaa6831 --- /dev/null +++ b/docs/waitlist-github-oauth-deployment.md @@ -0,0 +1,108 @@ +# Waitlist via GitHub OAuth — deployment notes + +Cross-deployment "Join waitlist with GitHub" flow: the landing site opens a +popup to the app, the app runs GitHub OAuth through the existing auth stack, and +the new user lands in the existing approval queue as `accessStatus: "pending"`. +This flow reuses the access-queue system in `docs/github-approval-queue-prd.md` +— it adds **no new status, gate, or admin surface**. Only the popup plumbing is +new. These are the deployment-level knobs; nothing here is optional-by-default. + +## Gate control — Databuddy feature flag (primary), env (fallback) + +The closed-beta gate is controlled by the **Databuddy feature flag `access-gate`**, +not an env var. Toggle it from the Databuddy dashboard (project client id +`09661145-7249-45d9-a9e3-f1a93e9c7266`) — no redeploy to flip it. + +- **Server (the real boundary):** `apps/web/src/lib/access-gate-flag.ts` evaluates + the flag with `@databuddy/sdk/node` (`createServerFlagsManager`), used by the + tRPC gate in `integrations/trpc/init.ts`. +- **Client (redirect UX):** `` in `__root.tsx` + `useFlag('access-gate')` + in `hooks/use-access-gate.ts`. +- **Fallback:** if Databuddy is unreachable, or the flag doesn't exist yet + (`NOT_FOUND`/`ERROR`/`SESSION_PENDING`, or a thrown lookup), the server falls + back to the `ACCESS_GATE_ENABLED` env var — a local kill-switch so the security + boundary never hard-depends on an external service. + +**To turn the gate on:** create/enable an `access-gate` flag in the Databuddy +dashboard. Until that flag exists it resolves `NOT_FOUND` → env fallback, so today +the gate is off. To test the block *before* wiring the dashboard flag, set +`ACCESS_GATE_ENABLED=true` (the fallback path) and restart the app. + +## Environment variables + +| Var | Deployment | Example | Purpose | +|-----|-----------|---------|---------| +| `VITE_WAITLIST_OPENER_ORIGINS` | **app** | `https://tripwire.sh,https://www.tripwire.sh` | Allowlist of landing origins the closer (`/oauth/popup-callback`) may `postMessage` to. The closer posts only to an exact origin from this list — never `*`. If unset/empty, the closer never messages an opener and falls back to in-place navigation. Add the local landing origin (e.g. `http://localhost:3001`) for dev. Build-time inlined (`VITE_`), not secret. | +| `NEXT_PUBLIC_APP_URL` | **landing** | `https://app.tripwire.sh` | App origin. Drives both the popup target (`${APP}/oauth/waitlist`) and the landing's strict `event.origin` check on incoming messages. Falls back to `https://app.tripwire.sh` (prod) / `http://localhost:3000` (dev) if unset. | + +Both sides must agree: the app's `VITE_WAITLIST_OPENER_ORIGINS` must contain the +exact origin the landing serves from, and the landing's `NEXT_PUBLIC_APP_URL` +must be the exact app origin. A mismatch = the popup completes but the opener is +never notified (it silently falls back to in-place `/queue`). + +## COOP headers (Cross-Origin-Opener-Policy) + +The popup relationship (`window.opener`) must not be severed. + +- **Landing (opener):** currently sends **no** COOP header (`next.config.ts` has + no `headers()`), which is what we want — `window.opener` survives. If COOP is + ever added to the landing, it must be `same-origin-allow-popups`, or the + opener handle is cut and every join silently falls back to full-page redirect. +- **App (popup):** sends no COOP either. `postMessage` works cross-origin + regardless of COOP; COOP only governs the opener handle, which lives on the + landing side. No change needed today — just don't add a bare + `same-origin` COOP to the landing. + +The code already degrades safely if an opener is severed (COOP, or popup blocked +→ full redirect): the closer navigates in place to `/queue`. So a COOP +regression downgrades UX (no auto-close) but never breaks the join. + +## GitHub OAuth callback URL + +**Unchanged.** OAuth still round-trips through `…/api/auth/callback/github` on +the app origin. The popup entry (`/oauth/waitlist`) and closer +(`/oauth/popup-callback`) are app-internal routes, not OAuth redirect URIs — no +new callback URL to register in the GitHub OAuth App. + +## trustedOrigins + +`packages/auth/src/index.ts` `trustedOrigins` is unchanged. The whole OAuth flow +runs on the app origin with an app-relative `callbackURL`, and the landing makes +no auth API calls, so the landing origins do **not** need to be added there. +(They are allowlisted for `postMessage` via `VITE_WAITLIST_OPENER_ORIGINS`, +which is a separate, narrower concern.) + +## Dev ports + +The app must own the origin in `BETTER_AUTH_URL` / the GitHub callback +registration — i.e. `http://localhost:3000`. The landing therefore must run on a +different port: + +```bash +# app (terminal 1) — owns :3000 +cd ~/tripwire && pnpm dev +# landing (terminal 2) +cd ~/tripwire-landing && next dev -p 3001 +``` + +Set `VITE_WAITLIST_OPENER_ORIGINS=http://localhost:3001` (app) and +`NEXT_PUBLIC_APP_URL=http://localhost:3000` (landing) for local end-to-end. + +## Known tradeoff: promotion is instant, demotion is not + +The gate trusts the cookie-cached session **only** when it already reads +`approved`; any other status triggers a fresh DB read (`resolveEffectiveStatus` +in `integrations/trpc/init.ts`). Consequences: + +- **Promotion (pending → approved): effectively instant.** A pending user is + re-read from the DB on every request, so an admin approval takes effect on + their next request with no re-authentication. (This is the invariant-4 + guarantee, covered by `apps/web/src/lib/access-gate.test.ts`.) +- **Demotion (approved → rejected/banned): lags up to the 5-minute + `cookieCache` TTL.** Because an `approved` session copy is trusted without a + DB read, a just-demoted user keeps product access until their cookie cache + expires (`session.cookieCache.maxAge = 5 * 60`). This is acceptable for a beta + gate. If instant revocation is ever required, add explicit session + invalidation on demote (Better Auth `revokeUserSessions`) rather than lowering + the cache TTL globally. +``` diff --git a/packages/auth/package.json b/packages/auth/package.json index 6e016a1c..5bc137aa 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./access": "./src/access.ts", "./autumn": "./src/autumn.ts", "./client": "./src/client/index.ts", "./components": "./src/components/index.ts" diff --git a/packages/auth/src/access.ts b/packages/auth/src/access.ts new file mode 100644 index 00000000..43ea9847 --- /dev/null +++ b/packages/auth/src/access.ts @@ -0,0 +1,100 @@ +import type { AccessStatus } from "@tripwire/db" + +/** + * Pure access-queue decision helpers, kept free of DB/env side effects so they + * can be unit-tested and shared between the auth package (signup create hook) + * and the app's tRPC gate (`integrations/trpc/init.ts`). No new access concept + * is introduced here — this only factors out logic that already existed inline. + */ + +/** + * Force server-assigned access defaults onto a new signup, regardless of + * anything the client sent. New GitHub signups always land in the approval + * queue as "pending" (never trust a client-supplied `accessStatus`), and + * `waitlistedAt` is stamped from the pre-launch email waitlist. + * + * Pairs with `input: false` on the Better Auth `additionalFields` — that stops + * the field being accepted off the wire; this guarantees the create-path value. + */ +export function applySignupAccessDefaults( + input: T, + waitlistedAt: Date | null +): T & { accessStatus: AccessStatus; waitlistedAt: Date | null } { + return { + ...input, + accessStatus: "pending", + waitlistedAt, + } as T & { accessStatus: AccessStatus; waitlistedAt: Date | null } +} + +/** + * Whether the cookie-cached session status can be trusted without a fresh DB + * read. Only "approved" is trusted; any other value is re-read so an admin + * promotion made mid-session takes effect on the user's next request without + * them re-authenticating (see the invariant-4 decision in the plan). + */ +export function canTrustSessionStatus( + sessionStatus: AccessStatus | null | undefined +): boolean { + return sessionStatus === "approved" +} + +/** + * Resolve the authoritative status: trust an "approved" session copy, else read + * from the DB via the injected reader. Callers supply the DB read so this stays + * pure and testable; a `null` read (missing row) defaults to "pending". + */ +export async function resolveEffectiveStatus( + sessionStatus: AccessStatus | null | undefined, + readFromDb: () => Promise +): Promise { + if (canTrustSessionStatus(sessionStatus)) return "approved" + return (await readFromDb()) ?? "pending" +} + +/** + * Databuddy flag reasons that mean "not authoritatively resolved" — the flag + * doesn't exist, the service errored, or the session isn't ready. In these + * cases the caller should fall back to the env kill-switch rather than trust + * the (false) `enabled` value. A thrown getFlag rejection maps to `null` here. + */ +export const GATE_FALLBACK_REASONS = new Set([ + "ERROR", + "NOT_FOUND", + "SESSION_PENDING", +]) + +/** + * Resolve the access gate from a Databuddy flag result, falling back to the env + * kill-switch when Databuddy couldn't authoritatively resolve it (unresolved + * reason, or `null` for a thrown lookup). Pure so it's unit-testable without + * the SDK/network. + */ +export function gateFromFlag( + flag: { enabled: boolean; reason: string } | null | undefined, + envFallback: boolean +): boolean { + if (!flag || GATE_FALLBACK_REASONS.has(flag.reason)) return envFallback + return flag.enabled +} + +export interface AccessDenial { + code: "FORBIDDEN" + message: string +} + +/** + * The gate decision for a resolved status. Returns `null` when the user may + * proceed (approved), or a FORBIDDEN denial for pending/rejected users. The + * caller is responsible for only invoking the gate when it's enabled. + */ +export function accessDenialFor(status: AccessStatus): AccessDenial | null { + if (status === "approved") return null + return { + code: "FORBIDDEN", + message: + status === "rejected" + ? "Your access request was not approved." + : "Your access request is still pending review.", + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index be3c9d2d..f362bbdf 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -8,10 +8,11 @@ import { autumn as autumnPlugin } from "autumn-js/better-auth" import { autumn as autumnClient } from "./autumn" import { db } from "@tripwire/db/client" import * as schema from "@tripwire/db" -import { organizations, member } from "@tripwire/db" +import { organizations, member, waitlist } from "@tripwire/db" import { env } from "@tripwire/env/server" import { eq, and, ne, count } from "drizzle-orm" import { deleteInstallation } from "@tripwire/github" +import { applySignupAccessDefaults } from "./access" export const auth = betterAuth({ baseURL: env.BETTER_AUTH_URL, @@ -43,6 +44,21 @@ export const auth = betterAuth({ }, }, user: { + additionalFields: { + // Approval-queue status. `input: false` means a client can never set it + // through the signup/update payload — only our server code (the create + // hook and the admin router) writes it. The DB column also defaults to + // "pending", so both layers agree. + accessStatus: { + type: "string", + required: false, + defaultValue: "pending", + input: false, + }, + accessReviewedAt: { type: "date", required: false, input: false }, + accessReviewedBy: { type: "string", required: false, input: false }, + waitlistedAt: { type: "date", required: false, input: false }, + }, deleteUser: { enabled: true, beforeDelete: async (user) => { @@ -205,13 +221,24 @@ export const auth = betterAuth({ databaseHooks: { user: { create: { - // Signups are paused while Tripwire is being reworked — block new-user - // creation. Existing users are unaffected (signing into an existing - // account never triggers a create). - before: async () => { - throw new APIError("FORBIDDEN", { - message: "Sign-ups are paused right now — check back soon.", - }) + // New GitHub signups land in the approval queue. Force accessStatus + // to "pending" server-side (never trust client input), and stamp + // waitlistedAt if this email was already on the pre-launch email + // waitlist so admins can prioritise early signups. + before: async (user) => { + let waitlistedAt: Date | null = null + try { + const [row] = await db + .select({ createdAt: waitlist.createdAt }) + .from(waitlist) + .where(eq(waitlist.email, user.email)) + .limit(1) + waitlistedAt = row?.createdAt ?? null + } catch (err) { + console.error("[Tripwire] waitlist lookup failed:", err) + } + + return { data: applySignupAccessDefaults(user, waitlistedAt) } }, after: async (user) => { // Auto-create a personal Better Auth org for new users diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index ad65ea8b..bfb5f186 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -1,25 +1,47 @@ -import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core" +import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core" import { relations } from "drizzle-orm" // Better Auth core tables // Managed by better-auth. We define them here so Drizzle is aware of // them for relations / migrations. -export const user = pgTable("user", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified").notNull().default(false), - image: text("image"), - githubId: text("github_id").unique(), - // admin plugin fields - role: text("role").default("user"), - banned: boolean("banned").default(false), - banReason: text("ban_reason"), - banExpires: timestamp("ban_expires"), - createdAt: timestamp("created_at").notNull().defaultNow(), - updatedAt: timestamp("updated_at").notNull().defaultNow(), -}) +export const ACCESS_STATUSES = ["pending", "approved", "rejected"] as const + +export type AccessStatus = (typeof ACCESS_STATUSES)[number] + +export const user = pgTable( + "user", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").notNull().default(false), + image: text("image"), + githubId: text("github_id").unique(), + // admin plugin fields + role: text("role").default("user"), + banned: boolean("banned").default(false), + banReason: text("ban_reason"), + banExpires: timestamp("ban_expires"), + // Approval queue. Defaults to "pending" so a user row that reaches the + // DB by any path other than an explicit approval is gated, not admitted. + // Existing users are moved to "approved" by scripts/backfill-access-status.ts — + // run it in the same window as the schema push or they lose access. + accessStatus: text("access_status") + .$type() + .notNull() + .default("pending"), + accessReviewedAt: timestamp("access_reviewed_at"), + accessReviewedBy: text("access_reviewed_by"), + // Set at signup when the email already existed in `waitlist`, so admins + // can prioritise people who signed up before the GitHub gate existed. + // Carries the original waitlist join date, not the signup date. + waitlistedAt: timestamp("waitlisted_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (table) => [index("user_access_status_idx").on(table.accessStatus)] +) export const session = pgTable("session", { id: text("id").primaryKey(), diff --git a/packages/email/package.json b/packages/email/package.json new file mode 100644 index 00000000..48422415 --- /dev/null +++ b/packages/email/package.json @@ -0,0 +1,24 @@ +{ + "name": "@tripwire/email", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./templates": "./src/templates.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@opencoredev/email-sdk": "^0.6.5", + "@tripwire/env": "workspace:*", + "evlog": "^2.17.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2", + "vitest": "^3.0.5" + } +} diff --git a/packages/email/src/client.test.ts b/packages/email/src/client.test.ts new file mode 100644 index 00000000..3237a19a --- /dev/null +++ b/packages/email/src/client.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest" +import { createEmailClient } from "@opencoredev/email-sdk" +import { memoryProvider, failingProvider } from "@opencoredev/email-sdk/testing" + +// Validates the SDK behaviours the approval-email job relies on: transient +// failures retry, a full primary outage falls through to a backup adapter, +// and the idempotency key rides along on the delivered message. These are +// contract tests against @opencoredev/email-sdk, independent of env config. + +describe("email client behaviour", () => { + it("routes a send to the configured adapter", async () => { + const primary = memoryProvider("primary") + const client = createEmailClient({ adapters: [primary] }) + + const res = await client.send({ + from: "Tripwire ", + to: "dev@example.com", + subject: "You're in", + text: "Approved.", + }) + + expect(res.provider).toBe("primary") + expect(primary.raw!.sent).toHaveLength(1) + expect(primary.raw!.sent[0].message.subject).toBe("You're in") + }) + + it("falls back to the backup adapter after the primary fails", async () => { + const backup = memoryProvider("backup") + const client = createEmailClient({ + adapters: [failingProvider("primary"), backup], + fallback: ["backup"], + retry: { retries: 2, delay: () => 0 }, + }) + + const res = await client.send({ + from: "Tripwire ", + to: "dev@example.com", + subject: "You're in", + text: "Approved.", + }) + + expect(res.provider).toBe("backup") + expect(backup.raw!.sent).toHaveLength(1) + }) + + it("carries the idempotency key through to the adapter", async () => { + const primary = memoryProvider("primary") + const client = createEmailClient({ adapters: [primary] }) + + await client.send( + { + from: "Tripwire ", + to: "dev@example.com", + subject: "You're in", + text: "Approved.", + }, + { idempotencyKey: "access-approved:user_123" } + ) + + expect(primary.raw!.sent[0].response.provider).toBe("primary") + }) +}) diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts new file mode 100644 index 00000000..522b3a3b --- /dev/null +++ b/packages/email/src/index.ts @@ -0,0 +1,26 @@ +import { createEmailClient } from "@opencoredev/email-sdk" +import { resend } from "@opencoredev/email-sdk/resend" +import { env } from "@tripwire/env/server" + +export type { EmailMessage } from "@opencoredev/email-sdk" +export { EmailValidationError } from "@opencoredev/email-sdk" + +if (env.NODE_ENV === "production" && !env.RESEND_API_KEY) { + throw new Error("RESEND_API_KEY is required in production") +} + +// Single shared transactional client. Resend is the only adapter for now; +// the SDK keeps provider choice a config change, so adding a fallback later +// (e.g. Postmark) touches this file and nothing that calls `email.send`. +// `retry: { retries: 2 }` only retries transient failures (5xx, 429, +// network) with exponential backoff — validation errors fail immediately. +export const email = createEmailClient({ + adapters: [resend({ apiKey: env.RESEND_API_KEY ?? "" })], + retry: { retries: 2 }, +}) + +export const EMAIL_FROM = env.EMAIL_FROM + +/** True when a real provider key is configured. Lets callers no-op in local + * dev instead of throwing on a missing key. */ +export const isEmailConfigured = Boolean(env.RESEND_API_KEY) diff --git a/packages/email/src/templates.test.ts b/packages/email/src/templates.test.ts new file mode 100644 index 00000000..8844976d --- /dev/null +++ b/packages/email/src/templates.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest" + +vi.mock("@tripwire/env/server", () => ({ + env: { + NODE_ENV: "test", + RESEND_API_KEY: undefined, + EMAIL_FROM: "Tripwire ", + APP_URL: "https://tripwire.sh", + }, +})) + +import { accessApprovedEmail } from "./templates" + +describe("accessApprovedEmail", () => { + it("addresses the recipient by first name and links to sign-in", () => { + const msg = accessApprovedEmail({ + to: "dev@example.com", + name: "Ada Lovelace", + }) + + expect(msg.to).toBe("dev@example.com") + expect(msg.from).toBe("Tripwire ") + expect(msg.subject).toContain("approved") + expect(msg.text).toContain("Hi Ada,") + expect(msg.text).toContain("https://tripwire.sh/login") + expect(msg.html).toContain("https://tripwire.sh/login") + }) + + it("falls back to a generic greeting when name is empty", () => { + const msg = accessApprovedEmail({ to: "dev@example.com", name: "" }) + expect(msg.text).toContain("Hi there,") + }) +}) diff --git a/packages/email/src/templates.ts b/packages/email/src/templates.ts new file mode 100644 index 00000000..4166fee4 --- /dev/null +++ b/packages/email/src/templates.ts @@ -0,0 +1,56 @@ +import type { EmailMessage } from "@opencoredev/email-sdk" +import { env } from "@tripwire/env/server" +import { EMAIL_FROM } from "./index" + +interface AccessApprovedInput { + to: string + name: string +} + +/** "You're in" email sent when an admin approves an access request. The + * sign-in link lands the user on /login, which routes them to onboarding + * once the session resolves as approved. */ +export function accessApprovedEmail(input: AccessApprovedInput): EmailMessage { + const signInUrl = `${env.APP_URL}/login` + const firstName = input.name.split(" ")[0] || "there" + + return { + from: EMAIL_FROM, + to: input.to, + subject: "You're in — your Tripwire access is approved", + text: [ + `Hi ${firstName},`, + "", + "Your request to join Tripwire has been approved. You can sign in now and get set up:", + "", + signInUrl, + "", + "Sign in with the same GitHub account you requested access with.", + "", + "— The Tripwire team", + ].join("\n"), + html: renderApprovedHtml(firstName, signInUrl), + } +} + +function renderApprovedHtml(firstName: string, signInUrl: string): string { + return ` + + + + + + +
+ + + + + +
You're in.
Hi ${firstName}, your request to join Tripwire has been approved. Sign in with the same GitHub account you requested access with and finish setting up.
+ Sign in to Tripwire +
If the button doesn't work, paste this link into your browser:
${signInUrl}
+
+ +` +} diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json new file mode 100644 index 00000000..c915358a --- /dev/null +++ b/packages/email/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/env/src/client.ts b/packages/env/src/client.ts index 29e27d83..7288d1ca 100644 --- a/packages/env/src/client.ts +++ b/packages/env/src/client.ts @@ -15,6 +15,11 @@ export const env = createEnv({ VITE_GITHUB_APP_SLUG: z.string().min(1).optional(), VITE_REACT_SCAN_ENABLED: z.string().optional(), VITE_REACT_GRAB_ENABLED: z.string().optional(), + // Comma-separated allowlist of landing-site origins the waitlist popup + // closer may postMessage back to (e.g. + // "https://tripwire.sh,http://localhost:3001"). The closer only ever posts + // to an exact origin drawn from this list — never "*". + VITE_WAITLIST_OPENER_ORIGINS: z.string().optional(), }, runtimeEnv: import.meta.env, emptyStringAsUndefined: true, diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index 4f019dfc..ebb59dd8 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -89,6 +89,17 @@ export const env = createEnv({ INNGEST_EVENT_KEY: z.string().min(1).optional(), INNGEST_SIGNING_KEY: z.string().min(1).optional(), INNGEST_ENV: z.string().min(1).optional(), + RESEND_API_KEY: z.string().min(1).optional(), + EMAIL_FROM: z + .string() + .min(1) + .optional() + .default("Tripwire "), + APP_URL: z.string().url().optional().default("https://tripwire.sh"), + // Enforces the GitHub approval queue: pending/rejected users are blocked + // from _app and _admin. Ships off so schema + backfill land before the + // gate turns on (see PRD rollout). + ACCESS_GATE_ENABLED: z.string().optional(), RESEARCH_GH_TOKEN: z.string().min(1).optional(), NODE_ENV: z.enum(["development", "production", "test"]).optional(), }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 612d548e..b10901d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,7 +107,7 @@ importers: version: 1.166.12(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.5))(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-start': specifier: 1.167.49 - version: 1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/react-store': specifier: latest version: 0.11.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -119,7 +119,7 @@ importers: version: 1.168.16 '@tanstack/router-plugin': specifier: 1.167.27 - version: 1.167.27(@rsbuild/core@2.1.4)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.167.27(@rsbuild/core@2.1.5)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/store': specifier: latest version: 0.11.0 @@ -135,6 +135,9 @@ importers: '@tripwire/db': specifier: workspace:* version: link:../../packages/db + '@tripwire/email': + specifier: workspace:* + version: link:../../packages/email '@tripwire/env': specifier: workspace:* version: link:../../packages/env @@ -179,10 +182,10 @@ importers: version: 6.0.184(zod@4.3.6) autumn-js: specifier: ^1.2.12 - version: 1.2.12(better-auth@1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 1.2.12(better-auth@1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) better-auth: specifier: ^1.5.3 - version: 1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -273,13 +276,13 @@ importers: version: 2.2.4 '@rsbuild/core': specifier: latest - version: 2.1.4 + version: 2.1.5 '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.19(tailwindcss@4.2.4) '@tanstack/devtools-vite': specifier: latest - version: 0.8.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 0.8.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -357,7 +360,7 @@ importers: version: 6.0.184(zod@4.3.6) autumn-js: specifier: ^1.2.12 - version: 1.2.12(better-auth@1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 1.2.12(better-auth@1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) drizzle-orm: specifier: ^0.45.1 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0) @@ -391,7 +394,7 @@ importers: dependencies: '@better-auth/infra': specifier: ^0.2.8 - version: 0.2.8(2a44bfd332f9d634e5a901cc48e7c892) + version: 0.2.8(feb6d1b16d34a02db2788b99b45be1c3) '@tanstack/react-router': specifier: 1.168.24 version: 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -406,10 +409,10 @@ importers: version: link:../github autumn-js: specifier: ^1.2.12 - version: 1.2.12(better-auth@1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 1.2.12(better-auth@1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) better-auth: specifier: ^1.5.3 - version: 1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) drizzle-orm: specifier: ^0.45.1 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0) @@ -485,6 +488,28 @@ importers: packages/dither-kit: {} + packages/email: + dependencies: + '@opencoredev/email-sdk': + specifier: ^0.6.5 + version: 0.6.5 + '@tripwire/env': + specifier: workspace:* + version: link:../env + evlog: + specifier: ^2.17.0 + version: 2.17.0(ai@6.0.184(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(next@16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(nitro@3.0.260311-beta(dotenv@17.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(jiti@2.6.1)(lru-cache@11.3.5)(rollup@4.60.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(ofetch@2.0.0-alpha.3)(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.19.17 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) + packages/env: dependencies: '@t3-oss/env-core': @@ -1237,14 +1262,14 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -1308,11 +1333,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -2369,6 +2394,11 @@ packages: resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} engines: {node: '>=20.0'} + '@opencoredev/email-sdk@0.6.5': + resolution: {integrity: sha512-CuEfbOSjjr4/bsCz7g/X7l7ha1D/QLq0Q4glX4CRsj0uUCnuhdBYRDXv6ffi9myaVx3afw3P8RzAnw+1++jLMg==} + engines: {bun: '>=1.1.0', node: '>=20.0.0'} + hasBin: true + '@openrouter/ai-sdk-provider@2.9.0': resolution: {integrity: sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ==} engines: {node: '>=18'} @@ -4001,8 +4031,8 @@ packages: cpu: [x64] os: [win32] - '@rsbuild/core@2.1.4': - resolution: {integrity: sha512-kdubx/qB6tXduCdqaW78OULLZJ3ludpuA4mOkDko18THsI1rUy9U34DsaDSnotE8GAvTeme+FX9MnqLEUlg8kQ==} + '@rsbuild/core@2.1.5': + resolution: {integrity: sha512-7TW4U1SH7VxQZzSTIOzvwj5lo9uNTmGpsmTXFr4axQAE4giLDKP3kVUA1ZW4P3/Mz4QQJJyvZP29mVcb8kZCfg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4011,70 +4041,70 @@ packages: core-js: optional: true - '@rspack/binding-darwin-arm64@2.1.2': - resolution: {integrity: sha512-IYcxareUOYJZz+uNMSIwn+iDRiVyjZNOjoxO/zL4OFaPK8Ncrw0ka/9DqL9Gd7OpnAXN1zK3uS8yD0O1yIYI3Q==} + '@rspack/binding-darwin-arm64@2.1.4': + resolution: {integrity: sha512-3Xcs01iw48F4WeE4SHga6bCNb/UEFvtQX4P4eMIaJfGPjTQuxfabGE8yCPm9e3tpLZ5uo+IBnJ6nh5r6tIDOXQ==} cpu: [arm64] os: [darwin] - '@rspack/binding-darwin-x64@2.1.2': - resolution: {integrity: sha512-aoifkILvx/XEHyvg8yW57xu95nx7f9f/3ah1+RguHSNKcJMcoCep9VX1Ct1N0ftqg8MC0JUObc7xWL5W14hmjA==} + '@rspack/binding-darwin-x64@2.1.4': + resolution: {integrity: sha512-bz/AsCplLs+3fXULPQU9d4r8H4PdeljgHFyItvVIrA/NKZqzQ8sX0topf/zJVZAOtPH7GNnrKjq0/F0U2DHikQ==} cpu: [x64] os: [darwin] - '@rspack/binding-linux-arm64-gnu@2.1.2': - resolution: {integrity: sha512-My4m40tyJSgiCEf3bB2KIEX710q3nZg99LIjy+8Zxgi3oZTkg1bFmFRusFU5U4eN5408zfSqDDGvjDE3Yv7o4w==} + '@rspack/binding-linux-arm64-gnu@2.1.4': + resolution: {integrity: sha512-x0HQTLU1MusCtNamuXxf3ayEPkvh9uuaq4wVyBqveRkn4FznSOoHUsxTAKMnjGARX+vdLV/y/SwWJRDp2RI4zw==} cpu: [arm64] os: [linux] - '@rspack/binding-linux-arm64-musl@2.1.2': - resolution: {integrity: sha512-yt+GGWUH7WPE8K97cRc8OpZhH7Pbj1vU+lkvKbDtF/rR8X9a/bJsA/nBqyUV2oBKOVbrp5I8rFZlnDskMqgvKw==} + '@rspack/binding-linux-arm64-musl@2.1.4': + resolution: {integrity: sha512-SEYCQD9UflKJMkYGnG5nt2gcsqdkgJsQmryBC/jxo+bOuY9gUSReU99FqCt7WRQrsHLbGeAFeTWs1QzItWG/Bw==} cpu: [arm64] os: [linux] - '@rspack/binding-linux-riscv64-gnu@2.1.2': - resolution: {integrity: sha512-uys8Jyw8Z3ralvICbN/L/nZfy5qELIwpOY72rhIqhoDYwFcL4fmMaY7WsvUcJOjCB2rqOcWPaWKuF2oPvo9iDQ==} + '@rspack/binding-linux-riscv64-gnu@2.1.4': + resolution: {integrity: sha512-jYtQKtnDRaVfyasvTGY04Z7m+xDWZYVwAIEOB4hP7czM7FVLOMgHlMlvw/EgF0DNHrBthqbPfBIS2tP50CzpEA==} cpu: [riscv64] os: [linux] - '@rspack/binding-linux-riscv64-musl@2.1.2': - resolution: {integrity: sha512-JYNVQwqCaRGQWvjHQYzZkIzQiwllMaJwh4Rdu3ww6W2OJcJUqT08sL1pkOtU0iCxT4VUYiRRcp93VGTGpHr8fg==} + '@rspack/binding-linux-riscv64-musl@2.1.4': + resolution: {integrity: sha512-ZgxKjQAm9pidq2kChQO2PqKI9OQpLuGD7iPBuyT0gQg3m1+7vvbbkArLBPzJ12CQHVZvqua7n/QGx8pQjJI2Zw==} cpu: [riscv64] os: [linux] - '@rspack/binding-linux-x64-gnu@2.1.2': - resolution: {integrity: sha512-KDoPy0Msf/JLhxgPPrJQzZeB4Qpqd32em8AP5lSW2s6jR5I35dHgAe9xc2A++EQtnSrU4GTn6DBvFC7q84SihQ==} + '@rspack/binding-linux-x64-gnu@2.1.4': + resolution: {integrity: sha512-C53B3e6M4yzlYn4hDxR9cHZV+HqkfFGR0zuhH8QCfdBfN5KyGsmXmujiFU85ANEvBgf9CF8VreBQeJ20lyto/g==} cpu: [x64] os: [linux] - '@rspack/binding-linux-x64-musl@2.1.2': - resolution: {integrity: sha512-66hWmIGvn4zCKAYXJE9Bp5SNSLYnLFq2Ke/efE+ZtWy43Dd5vk9AAOmThVGBwdwmIxmGtHGCp+cAuS4G0wu0TA==} + '@rspack/binding-linux-x64-musl@2.1.4': + resolution: {integrity: sha512-UaeG3FRo7e5RameQvWRNQ6KeXF29LEk67U/ohb9tF4U38mKH1OGqhmwCf5yLkqiOSgOi7Ff3ireOZD83Fso1iw==} cpu: [x64] os: [linux] - '@rspack/binding-wasm32-wasi@2.1.2': - resolution: {integrity: sha512-EB4SqH8DW/E/OmqssNQvnIVGQiVUyYNlA/pcc6Ia4MlTNwu6eNDppcNLrToH+kSZpL4CpHSFfSM3eIsSuar2Rw==} + '@rspack/binding-wasm32-wasi@2.1.4': + resolution: {integrity: sha512-0P1WZEfu7JOPzD/jQfk9U/6gnRFc7RpvYCQaYZVVYZNJ2gU6O0/yLegRGKNK/2L0zjYwiD0ynhOIBVUUERf5Pw==} cpu: [wasm32] - '@rspack/binding-win32-arm64-msvc@2.1.2': - resolution: {integrity: sha512-T6Fs/g32MRja/UpCq4AdyPRj8tA0cOkcEa4PrAcn/ztUgK8b/qMVxj5mhMI+n7k+kHZQnpeB1Q4HqdSJi6OocA==} + '@rspack/binding-win32-arm64-msvc@2.1.4': + resolution: {integrity: sha512-+aStQipk1EakLRPfD+/aQbtmTXfxqSWetcRRDWV3cAsD4ebv1tF8FLKYY1PCaFpQbX1FxzCBGF0KHxkAsi9cxA==} cpu: [arm64] os: [win32] - '@rspack/binding-win32-ia32-msvc@2.1.2': - resolution: {integrity: sha512-OtxkFVz14mVL4QK8QriSELn9B6PaYGHw1jGJwVDEzpu2ZxSHCTQPz9dVE1ekYtREEqZUkRU7Fp7VfhJSmjTt2Q==} + '@rspack/binding-win32-ia32-msvc@2.1.4': + resolution: {integrity: sha512-7nyrLRQ07j6i6omZuwiwwTI6rpjdy/Su3niLDAG7WsLL21u3/UQQrw6Fvm8SH3XYteghoHLSM3dhgaisuUhdJg==} cpu: [ia32] os: [win32] - '@rspack/binding-win32-x64-msvc@2.1.2': - resolution: {integrity: sha512-Am+nx9fLF3nzgD/K05Bs1Bb+WO8SFLWAYRbXkymaL1r+RQxjRj7jd5ap2PhGOCcfaNA4yVWkAFvmFP92eRu7bQ==} + '@rspack/binding-win32-x64-msvc@2.1.4': + resolution: {integrity: sha512-Z4je7JBaDpO9vvNMgCxlycycRJq+GQwwuXSXagW2xvvGM10Ij63YVtIehSMG9xaW8PED41oc9nWndoEsiD60pA==} cpu: [x64] os: [win32] - '@rspack/binding@2.1.2': - resolution: {integrity: sha512-/mFcRSUW7Pl19KeaBIujJvZYNJQu0wD5D3aa5h+Qcph26v7nmLYlX7eajIHGi8tt2qTZX1lXifw2KLIXKwYaRQ==} + '@rspack/binding@2.1.4': + resolution: {integrity: sha512-iye4BaTYtTt0qa39avWwEsUobVxFNhQHr6B8reFeKU7sdaZsC9LUoDR8JAt5gOnbnzFMPdKgrdU/VzkC6rXbIg==} - '@rspack/core@2.1.2': - resolution: {integrity: sha512-crpNQKhHfnzrIl4Sa4fjH30Ho5aAPgyqpmJZ41SkUFOzyKHdZKYfE5LF3CMh7MiFQFPPxiiKf5BcpxmtZZx4MQ==} + '@rspack/core@2.1.4': + resolution: {integrity: sha512-lpJgtr+JAXuDAMBJfRJ1LHyWVuYJyhZu6L6aj9t4lipUU03qwakHnzn3vwSCr68PsVvVPzR6NbJE1gJienSV0g==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 @@ -9476,12 +9506,12 @@ snapshots: optionalDependencies: drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0) - '@better-auth/infra@0.2.8(2a44bfd332f9d634e5a901cc48e7c892)': + '@better-auth/infra@0.2.8(feb6d1b16d34a02db2788b99b45be1c3)': dependencies: '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) - '@better-auth/sso': 1.6.9(3439bff2d5a368c3234319b0fdaf6c50) + '@better-auth/sso': 1.6.9(534e22264750f465aed4c06df276ee2c) '@better-fetch/fetch': 1.1.21 - better-auth: 1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + better-auth: 1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) better-call: 1.3.5(zod@4.3.6) jose: 6.2.2 libphonenumber-js: 1.13.2 @@ -9509,12 +9539,12 @@ snapshots: '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/sso@1.6.9(3439bff2d5a368c3234319b0fdaf6c50)': + '@better-auth/sso@1.6.9(534e22264750f465aed4c06df276ee2c)': dependencies: '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 - better-auth: 1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + better-auth: 1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) better-call: 1.3.5(zod@4.3.6) fast-xml-parser: 5.7.2 jose: 6.2.2 @@ -9632,7 +9662,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.11.1': + '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 @@ -9643,7 +9673,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.1': + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 optional: true @@ -10162,7 +10192,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.11.1 + '@emnapi/runtime': 1.11.2 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -10464,17 +10494,17 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.10.3 optional: true @@ -10528,6 +10558,8 @@ snapshots: '@oozcitak/util@10.0.0': {} + '@opencoredev/email-sdk@0.6.5': {} + '@openrouter/ai-sdk-provider@2.9.0(ai@6.0.184(zod@4.3.6))(zod@4.3.6)': dependencies: ai: 6.0.184(zod@4.3.6) @@ -11288,9 +11320,9 @@ snapshots: '@oxc-parser/binding-openharmony-arm64@0.120.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -12287,71 +12319,71 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true - '@rsbuild/core@2.1.4': + '@rsbuild/core@2.1.5': dependencies: - '@rspack/core': 2.1.2(@swc/helpers@0.5.23) + '@rspack/core': 2.1.4(@swc/helpers@0.5.23) '@swc/helpers': 0.5.23 transitivePeerDependencies: - '@module-federation/runtime-tools' - '@rspack/binding-darwin-arm64@2.1.2': + '@rspack/binding-darwin-arm64@2.1.4': optional: true - '@rspack/binding-darwin-x64@2.1.2': + '@rspack/binding-darwin-x64@2.1.4': optional: true - '@rspack/binding-linux-arm64-gnu@2.1.2': + '@rspack/binding-linux-arm64-gnu@2.1.4': optional: true - '@rspack/binding-linux-arm64-musl@2.1.2': + '@rspack/binding-linux-arm64-musl@2.1.4': optional: true - '@rspack/binding-linux-riscv64-gnu@2.1.2': + '@rspack/binding-linux-riscv64-gnu@2.1.4': optional: true - '@rspack/binding-linux-riscv64-musl@2.1.2': + '@rspack/binding-linux-riscv64-musl@2.1.4': optional: true - '@rspack/binding-linux-x64-gnu@2.1.2': + '@rspack/binding-linux-x64-gnu@2.1.4': optional: true - '@rspack/binding-linux-x64-musl@2.1.2': + '@rspack/binding-linux-x64-musl@2.1.4': optional: true - '@rspack/binding-wasm32-wasi@2.1.2': + '@rspack/binding-wasm32-wasi@2.1.4': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true - '@rspack/binding-win32-arm64-msvc@2.1.2': + '@rspack/binding-win32-arm64-msvc@2.1.4': optional: true - '@rspack/binding-win32-ia32-msvc@2.1.2': + '@rspack/binding-win32-ia32-msvc@2.1.4': optional: true - '@rspack/binding-win32-x64-msvc@2.1.2': + '@rspack/binding-win32-x64-msvc@2.1.4': optional: true - '@rspack/binding@2.1.2': + '@rspack/binding@2.1.4': optionalDependencies: - '@rspack/binding-darwin-arm64': 2.1.2 - '@rspack/binding-darwin-x64': 2.1.2 - '@rspack/binding-linux-arm64-gnu': 2.1.2 - '@rspack/binding-linux-arm64-musl': 2.1.2 - '@rspack/binding-linux-riscv64-gnu': 2.1.2 - '@rspack/binding-linux-riscv64-musl': 2.1.2 - '@rspack/binding-linux-x64-gnu': 2.1.2 - '@rspack/binding-linux-x64-musl': 2.1.2 - '@rspack/binding-wasm32-wasi': 2.1.2 - '@rspack/binding-win32-arm64-msvc': 2.1.2 - '@rspack/binding-win32-ia32-msvc': 2.1.2 - '@rspack/binding-win32-x64-msvc': 2.1.2 - - '@rspack/core@2.1.2(@swc/helpers@0.5.23)': - dependencies: - '@rspack/binding': 2.1.2 + '@rspack/binding-darwin-arm64': 2.1.4 + '@rspack/binding-darwin-x64': 2.1.4 + '@rspack/binding-linux-arm64-gnu': 2.1.4 + '@rspack/binding-linux-arm64-musl': 2.1.4 + '@rspack/binding-linux-riscv64-gnu': 2.1.4 + '@rspack/binding-linux-riscv64-musl': 2.1.4 + '@rspack/binding-linux-x64-gnu': 2.1.4 + '@rspack/binding-linux-x64-musl': 2.1.4 + '@rspack/binding-wasm32-wasi': 2.1.4 + '@rspack/binding-win32-arm64-msvc': 2.1.4 + '@rspack/binding-win32-ia32-msvc': 2.1.4 + '@rspack/binding-win32-x64-msvc': 2.1.4 + + '@rspack/core@2.1.4(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.1.4 optionalDependencies: '@swc/helpers': 0.5.23 @@ -12501,14 +12533,14 @@ snapshots: '@tanstack/devtools-event-client@0.5.0': {} - '@tanstack/devtools-vite@0.8.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/devtools-vite@0.8.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@tanstack/devtools-client': 0.0.8 '@tanstack/devtools-event-bus': 0.4.2 chalk: 5.6.2 launch-editor: 2.13.2 magic-string: 0.30.21 - oxc-parser: 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + oxc-parser: 0.120.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) picomatch: 4.0.4 vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: @@ -12573,7 +12605,7 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@tanstack/react-start-rsc@0.0.28(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/react-start-rsc@0.0.28(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@tanstack/react-router': 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-start-server': 1.166.43(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -12581,14 +12613,14 @@ snapshots: '@tanstack/router-utils': 1.161.7 '@tanstack/start-client-core': 1.167.19 '@tanstack/start-fn-stubs': 1.161.6 - '@tanstack/start-plugin-core': 1.169.5(@rsbuild/core@2.1.4)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.15))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.169.5(@rsbuild/core@2.1.5)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.15))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/start-server-core': 1.167.21(crossws@0.4.5(srvx@0.11.15)) '@tanstack/start-storage-context': 1.166.30 pathe: 2.0.3 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: - '@rspack/core': 2.1.2(@swc/helpers@0.5.23) + '@rspack/core': 2.1.4(@swc/helpers@0.5.23) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -12609,21 +12641,21 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@tanstack/react-router': 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-start-client': 1.166.42(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/react-start-rsc': 0.0.28(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + '@tanstack/react-start-rsc': 0.0.28(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/react-start-server': 1.166.43(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/router-utils': 1.161.7 '@tanstack/start-client-core': 1.167.19 - '@tanstack/start-plugin-core': 1.169.5(@rsbuild/core@2.1.4)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.15))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.169.5(@rsbuild/core@2.1.5)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.15))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/start-server-core': 1.167.21(crossws@0.4.5(srvx@0.11.15)) pathe: 2.0.3 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: - '@rsbuild/core': 2.1.4 + '@rsbuild/core': 2.1.5 vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - '@rspack/core' @@ -12681,7 +12713,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.27(@rsbuild/core@2.1.4)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/router-plugin@1.167.27(@rsbuild/core@2.1.5)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -12697,7 +12729,7 @@ snapshots: unplugin: 3.0.0 zod: 3.25.76 optionalDependencies: - '@rsbuild/core': 2.1.4 + '@rsbuild/core': 2.1.5 '@tanstack/react-router': 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: @@ -12731,7 +12763,7 @@ snapshots: '@tanstack/start-fn-stubs@1.161.6': {} - '@tanstack/start-plugin-core@1.169.5(@rsbuild/core@2.1.4)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.15))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/start-plugin-core@1.169.5(@rsbuild/core@2.1.5)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(crossws@0.4.5(srvx@0.11.15))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 @@ -12739,7 +12771,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.168.16 '@tanstack/router-generator': 1.166.35 - '@tanstack/router-plugin': 1.167.27(@rsbuild/core@2.1.4)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + '@tanstack/router-plugin': 1.167.27(@rsbuild/core@2.1.5)(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/router-utils': 1.161.7 '@tanstack/start-client-core': 1.167.19 '@tanstack/start-server-core': 1.167.21(crossws@0.4.5(srvx@0.11.15)) @@ -12757,7 +12789,7 @@ snapshots: xmlbuilder2: 4.0.3 zod: 3.25.76 optionalDependencies: - '@rsbuild/core': 2.1.4 + '@rsbuild/core': 2.1.5 vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - '@tanstack/react-router' @@ -13506,13 +13538,13 @@ snapshots: auto-bind@5.0.1: {} - autumn-js@1.2.12(better-auth@1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5): + autumn-js@1.2.12(better-auth@1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(better-call@1.3.5(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5): dependencies: query-string: 9.3.1 rou3: 0.6.3 zod: 4.3.6 optionalDependencies: - better-auth: 1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + better-auth: 1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) better-call: 1.3.5(zod@4.3.6) express: 5.2.1 hono: 4.12.18 @@ -13558,7 +13590,7 @@ snapshots: baseline-browser-mapping@2.10.21: {} - better-auth@1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)): + better-auth@1.6.9(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(next@16.2.4(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(pg@8.20.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.12)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0)) @@ -13578,7 +13610,7 @@ snapshots: nanostores: 1.3.0 zod: 4.3.6 optionalDependencies: - '@tanstack/react-start': 1.167.49(@rsbuild/core@2.1.4)(@rspack/core@2.1.2(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + '@tanstack/react-start': 1.167.49(@rsbuild/core@2.1.5)(@rspack/core@2.1.4(@swc/helpers@0.5.23))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) drizzle-kit: 0.31.10 drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0) next: 16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -16193,7 +16225,7 @@ snapshots: stdin-discarder: 0.3.2 string-width: 8.2.1 - oxc-parser@0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + oxc-parser@0.120.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2): dependencies: '@oxc-project/types': 0.120.0 optionalDependencies: @@ -16213,7 +16245,7 @@ snapshots: '@oxc-parser/binding-linux-x64-gnu': 0.120.0 '@oxc-parser/binding-linux-x64-musl': 0.120.0 '@oxc-parser/binding-openharmony-arm64': 0.120.0 - '@oxc-parser/binding-wasm32-wasi': 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@oxc-parser/binding-wasm32-wasi': 0.120.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) '@oxc-parser/binding-win32-arm64-msvc': 0.120.0 '@oxc-parser/binding-win32-ia32-msvc': 0.120.0 '@oxc-parser/binding-win32-x64-msvc': 0.120.0