@poly-x/next 0.2.0 → 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/README.md CHANGED
@@ -150,6 +150,45 @@ platform's update applies whatever recognised fields it receives — including
150
150
  turn a profile form into a way to change a credential or grant a role. Only the
151
151
  six profile fields ever leave the server.
152
152
 
153
+ ## Cross-application sign-on and switching workspace
154
+
155
+ `createPolyXHandler()` also mounts `/api/polyx/hop` and `/api/polyx/hop-complete`. You do not
156
+ call these directly — `@poly-x/react`'s `useWarmHop()` and `switchWorkspace()` drive them.
157
+
158
+ **The attempt is made by the browser, and it has to be.** The platform recognises a returning
159
+ person from an ecosystem cookie set on *its own* domain. Your server does not have that cookie,
160
+ so a server-side attempt is told "sign-in required" every single time — correctly, and
161
+ indistinguishably from someone who genuinely is signed out. The feature would look implemented
162
+ and never once work.
163
+
164
+ So the routes bracket a browser-made request rather than making one:
165
+
166
+ - **`hop`** mints the PKCE pair, keeps the verifier in an httpOnly cookie, and returns only a
167
+ URL. It makes no request of its own.
168
+ - **`hop-complete`** takes the `code` the browser received and exchanges it here. The code is
169
+ safe for a page to hold precisely because the verifier that redeems it never left your server.
170
+
171
+ Because of that, calling `completeAuthorize()` from server code **throws**
172
+ (`WARM_HOP_ON_SERVER`) rather than returning the plausible answer.
173
+
174
+ Register your `/api/polyx/callback` on the App even though nothing redirects to it during a
175
+ hop — the platform validates `redirect_uri` on both the authorize and the exchange.
176
+
177
+ > **Warm hop needs https end to end** (or a `localhost` dev setup). The platform issues its
178
+ > ecosystem cookie with `Secure`, and a browser silently discards a `Secure` cookie delivered
179
+ > over http. Nothing reports this: sign-in still succeeds, the `Set-Cookie` header is still
180
+ > there, and every hop afterwards reports "sign-in required" — indistinguishable from someone
181
+ > who is genuinely signed out. `localhost` is the one host browsers exempt.
182
+
183
+ ### The boundary, stated plainly
184
+
185
+ **This works between applications that share the ecosystem cookie's domain.** Browsers block
186
+ third-party cookies by default, so an application on an unrelated domain cannot see that cookie
187
+ and will fall back to ordinary sign-in. That is a browser policy, not a limitation we can
188
+ configure away — solving it needs a different mechanism entirely. Nothing here claims otherwise.
189
+
190
+ Switching workspace is unaffected by that boundary: it happens inside one application.
191
+
153
192
  ## Exports
154
193
 
155
194
  - `@poly-x/next` — the client surface (re-exports `@poly-x/react`): `PolyXProvider`,
package/dist/index.cjs CHANGED
@@ -3,7 +3,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  //#region src/index.ts
4
4
  const PACKAGE_NAME = "@poly-x/next";
5
5
  /** The published version of this package, injected at build time. */
6
- const version = "0.2.0";
6
+ const version = "0.3.0";
7
7
  //#endregion
8
8
  exports.PACKAGE_NAME = PACKAGE_NAME;
9
9
  exports.version = version;
package/dist/index.mjs CHANGED
@@ -3,6 +3,6 @@ export * from "@poly-x/react";
3
3
  //#region src/index.ts
4
4
  const PACKAGE_NAME = "@poly-x/next";
5
5
  /** The published version of this package, injected at build time. */
6
- const version = "0.2.0";
6
+ const version = "0.3.0";
7
7
  //#endregion
8
8
  export { PACKAGE_NAME, version };
