@poly-x/next 0.1.1 → 0.3.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, PolyXError, 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,6 +123,175 @@ 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 hop: "hop";
225
+ readonly "hop-complete": "hopComplete";
226
+ readonly refresh: "refresh";
227
+ readonly signout: "signout";
228
+ readonly session: "session";
229
+ readonly "subscription-credential": "subscriptionCredential";
230
+ readonly "realtime-ticket": "realtimeTicket";
231
+ readonly profile: "profile";
232
+ };
233
+ type PolyXActionSegment = keyof typeof POLYX_ACTIONS;
234
+ /** What Next passes as the second argument to a catch-all route handler. */
235
+ interface PolyXRouteContext {
236
+ params: Promise<Record<string, string | string[] | undefined>>;
237
+ }
238
+ type PolyXRouteHandler = (request: Request, context: PolyXRouteContext) => Promise<Response>;
239
+ interface PolyXMountedHandlers {
240
+ GET: PolyXRouteHandler;
241
+ POST: PolyXRouteHandler;
242
+ }
243
+ /**
244
+ * Mount every SDK auth route from one declaration:
245
+ *
246
+ * ```ts
247
+ * // app/api/polyx/[...polyx]/route.ts
248
+ * export const { GET, POST } = createPolyXHandler();
249
+ * ```
250
+ *
251
+ * `handlers` is injectable for tests; production callers pass only the config.
252
+ */
253
+ declare function createPolyXHandler(config?: AuthHandlersConfig, handlers?: AuthHandlers): PolyXMountedHandlers;
254
+ //#endregion
255
+ //#region src/data-proxy.d.ts
256
+ interface DataProxyOptions extends Partial<NextServerConfig> {
257
+ /**
258
+ * Absolute base URL of the backend to forward to. A function is resolved per request,
259
+ * so a missing environment variable reports as a clear 500 rather than baking an empty
260
+ * base in at module load.
261
+ */
262
+ upstream: string | (() => string);
263
+ /** Names this proxy in misconfiguration messages, e.g. "Billing". */
264
+ label?: string;
265
+ }
266
+ /** What Next passes as the second argument to a catch-all route handler. */
267
+ interface DataProxyContext {
268
+ params: Promise<Record<string, string | string[] | undefined>>;
269
+ }
270
+ type DataProxyHandler = (request: Request, context: DataProxyContext) => Promise<Response>;
271
+ interface MountedDataProxy {
272
+ GET: DataProxyHandler;
273
+ POST: DataProxyHandler;
274
+ PUT: DataProxyHandler;
275
+ PATCH: DataProxyHandler;
276
+ DELETE: DataProxyHandler;
277
+ HEAD: DataProxyHandler;
278
+ }
279
+ /**
280
+ * Mount an authenticated proxy to one declared backend:
281
+ *
282
+ * ```ts
283
+ * // app/api/billing/proxy/[...path]/route.ts
284
+ * export const { GET, POST, PUT, PATCH, DELETE } = createDataProxy({
285
+ * upstream: process.env.BILLING_URL!,
286
+ * label: "Billing",
287
+ * });
288
+ * ```
289
+ *
290
+ * The client keeps calling a same-origin path; the credential is attached on this side of
291
+ * it and never reaches page scripts.
292
+ */
293
+ declare function createDataProxy(options: DataProxyOptions): MountedDataProxy;
294
+ //#endregion
87
295
  //#region src/cookie-codec.d.ts
88
296
  /**
89
297
  * Browsers cap a single cookie at roughly 4096 bytes for the whole `Set-Cookie` pair —
@@ -118,6 +326,38 @@ declare function openSession(token: string, secret: string): Promise<StoredSessi
118
326
  //#region src/server.d.ts
119
327
  /** Read the current session — safe in Server Components (no cookie write). */
120
328
  declare function auth(overrides?: Partial<NextServerConfig>): Promise<AuthContext>;
