@stacknet/userutils 0.6.4 → 0.6.5
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.
- package/dist/server/index.d.cts +499 -0
- package/dist/server/index.d.ts +499 -0
- package/package.json +1 -1
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
type Web3Chain = 'ethereum' | 'solana' | 'polygon' | 'arbitrum' | 'base';
|
|
2
|
+
/**
|
|
3
|
+
* Client-safe session metadata. Does NOT contain the JWT.
|
|
4
|
+
* Use ServerSession (from @stacknet/userutils/server) for server-side code that needs the JWT.
|
|
5
|
+
*/
|
|
6
|
+
interface Session {
|
|
7
|
+
id: string;
|
|
8
|
+
app_identity_id: string;
|
|
9
|
+
global_identity_commitment: string;
|
|
10
|
+
expires_at: number;
|
|
11
|
+
created_at: number;
|
|
12
|
+
stack_id?: string;
|
|
13
|
+
signed_by: string[];
|
|
14
|
+
user_id?: string;
|
|
15
|
+
address?: string;
|
|
16
|
+
chain?: Web3Chain;
|
|
17
|
+
auth_method?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Server-only session type that includes the JWT.
|
|
22
|
+
* NEVER export this from the client bundle.
|
|
23
|
+
*/
|
|
24
|
+
interface ServerSession extends Session {
|
|
25
|
+
jwt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Decode JWT payload without verification (server-side helper) */
|
|
29
|
+
declare function decodeJWTPayload(jwt: string): Record<string, any> | null;
|
|
30
|
+
/** Sign a JWT with HMAC-SHA256 */
|
|
31
|
+
declare function signJWT(payload: Record<string, any>, secret: string): string;
|
|
32
|
+
/** Verify a JWT signature with HMAC-SHA256 (constant-time comparison) */
|
|
33
|
+
declare function verifyJWTSignature(jwt: string, secret: string): boolean;
|
|
34
|
+
/** Verify JWT and return payload if valid (checks signature + expiry) */
|
|
35
|
+
declare function verifyJWT(jwt: string, secret: string): Record<string, any> | null;
|
|
36
|
+
/**
|
|
37
|
+
* Check if JWT needs refresh and return a new one if so.
|
|
38
|
+
* Returns null if no refresh needed or JWT is invalid.
|
|
39
|
+
*/
|
|
40
|
+
declare function maybeRefreshJWT(jwt: string, secret: string, expirySeconds?: number, refreshWindowSeconds?: number): string | null;
|
|
41
|
+
/** Generate a cryptographically secure random token */
|
|
42
|
+
declare function generateToken(bytes?: number): string;
|
|
43
|
+
/** Options controlling how the real client IP is extracted from a Request.
|
|
44
|
+
*
|
|
45
|
+
* The previous implementation trusted `X-Forwarded-For[0]` unconditionally,
|
|
46
|
+
* which lets any direct caller pin arbitrary values and bypass per-IP rate
|
|
47
|
+
* limits. The correct read depends on how many reverse proxies sit between
|
|
48
|
+
* the app and the real client: with N trusted hops, the real client IP is
|
|
49
|
+
* at position `(length - N)` in the XFF list, because each trusted hop
|
|
50
|
+
* appends its peer IP and the attacker-controlled prefix is pushed left. */
|
|
51
|
+
interface IPExtractorConfig {
|
|
52
|
+
/** Number of trusted reverse-proxy hops between this app and the real client.
|
|
53
|
+
* - `0`: do NOT trust X-Forwarded-For (app is directly internet-exposed).
|
|
54
|
+
* - `1` (default): one trusted proxy (e.g. single LB, Vercel edge, Nginx).
|
|
55
|
+
* - `N`: N trusted hops; the real client IP is `XFF[length - N]`.
|
|
56
|
+
* If XFF has fewer entries than expected, the chain is misconfigured or
|
|
57
|
+
* spoofed and the extractor falls through to the next source. */
|
|
58
|
+
trustedProxyCount?: number;
|
|
59
|
+
/** Trust the `X-Real-IP` header. Only enable if your proxy sets it AND
|
|
60
|
+
* strips any inbound `X-Real-IP` from clients. Default: `false`. */
|
|
61
|
+
trustRealIpHeader?: boolean;
|
|
62
|
+
/** Override entirely — return the real client IP from a request however
|
|
63
|
+
* you know best (e.g. a platform-specific header like `cf-connecting-ip`
|
|
64
|
+
* or `x-vercel-forwarded-for`). If this returns a non-empty string it
|
|
65
|
+
* wins; if it returns null/empty the other strategies run. */
|
|
66
|
+
customExtractor?: (request: Request) => string | null | undefined;
|
|
67
|
+
}
|
|
68
|
+
/** Extract the real client IP address from a request.
|
|
69
|
+
*
|
|
70
|
+
* Default behavior (`trustedProxyCount: 1`) is safe for the common case of
|
|
71
|
+
* one trusted reverse proxy. Consumers with no proxy must pass `0`, and
|
|
72
|
+
* consumers behind multiple proxies must pass the exact hop count. */
|
|
73
|
+
declare function extractIP(request: Request, config?: IPExtractorConfig): string;
|
|
74
|
+
|
|
75
|
+
/** Server-side configuration for handler factories */
|
|
76
|
+
interface ServerConfig {
|
|
77
|
+
/** HMAC-SHA256 secret for signing JWTs */
|
|
78
|
+
authSecret: string;
|
|
79
|
+
/** StackNet backend URL (always https://stacknet.magma-rpc.com) */
|
|
80
|
+
stacknetUrl: string;
|
|
81
|
+
/** StackNet stack identifier */
|
|
82
|
+
stackId: string;
|
|
83
|
+
/** JWT secret for re-signing to StackNet (defaults to authSecret) */
|
|
84
|
+
stacknetJwtSecret?: string;
|
|
85
|
+
/** Cookie domain for subdomain sharing (e.g. '.geoff.ai') */
|
|
86
|
+
cookieDomain?: string;
|
|
87
|
+
/** Use Secure flag on cookies (default: true) */
|
|
88
|
+
secureCookies?: boolean;
|
|
89
|
+
/** Session max age in seconds (default: 604800 = 7 days) */
|
|
90
|
+
sessionMaxAge?: number;
|
|
91
|
+
/** JWT expiry in seconds (default: 900 = 15 minutes) */
|
|
92
|
+
jwtExpiry?: number;
|
|
93
|
+
/**
|
|
94
|
+
* Google OAuth client ID (single). Used by createGoogleOneTapHandler to
|
|
95
|
+
* validate the `aud` claim on incoming Google ID tokens. Required for
|
|
96
|
+
* Google One Tap — without it, an ID token issued to any other Google
|
|
97
|
+
* application could be replayed against this endpoint.
|
|
98
|
+
*/
|
|
99
|
+
googleClientId?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Google OAuth client IDs (multiple). Use when the stack accepts tokens
|
|
102
|
+
* from more than one Google client (e.g. web + native).
|
|
103
|
+
*/
|
|
104
|
+
googleClientIds?: string[];
|
|
105
|
+
/**
|
|
106
|
+
* How the handlers should derive the real client IP for rate-limit keys.
|
|
107
|
+
* Defaults to `{ trustedProxyCount: 1 }`. Set `trustedProxyCount: 0` if
|
|
108
|
+
* the app is exposed directly (no proxy) — otherwise any caller can pin
|
|
109
|
+
* their X-Forwarded-For and bypass rate limiting.
|
|
110
|
+
*/
|
|
111
|
+
ipConfig?: IPExtractorConfig;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Rate limiter interface — implement with Redis, Upstash, or use the in-memory default */
|
|
115
|
+
interface RateLimiter {
|
|
116
|
+
check(key: string): Promise<{
|
|
117
|
+
allowed: boolean;
|
|
118
|
+
remaining: number;
|
|
119
|
+
retryAfter?: number;
|
|
120
|
+
}>;
|
|
121
|
+
}
|
|
122
|
+
/** Replay store interface — for preventing JWT re-sign replay attacks */
|
|
123
|
+
interface ReplayStore {
|
|
124
|
+
has(key: string): Promise<boolean>;
|
|
125
|
+
set(key: string, ttlSeconds: number): Promise<void>;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* In-memory rate limiter with sliding window.
|
|
129
|
+
*
|
|
130
|
+
* Safe default for single-process deployments. For multi-process or
|
|
131
|
+
* distributed environments, provide a Redis-backed RateLimiter instead.
|
|
132
|
+
*/
|
|
133
|
+
declare function createInMemoryRateLimiter(opts: {
|
|
134
|
+
maxRequests: number;
|
|
135
|
+
windowMs: number;
|
|
136
|
+
}): RateLimiter;
|
|
137
|
+
/** In-memory replay store (single-process, entries auto-expire) */
|
|
138
|
+
declare function createInMemoryReplayStore(): ReplayStore;
|
|
139
|
+
|
|
140
|
+
interface AuthCallbackOptions {
|
|
141
|
+
rateLimiter?: RateLimiter;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Factory: POST handler for auth callback (login completion).
|
|
145
|
+
*
|
|
146
|
+
* Accepts wallet signature verification or OTP results from client,
|
|
147
|
+
* validates with StackNet, sets HttpOnly JWT cookie + public session cookie + CSRF cookie.
|
|
148
|
+
*/
|
|
149
|
+
declare function createAuthCallback(config: ServerConfig, opts?: AuthCallbackOptions): (request: Request) => Promise<Response>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Factory: POST handler for logout.
|
|
153
|
+
* Revokes session with StackNet and clears all auth cookies.
|
|
154
|
+
*/
|
|
155
|
+
declare function createLogoutHandler(config: Pick<ServerConfig, 'stacknetUrl' | 'secureCookies' | 'cookieDomain'> & {
|
|
156
|
+
/**
|
|
157
|
+
* HMAC secret used to verify the JWT signature before extracting the
|
|
158
|
+
* sessionId for upstream revocation. STRONGLY RECOMMENDED.
|
|
159
|
+
*
|
|
160
|
+
* Without this, the handler skips upstream revocation entirely and only
|
|
161
|
+
* clears cookies — because trusting an unverified JWT for the sessionId
|
|
162
|
+
* would let an attacker plant a forged cookie and trigger DELETE on
|
|
163
|
+
* another user's session.
|
|
164
|
+
*/
|
|
165
|
+
authSecret?: string;
|
|
166
|
+
}): (request: Request) => Promise<Response>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Factory: GET handler for session validation.
|
|
170
|
+
* Reads HttpOnly JWT cookie, validates, returns public session info.
|
|
171
|
+
* Transparently refreshes JWT if close to expiry.
|
|
172
|
+
*/
|
|
173
|
+
declare function createSessionHandler(config: Pick<ServerConfig, 'authSecret' | 'jwtExpiry' | 'secureCookies' | 'cookieDomain' | 'sessionMaxAge'>): (request: Request) => Promise<Response>;
|
|
174
|
+
|
|
175
|
+
interface OTPHandlerConfig extends Pick<ServerConfig, 'authSecret' | 'secureCookies' | 'cookieDomain' | 'sessionMaxAge' | 'jwtExpiry'> {
|
|
176
|
+
/** The OTP secret to validate against */
|
|
177
|
+
otpSecret: string;
|
|
178
|
+
/** Rate limiter (default: 5 attempts per 5 min per IP) */
|
|
179
|
+
rateLimiter?: RateLimiter;
|
|
180
|
+
/** How to extract the real client IP for rate-limit keys. Defaults to
|
|
181
|
+
* `{ trustedProxyCount: 1 }`. Set `trustedProxyCount: 0` if the handler
|
|
182
|
+
* is exposed directly (no proxy in front). */
|
|
183
|
+
ipConfig?: IPExtractorConfig;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Factory: POST handler for OTP verification.
|
|
187
|
+
* Validates OTP code, creates session JWT, sets HttpOnly cookie.
|
|
188
|
+
*/
|
|
189
|
+
declare function createOTPHandler(config: OTPHandlerConfig): (request: Request) => Promise<Response>;
|
|
190
|
+
|
|
191
|
+
interface OAuthHandlerConfig {
|
|
192
|
+
rateLimiter?: RateLimiter;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Factory: OAuth flow handlers.
|
|
196
|
+
*
|
|
197
|
+
* Returns two handlers:
|
|
198
|
+
* - GET /api/auth/oauth/[provider] — Starts OAuth flow, returns redirect URL
|
|
199
|
+
* - POST /api/auth/oauth/[provider]/callback — Handles OAuth callback, sets cookies
|
|
200
|
+
*/
|
|
201
|
+
declare function createOAuthHandlers(config: ServerConfig, opts?: OAuthHandlerConfig): {
|
|
202
|
+
startFlow: (request: Request) => Promise<Response>;
|
|
203
|
+
handleCallback: (request: Request) => Promise<Response>;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
interface GoogleOneTapHandlerConfig {
|
|
207
|
+
rateLimiter?: RateLimiter;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Factory: POST handler for Google One Tap credential verification.
|
|
211
|
+
*
|
|
212
|
+
* Receives the Google JWT credential from the client, verifies it with
|
|
213
|
+
* Google's tokeninfo endpoint, then creates a StackNet session.
|
|
214
|
+
*/
|
|
215
|
+
declare function createGoogleOneTapHandler(config: ServerConfig, opts?: GoogleOneTapHandlerConfig): (request: Request) => Promise<Response>;
|
|
216
|
+
|
|
217
|
+
interface PreviewCodeHandlerOptions {
|
|
218
|
+
rateLimiter?: RateLimiter;
|
|
219
|
+
/** Maximum session length in seconds (default 3600 = 1h). Preview
|
|
220
|
+
* codes are meant for short-lived demo access — keep this
|
|
221
|
+
* meaningfully shorter than a normal wallet/OAuth session. */
|
|
222
|
+
sessionMaxAge?: number;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Factory: POST handler for preview-code login.
|
|
226
|
+
*
|
|
227
|
+
* Flow:
|
|
228
|
+
* 1. Client POSTs `{code: "692232"}` to this route.
|
|
229
|
+
* 2. Handler validates the code against
|
|
230
|
+
* `${config.stacknetUrl}/v1/preview-codes/:code` (public read —
|
|
231
|
+
* the 6-digit code is itself the bearer credential).
|
|
232
|
+
* 3. Rejects on not-found / revoked / expired / exhausted.
|
|
233
|
+
* 4. Mints a local JWT (signed with `config.authSecret`) with
|
|
234
|
+
* `sub = global_id = "preview_<code>"` and `auth_method =
|
|
235
|
+
* "preview_code"`. Sets it as the `stackauth_jwt` HttpOnly
|
|
236
|
+
* cookie so `useSession` recognizes the user as authenticated.
|
|
237
|
+
* 5. Also sets the public `stackauth_session` cookie + CSRF cookie
|
|
238
|
+
* to match the normal wallet / OAuth callback shape.
|
|
239
|
+
*
|
|
240
|
+
* The preview-code bearer (`pc_<code>`) is expected to already be in
|
|
241
|
+
* `localStorage['stacknet-api-key']` — the widget sets that
|
|
242
|
+
* client-side after a successful POST here. Together, the cookie
|
|
243
|
+
* drives same-origin `useSession` / server-rendered pages, and the
|
|
244
|
+
* bearer drives direct stacknet API calls from the browser.
|
|
245
|
+
*
|
|
246
|
+
* Security:
|
|
247
|
+
* - Rate-limited per IP (10 attempts / minute by default).
|
|
248
|
+
* - Format guard: only 6-digit numeric codes reach the upstream
|
|
249
|
+
* validator so nothing weird hits stacknet.
|
|
250
|
+
* - No upstream JWT is trusted — the stacknet response is a
|
|
251
|
+
* read-only row and the session JWT is freshly signed locally
|
|
252
|
+
* with the app's own `authSecret`.
|
|
253
|
+
*/
|
|
254
|
+
declare function createPreviewCodeHandler(config: ServerConfig, opts?: PreviewCodeHandlerOptions): (request: Request) => Promise<Response>;
|
|
255
|
+
|
|
256
|
+
interface BillingProxyConfig extends Pick<ServerConfig, 'authSecret' | 'stacknetUrl' | 'stackId' | 'stacknetJwtSecret' | 'secureCookies' | 'cookieDomain' | 'sessionMaxAge' | 'jwtExpiry'> {
|
|
257
|
+
/** Rate limiter for mutations (default: 20/min per user) */
|
|
258
|
+
rateLimiter?: RateLimiter;
|
|
259
|
+
/**
|
|
260
|
+
* Canonical absolute origin (e.g. "https://app.example.com") used when
|
|
261
|
+
* constructing Stripe success/cancel URLs. STRONGLY RECOMMENDED.
|
|
262
|
+
*
|
|
263
|
+
* Without this, the origin is derived from the request URL — which is
|
|
264
|
+
* populated from the `Host` header. If the app is deployed behind a
|
|
265
|
+
* proxy that does not validate / rewrite `Host`, an attacker can send
|
|
266
|
+
* `Host: evil.example` and the post-checkout redirect will point there.
|
|
267
|
+
* Stripe's dashboard allowlist catches most cases, but we should not
|
|
268
|
+
* depend on that alone.
|
|
269
|
+
*
|
|
270
|
+
* Must be a full http(s) origin with no path. Factory throws on
|
|
271
|
+
* malformed input so misconfigurations surface at boot, not at runtime.
|
|
272
|
+
*/
|
|
273
|
+
canonicalOrigin?: string;
|
|
274
|
+
}
|
|
275
|
+
type Handler = (request: Request) => Promise<Response>;
|
|
276
|
+
/**
|
|
277
|
+
* Factory: creates all billing route handlers that proxy to StackNet.
|
|
278
|
+
*
|
|
279
|
+
* All POST handlers validate CSRF tokens. All handlers validate the JWT cookie
|
|
280
|
+
* and transparently refresh if close to expiry.
|
|
281
|
+
*/
|
|
282
|
+
declare function createBillingProxy(config: BillingProxyConfig): {
|
|
283
|
+
plans: {
|
|
284
|
+
GET: Handler;
|
|
285
|
+
};
|
|
286
|
+
subscription: {
|
|
287
|
+
GET: Handler;
|
|
288
|
+
};
|
|
289
|
+
subscribe: {
|
|
290
|
+
POST: Handler;
|
|
291
|
+
};
|
|
292
|
+
cancel: {
|
|
293
|
+
POST: Handler;
|
|
294
|
+
};
|
|
295
|
+
usage: {
|
|
296
|
+
GET: Handler;
|
|
297
|
+
};
|
|
298
|
+
history: {
|
|
299
|
+
GET: Handler;
|
|
300
|
+
};
|
|
301
|
+
prepaid: {
|
|
302
|
+
POST: Handler;
|
|
303
|
+
};
|
|
304
|
+
verifyPrepaid: {
|
|
305
|
+
POST: Handler;
|
|
306
|
+
};
|
|
307
|
+
verifySession: {
|
|
308
|
+
POST: Handler;
|
|
309
|
+
};
|
|
310
|
+
subscribeSol: {
|
|
311
|
+
POST: Handler;
|
|
312
|
+
};
|
|
313
|
+
prepaidSol: {
|
|
314
|
+
POST: Handler;
|
|
315
|
+
};
|
|
316
|
+
topup: {
|
|
317
|
+
POST: Handler;
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Factory: POST handler for Stripe webhooks.
|
|
323
|
+
* Forwards raw body + stripe-signature to StackNet for verification and processing.
|
|
324
|
+
*/
|
|
325
|
+
declare function createWebhookHandler(config: Pick<ServerConfig, 'stacknetUrl' | 'stackId'>): (request: Request) => Promise<Response>;
|
|
326
|
+
|
|
327
|
+
interface CSRFConfig {
|
|
328
|
+
/** Cookie name (default: '__csrf') */
|
|
329
|
+
cookieName?: string;
|
|
330
|
+
/** Header name (default: 'x-csrf-token') */
|
|
331
|
+
headerName?: string;
|
|
332
|
+
/** Token length in bytes (default: 32) */
|
|
333
|
+
tokenLength?: number;
|
|
334
|
+
/** Use Secure flag on cookie (default: true) */
|
|
335
|
+
secure?: boolean;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Create CSRF protection using the double-submit cookie pattern.
|
|
339
|
+
*
|
|
340
|
+
* 1. Server sets a non-HttpOnly cookie with a random token
|
|
341
|
+
* 2. Client reads the cookie and sends the token in a header on mutations
|
|
342
|
+
* 3. Server validates cookie === header (attacker can't read cookie cross-origin)
|
|
343
|
+
*/
|
|
344
|
+
declare function createCSRFProtection(config?: CSRFConfig): {
|
|
345
|
+
/**
|
|
346
|
+
* Generate a CSRF token and add Set-Cookie header to a response.
|
|
347
|
+
* Call this on auth callback (login) to establish the CSRF cookie.
|
|
348
|
+
*/
|
|
349
|
+
generateToken(headers: Headers): string;
|
|
350
|
+
/**
|
|
351
|
+
* Validate a request's CSRF token (cookie vs header).
|
|
352
|
+
* Returns true if valid, false if not.
|
|
353
|
+
*/
|
|
354
|
+
validateRequest(request: Request): {
|
|
355
|
+
valid: boolean;
|
|
356
|
+
error?: string;
|
|
357
|
+
};
|
|
358
|
+
/** Cookie name for client-side reading */
|
|
359
|
+
cookieName: string;
|
|
360
|
+
/** Header name for client-side sending */
|
|
361
|
+
headerName: string;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/** Standard security response headers */
|
|
365
|
+
declare function securityHeaders(): Record<string, string>;
|
|
366
|
+
/**
|
|
367
|
+
* Wrap a request handler to add security headers to the response.
|
|
368
|
+
*/
|
|
369
|
+
declare function withSecurityHeaders(handler: (request: Request) => Promise<Response> | Response): (request: Request) => Promise<Response>;
|
|
370
|
+
/**
|
|
371
|
+
* Generate security headers config for Next.js next.config.ts.
|
|
372
|
+
*
|
|
373
|
+
* Usage in next.config.ts:
|
|
374
|
+
* ```ts
|
|
375
|
+
* import { nextSecurityHeaders } from '@stacknet/userutils/server';
|
|
376
|
+
* export default { headers: () => [{ source: '/(.*)', headers: nextSecurityHeaders() }] };
|
|
377
|
+
* ```
|
|
378
|
+
*/
|
|
379
|
+
declare function nextSecurityHeaders(): Array<{
|
|
380
|
+
key: string;
|
|
381
|
+
value: string;
|
|
382
|
+
}>;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Re-sign a JWT using StackNet's HMAC-SHA256 scheme.
|
|
386
|
+
*
|
|
387
|
+
* Geoff apps sign JWTs with AUTH_SECRET. StackNet validates with JWT_SECRET.
|
|
388
|
+
* This function re-signs using the StackNet secret so the backend resolves
|
|
389
|
+
* the correct per-user identity.
|
|
390
|
+
*/
|
|
391
|
+
declare function resignForStackNet(jwt: string, stacknetJwtSecret: string): string | null;
|
|
392
|
+
/**
|
|
393
|
+
* Build headers for proxying a user-scoped request to StackNet.
|
|
394
|
+
* Re-signs the JWT so StackNet recognises the user's identity.
|
|
395
|
+
*
|
|
396
|
+
* Refuses to interpolate any value that doesn't match the compact JWT
|
|
397
|
+
* format (header.body.signature, base64url segments only) to prevent
|
|
398
|
+
* header injection.
|
|
399
|
+
*/
|
|
400
|
+
declare function buildStackNetHeaders(jwt: string, stacknetJwtSecret: string): Record<string, string>;
|
|
401
|
+
/**
|
|
402
|
+
* Extract JWT from a request's cookies or Authorization header.
|
|
403
|
+
*/
|
|
404
|
+
declare function extractJwt(request: Request): string | null;
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Server-side proxy helpers for StackNet preview codes.
|
|
408
|
+
*
|
|
409
|
+
* Preview codes are admin-minted 6-digit access credentials with a
|
|
410
|
+
* per-code token budget. Only the pinned admin global id (enforced
|
|
411
|
+
* by StackNet's state machine) can mint / list / revoke codes.
|
|
412
|
+
*
|
|
413
|
+
* These helpers wrap `fetch` against the StackNet HTTP layer with
|
|
414
|
+
* re-signed JWT cookies (same pattern as `buildStackNetHeaders`).
|
|
415
|
+
* Admin-console API routes are expected to call them; the raw
|
|
416
|
+
* endpoints are NOT exposed in the client bundle.
|
|
417
|
+
*/
|
|
418
|
+
interface PreviewCode {
|
|
419
|
+
code: string;
|
|
420
|
+
createdBy: string;
|
|
421
|
+
tokenBudget: number;
|
|
422
|
+
tokensUsed: number;
|
|
423
|
+
tokensRemaining: number;
|
|
424
|
+
createdAt: number;
|
|
425
|
+
expiresAt: number | null;
|
|
426
|
+
revoked: boolean;
|
|
427
|
+
/** Optional human-readable label (e.g. "Tester: Alice"). Absent on
|
|
428
|
+
* codes minted before the name field shipped. */
|
|
429
|
+
name?: string | null;
|
|
430
|
+
}
|
|
431
|
+
interface MintPreviewCodeOptions {
|
|
432
|
+
/** Token budget for the new code. Must be > 0. */
|
|
433
|
+
tokenBudget: number;
|
|
434
|
+
/** Optional explicit 6-digit code string. Server generates one
|
|
435
|
+
* if omitted. */
|
|
436
|
+
code?: string;
|
|
437
|
+
/** Optional Unix-ms expiry. */
|
|
438
|
+
expiresAt?: number;
|
|
439
|
+
/** Optional human-readable label for the code. Shown in the admin
|
|
440
|
+
* list so the operator can tell codes apart. */
|
|
441
|
+
name?: string;
|
|
442
|
+
}
|
|
443
|
+
interface PreviewCodesProxyConfig {
|
|
444
|
+
/** StackNet base URL (no trailing slash). */
|
|
445
|
+
stacknetBaseUrl: string;
|
|
446
|
+
/** Shared HMAC secret for re-signing the caller's JWT. */
|
|
447
|
+
stacknetJwtSecret: string;
|
|
448
|
+
/** Caller's StackAuth JWT (user identity). */
|
|
449
|
+
jwt: string;
|
|
450
|
+
}
|
|
451
|
+
/** Admin-only: mint a new preview code. Returns the new code row. */
|
|
452
|
+
declare function mintPreviewCode(cfg: PreviewCodesProxyConfig, options: MintPreviewCodeOptions): Promise<{
|
|
453
|
+
minted: boolean;
|
|
454
|
+
code: PreviewCode;
|
|
455
|
+
} | {
|
|
456
|
+
error: string;
|
|
457
|
+
status: number;
|
|
458
|
+
}>;
|
|
459
|
+
/** Admin-only: list every preview code in the system. */
|
|
460
|
+
declare function listPreviewCodes(cfg: PreviewCodesProxyConfig): Promise<PreviewCode[] | {
|
|
461
|
+
error: string;
|
|
462
|
+
status: number;
|
|
463
|
+
}>;
|
|
464
|
+
/** Public: read a code's balance + status. Used by auth middleware. */
|
|
465
|
+
declare function getPreviewCode(stacknetBaseUrl: string, code: string): Promise<PreviewCode | null>;
|
|
466
|
+
/** Admin-only: revoke a preview code. */
|
|
467
|
+
declare function revokePreviewCode(cfg: PreviewCodesProxyConfig, code: string): Promise<{
|
|
468
|
+
revoked: boolean;
|
|
469
|
+
code: PreviewCode;
|
|
470
|
+
} | {
|
|
471
|
+
error: string;
|
|
472
|
+
status: number;
|
|
473
|
+
}>;
|
|
474
|
+
/** Internal: debit tokens from a preview code. Called by the metering
|
|
475
|
+
* layer after inference completes. */
|
|
476
|
+
declare function redeemPreviewCode(stacknetBaseUrl: string, code: string, tokens: number): Promise<{
|
|
477
|
+
redeemed: boolean;
|
|
478
|
+
code: string;
|
|
479
|
+
tokensUsed: number;
|
|
480
|
+
tokensRemaining: number;
|
|
481
|
+
} | {
|
|
482
|
+
error: string;
|
|
483
|
+
status: number;
|
|
484
|
+
}>;
|
|
485
|
+
/** Allowlist of admin global ids that can mint / revoke preview
|
|
486
|
+
* codes. Mirrors PREVIEW_CODE_ADMIN_GLOBAL_IDS in the Rust state
|
|
487
|
+
* machine — must stay in sync. Admin-console UI and API route guards
|
|
488
|
+
* should call `isPreviewCodeAdmin(currentUser.userId)` instead of
|
|
489
|
+
* comparing against a single constant so every entry is accepted. */
|
|
490
|
+
declare const PREVIEW_CODE_ADMIN_GLOBAL_IDS: readonly string[];
|
|
491
|
+
/** Returns true if the given global id is in the preview-code admin
|
|
492
|
+
* allowlist. */
|
|
493
|
+
declare function isPreviewCodeAdmin(globalId: string | null | undefined): boolean;
|
|
494
|
+
/** Back-compat alias for callers that only need a single canonical
|
|
495
|
+
* admin id for display/logging. Don't use for gating — use
|
|
496
|
+
* `isPreviewCodeAdmin()` to accept every entry in the allowlist. */
|
|
497
|
+
declare const PREVIEW_CODE_ADMIN_GLOBAL_ID: string;
|
|
498
|
+
|
|
499
|
+
export { type AuthCallbackOptions, type BillingProxyConfig, type CSRFConfig, type GoogleOneTapHandlerConfig, type IPExtractorConfig, type MintPreviewCodeOptions, type OAuthHandlerConfig, type OTPHandlerConfig, PREVIEW_CODE_ADMIN_GLOBAL_ID, PREVIEW_CODE_ADMIN_GLOBAL_IDS, type PreviewCode, type PreviewCodeHandlerOptions, type PreviewCodesProxyConfig, type RateLimiter, type ReplayStore, type ServerConfig, type ServerSession, buildStackNetHeaders, createAuthCallback, createBillingProxy, createCSRFProtection, createGoogleOneTapHandler, createInMemoryRateLimiter, createInMemoryReplayStore, createLogoutHandler, createOAuthHandlers, createOTPHandler, createPreviewCodeHandler, createSessionHandler, createWebhookHandler, decodeJWTPayload, extractIP, extractJwt, generateToken, getPreviewCode, isPreviewCodeAdmin, listPreviewCodes, maybeRefreshJWT, mintPreviewCode, nextSecurityHeaders, redeemPreviewCode, resignForStackNet, revokePreviewCode, securityHeaders, signJWT, verifyJWT, verifyJWTSignature, withSecurityHeaders };
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
type Web3Chain = 'ethereum' | 'solana' | 'polygon' | 'arbitrum' | 'base';
|
|
2
|
+
/**
|
|
3
|
+
* Client-safe session metadata. Does NOT contain the JWT.
|
|
4
|
+
* Use ServerSession (from @stacknet/userutils/server) for server-side code that needs the JWT.
|
|
5
|
+
*/
|
|
6
|
+
interface Session {
|
|
7
|
+
id: string;
|
|
8
|
+
app_identity_id: string;
|
|
9
|
+
global_identity_commitment: string;
|
|
10
|
+
expires_at: number;
|
|
11
|
+
created_at: number;
|
|
12
|
+
stack_id?: string;
|
|
13
|
+
signed_by: string[];
|
|
14
|
+
user_id?: string;
|
|
15
|
+
address?: string;
|
|
16
|
+
chain?: Web3Chain;
|
|
17
|
+
auth_method?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Server-only session type that includes the JWT.
|
|
22
|
+
* NEVER export this from the client bundle.
|
|
23
|
+
*/
|
|
24
|
+
interface ServerSession extends Session {
|
|
25
|
+
jwt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Decode JWT payload without verification (server-side helper) */
|
|
29
|
+
declare function decodeJWTPayload(jwt: string): Record<string, any> | null;
|
|
30
|
+
/** Sign a JWT with HMAC-SHA256 */
|
|
31
|
+
declare function signJWT(payload: Record<string, any>, secret: string): string;
|
|
32
|
+
/** Verify a JWT signature with HMAC-SHA256 (constant-time comparison) */
|
|
33
|
+
declare function verifyJWTSignature(jwt: string, secret: string): boolean;
|
|
34
|
+
/** Verify JWT and return payload if valid (checks signature + expiry) */
|
|
35
|
+
declare function verifyJWT(jwt: string, secret: string): Record<string, any> | null;
|
|
36
|
+
/**
|
|
37
|
+
* Check if JWT needs refresh and return a new one if so.
|
|
38
|
+
* Returns null if no refresh needed or JWT is invalid.
|
|
39
|
+
*/
|
|
40
|
+
declare function maybeRefreshJWT(jwt: string, secret: string, expirySeconds?: number, refreshWindowSeconds?: number): string | null;
|
|
41
|
+
/** Generate a cryptographically secure random token */
|
|
42
|
+
declare function generateToken(bytes?: number): string;
|
|
43
|
+
/** Options controlling how the real client IP is extracted from a Request.
|
|
44
|
+
*
|
|
45
|
+
* The previous implementation trusted `X-Forwarded-For[0]` unconditionally,
|
|
46
|
+
* which lets any direct caller pin arbitrary values and bypass per-IP rate
|
|
47
|
+
* limits. The correct read depends on how many reverse proxies sit between
|
|
48
|
+
* the app and the real client: with N trusted hops, the real client IP is
|
|
49
|
+
* at position `(length - N)` in the XFF list, because each trusted hop
|
|
50
|
+
* appends its peer IP and the attacker-controlled prefix is pushed left. */
|
|
51
|
+
interface IPExtractorConfig {
|
|
52
|
+
/** Number of trusted reverse-proxy hops between this app and the real client.
|
|
53
|
+
* - `0`: do NOT trust X-Forwarded-For (app is directly internet-exposed).
|
|
54
|
+
* - `1` (default): one trusted proxy (e.g. single LB, Vercel edge, Nginx).
|
|
55
|
+
* - `N`: N trusted hops; the real client IP is `XFF[length - N]`.
|
|
56
|
+
* If XFF has fewer entries than expected, the chain is misconfigured or
|
|
57
|
+
* spoofed and the extractor falls through to the next source. */
|
|
58
|
+
trustedProxyCount?: number;
|
|
59
|
+
/** Trust the `X-Real-IP` header. Only enable if your proxy sets it AND
|
|
60
|
+
* strips any inbound `X-Real-IP` from clients. Default: `false`. */
|
|
61
|
+
trustRealIpHeader?: boolean;
|
|
62
|
+
/** Override entirely — return the real client IP from a request however
|
|
63
|
+
* you know best (e.g. a platform-specific header like `cf-connecting-ip`
|
|
64
|
+
* or `x-vercel-forwarded-for`). If this returns a non-empty string it
|
|
65
|
+
* wins; if it returns null/empty the other strategies run. */
|
|
66
|
+
customExtractor?: (request: Request) => string | null | undefined;
|
|
67
|
+
}
|
|
68
|
+
/** Extract the real client IP address from a request.
|
|
69
|
+
*
|
|
70
|
+
* Default behavior (`trustedProxyCount: 1`) is safe for the common case of
|
|
71
|
+
* one trusted reverse proxy. Consumers with no proxy must pass `0`, and
|
|
72
|
+
* consumers behind multiple proxies must pass the exact hop count. */
|
|
73
|
+
declare function extractIP(request: Request, config?: IPExtractorConfig): string;
|
|
74
|
+
|
|
75
|
+
/** Server-side configuration for handler factories */
|
|
76
|
+
interface ServerConfig {
|
|
77
|
+
/** HMAC-SHA256 secret for signing JWTs */
|
|
78
|
+
authSecret: string;
|
|
79
|
+
/** StackNet backend URL (always https://stacknet.magma-rpc.com) */
|
|
80
|
+
stacknetUrl: string;
|
|
81
|
+
/** StackNet stack identifier */
|
|
82
|
+
stackId: string;
|
|
83
|
+
/** JWT secret for re-signing to StackNet (defaults to authSecret) */
|
|
84
|
+
stacknetJwtSecret?: string;
|
|
85
|
+
/** Cookie domain for subdomain sharing (e.g. '.geoff.ai') */
|
|
86
|
+
cookieDomain?: string;
|
|
87
|
+
/** Use Secure flag on cookies (default: true) */
|
|
88
|
+
secureCookies?: boolean;
|
|
89
|
+
/** Session max age in seconds (default: 604800 = 7 days) */
|
|
90
|
+
sessionMaxAge?: number;
|
|
91
|
+
/** JWT expiry in seconds (default: 900 = 15 minutes) */
|
|
92
|
+
jwtExpiry?: number;
|
|
93
|
+
/**
|
|
94
|
+
* Google OAuth client ID (single). Used by createGoogleOneTapHandler to
|
|
95
|
+
* validate the `aud` claim on incoming Google ID tokens. Required for
|
|
96
|
+
* Google One Tap — without it, an ID token issued to any other Google
|
|
97
|
+
* application could be replayed against this endpoint.
|
|
98
|
+
*/
|
|
99
|
+
googleClientId?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Google OAuth client IDs (multiple). Use when the stack accepts tokens
|
|
102
|
+
* from more than one Google client (e.g. web + native).
|
|
103
|
+
*/
|
|
104
|
+
googleClientIds?: string[];
|
|
105
|
+
/**
|
|
106
|
+
* How the handlers should derive the real client IP for rate-limit keys.
|
|
107
|
+
* Defaults to `{ trustedProxyCount: 1 }`. Set `trustedProxyCount: 0` if
|
|
108
|
+
* the app is exposed directly (no proxy) — otherwise any caller can pin
|
|
109
|
+
* their X-Forwarded-For and bypass rate limiting.
|
|
110
|
+
*/
|
|
111
|
+
ipConfig?: IPExtractorConfig;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Rate limiter interface — implement with Redis, Upstash, or use the in-memory default */
|
|
115
|
+
interface RateLimiter {
|
|
116
|
+
check(key: string): Promise<{
|
|
117
|
+
allowed: boolean;
|
|
118
|
+
remaining: number;
|
|
119
|
+
retryAfter?: number;
|
|
120
|
+
}>;
|
|
121
|
+
}
|
|
122
|
+
/** Replay store interface — for preventing JWT re-sign replay attacks */
|
|
123
|
+
interface ReplayStore {
|
|
124
|
+
has(key: string): Promise<boolean>;
|
|
125
|
+
set(key: string, ttlSeconds: number): Promise<void>;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* In-memory rate limiter with sliding window.
|
|
129
|
+
*
|
|
130
|
+
* Safe default for single-process deployments. For multi-process or
|
|
131
|
+
* distributed environments, provide a Redis-backed RateLimiter instead.
|
|
132
|
+
*/
|
|
133
|
+
declare function createInMemoryRateLimiter(opts: {
|
|
134
|
+
maxRequests: number;
|
|
135
|
+
windowMs: number;
|
|
136
|
+
}): RateLimiter;
|
|
137
|
+
/** In-memory replay store (single-process, entries auto-expire) */
|
|
138
|
+
declare function createInMemoryReplayStore(): ReplayStore;
|
|
139
|
+
|
|
140
|
+
interface AuthCallbackOptions {
|
|
141
|
+
rateLimiter?: RateLimiter;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Factory: POST handler for auth callback (login completion).
|
|
145
|
+
*
|
|
146
|
+
* Accepts wallet signature verification or OTP results from client,
|
|
147
|
+
* validates with StackNet, sets HttpOnly JWT cookie + public session cookie + CSRF cookie.
|
|
148
|
+
*/
|
|
149
|
+
declare function createAuthCallback(config: ServerConfig, opts?: AuthCallbackOptions): (request: Request) => Promise<Response>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Factory: POST handler for logout.
|
|
153
|
+
* Revokes session with StackNet and clears all auth cookies.
|
|
154
|
+
*/
|
|
155
|
+
declare function createLogoutHandler(config: Pick<ServerConfig, 'stacknetUrl' | 'secureCookies' | 'cookieDomain'> & {
|
|
156
|
+
/**
|
|
157
|
+
* HMAC secret used to verify the JWT signature before extracting the
|
|
158
|
+
* sessionId for upstream revocation. STRONGLY RECOMMENDED.
|
|
159
|
+
*
|
|
160
|
+
* Without this, the handler skips upstream revocation entirely and only
|
|
161
|
+
* clears cookies — because trusting an unverified JWT for the sessionId
|
|
162
|
+
* would let an attacker plant a forged cookie and trigger DELETE on
|
|
163
|
+
* another user's session.
|
|
164
|
+
*/
|
|
165
|
+
authSecret?: string;
|
|
166
|
+
}): (request: Request) => Promise<Response>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Factory: GET handler for session validation.
|
|
170
|
+
* Reads HttpOnly JWT cookie, validates, returns public session info.
|
|
171
|
+
* Transparently refreshes JWT if close to expiry.
|
|
172
|
+
*/
|
|
173
|
+
declare function createSessionHandler(config: Pick<ServerConfig, 'authSecret' | 'jwtExpiry' | 'secureCookies' | 'cookieDomain' | 'sessionMaxAge'>): (request: Request) => Promise<Response>;
|
|
174
|
+
|
|
175
|
+
interface OTPHandlerConfig extends Pick<ServerConfig, 'authSecret' | 'secureCookies' | 'cookieDomain' | 'sessionMaxAge' | 'jwtExpiry'> {
|
|
176
|
+
/** The OTP secret to validate against */
|
|
177
|
+
otpSecret: string;
|
|
178
|
+
/** Rate limiter (default: 5 attempts per 5 min per IP) */
|
|
179
|
+
rateLimiter?: RateLimiter;
|
|
180
|
+
/** How to extract the real client IP for rate-limit keys. Defaults to
|
|
181
|
+
* `{ trustedProxyCount: 1 }`. Set `trustedProxyCount: 0` if the handler
|
|
182
|
+
* is exposed directly (no proxy in front). */
|
|
183
|
+
ipConfig?: IPExtractorConfig;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Factory: POST handler for OTP verification.
|
|
187
|
+
* Validates OTP code, creates session JWT, sets HttpOnly cookie.
|
|
188
|
+
*/
|
|
189
|
+
declare function createOTPHandler(config: OTPHandlerConfig): (request: Request) => Promise<Response>;
|
|
190
|
+
|
|
191
|
+
interface OAuthHandlerConfig {
|
|
192
|
+
rateLimiter?: RateLimiter;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Factory: OAuth flow handlers.
|
|
196
|
+
*
|
|
197
|
+
* Returns two handlers:
|
|
198
|
+
* - GET /api/auth/oauth/[provider] — Starts OAuth flow, returns redirect URL
|
|
199
|
+
* - POST /api/auth/oauth/[provider]/callback — Handles OAuth callback, sets cookies
|
|
200
|
+
*/
|
|
201
|
+
declare function createOAuthHandlers(config: ServerConfig, opts?: OAuthHandlerConfig): {
|
|
202
|
+
startFlow: (request: Request) => Promise<Response>;
|
|
203
|
+
handleCallback: (request: Request) => Promise<Response>;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
interface GoogleOneTapHandlerConfig {
|
|
207
|
+
rateLimiter?: RateLimiter;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Factory: POST handler for Google One Tap credential verification.
|
|
211
|
+
*
|
|
212
|
+
* Receives the Google JWT credential from the client, verifies it with
|
|
213
|
+
* Google's tokeninfo endpoint, then creates a StackNet session.
|
|
214
|
+
*/
|
|
215
|
+
declare function createGoogleOneTapHandler(config: ServerConfig, opts?: GoogleOneTapHandlerConfig): (request: Request) => Promise<Response>;
|
|
216
|
+
|
|
217
|
+
interface PreviewCodeHandlerOptions {
|
|
218
|
+
rateLimiter?: RateLimiter;
|
|
219
|
+
/** Maximum session length in seconds (default 3600 = 1h). Preview
|
|
220
|
+
* codes are meant for short-lived demo access — keep this
|
|
221
|
+
* meaningfully shorter than a normal wallet/OAuth session. */
|
|
222
|
+
sessionMaxAge?: number;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Factory: POST handler for preview-code login.
|
|
226
|
+
*
|
|
227
|
+
* Flow:
|
|
228
|
+
* 1. Client POSTs `{code: "692232"}` to this route.
|
|
229
|
+
* 2. Handler validates the code against
|
|
230
|
+
* `${config.stacknetUrl}/v1/preview-codes/:code` (public read —
|
|
231
|
+
* the 6-digit code is itself the bearer credential).
|
|
232
|
+
* 3. Rejects on not-found / revoked / expired / exhausted.
|
|
233
|
+
* 4. Mints a local JWT (signed with `config.authSecret`) with
|
|
234
|
+
* `sub = global_id = "preview_<code>"` and `auth_method =
|
|
235
|
+
* "preview_code"`. Sets it as the `stackauth_jwt` HttpOnly
|
|
236
|
+
* cookie so `useSession` recognizes the user as authenticated.
|
|
237
|
+
* 5. Also sets the public `stackauth_session` cookie + CSRF cookie
|
|
238
|
+
* to match the normal wallet / OAuth callback shape.
|
|
239
|
+
*
|
|
240
|
+
* The preview-code bearer (`pc_<code>`) is expected to already be in
|
|
241
|
+
* `localStorage['stacknet-api-key']` — the widget sets that
|
|
242
|
+
* client-side after a successful POST here. Together, the cookie
|
|
243
|
+
* drives same-origin `useSession` / server-rendered pages, and the
|
|
244
|
+
* bearer drives direct stacknet API calls from the browser.
|
|
245
|
+
*
|
|
246
|
+
* Security:
|
|
247
|
+
* - Rate-limited per IP (10 attempts / minute by default).
|
|
248
|
+
* - Format guard: only 6-digit numeric codes reach the upstream
|
|
249
|
+
* validator so nothing weird hits stacknet.
|
|
250
|
+
* - No upstream JWT is trusted — the stacknet response is a
|
|
251
|
+
* read-only row and the session JWT is freshly signed locally
|
|
252
|
+
* with the app's own `authSecret`.
|
|
253
|
+
*/
|
|
254
|
+
declare function createPreviewCodeHandler(config: ServerConfig, opts?: PreviewCodeHandlerOptions): (request: Request) => Promise<Response>;
|
|
255
|
+
|
|
256
|
+
interface BillingProxyConfig extends Pick<ServerConfig, 'authSecret' | 'stacknetUrl' | 'stackId' | 'stacknetJwtSecret' | 'secureCookies' | 'cookieDomain' | 'sessionMaxAge' | 'jwtExpiry'> {
|
|
257
|
+
/** Rate limiter for mutations (default: 20/min per user) */
|
|
258
|
+
rateLimiter?: RateLimiter;
|
|
259
|
+
/**
|
|
260
|
+
* Canonical absolute origin (e.g. "https://app.example.com") used when
|
|
261
|
+
* constructing Stripe success/cancel URLs. STRONGLY RECOMMENDED.
|
|
262
|
+
*
|
|
263
|
+
* Without this, the origin is derived from the request URL — which is
|
|
264
|
+
* populated from the `Host` header. If the app is deployed behind a
|
|
265
|
+
* proxy that does not validate / rewrite `Host`, an attacker can send
|
|
266
|
+
* `Host: evil.example` and the post-checkout redirect will point there.
|
|
267
|
+
* Stripe's dashboard allowlist catches most cases, but we should not
|
|
268
|
+
* depend on that alone.
|
|
269
|
+
*
|
|
270
|
+
* Must be a full http(s) origin with no path. Factory throws on
|
|
271
|
+
* malformed input so misconfigurations surface at boot, not at runtime.
|
|
272
|
+
*/
|
|
273
|
+
canonicalOrigin?: string;
|
|
274
|
+
}
|
|
275
|
+
type Handler = (request: Request) => Promise<Response>;
|
|
276
|
+
/**
|
|
277
|
+
* Factory: creates all billing route handlers that proxy to StackNet.
|
|
278
|
+
*
|
|
279
|
+
* All POST handlers validate CSRF tokens. All handlers validate the JWT cookie
|
|
280
|
+
* and transparently refresh if close to expiry.
|
|
281
|
+
*/
|
|
282
|
+
declare function createBillingProxy(config: BillingProxyConfig): {
|
|
283
|
+
plans: {
|
|
284
|
+
GET: Handler;
|
|
285
|
+
};
|
|
286
|
+
subscription: {
|
|
287
|
+
GET: Handler;
|
|
288
|
+
};
|
|
289
|
+
subscribe: {
|
|
290
|
+
POST: Handler;
|
|
291
|
+
};
|
|
292
|
+
cancel: {
|
|
293
|
+
POST: Handler;
|
|
294
|
+
};
|
|
295
|
+
usage: {
|
|
296
|
+
GET: Handler;
|
|
297
|
+
};
|
|
298
|
+
history: {
|
|
299
|
+
GET: Handler;
|
|
300
|
+
};
|
|
301
|
+
prepaid: {
|
|
302
|
+
POST: Handler;
|
|
303
|
+
};
|
|
304
|
+
verifyPrepaid: {
|
|
305
|
+
POST: Handler;
|
|
306
|
+
};
|
|
307
|
+
verifySession: {
|
|
308
|
+
POST: Handler;
|
|
309
|
+
};
|
|
310
|
+
subscribeSol: {
|
|
311
|
+
POST: Handler;
|
|
312
|
+
};
|
|
313
|
+
prepaidSol: {
|
|
314
|
+
POST: Handler;
|
|
315
|
+
};
|
|
316
|
+
topup: {
|
|
317
|
+
POST: Handler;
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Factory: POST handler for Stripe webhooks.
|
|
323
|
+
* Forwards raw body + stripe-signature to StackNet for verification and processing.
|
|
324
|
+
*/
|
|
325
|
+
declare function createWebhookHandler(config: Pick<ServerConfig, 'stacknetUrl' | 'stackId'>): (request: Request) => Promise<Response>;
|
|
326
|
+
|
|
327
|
+
interface CSRFConfig {
|
|
328
|
+
/** Cookie name (default: '__csrf') */
|
|
329
|
+
cookieName?: string;
|
|
330
|
+
/** Header name (default: 'x-csrf-token') */
|
|
331
|
+
headerName?: string;
|
|
332
|
+
/** Token length in bytes (default: 32) */
|
|
333
|
+
tokenLength?: number;
|
|
334
|
+
/** Use Secure flag on cookie (default: true) */
|
|
335
|
+
secure?: boolean;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Create CSRF protection using the double-submit cookie pattern.
|
|
339
|
+
*
|
|
340
|
+
* 1. Server sets a non-HttpOnly cookie with a random token
|
|
341
|
+
* 2. Client reads the cookie and sends the token in a header on mutations
|
|
342
|
+
* 3. Server validates cookie === header (attacker can't read cookie cross-origin)
|
|
343
|
+
*/
|
|
344
|
+
declare function createCSRFProtection(config?: CSRFConfig): {
|
|
345
|
+
/**
|
|
346
|
+
* Generate a CSRF token and add Set-Cookie header to a response.
|
|
347
|
+
* Call this on auth callback (login) to establish the CSRF cookie.
|
|
348
|
+
*/
|
|
349
|
+
generateToken(headers: Headers): string;
|
|
350
|
+
/**
|
|
351
|
+
* Validate a request's CSRF token (cookie vs header).
|
|
352
|
+
* Returns true if valid, false if not.
|
|
353
|
+
*/
|
|
354
|
+
validateRequest(request: Request): {
|
|
355
|
+
valid: boolean;
|
|
356
|
+
error?: string;
|
|
357
|
+
};
|
|
358
|
+
/** Cookie name for client-side reading */
|
|
359
|
+
cookieName: string;
|
|
360
|
+
/** Header name for client-side sending */
|
|
361
|
+
headerName: string;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/** Standard security response headers */
|
|
365
|
+
declare function securityHeaders(): Record<string, string>;
|
|
366
|
+
/**
|
|
367
|
+
* Wrap a request handler to add security headers to the response.
|
|
368
|
+
*/
|
|
369
|
+
declare function withSecurityHeaders(handler: (request: Request) => Promise<Response> | Response): (request: Request) => Promise<Response>;
|
|
370
|
+
/**
|
|
371
|
+
* Generate security headers config for Next.js next.config.ts.
|
|
372
|
+
*
|
|
373
|
+
* Usage in next.config.ts:
|
|
374
|
+
* ```ts
|
|
375
|
+
* import { nextSecurityHeaders } from '@stacknet/userutils/server';
|
|
376
|
+
* export default { headers: () => [{ source: '/(.*)', headers: nextSecurityHeaders() }] };
|
|
377
|
+
* ```
|
|
378
|
+
*/
|
|
379
|
+
declare function nextSecurityHeaders(): Array<{
|
|
380
|
+
key: string;
|
|
381
|
+
value: string;
|
|
382
|
+
}>;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Re-sign a JWT using StackNet's HMAC-SHA256 scheme.
|
|
386
|
+
*
|
|
387
|
+
* Geoff apps sign JWTs with AUTH_SECRET. StackNet validates with JWT_SECRET.
|
|
388
|
+
* This function re-signs using the StackNet secret so the backend resolves
|
|
389
|
+
* the correct per-user identity.
|
|
390
|
+
*/
|
|
391
|
+
declare function resignForStackNet(jwt: string, stacknetJwtSecret: string): string | null;
|
|
392
|
+
/**
|
|
393
|
+
* Build headers for proxying a user-scoped request to StackNet.
|
|
394
|
+
* Re-signs the JWT so StackNet recognises the user's identity.
|
|
395
|
+
*
|
|
396
|
+
* Refuses to interpolate any value that doesn't match the compact JWT
|
|
397
|
+
* format (header.body.signature, base64url segments only) to prevent
|
|
398
|
+
* header injection.
|
|
399
|
+
*/
|
|
400
|
+
declare function buildStackNetHeaders(jwt: string, stacknetJwtSecret: string): Record<string, string>;
|
|
401
|
+
/**
|
|
402
|
+
* Extract JWT from a request's cookies or Authorization header.
|
|
403
|
+
*/
|
|
404
|
+
declare function extractJwt(request: Request): string | null;
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Server-side proxy helpers for StackNet preview codes.
|
|
408
|
+
*
|
|
409
|
+
* Preview codes are admin-minted 6-digit access credentials with a
|
|
410
|
+
* per-code token budget. Only the pinned admin global id (enforced
|
|
411
|
+
* by StackNet's state machine) can mint / list / revoke codes.
|
|
412
|
+
*
|
|
413
|
+
* These helpers wrap `fetch` against the StackNet HTTP layer with
|
|
414
|
+
* re-signed JWT cookies (same pattern as `buildStackNetHeaders`).
|
|
415
|
+
* Admin-console API routes are expected to call them; the raw
|
|
416
|
+
* endpoints are NOT exposed in the client bundle.
|
|
417
|
+
*/
|
|
418
|
+
interface PreviewCode {
|
|
419
|
+
code: string;
|
|
420
|
+
createdBy: string;
|
|
421
|
+
tokenBudget: number;
|
|
422
|
+
tokensUsed: number;
|
|
423
|
+
tokensRemaining: number;
|
|
424
|
+
createdAt: number;
|
|
425
|
+
expiresAt: number | null;
|
|
426
|
+
revoked: boolean;
|
|
427
|
+
/** Optional human-readable label (e.g. "Tester: Alice"). Absent on
|
|
428
|
+
* codes minted before the name field shipped. */
|
|
429
|
+
name?: string | null;
|
|
430
|
+
}
|
|
431
|
+
interface MintPreviewCodeOptions {
|
|
432
|
+
/** Token budget for the new code. Must be > 0. */
|
|
433
|
+
tokenBudget: number;
|
|
434
|
+
/** Optional explicit 6-digit code string. Server generates one
|
|
435
|
+
* if omitted. */
|
|
436
|
+
code?: string;
|
|
437
|
+
/** Optional Unix-ms expiry. */
|
|
438
|
+
expiresAt?: number;
|
|
439
|
+
/** Optional human-readable label for the code. Shown in the admin
|
|
440
|
+
* list so the operator can tell codes apart. */
|
|
441
|
+
name?: string;
|
|
442
|
+
}
|
|
443
|
+
interface PreviewCodesProxyConfig {
|
|
444
|
+
/** StackNet base URL (no trailing slash). */
|
|
445
|
+
stacknetBaseUrl: string;
|
|
446
|
+
/** Shared HMAC secret for re-signing the caller's JWT. */
|
|
447
|
+
stacknetJwtSecret: string;
|
|
448
|
+
/** Caller's StackAuth JWT (user identity). */
|
|
449
|
+
jwt: string;
|
|
450
|
+
}
|
|
451
|
+
/** Admin-only: mint a new preview code. Returns the new code row. */
|
|
452
|
+
declare function mintPreviewCode(cfg: PreviewCodesProxyConfig, options: MintPreviewCodeOptions): Promise<{
|
|
453
|
+
minted: boolean;
|
|
454
|
+
code: PreviewCode;
|
|
455
|
+
} | {
|
|
456
|
+
error: string;
|
|
457
|
+
status: number;
|
|
458
|
+
}>;
|
|
459
|
+
/** Admin-only: list every preview code in the system. */
|
|
460
|
+
declare function listPreviewCodes(cfg: PreviewCodesProxyConfig): Promise<PreviewCode[] | {
|
|
461
|
+
error: string;
|
|
462
|
+
status: number;
|
|
463
|
+
}>;
|
|
464
|
+
/** Public: read a code's balance + status. Used by auth middleware. */
|
|
465
|
+
declare function getPreviewCode(stacknetBaseUrl: string, code: string): Promise<PreviewCode | null>;
|
|
466
|
+
/** Admin-only: revoke a preview code. */
|
|
467
|
+
declare function revokePreviewCode(cfg: PreviewCodesProxyConfig, code: string): Promise<{
|
|
468
|
+
revoked: boolean;
|
|
469
|
+
code: PreviewCode;
|
|
470
|
+
} | {
|
|
471
|
+
error: string;
|
|
472
|
+
status: number;
|
|
473
|
+
}>;
|
|
474
|
+
/** Internal: debit tokens from a preview code. Called by the metering
|
|
475
|
+
* layer after inference completes. */
|
|
476
|
+
declare function redeemPreviewCode(stacknetBaseUrl: string, code: string, tokens: number): Promise<{
|
|
477
|
+
redeemed: boolean;
|
|
478
|
+
code: string;
|
|
479
|
+
tokensUsed: number;
|
|
480
|
+
tokensRemaining: number;
|
|
481
|
+
} | {
|
|
482
|
+
error: string;
|
|
483
|
+
status: number;
|
|
484
|
+
}>;
|
|
485
|
+
/** Allowlist of admin global ids that can mint / revoke preview
|
|
486
|
+
* codes. Mirrors PREVIEW_CODE_ADMIN_GLOBAL_IDS in the Rust state
|
|
487
|
+
* machine — must stay in sync. Admin-console UI and API route guards
|
|
488
|
+
* should call `isPreviewCodeAdmin(currentUser.userId)` instead of
|
|
489
|
+
* comparing against a single constant so every entry is accepted. */
|
|
490
|
+
declare const PREVIEW_CODE_ADMIN_GLOBAL_IDS: readonly string[];
|
|
491
|
+
/** Returns true if the given global id is in the preview-code admin
|
|
492
|
+
* allowlist. */
|
|
493
|
+
declare function isPreviewCodeAdmin(globalId: string | null | undefined): boolean;
|
|
494
|
+
/** Back-compat alias for callers that only need a single canonical
|
|
495
|
+
* admin id for display/logging. Don't use for gating — use
|
|
496
|
+
* `isPreviewCodeAdmin()` to accept every entry in the allowlist. */
|
|
497
|
+
declare const PREVIEW_CODE_ADMIN_GLOBAL_ID: string;
|
|
498
|
+
|
|
499
|
+
export { type AuthCallbackOptions, type BillingProxyConfig, type CSRFConfig, type GoogleOneTapHandlerConfig, type IPExtractorConfig, type MintPreviewCodeOptions, type OAuthHandlerConfig, type OTPHandlerConfig, PREVIEW_CODE_ADMIN_GLOBAL_ID, PREVIEW_CODE_ADMIN_GLOBAL_IDS, type PreviewCode, type PreviewCodeHandlerOptions, type PreviewCodesProxyConfig, type RateLimiter, type ReplayStore, type ServerConfig, type ServerSession, buildStackNetHeaders, createAuthCallback, createBillingProxy, createCSRFProtection, createGoogleOneTapHandler, createInMemoryRateLimiter, createInMemoryReplayStore, createLogoutHandler, createOAuthHandlers, createOTPHandler, createPreviewCodeHandler, createSessionHandler, createWebhookHandler, decodeJWTPayload, extractIP, extractJwt, generateToken, getPreviewCode, isPreviewCodeAdmin, listPreviewCodes, maybeRefreshJWT, mintPreviewCode, nextSecurityHeaders, redeemPreviewCode, resignForStackNet, revokePreviewCode, securityHeaders, signJWT, verifyJWT, verifyJWTSignature, withSecurityHeaders };
|