package/dist/server.cjs CHANGED
@@ -512,6 +512,8 @@ const POLYX_ACTIONS = {
512
512
  "forgot-password": "forgotPassword",
513
513
  "reset-password": "resetPassword",
514
514
  callback: "callback",
515
+ hop: "hop",
516
+ "hop-complete": "hopComplete",
515
517
  refresh: "refresh",
516
518
  signout: "signout",
517
519
  session: "session",
@@ -534,6 +536,8 @@ const POLYX_ACTION_METHODS = {
534
536
  "subscription-credential": ["GET"],
535
537
  "realtime-ticket": ["GET"],
536
538
  authenticate: ["POST"],
539
+ hop: ["POST"],
540
+ "hop-complete": ["POST"],
537
541
  "forgot-password": ["POST"],
538
542
  "reset-password": ["POST"],
539
543
  refresh: ["POST"],
@@ -1125,6 +1129,110 @@ function createAuthHandlers(handlersConfig = {}) {
1125
1129
  }
1126
1130
  return Response.redirect(home, 302);
1127
1131
  },
1132
+ async hop(request) {
1133
+ const config = resolveNextConfig(handlersConfig);
1134
+ const secure = resolveCookieSecure(request, config);
1135
+ const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
1136
+ let hint = {};
1137
+ try {
1138
+ hint = await request.json() ?? {};
1139
+ } catch {
1140
+ hint = {};
1141
+ }
1142
+ const organizationCode = typeof hint.tenantId === "string" && hint.tenantId !== "" ? hint.tenantId : typeof hint.organizationCode === "string" && hint.organizationCode !== "" ? hint.organizationCode : void 0;
1143
+ const { verifier, challenge } = await (0, _poly_x_core.generatePkce)();
1144
+ const state = (0, _poly_x_core.randomState)();
1145
+ (await (0, next_headers.cookies)()).set(VERIFIER_COOKIE, JSON.stringify({
1146
+ verifier,
1147
+ state,
1148
+ redirectUri,
1149
+ ...organizationCode ? { requested: organizationCode } : {}
1150
+ }), {
1151
+ httpOnly: true,
1152
+ secure,
1153
+ sameSite: "lax",
1154
+ path: "/",
1155
+ maxAge: VERIFIER_MAX_AGE
1156
+ });
1157
+ const authorizeUrl = buildServerAuthClient(config).buildAuthorizeUrl({
1158
+ redirectUri,
1159
+ state,
1160
+ challenge,
1161
+ prompt: "none",
1162
+ ...organizationCode ? { organizationCode } : {}
1163
+ });
1164
+ return Response.json({
1165
+ status: "ready",
1166
+ authorizeUrl
1167
+ });
1168
+ },
1169
+ async hopComplete(request) {
1170
+ const config = resolveNextConfig(handlersConfig);
1171
+ const secure = resolveCookieSecure(request, config);
1172
+ const store = await (0, next_headers.cookies)();
1173
+ let body;
1174
+ try {
1175
+ body = await request.json() ?? {};
1176
+ } catch {
1177
+ return Response.json({ status: "invalid_request" }, { status: 400 });
1178
+ }
1179
+ const code = typeof body.code === "string" ? body.code : "";
1180
+ const state = typeof body.state === "string" ? body.state : "";
1181
+ const raw = store.get(VERIFIER_COOKIE)?.value;
1182
+ store.delete(VERIFIER_COOKIE);
1183
+ if (!code || !state || !raw) return Response.json({ status: "invalid_request" }, { status: 400 });
1184
+ let tx;
1185
+ try {
1186
+ tx = JSON.parse(raw);
1187
+ } catch {
1188
+ return Response.json({ status: "invalid_request" }, { status: 400 });
1189
+ }
1190
+ if (tx.state !== state) return Response.json({ status: "invalid_request" }, { status: 400 });
1191
+ let entered;
1192
+ try {
1193
+ entered = (await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request))).session.claims.organizationId;
1194
+ } catch (error) {
1195
+ if (error instanceof SessionCookieTooLargeError) {
1196
+ console.error(`[polyx] ${error.message}`);
1197
+ return Response.json({
1198
+ status: "session_too_large",
1199
+ bytes: error.bytes,
1200
+ limit: error.limit
1201
+ }, { status: 400 });
1202
+ }
1203
+ return Response.json({ status: "exchange_failed" }, { status: 400 });
1204
+ }
1205
+ /**
1206
+ * The substitution guard (verified against a live platform, 2026-07-29).
1207
+ *
1208
+ * The platform only consults a workspace hint when the person has **more than one**
1209
+ * candidate workspace for that application. With exactly one, the hint is ignored and they
1210
+ * are placed in that one — answering `authorized`, with nothing to say the request was not
1211
+ * honoured. Asking for workspace A and being silently seated in workspace B is precisely
1212
+ * the outcome the scope decisions call worse than a prompt: someone can act in the wrong
1213
+ * tenant without noticing.
1214
+ *
1215
+ * A hint can be a tenant id, an organization code, or a slug, and only the first is
1216
+ * comparable to what the session carries. So an id-shaped hint that does not match is
1217
+ * reported as a mismatch; for the other forms the workspace actually entered is returned
1218
+ * and the caller can decide. Either way the answer is never a bare "success" that hides a
1219
+ * substitution.
1220
+ *
1221
+ * The session IS established at this point and is left in place: the person is signed in
1222
+ * somewhere they are entitled to be. This reports what happened rather than tearing down a
1223
+ * valid session the caller may be perfectly happy with.
1224
+ */
1225
+ const requested = tx.requested;
1226
+ if (typeof requested === "string" && /^[a-f0-9]{24}$/i.test(requested) && entered && requested.toLowerCase() !== entered.toLowerCase()) return Response.json({
1227
+ status: "workspace_mismatch",
1228
+ requested,
1229
+ workspace: entered
1230
+ });
1231
+ return Response.json({
1232
+ status: "success",
1233
+ ...entered ? { workspace: entered } : {}
1234
+ });
1235
+ },
1128
1236
  async refresh(request) {
1129
1237
  const { isSignedIn } = await refreshSession({
1130
1238
  ...handlersConfig,
package/dist/server.d.cts CHANGED
@@ -221,6 +221,8 @@ declare const POLYX_ACTIONS: {
221
221
  readonly "forgot-password": "forgotPassword";
222
222
  readonly "reset-password": "resetPassword";
223
223
  readonly callback: "callback";
224
+ readonly hop: "hop";
225
+ readonly "hop-complete": "hopComplete";
224
226
  readonly refresh: "refresh";
225
227
  readonly signout: "signout";
226
228
  readonly session: "session";
@@ -383,6 +385,28 @@ interface AuthHandlers {
383
385
  */
384
386
  resetPassword(request: Request): Promise<Response>;
385
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>;
386
410
  refresh(request: Request): Promise<Response>;
387
411
  signout(request: Request): Promise<Response>;
388
412
  /**
package/dist/server.d.mts CHANGED
@@ -221,6 +221,8 @@ declare const POLYX_ACTIONS: {
221
221
  readonly "forgot-password": "forgotPassword";
222
222
  readonly "reset-password": "resetPassword";
223
223
  readonly callback: "callback";
224
+ readonly hop: "hop";
225
+ readonly "hop-complete": "hopComplete";
224
226
  readonly refresh: "refresh";
225
227
  readonly signout: "signout";
226
228
  readonly session: "session";
@@ -383,6 +385,28 @@ interface AuthHandlers {
383
385
  */
384
386
  resetPassword(request: Request): Promise<Response>;
385
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>;
386
410
  refresh(request: Request): Promise<Response>;
387
411
  signout(request: Request): Promise<Response>;
388
412
  /**
package/dist/server.mjs CHANGED
@@ -511,6 +511,8 @@ const POLYX_ACTIONS = {
511
511
  "forgot-password": "forgotPassword",
512
512
  "reset-password": "resetPassword",
513
513
  callback: "callback",
514
+ hop: "hop",
515
+ "hop-complete": "hopComplete",
514
516
  refresh: "refresh",
515
517
  signout: "signout",
516
518
  session: "session",
@@ -533,6 +535,8 @@ const POLYX_ACTION_METHODS = {
533
535
  "subscription-credential": ["GET"],
534
536
  "realtime-ticket": ["GET"],
535
537
  authenticate: ["POST"],
538
+ hop: ["POST"],
539
+ "hop-complete": ["POST"],
536
540
  "forgot-password": ["POST"],
537
541
  "reset-password": ["POST"],
538
542
  refresh: ["POST"],
@@ -1124,6 +1128,110 @@ function createAuthHandlers(handlersConfig = {}) {
1124
1128
  }
1125
1129
  return Response.redirect(home, 302);
1126
1130
  },
1131
+ async hop(request) {
1132
+ const config = resolveNextConfig(handlersConfig);
1133
+ const secure = resolveCookieSecure(request, config);
1134
+ const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
1135
+ let hint = {};
1136
+ try {
1137
+ hint = await request.json() ?? {};
1138
+ } catch {
1139
+ hint = {};
1140
+ }
1141
+ const organizationCode = typeof hint.tenantId === "string" && hint.tenantId !== "" ? hint.tenantId : typeof hint.organizationCode === "string" && hint.organizationCode !== "" ? hint.organizationCode : void 0;
1142
+ const { verifier, challenge } = await generatePkce();
1143
+ const state = randomState();
1144
+ (await cookies()).set(VERIFIER_COOKIE, JSON.stringify({
1145
+ verifier,
1146
+ state,
1147
+ redirectUri,
1148
+ ...organizationCode ? { requested: organizationCode } : {}
1149
+ }), {
1150
+ httpOnly: true,
1151
+ secure,
1152
+ sameSite: "lax",
1153
+ path: "/",
1154
+ maxAge: VERIFIER_MAX_AGE
1155
+ });
1156
+ const authorizeUrl = buildServerAuthClient(config).buildAuthorizeUrl({
1157
+ redirectUri,
1158
+ state,
1159
+ challenge,
1160
+ prompt: "none",
1161
+ ...organizationCode ? { organizationCode } : {}
1162
+ });
1163
+ return Response.json({
1164
+ status: "ready",
1165
+ authorizeUrl
1166
+ });
1167
+ },
1168
+ async hopComplete(request) {
1169
+ const config = resolveNextConfig(handlersConfig);
1170
+ const secure = resolveCookieSecure(request, config);
1171
+ const store = await cookies();
1172
+ let body;
1173
+ try {
1174
+ body = await request.json() ?? {};
1175
+ } catch {
1176
+ return Response.json({ status: "invalid_request" }, { status: 400 });
1177
+ }
1178
+ const code = typeof body.code === "string" ? body.code : "";
1179
+ const state = typeof body.state === "string" ? body.state : "";
1180
+ const raw = store.get(VERIFIER_COOKIE)?.value;
1181
+ store.delete(VERIFIER_COOKIE);
1182
+ if (!code || !state || !raw) return Response.json({ status: "invalid_request" }, { status: 400 });
1183
+ let tx;
1184
+ try {
1185
+ tx = JSON.parse(raw);
1186
+ } catch {
1187
+ return Response.json({ status: "invalid_request" }, { status: 400 });
1188
+ }
1189
+ if (tx.state !== state) return Response.json({ status: "invalid_request" }, { status: 400 });
1190
+ let entered;
1191
+ try {
1192
+ entered = (await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request))).session.claims.organizationId;
1193
+ } catch (error) {
1194
+ if (error instanceof SessionCookieTooLargeError) {
1195
+ console.error(`[polyx] ${error.message}`);
1196
+ return Response.json({
1197
+ status: "session_too_large",
1198
+ bytes: error.bytes,
1199
+ limit: error.limit
1200
+ }, { status: 400 });
1201
+ }
1202
+ return Response.json({ status: "exchange_failed" }, { status: 400 });
1203
+ }
1204
+ /**
1205
+ * The substitution guard (verified against a live platform, 2026-07-29).
1206
+ *
1207
+ * The platform only consults a workspace hint when the person has **more than one**
1208
+ * candidate workspace for that application. With exactly one, the hint is ignored and they
1209
+ * are placed in that one — answering `authorized`, with nothing to say the request was not
1210
+ * honoured. Asking for workspace A and being silently seated in workspace B is precisely
1211
+ * the outcome the scope decisions call worse than a prompt: someone can act in the wrong
1212
+ * tenant without noticing.
1213
+ *
1214
+ * A hint can be a tenant id, an organization code, or a slug, and only the first is
1215
+ * comparable to what the session carries. So an id-shaped hint that does not match is
1216
+ * reported as a mismatch; for the other forms the workspace actually entered is returned
1217
+ * and the caller can decide. Either way the answer is never a bare "success" that hides a
1218
+ * substitution.
1219
+ *
1220
+ * The session IS established at this point and is left in place: the person is signed in
1221
+ * somewhere they are entitled to be. This reports what happened rather than tearing down a
1222
+ * valid session the caller may be perfectly happy with.
1223
+ */
1224
+ const requested = tx.requested;
1225
+ if (typeof requested === "string" && /^[a-f0-9]{24}$/i.test(requested) && entered && requested.toLowerCase() !== entered.toLowerCase()) return Response.json({
1226
+ status: "workspace_mismatch",
1227
+ requested,
1228
+ workspace: entered
1229
+ });
1230
+ return Response.json({
1231
+ status: "success",
1232
+ ...entered ? { workspace: entered } : {}
1233
+ });
1234
+ },
1127
1235
  async refresh(request) {
1128
1236
  const { isSignedIn } = await refreshSession({
1129
1237
  ...handlersConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poly-x/next",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "PolyX SDK for Next.js App Router - components plus server helpers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,8 +47,8 @@
47
47
  "module": "./dist/index.mjs",
48
48
  "types": "./dist/index.d.cts",
49
49
  "dependencies": {
50
- "@poly-x/core": "0.2.0",
51
- "@poly-x/react": "0.2.0"
50
+ "@poly-x/core": "0.3.0",
51
+ "@poly-x/react": "0.3.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "next": ">=14",