- Your SSO login isn't linked to a workspace yet. Contact your
- workspace admin.
+ {ERROR_MESSAGES[error]}
) : null}
-
- {process.env.NODE_ENV === "development" ||
- process.env.SELF_HOST === "true" ? (
-
-
-
-
- ) : null}
-
-
- {process.env.AUTH_OIDC_ISSUER ? (
+
+
+
- ) : null}
- {hasWorkOS ? : null}
+ {process.env.AUTH_OIDC_ISSUER ? (
+
+ ) : 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 3ddaa962d..42ffffe6d 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 {
FormCardSeparator,
} from "@/components/forms/form-card";
import { useFormSheetDirty } from "@/components/forms/form-sheet";
-import { personName, severityConfig } from "@/data/managed-incidents.client";
+import { severityConfig } from "@/data/managed-incidents.client";
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 792e65037..b8ae9de88 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";
@@ -22,7 +23,7 @@ import {
ComposerTextarea,
} from "@/components/content/composer";
import { TimelineAvatar, TimelineItem } from "@/components/content/timeline";
-import { personName, statusConfig } from "@/data/managed-incidents.client";
+import { statusConfig } from "@/data/managed-incidents.client";
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 589cc178d..1fde98794 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,
@@ -32,7 +33,6 @@ import {
} from "@/components/content/property-list";
import {
incidentEndedAt,
- personName,
severityConfig,
statusConfig,
} from "@/data/managed-incidents.client";
diff --git a/apps/dashboard/src/components/incidents/incident-timeline.tsx b/apps/dashboard/src/components/incidents/incident-timeline.tsx
index be903cf22..0992b8650 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";
@@ -42,11 +43,7 @@ import {
TimelineTime,
TimelineTitle,
} from "@/components/content/timeline";
-import {
- personName,
- severityConfig,
- statusConfig,
-} from "@/data/managed-incidents.client";
+import { severityConfig, statusConfig } from "@/data/managed-incidents.client";
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 16e4ccfaa..2e2fd8807 100644
--- a/apps/dashboard/src/components/nav/nav-user.tsx
+++ b/apps/dashboard/src/components/nav/nav-user.tsx
@@ -34,6 +34,7 @@ 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";
@@ -59,7 +60,7 @@ export function NavUser() {
if (!user || !workspace) return null;
- const userName = user?.name ?? `${user?.firstName} ${user?.lastName}`.trim();
+ const userName = personName(user) ?? "";
const isTrialing = workspace.trialDaysLeft !== null;
return (
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 27839efdb..25229bde5 100644
--- a/apps/dashboard/src/components/status-reports/status-report-composer.tsx
+++ b/apps/dashboard/src/components/status-reports/status-report-composer.tsx
@@ -18,6 +18,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";
@@ -42,7 +43,6 @@ import {
ComposerTextarea,
} from "@/components/content/composer";
import { TimelineAvatar, TimelineItem } from "@/components/content/timeline";
-import { personName } from "@/data/managed-incidents.client";
import { toGroupNameLookup } from "@/data/page-components.client";
import {
getNextStatus,
diff --git a/apps/dashboard/src/data/managed-incidents.client.ts b/apps/dashboard/src/data/managed-incidents.client.ts
index 3302b0d5e..9a41b7fd8 100644
--- a/apps/dashboard/src/data/managed-incidents.client.ts
+++ b/apps/dashboard/src/data/managed-incidents.client.ts
@@ -35,16 +35,3 @@ export function incidentEndedAt(incident: {
}
return incident.closedAt;
}
-
-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/auth/adapter.ts b/apps/dashboard/src/lib/auth/adapter.ts
index 5ca66ef16..06fdbb371 100644
--- a/apps/dashboard/src/lib/auth/adapter.ts
+++ b/apps/dashboard/src/lib/auth/adapter.ts
@@ -8,18 +8,32 @@ import {
} from "@openstatus/db/src/schema";
import type { Adapter } from "next-auth/adapters";
-import { createUser, getUser } from "./helpers";
+import { createUser, getUser, getUserByEmail } from "./helpers";
+
+const drizzleAdapter = DrizzleAdapter(db, {
+ // @ts-expect-error: problem with type
+ usersTable: user,
+ // @ts-expect-error: problem with type
+ accountsTable: account,
+ // @ts-expect-error: problem with type
+ sessionsTable: session,
+ verificationTokensTable: verificationToken,
+}) as Adapter;
export const adapter: Adapter = {
- ...(DrizzleAdapter(db, {
- // @ts-expect-error: problem with type
- usersTable: user,
- // @ts-expect-error: problem with type
- accountsTable: account,
- // @ts-expect-error: problem with type
- sessionsTable: session,
- verificationTokensTable: verificationToken,
- }) as Adapter),
+ ...drizzleAdapter,
+ // Auth.js lowercases magic-link addresses while OAuth profiles arrive as-is;
+ // without this a mixed-case OAuth user gets a second account on first
+ // magic-link sign-in.
+ getUserByEmail: async (email) => {
+ const user = await getUserByEmail(email);
+ if (!user) return null;
+ return {
+ ...user,
+ id: user.id.toString(),
+ email: user.email || "",
+ };
+ },
createUser: async (data) => {
const user = await createUser(data);
return {
diff --git a/apps/dashboard/src/lib/auth/helpers.ts b/apps/dashboard/src/lib/auth/helpers.ts
index 367e21550..9cebbde46 100644
--- a/apps/dashboard/src/lib/auth/helpers.ts
+++ b/apps/dashboard/src/lib/auth/helpers.ts
@@ -1,13 +1,46 @@
-import { db, eq } 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";
+/** Stored and looked up lowercase; the `user.email` index is an exact match. */
+export function normalizeEmail(email: string) {
+ return email.trim().toLowerCase();
+}
+
+// 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
+ .select()
+ .from(user)
+ .where(eq(user.email, normalized))
+ .get();
+ if (exact) return exact;
+
+ const legacy = await db
+ .select()
+ .from(user)
+ .where(sql`lower(${user.email}) = ${normalized}`)
+ .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) {
const newUser = await db
.insert(user)
.values({
- email: data.email,
+ email: normalizeEmail(data.email),
photoUrl: data.image,
name: data.name,
firstName: data.firstName,
diff --git a/apps/dashboard/src/lib/auth/index.ts b/apps/dashboard/src/lib/auth/index.ts
index 9a731b36a..da98a158f 100644
--- a/apps/dashboard/src/lib/auth/index.ts
+++ b/apps/dashboard/src/lib/auth/index.ts
@@ -119,10 +119,7 @@ const {
GoogleProvider,
...(process.env.AUTH_OIDC_ISSUER ? [OIDCProvider] : []),
...(hasWorkOS ? [WorkOSProvider] : []),
- ...(process.env.NODE_ENV === "development" ||
- process.env.SELF_HOST === "true"
- ? [ResendProvider]
- : []),
+ ResendProvider,
],
callbacks: {
async redirect({ url, baseUrl }) {
@@ -191,7 +188,6 @@ const {
return authorizeSsoSignIn(readWorkOSProfile(params.profile));
}
- // REMINDER: only used in dev mode
if (params.account?.provider === "resend") {
if (Number.isNaN(Number(params.user.id))) return true;
await db
@@ -244,6 +240,8 @@ const {
},
pages: {
signIn: "/login",
+ // Expired or reused magic links surface as `?error=Verification` here.
+ error: "/login",
newUser: "/onboarding",
},
// basePath: "/api/auth", // default is `/api/auth`
diff --git a/apps/dashboard/src/lib/auth/providers.test.ts b/apps/dashboard/src/lib/auth/providers.test.ts
new file mode 100644
index 000000000..734f82427
--- /dev/null
+++ b/apps/dashboard/src/lib/auth/providers.test.ts
@@ -0,0 +1,69 @@
+import "@/test-preload";
+import { AuthError } from "@auth/core/errors";
+import { redis } from "@openstatus/upstash";
+import { expect } from "@std/expect";
+import { afterEach, describe, test } from "@std/testing/bdd";
+import { type Stub, stub } from "@std/testing/mock";
+
+import { ResendProvider } from "./providers";
+
+let evalStub: Stub | undefined;
+let logStub: Stub | undefined;
+
+afterEach(() => {
+ evalStub?.restore();
+ logStub?.restore();
+});
+
+function sendTo(identifier: string, ip = "1.2.3.4") {
+ // `Resend()` keeps overrides under `options`; Auth.js merges them at init.
+ const send = ResendProvider.options?.sendVerificationRequest;
+ if (!send) throw new Error("sendVerificationRequest override missing");
+ return send({
+ identifier,
+ url: "https://app.openstatus.dev/api/auth/callback/resend?token=t",
+ token: "t",
+ expires: new Date(Date.now() + 60_000),
+ provider: ResendProvider,
+ request: new Request("https://app.openstatus.dev/api/auth/signin/resend", {
+ headers: { "x-forwarded-for": ip },
+ }),
+ theme: {},
+ });
+}
+
+describe("ResendProvider.sendVerificationRequest", () => {
+ test("refuses disposable domains before touching the rate limit", async () => {
+ evalStub = stub(redis, "eval");
+
+ await expect(sendTo("a@mailinator.com")).rejects.toThrow(AuthError);
+ await expect(sendTo("a@mailinator.com")).rejects.toThrow(
+ "disposable domain",
+ );
+ expect(evalStub.calls).toHaveLength(0);
+ });
+
+ test("refuses throttled addresses", async () => {
+ evalStub = stub(redis, "eval", () => Promise.resolve([1, 4]));
+
+ await expect(sendTo("gilfoyle@piedpiper.dev")).rejects.toThrow(
+ "rate limited",
+ );
+ });
+
+ test("sends when screening passes, keyed by client ip and address", async () => {
+ evalStub = stub(redis, "eval", () => Promise.resolve([1, 1]));
+ logStub = stub(console, "log");
+
+ await sendTo("gilfoyle@piedpiper.dev", "9.9.9.9");
+
+ expect(evalStub.calls[0]?.args[1]).toEqual([
+ "ratelimit:magic-link:ip:9.9.9.9",
+ "ratelimit:magic-link:email:gilfoyle@piedpiper.dev",
+ ]);
+ const printed = logStub.calls.map((c) => String(c.args[0])).join("\n");
+ expect(printed).toContain(
+ "https://app.openstatus.dev/api/auth/callback/resend?token=t",
+ );
+ });
+});
diff --git a/apps/dashboard/src/lib/auth/providers.ts b/apps/dashboard/src/lib/auth/providers.ts
index 823125405..2199ba635 100644
--- a/apps/dashboard/src/lib/auth/providers.ts
+++ b/apps/dashboard/src/lib/auth/providers.ts
@@ -1,3 +1,6 @@
+import { AuthError } from "@auth/core/errors";
+import { EmailClient } from "@openstatus/emails";
+import { resolveClientIp } from "@openstatus/services/page-access";
import type { Profile } from "next-auth";
import type { OIDCConfig } from "next-auth/providers";
import GitHub from "next-auth/providers/github";
@@ -41,11 +44,53 @@ export const WorkOSProvider = WorkOS({
allowDangerousEmailAccountLinking: true,
});
+// An `AuthError` is rethrown to a server-action `signIn`; a plain throw comes
+// back as a `?error=Configuration` URL and the form would report the link sent.
+class MagicLinkRefused extends AuthError {
+ static type = "MagicLinkRefused";
+ static kind = "signIn" as const;
+}
+
+// `apiKey` stays undefined: the email goes through our own template and client,
+// which prints the link in development instead of sending. Screening lives here
+// rather than in the form action because `POST /api/auth/signin/resend` reaches
+// this provider directly.
export const ResendProvider = Resend({
- apiKey: undefined, // REMINDER: keep undefined to avoid sending emails
+ apiKey: undefined,
async sendVerificationRequest(params) {
- console.log("");
- console.log(`>>> Magic Link: ${params.url}`);
- console.log("");
+ // Lazy: the proxy loads this module too, and it has no use for the
+ // disposable-domain list or Redis.
+ const [{ default: MailChecker }, { magicLinkRateLimit }] =
+ await Promise.all([
+ import("mailchecker"),
+ import("@/lib/rate-limit/magic-link"),
+ ]);
+
+ const email = params.identifier;
+ if (!MailChecker.isValid(email)) {
+ throw new MagicLinkRefused("disposable domain");
+ }
+ const ip = resolveClientIp(params.request.headers) ?? "unknown";
+ if (!(await magicLinkRateLimit({ ip, email }))) {
+ throw new MagicLinkRefused("rate limited");
+ }
+
+ try {
+ // `@openstatus/emails` refuses to load without RESEND_API_KEY, so the
+ // key is set here; in development the client prints instead of sending.
+ const emailClient = new EmailClient({
+ apiKey: process.env.RESEND_API_KEY ?? "",
+ });
+ await emailClient.sendDashboardMagicLink({ link: params.url, to: email });
+ } catch (cause) {
+ // Self-hosted installs may run with a dummy Resend key: fall back to the
+ // dashboard log, only now, so a working install never logs live tokens.
+ if (process.env.SELF_HOST === "true") {
+ console.warn("magic link email not sent, use the printed link", cause);
+ console.log(`>>> Magic Link: ${params.url}`);
+ return;
+ }
+ throw new MagicLinkRefused("send failed", { cause });
+ }
},
});
diff --git a/apps/dashboard/src/lib/rate-limit/magic-link.test.ts b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts
new file mode 100644
index 000000000..966e59599
--- /dev/null
+++ b/apps/dashboard/src/lib/rate-limit/magic-link.test.ts
@@ -0,0 +1,69 @@
+import "@/test-preload";
+import { redis } from "@openstatus/upstash";
+import { expect } from "@std/expect";
+import { afterEach, describe, test } from "@std/testing/bdd";
+import { type Stub, stub } from "@std/testing/mock";
+
+import { magicLinkRateLimit } from "./magic-link";
+
+let evalStub: Stub | undefined;
+let warnStub: Stub | undefined;
+
+function stubCounts(counts: number[] | Error) {
+ evalStub = stub(redis, "eval", () =>
+ counts instanceof Error ? Promise.reject(counts) : Promise.resolve(counts),
+ );
+}
+
+afterEach(() => {
+ evalStub?.restore();
+ warnStub?.restore();
+});
+
+describe("magicLinkRateLimit", () => {
+ test("keys the counters by ip and email", async () => {
+ stubCounts([1, 1]);
+
+ await magicLinkRateLimit({ ip: "1.2.3.4", email: "a@b.c" });
+
+ expect(evalStub?.calls[0]?.args[1]).toEqual([
+ "ratelimit:magic-link:ip:1.2.3.4",
+ "ratelimit:magic-link:email:a@b.c",
+ ]);
+ 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);
+ });
+
+ test("refuses past the ip limit", async () => {
+ stubCounts([11, 1]);
+ expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(false);
+ });
+
+ test("refuses past the email limit", async () => {
+ stubCounts([1, 4]);
+ expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(false);
+ });
+
+ test("fails open with a warning when redis is down", async () => {
+ stubCounts(new Error("redis down"));
+ warnStub = stub(console, "warn");
+
+ expect(await magicLinkRateLimit({ ip: "ip", email: "e" })).toBe(true);
+ expect(warnStub.calls).toHaveLength(1);
+ });
+});
diff --git a/apps/dashboard/src/lib/rate-limit/magic-link.ts b/apps/dashboard/src/lib/rate-limit/magic-link.ts
new file mode 100644
index 000000000..8ddbca22f
--- /dev/null
+++ b/apps/dashboard/src/lib/rate-limit/magic-link.ts
@@ -0,0 +1,36 @@
+import { incrWithTtl, redis } from "@openstatus/upstash";
+
+const WINDOW_SECONDS = 60 * 10;
+const MAX_PER_IP = 10;
+const MAX_PER_EMAIL = 3;
+
+/**
+ * Throttle magic-link requests per sender IP and per target address: without
+ * it the login form sends one email to any inbox per submit.
+ *
+ * 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;
+ email: string;
+}): Promise {
+ try {
+ const [byIp, byEmail] = await incrWithTtl(
+ redis,
+ [
+ `ratelimit:magic-link:ip:${args.ip}`,
+ `ratelimit:magic-link:email:${args.email.trim().toLowerCase()}`,
+ ],
+ WINDOW_SECONDS,
+ );
+ return byIp <= MAX_PER_IP && byEmail <= MAX_PER_EMAIL;
+ } catch (e) {
+ // Redis unavailable: allow the request rather than locking everyone out.
+ console.warn("magic link rate limit unavailable, allowing request", e);
+ return true;
+ }
+}
diff --git a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts
index 81e988f6d..fcf558527 100644
--- a/apps/dashboard/src/lib/rate-limit/sso-lookup.ts
+++ b/apps/dashboard/src/lib/rate-limit/sso-lookup.ts
@@ -1,33 +1,24 @@
-import { redis } from "@openstatus/upstash";
+import { incrWithTtl, redis } from "@openstatus/upstash";
const WINDOW_SECONDS = 60 * 10;
const MAX_ATTEMPTS = 10;
-// INCR + conditional EXPIRE in one round-trip so a crash between the two can't
-// leave a TTL-less key. Mirrors `rate-limit/chat.ts`.
-const INCR_WITH_TTL = `
- local count = redis.call('INCR', KEYS[1])
- if count == 1 then
- redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
- end
- return count
-`;
-
/**
* Throttle unauthenticated SSO domain lookups — without this the login form is
* a free oracle for enumerating which companies have SSO configured.
*/
export async function ssoLookupRateLimit(ip: string): Promise {
try {
- const count = await redis.eval<[number], number>(
- INCR_WITH_TTL,
+ const [count] = await incrWithTtl(
+ redis,
[`ratelimit:sso-lookup:${ip}`],
- [WINDOW_SECONDS],
+ 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/dashboard/src/test-preload.ts b/apps/dashboard/src/test-preload.ts
new file mode 100644
index 000000000..1fe4ba7a1
--- /dev/null
+++ b/apps/dashboard/src/test-preload.ts
@@ -0,0 +1,5 @@
+// Import FIRST in every test file: `@openstatus/emails` validates
+// RESEND_API_KEY and snapshots NODE_ENV on import. Development keeps
+// EmailClient off the network.
+Object.assign(process.env, { NODE_ENV: "development" });
+process.env.RESEND_API_KEY ||= "test-key";
diff --git a/apps/dashboard/src/test/upstash.mock.ts b/apps/dashboard/src/test/upstash.mock.ts
new file mode 100644
index 000000000..c20a9af77
--- /dev/null
+++ b/apps/dashboard/src/test/upstash.mock.ts
@@ -0,0 +1,11 @@
+// Test double for @openstatus/upstash, swapped in via --import-map. The real
+// client is a pipelining Proxy that `stub()` cannot intercept.
+export { incrWithTtl } from "../../../../packages/upstash/src/redis/incr-with-ttl";
+
+export const redis = {
+ eval: (
+ _script: string,
+ _keys: string[],
+ _args: unknown[],
+ ): Promise => Promise.resolve([1, 1]),
+};
diff --git a/apps/dashboard/test.importmap.json b/apps/dashboard/test.importmap.json
new file mode 100644
index 000000000..b9dd92bcf
--- /dev/null
+++ b/apps/dashboard/test.importmap.json
@@ -0,0 +1,6 @@
+{
+ "imports": {
+ "@/": "./src/",
+ "@openstatus/upstash": "./src/test/upstash.mock.ts"
+ }
+}
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 9f1bce8e8..4407a90f5 100644
--- a/apps/status-page/src/app/api/auth/[...nextauth]/route.ts
+++ b/apps/status-page/src/app/api/auth/[...nextauth]/route.ts
@@ -1,3 +1,9 @@
import { handlers } from "../../../../lib/auth";
export const { GET, POST } = handlers;
+
+// 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: 405, headers: { Allow: "GET, POST" } });
+}
diff --git a/apps/web/src/content/pages/changelog/saml-sso.mdx b/apps/web/src/content/pages/changelog/saml-sso.mdx
index 0e87437a0..d05fc284d 100644
--- a/apps/web/src/content/pages/changelog/saml-sso.mdx
+++ b/apps/web/src/content/pages/changelog/saml-sso.mdx
@@ -11,7 +11,7 @@ Your team can now sign in to openstatus through your own identity provider. **Ok
Head to **Settings > SSO**, enable it, and verify your domain with a DNS TXT record. Verification is not optional: openstatus only accepts an SSO sign-in when the email your identity provider asserts belongs to a domain you own. Then connect your provider and you're done.
-On the login page your team selects **Sign in with SSO** and enters their work email — they're redirected to your identity provider and added to the workspace as members on first login. Signing in from your provider's app tile works too. Anyone who already has an openstatus account with the same email keeps it, along with their role.
+On the login page your team selects **Continue with SSO** and enters their work email — they're redirected to your identity provider and added to the workspace as members on first login. Signing in from your provider's app tile works too. Anyone who already has an openstatus account with the same email keeps it, along with their role.
GitHub and Google stay available — SSO is an additional way in, not a replacement.
diff --git a/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx b/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx
index e3ac0822f..22514c1d8 100644
--- a/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx
+++ b/apps/web/src/content/pages/docs/guides/how-to-set-up-saml-sso.mdx
@@ -41,7 +41,7 @@ When the connection goes live, the SSO page shows **Ready — your team can sign
## Signing in
-On the login page your team selects **Sign in with SSO** and enters their work email. The domain is matched against your verified domains and they're redirected to your identity provider.
+On the login page your team selects **Continue with SSO** and enters their work email. The domain is matched against your verified domains and they're redirected to your identity provider. An address whose domain isn't verified receives a magic link instead.
Users signing in this way are added to your workspace automatically as **members** on first login. Someone who already has an openstatus account with the same email keeps that account, along with any role they already hold — an existing owner stays an owner.
diff --git a/apps/web/src/content/pages/unrelated/security.mdx b/apps/web/src/content/pages/unrelated/security.mdx
index cff073bb2..e1c01148f 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. We never see or store your password.
+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/deno.lock b/deno.lock
index af5e05d4b..212bdcce3 100644
--- a/deno.lock
+++ b/deno.lock
@@ -60,6 +60,14 @@
]
},
"members": {
+ "apps/dashboard": {
+ "packageJson": {
+ "dependencies": [
+ "npm:@jsr/std__expect@^1.0.19",
+ "npm:@jsr/std__testing@^1.0.19"
+ ]
+ }
+ },
"apps/server": {
"packageJson": {
"dependencies": [
@@ -292,6 +300,14 @@
]
}
},
+ "packages/upstash": {
+ "packageJson": {
+ "dependencies": [
+ "npm:@jsr/std__expect@^1.0.19",
+ "npm:@jsr/std__testing@^1.0.19"
+ ]
+ }
+ },
"packages/utils": {
"packageJson": {
"dependencies": [
diff --git a/packages/api/src/router/stripe/trial.test.ts b/packages/api/src/router/stripe/trial.test.ts
index 4bb08c9e6..2864d1296 100644
--- a/packages/api/src/router/stripe/trial.test.ts
+++ b/packages/api/src/router/stripe/trial.test.ts
@@ -162,6 +162,20 @@ describe("maybeStartSignupTrial", () => {
assertSpyCalls(createCustomer, 0);
});
+ test("skips magic-link sign-ins", async () => {
+ const { user } = await freeWorkspace();
+
+ const result = await maybeStartSignupTrial({
+ userId: user.id,
+ email: user.email ?? "",
+ provider: "resend",
+ currency: "USD",
+ });
+
+ expect(result).toEqual({ started: false, reason: "email" });
+ assertSpyCalls(createCustomer, 0);
+ });
+
test("skips users with a pending invitation", async () => {
const { workspace: ws, user } = await freeWorkspace();
await db.insert(invitation).values({
diff --git a/packages/api/src/router/stripe/trial.ts b/packages/api/src/router/stripe/trial.ts
index 427044e34..51cb1ec28 100644
--- a/packages/api/src/router/stripe/trial.ts
+++ b/packages/api/src/router/stripe/trial.ts
@@ -26,6 +26,7 @@ export const TRIAL_DAYS = 14;
export type TrialSkipReason =
| "disabled"
| "sso"
+ | "email"
| "invited"
| "disposable"
| "already_trialed"
@@ -71,6 +72,9 @@ export async function maybeStartSignupTrial(args: {
return { started: false, reason: "disabled" };
}
if (args.provider === "workos") return { started: false, reason: "sso" };
+ // A magic link proves only inbox access, so email signups start on free and
+ // upgrade through checkout.
+ if (args.provider === "resend") return { started: false, reason: "email" };
if (await hasPendingInvitation({ email, db })) {
return { started: false, reason: "invited" };
}
diff --git a/packages/emails/emails/dashboard-magic-link.tsx b/packages/emails/emails/dashboard-magic-link.tsx
new file mode 100644
index 000000000..e1f824ad7
--- /dev/null
+++ b/packages/emails/emails/dashboard-magic-link.tsx
@@ -0,0 +1,42 @@
+/** @jsxRuntime automatic @jsxImportSource react */
+
+import { Text } from "react-email";
+
+import { Actions } from "./_components/actions";
+import { CodeBlock } from "./_components/code-block";
+import { Footer } from "./_components/footer";
+import { Heading } from "./_components/heading";
+import { Layout } from "./_components/layout";
+import { styles } from "./_components/styles";
+
+export interface DashboardMagicLinkProps {
+ link: string;
+}
+
+const DashboardMagicLinkEmail = ({ link }: DashboardMagicLinkProps) => {
+ return (
+
+ }
+ >
+
+ The link below signs you in and is valid for 24 hours. It only works
+ once.
+
+
+
+ If the button doesn’t work, copy this link into your browser:
+
+ {link}
+
+ );
+};
+
+DashboardMagicLinkEmail.PreviewProps = {
+ link: "https://app.openstatus.dev/api/auth/callback/resend?token=token-xyz",
+} satisfies DashboardMagicLinkProps;
+
+export default DashboardMagicLinkEmail;
diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx
index b582f434c..f267bf344 100644
--- a/packages/emails/src/client.tsx
+++ b/packages/emails/src/client.tsx
@@ -5,6 +5,8 @@ import { type Duration, Effect, Schedule } from "effect";
import { render } from "react-email";
import { Resend } from "resend";
+import DashboardMagicLinkEmail from "../emails/dashboard-magic-link";
+import type { DashboardMagicLinkProps } from "../emails/dashboard-magic-link";
import FollowUpEmail from "../emails/followup";
import MonitorAlertEmail, {
monitorAlertSubject,
@@ -385,6 +387,34 @@ export class EmailClient {
}
}
+ /** Throws on a Resend failure so the login form can say the email did not go out. */
+ public async sendDashboardMagicLink(
+ req: DashboardMagicLinkProps & { to: string },
+ ) {
+ if (env.NODE_ENV === "development") {
+ console.log(`Sending dashboard magic link email to ${req.to}`);
+ console.log(`>>> Magic Link: ${req.link}`);
+ return;
+ }
+
+ const html = await render();
+ const result = await this.client.emails.send({
+ from: SYSTEM_FROM,
+ subject: "Sign in to openstatus",
+ to: req.to,
+ html,
+ });
+
+ if (result.error) {
+ console.error(
+ `Error sending dashboard magic link to ${req.to}`,
+ result.error,
+ );
+ throw result.error;
+ }
+ console.log(`Sent dashboard magic link email to ${req.to}`);
+ }
+
public async sendMaintenanceNotification(req: {
subscribers: Array<{ email: string; token: string }>;
pageTitle: string;
diff --git a/packages/emails/src/index.ts b/packages/emails/src/index.ts
index c52579063..6725d158f 100644
--- a/packages/emails/src/index.ts
+++ b/packages/emails/src/index.ts
@@ -9,6 +9,7 @@ export { default as MonitorPausedEmail } from "../emails/monitor-paused";
export { default as MonitorDeactivationEmail } from "../emails/monitor-deactivation";
export { default as PrivateLocationAlertEmail } from "../emails/private-location-alert";
export { default as StatusPageMagicLinkEmail } from "../emails/status-page-magic-link";
+export { default as DashboardMagicLinkEmail } from "../emails/dashboard-magic-link";
export { monitorDeactivationEmail, monitorPausedEmail } from "./render";
export {
diff --git a/packages/emails/src/templates.test.tsx b/packages/emails/src/templates.test.tsx
index 456cdc1a7..5bc2d1597 100644
--- a/packages/emails/src/templates.test.tsx
+++ b/packages/emails/src/templates.test.tsx
@@ -13,6 +13,7 @@ import { renderMarkdown } from "../emails/_components/markdown";
import { Pill } from "../emails/_components/pill";
import { Steps } from "../emails/_components/steps";
import { tones } from "../emails/_components/styles";
+import DashboardMagicLinkEmail from "../emails/dashboard-magic-link";
import IncidentCommanderEmail, {
incidentCommanderSubject,
} from "../emails/incident-commander";
@@ -660,6 +661,17 @@ describe("account and status page mail", () => {
expect(html).toContain("24 hours");
expect(html).toContain('href="https://acme.openstatus.dev/verify/t"');
});
+
+ test("dashboard magic link", async () => {
+ const link = "https://app.openstatus.dev/api/auth/callback/resend?token=t";
+ const html = await render();
+ expect(html).toContain("Sign in to openstatus");
+ expect(html).toContain("24 hours");
+ expect(html.split(`href="${link}"`).length - 1).toBe(1);
+ // raw link rendered as copyable text, not a second anchor
+ expect(html).toContain(`>${link}
`);
+ expect(html).toContain('href="https://www.openstatus.dev"');
+ });
});
describe("every transactional template", () => {
@@ -682,6 +694,7 @@ describe("every transactional template", () => {
invitation:
,
subscription:
,
magicLink:
,
+ dashboardMagicLink:
,
welcome:
,
incidentCommander: (
diff --git a/packages/services/src/attribution.ts b/packages/services/src/attribution.ts
index c9b9dbc2d..95157d289 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/package.json b/packages/upstash/package.json
index 68403d46a..fd768f2e7 100644
--- a/packages/upstash/package.json
+++ b/packages/upstash/package.json
@@ -4,7 +4,8 @@
"license": "MIT",
"main": "./src/index.ts",
"scripts": {
- "check": "deno check --sloppy-imports ."
+ "check": "deno check --sloppy-imports .",
+ "test": "deno test --parallel -A --no-check --sloppy-imports"
},
"dependencies": {
"@upstash/qstash": "catalog:",
@@ -12,6 +13,8 @@
},
"devDependencies": {
"@openstatus/tsconfig": "workspace:*",
+ "@std/expect": "jsr:^1.0.19",
+ "@std/testing": "jsr:^1.0.19",
"@types/node": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:"
diff --git a/packages/upstash/src/index.ts b/packages/upstash/src/index.ts
index 4ecbe4f1f..9b7ec4017 100644
--- a/packages/upstash/src/index.ts
+++ b/packages/upstash/src/index.ts
@@ -1,2 +1,3 @@
export * from "./redis/client";
+export * from "./redis/incr-with-ttl";
export * from "@upstash/redis";
diff --git a/packages/upstash/src/redis/incr-with-ttl.test.ts b/packages/upstash/src/redis/incr-with-ttl.test.ts
new file mode 100644
index 000000000..17befdcd5
--- /dev/null
+++ b/packages/upstash/src/redis/incr-with-ttl.test.ts
@@ -0,0 +1,49 @@
+import { expect } from "@std/expect";
+import { describe, test } from "@std/testing/bdd";
+
+import { incrWithTtl } from "./incr-with-ttl";
+
+function fakeClient(counts: number[]) {
+ const calls: { script: string; keys: string[]; args: unknown[] }[] = [];
+ const client = {
+ eval: (script: string, keys: string[], args: unknown[]) => {
+ calls.push({ script, keys, args });
+ return Promise.resolve(counts);
+ },
+ };
+ return { client: client as never, calls };
+}
+
+describe("incrWithTtl", () => {
+ test("sends every key in one eval with the shared window", async () => {
+ const { client, calls } = fakeClient([3, 1]);
+
+ const counts = await incrWithTtl(client, ["a", "b"], 600);
+
+ expect(counts).toEqual([3, 1]);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.keys).toEqual(["a", "b"]);
+ expect(calls[0]?.args).toEqual([600]);
+ });
+
+ // 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);
+
+ const script = calls[0]?.script ?? "";
+ expect(script).toContain("for i, key in ipairs(KEYS)");
+ expect(script).toContain("redis.call('INCR', key)");
+ expect(script).toContain("if count == 1 then");
+ expect(script).toContain("redis.call('EXPIRE', key, tonumber(ARGV[1]))");
+ });
+
+ test("propagates client failures", async () => {
+ const client = {
+ eval: () => Promise.reject(new Error("redis down")),
+ } as never;
+
+ await expect(incrWithTtl(client, ["a"], 600)).rejects.toThrow("redis down");
+ });
+});
diff --git a/packages/upstash/src/redis/incr-with-ttl.ts b/packages/upstash/src/redis/incr-with-ttl.ts
new file mode 100644
index 000000000..221ed63c2
--- /dev/null
+++ b/packages/upstash/src/redis/incr-with-ttl.ts
@@ -0,0 +1,24 @@
+import type { Redis } from "@upstash/redis";
+
+// INCR + conditional EXPIRE in one round-trip so a crash between the two can't
+// leave a TTL-less key. Every key shares the window.
+const INCR_WITH_TTL = `
+ local counts = {}
+ for i, key in ipairs(KEYS) do
+ local count = redis.call('INCR', key)
+ if count == 1 then
+ redis.call('EXPIRE', key, tonumber(ARGV[1]))
+ end
+ counts[i] = count
+ end
+ return counts
+`;
+
+/** Current count per key after this hit, in `keys` order. */
+export function incrWithTtl(
+ client: Pick
,
+ keys: string[],
+ windowSeconds: number,
+) {
+ return client.eval<[number], number[]>(INCR_WITH_TTL, keys, [windowSeconds]);
+}
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index 1aa6a38f4..dd4161fb6 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 000000000..ee6887d5d
--- /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;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 98948c25e..f370ecea5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -901,6 +901,9 @@ importers:
lucide-react:
specifier: 'catalog:'
version: 0.525.0(react@19.3.0)
+ mailchecker:
+ specifier: 'catalog:'
+ version: 6.0.21
next:
specifier: 'catalog:'
version: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
@@ -912,7 +915,7 @@ importers:
version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
nuqs:
specifier: 'catalog:'
- version: 2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)
+ version: 2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)
random-word-slugs:
specifier: 'catalog:'
version: 0.1.7
@@ -962,6 +965,12 @@ importers:
specifier: 'catalog:'
version: 4.6.5
devDependencies:
+ '@std/expect':
+ specifier: jsr:^1.0.19
+ version: '@jsr/std__expect@1.0.20'
+ '@std/testing':
+ specifier: jsr:^1.0.19
+ version: '@jsr/std__testing@1.0.20'
'@tailwindcss/postcss':
specifier: 'catalog:'
version: 4.3.3
@@ -1404,7 +1413,7 @@ importers:
version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
nuqs:
specifier: 'catalog:'
- version: 2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)
+ version: 2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)
react:
specifier: 'catalog:'
version: 19.3.0
@@ -1669,7 +1678,7 @@ importers:
version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
nuqs:
specifier: 'catalog:'
- version: 2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)
+ version: 2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)
random-word-slugs:
specifier: 'catalog:'
version: 0.1.7
@@ -3230,7 +3239,7 @@ importers:
version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
nuqs:
specifier: 'catalog:'
- version: 2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)
+ version: 2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)
qr-code-styling:
specifier: 'catalog:'
version: 1.9.2
@@ -3299,6 +3308,12 @@ importers:
'@openstatus/tsconfig':
specifier: workspace:*
version: link:../tsconfig
+ '@std/expect':
+ specifier: jsr:^1.0.19
+ version: '@jsr/std__expect@1.0.20'
+ '@std/testing':
+ specifier: jsr:^1.0.19
+ version: '@jsr/std__testing@1.0.20'
'@types/node':
specifier: 'catalog:'
version: 26.6.2
@@ -18792,7 +18807,7 @@ snapshots:
dependencies:
boolbase: 1.0.0
- nuqs@2.10.1(next@16.3.5(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0):
+ nuqs@2.10.1(next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.2)(babel-plugin-macros@3.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0):
dependencies:
'@standard-schema/spec': 1.1.0
react: 19.3.0
--
2.51.2
From 8ded2369d1c2c825fc1e320eb456fc539a3e992f Mon Sep 17 00:00:00 2001
From: Thibault Le Ouay
Date: Mon, 5 Oct 2026 13:39:59 +0200
Subject: [PATCH 04/56] stripe: improve webhook (#2847)
---
packages/api/src/router/stripe/index.ts | 11 ++-
packages/api/src/router/stripe/shared.ts | 16 +++-
packages/api/src/router/stripe/utils.test.ts | 13 +--
packages/api/src/router/stripe/utils.ts | 15 ++--
.../api/src/router/stripe/webhook.test.ts | 79 +++++++++++++++++++
packages/api/src/router/stripe/webhook.ts | 57 ++++++-------
packages/emails/src/billing.tsx | 5 +-
packages/emails/src/send.ts | 7 ++
packages/services/src/workspace/schemas.ts | 5 +-
packages/services/src/workspace/update.ts | 4 +-
10 files changed, 160 insertions(+), 52 deletions(-)
diff --git a/packages/api/src/router/stripe/index.ts b/packages/api/src/router/stripe/index.ts
index 90ac92837..d6d7d4b99 100644
--- a/packages/api/src/router/stripe/index.ts
+++ b/packages/api/src/router/stripe/index.ts
@@ -32,6 +32,7 @@ import {
getCurrentSubscription,
hasPaymentMethod,
stripe,
+ syncedLimits,
trialEndsAtOf,
} from "./shared";
import {
@@ -232,11 +233,9 @@ export const stripeRouter = createTRPCRouter({
});
}
- // Classify before mutating Stripe. An item on a price neither table
- // knows throws, and throwing *after* the update would leave the
- // customer re-priced and billed while the workspace kept the old plan
- // — a split the webhook cannot repair either, since it throws on the
- // same item.
+ // Classify before mutating Stripe: throwing *after* the update would
+ // leave the customer re-priced and billed while the workspace kept the
+ // old plan. Custom-deal prices were already refused above.
buildFromSubscriptionOrThrow(current);
// Every existing item is listed by id, so Stripe re-prices it in place
@@ -275,7 +274,7 @@ export const stripeRouter = createTRPCRouter({
endsAt: getCurrentPeriodEnd(updated),
paidUntil: getCurrentPeriodEnd(updated),
trialEndsAt: trialEndsAtOf(updated),
- limits: built.limits,
+ limits: syncedLimits(updated, built),
reason: "plan_changed",
},
});
diff --git a/packages/api/src/router/stripe/shared.ts b/packages/api/src/router/stripe/shared.ts
index faabc0d18..fe325152e 100644
--- a/packages/api/src/router/stripe/shared.ts
+++ b/packages/api/src/router/stripe/shared.ts
@@ -14,8 +14,8 @@ export const stripe = new Stripe(env.STRIPE_SECRET_KEY || "sk_unset", {
},
});
-// An unsupported price is a permanent misconfiguration; surface it as a 400 so
-// Stripe stops retrying instead of hammering the endpoint on a 5xx.
+// A subscription we cannot classify is a permanent misconfiguration; surface
+// it as a 400 so Stripe stops retrying instead of hammering the endpoint.
export function buildFromSubscriptionOrThrow(
subscription: Stripe.Subscription,
) {
@@ -30,6 +30,18 @@ export function buildFromSubscriptionOrThrow(
}
}
+/** The limits to write, or undefined to keep a custom deal's hand-set ones. */
+export function syncedLimits(
+ subscription: Stripe.Subscription,
+ built: NonNullable>,
+) {
+ if (built.customPriceIds.length === 0) return built.limits;
+ console.warn(
+ `Subscription ${subscription.id} has custom prices (${built.customPriceIds.join(", ")}); keeping workspace limits`,
+ );
+ return undefined;
+}
+
// Statuses that mean the customer still has a subscription. `incomplete` and
// `incomplete_expired` are abandoned checkouts, `unpaid` and `paused` no longer
// entitle anything, and `canceled` is gone.
diff --git a/packages/api/src/router/stripe/utils.test.ts b/packages/api/src/router/stripe/utils.test.ts
index 54be41d5c..50378bae3 100644
--- a/packages/api/src/router/stripe/utils.test.ts
+++ b/packages/api/src/router/stripe/utils.test.ts
@@ -103,12 +103,13 @@ describe("buildLimitsFromSubscription", () => {
expect(built?.limits["status-pages"]).toBe(planDefault + 5);
});
- test("throws on an unsupported price when a plan is present", () => {
- expect(() =>
- buildLimitsFromSubscription(
- subscriptionWith([{ priceId: STARTER }, { priceId: "price_unknown" }]),
- ),
- ).toThrow(/unsupported stripe price/i);
+ test("reports a custom price instead of throwing", () => {
+ const built = buildLimitsFromSubscription(
+ subscriptionWith([{ priceId: STARTER }, { priceId: "price_custom" }]),
+ );
+ expect(built?.plan).toBe("starter");
+ expect(built?.customPriceIds).toEqual(["price_custom"]);
+ expect(built?.limits).toEqual(getLimits("starter"));
});
test("one pack addon unit grants `packSize` limit units", () => {
diff --git a/packages/api/src/router/stripe/utils.ts b/packages/api/src/router/stripe/utils.ts
index b15ad4cca..6abffec0c 100644
--- a/packages/api/src/router/stripe/utils.ts
+++ b/packages/api/src/router/stripe/utils.ts
@@ -22,12 +22,13 @@ type PriceIds = { priceIds: { test: string; production: string } };
* The plan item sets the baseline; each addon item then re-applies its flag or
* quantity on top, so purchased addons survive subscription updates instead of
* being reset to the plan default. Returns null when no plan item is present.
- * Throws on a line item whose price is neither a known plan nor a known addon,
- * so misconfigured prices surface instead of silently drifting from billing.
+ * A line item on a price neither table knows is a custom deal: its id is
+ * returned in `customPriceIds` and left out of `limits`, so callers keep the
+ * hand-set limits instead of overwriting them.
*/
export function buildLimitsFromSubscription(
subscription: Stripe.Subscription,
-): { plan: WorkspacePlan; limits: Limits } | null {
+): { plan: WorkspacePlan; limits: Limits; customPriceIds: string[] } | null {
const detectedPlan = subscription.items.data
.map((item) => getPlanFromPriceId(item.price.id))
.find((plan) => plan !== undefined);
@@ -35,14 +36,14 @@ export function buildLimitsFromSubscription(
if (!detectedPlan) return null;
let limits: Limits = getLimits(detectedPlan.plan);
+ const customPriceIds: string[] = [];
for (const item of subscription.items.data) {
if (getPlanFromPriceId(item.price.id)) continue;
const feature = getFeatureFromPriceId(item.price.id);
if (!feature) {
- throw new Error(
- `Unsupported Stripe price on subscription: ${item.price.id}`,
- );
+ customPriceIds.push(item.price.id);
+ continue;
}
// Accumulate onto the running value so repeated addon items add up; boolean
// addons just flip on. One unit of a pack addon grants `packSize` units.
@@ -54,7 +55,7 @@ export function buildLimitsFromSubscription(
limits = updateAddonInLimits(limits, feature.feature, value);
}
- return { plan: detectedPlan.plan, limits };
+ return { plan: detectedPlan.plan, limits, customPriceIds };
}
/**
diff --git a/packages/api/src/router/stripe/webhook.test.ts b/packages/api/src/router/stripe/webhook.test.ts
index 212f5c058..b928588e6 100644
--- a/packages/api/src/router/stripe/webhook.test.ts
+++ b/packages/api/src/router/stripe/webhook.test.ts
@@ -466,6 +466,46 @@ describe("stripe webhook emails", () => {
expect(keys.slice(0, 2)).toEqual(keys.slice(2, 4));
});
+ test("a custom-deal price syncs the plan but keeps hand-set limits", async () => {
+ const s = await seed();
+ const handSet = JSON.stringify({ monitors: 999 });
+ await db
+ .update(workspace)
+ .set({ limits: handSet })
+ .where(eq(workspace.id, s.workspace.id));
+ const sub = subscription(s.stripeId, {
+ id: "sub_custom_deal",
+ items: {
+ data: [
+ {
+ price: { id: TEAM_PRICE },
+ quantity: 1,
+ current_period_end: now() + 10 * DAY,
+ },
+ {
+ price: { id: "price_custom_deal" },
+ quantity: 1,
+ current_period_end: now() + 10 * DAY,
+ },
+ ],
+ },
+ } as Partial);
+ live = [sub];
+
+ await caller().customerSubscriptionUpdated(
+ event("customer.subscription.updated", sub, { metadata: {} }),
+ );
+
+ const ws = await db
+ .select()
+ .from(workspace)
+ .where(eq(workspace.id, s.workspace.id))
+ .get();
+ expect(ws?.subscriptionId).toBe("sub_custom_deal");
+ expect(ws?.plan).toBe("team");
+ expect(ws?.limits).toBe(handSet);
+ });
+
test("a mail failure does not fail the plan sync", async () => {
const s = await seed();
const sub = subscription(s.stripeId, { cancel_at_period_end: true });
@@ -561,6 +601,45 @@ describe("stripe webhook emails", () => {
assertSpyCalls(send, 0);
});
+ test("a mail failure fails the webhook so Stripe redelivers", async () => {
+ const s = await seed();
+ failSends();
+ const evt = event(
+ "customer.subscription.trial_will_end",
+ subscription(s.stripeId, { status: "trialing", trial_end: trialEnd }),
+ );
+
+ await expect(
+ caller().customerSubscriptionTrialWillEnd(evt),
+ ).rejects.toThrow();
+ });
+
+ test("a Resend error response fails the webhook too", async () => {
+ const s = await seed();
+ send.restore();
+ stubs = stubs.filter((x) => x !== send);
+ send = stub(resend.emails, "send", () =>
+ Promise.resolve({
+ data: null,
+ error: { name: "application_error", message: "boom" },
+ // biome-ignore lint/suspicious/noExplicitAny: Resend result double
+ } as any),
+ );
+ stubs.push(send);
+
+ await expect(
+ caller().customerSubscriptionTrialWillEnd(
+ event(
+ "customer.subscription.trial_will_end",
+ subscription(s.stripeId, {
+ status: "trialing",
+ trial_end: trialEnd,
+ }),
+ ),
+ ),
+ ).rejects.toThrow();
+ });
+
test("no trial_end → nothing sent", async () => {
const s = await seed();
await caller().customerSubscriptionTrialWillEnd(
diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts
index 693775fab..7a3cfc211 100644
--- a/packages/api/src/router/stripe/webhook.ts
+++ b/packages/api/src/router/stripe/webhook.ts
@@ -36,6 +36,7 @@ import {
isNewerSubscription,
listLiveSubscriptions,
stripe,
+ syncedLimits,
trialEndsAtOf,
} from "./shared";
@@ -267,7 +268,7 @@ export const webhookRouter = createTRPCRouter({
endsAt: getCurrentPeriodEnd(current),
paidUntil: getCurrentPeriodEnd(current),
trialEndsAt: trialEndsAtOf(current),
- limits: built.limits,
+ limits: syncedLimits(current, built),
},
});
@@ -427,7 +428,7 @@ export const webhookRouter = createTRPCRouter({
endsAt: getCurrentPeriodEnd(subscription),
paidUntil: getCurrentPeriodEnd(subscription),
trialEndsAt: trialEndsAtOf(subscription),
- limits: built.limits,
+ limits: syncedLimits(subscription, built),
reason: "checkout_session_completed",
},
});
@@ -474,31 +475,33 @@ export const webhookRouter = createTRPCRouter({
});
}
- try {
- const withCard = await hasPaymentMethod(subscription);
- const preview = withCard
- ? undefined
- : await previewWorkspaceDowngrade({
- ctx: {
- workspace: ws,
- actor: { type: "system", job: "stripe-trial-will-end" },
- db: opts.ctx.db,
- },
- });
- await sendTrialEnding({
- to: await getBillingRecipients(opts.ctx.db, ws.id, customerId),
- eventId: opts.input.event.id,
- workspaceSlug: ws.slug,
- trialEnd: new Date(subscription.trial_end * 1000),
- plan: ws.plan ?? "starter",
- hasPaymentMethod: withCard,
- loss: preview
- ? toPlanLoss(preview, preview.customDomains, preview.ssoEnabled)
- : undefined,
- });
- } catch (err) {
- console.error("Failed to send trial ending email:", err);
- }
+ // Stripe fires this once per trial, so a failure must reach Stripe as a
+ // non-2xx to be redelivered; the idempotency key dedupes the retry.
+ const withCard = await hasPaymentMethod(subscription);
+ const preview = withCard
+ ? undefined
+ : await previewWorkspaceDowngrade({
+ ctx: {
+ workspace: ws,
+ actor: { type: "system", job: "stripe-trial-will-end" },
+ db: opts.ctx.db,
+ },
+ }).catch((err) => {
+ // The reminder matters more than the loss lines.
+ console.error("Failed to preview trial downgrade:", err);
+ return undefined;
+ });
+ await sendTrialEnding({
+ to: await getBillingRecipients(opts.ctx.db, ws.id, customerId),
+ eventId: opts.input.event.id,
+ workspaceSlug: ws.slug,
+ trialEnd: new Date(subscription.trial_end * 1000),
+ plan: ws.plan ?? "starter",
+ hasPaymentMethod: withCard,
+ loss: preview
+ ? toPlanLoss(preview, preview.customDomains, preview.ssoEnabled)
+ : undefined,
+ });
}),
customerSubscriptionDeleted: webhookProcedure.mutation(async (opts) => {
const subscription = opts.input.event.data.object as Stripe.Subscription;
diff --git a/packages/emails/src/billing.tsx b/packages/emails/src/billing.tsx
index 037818070..bb0060745 100644
--- a/packages/emails/src/billing.tsx
+++ b/packages/emails/src/billing.tsx
@@ -223,6 +223,9 @@ export async function sendTrialEnding(
subject: trialEndingSubject(props),
react: ,
},
- { idempotencyKey: stripeIdempotencyKey(eventId, "trial-ending") },
+ {
+ idempotencyKey: stripeIdempotencyKey(eventId, "trial-ending"),
+ throwOnError: true,
+ },
);
}
diff --git a/packages/emails/src/send.ts b/packages/emails/src/send.ts
index 265521746..3fb4e790a 100644
--- a/packages/emails/src/send.ts
+++ b/packages/emails/src/send.ts
@@ -31,6 +31,8 @@ export interface SendOptions {
idempotencyKey?: string;
/** ISO 8601. Resend schedules at most 30 days out. */
scheduledAt?: string;
+ /** Throw instead of logging, so a webhook caller fails and gets redelivered. */
+ throwOnError?: boolean;
}
/** Returns the Resend email id, or undefined when nothing was sent. */
@@ -49,6 +51,11 @@ export const sendEmail = async (
);
// Same key, different body: the first send already went out.
if (error && error.name !== "invalid_idempotent_request") {
+ if (opts.throwOnError) {
+ throw new Error(
+ `Error sending email "${email.subject}": ${error.message}`,
+ );
+ }
console.error(`Error sending email "${email.subject}":`, error);
}
return data?.id;
diff --git a/packages/services/src/workspace/schemas.ts b/packages/services/src/workspace/schemas.ts
index ff795cbfc..7a0819d4c 100644
--- a/packages/services/src/workspace/schemas.ts
+++ b/packages/services/src/workspace/schemas.ts
@@ -83,7 +83,8 @@ export type UpdateWorkspaceNameInput = z.infer;
* Set a workspace's billing plan and the columns that move with it
* (subscription id, paid-until / ends-at dates, feature limits). Driven
* by the Stripe webhook — `limits` is the structured object; the verb
- * serialises it to the `text` column. `reason` is stamped into the audit
+ * serialises it to the `text` column. Omitted `limits` leaves the stored
+ * ones untouched (custom deals whose limits are set by hand). `reason` is stamped into the audit
* row's `metadata` so a plan change from an involuntary cancellation is
* distinguishable from a checkout upgrade.
*/
@@ -93,7 +94,7 @@ export const UpdateWorkspacePlanInput = z.object({
paidUntil: z.date().nullable(),
endsAt: z.date().nullable(),
trialEndsAt: z.date().nullable().optional(),
- limits: limitsSchema,
+ limits: limitsSchema.optional(),
reason: z.string().optional(),
});
export type UpdateWorkspacePlanInput = z.infer;
diff --git a/packages/services/src/workspace/update.ts b/packages/services/src/workspace/update.ts
index 8b3492dba..cfd2ca2aa 100644
--- a/packages/services/src/workspace/update.ts
+++ b/packages/services/src/workspace/update.ts
@@ -87,7 +87,9 @@ export async function updateWorkspacePlan(args: {
...(input.trialEndsAt !== undefined && {
trialEndsAt: input.trialEndsAt,
}),
- limits: JSON.stringify(input.limits),
+ ...(input.limits !== undefined && {
+ limits: JSON.stringify(input.limits),
+ }),
updatedAt: new Date(),
})
.where(eq(workspace.id, ctx.workspace.id))
--
2.51.2
From dcfa88b629891b855c961894773dc012c173ce8b Mon Sep 17 00:00:00 2001
From: Thibault Le Ouay
Date: Mon, 5 Oct 2026 15:35:07 +0200
Subject: [PATCH 05/56] stripe: improve email from (#2848)
---
packages/emails/src/billing.test.tsx | 8 ++++++--
packages/emails/src/billing.tsx | 8 ++++----
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/packages/emails/src/billing.test.tsx b/packages/emails/src/billing.test.tsx
index 3601fae5b..7586ea2e1 100644
--- a/packages/emails/src/billing.test.tsx
+++ b/packages/emails/src/billing.test.tsx
@@ -471,6 +471,10 @@ describe("send plumbing", () => {
});
expect(id).toBe("email_123");
const [payload, options] = send.calls[0].args;
+ expect(payload.from).toBe(
+ "openstatus ",
+ );
+ expect(payload.replyTo).toBe("ping@openstatus.dev");
expect(payload.scheduledAt).toBe("2026-09-22T00:00:00.000Z");
expect(options).toEqual({
idempotencyKey: "stripe:evt_2:plan-ending-soon",
@@ -503,9 +507,9 @@ describe("send plumbing", () => {
});
const [payload, options] = send.calls[0].args;
expect(payload.from).toBe(
- "Thibault from openstatus ",
+ "openstatus ",
);
- expect(payload.replyTo).toBe("thibault@openstatus.dev");
+ expect(payload.replyTo).toBe("ping@openstatus.dev");
expect(payload.subject).toBe(
"Your trial ends on 25 Sep — add a payment method to keep starter",
);
diff --git a/packages/emails/src/billing.tsx b/packages/emails/src/billing.tsx
index bb0060745..f4cc46a98 100644
--- a/packages/emails/src/billing.tsx
+++ b/packages/emails/src/billing.tsx
@@ -197,8 +197,8 @@ export async function schedulePlanEndingSoon(
if (!scheduledAt) return;
return sendEmail(
{
- from: SIGNED_FROM,
- reply_to: SIGNED_REPLY_TO,
+ from: SYSTEM_FROM,
+ reply_to: SUPPORT_EMAIL,
to,
subject: planEndingSoonSubject(props),
react: ,
@@ -217,8 +217,8 @@ export async function sendTrialEnding(
const { to, eventId, ...props } = req;
return sendEmail(
{
- from: SIGNED_FROM,
- reply_to: SIGNED_REPLY_TO,
+ from: SYSTEM_FROM,
+ reply_to: SUPPORT_EMAIL,
to,
subject: trialEndingSubject(props),
react: ,
--
2.51.2
From 3aa61e7cf25c8fda8579c581ef59e3e1a95bae55 Mon Sep 17 00:00:00 2001
From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com>
Date: Mon, 5 Oct 2026 18:50:41 +0200
Subject: [PATCH 06/56] chore: dashboard mobile form layout improvements
(#2845)
* chore: dashboard mobile form layout improvements
* fix:
---
.../overview/create-event-button-group.tsx | 5 +-
.../app/(dashboard)/settings/account/page.tsx | 6 +-
.../[id]/maintenances/(list)/page.tsx | 2 -
.../[id]/status-reports/(list)/page.tsx | 3 +-
.../forms/components/form-components.tsx | 58 +++++++++----------
.../forms/components/form-import.tsx | 16 ++---
.../components/forms/monitor/form-general.tsx | 6 +-
.../components/forms/monitor/form-otel.tsx | 4 +-
.../forms/monitor/form-response-time.tsx | 2 +-
.../components/forms/monitor/form-retry.tsx | 4 +-
.../components/forms/monitor/form-tags.tsx | 4 +-
.../forms/settings/form-members.tsx | 4 +-
.../forms/settings/form-workspace.tsx | 4 +-
.../forms/status-page/form-configuration.tsx | 6 +-
.../forms/status-page/form-custom-domain.tsx | 4 +-
.../forms/status-page/form-general.tsx | 10 ++--
.../forms/status-page/form-links.tsx | 4 +-
.../forms/status-page/form-locale.tsx | 6 +-
.../forms/status-page/form-page-access.tsx | 12 ++--
.../status-reports/status-report-composer.tsx | 31 ++++++----
20 files changed, 94 insertions(+), 97 deletions(-)
diff --git a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx
index 90e63c7b7..8481710d3 100644
--- a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx
+++ b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ChevronDown, Add } from "@openstatus/icons";
+import { ChevronDown } from "@openstatus/icons";
import { Button } from "@openstatus/ui/components/ui/button";
import { ButtonGroup } from "@openstatus/ui/components/ui/button-group";
import {
@@ -26,7 +26,6 @@ export function CreateEventButtonGroup() {
@@ -43,12 +42,10 @@ export function CreateEventButtonGroup() {
setMaintenanceOpen(true)}>
-
Create Maintenance
{incidentsEnabled ? (
setIncidentOpen(true)}>
-
Declare Incident
) : null}
diff --git a/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx
index f9466526d..d56b7b71d 100644
--- a/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx
+++ b/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx
@@ -60,12 +60,12 @@ export default function Page() {
-