329
+ /** What a server-side refresh reports back. */
330
+ interface RefreshResult {
331
+ isSignedIn: boolean;
332
+ /**
333
+ * SERVER-SIDE ONLY. This is the credential the BFF attaches to upstream calls; it
334
+ * must never be returned to the browser (FR-SESS-5). `null` when signed out.
335
+ */
336
+ accessToken: string | null;
337
+ claims: AuthContext["claims"];
338
+ }
339
+ interface RefreshSessionOptions extends Partial<NextServerConfig> {
340
+ /**
341
+ * The inbound request, when there is one. Used only to derive the cookie `Secure`
342
+ * attribute from the transport. Pass it whenever available — without it we fall back
343
+ * to `cookieSecure` and then fail secure, which would drop the cookie over plain http.
344
+ */
345
+ request?: Request;
346
+ }
347
+ /**
348
+ * Refresh the current session from server code (F013 / FR-SESS-1).
349
+ *
350
+ * Before this existed there was no supported way to do it, so both live integrations
351
+ * did something worse: the server issued an HTTP request to its OWN
352
+ * `/api/polyx/refresh` route and then re-parsed the returned `Set-Cookie` header to
353
+ * recover the new token. That is a network round trip through the app's own edge to do
354
+ * something already possible in-process, and the header re-parse is fragile.
355
+ *
356
+ * Terminal-vs-transient classification lives in `ServerSession.refresh()` and is not
357
+ * duplicated here: a revoked family or an unstorable session signs the user out, and a
358
+ * transient upstream failure propagates so the caller can fail closed.
359
+ */
360
+ declare function refreshSession(options?: RefreshSessionOptions): Promise<RefreshResult>;
121
361
  interface AuthHandlersConfig extends Partial<NextServerConfig> {
122
362
  /** Where to send the user after a successful sign-in. Default `/`. */
123
363
  afterSignInPath?: string;
@@ -145,6 +385,28 @@ interface AuthHandlers {
145
385
  */
146
386
  resetPassword(request: Request): Promise<Response>;
147
387
  callback(request: Request): Promise<Response>;
388
+ /**
389
+ * Prepare a warm hop — the silent sign-in attempt (F026 / FR-HOP-1).
390
+ *
391
+ * Returns the authorize URL for **the browser** to call, and keeps the PKCE verifier in an
392
+ * httpOnly cookie. It deliberately makes no request of its own: the platform resolves this
393
+ * from the browser's ecosystem cookie, which a server-side call does not carry, so a
394
+ * server-side attempt would report "sign-in required" every time and the feature would look
395
+ * built while never working (FR-HOP-5).
396
+ *
397
+ * An optional `{ tenantId }` / `{ organizationCode }` body pins a workspace. The platform
398
+ * matches it **only within the person's own memberships**, so it can narrow a choice but
399
+ * never widen access — which is what also makes this the route the workspace switch uses.
400
+ */
401
+ hop(request: Request): Promise<Response>;
402
+ /**
403
+ * Finish a warm hop: exchange the `code` the browser received for a session (F026 / FR-HOP-3).
404
+ *
405
+ * The code is safe for a browser to hold because it is worthless without the PKCE verifier,
406
+ * and the verifier never left this server. The exchange happens here, so tokens never touch
407
+ * the page — the same custody path as every other sign-in, not a second mechanism.
408
+ */
409
+ hopComplete(request: Request): Promise<Response>;
148
410
  refresh(request: Request): Promise<Response>;
149
411
  signout(request: Request): Promise<Response>;
150
412
  /**
@@ -162,6 +424,21 @@ interface AuthHandlers {
162
424
  * only to open the live-authz subscription.
163
425
  */
164
426
  subscriptionCredential(request: Request): Promise<Response>;
427
+ /**
428
+ * Obtain a single-use, seconds-lived ticket for opening a realtime connection
429
+ * (F019 / FR-RT-2). Supersedes `subscriptionCredential`: a leaked ticket is worth one
430
+ * connection, whereas the session token it used to hand out is worth an hour against
431
+ * every API.
432
+ */
433
+ realtimeTicket(request: Request): Promise<Response>;
434
+ /**
435
+ * Read (`GET`) and update (`POST`) the signed-in person's own profile (v0.4 / FR-CUSTODY-1).
436
+ *
437
+ * The identity acted on is taken from the sealed session and nothing else: there is no user
438
+ * id in the path or the body, so a caller cannot aim this at somebody else's profile
439
+ * (FR-CUSTODY-4 / D24). Editing another person is administration and lives in the console.
440
+ */
441
+ profile(request: Request): Promise<Response>;
165
442
  }
166
443
  /** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
167
444
  type ForgotPasswordResult = {
@@ -206,9 +483,23 @@ type AuthenticateResult = {
206
483
  status: "session_too_large";
207
484
  bytes: number;
208
485
  limit: number;
486
+ } /** The APP is misconfigured (unregistered redirect URI, unknown client, wrong deployment). */ | {
487
+ status: "configuration_error";
488
+ code: string;
489
+ message?: string;
490
+ }
491
+ /**
492
+ * RESERVED (FR-STEP-1…4). The platform wants a second proof of identity. No deployment
493
+ * produces this today; it is modelled so enabling MFA later is additive rather than a
494
+ * breaking change across three packages.
495
+ */
496
+ | {
497
+ status: "verification_required";
498
+ challengeId: string;
499
+ factorTypes: readonly string[];
209
500
  } | {
210
501
  status: "invalid_request";
211
502
  };
212
503
  declare function createAuthHandlers(handlersConfig?: AuthHandlersConfig): AuthHandlers;
213
504
  //#endregion
214
- export { type AuthContext, AuthHandlers, AuthHandlersConfig, AuthenticateResult, type CookieAdapter, type CookieSetOptions, DEFAULT_SESSION_COOKIE, ForgotPasswordResult, type NextServerConfig, ResetPasswordResult, SESSION_COOKIE_MAX_BYTES, ServerSession, SessionCookieTooLargeError, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession, sealedCookieByteLength };
505
+ 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 };