@poly-x/next 0.1.0 → 0.2.0

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.d.cts CHANGED
@@ -1,4 +1,5 @@
1
- import { AuthClient, StoredSession } from "@poly-x/core";
1
+ import { n as readSessionCookie, t as DEFAULT_SESSION_COOKIE } from "./session-cookie-AEnY4PVW.cjs";
2
+ import { AuthClient, PolyXError, SessionClaims, StoredSession } from "@poly-x/core";
2
3
 
3
4
  //#region src/config.d.ts
4
5
  interface NextServerConfig {
@@ -15,6 +16,23 @@ interface NextServerConfig {
15
16
  * that doesn't forward `x-forwarded-proto`).
16
17
  */
17
18
  cookieSecure?: boolean;
19
+ /**
20
+ * The origin the browser actually reaches this app on, used to build `redirect_uri`.
21
+ * Set when the deployment knows its own address. `POLYX_PUBLIC_ORIGIN`.
22
+ */
23
+ publicOrigin?: string;
24
+ /**
25
+ * Honour `x-forwarded-host` / `x-forwarded-proto` when deriving the public origin.
26
+ * OPT-IN: `X-Forwarded-Host` is attacker-controllable when the app is reachable
27
+ * directly, so enable this only behind a trusted proxy. `POLYX_TRUST_FORWARDED_ORIGIN`.
28
+ */
29
+ trustForwardedOrigin?: boolean;
30
+ /**
31
+ * Base URL of the realtime service (poly-alert) that issues socket handshake tickets.
32
+ * `POLYX_REALTIME_URL`. Without it, `realtimeTicket` reports unavailable and apps stay
33
+ * on the superseded `subscriptionCredential` path.
34
+ */
35
+ realtimeBaseUrl?: string;
18
36
  }
19
37
  declare function resolveNextConfig(overrides?: Partial<NextServerConfig>): NextServerConfig;
20
38
  //#endregion
@@ -38,7 +56,6 @@ interface CookieAdapter {
38
56
  }
39
57
  //#endregion
40
58
  //#region src/server-session.d.ts
41
- declare const DEFAULT_SESSION_COOKIE = "polyx_session";
42
59
  interface ServerSessionOptions {
43
60
  authClient: AuthClient;
44
61
  secret: string;
@@ -60,6 +77,28 @@ declare class ServerSession {
60
77
  constructor(options: ServerSessionOptions);
61
78
  establishFromCode(code: string, codeVerifier: string, redirectUri: string, forwardedHeaders?: Record<string, string>): Promise<StoredSession>;
62
79
  read(): Promise<StoredSession | null>;
80
+ /**
81
+ * Re-seal the session with an updated **display** subset — name, email, avatar (ADR-0006).
82
+ *
83
+ * Needed because `normalizeRefresh` carries claims forward unchanged: the display claims are
84
+ * derived once, at login, and never again. So after a profile save the session still describes
85
+ * the person as they were when they signed in, and every surface reading `useUser()` shows the
86
+ * old name and photo until they sign out and back in (FR-PROF-5).
87
+ *
88
+ * Deliberately narrow. `userId` and `organizationId` are authorization-relevant and are **not**
89
+ * writable here — a profile save must never become a route to changing who the session is for,
90
+ * whatever the platform echoes back. Only the three display fields move.
91
+ *
92
+ * Returns `false` rather than throwing when the session cannot be re-sealed (an oversized
93
+ * cookie, a missing session): the caller's own operation has usually already succeeded
94
+ * upstream, and failing it because the *display* copy could not be updated would report a
95
+ * successful save as a failure.
96
+ */
97
+ updateDisplayClaims(patch: {
98
+ displayName?: string;
99
+ email?: string;
100
+ avatarUrl?: string;
101
+ }): Promise<boolean>;
63
102
  /** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
64
103
  refresh(): Promise<StoredSession | null>;
65
104
  /**
@@ -84,13 +123,239 @@ interface AuthContext {
84
123
  claims: StoredSession["session"]["claims"] | null;
85
124
  }
86
125
  //#endregion
126
+ //#region src/session-context.d.ts
127
+ /** Why the platform profile is absent even though the session is valid. */
128
+ type ProfileStatus = "ok" /** The platform could not be reached, or answered 5xx. The user is still signed in. */ | "unavailable" /** The session has no workspace, and poly-auth requires one to resolve a profile. */ | "no_organization";
129
+ interface SessionContext {
130
+ isSignedIn: boolean;
131
+ userId: string | null;
132
+ organizationId?: string;
133
+ claims: SessionClaims | null;
134
+ /** The platform user profile, or `null` when it could not be resolved. */
135
+ user: Record<string, unknown> | null;
136
+ /**
137
+ * Present only when signed in. `"ok"` means `user` is populated; anything else says
138
+ * why it is not — so an app never mistakes a transient outage for a signed-out user.
139
+ */
140
+ profileStatus?: ProfileStatus;
141
+ }
142
+ interface SessionContextOptions extends Partial<NextServerConfig> {
143
+ /** Skip the platform profile fetch and return identity + claims only. */
144
+ skipProfile?: boolean;
145
+ }
146
+ /**
147
+ * Read who is signed in, including the platform profile.
148
+ *
149
+ * Safe in a Server Component: it reads the session and never writes it. The access
150
+ * credential is used to call the platform and is never part of the result (FR-SESS-5).
151
+ */
152
+ declare function getSessionContext(options?: SessionContextOptions): Promise<SessionContext>;
153
+ //#endregion
154
+ //#region src/auth-event.d.ts
155
+ /**
156
+ * One-shot auth events that survive a navigation (F017 / FR-CLIENT-5).
157
+ *
158
+ * Sign-in and sign-out are full-page navigations, so anything an app queues before them —
159
+ * a toast, an analytics call — is destroyed when the page unloads. Odin built ~80 lines of
160
+ * cookie plumbing to show "Logged in successfully"; this absorbs the shape.
161
+ *
162
+ * A cookie rather than a query parameter on the redirect target: the destination may
163
+ * redirect again before it renders (a route gate sending an unauthorised user elsewhere,
164
+ * for one), which drops the query and the event with it. A cookie survives the chain.
165
+ *
166
+ * It carries no secret — it exists to be read by page script, which is why it is
167
+ * deliberately NOT httpOnly. Never put anything in it that the browser should not see.
168
+ */
169
+ /** The cookie the SDK publishes a one-shot auth event in. Readable by script, by design. */
170
+ declare const AUTH_EVENT_COOKIE = "polyx_auth_event";
171
+ type AuthEventKind = "signed-in" | "signed-out";
172
+ /** Serialize the `Set-Cookie` value for a one-shot event. */
173
+ declare function buildAuthEventCookie(kind: AuthEventKind, secure: boolean): string;
174
+ //#endregion
175
+ //#region src/public-origin.d.ts
176
+ /**
177
+ * Public-origin resolution (F016 / FR-ORIG-1…3).
178
+ *
179
+ * The SDK derives its OAuth `redirect_uri` from the incoming request. Self-hosted behind
180
+ * a proxy or in a container, `request.url` is the BIND address — odin saw
181
+ * `https://0.0.0.0:3000` — so the platform is handed a redirect URI nobody registered and
182
+ * rejects it. Odin carries 62 lines of workaround for exactly this; alara carries a
183
+ * diagnostic logger to explain the resulting confusion.
184
+ *
185
+ * Forwarded headers are honoured only when the deployment OPTS IN. `X-Forwarded-Host` is
186
+ * attacker-controllable whenever the app is reachable directly, so trusting it by default
187
+ * would let a caller steer the redirect URI. Behind a trusted proxy the same header is set
188
+ * by infrastructure and is precisely what we need.
189
+ */
190
+ interface PublicOriginOptions {
191
+ /**
192
+ * Explicit public origin (e.g. `https://app.example.com`). Wins over any derivation.
193
+ * Set via `POLYX_PUBLIC_ORIGIN` when the deployment knows its own address.
194
+ */
195
+ publicOrigin?: string;
196
+ /**
197
+ * Honour `x-forwarded-host` / `x-forwarded-proto`. Opt-in: only enable when a trusted
198
+ * proxy sets these and the app is not reachable directly. `POLYX_TRUST_FORWARDED_ORIGIN`.
199
+ */
200
+ trustForwardedOrigin?: boolean;
201
+ }
202
+ /**
203
+ * Resolve the origin the browser actually reached this app on.
204
+ *
205
+ * Precedence: explicit configuration → forwarded headers (only when trusted) → the
206
+ * request URL. Every step falls through on malformed input rather than emitting a broken
207
+ * origin, because a broken `redirect_uri` fails at the platform with a message about
208
+ * credentials rather than configuration.
209
+ */
210
+ declare function resolvePublicOrigin(request: Request, options?: PublicOriginOptions): string;
211
+ //#endregion
212
+ //#region src/handler.d.ts
213
+ /**
214
+ * URL segment → handler key. The segments are kebab-case (what a developer types in a
215
+ * path); the handler keys are camelCase (what the object exposes). Keeping the map
216
+ * explicit means a new handler cannot be reached by an unintended segment spelling.
217
+ */
218
+ declare const POLYX_ACTIONS: {
219
+ readonly signin: "signin";
220
+ readonly authenticate: "authenticate";
221
+ readonly "forgot-password": "forgotPassword";
222
+ readonly "reset-password": "resetPassword";
223
+ readonly callback: "callback";
224
+ readonly refresh: "refresh";
225
+ readonly signout: "signout";
226
+ readonly session: "session";
227
+ readonly "subscription-credential": "subscriptionCredential";
228
+ readonly "realtime-ticket": "realtimeTicket";
229
+ readonly profile: "profile";
230
+ };
231
+ type PolyXActionSegment = keyof typeof POLYX_ACTIONS;
232
+ /** What Next passes as the second argument to a catch-all route handler. */
233
+ interface PolyXRouteContext {
234
+ params: Promise<Record<string, string | string[] | undefined>>;
235
+ }
236
+ type PolyXRouteHandler = (request: Request, context: PolyXRouteContext) => Promise<Response>;
237
+ interface PolyXMountedHandlers {
238
+ GET: PolyXRouteHandler;
239
+ POST: PolyXRouteHandler;
240
+ }
241
+ /**
242
+ * Mount every SDK auth route from one declaration:
243
+ *
244
+ * ```ts
245
+ * // app/api/polyx/[...polyx]/route.ts
246
+ * export const { GET, POST } = createPolyXHandler();
247
+ * ```
248
+ *
249
+ * `handlers` is injectable for tests; production callers pass only the config.
250
+ */
251
+ declare function createPolyXHandler(config?: AuthHandlersConfig, handlers?: AuthHandlers): PolyXMountedHandlers;
252
+ //#endregion
253
+ //#region src/data-proxy.d.ts
254
+ interface DataProxyOptions extends Partial<NextServerConfig> {
255
+ /**
256
+ * Absolute base URL of the backend to forward to. A function is resolved per request,
257
+ * so a missing environment variable reports as a clear 500 rather than baking an empty
258
+ * base in at module load.
259
+ */
260
+ upstream: string | (() => string);
261
+ /** Names this proxy in misconfiguration messages, e.g. "Billing". */
262
+ label?: string;
263
+ }
264
+ /** What Next passes as the second argument to a catch-all route handler. */
265
+ interface DataProxyContext {
266
+ params: Promise<Record<string, string | string[] | undefined>>;
267
+ }
268
+ type DataProxyHandler = (request: Request, context: DataProxyContext) => Promise<Response>;
269
+ interface MountedDataProxy {
270
+ GET: DataProxyHandler;
271
+ POST: DataProxyHandler;
272
+ PUT: DataProxyHandler;
273
+ PATCH: DataProxyHandler;
274
+ DELETE: DataProxyHandler;
275
+ HEAD: DataProxyHandler;
276
+ }
277
+ /**
278
+ * Mount an authenticated proxy to one declared backend:
279
+ *
280
+ * ```ts
281
+ * // app/api/billing/proxy/[...path]/route.ts
282
+ * export const { GET, POST, PUT, PATCH, DELETE } = createDataProxy({
283
+ * upstream: process.env.BILLING_URL!,
284
+ * label: "Billing",
285
+ * });
286
+ * ```
287
+ *
288
+ * The client keeps calling a same-origin path; the credential is attached on this side of
289
+ * it and never reaches page scripts.
290
+ */
291
+ declare function createDataProxy(options: DataProxyOptions): MountedDataProxy;
292
+ //#endregion
87
293
  //#region src/cookie-codec.d.ts
88
- declare function sealSession(session: StoredSession, secret: string): Promise<string>;
294
+ /**
295
+ * Browsers cap a single cookie at roughly 4096 bytes for the whole `Set-Cookie` pair —
296
+ * name, value and attributes together — and enforce it by DISCARDING the cookie without
297
+ * an error. For a session cookie that failure is invisible and badly misleading: the
298
+ * platform authenticates, the handler answers 200, and the user is bounced back to
299
+ * sign-in on the next request because the cookie was never stored.
300
+ *
301
+ * poly-x-sdk/ADR-0006 measured a live Odin session at 2628 bytes and recorded the missing
302
+ * guard as an open follow-up; a per-tenant flag that inflates the JWT is enough to cross
303
+ * the line. We fail loudly here instead.
304
+ */
305
+ declare const SESSION_COOKIE_MAX_BYTES = 4096;
306
+ /** Bytes the whole `Set-Cookie` pair will occupy for this value under this cookie name. */
307
+ declare function sealedCookieByteLength(value: string, cookieName?: string): number;
308
+ /**
309
+ * Thrown when a sealed session would exceed what a browser will store. Never includes the
310
+ * session itself — the claims that caused the overflow are exactly the data we must not log.
311
+ */
312
+ declare class SessionCookieTooLargeError extends PolyXError {
313
+ readonly bytes: number;
314
+ readonly limit: number;
315
+ constructor(bytes: number, limit: number, cookieName: string);
316
+ }
317
+ interface SealSessionOptions {
318
+ /** The cookie name the value will be stored under; counted against the browser budget. */
319
+ cookieName?: string;
320
+ }
321
+ declare function sealSession(session: StoredSession, secret: string, options?: SealSessionOptions): Promise<string>;
89
322
  declare function openSession(token: string, secret: string): Promise<StoredSession | null>;
90
323
  //#endregion
91
324
  //#region src/server.d.ts
92
325
  /** Read the current session — safe in Server Components (no cookie write). */
93
326
  declare function auth(overrides?: Partial<NextServerConfig>): Promise<AuthContext>;
327
+ /** What a server-side refresh reports back. */
328
+ interface RefreshResult {
329
+ isSignedIn: boolean;
330
+ /**
331
+ * SERVER-SIDE ONLY. This is the credential the BFF attaches to upstream calls; it
332
+ * must never be returned to the browser (FR-SESS-5). `null` when signed out.
333
+ */
334
+ accessToken: string | null;
335
+ claims: AuthContext["claims"];
336
+ }
337
+ interface RefreshSessionOptions extends Partial<NextServerConfig> {
338
+ /**
339
+ * The inbound request, when there is one. Used only to derive the cookie `Secure`
340
+ * attribute from the transport. Pass it whenever available — without it we fall back
341
+ * to `cookieSecure` and then fail secure, which would drop the cookie over plain http.
342
+ */
343
+ request?: Request;
344
+ }
345
+ /**
346
+ * Refresh the current session from server code (F013 / FR-SESS-1).
347
+ *
348
+ * Before this existed there was no supported way to do it, so both live integrations
349
+ * did something worse: the server issued an HTTP request to its OWN
350
+ * `/api/polyx/refresh` route and then re-parsed the returned `Set-Cookie` header to
351
+ * recover the new token. That is a network round trip through the app's own edge to do
352
+ * something already possible in-process, and the header re-parse is fragile.
353
+ *
354
+ * Terminal-vs-transient classification lives in `ServerSession.refresh()` and is not
355
+ * duplicated here: a revoked family or an unstorable session signs the user out, and a
356
+ * transient upstream failure propagates so the caller can fail closed.
357
+ */
358
+ declare function refreshSession(options?: RefreshSessionOptions): Promise<RefreshResult>;
94
359
  interface AuthHandlersConfig extends Partial<NextServerConfig> {
95
360
  /** Where to send the user after a successful sign-in. Default `/`. */
96
361
  afterSignInPath?: string;
@@ -135,6 +400,21 @@ interface AuthHandlers {
135
400
  * only to open the live-authz subscription.
136
401
  */
137
402
  subscriptionCredential(request: Request): Promise<Response>;
403
+ /**
404
+ * Obtain a single-use, seconds-lived ticket for opening a realtime connection
405
+ * (F019 / FR-RT-2). Supersedes `subscriptionCredential`: a leaked ticket is worth one
406
+ * connection, whereas the session token it used to hand out is worth an hour against
407
+ * every API.
408
+ */
409
+ realtimeTicket(request: Request): Promise<Response>;
410
+ /**
411
+ * Read (`GET`) and update (`POST`) the signed-in person's own profile (v0.4 / FR-CUSTODY-1).
412
+ *
413
+ * The identity acted on is taken from the sealed session and nothing else: there is no user
414
+ * id in the path or the body, so a caller cannot aim this at somebody else's profile
415
+ * (FR-CUSTODY-4 / D24). Editing another person is administration and lives in the console.
416
+ */
417
+ profile(request: Request): Promise<Response>;
138
418
  }
139
419
  /** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
140
420
  type ForgotPasswordResult = {
@@ -175,9 +455,27 @@ type AuthenticateResult = {
175
455
  status: "no_access";
176
456
  } | {
177
457
  status: "invalid_credentials";
458
+ } /** Credentials were correct, but the sealed session exceeds what a browser will store. */ | {
459
+ status: "session_too_large";
460
+ bytes: number;
461
+ limit: number;
462
+ } /** The APP is misconfigured (unregistered redirect URI, unknown client, wrong deployment). */ | {
463
+ status: "configuration_error";
464
+ code: string;
465
+ message?: string;
466
+ }
467
+ /**
468
+ * RESERVED (FR-STEP-1…4). The platform wants a second proof of identity. No deployment
469
+ * produces this today; it is modelled so enabling MFA later is additive rather than a
470
+ * breaking change across three packages.
471
+ */
472
+ | {
473
+ status: "verification_required";
474
+ challengeId: string;
475
+ factorTypes: readonly string[];
178
476
  } | {
179
477
  status: "invalid_request";
180
478
  };
181
479
  declare function createAuthHandlers(handlersConfig?: AuthHandlersConfig): AuthHandlers;
182
480
  //#endregion
183
- export { type AuthContext, AuthHandlers, AuthHandlersConfig, AuthenticateResult, type CookieAdapter, type CookieSetOptions, DEFAULT_SESSION_COOKIE, ForgotPasswordResult, type NextServerConfig, ResetPasswordResult, ServerSession, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession };
481
+ export { AUTH_EVENT_COOKIE, type AuthContext, type AuthEventKind, AuthHandlers, AuthHandlersConfig, AuthenticateResult, type CookieAdapter, type CookieSetOptions, DEFAULT_SESSION_COOKIE, type DataProxyContext, type DataProxyHandler, type DataProxyOptions, ForgotPasswordResult, type MountedDataProxy, type NextServerConfig, POLYX_ACTIONS, type PolyXActionSegment, type PolyXMountedHandlers, type PolyXRouteContext, type PolyXRouteHandler, type ProfileStatus, type PublicOriginOptions, RefreshResult, RefreshSessionOptions, ResetPasswordResult, SESSION_COOKIE_MAX_BYTES, ServerSession, type SessionContext, type SessionContextOptions, SessionCookieTooLargeError, auth, buildAuthEventCookie, createAuthHandlers, createDataProxy, createPolyXHandler, getSessionContext, openSession, readSessionCookie, refreshSession, resolveNextConfig, resolvePublicOrigin, sealSession, sealedCookieByteLength };