From 0c08ed32fe868f7487c33c5db695802289933fd1 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Thu, 1 Oct 2026 22:22:33 +0200 Subject: [PATCH] fix: more stuff --- apps/dashboard/README.md | 2 +- .../app/(dashboard)/incidents/[id]/client.tsx | 2 +- .../src/app/api/auth/[...nextauth]/route.ts | 33 +++++++++-- .../src/app/login/_components/actions.ts | 6 +- .../src/app/login/_components/email-form.tsx | 44 +++++++++++---- apps/dashboard/src/app/login/page.tsx | 55 ++++++++++--------- .../src/components/forms/incident/form.tsx | 3 +- .../incidents/incident-composer.tsx | 2 +- .../incidents/incident-properties.tsx | 2 +- .../incidents/incident-timeline.tsx | 2 +- .../dashboard/src/components/nav/nav-user.tsx | 2 +- .../status-reports/status-report-composer.tsx | 3 +- apps/dashboard/src/lib/auth/helpers.ts | 16 ++++-- apps/dashboard/src/lib/formatter.ts | 18 ------ .../src/lib/rate-limit/magic-link.test.ts | 11 ++++ .../src/lib/rate-limit/magic-link.ts | 4 +- .../src/lib/rate-limit/sso-lookup.ts | 3 +- .../src/app/api/auth/[...nextauth]/route.ts | 6 +- .../src/content/pages/unrelated/security.mdx | 2 +- packages/services/src/attribution.ts | 4 +- .../upstash/src/redis/incr-with-ttl.test.ts | 4 +- packages/utils/src/index.ts | 1 + packages/utils/src/person-name.ts | 13 +++++ 23 files changed, 156 insertions(+), 82 deletions(-) create mode 100644 packages/utils/src/person-name.ts diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index e4ffd5a0..9d0c3757 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -69,7 +69,7 @@ Turbo runs the dashboard (`apps/dashboard`) and `@openstatus/db` together. The dashboard uses NextAuth with GitHub, Google, SSO and a Resend magic-link provider. -In `NODE_ENV=development` or `SELF_HOST=true`, `src/lib/auth/providers.ts` **prints the magic link to the dashboard's terminal stdout**; self-hosted deployments additionally email it when `RESEND_API_KEY` is a real key. No OAuth credentials required. Everywhere else the link is only emailed through Resend. +In `NODE_ENV=development`, `src/lib/auth/providers.ts` **prints the magic link to the dashboard's terminal stdout** instead of emailing it; the dummy `RESEND_API_KEY` from `.env.example` is enough and no OAuth credentials are required. Everywhere else the link is emailed through Resend. A self-hosted deployment (`SELF_HOST=true`) whose Resend send fails still tells the user to check their inbox but prints the link to the server log instead, so look there when running without a real key. To log in: diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx index b6f957e9..2f81159f 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -1,6 +1,7 @@ "use client"; import type { IncidentStatus } from "@openstatus/db/src/schema/incidents/constants"; +import { personName } from "@openstatus/utils"; import { useQuery } from "@tanstack/react-query"; import { formatDistanceStrict, formatDistanceToNow } from "date-fns"; import { useState } from "react"; @@ -42,7 +43,6 @@ import { IncidentTimelineItem } from "@/components/incidents/incident-timeline"; import { ResolveReportDialog } from "@/components/incidents/resolve-report-dialog"; import { incidentEndedAt } from "@/data/managed-incidents.client"; import { useFeature } from "@/hooks/use-feature"; -import { personName } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; function slackChannelUrl(teamId: string, channelId: string): string { diff --git a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts index 9dff1f86..c3b5d741 100644 --- a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts @@ -1,9 +1,34 @@ +import type { NextRequest } from "next/server"; + import { handlers } from "@/lib/auth"; -export const { GET, POST } = handlers; +export const { POST } = handlers; + +// Auth.js bounces an expired or reused magic link to `pages.error` without the +// link's `callbackUrl`; carry it over as `redirectTo` so the retry still lands +// on the invite. +export async function GET(req: NextRequest) { + const res = await handlers.GET(req); + const location = res.headers.get("Location"); + const callbackUrl = req.nextUrl.searchParams.get("callbackUrl"); + if (!location || !callbackUrl) return res; + + const target = new URL(location, req.nextUrl.origin); + if (target.searchParams.get("error") !== "Verification") return res; + const destination = new URL(callbackUrl, req.nextUrl.origin); + if (destination.origin !== req.nextUrl.origin) return res; + + target.searchParams.set( + "redirectTo", + `${destination.pathname}${destination.search}`, + ); + const headers = new Headers(res.headers); + headers.set("Location", target.toString()); + return new Response(null, { status: res.status, headers }); +} -// Mail link scanners probe magic links with HEAD. Next routes HEAD to GET; -// Auth.js then rejects the method with a 500 anyway, so answer it up front. +// Mail link scanners probe magic links with HEAD. Next would route HEAD to +// GET, which consumes the token; refuse the method before Auth.js sees it. export function HEAD() { - return new Response(null, { status: 500 }); + return new Response(null, { status: 405, headers: { Allow: "GET, POST" } }); } diff --git a/apps/dashboard/src/app/login/_components/actions.ts b/apps/dashboard/src/app/login/_components/actions.ts index 4ca765c6..95484790 100644 --- a/apps/dashboard/src/app/login/_components/actions.ts +++ b/apps/dashboard/src/app/login/_components/actions.ts @@ -48,7 +48,11 @@ export async function continueWithEmail( // The lookup limiter guards the SSO-domain oracle, not the login: once it // trips (shared office IP), the address takes the magic-link path instead. const workspace = (await ssoLookupRateLimit(ip)) - ? await getWorkspaceByVerifiedSsoDomain(email) + ? await getWorkspaceByVerifiedSsoDomain(email).catch((e: unknown) => { + // A lookup outage must not take the magic link down with it. + console.warn("sso domain lookup failed, sending magic link", e); + return null; + }) : null; if (workspace?.workosOrganizationId) { const cookieStore = await cookies(); diff --git a/apps/dashboard/src/app/login/_components/email-form.tsx b/apps/dashboard/src/app/login/_components/email-form.tsx index bb4313c6..8a00211a 100644 --- a/apps/dashboard/src/app/login/_components/email-form.tsx +++ b/apps/dashboard/src/app/login/_components/email-form.tsx @@ -1,10 +1,17 @@ "use client"; +import { Email } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { Input } from "@openstatus/ui/components/ui/input"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { useActionState, useEffect, useState } from "react"; +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; + import { type EmailFormState, continueWithEmail } from "./actions"; import { LoginButton, STORAGE_KEY } from "./login-button"; @@ -16,20 +23,31 @@ type Mode = "closed" | "sso" | "email"; * One form, two doors: "Continue with SSO" sits with the OAuth buttons, the * magic link hides behind a text link so OAuth stays the obvious path. Both * submit the same action, which routes verified SSO domains server-side. - * Reopens by itself for returning email/SSO users. + * Reopens by itself for returning email/SSO users. The OAuth forms come in as + * children so the "check your inbox" state can replace the whole list. */ export function EmailForm({ redirectTo, sso, + children, }: { redirectTo?: string; sso: boolean; + children?: React.ReactNode; }) { const [state, formAction, isPending] = useActionState( continueWithEmail, initialState, ); const [mode, setMode] = useState("closed"); + // The action state outlives a mode switch; an error from the email form + // must not show up, or mark the input invalid, on the SSO form. + const [staleState, setStaleState] = useState(null); + const error = state === staleState ? undefined : state.error; + const switchMode = (next: Mode) => { + setStaleState(state); + setMode(next); + }; useEffect(() => { const last = localStorage.getItem(STORAGE_KEY); @@ -39,12 +57,13 @@ export function EmailForm({ if (state.sent) { return ( -
-

Check your inbox

-

+ + + Check your inbox + We sent you a sign-in link. It is valid for 24 hours and works once. -

-
+ + ); } @@ -63,12 +82,12 @@ export function EmailForm({ mode === "sso" ? "you@company.com" : "gilfoyle@piedpiper.dev" } aria-label={mode === "sso" ? "Work email" : "Email"} - aria-invalid={state.error ? true : undefined} - aria-describedby={state.error ? "email-error" : undefined} + aria-invalid={error ? true : undefined} + aria-describedby={error ? "email-error" : undefined} /> - {state.error ? ( + {error ? ( ) : null} + {children} {sso ? ( mode === "sso" ? ( form @@ -97,7 +117,7 @@ export function EmailForm({ type="button" variant="secondary" className="w-full" - onClick={() => setMode("sso")} + onClick={() => switchMode("sso")} > Continue with SSO @@ -115,7 +135,7 @@ export function EmailForm({ type="button" variant="ghost" className="text-muted-foreground w-full" - onClick={() => setMode("email")} + onClick={() => switchMode("email")} > Continue with email diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx index ec6d6e0d..b3a547a8 100644 --- a/apps/dashboard/src/app/login/page.tsx +++ b/apps/dashboard/src/app/login/page.tsx @@ -20,6 +20,8 @@ const ERROR_MESSAGES: Record = { "Your SSO login isn't linked to a workspace yet. Contact your workspace admin.", Verification: "That sign-in link has expired or was already used. Request a new one.", + Configuration: + "We couldn't complete your sign-in. Try again or contact support.", }; export const metadata: Metadata = { @@ -55,39 +57,40 @@ export default async function Page(props: {

) : null}
-
{ - "use server"; - await signIn("github", { redirectTo: redirectTo ?? undefined }); - }} - > - - Continue with GitHub - -
-
{ - "use server"; - await signIn("google", { redirectTo: redirectTo ?? undefined }); - }} - > - - Continue with Google - -
- {process.env.AUTH_OIDC_ISSUER ? ( + +
{ + "use server"; + await signIn("github", { redirectTo: redirectTo ?? undefined }); + }} + > + + Continue with GitHub + +
{ "use server"; - await signIn("oidc", { redirectTo: redirectTo ?? undefined }); + await signIn("google", { redirectTo: redirectTo ?? undefined }); }} > - - Continue with {process.env.AUTH_OIDC_NAME ?? "SSO"} + + Continue with Google - ) : null} - + {process.env.AUTH_OIDC_ISSUER ? ( +
{ + "use server"; + await signIn("oidc", { redirectTo: redirectTo ?? undefined }); + }} + > + + Continue with {process.env.AUTH_OIDC_NAME ?? "SSO"} + +
+ ) : null} +

By clicking continue, you agree to our{" "} diff --git a/apps/dashboard/src/components/forms/incident/form.tsx b/apps/dashboard/src/components/forms/incident/form.tsx index dfaf3ed5..55edaf82 100644 --- a/apps/dashboard/src/components/forms/incident/form.tsx +++ b/apps/dashboard/src/components/forms/incident/form.tsx @@ -22,6 +22,7 @@ import { } from "@openstatus/ui/components/ui/select"; import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { cn } from "@openstatus/ui/lib/utils"; +import { personName } from "@openstatus/utils"; import { useQuery } from "@tanstack/react-query"; import React, { useTransition } from "react"; import { useForm } from "react-hook-form"; @@ -35,7 +36,7 @@ import { } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { severityConfig } from "@/data/managed-incidents.client"; -import { formatDateForInput, personName } from "@/lib/formatter"; +import { formatDateForInput } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; diff --git a/apps/dashboard/src/components/incidents/incident-composer.tsx b/apps/dashboard/src/components/incidents/incident-composer.tsx index 7ed154f0..b8ae9de8 100644 --- a/apps/dashboard/src/components/incidents/incident-composer.tsx +++ b/apps/dashboard/src/components/incidents/incident-composer.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; +import { personName } from "@openstatus/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; @@ -23,7 +24,6 @@ import { } from "@/components/content/composer"; import { TimelineAvatar, TimelineItem } from "@/components/content/timeline"; import { statusConfig } from "@/data/managed-incidents.client"; -import { personName } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; diff --git a/apps/dashboard/src/components/incidents/incident-properties.tsx b/apps/dashboard/src/components/incidents/incident-properties.tsx index 998cd1bb..1fde9879 100644 --- a/apps/dashboard/src/components/incidents/incident-properties.tsx +++ b/apps/dashboard/src/components/incidents/incident-properties.tsx @@ -11,6 +11,7 @@ import { SelectItem, SelectValue, } from "@openstatus/ui/components/ui/select"; +import { personName } from "@openstatus/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { format, @@ -35,7 +36,6 @@ import { severityConfig, statusConfig, } from "@/data/managed-incidents.client"; -import { personName } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; diff --git a/apps/dashboard/src/components/incidents/incident-timeline.tsx b/apps/dashboard/src/components/incidents/incident-timeline.tsx index 54812ef4..0992b865 100644 --- a/apps/dashboard/src/components/incidents/incident-timeline.tsx +++ b/apps/dashboard/src/components/incidents/incident-timeline.tsx @@ -25,6 +25,7 @@ import { Warning, } from "@openstatus/icons"; import { SlackIcon } from "@openstatus/icons/brand"; +import { personName } from "@openstatus/utils"; import type { StatusVariant } from "@/components/common/status-dot"; import { ProcessMessage } from "@/components/content/process-message"; @@ -43,7 +44,6 @@ import { TimelineTitle, } from "@/components/content/timeline"; import { severityConfig, statusConfig } from "@/data/managed-incidents.client"; -import { personName } from "@/lib/formatter"; import { IncidentSeverityBadge, IncidentStatusBadge } from "./incident-badge"; diff --git a/apps/dashboard/src/components/nav/nav-user.tsx b/apps/dashboard/src/components/nav/nav-user.tsx index ae626c8a..2e2fd880 100644 --- a/apps/dashboard/src/components/nav/nav-user.tsx +++ b/apps/dashboard/src/components/nav/nav-user.tsx @@ -34,13 +34,13 @@ import { SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; +import { personName } from "@openstatus/utils"; import { useMutation, useQuery } from "@tanstack/react-query"; import { signOut } from "next-auth/react"; import { useTheme } from "next-themes"; import Link from "next/link"; import { toast } from "sonner"; -import { personName } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; export function NavUser() { diff --git a/apps/dashboard/src/components/status-reports/status-report-composer.tsx b/apps/dashboard/src/components/status-reports/status-report-composer.tsx index 84bcdeab..189a9b08 100644 --- a/apps/dashboard/src/components/status-reports/status-report-composer.tsx +++ b/apps/dashboard/src/components/status-reports/status-report-composer.tsx @@ -19,6 +19,7 @@ import { SelectTrigger, SelectValue, } from "@openstatus/ui/components/ui/select"; +import { personName } from "@openstatus/utils"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; @@ -48,7 +49,7 @@ import { statusVariants, toCreateStatusReportUpdateInput, } from "@/data/status-report-updates.client"; -import { formatDateForInput, personName } from "@/lib/formatter"; +import { formatDateForInput } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; diff --git a/apps/dashboard/src/lib/auth/helpers.ts b/apps/dashboard/src/lib/auth/helpers.ts index 6ab4f50c..9cebbde4 100644 --- a/apps/dashboard/src/lib/auth/helpers.ts +++ b/apps/dashboard/src/lib/auth/helpers.ts @@ -1,4 +1,4 @@ -import { asc, db, eq, sql } from "@openstatus/db"; +import { db, eq, sql } from "@openstatus/db"; import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; import type { AdapterUser } from "next-auth/adapters"; import * as randomWordSlugs from "random-word-slugs"; @@ -10,6 +10,8 @@ export function normalizeEmail(email: string) { // Rows created before emails were normalized keep the OAuth profile's casing, // so an indexed exact match comes first and a `lower()` scan only on a miss. +// Two case variants of one address cannot be told apart, so the sign-in fails +// (`?error=Configuration`) rather than landing in either workspace. export async function getUserByEmail(email: string) { const normalized = normalizeEmail(email); const exact = await db @@ -23,9 +25,15 @@ export async function getUserByEmail(email: string) { .select() .from(user) .where(sql`lower(${user.email}) = ${normalized}`) - .orderBy(asc(user.id)) - .get(); - return legacy ?? null; + .limit(2) + .all(); + if (legacy.length > 1) { + console.error("ambiguous legacy email, refusing sign-in", { + userIds: legacy.map((row) => row.id), + }); + throw new Error("ambiguous legacy email"); + } + return legacy[0] ?? null; } export async function createUser(data: AdapterUser) { diff --git a/apps/dashboard/src/lib/formatter.ts b/apps/dashboard/src/lib/formatter.ts index 47b3e25a..a2b4e076 100644 --- a/apps/dashboard/src/lib/formatter.ts +++ b/apps/dashboard/src/lib/formatter.ts @@ -103,21 +103,3 @@ export function formatDateForInput(date: Date): string { return `${year}-${month}-${day}T${hours}:${minutes}`; } - -/** - * Display name for a user row: name, then first/last, then email. Twin of - * `displayName` in `@openstatus/services/attribution`, which imports the db - * and cannot reach client components. - */ -export function personName( - person: { - name: string | null; - firstName: string | null; - lastName: string | null; - email: string | null; - } | null, -): string | null { - if (!person) return null; - const full = [person.firstName, person.lastName].filter(Boolean).join(" "); - return person.name || full || person.email || null; -} diff --git a/apps/dashboard/src/lib/rate-limit/magic-link.test.ts b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts index 3cfd94f5..966e5959 100644 --- a/apps/dashboard/src/lib/rate-limit/magic-link.test.ts +++ b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts @@ -33,6 +33,17 @@ describe("magicLinkRateLimit", () => { expect(evalStub?.calls[0]?.args[2]).toEqual([600]); }); + test("lowercases the email key", async () => { + stubCounts([1, 1]); + + await magicLinkRateLimit({ ip: "ip", email: " Gilfoyle@PiedPiper.dev " }); + + expect(evalStub?.calls[0]?.args[1]).toEqual([ + "ratelimit:magic-link:ip:ip", + "ratelimit:magic-link:email:gilfoyle@piedpiper.dev", + ]); + }); + test("allows at the limits", async () => { stubCounts([10, 3]); expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(true); diff --git a/apps/dashboard/src/lib/rate-limit/magic-link.ts b/apps/dashboard/src/lib/rate-limit/magic-link.ts index 616ad63f..8ddbca22 100644 --- a/apps/dashboard/src/lib/rate-limit/magic-link.ts +++ b/apps/dashboard/src/lib/rate-limit/magic-link.ts @@ -11,6 +11,8 @@ const MAX_PER_EMAIL = 3; * Accepted trade-offs: refused requests count too, so three submits for * someone else's address block that inbox for the window (they keep GitHub * and Google); every request without a resolvable IP shares one bucket. + * Auth.js already lowercases the identifier; the key does so again so direct + * callers cannot multiply the per-address budget with case variants. */ export async function magicLinkRateLimit(args: { ip: string; @@ -21,7 +23,7 @@ export async function magicLinkRateLimit(args: { redis, [ `ratelimit:magic-link:ip:${args.ip}`, - `ratelimit:magic-link:email:${args.email}`, + `ratelimit:magic-link:email:${args.email.trim().toLowerCase()}`, ], WINDOW_SECONDS, ); diff --git a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts index e63d7d27..fcf55852 100644 --- a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts +++ b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts @@ -15,9 +15,10 @@ export async function ssoLookupRateLimit(ip: string): Promise { WINDOW_SECONDS, ); return count <= MAX_ATTEMPTS; - } catch { + } catch (e) { // Redis unavailable: allow the lookup rather than locking everyone out of // SSO. The verified-domain check downstream is the real security boundary. + console.warn("sso lookup rate limit unavailable, allowing request", e); return true; } } diff --git a/apps/status-page/src/app/api/auth/[...nextauth]/route.ts b/apps/status-page/src/app/api/auth/[...nextauth]/route.ts index 1afffead..4407a90f 100644 --- a/apps/status-page/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/status-page/src/app/api/auth/[...nextauth]/route.ts @@ -2,8 +2,8 @@ import { handlers } from "../../../../lib/auth"; export const { GET, POST } = handlers; -// Mail link scanners probe magic links with HEAD. Next routes HEAD to GET; -// Auth.js then rejects the method with a 500 anyway, so answer it up front. +// Mail link scanners probe magic links with HEAD. Next would route HEAD to +// GET, which consumes the token; refuse the method before Auth.js sees it. export function HEAD() { - return new Response(null, { status: 500 }); + return new Response(null, { status: 405, headers: { Allow: "GET, POST" } }); } diff --git a/apps/web/src/content/pages/unrelated/security.mdx b/apps/web/src/content/pages/unrelated/security.mdx index 809448a5..e1c01148 100644 --- a/apps/web/src/content/pages/unrelated/security.mdx +++ b/apps/web/src/content/pages/unrelated/security.mdx @@ -46,7 +46,7 @@ Application secrets — third-party tokens, webhook URLs, integration credential ## Authentication and access -You sign in with GitHub or Google OAuth, an email magic link, or your company's SSO provider. openstatus has no passwords to see or store. +You sign in with GitHub or Google OAuth, an email magic link, or your company's SSO provider. Account sign-in never involves a password, so there is none for openstatus to see or store. API access uses scoped keys: a key with the `read` scope can only call read-only endpoints; a key with the `write` scope can mutate data. Scopes are enforced before any database lookup, so a read-only key can't even reach a write code path. diff --git a/packages/services/src/attribution.ts b/packages/services/src/attribution.ts index c9b9dbc2..95157d28 100644 --- a/packages/services/src/attribution.ts +++ b/packages/services/src/attribution.ts @@ -1,5 +1,6 @@ import { inArray } from "@openstatus/db"; import { user } from "@openstatus/db/src/schema"; +import { personName } from "@openstatus/utils"; import { z } from "zod"; import type { DB } from "./context"; @@ -37,8 +38,7 @@ export function displayName(row: { lastName: string | null; email: string | null; }): string { - const full = [row.firstName, row.lastName].filter(Boolean).join(" "); - return row.name || full || row.email || "Unknown user"; + return personName(row) ?? "Unknown user"; } // `user/delete.ts` soft-deletes and blanks every name field, so the row diff --git a/packages/upstash/src/redis/incr-with-ttl.test.ts b/packages/upstash/src/redis/incr-with-ttl.test.ts index 083e374e..17befdcd 100644 --- a/packages/upstash/src/redis/incr-with-ttl.test.ts +++ b/packages/upstash/src/redis/incr-with-ttl.test.ts @@ -26,7 +26,9 @@ describe("incrWithTtl", () => { expect(calls[0]?.args).toEqual([600]); }); - test("the script guards EXPIRE behind the first INCR of each key", () => { + // Source-level only: no Lua runtime here, so this pins the shape of the + // script, not its behaviour. + test("ships INCR followed by a first-hit-only EXPIRE per key", () => { const { client, calls } = fakeClient([1]); incrWithTtl(client, ["a"], 600); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 1aa6a38f..dd4161fb 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -24,6 +24,7 @@ export { } from "./constants"; export { buildCurlCommand, type CurlRequest } from "./curl"; export { type HeaderPair, headerPairSchema } from "./headers"; +export { personName } from "./person-name"; export { iteratorToStream, yieldMany } from "./stream"; export { type PageUpdateStatus, statusLabel } from "./status"; diff --git a/packages/utils/src/person-name.ts b/packages/utils/src/person-name.ts new file mode 100644 index 00000000..ee6887d5 --- /dev/null +++ b/packages/utils/src/person-name.ts @@ -0,0 +1,13 @@ +/** Display name for a user row: name, then first/last, then email. */ +export function personName( + person: { + name: string | null; + firstName: string | null; + lastName: string | null; + email: string | null; + } | null, +): string | null { + if (!person) return null; + const full = [person.firstName, person.lastName].filter(Boolean).join(" "); + return person.name || full || person.email || null; +} -- 2.51.2