@grantry/nextjs 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Route protection in Next.js middleware — `@grantry/nextjs/middleware`.
3
+ *
4
+ * export default grantryMiddleware({
5
+ * protectedRoutes: ['/dashboard', '/account'],
6
+ * signInUrl: '/sign-in',
7
+ * });
8
+ *
9
+ * export const config = { matcher: ['/((?!_next|.*\\..*).*)'] };
10
+ *
11
+ * Middleware runs before rendering, so an unauthenticated visitor is redirected
12
+ * without the protected page ever being built — which is the difference between
13
+ * protecting a route and merely hiding it. No request is let through signed out,
14
+ * a support visit's included; see `visitTicket`.
15
+ */
16
+ import { NextResponse, type NextRequest } from 'next/server';
17
+ import {
18
+ REDIRECT_URL_PARAMETER,
19
+ TICKET_PARAMETER,
20
+ environmentSignInUrl,
21
+ hostedSignInPage,
22
+ serverRouteOptions,
23
+ verifyToken,
24
+ } from '@grantry/react/server';
25
+ import { TOKEN_COOKIE_NAME, tokenFromCookie } from './cookie';
26
+
27
+ /**
28
+ * The headers a verified request goes on with, for the application's pages to read.
29
+ *
30
+ * ⚠️ REMOVED FROM EVERY REQUEST THIS MIDDLEWARE SEES, NOT ONLY THE ONES IT VERIFIES. They were
31
+ * replaced on a protected route and passed through untouched everywhere else, so a visitor who sent
32
+ * `x-grantry-user-id: <anybody>` to a public page had it arrive as though this middleware had set
33
+ * it — and the docs tell a page it can trust these names. A page outside the middleware's `matcher`
34
+ * is still beyond reach, which is why `auth()`, which verifies the cookie itself, is the safer read.
35
+ */
36
+ const FORWARDED_HEADERS = ['x-grantry-user-id', 'x-grantry-session-id'] as const;
37
+
38
+ export interface GrantryMiddlewareOptions {
39
+ /**
40
+ * Path patterns requiring a signed-in visitor.
41
+ *
42
+ * A path prefix as a string — `'/dashboard'` protects it and everything under
43
+ * it — or a `RegExp` for anything else. `'/dashboard(.*)'`, the spelling other
44
+ * libraries use, means the same prefix; any other string that looks like a
45
+ * pattern is refused when the middleware is made (`prefixOf`).
46
+ * Anything not listed is public — an allowlist would silently protect new
47
+ * routes, which sounds safer but produces a site where adding a public page
48
+ * quietly breaks it.
49
+ */
50
+ protectedRoutes?: Array<string | RegExp>;
51
+ /**
52
+ * Where to send unauthenticated visitors.
53
+ *
54
+ * Unset: `GRANTRY_SIGN_IN_URL`, then `NEXT_PUBLIC_GRANTRY_SIGN_IN_URL` — the
55
+ * variable `<GrantryProvider>` reads, so the two agree — then the hosted
56
+ * sign-in screen on the environment's sign-in URL, which is looked up with the
57
+ * publishable key (one request, remembered for five minutes). With none of
58
+ * those the visitor is answered 401 (docs/paths-and-domains.md §6).
59
+ * ⚠️ It defaulted to `/sign-in`, a page Grantry could only guess the
60
+ * application had.
61
+ */
62
+ signInUrl?: string;
63
+ /** Override for self-hosted deployments and local development. */
64
+ apiUrl?: string;
65
+ /**
66
+ * Your publishable key, which names the application these tokens must belong
67
+ * to, and finds the hosted sign-in screen when no sign-in page is named. Read
68
+ * from `GRANTRY_PUBLISHABLE_KEY`, then `NEXT_PUBLIC_GRANTRY_PUBLISHABLE_KEY`,
69
+ * when omitted. ⚠️ With no key and no `environmentId`, a request carrying a
70
+ * token makes the middleware THROW (`no_audience`) rather than redirect — no
71
+ * token is ever admitted without knowing the application, and a sign-in page
72
+ * cannot fix a server that does not know which one it is. See
73
+ * `@grantry/react/server`.
74
+ */
75
+ publishableKey?: string;
76
+ /** The environment id, for a deployment that configures it directly. */
77
+ environmentId?: string;
78
+ /** Require a verified email address, not merely a session. */
79
+ requireVerifiedEmail?: boolean;
80
+ }
81
+
82
+ /**
83
+ * A string route pattern, as the path prefix it protects.
84
+ *
85
+ * ⚠️ `'/dashboard(.*)'` PROTECTED NOTHING UNTIL 1 OCTOBER 2026, AND THE DOCS SAID TO WRITE IT. A
86
+ * string was compared as a literal prefix, so the pattern matched only a path that began with the
87
+ * characters `/dashboard(.*)` — none — and the middleware let every request through, signed out.
88
+ * The quickstart, the middleware page and this file's own example all used it (the docs audit). It
89
+ * is the spelling Clerk's route matcher takes, so somebody arriving from there writes it too: it is
90
+ * read as the prefix it means, and anything else that looks like a pattern is refused rather than
91
+ * silently matched against nothing.
92
+ */
93
+ export function prefixOf(pattern: string): string {
94
+ const prefix = pattern.endsWith('(.*)') ? pattern.slice(0, -'(.*)'.length) : pattern;
95
+ if (/[()*?+[\]{}|^$\\]/u.test(prefix)) {
96
+ throw new Error(
97
+ `Grantry: the route pattern "${pattern}" is not a path prefix. ` +
98
+ 'Use a prefix such as "/dashboard", or pass a RegExp.'
99
+ );
100
+ }
101
+ const trimmed = prefix.replace(/\/+$/u, '');
102
+ return trimmed === '' ? '/' : trimmed;
103
+ }
104
+
105
+ function matchesPath(pathname: string, patterns: Array<string | RegExp>): boolean {
106
+ return patterns.some((pattern) => {
107
+ if (typeof pattern === 'string') {
108
+ // A prefix, anchored at a path boundary so `/dashboard` does not also
109
+ // protect `/dashboards-public`.
110
+ return pattern === '/' || pathname === pattern || pathname.startsWith(`${pattern}/`);
111
+ }
112
+ /*
113
+ * ⚠️ `lastIndex` RESET FIRST. A RegExp with the `g` or `y` flag remembers where its last match
114
+ * ended and starts the next `test` there, so `/^\/admin/g` matched one request, missed the
115
+ * next — letting it through signed out — and matched the one after.
116
+ */
117
+ pattern.lastIndex = 0;
118
+ return pattern.test(pathname);
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Whether a path is protected, as written and as decoded.
124
+ *
125
+ * ⚠️ BOTH SPELLINGS, BECAUSE THE ROUTER AND THIS CHECK MAY NOT AGREE ON ONE. `nextUrl.pathname`
126
+ * keeps percent-encoding (`/%64ashboard`), and a router that decodes before it matches a page
127
+ * would build `/dashboard` for an address the prefix never saw. Matching the decoded path too
128
+ * costs nothing on an ordinary request and only ever protects more.
129
+ */
130
+ function matches(pathname: string, patterns: Array<string | RegExp>): boolean {
131
+ if (matchesPath(pathname, patterns)) return true;
132
+ let decoded: string;
133
+ try {
134
+ decoded = decodeURIComponent(pathname);
135
+ } catch {
136
+ return false;
137
+ }
138
+ return decoded !== pathname && matchesPath(decoded, patterns);
139
+ }
140
+
141
+ /** The request as the application will see it, with no forwarded header the visitor wrote. */
142
+ function withoutForwardedHeaders(request: NextRequest): Headers {
143
+ const headers = new Headers(request.headers);
144
+ for (const name of FORWARDED_HEADERS) headers.delete(name);
145
+ return headers;
146
+ }
147
+
148
+ /**
149
+ * A support visit's first request: a navigation carrying `?__grantry_ticket=`.
150
+ *
151
+ * ⚠️ CARRIED TO SIGN-IN, NEVER LET THROUGH. A view-as link opens the Home URL
152
+ * with its ticket (0242), and `@grantry/js` spends it on whatever page loads. A
153
+ * plain redirect to sign-in dropped the query string, so a protected Home URL
154
+ * turned every visit into a sign-in form for an operator with no password to
155
+ * type — and the first fix, letting the request through signed out, was a way
156
+ * past this middleware for anybody: the ticket is not checked here, so adding
157
+ * the parameter to any protected address rendered that page to a signed-out
158
+ * visitor. A page that trusted this middleware to keep its data from signed-out
159
+ * visitors would have served it.
160
+ *
161
+ * So the ticket rides the redirect instead. The sign-in page is public, the SDK
162
+ * spends the ticket there before anything else runs, and `<SignIn />` moves a
163
+ * visit on to `redirect_url` — the address the link pointed at, without the
164
+ * ticket. The protected page is only ever built for a signed-in request.
165
+ *
166
+ * Only GET and HEAD: a form post or a server action is never a navigation from a
167
+ * link, and carries nothing anywhere.
168
+ */
169
+ function visitTicket(request: NextRequest): string | null {
170
+ if (request.method !== 'GET' && request.method !== 'HEAD') return null;
171
+ return request.nextUrl.searchParams.get(TICKET_PARAMETER) || null;
172
+ }
173
+
174
+ export function grantryMiddleware(options: GrantryMiddlewareOptions = {}) {
175
+ const {
176
+ protectedRoutes: patterns = [],
177
+ apiUrl,
178
+ publishableKey,
179
+ environmentId,
180
+ requireVerifiedEmail = false,
181
+ } = options;
182
+
183
+ // Read once, here, so a pattern that cannot work fails the deploy and not a visitor.
184
+ const protectedRoutes = patterns.map((pattern) =>
185
+ typeof pattern === 'string' ? prefixOf(pattern) : pattern
186
+ );
187
+
188
+ /**
189
+ * Where sign-in is: the option, the server's variables, the hosted sign-in screen.
190
+ *
191
+ * ⚠️ THE ONE SDK THAT DOES NOT DEFAULT TO REDIRECT SIGN-IN, because it cannot.
192
+ * A redirect sign-in keeps its verifier in the browser and spends its code
193
+ * there (docs/hosted-sign-in.md §6), and the only page this middleware knows
194
+ * is the protected one it is turning the visitor away from. Name a sign-in
195
+ * page — `<SignIn mode="redirect" />` makes one a single line.
196
+ */
197
+ async function signInPage(): Promise<string | null> {
198
+ const configured = options.signInUrl ?? serverRouteOptions().signInUrl;
199
+ if (configured) return configured;
200
+ const signInUrl = await environmentSignInUrl({
201
+ ...(publishableKey ? { publishableKey } : {}),
202
+ ...(apiUrl ? { apiUrl } : {}),
203
+ });
204
+ return hostedSignInPage(signInUrl, 'sign-in');
205
+ }
206
+
207
+ return async function middleware(request: NextRequest): Promise<NextResponse> {
208
+ if (!matches(request.nextUrl.pathname, protectedRoutes)) {
209
+ // A public page: through, with nothing that claims to be this middleware's word.
210
+ return FORWARDED_HEADERS.some((name) => request.headers.has(name))
211
+ ? NextResponse.next({ request: { headers: withoutForwardedHeaders(request) } })
212
+ : NextResponse.next();
213
+ }
214
+
215
+ const token = tokenFromCookie(request.cookies.get(TOKEN_COOKIE_NAME));
216
+
217
+ const turnAway = async (): Promise<NextResponse> => {
218
+ const signIn = await signInPage();
219
+ if (!signIn) {
220
+ return new NextResponse('Sign-in is required, and this application has no sign-in page.', {
221
+ status: 401,
222
+ });
223
+ }
224
+
225
+ const target = new URL(signIn, request.url);
226
+ const here = new URL(request.url);
227
+ // A support visit: the sign-in page spends the ticket, and the visit goes on to
228
+ // where the link pointed, without it. See `visitTicket`.
229
+ const ticket = visitTicket(request);
230
+ if (ticket) {
231
+ here.searchParams.delete(TICKET_PARAMETER);
232
+ target.searchParams.set(TICKET_PARAMETER, ticket);
233
+ }
234
+ /*
235
+ * So the app can return the visitor where they were headed rather than
236
+ * dumping them on a home page after signing in. ⚠️ A PATH WHEN SIGN-IN IS
237
+ * ON THIS ORIGIN, THE WHOLE ADDRESS WHEN IT IS NOT: on the hosted sign-in
238
+ * screen a path would name a page of the sign-in URL's. The query
239
+ * string goes too: `/dashboard?tab=billing` came back as `/dashboard`.
240
+ * The sign-in page follows it only on its own origin (`requestedRedirectUrl`).
241
+ */
242
+ target.searchParams.set(
243
+ REDIRECT_URL_PARAMETER,
244
+ target.origin === here.origin ? `${here.pathname}${here.search}` : here.href
245
+ );
246
+ const response = NextResponse.redirect(target);
247
+ // The ticket is in this address; the page it leads to has no need to be told where it came from.
248
+ if (ticket) response.headers.set('Referrer-Policy', 'no-referrer');
249
+ return response;
250
+ };
251
+
252
+ if (!token) return turnAway();
253
+
254
+ try {
255
+ const verified = await verifyToken(token, {
256
+ ...(apiUrl ? { apiUrl } : {}),
257
+ ...(publishableKey ? { publishableKey } : {}),
258
+ ...(environmentId ? { environmentId } : {}),
259
+ });
260
+
261
+ if (requireVerifiedEmail && !verified.emailVerified) {
262
+ return turnAway();
263
+ }
264
+
265
+ // Passed to the application so a server component can read the user
266
+ // without verifying a second time. Set on the *outgoing* request headers,
267
+ // which the visitor cannot forge — anything they send under these names
268
+ // is replaced here (and removed on public pages; see `FORWARDED_HEADERS`).
269
+ const headers = withoutForwardedHeaders(request);
270
+ headers.set('x-grantry-user-id', verified.userId);
271
+ headers.set('x-grantry-session-id', verified.sessionId);
272
+
273
+ return NextResponse.next({ request: { headers } });
274
+ } catch (cause) {
275
+ /*
276
+ * ⚠️ A MISSING AUDIENCE IS THE ONE FAILURE THAT MUST NOT REDIRECT. Every
277
+ * other reason means this request cannot be trusted, and the sign-in page
278
+ * is the honest answer; `no_audience` means the application never said
279
+ * which Grantry environment it belongs to, and sending the visitor to
280
+ * sign in would loop them forever against a check that can never pass.
281
+ */
282
+ if (
283
+ cause instanceof Error &&
284
+ cause.name === 'TokenVerificationError' &&
285
+ (cause as { reason?: string }).reason === 'no_audience'
286
+ ) {
287
+ throw cause;
288
+ }
289
+ return turnAway();
290
+ }
291
+ };
292
+ }
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The React provider, saying "nextjs" — and reading `NEXT_PUBLIC_GRANTRY_*`.
5
+ *
6
+ * ## Why this file exists at all
7
+ *
8
+ * Everything else in this package is a straight re-export of `@grantry/react`,
9
+ * on the principle that a second implementation is a second thing to drift. So
10
+ * a wrapper needs a reason, and it has two:
11
+ *
12
+ * ⚠️ A NEXT.JS APP AND A PLAIN REACT APP ARE INDISTINGUISHABLE ON THE WIRE
13
+ * UNLESS ONE OF THEM SAYS SO. They render the same provider, construct the same
14
+ * client and send the same requests. That is normally a virtue and here it is a
15
+ * problem: the development Overview's setup guide watches for the merchant's
16
+ * app to connect and then shows them how to sign a user up *in the framework
17
+ * that connected*. Without this, everyone who installed `@grantry/nextjs` would
18
+ * be shown a plain-React snippet — no `middleware.ts`, no server helpers — on
19
+ * the one screen whose whole job is knowing what they are building.
20
+ *
21
+ * ⚠️ AND IT IS NOT DETECTABLE AT RUNTIME. `__NEXT_DATA__` exists only in the
22
+ * Pages Router, `process.env.NEXT_RUNTIME` is server-only, and sniffing for
23
+ * either would make the answer depend on which router the merchant chose. The
24
+ * honest signal is which package they installed, and this is the file that
25
+ * knows.
26
+ *
27
+ * ⚠️ THE SECOND REASON IS THE ENVIRONMENT VARIABLES, WHICH ONLY THIS PACKAGE
28
+ * CAN READ. Where the application's pages are is its own code since 0242 —
29
+ * `signInUrl`, `signUpUrl`, the fallback and force redirect URLs and
30
+ * `afterSignOutUrl`, Clerk's names — and a Next.js application sets them as
31
+ * `NEXT_PUBLIC_GRANTRY_*`. They become props of the React provider here, under
32
+ * whatever props were passed, so the rules stay `@grantry/react`'s.
33
+ */
34
+
35
+ import {
36
+ GrantryProvider as ReactProvider,
37
+ mergeRouteOptions,
38
+ type GrantryProviderProps,
39
+ type RouteOptions,
40
+ } from '@grantry/react';
41
+
42
+ /**
43
+ * `NEXT_PUBLIC_GRANTRY_*`, as route options.
44
+ *
45
+ * ⚠️ EVERY NAME WRITTEN OUT IN FULL. Next.js puts a `NEXT_PUBLIC_` variable into
46
+ * the browser bundle only where `process.env.NAME` appears literally; a name
47
+ * built from a prefix is correct on the server and `undefined` in the browser,
48
+ * which is a hydration mismatch as well as a wrong link.
49
+ */
50
+ function publicRouteOptions(): RouteOptions {
51
+ return {
52
+ signInUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_IN_URL,
53
+ signUpUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_UP_URL,
54
+ signInFallbackRedirectUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_IN_FALLBACK_REDIRECT_URL,
55
+ signUpFallbackRedirectUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_UP_FALLBACK_REDIRECT_URL,
56
+ signInForceRedirectUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_IN_FORCE_REDIRECT_URL,
57
+ signUpForceRedirectUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_UP_FORCE_REDIRECT_URL,
58
+ afterSignOutUrl: process.env.NEXT_PUBLIC_GRANTRY_AFTER_SIGN_OUT_URL,
59
+ };
60
+ }
61
+
62
+ export function GrantryProvider(props: GrantryProviderProps) {
63
+ /*
64
+ * Props first, then the variables — Clerk's order — merged option by option,
65
+ * so a prop left undefined does not hide the variable beneath it.
66
+ *
67
+ * `props.sdk` after `sdk="nextjs"`, so it is genuinely a default rather than
68
+ * a lock. An application embedding this inside something else can still say
69
+ * what it is, and passing nothing — the normal case — reports `nextjs`.
70
+ */
71
+ const routes = mergeRouteOptions(props, publicRouteOptions());
72
+ return <ReactProvider sdk="nextjs" {...props} {...routes} />;
73
+ }
package/src/server.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Server-side auth for the Next.js App Router — `@grantry/nextjs/server`.
3
+ *
4
+ * const { userId } = await auth();
5
+ * if (!userId) redirect('/sign-in');
6
+ *
7
+ * Reads the access-token cookie the client SDK mirrors onto this app's domain
8
+ * and verifies it against Grantry's published keys. No network call in the hot
9
+ * path once the keys are cached, so this is cheap enough to use on every render.
10
+ *
11
+ * ⚠️ `auth()` IS THE APP ROUTER'S ALONE, SO THE VERIFIER IS EXPORTED BESIDE IT. It reads
12
+ * `next/headers`, which a Pages Router `getServerSideProps` or API route cannot call; those verify
13
+ * `req.cookies.grantry_token` with `verifyToken` themselves. The docs said "verify in
14
+ * getServerSideProps" while this entry point offered nothing to verify with, and a Next.js
15
+ * application is told not to install `@grantry/react` beside this package (the docs audit,
16
+ * 1 October 2026).
17
+ */
18
+ import { cookies } from 'next/headers';
19
+ import {
20
+ TokenVerificationError,
21
+ verifyToken,
22
+ type VerifiedToken,
23
+ type VerifyFailureReason,
24
+ type VerifyTokenOptions,
25
+ } from '@grantry/react/server';
26
+ import { TOKEN_COOKIE_NAME, tokenFromCookie } from './cookie';
27
+
28
+ export interface AuthObject {
29
+ /** Null when the visitor is not signed in. */
30
+ userId: string | null;
31
+ sessionId: string | null;
32
+ environmentId: string | null;
33
+ emailVerified: boolean;
34
+ /**
35
+ * The operator, when this visitor is somebody from your team viewing as the user — the verified
36
+ * `act` claim. Refuse your own sensitive actions while it is set.
37
+ */
38
+ actor: { sub: string } | null;
39
+ /** The verified token, for callers that want the expiry or full claims. */
40
+ token: VerifiedToken | null;
41
+ /**
42
+ * Why authentication failed, when it did.
43
+ *
44
+ * `expired` in particular is worth distinguishing: it usually means the tab
45
+ * was asleep and the client will recover on its own, not that anything is
46
+ * misconfigured.
47
+ */
48
+ reason: TokenVerificationError['reason'] | null;
49
+ }
50
+
51
+ const SIGNED_OUT: AuthObject = {
52
+ userId: null,
53
+ sessionId: null,
54
+ environmentId: null,
55
+ emailVerified: false,
56
+ actor: null,
57
+ token: null,
58
+ reason: null,
59
+ };
60
+
61
+ export interface AuthOptions {
62
+ /** Override for self-hosted deployments and local development. */
63
+ apiUrl?: string;
64
+ /**
65
+ * Your publishable key — the same one the provider takes.
66
+ *
67
+ * ⚠️ IT IS WHAT SAYS WHICH APPLICATION A TOKEN MUST BELONG TO. Read from
68
+ * `GRANTRY_PUBLISHABLE_KEY`, then `NEXT_PUBLIC_GRANTRY_PUBLISHABLE_KEY` — the
69
+ * variable `<GrantryProvider>` is already given — when omitted, so the
70
+ * ordinary Next.js application passes nothing here. With no key and no
71
+ * `environmentId`, `auth()` throws `no_audience` rather than accepting a token
72
+ * minted for another application. See `@grantry/react/server`.
73
+ */
74
+ publishableKey?: string;
75
+ /** The environment id, for a deployment that configures it directly. */
76
+ environmentId?: string;
77
+ }
78
+
79
+ /**
80
+ * The current visitor, in a server component, route handler or server action.
81
+ *
82
+ * Never throws for an unauthenticated visitor — being signed out is an ordinary
83
+ * state, not an exception. The `reason` field carries the detail when a token
84
+ * was present but unusable.
85
+ */
86
+ export async function auth(options: AuthOptions = {}): Promise<AuthObject> {
87
+ const store = await cookies();
88
+ const raw = tokenFromCookie(store.get(TOKEN_COOKIE_NAME));
89
+ if (!raw) return SIGNED_OUT;
90
+
91
+ try {
92
+ const token = await verifyToken(raw, options);
93
+ return {
94
+ userId: token.userId,
95
+ sessionId: token.sessionId,
96
+ environmentId: token.environmentId,
97
+ emailVerified: token.emailVerified,
98
+ actor: token.actor,
99
+ token,
100
+ reason: null,
101
+ };
102
+ } catch (cause) {
103
+ /*
104
+ * ⚠️ `no_audience` IS RETHROWN RATHER THAN REPORTED AS SIGNED OUT. It says
105
+ * this deployment never configured which application to accept tokens for,
106
+ * which is a fault in the app rather than a fact about the visitor —
107
+ * swallowing it would sign every user out at once and look like a broken
108
+ * cookie.
109
+ */
110
+ if (cause instanceof TokenVerificationError && cause.reason !== 'no_audience') {
111
+ return { ...SIGNED_OUT, reason: cause.reason };
112
+ }
113
+ throw cause;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * `auth()`, but throws when nobody is signed in.
119
+ *
120
+ * For routes where being signed out is a bug rather than a case to handle —
121
+ * it narrows `userId` to a string, so the call site needs no null check and
122
+ * cannot forget one.
123
+ */
124
+ export async function requireAuth(
125
+ options: AuthOptions = {}
126
+ ): Promise<AuthObject & { userId: string; sessionId: string }> {
127
+ const result = await auth(options);
128
+
129
+ if (!result.userId || !result.sessionId) {
130
+ throw new Error(
131
+ 'requireAuth() was called but no user is signed in. ' +
132
+ 'Use auth() and handle the signed-out case, or protect this route in middleware.'
133
+ );
134
+ }
135
+
136
+ return result as AuthObject & { userId: string; sessionId: string };
137
+ }
138
+
139
+ /*
140
+ * `verifyToken` for the Pages Router and anything else `auth()` cannot reach — see the note at the
141
+ * top. The same function `@grantry/react/server` exports, not a copy.
142
+ */
143
+ export {
144
+ TokenVerificationError,
145
+ verifyToken,
146
+ type VerifiedToken,
147
+ type VerifyFailureReason,
148
+ type VerifyTokenOptions,
149
+ };
150
+
151
+ /*
152
+ * Spending a "Verify it's you" grant before a sensitive action (0330), with `auth()`'s `sessionId`
153
+ * and `userId` — the same function `@grantry/react/server` exports, for the reason above.
154
+ */
155
+ export {
156
+ ReverificationCheckError,
157
+ verifyReverification,
158
+ type VerifiedReverification,
159
+ } from '@grantry/react/server';