From bb28a8f445adf9b7669b1f858043491e4f3df0ef Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 1 Nov 2026 16:13:34 -0500 Subject: [PATCH 1/2] feat(nextjs): requireAuth and requireRole guards, trustedProxies client IP Add requireAuth(handler, options) and requireRole(roles, handler, options) for App Router route handlers. They take the access cookie or a bearer access token, verify the token against the auth API's JWKS, refuse recovery sessions unless allowed, apply the origin check to cookie requests and never refresh. Refusals use the codes the Go, Python and Rust adapters answer with (unauthenticated, enrollment_required, cross_site_request_blocked, insufficient_role) and never echo roles. The user carries organizationId from the verified token's org_id, which core's authenticateBearer and authenticateRequest now set. Add a trustedProxies option that walks X-Forwarded-For from the right past the listed addresses or ranges, used when resolveClientIp is not given. There is no hop count option: a count cannot tell a proxy from a client. With neither set nothing is forwarded, and the handler logs one startup warning. getSeamlessSession resolves and forwards the client IP from the request headers too. Refs #262 --- .changeset/nextjs-route-guards.md | 10 + packages/core/src/guards.ts | 17 +- .../core/tests/authenticateBearer.test.js | 41 +++ packages/nextjs/README.md | 127 ++++++++- packages/nextjs/src/guards.ts | 211 +++++++++++++++ packages/nextjs/src/handler.ts | 13 +- packages/nextjs/src/index.ts | 7 + packages/nextjs/src/internal/clientIp.ts | 123 +++++++++ packages/nextjs/src/internal/context.ts | 12 - packages/nextjs/src/options.ts | 14 +- packages/nextjs/src/routes/adminRoutes.ts | 3 +- packages/nextjs/src/routes/authRoutes.ts | 3 +- packages/nextjs/src/session.ts | 34 ++- packages/nextjs/tests/clientIpWarning.test.js | 31 +++ packages/nextjs/tests/handler.test.js | 57 ++++ packages/nextjs/tests/parity.test.js | 244 +++++++++++++++++- packages/nextjs/tests/session.test.js | 47 ++++ packages/nextjs/tests/types/consumer.ts | 24 ++ 18 files changed, 980 insertions(+), 38 deletions(-) create mode 100644 .changeset/nextjs-route-guards.md create mode 100644 packages/nextjs/src/guards.ts create mode 100644 packages/nextjs/src/internal/clientIp.ts create mode 100644 packages/nextjs/tests/clientIpWarning.test.js diff --git a/.changeset/nextjs-route-guards.md b/.changeset/nextjs-route-guards.md new file mode 100644 index 0000000..679aad6 --- /dev/null +++ b/.changeset/nextjs-route-guards.md @@ -0,0 +1,10 @@ +--- +"@seamless-auth/nextjs": minor +"@seamless-auth/core": minor +--- + +Next.js: add `requireAuth(handler, options)` and `requireRole(roles, handler, options)` to guard App Router route handlers. They accept the access cookie or the auth API's access token in `Authorization: Bearer`, verify the token against the auth API's key set (so roles come from a token the API signed), refuse recovery sessions unless `allowRecoverySession` is set, apply the origin check to cookie requests, and never refresh. Refusals answer `401 {"error":"unauthenticated"}`, `403 {"error":"enrollment_required"}`, `403 {"error":"cross_site_request_blocked"}` or `403 {"error":"insufficient_role"}`, without naming any roles. The user handed to the route carries `organizationId` from the token's `org_id`. A role guard with no roles throws at setup. + +Next.js: add a `trustedProxies` option (addresses or CIDR ranges) that reads the client IP from `X-Forwarded-For`, walking from the right past your proxies, when `resolveClientIp` is not given. Nothing is forwarded when neither is set, as before, but the handler now logs one warning at startup saying so. `getSeamlessSession` takes the request `headers` with `trustedProxies` or `resolveClientIp` and forwards the client IP, and reads the user agent from them when `userAgent` is not given. + +Core: `authenticateBearer` and `authenticateRequest` (with bearer verification on) set `organizationId` on the user from the verified access token's `org_id`. The new `GuardUser` type describes it. diff --git a/packages/core/src/guards.ts b/packages/core/src/guards.ts index 13a7638..40f3e35 100644 --- a/packages/core/src/guards.ts +++ b/packages/core/src/guards.ts @@ -153,8 +153,21 @@ function isRecoveryToken(token: unknown): boolean { } } +/** + * A session as a guard hands it on. `organizationId` is the verified access + * token's `org_id`, so it is only set on a path that checked that token against + * the auth API's key set (bearer, or a cookie with bearer verification on). + */ +export type GuardUser = SeamlessAuthUser & { organizationId?: string }; + +function organizationOf(claims: { org_id?: unknown }) { + return typeof claims.org_id === "string" && claims.org_id + ? { organizationId: claims.org_id } + : {}; +} + export type CookieAuthResult = - | { user: SeamlessAuthUser; rejection?: undefined } + | { user: GuardUser; rejection?: undefined } | { user?: undefined; rejection: GuardRejection }; /** @@ -293,6 +306,7 @@ export async function authenticateBearer( iat: claims.iat, exp: claims.exp, token, + ...organizationOf(claims), }, }; } @@ -375,6 +389,7 @@ async function verifyCookieAccessToken( user: { ...user, roles: Array.isArray(claims.roles) ? claims.roles : user.roles, + ...organizationOf(claims), }, }; } diff --git a/packages/core/tests/authenticateBearer.test.js b/packages/core/tests/authenticateBearer.test.js index 1b28548..833959e 100644 --- a/packages/core/tests/authenticateBearer.test.js +++ b/packages/core/tests/authenticateBearer.test.js @@ -282,6 +282,47 @@ describe("authenticateRequest", () => { expect(result.rejection.warn).toMatch(/bearer/i); }); + it("carries the verified org_id as organizationId on both credentials", async () => { + const server = nextServer(); + mockJwks(server); + const token = await accessToken(server, { org_id: "org-7" }); + const bearer = { authServerUrl: server, audience: server }; + + const viaBearer = await authenticateRequest({ + token: undefined, + cookieSecret: COOKIE_SECRET, + authorization: `Bearer ${token}`, + bearer, + }); + const viaCookie = await authenticateRequest({ + token: cookie({ sub: "user-123", org_id: "forged", token }), + cookieSecret: COOKIE_SECRET, + bearer, + }); + + expect(viaBearer.user.organizationId).toBe("org-7"); + expect(viaCookie.user.organizationId).toBe("org-7"); + }); + + it("leaves organizationId unset without a verified org_id", async () => { + const server = nextServer(); + mockJwks(server); + + const verified = await authenticateRequest({ + token: undefined, + cookieSecret: COOKIE_SECRET, + authorization: `Bearer ${await accessToken(server)}`, + bearer: { authServerUrl: server, audience: server }, + }); + const unverified = await authenticateRequest({ + token: cookie({ sub: "user-1", org_id: "org-7", token: INNER }), + cookieSecret: COOKIE_SECRET, + }); + + expect(verified.user).not.toHaveProperty("organizationId"); + expect(unverified.user).not.toHaveProperty("organizationId"); + }); + it("still enforces cookie secret strength on the bearer path", async () => { const server = nextServer(); diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 9b988ef..768c487 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -6,7 +6,7 @@ passwordless authentication. It serves the Seamless Auth routes from a route handler in your Next.js application and manages the session cookies they depend on, so the browser talks to your origin and never holds a token itself. It also reads the session -in server components and `proxy.ts`. The decisions all live in +in server components and `proxy.ts`, and guards your own route handlers. The decisions all live in `@seamless-auth/core`; this package binds them to web-standard `Request` and `Response`. @@ -69,7 +69,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo cookieSecret: process.env.COOKIE_SECRET!, serviceSecret: process.env.SEAMLESS_SERVICE_TOKEN!, jwksKid: process.env.JWKS_KID, - userAgent: (await headers()).get("user-agent") ?? undefined, + headers: await headers(), + trustedProxies: ["10.0.0.0/8"], }); return ( @@ -87,7 +88,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo `getSeamlessSession` verifies the access cookie locally, asks the auth API for `/users/me` with the token inside it, and returns the result in the shape `AuthProvider` takes as `initialSession`. The first paint then renders the -signed-in user instead of a loading state. +signed-in user instead of a loading state. Given the request `headers`, it +forwards the browser's user agent and, with `trustedProxies` or +`resolveClientIp`, its address, the same way the route handler does. It never refreshes. When the access cookie has expired it resolves to `null`, even if the refresh cookie is still valid. A refresh here would rotate the @@ -109,6 +112,78 @@ soon as `POST /webAuthn/register/finish` or `POST /totp/enroll/verify` succeeds recovery session, so the new access cookie drops the claim at once. If that refresh fails, the next refresh drops it instead. +## Protect your own route handlers + +`requireAuth` and `requireRole` wrap an App Router route handler so it only runs +for a signed-in user. They accept the access cookie the auth routes set, or the +auth API's access token in `Authorization: Bearer`, which is how a native client +calls. The cookie wins when both are present. + +```ts +// lib/seamless.ts +export const seamless = { + authServerUrl: process.env.AUTH_SERVER_URL!, + audience: process.env.AUTH_SERVER_URL!, + cookieSecret: process.env.COOKIE_SECRET!, + serviceSecret: process.env.SEAMLESS_SERVICE_TOKEN!, + jwksKid: process.env.JWKS_KID, +}; +``` + +```ts +// app/api/me/route.ts +import { requireAuth } from "@seamless-auth/nextjs"; +import { seamless } from "@/lib/seamless"; + +export const GET = requireAuth( + async (_request, _context, user) => + Response.json({ id: user.id, organizationId: user.organizationId }), + seamless, +); +``` + +```ts +// app/api/reports/[id]/route.ts +import { requireRole } from "@seamless-auth/nextjs"; +import { seamless } from "@/lib/seamless"; + +export const DELETE = requireRole( + ["admin", "reports:write"], + async (_request, { params }: { params: Promise<{ id: string }> }, user) => + Response.json(await deleteReport((await params).id, user.id)), + seamless, +); +``` + +The handler gets the request, the route context and the user. The access token +inside a cookie is verified against the auth API's key set as well as the +cookie's own signature, so `roles` and `organizationId` (the token's `org_id`) +always come from a token the auth API signed, for a cookie session as much as a +bearer one. `requireRole` passes when any one of the roles is granted, with the +same scoped matching as `hasScopedRole`: a broad `admin` grants `admin:read`, and +a `:write` role grants the matching `:read`. A role guard with no roles throws when +the module loads. + +| Refusal | Status | Body | +| --- | --- | --- | +| No session, an invalid or expired one, or a sign-in flow's ephemeral token | 401 | `{"error":"unauthenticated"}` | +| A recovery session, unless `allowRecoverySession: true` | 403 | `{"error":"enrollment_required"}` | +| A cookie request the origin check refuses (see `allowedOrigins`) | 403 | `{"error":"cross_site_request_blocked"}` | +| None of the roles granted | 403 | `{"error":"insufficient_role"}` | + +The body never names the user's roles or the required ones. These are the codes +the Go, Python and Rust adapters answer with. + +The guards never refresh. An expired access cookie is a `401`, and the client +renews the session through the auth routes, as with the Express and Fastify +guards. Refreshing here would rotate the refresh token in a response the +client's other requests never see. + +They take `authServerUrl`, `audience`, `cookieSecret`, and optionally +`authServerIssuer`, `accessCookieName`, `cookieSecure`, `cookieSameSite`, +`allowedOrigins` and `allowRecoverySession`: a subset of the route handler's +options, so one object serves both. + ## Protect routes in `proxy.ts` ```ts @@ -267,27 +342,54 @@ adapters, plus `basePath`. | `preAuthCookieName` | `seamless-ephemeral` | Login-initiation cookie name | | `messaging` | none | Adopter-supplied delivery transports and overrides | | `resolveClientIp` | none | Picks the end user's IP to forward | +| `trustedProxies` | none | Addresses or CIDR ranges of your proxies; the client IP is read from `X-Forwarded-For` past them | The session helpers take `authServerUrl`, `cookieSecret`, and optionally -`serviceSecret`, `jwksKid`, the cookie names, and `userAgent`. They also accept +`serviceSecret`, `jwksKid`, the cookie names, `userAgent`, `headers`, +`trustedProxies` and `resolveClientIp`. They also accept `authServerIssuer`, so one options object can serve the handler and the helpers; the helpers verify only the adapter's own cookies, so it has no effect there. ### Client IP -A route handler has no trustworthy source for the client address: any caller -can set `X-Forwarded-For`. So nothing is forwarded unless you pass -`resolveClientIp`, reading the header your platform sets and a client cannot: +The auth API rate limits and audits by client address, so the adapter forwards +one as `x-seamless-client-ip`. A route handler never sees the connecting peer, +and any caller can set `X-Forwarded-For`, so the address has to come from +something you trust. There are two ways to say what that is. + +`trustedProxies` lists the addresses or CIDR ranges of the proxies in front of +the app. `X-Forwarded-For` is read from the right, skipping those, and the first +other hop is the client. A hop a client wrote itself sits to the left of the one +your proxy appended, so it is never picked. ```ts createSeamlessAuthHandler({ // ... - resolveClientIp: (request) => request.headers.get("x-real-ip") ?? undefined, + trustedProxies: ["10.0.0.0/8", "fd00::/8"], }); ``` -A value that is not an IP address is dropped. The browser's `User-Agent` is -always forwarded, capped at 512 characters: it is self-reported either way. +There is deliberately no hop count option: a count cannot tell a proxy from a +client that sent its own header. + +`resolveClientIp` reads the address yourself, for a platform that sets a header +of its own and strips any copy a client sent. It takes precedence over +`trustedProxies`. + +```ts +// Vercel +resolveClientIp: (request) => request.headers.get("x-real-ip") ?? undefined, + +// Cloudflare +resolveClientIp: (request) => + request.headers.get("cf-connecting-ip") ?? undefined, +``` + +With neither set, no address is forwarded and the handler logs one warning at +startup saying so. A value that is not an IP address is dropped, and an +IPv4-mapped IPv6 address is sent as IPv4. A `trustedProxies` entry that is not an +address or range throws when the handler is created. The browser's `User-Agent` +is always forwarded, capped at 512 characters: it is self-reported either way. ## Relationship to the other adapters @@ -302,6 +404,11 @@ Two differences follow from having no framework underneath: `application/json`, strict, 100kb maximum - an unknown route answers `404 { "error": "not_found" }` as JSON +`requireAuth` and `requireRole` are run against the Express guards of the same +names: status, the user handed to the route, and no refresh all match. Their +error bodies are the codes in the table above, which Express and Fastify do not +use yet. + The console proxy runs the Fastify parity scenarios against `createSeamlessConsoleProxy` from `@seamless-auth/express`. A write is refused by Next.js itself, since the route exports only `GET` and `HEAD`. diff --git a/packages/nextjs/src/guards.ts b/packages/nextjs/src/guards.ts new file mode 100644 index 0000000..f374d42 --- /dev/null +++ b/packages/nextjs/src/guards.ts @@ -0,0 +1,211 @@ +import { + assertSecretStrength, + authenticateRequest, + authorizeRoles, + checkOrigin, + type GuardRejection, + type GuardUser, +} from "@seamless-auth/core"; + +import { readCookies } from "./internal/context.js"; +import type { SeamlessAuthHandlerOptions } from "./options.js"; + +/** + * What the guards need. A subset of the route handler options, so the same + * object can be passed to both. + */ +export type SeamlessGuardOptions = Pick< + SeamlessAuthHandlerOptions, + | "authServerUrl" + | "audience" + | "authServerIssuer" + | "cookieSecret" + | "accessCookieName" + | "cookieSecure" + | "cookieSameSite" + | "allowedOrigins" +> & { + /** + * Accept a recovery session (`recovery: true` on the access token), which can + * only enroll a new passkey or authenticator app. Off by default, so such a + * session gets 403 `enrollment_required`; turn it on only for the routes that + * drive that enrollment. + */ + allowRecoverySession?: boolean; +}; + +/** + * The signed-in user a guard hands its route handler. Roles and + * `organizationId` come from the access token as verified against the auth + * API's key set, for a cookie session as much as a bearer one. + */ +export type SeamlessGuardUser = GuardUser; + +export type SeamlessGuardedHandler = ( + request: Request, + context: Context, + user: SeamlessGuardUser, +) => Response | Promise; + +export type SeamlessGuardedRoute = ( + request: Request, + context: Context, +) => Promise; + +const LOG_PREFIX = "[SEAMLESS-AUTH-NEXTJS]"; + +function refuse(status: number, error: string): Response { + return Response.json({ error }, { status }); +} + +// The core rejections carry the Express wording; these guards answer with the +// codes the other adapters use. +function toResponse(rejection: GuardRejection): Response { + if (rejection.status === 401) { + return refuse(401, "unauthenticated"); + } + + return refuse(403, rejection.errorCode); +} + +function prepare(name: string, opts: SeamlessGuardOptions) { + assertSecretStrength(`${name}: cookieSecret`, opts.cookieSecret, { + allowPlaceholder: true, + }); + + if (!opts.authServerUrl || !opts.audience) { + throw new Error( + `${name}: authServerUrl and audience are required to verify access tokens`, + ); + } + + const cookieName = opts.accessCookieName ?? "seamless-access"; + const bearer = { + authServerUrl: opts.authServerUrl, + audience: opts.audience, + authServerIssuer: opts.authServerIssuer, + }; + + return async function authenticate( + request: Request, + ): Promise<{ user: SeamlessGuardUser } | { response: Response }> { + const token = readCookies(request)[cookieName]; + + if (token) { + const blocked = checkOrigin({ + method: request.method, + secFetchSite: request.headers.get("sec-fetch-site") ?? undefined, + origin: request.headers.get("origin") ?? undefined, + cookieSecure: opts.cookieSecure, + cookieSameSite: opts.cookieSameSite, + allowedOrigins: opts.allowedOrigins, + }); + + if (blocked) { + return { response: refuse(blocked.status, blocked.errorCode) }; + } + } + + const { user, rejection } = await authenticateRequest({ + token, + cookieSecret: opts.cookieSecret, + authorization: request.headers.get("authorization") ?? undefined, + bearer, + allowRecoverySession: opts.allowRecoverySession, + }); + + if (rejection) { + if (rejection.warn) { + console.warn(`${LOG_PREFIX} - (${name}) - ${rejection.warn}`); + } + + return { response: toResponse(rejection) }; + } + + return { user }; + }; +} + +/** + * Wraps an App Router route handler so it only runs for a signed-in user: the + * access cookie this adapter set, or the auth API's access token in + * `Authorization: Bearer` (how a native client calls). The cookie wins when + * both are present. + * + * Answers 401 `unauthenticated` for a missing, invalid or pre-auth credential, + * 403 `enrollment_required` for a recovery session, and 403 + * `cross_site_request_blocked` for a cookie request the origin check refuses. + * + * It never refreshes. An expired access cookie is a 401, and the client renews + * the session through the auth routes, as it does for the Express and Fastify + * guards. + * + * ### Example + * ```ts + * // app/api/me/route.ts + * export const GET = requireAuth( + * async (_request, _context, user) => Response.json({ id: user.id }), + * seamlessOptions, + * ); + * ``` + */ +export function requireAuth( + handler: SeamlessGuardedHandler, + opts: SeamlessGuardOptions, +): SeamlessGuardedRoute { + const authenticate = prepare("requireAuth", opts); + + return async (request, context) => { + const result = await authenticate(request); + return "response" in result + ? result.response + : handler(request, context, result.user); + }; +} + +/** + * `requireAuth`, plus a role check. Any one of `roles` is enough, with scoped + * role matching: a broad `admin` grants `admin:...`, and a `:write` role grants + * the matching `:read`. A user with none of them gets 403 `insufficient_role`; + * the body never names the roles. + * + * ### Example + * ```ts + * // app/api/admin/route.ts + * export const POST = requireRole( + * ["admin", "support:write"], + * async (request, _context, user) => Response.json(await act(request, user)), + * seamlessOptions, + * ); + * ``` + */ +export function requireRole( + roles: string | string[], + handler: SeamlessGuardedHandler, + opts: SeamlessGuardOptions, +): SeamlessGuardedRoute { + const required = Array.isArray(roles) ? roles : [roles]; + + if ( + required.length === 0 || + required.some((role) => typeof role !== "string" || !role.trim()) + ) { + throw new Error("requireRole: give at least one non-empty role"); + } + + const authenticate = prepare("requireRole", opts); + + return async (request, context) => { + const result = await authenticate(request); + + if ("response" in result) { + return result.response; + } + + if (authorizeRoles(result.user, required)) { + return refuse(403, "insufficient_role"); + } + + return handler(request, context, result.user); + }; +} diff --git a/packages/nextjs/src/handler.ts b/packages/nextjs/src/handler.ts index 40761ba..b8cf261 100644 --- a/packages/nextjs/src/handler.ts +++ b/packages/nextjs/src/handler.ts @@ -26,9 +26,12 @@ import { buildProxyServiceAuthorization, buildServiceAuthorization, } from "./internal/buildAuthorization.js"; +import { + createClientIpResolver, + warnOnNoClientIp, +} from "./internal/clientIp.js"; import { BodyError, - forwardedClientIp, forwardedUserAgent, mountRelativePath, readBody, @@ -68,6 +71,7 @@ export function resolveOptions( // initiation never hold an ephemeral cookie at the same time. preAuthCookieName: opts.preAuthCookieName ?? "seamless-ephemeral", basePath: opts.basePath ?? "/auth", + clientIpOf: createClientIpResolver(opts), }; } @@ -101,7 +105,7 @@ const PASSTHROUGH_ROUTES: Route[] = PROXY_ROUTES.map((definition) => ({ method: definition.method, authorization: buildServiceAuthorization(ctx), serviceAuthorization: buildProxyServiceAuthorization(opts), - forwardedClientIp: forwardedClientIp(ctx.request, opts.resolveClientIp), + forwardedClientIp: opts.clientIpOf(ctx.request), forwardedUserAgent: forwardedUserAgent(ctx.request), query: ctx.query, body: ctx.body, @@ -152,6 +156,7 @@ export function createSeamlessAuthHandler( ): SeamlessAuthRouteHandlers { assertSecrets(options); warnOnDevJwksKid(options.jwksKid); + warnOnNoClientIp(options); const opts = resolveOptions(options); const manifestSource = createAdapterManifestSource({ @@ -264,7 +269,7 @@ async function dispatch( accessTokenAudience: opts.audience, authServerIssuer: opts.authServerIssuer, keyId: opts.jwksKid, - forwardedClientIp: forwardedClientIp(request, opts.resolveClientIp), + forwardedClientIp: opts.clientIpOf(request), forwardedUserAgent: forwardedUserAgent(request), }; @@ -349,7 +354,7 @@ function resolveTarget( authorization: request.headers.get("authorization") ?? undefined, cookiePayload: ctx.cookiePayload, cookies: ctx.cookies, - forwardedClientIp: forwardedClientIp(request, opts.resolveClientIp), + forwardedClientIp: opts.clientIpOf(request), forwardedUserAgent: forwardedUserAgent(request), }, { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 24aef51..beca27f 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -5,6 +5,13 @@ export type { SeamlessConsoleProxyHandlers, SeamlessConsoleProxyOptions, } from "./consoleProxy.js"; +export { requireAuth, requireRole } from "./guards.js"; +export type { + SeamlessGuardedHandler, + SeamlessGuardedRoute, + SeamlessGuardOptions, + SeamlessGuardUser, +} from "./guards.js"; export { getSeamlessClaims, getSeamlessSession, diff --git a/packages/nextjs/src/internal/clientIp.ts b/packages/nextjs/src/internal/clientIp.ts new file mode 100644 index 0000000..ba9efb7 --- /dev/null +++ b/packages/nextjs/src/internal/clientIp.ts @@ -0,0 +1,123 @@ +import { BlockList, isIP } from "node:net"; + +import type { ClientIpResolver } from "../options.js"; + +export interface ClientIpOptions { + resolveClientIp?: ClientIpResolver; + trustedProxies?: string[]; +} + +const MAPPED_IPV4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i; + +function unmap(address: string): string { + return MAPPED_IPV4.exec(address)?.[1] ?? address; +} + +function family(address: string): "ipv4" | "ipv6" | undefined { + const version = isIP(address); + return version === 4 ? "ipv4" : version === 6 ? "ipv6" : undefined; +} + +/** + * Builds the matcher for `trustedProxies`. A bad entry throws, so a typo fails + * at setup instead of silently trusting less than the deployer meant. + */ +export function compileTrustedProxies( + proxies: string[], +): (address: string) => boolean { + const list = new BlockList(); + + for (const entry of proxies) { + const [rawAddress, prefix, ...rest] = String(entry).trim().split("/"); + const address = unmap(rawAddress ?? ""); + const kind = family(address); + const bits = kind === "ipv4" ? 32 : 128; + const length = prefix === undefined ? bits : Number(prefix); + + if ( + !kind || + rest.length > 0 || + (prefix !== undefined && !/^\d{1,3}$/.test(prefix)) || + length > bits + ) { + throw new Error( + `trustedProxies: "${entry}" is not an IP address or CIDR range`, + ); + } + + list.addSubnet(address, length, kind); + } + + return (candidate) => { + const address = unmap(candidate); + const kind = family(address); + return kind !== undefined && list.check(address, kind); + }; +} + +/** + * Walks `X-Forwarded-For` from the right, skipping trusted proxies, and returns + * the first hop that is not one. A route handler never sees the connecting + * peer, so the walk starts at the last hop a trusted proxy appended. There is + * deliberately no hop count: it cannot tell a proxy from a client that sent its + * own header. + */ +export function walkForwardedFor( + header: string | null, + trusted: (address: string) => boolean, +): string | undefined { + const hops = (header ?? "") + .split(",") + .map((hop) => hop.trim()) + .filter(Boolean); + + for (let i = hops.length - 1; i >= 0; i -= 1) { + if (!trusted(hops[i] as string)) { + return hops[i]; + } + } + + return hops[0]; +} + +/** + * The client address to forward, from `resolveClientIp` when given, otherwise + * from `trustedProxies`. Neither set forwards none. + */ +export function createClientIpResolver( + opts: ClientIpOptions, +): (request: Request) => string | undefined { + const { resolveClientIp, trustedProxies } = opts; + const trusted = + !resolveClientIp && trustedProxies?.length + ? compileTrustedProxies(trustedProxies) + : undefined; + + return (request) => { + const candidate = resolveClientIp + ? resolveClientIp(request) + : trusted + ? walkForwardedFor(request.headers.get("x-forwarded-for"), trusted) + : undefined; + + if (!candidate) { + return undefined; + } + + const address = unmap(candidate.trim()); + return isIP(address) !== 0 ? address : undefined; + }; +} + +let warnedNoClientIp = false; + +export function warnOnNoClientIp(opts: ClientIpOptions): void { + if (opts.resolveClientIp || opts.trustedProxies?.length || warnedNoClientIp) { + return; + } + + warnedNoClientIp = true; + console.warn( + "[SEAMLESS-AUTH-NEXTJS] - Neither resolveClientIp nor trustedProxies is set, so no client IP is forwarded to the auth API and its rate limits and audit log see none. Set trustedProxies to your proxy addresses, or resolveClientIp to read the header your platform sets.", + ); +} diff --git a/packages/nextjs/src/internal/context.ts b/packages/nextjs/src/internal/context.ts index e114561..cfb8b50 100644 --- a/packages/nextjs/src/internal/context.ts +++ b/packages/nextjs/src/internal/context.ts @@ -1,5 +1,3 @@ -import { isIP } from "node:net"; - import { parse as parseCookieHeader } from "cookie"; import { AUTH_TRANSPORT_HEADER, @@ -7,8 +5,6 @@ import { type AuthTransport, } from "@seamless-auth/core"; -import type { ClientIpResolver } from "../options.js"; - /** * What a route reads from a request. The Express and Fastify adapters get this * from their framework; a route handler only has a `Request`, so it is built @@ -133,11 +129,3 @@ export function forwardedUserAgent(request: Request): string | undefined { .slice(0, MAX_USER_AGENT_LENGTH) || undefined ); } - -export function forwardedClientIp( - request: Request, - resolveClientIp?: ClientIpResolver, -): string | undefined { - const candidate = resolveClientIp?.(request); - return candidate && isIP(candidate) !== 0 ? candidate : undefined; -} diff --git a/packages/nextjs/src/options.ts b/packages/nextjs/src/options.ts index 7825dd7..d00d087 100644 --- a/packages/nextjs/src/options.ts +++ b/packages/nextjs/src/options.ts @@ -41,11 +41,18 @@ export type SeamlessAuthHandlerOptions = { messaging?: SeamlessAuthMessagingOptions; /** * A route handler has no trusted source for the client address: any caller - * can set `X-Forwarded-For`. Nothing is forwarded unless this is given, so - * read the header your platform sets and a client cannot (for example - * `x-real-ip` on Vercel). + * can set `X-Forwarded-For`. Use this to read the header your platform sets + * and a client cannot (for example `x-real-ip` on Vercel). Takes precedence + * over `trustedProxies`. With neither set, nothing is forwarded. */ resolveClientIp?: ClientIpResolver; + /** + * Addresses or CIDR ranges of the proxies in front of this app, such as + * `["10.0.0.0/8"]`. `X-Forwarded-For` is read from the right, skipping these, + * and the first other hop is forwarded as the client address. Used when + * `resolveClientIp` is not given. + */ + trustedProxies?: string[]; /** * Fetch the adapter manifest from the auth API (the default). Routes with no * handler of their own here are proxied as it describes, so a new API route @@ -68,4 +75,5 @@ export type ResolvedOptions = SeamlessAuthHandlerOptions & { refreshCookieName: string; preAuthCookieName: string; basePath: string; + clientIpOf: (request: Request) => string | undefined; }; diff --git a/packages/nextjs/src/routes/adminRoutes.ts b/packages/nextjs/src/routes/adminRoutes.ts index 492e734..b5fac19 100644 --- a/packages/nextjs/src/routes/adminRoutes.ts +++ b/packages/nextjs/src/routes/adminRoutes.ts @@ -34,7 +34,6 @@ import { buildServiceAuthorization, } from "../internal/buildAuthorization.js"; import { - forwardedClientIp, forwardedUserAgent, type AuthContext, } from "../internal/context.js"; @@ -209,7 +208,7 @@ export const ADMIN_ROUTES: Route[] = TABLE.map(([method, path, call]) => ({ authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(ctx), serviceAuthorization: buildProxyServiceAuthorization(opts), - forwardedClientIp: forwardedClientIp(ctx.request, opts.resolveClientIp), + forwardedClientIp: opts.clientIpOf(ctx.request), forwardedUserAgent: forwardedUserAgent(ctx.request), }, ctx, diff --git a/packages/nextjs/src/routes/authRoutes.ts b/packages/nextjs/src/routes/authRoutes.ts index e8b88b3..eea7717 100644 --- a/packages/nextjs/src/routes/authRoutes.ts +++ b/packages/nextjs/src/routes/authRoutes.ts @@ -29,7 +29,6 @@ import { buildServiceAuthorization, } from "../internal/buildAuthorization.js"; import { - forwardedClientIp, forwardedUserAgent, type AuthContext, } from "../internal/context.js"; @@ -38,7 +37,7 @@ import type { ResolvedOptions } from "../options.js"; function forwarded(ctx: AuthContext, opts: ResolvedOptions) { return { - forwardedClientIp: forwardedClientIp(ctx.request, opts.resolveClientIp), + forwardedClientIp: opts.clientIpOf(ctx.request), forwardedUserAgent: forwardedUserAgent(ctx.request), }; } diff --git a/packages/nextjs/src/session.ts b/packages/nextjs/src/session.ts index f704d3f..7a702cd 100644 --- a/packages/nextjs/src/session.ts +++ b/packages/nextjs/src/session.ts @@ -8,6 +8,8 @@ import { import type { MeResponse } from "@seamless-auth/types"; import { buildProxyServiceAuthorization } from "./internal/buildAuthorization.js"; +import { createClientIpResolver } from "./internal/clientIp.js"; +import type { ClientIpResolver } from "./options.js"; /** * The read side of a cookie store. Both `await cookies()` from `next/headers` @@ -31,8 +33,22 @@ export interface SeamlessSessionOptions { jwksKid?: string; accessCookieName?: string; refreshCookieName?: string; - /** The browser's user agent, for the auth API to record instead of this server's. */ + /** + * The browser's user agent, for the auth API to record instead of this + * server's. Read from `headers` when left out. + */ userAgent?: string; + /** + * The incoming request's headers (`await headers()` from `next/headers`, or + * `request.headers` in `proxy.ts`). The client IP is resolved from them the + * way the route handler resolves it, with `resolveClientIp` or + * `trustedProxies`, and forwarded to the auth API. + */ + headers?: Headers; + /** As on the route handler options. */ + resolveClientIp?: ClientIpResolver; + /** As on the route handler options. */ + trustedProxies?: string[]; /** * Accept a recovery session (`recovery: true` on the access token), which can only * enroll a new passkey or authenticator app. Off by default, so the helpers treat it @@ -108,6 +124,16 @@ export function hasSeamlessSession( return Boolean(refresh && verifyRefreshCookie(refresh, opts.cookieSecret)); } +function clientIpOf(opts: SeamlessSessionOptions): string | undefined { + if (!opts.headers) { + return undefined; + } + + return createClientIpResolver(opts)( + new Request("http://localhost/", { headers: opts.headers }), + ); +} + /** * Resolves the signed-in user's session for a server component. * @@ -140,7 +166,11 @@ export async function getSeamlessSession( method: "GET", authorization: `Bearer ${user.token}`, serviceAuthorization: buildProxyServiceAuthorization(opts), - forwardedUserAgent: opts.userAgent?.trim().slice(0, 512) || undefined, + forwardedClientIp: clientIpOf(opts), + forwardedUserAgent: + (opts.userAgent ?? opts.headers?.get("user-agent") ?? undefined) + ?.trim() + .slice(0, 512) || undefined, }); if (!response.ok) { diff --git a/packages/nextjs/tests/clientIpWarning.test.js b/packages/nextjs/tests/clientIpWarning.test.js new file mode 100644 index 0000000..7921453 --- /dev/null +++ b/packages/nextjs/tests/clientIpWarning.test.js @@ -0,0 +1,31 @@ +import { jest } from "@jest/globals"; + +const { createSeamlessAuthHandler } = await import("../dist/index.js"); + +const OPTIONS = { + fetchManifest: false, + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret-cookie-secret-cookie-secret", + serviceSecret: "service-secret-service-secret-service-secret", + audience: "https://auth.example.com", + jwksKid: "test-main", +}; + +const noClientIpWarnings = (spy) => + spy.mock.calls.filter(([message]) => /no client IP is forwarded/.test(message)); + +describe("client IP startup warning", () => { + it("is skipped when a source is configured", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + createSeamlessAuthHandler({ ...OPTIONS, trustedProxies: ["10.0.0.0/8"] }); + createSeamlessAuthHandler({ ...OPTIONS, resolveClientIp: () => undefined }); + expect(noClientIpWarnings(warn)).toHaveLength(0); + }); + + it("is logged once when neither resolveClientIp nor trustedProxies is set", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + createSeamlessAuthHandler(OPTIONS); + createSeamlessAuthHandler(OPTIONS); + expect(noClientIpWarnings(warn)).toHaveLength(1); + }); +}); diff --git a/packages/nextjs/tests/handler.test.js b/packages/nextjs/tests/handler.test.js index 1453376..62b713c 100644 --- a/packages/nextjs/tests/handler.test.js +++ b/packages/nextjs/tests/handler.test.js @@ -218,6 +218,63 @@ describe("createSeamlessAuthHandler", () => { expect(forwardedIp()).toBeUndefined(); }); + + describe("trustedProxies", () => { + async function resolvedFrom(forwardedFor, trustedProxies, extra = {}) { + global.fetch = jest.fn(async () => upstream(200, { providers: [] })); + await call("/auth/system-config/public", { + headers: + forwardedFor === undefined + ? {} + : { "x-forwarded-for": forwardedFor }, + options: { trustedProxies, ...extra }, + }); + return forwardedIp(); + } + + it.each([ + ["the hop left of a trusted proxy", "203.0.113.9, 10.0.0.2", ["10.0.0.0/8"], "203.0.113.9"], + ["past every trusted hop", "203.0.113.9, 10.1.2.3, 10.0.0.2", ["10.0.0.0/8"], "203.0.113.9"], + ["the rightmost untrusted hop, not a forged leftmost one", "198.51.100.1, 203.0.113.9, 10.0.0.2", ["10.0.0.0/8"], "203.0.113.9"], + ["the rightmost hop when it is untrusted", "203.0.113.9, 198.51.100.7", ["10.0.0.0/8"], "198.51.100.7"], + ["the leftmost hop when every hop is trusted", "10.0.0.5, 10.0.0.2", ["10.0.0.0/8"], "10.0.0.5"], + ["an exact address entry", "203.0.113.9, 192.0.2.10", ["192.0.2.10"], "203.0.113.9"], + ["IPv6 ranges", "2001:db8::1, fd00::2", ["fd00::/8"], "2001:db8::1"], + ["an IPv4-mapped trusted hop", "203.0.113.9, ::ffff:10.0.0.2", ["10.0.0.0/8"], "203.0.113.9"], + ["an IPv4-mapped client, unmapped", "::ffff:203.0.113.9, 10.0.0.2", ["10.0.0.0/8"], "203.0.113.9"], + ])("forwards %s", async (_, header, proxies, expected) => { + expect(await resolvedFrom(header, proxies)).toBe(expected); + }); + + it.each([ + ["no header", undefined], + ["an empty header", ""], + ["a first untrusted hop that is not an address", "203.0.113.9, garbage, 10.0.0.2"], + ])("forwards none for %s", async (_, header) => { + expect(await resolvedFrom(header, ["10.0.0.0/8"])).toBeUndefined(); + }); + + it("treats an empty list as not configured", async () => { + expect(await resolvedFrom("203.0.113.9", [])).toBeUndefined(); + }); + + it("yields to resolveClientIp when both are set", async () => { + expect( + await resolvedFrom("203.0.113.9, 10.0.0.2", ["10.0.0.0/8"], { + resolveClientIp: () => "192.0.2.44", + }), + ).toBe("192.0.2.44"); + }); + + it.each(["10.0.0.0/33", "not-an-ip", "10.0.0.0/8/1", "fd00::/129", "10.0.0.0/x"])( + "refuses %s at setup", + (entry) => { + expect(() => + createSeamlessAuthHandler({ ...OPTIONS, trustedProxies: [entry] }), + ).toThrow(/trustedProxies/); + }, + ); + }); }); it("sends no body on a 204, where a Response would refuse one", async () => { diff --git a/packages/nextjs/tests/parity.test.js b/packages/nextjs/tests/parity.test.js index 720c378..b9fbccc 100644 --- a/packages/nextjs/tests/parity.test.js +++ b/packages/nextjs/tests/parity.test.js @@ -3,16 +3,23 @@ // scenarios are the Fastify parity suite's, so all three adapters are held to // one contract. import { jest } from "@jest/globals"; +import { parse as parseCookieHeader } from "cookie"; import express from "express"; import jwt from "jsonwebtoken"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; import request from "supertest"; -const { createSeamlessAuthHandler, createSeamlessConsoleProxy } = - await import("../dist/index.js"); +const { + createSeamlessAuthHandler, + createSeamlessConsoleProxy, + requireAuth: nextRequireAuth, + requireRole: nextRequireRole, +} = await import("../dist/index.js"); const { default: createSeamlessAuthServer, createSeamlessConsoleProxy: createExpressConsoleProxy, + requireAuth: expressRequireAuth, + requireRole: expressRequireRole, } = await import("../../express/dist/index.js"); // Unsigned: the adapter only reads the inner token's type, the auth API verifies it. @@ -1951,3 +1958,236 @@ describe("next and express rotate a recovery session after enrollment", () => { expect(viaAdapter.result).toEqual(viaExpressResult.result); }); }); + +const GUARD_SESSION_CLAIMS = { + sub: "user-123", + typ: "access", + sid: "s-1", + roles: ["admin"], + org_id: "org-9", +}; + +const GUARD_TOKENS = { + session: await accessToken(GUARD_SESSION_CLAIMS), + reader: await accessToken({ ...GUARD_SESSION_CLAIMS, roles: ["reports:read"] }), + noRoles: await accessToken({ ...GUARD_SESSION_CLAIMS, roles: [], org_id: undefined }), + recovery: await accessToken({ ...GUARD_SESSION_CLAIMS, recovery: true }), + ephemeral: await accessToken({ sub: "user-123", typ: "ephemeral" }), +}; + +// The first-party guards on an adopter's own routes. Status, the user handed to +// the route and the decision to refresh (never) match Express. Error bodies are +// the codes the Go, Python and Rust adapters answer with; Express moves to them +// in #263, so for now only its status is compared on a refusal. +describe("next.js and express route guards agree", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + const tokens = GUARD_TOKENS; + + const GUARD_OPTIONS = { + authServerUrl: OPTIONS.authServerUrl, + audience: OPTIONS.audience, + cookieSecret: COOKIE_SECRET, + }; + + const cookieFor = (token, extra = {}) => + `seamless-access=${signed({ sub: "user-123", roles: ["admin"], email: "user@example.com", token, ...extra })}`; + + // The same signed cookie through both adapters, so iat/exp agree. + const cookies = { + session: cookieFor(tokens.session), + forgedRoles: cookieFor(tokens.noRoles, { roles: ["admin"], org_id: "forged" }), + recovery: cookieFor(tokens.recovery), + preAuth: `seamless-access=${signed({ sub: "user-123", token: "pre-auth" })}`, + ephemeral: cookieFor(tokens.ephemeral), + expired: `seamless-access=${signed({ sub: "user-123", token: tokens.session }, "-10s")}`, + refresh: `seamless-refresh=${signed({ sub: "user-123", refreshToken: "opaque" }, "3600s")}`, + garbage: "seamless-access=not-a-jwt", + }; + + function guardFetch() { + return jest.fn(async (url) => { + if (String(url).endsWith("/.well-known/jwks.json")) { + return upstream(200, { keys: [jwk] }); + } + return upstream(200, REFRESH_OK); + }); + } + + async function viaNextGuard({ roles, method = "GET", cookie, headers, options }) { + const route = (_request, _context, user) => Response.json({ user }); + const opts = { ...GUARD_OPTIONS, ...options }; + const guarded = roles + ? nextRequireRole(roles, route, opts) + : nextRequireAuth(route, opts); + const res = await guarded( + new Request("http://localhost/api/thing", { + method, + headers: { ...(headers ?? {}), ...(cookie ? { cookie } : {}) }, + }), + { params: Promise.resolve({}) }, + ); + return { + status: res.status, + body: parseBody(await res.text()), + cookies: normalizeCookies(res.headers.getSetCookie()), + }; + } + + async function viaExpressGuard({ roles, method = "GET", cookie, headers, options }) { + const app = express(); + // What cookie-parser does, which an adopter mounts in front of requireAuth. + app.use((req, _res, next) => { + req.cookies = parseCookieHeader(req.headers.cookie ?? ""); + next(); + }); + const chain = [ + expressRequireAuth({ ...GUARD_OPTIONS, ...options }), + ...(roles ? [expressRequireRole(roles)] : []), + ]; + app.all("/api/thing", ...chain, (req, res) => res.json({ user: req.user })); + let req = request(app)[method.toLowerCase()]("/api/thing"); + if (headers) req = req.set(headers); + if (cookie) req = req.set("Cookie", cookie); + const res = await req; + return { + status: res.status, + body: parseBody(res.text), + cookies: normalizeCookies(res.headers["set-cookie"]), + }; + } + + async function bothGuards(scenario) { + global.fetch = guardFetch(); + const next = await viaNextGuard(scenario); + const nextUrls = global.fetch.mock.calls.map(([url]) => String(url)); + + global.fetch = guardFetch(); + const expressResult = await viaExpressGuard(scenario); + const expressUrls = global.fetch.mock.calls.map(([url]) => String(url)); + + // Only ever the key set: neither guard refreshes or calls the auth API. + for (const urls of [nextUrls, expressUrls]) { + expect(urls.every((url) => url.endsWith("/.well-known/jwks.json"))).toBe(true); + } + expect(next.cookies).toEqual([]); + expect(expressResult.cookies).toEqual([]); + + return { next, express: expressResult }; + } + + const bearer = (token) => ({ authorization: `Bearer ${token}` }); + + it.each([ + ["a cookie session", { cookie: cookies.session }], + ["a bearer session", { headers: bearer(tokens.session) }], + ["a cookie session with a role", { cookie: cookies.session, roles: "admin" }], + ["a bearer session with a role", { headers: bearer(tokens.session), roles: ["support", "admin"] }], + ["a scoped role granted by a broader one", { cookie: cookies.session, roles: "admin:read" }], + ["a read role granted by its write role", { headers: bearer(tokens.reader), roles: "reports:read" }], + ["a recovery session where it is allowed", { cookie: cookies.recovery, options: { allowRecoverySession: true } }], + ["a cookie preferred over a bearer token", { cookie: cookies.session, headers: bearer(tokens.ephemeral) }], + ])("passes %s with the same user", async (_, scenario) => { + const { next, express: expressResult } = await bothGuards(scenario); + + expect(next.status).toBe(200); + expect(next).toEqual(expressResult); + expect(next.body.user).toMatchObject({ id: "user-123", organizationId: "org-9" }); + }); + + it("takes roles and the organization from the verified token, not the cookie", async () => { + const { next, express: expressResult } = await bothGuards({ cookie: cookies.forgedRoles }); + + expect(next).toEqual(expressResult); + expect(next.body.user.roles).toEqual([]); + expect(next.body.user).not.toHaveProperty("organizationId"); + }); + + it.each([ + ["no credential", {}, 401, "unauthenticated"], + ["a malformed cookie", { cookie: cookies.garbage }, 401, "unauthenticated"], + ["an expired cookie, even with a refresh cookie", { cookie: `${cookies.expired}; ${cookies.refresh}` }, 401, "unauthenticated"], + ["a pre-auth cookie presented as the session", { cookie: cookies.preAuth }, 401, "unauthenticated"], + ["a cookie holding an ephemeral token", { cookie: cookies.ephemeral }, 401, "unauthenticated"], + ["an ephemeral bearer token", { headers: bearer(tokens.ephemeral) }, 401, "unauthenticated"], + ["a bearer token with another scheme", { headers: { authorization: `Basic ${tokens.session}` } }, 401, "unauthenticated"], + ["a recovery cookie session", { cookie: cookies.recovery }, 403, "enrollment_required"], + ["a recovery bearer session", { headers: bearer(tokens.recovery) }, 403, "enrollment_required"], + ["a recovery session behind a role guard", { cookie: cookies.recovery, roles: "admin" }, 403, "enrollment_required"], + ["a session without the role", { headers: bearer(tokens.reader), roles: "admin" }, 403, "insufficient_role"], + ["a session with no roles", { cookie: cookies.forgedRoles, roles: "admin" }, 403, "insufficient_role"], + ["no credential behind a role guard", { roles: "admin" }, 401, "unauthenticated"], + ])("refuses %s with the same status", async (_, scenario, status, error) => { + jest.spyOn(console, "warn").mockImplementation(() => {}); + const { next, express: expressResult } = await bothGuards(scenario); + + expect(next.status).toBe(status); + expect(expressResult.status).toBe(status); + expect(next.body).toEqual({ error }); + }); + + it("blocks a cross-site state change on a cookie session but not on a bearer one", async () => { + global.fetch = guardFetch(); + const options = { cookieSameSite: "none" }; + const crossSite = { "sec-fetch-site": "cross-site" }; + + const cookieResult = await viaNextGuard({ + method: "POST", + cookie: cookies.session, + headers: crossSite, + options, + }); + const bearerResult = await viaNextGuard({ + method: "POST", + headers: { ...crossSite, ...bearer(tokens.session) }, + options, + }); + const safeRead = await viaNextGuard({ + cookie: cookies.session, + headers: crossSite, + options, + }); + + expect(cookieResult).toMatchObject({ + status: 403, + body: { error: "cross_site_request_blocked" }, + }); + expect(bearerResult.status).toBe(200); + expect(safeRead.status).toBe(200); + }); + + it.each([[[]], [""], [["admin", " "]]])( + "refuses a role guard configured with no role (%j) at setup", + (roles) => { + expect(() => + nextRequireRole(roles, () => new Response(), GUARD_OPTIONS), + ).toThrow(/at least one/); + }, + ); + + it("refuses to build a guard that cannot verify access tokens", () => { + expect(() => + nextRequireAuth(() => new Response(), { cookieSecret: COOKIE_SECRET }), + ).toThrow(/authServerUrl and audience/); + expect(() => + nextRequireAuth(() => new Response(), { ...GUARD_OPTIONS, cookieSecret: "short" }), + ).toThrow(/cookieSecret/); + }); + + it("hands the route its context", async () => { + global.fetch = guardFetch(); + const guarded = nextRequireAuth( + (_request, context) => Response.json(context), + GUARD_OPTIONS, + ); + const res = await guarded( + new Request("http://localhost/api/thing", { headers: bearer(tokens.session) }), + { params: { id: "t-1" } }, + ); + + expect(await res.json()).toEqual({ params: { id: "t-1" } }); + }); +}); diff --git a/packages/nextjs/tests/session.test.js b/packages/nextjs/tests/session.test.js index 93cfb5b..f26f46a 100644 --- a/packages/nextjs/tests/session.test.js +++ b/packages/nextjs/tests/session.test.js @@ -75,6 +75,53 @@ describe("session helpers", () => { ); }); + it("forwards the client IP and user agent resolved from the request headers", async () => { + const headers = new Headers({ + "x-forwarded-for": "198.51.100.1, 203.0.113.9, 10.0.0.2", + "user-agent": "Mozilla/5.0 (headers)", + }); + + await getSeamlessSession(jar({ "seamless-access": signed(ACCESS) }), { + ...OPTIONS, + headers, + trustedProxies: ["10.0.0.0/8"], + }); + + const [, init] = global.fetch.mock.calls[0]; + expect(init.headers["x-seamless-client-ip"]).toBe("203.0.113.9"); + expect(init.headers["x-seamless-client-user-agent"]).toBe( + "Mozilla/5.0 (headers)", + ); + }); + + it("resolves the client IP with resolveClientIp from the request headers", async () => { + await getSeamlessSession(jar({ "seamless-access": signed(ACCESS) }), { + ...OPTIONS, + headers: new Headers({ "x-real-ip": "203.0.113.9" }), + resolveClientIp: (request) => + request.headers.get("x-real-ip") ?? undefined, + }); + + expect( + global.fetch.mock.calls[0][1].headers["x-seamless-client-ip"], + ).toBe("203.0.113.9"); + }); + + it("forwards no client IP without headers or a configured source", async () => { + await getSeamlessSession(jar({ "seamless-access": signed(ACCESS) }), { + ...OPTIONS, + trustedProxies: ["10.0.0.0/8"], + }); + await getSeamlessSession(jar({ "seamless-access": signed(ACCESS) }), { + ...OPTIONS, + headers: new Headers({ "x-forwarded-for": "203.0.113.9" }), + }); + + for (const [, init] of global.fetch.mock.calls) { + expect(init.headers["x-seamless-client-ip"]).toBeUndefined(); + } + }); + it("works without a serviceSecret, sending no service token", async () => { const { serviceSecret: _unused, ...withoutService } = OPTIONS; diff --git a/packages/nextjs/tests/types/consumer.ts b/packages/nextjs/tests/types/consumer.ts index 361f5bb..770907f 100644 --- a/packages/nextjs/tests/types/consumer.ts +++ b/packages/nextjs/tests/types/consumer.ts @@ -4,6 +4,9 @@ import { createSeamlessAuthHandler, getSeamlessClaims, getSeamlessSession, + requireAuth, + requireRole, + type SeamlessGuardUser, type SeamlessSession, } from "@seamless-auth/nextjs"; @@ -22,8 +25,27 @@ const session: Promise = getSeamlessSession( cookies, options, ); +type RouteContext = { params: Promise<{ id: string }> }; +const guarded: (request: Request, context: RouteContext) => Promise = + requireRole( + "admin", + async (_request, context: RouteContext, user: SeamlessGuardUser) => { + const org: string | undefined = user.organizationId; + return Response.json({ id: (await context.params).id, org }); + }, + options, + ); +// One options object serves the route handler and the guards. +const shared = { ...options, trustedProxies: ["10.0.0.0/8"] }; +createSeamlessAuthHandler(shared); +const me = requireAuth( + (_request, _context, user) => Response.json(user), + shared, +); void get; void session; +void guarded; +void me; // Each wrong call below must be a type error. If an export resolves to `any` // (an unresolvable import in the shipped .d.ts), the directive is unused and @@ -34,3 +56,5 @@ createSeamlessAuthHandler(42); getSeamlessSession(42); // @ts-expect-error getSeamlessClaims takes a cookie reader and options getSeamlessClaims(42); +// @ts-expect-error requireRole takes the roles first +requireRole(() => new Response(), options); From e5f0a273625dd9f8e2a6009e5d3b727f683b7018 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 1 Nov 2026 16:17:20 -0500 Subject: [PATCH 2/2] ci(nextjs): run the adapter conformance specs against a Next.js reference app conformance/nextjs-refapp is a private workspace app built on @seamless-auth/nextjs with the routes verify/CONFORMANCE.md in seamless-cli asks for: the adapter at /auth, /api/me behind requireAuth, and the /__captured delivery hook. A new job in conformance.yml builds it, starts it on the host and runs seamless verify --adapter-url against it, the way the Go, Python and Rust adapters run the same specs. It needs no seamless-cli change. Closes #262 --- .github/workflows/conformance.yml | 79 +++ AGENTS.md | 8 + conformance/nextjs-refapp/.gitignore | 2 + .../app/%5F_captured/[recipient]/route.js | 10 + conformance/nextjs-refapp/app/api/me/route.js | 6 + .../app/auth/[...seamless]/route.js | 9 + conformance/nextjs-refapp/app/route.js | 5 + conformance/nextjs-refapp/lib/seamless.mjs | 58 ++ conformance/nextjs-refapp/next.config.mjs | 5 + conformance/nextjs-refapp/package.json | 17 + pnpm-lock.yaml | 569 +++++++++++++++++- pnpm-workspace.yaml | 1 + 12 files changed, 763 insertions(+), 6 deletions(-) create mode 100644 conformance/nextjs-refapp/.gitignore create mode 100644 conformance/nextjs-refapp/app/%5F_captured/[recipient]/route.js create mode 100644 conformance/nextjs-refapp/app/api/me/route.js create mode 100644 conformance/nextjs-refapp/app/auth/[...seamless]/route.js create mode 100644 conformance/nextjs-refapp/app/route.js create mode 100644 conformance/nextjs-refapp/lib/seamless.mjs create mode 100644 conformance/nextjs-refapp/next.config.mjs create mode 100644 conformance/nextjs-refapp/package.json diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 38751ef..071c00f 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -22,3 +22,82 @@ jobs: uses: fells-code/seamless-cli/.github/workflows/verify-conformance.yml@1ff9552d68d3d74096992a83d931acc0233edab2 # v0.26.0 with: server-ref: ${{ github.event.pull_request.head.sha }} + + # The seamless-cli matrix above runs the Express and Fastify reference apps it + # builds itself. Next.js has no reference app there, so this job runs the same + # adapter specs against conformance/nextjs-refapp through --adapter-url, the + # way the Go, Python and Rust adapters run them from their own repositories. + nextjs: + name: adapter conformance (Next.js) + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + HUSKY: '0' + NEXT_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: seamless-auth-server + + # The same CLI the matrix above pins, so both run one version of the specs. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: fells-code/seamless-cli + ref: 1ff9552d68d3d74096992a83d931acc0233edab2 # v0.26.0 + path: seamless-cli + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: fells-code/seamless-auth-api + path: seamless-auth-api + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10.33.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: seamless-auth-server/.nvmrc + cache: pnpm + cache-dependency-path: seamless-auth-server/pnpm-lock.yaml + + - name: Build the CLI + working-directory: seamless-cli + run: npm ci && npm run build + + - name: Build the reference app + working-directory: seamless-auth-server + run: | + pnpm install --frozen-lockfile + pnpm --filter @seamless-auth/core build + pnpm --filter @seamless-auth/nextjs build + pnpm --filter seamless-nextjs-refapp build:refapp + + # Values from verify/CONFORMANCE.md in seamless-cli. The app runs on the + # host and reaches the API through its published port, while the API signs + # as its in-network ISSUER. + - name: Start the reference app + working-directory: seamless-auth-server/conformance/nextjs-refapp + env: + AUTH_SERVER_URL: http://localhost:5312 + AUTH_SERVER_ISSUER: http://auth-api:5312 + SEAMLESS_SERVICE_TOKEN: verify-dev-service-token-not-a-real-secret + COOKIE_SIGNING_KEY: verify-dev-service-token-not-a-real-secret + JWKS_KID: dev-main + run: nohup pnpm start:refapp -p 3002 > refapp.log 2>&1 & + + - name: Conformance + env: + SEAMLESS_API_DIR: ${{ github.workspace }}/seamless-auth-api + run: node seamless-cli/dist/index.js verify --adapter-url=http://localhost:3002 + + - name: Reference app log + if: always() + run: cat seamless-auth-server/conformance/nextjs-refapp/refapp.log + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: conformance-results-nextjs + path: seamless-cli/verify/harness/results/ + if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index 4276839..d5952e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,8 +86,16 @@ packages/ express/ Express adapter built on top of core fastify/ Fastify adapter built on top of core nextjs/ Next.js route handler adapter built on top of core +conformance/ + nextjs-refapp/ reference app the adapter conformance specs drive ``` +- `conformance/nextjs-refapp` is a private workspace package, never + published. The `nextjs` job in `.github/workflows/conformance.yml` runs the + seamless-cli adapter specs against it with `seamless verify --adapter-url`, + since the seamless-cli matrix builds only the Express and Fastify apps. Its + routes follow `verify/CONFORMANCE.md` in seamless-cli. + - Keep framework specifics in the adapter package, not in `core`. - The Next.js adapter has no framework underneath, so it builds its own request context (`src/internal/context.ts`): cookie and JSON body parsing diff --git a/conformance/nextjs-refapp/.gitignore b/conformance/nextjs-refapp/.gitignore new file mode 100644 index 0000000..81f74ff --- /dev/null +++ b/conformance/nextjs-refapp/.gitignore @@ -0,0 +1,2 @@ +.next/ +next-env.d.ts diff --git a/conformance/nextjs-refapp/app/%5F_captured/[recipient]/route.js b/conformance/nextjs-refapp/app/%5F_captured/[recipient]/route.js new file mode 100644 index 0000000..c6008b6 --- /dev/null +++ b/conformance/nextjs-refapp/app/%5F_captured/[recipient]/route.js @@ -0,0 +1,10 @@ +import { captured } from "../../../lib/seamless.mjs"; + +// A test hook, never part of a real deployment. The folder name is URL-encoded +// because App Router treats a folder starting with an underscore as private. +export const dynamic = "force-dynamic"; + +export async function GET(_request, { params }) { + const { recipient } = await params; + return Response.json(captured.get(recipient) ?? null); +} diff --git a/conformance/nextjs-refapp/app/api/me/route.js b/conformance/nextjs-refapp/app/api/me/route.js new file mode 100644 index 0000000..262f137 --- /dev/null +++ b/conformance/nextjs-refapp/app/api/me/route.js @@ -0,0 +1,6 @@ +import { guarded } from "../../../lib/seamless.mjs"; + +// The conformance contract's protected endpoint, behind the adapter's own guard. +export const GET = guarded((_request, _context, user) => + Response.json({ id: user.id }), +); diff --git a/conformance/nextjs-refapp/app/auth/[...seamless]/route.js b/conformance/nextjs-refapp/app/auth/[...seamless]/route.js new file mode 100644 index 0000000..0f58497 --- /dev/null +++ b/conformance/nextjs-refapp/app/auth/[...seamless]/route.js @@ -0,0 +1,9 @@ +import { authHandlers } from "../../../lib/seamless.mjs"; + +const handle = (request) => authHandlers()[request.method](request); + +export const GET = handle; +export const POST = handle; +export const PUT = handle; +export const PATCH = handle; +export const DELETE = handle; diff --git a/conformance/nextjs-refapp/app/route.js b/conformance/nextjs-refapp/app/route.js new file mode 100644 index 0000000..a5dfae0 --- /dev/null +++ b/conformance/nextjs-refapp/app/route.js @@ -0,0 +1,5 @@ +export const dynamic = "force-dynamic"; + +export function GET() { + return Response.json({ ok: true }); +} diff --git a/conformance/nextjs-refapp/lib/seamless.mjs b/conformance/nextjs-refapp/lib/seamless.mjs new file mode 100644 index 0000000..e5ddc1a --- /dev/null +++ b/conformance/nextjs-refapp/lib/seamless.mjs @@ -0,0 +1,58 @@ +// Configuration follows verify/CONFORMANCE.md in fells-code/seamless-cli. Read +// on the first request, not at import: `next build` loads every route module, +// and the adapter refuses to start without its secrets. +import { createSeamlessAuthHandler, requireAuth } from "@seamless-auth/nextjs"; + +const store = (globalThis.__seamlessRefapp ??= { captured: new Map() }); + +export const captured = store.captured; + +const ok = (channel) => ({ accepted: true, provider: "capture", channel }); + +const handlers = { + async sendOtpEmail({ to, token }) { + captured.set(to, { token: String(token) }); + return ok("email"); + }, + async sendOtpSms({ to, token }) { + captured.set(to, { token: String(token) }); + return ok("sms"); + }, + async sendMagicLinkEmail({ to, token, magicLinkUrl }) { + captured.set(to, { token, magicLinkUrl }); + return ok("email"); + }, + async sendBootstrapInviteEmail({ to, token, inviteUrl }) { + captured.set(to, { token, inviteUrl }); + return ok("email"); + }, +}; + +function options() { + const issuer = process.env.AUTH_SERVER_ISSUER ?? process.env.AUTH_SERVER_URL; + + return { + authServerUrl: process.env.AUTH_SERVER_URL, + authServerIssuer: issuer, + audience: issuer, + cookieSecret: process.env.COOKIE_SIGNING_KEY, + serviceSecret: process.env.SEAMLESS_SERVICE_TOKEN, + jwksKid: process.env.JWKS_KID, + // The harness is the only peer and sends each virtual user with its own + // X-Forwarded-For, so loopback is the one trusted hop. + trustedProxies: ["127.0.0.1", "::1"], + messaging: { handlers, defaults: { appName: "Seamless Verify" } }, + }; +} + +export function authHandlers() { + return (store.handlers ??= createSeamlessAuthHandler(options())); +} + +export function guarded(handler) { + let route; + return (request, context) => { + route ??= requireAuth(handler, options()); + return route(request, context); + }; +} diff --git a/conformance/nextjs-refapp/next.config.mjs b/conformance/nextjs-refapp/next.config.mjs new file mode 100644 index 0000000..e9982a4 --- /dev/null +++ b/conformance/nextjs-refapp/next.config.mjs @@ -0,0 +1,5 @@ +// The adapter packages run as plain Node modules rather than through the +// bundler, as they would from node_modules in an adopter's app. +export default { + serverExternalPackages: ["@seamless-auth/nextjs", "@seamless-auth/core"], +}; diff --git a/conformance/nextjs-refapp/package.json b/conformance/nextjs-refapp/package.json new file mode 100644 index 0000000..85152d1 --- /dev/null +++ b/conformance/nextjs-refapp/package.json @@ -0,0 +1,17 @@ +{ + "name": "seamless-nextjs-refapp", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Reference app for the adapter conformance suite (seamless verify --adapter-url), built on @seamless-auth/nextjs. A test fixture, not an example deployment.", + "scripts": { + "build:refapp": "next build", + "start:refapp": "next start" + }, + "dependencies": { + "@seamless-auth/nextjs": "workspace:*", + "next": "^16.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfd6d06..63b28d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,21 @@ importers: specifier: ^3.0.3 version: 3.0.3 + conformance/nextjs-refapp: + dependencies: + '@seamless-auth/nextjs': + specifier: workspace:* + version: link:../../packages/nextjs + next: + specifier: ^16.4.0 + version: 16.4.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: + specifier: ^19.2.0 + version: 19.3.1 + react-dom: + specifier: ^19.2.0 + version: 19.3.1(react@19.3.1) + packages/core: dependencies: '@seamless-auth/types': @@ -87,7 +102,7 @@ importers: version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) tsup: specifier: ^8.5.1 - version: 8.5.1(typescript@5.9.3)(yaml@2.9.1) + version: 8.5.1(postcss@8.5.23)(typescript@5.9.3)(yaml@2.9.1) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -133,7 +148,7 @@ importers: version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) tsup: specifier: ^8.5.1 - version: 8.5.1(typescript@5.9.3)(yaml@2.9.1) + version: 8.5.1(postcss@8.5.23)(typescript@5.9.3)(yaml@2.9.1) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -173,7 +188,7 @@ importers: version: 7.2.2 tsup: specifier: ^8.5.1 - version: 8.5.1(typescript@5.9.3)(yaml@2.9.1) + version: 8.5.1(postcss@8.5.23)(typescript@5.9.3)(yaml@2.9.1) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -447,6 +462,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -624,6 +642,168 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -729,6 +909,61 @@ packages: resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==} engines: {node: '>=20.0.0'} + '@next/env@16.4.3': + resolution: {integrity: sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ==} + + '@next/swc-darwin-arm64@16.4.3': + resolution: {integrity: sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.4.3': + resolution: {integrity: sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.4.3': + resolution: {integrity: sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.4.3': + resolution: {integrity: sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.4.3': + resolution: {integrity: sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.4.3': + resolution: {integrity: sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.4.3': + resolution: {integrity: sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.4.3': + resolution: {integrity: sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -894,6 +1129,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -1173,6 +1411,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1298,6 +1539,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1958,6 +2203,11 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1965,6 +2215,27 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next@16.4.3: + resolution: {integrity: sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -2105,6 +2376,10 @@ packages: yaml: optional: true + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2141,9 +2416,18 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -2214,6 +2498,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -2226,6 +2513,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -2240,6 +2532,15 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2281,6 +2582,10 @@ packages: sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} @@ -2331,6 +2636,19 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -2418,6 +2736,9 @@ packages: '@swc/wasm': optional: true + tslib@2.9.1: + resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -2875,6 +3196,11 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.9.1 + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -2981,6 +3307,113 @@ snapshots: '@fastify/forwarded': 3.0.2 ipaddr.js: 2.4.0 + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -3192,6 +3625,32 @@ snapshots: tinyglobby: 0.2.16 yaml: 2.9.1 + '@next/env@16.4.3': {} + + '@next/swc-darwin-arm64@16.4.3': + optional: true + + '@next/swc-darwin-x64@16.4.3': + optional: true + + '@next/swc-linux-arm64-gnu@16.4.3': + optional: true + + '@next/swc-linux-arm64-musl@16.4.3': + optional: true + + '@next/swc-linux-x64-gnu@16.4.3': + optional: true + + '@next/swc-linux-x64-musl@16.4.3': + optional: true + + '@next/swc-win32-arm64-msvc@16.4.3': + optional: true + + '@next/swc-win32-x64-msvc@16.4.3': + optional: true + '@noble/hashes@1.8.0': {} '@paralleldrive/cuid2@2.3.1': @@ -3291,6 +3750,10 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.9.1 + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -3603,6 +4066,8 @@ snapshots: cjs-module-lexer@1.4.3: {} + client-only@0.0.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -3695,6 +4160,9 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.1.2: + optional: true + detect-newline@3.1.0: {} dezalgo@1.0.4: @@ -4584,10 +5052,37 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.20: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} + next@16.4.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@next/env': 16.4.3 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + postcss: 8.5.23 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + styled-jsx: 5.1.6(react@19.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 16.4.3 + '@next/swc-darwin-x64': 16.4.3 + '@next/swc-linux-arm64-gnu': 16.4.3 + '@next/swc-linux-arm64-musl': 16.4.3 + '@next/swc-linux-x64-gnu': 16.4.3 + '@next/swc-linux-x64-musl': 16.4.3 + '@next/swc-win32-arm64-msvc': 16.4.3 + '@next/swc-win32-x64-msvc': 16.4.3 + sharp: 0.35.5 + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + node-int64@0.4.0: {} node-releases@2.0.46: {} @@ -4693,12 +5188,19 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(yaml@2.9.1): + postcss-load-config@6.0.1(postcss@8.5.23)(yaml@2.9.1): dependencies: lilconfig: 3.1.3 optionalDependencies: + postcss: 8.5.23 yaml: 2.9.1 + postcss@8.5.23: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -4736,8 +5238,15 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + react-is@18.3.1: {} + react@19.3.1: {} + readdirp@4.1.2: {} real-require@0.2.0: {} @@ -4820,12 +5329,17 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.28.0: {} + secure-json-parse@4.1.0: {} semver@6.3.1: {} semver@7.8.1: {} + semver@7.8.5: + optional: true + send@1.2.1: dependencies: debug: 4.4.3 @@ -4855,6 +5369,39 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.35.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4901,6 +5448,8 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + source-map-js@1.2.2: {} + source-map-support@0.5.13: dependencies: buffer-from: 1.1.2 @@ -4941,6 +5490,11 @@ snapshots: strip-json-comments@3.1.1: {} + styled-jsx@5.1.6(react@19.3.1): + dependencies: + client-only: 0.0.1 + react: 19.3.1 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -5042,7 +5596,9 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - tsup@8.5.1(typescript@5.9.3)(yaml@2.9.1): + tslib@2.9.1: {} + + tsup@8.5.1(postcss@8.5.23)(typescript@5.9.3)(yaml@2.9.1): dependencies: bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 @@ -5053,7 +5609,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(yaml@2.9.1) + postcss-load-config: 6.0.1(postcss@8.5.23)(yaml@2.9.1) resolve-from: 5.0.0 rollup: 4.60.4 source-map: 0.7.6 @@ -5062,6 +5618,7 @@ snapshots: tinyglobby: 0.2.16 tree-kill: 1.2.2 optionalDependencies: + postcss: 8.5.23 typescript: 5.9.3 transitivePeerDependencies: - jiti diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e9..86a6b19 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - "packages/*" + - "conformance/*"