@poly-x/next 0.1.1 → 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.
@@ -0,0 +1,21 @@
1
+ //#region src/session-cookie.d.ts
2
+ /**
3
+ * The session cookie's name, in a module with NO imports (F015 / FR-SESS-4).
4
+ *
5
+ * Both consuming apps hard-code `"polyx_session"` rather than importing it, and the
6
+ * reason is mechanical: it lived in `server-session.ts`, so importing it from the edge
7
+ * dragged server-only code into the edge runtime. A hard-coded copy in each app is a
8
+ * silent breakage waiting to happen — if the SDK ever changed the name, route gating
9
+ * would fail open or closed with nothing to point at.
10
+ *
11
+ * This module is safe to import anywhere: edge middleware, the browser, the server.
12
+ */
13
+ /** The cookie the SDK seals the session into. */
14
+ declare const DEFAULT_SESSION_COOKIE = "polyx_session";
15
+ /**
16
+ * Read the sealed session value out of a raw `Cookie` header, without unsealing it.
17
+ * Useful at the edge, where presence is all that can be checked cheaply.
18
+ */
19
+ declare function readSessionCookie(cookieHeader: string | null | undefined, cookieName?: string): string | undefined;
20
+ //#endregion
21
+ export { readSessionCookie as n, DEFAULT_SESSION_COOKIE as t };
@@ -0,0 +1,21 @@
1
+ //#region src/session-cookie.d.ts
2
+ /**
3
+ * The session cookie's name, in a module with NO imports (F015 / FR-SESS-4).
4
+ *
5
+ * Both consuming apps hard-code `"polyx_session"` rather than importing it, and the
6
+ * reason is mechanical: it lived in `server-session.ts`, so importing it from the edge
7
+ * dragged server-only code into the edge runtime. A hard-coded copy in each app is a
8
+ * silent breakage waiting to happen — if the SDK ever changed the name, route gating
9
+ * would fail open or closed with nothing to point at.
10
+ *
11
+ * This module is safe to import anywhere: edge middleware, the browser, the server.
12
+ */
13
+ /** The cookie the SDK seals the session into. */
14
+ declare const DEFAULT_SESSION_COOKIE = "polyx_session";
15
+ /**
16
+ * Read the sealed session value out of a raw `Cookie` header, without unsealing it.
17
+ * Useful at the edge, where presence is all that can be checked cheaply.
18
+ */
19
+ declare function readSessionCookie(cookieHeader: string | null | undefined, cookieName?: string): string | undefined;
20
+ //#endregion
21
+ export { readSessionCookie as n, DEFAULT_SESSION_COOKIE as t };
@@ -0,0 +1,34 @@
1
+ //#region src/session-cookie.ts
2
+ /**
3
+ * The session cookie's name, in a module with NO imports (F015 / FR-SESS-4).
4
+ *
5
+ * Both consuming apps hard-code `"polyx_session"` rather than importing it, and the
6
+ * reason is mechanical: it lived in `server-session.ts`, so importing it from the edge
7
+ * dragged server-only code into the edge runtime. A hard-coded copy in each app is a
8
+ * silent breakage waiting to happen — if the SDK ever changed the name, route gating
9
+ * would fail open or closed with nothing to point at.
10
+ *
11
+ * This module is safe to import anywhere: edge middleware, the browser, the server.
12
+ */
13
+ /** The cookie the SDK seals the session into. */
14
+ const DEFAULT_SESSION_COOKIE = "polyx_session";
15
+ /**
16
+ * Read the sealed session value out of a raw `Cookie` header, without unsealing it.
17
+ * Useful at the edge, where presence is all that can be checked cheaply.
18
+ */
19
+ function readSessionCookie(cookieHeader, cookieName = DEFAULT_SESSION_COOKIE) {
20
+ return cookieHeader?.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
21
+ }
22
+ //#endregion
23
+ Object.defineProperty(exports, "DEFAULT_SESSION_COOKIE", {
24
+ enumerable: true,
25
+ get: function() {
26
+ return DEFAULT_SESSION_COOKIE;
27
+ }
28
+ });
29
+ Object.defineProperty(exports, "readSessionCookie", {
30
+ enumerable: true,
31
+ get: function() {
32
+ return readSessionCookie;
33
+ }
34
+ });
@@ -0,0 +1,23 @@
1
+ //#region src/session-cookie.ts
2
+ /**
3
+ * The session cookie's name, in a module with NO imports (F015 / FR-SESS-4).
4
+ *
5
+ * Both consuming apps hard-code `"polyx_session"` rather than importing it, and the
6
+ * reason is mechanical: it lived in `server-session.ts`, so importing it from the edge
7
+ * dragged server-only code into the edge runtime. A hard-coded copy in each app is a
8
+ * silent breakage waiting to happen — if the SDK ever changed the name, route gating
9
+ * would fail open or closed with nothing to point at.
10
+ *
11
+ * This module is safe to import anywhere: edge middleware, the browser, the server.
12
+ */
13
+ /** The cookie the SDK seals the session into. */
14
+ const DEFAULT_SESSION_COOKIE = "polyx_session";
15
+ /**
16
+ * Read the sealed session value out of a raw `Cookie` header, without unsealing it.
17
+ * Useful at the edge, where presence is all that can be checked cheaply.
18
+ */
19
+ function readSessionCookie(cookieHeader, cookieName = DEFAULT_SESSION_COOKIE) {
20
+ return cookieHeader?.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
21
+ }
22
+ //#endregion
23
+ export { readSessionCookie as n, DEFAULT_SESSION_COOKIE as t };
package/dist/styles.css CHANGED
@@ -709,6 +709,157 @@
709
709
  color: var(--polyx-muted-fg);
710
710
  }
711
711
 
712
+ /* --- User profile dialog (v0.4) ------------------------------------------ */
713
+
714
+ .polyx-userprofile__trigger {
715
+ font: inherit;
716
+ font-size: 0.875rem;
717
+ color: var(--polyx-fg);
718
+ background: none;
719
+ border: none;
720
+ padding: 0;
721
+ cursor: pointer;
722
+ }
723
+
724
+ .polyx-userprofile__overlay {
725
+ position: fixed;
726
+ inset: 0;
727
+ z-index: 50;
728
+ display: flex;
729
+ align-items: center;
730
+ justify-content: center;
731
+ padding: 1rem;
732
+ background: rgb(0 0 0 / 0.45);
733
+ overflow-y: auto;
734
+ }
735
+
736
+ .polyx-userprofile__dialog {
737
+ width: 100%;
738
+ max-width: 28rem;
739
+ padding: 1.5rem;
740
+ background: var(--polyx-bg);
741
+ color: var(--polyx-fg);
742
+ border: 1px solid var(--polyx-border);
743
+ border-radius: var(--polyx-radius);
744
+ box-shadow: var(--polyx-shadow);
745
+ }
746
+
747
+ .polyx-userprofile__header {
748
+ margin-bottom: 1.25rem;
749
+ }
750
+
751
+ .polyx-userprofile__title {
752
+ margin: 0;
753
+ font-size: 1.0625rem;
754
+ font-weight: 600;
755
+ }
756
+
757
+ .polyx-userprofile__description {
758
+ margin: 0.25rem 0 0;
759
+ font-size: 0.8125rem;
760
+ color: var(--polyx-muted-fg);
761
+ }
762
+
763
+ .polyx-userprofile__status {
764
+ margin: 0;
765
+ font-size: 0.875rem;
766
+ color: var(--polyx-muted-fg);
767
+ }
768
+
769
+ .polyx-userprofile__photo {
770
+ display: flex;
771
+ align-items: center;
772
+ gap: 0.875rem;
773
+ }
774
+
775
+ .polyx-userprofile__photo-actions {
776
+ display: flex;
777
+ flex-direction: column;
778
+ gap: 0.25rem;
779
+ }
780
+
781
+ .polyx-userprofile__photo-button {
782
+ font: inherit;
783
+ font-size: 0.8125rem;
784
+ font-weight: 500;
785
+ color: var(--polyx-brand);
786
+ background: none;
787
+ border: none;
788
+ padding: 0;
789
+ text-align: left;
790
+ cursor: pointer;
791
+ }
792
+
793
+ /* Hidden from sight but not from assistive technology — the visible control is the button. */
794
+ .polyx-userprofile__file {
795
+ position: absolute;
796
+ width: 1px;
797
+ height: 1px;
798
+ padding: 0;
799
+ margin: -1px;
800
+ overflow: hidden;
801
+ clip: rect(0, 0, 0, 0);
802
+ white-space: nowrap;
803
+ border: 0;
804
+ }
805
+
806
+ .polyx-userprofile__row {
807
+ display: grid;
808
+ grid-template-columns: 1fr 1fr;
809
+ gap: 0.75rem;
810
+ }
811
+
812
+ @media (max-width: 24rem) {
813
+ .polyx-userprofile__row {
814
+ grid-template-columns: 1fr;
815
+ }
816
+ }
817
+
818
+ .polyx-userprofile__optional {
819
+ font-weight: 400;
820
+ color: var(--polyx-muted-fg);
821
+ }
822
+
823
+ .polyx-userprofile__field-error {
824
+ margin: 0;
825
+ font-size: 0.8125rem;
826
+ color: var(--polyx-danger);
827
+ }
828
+
829
+ .polyx-userprofile__success {
830
+ margin: 0;
831
+ padding: 0.625rem 0.75rem;
832
+ font-size: 0.8125rem;
833
+ color: var(--polyx-fg);
834
+ background: var(--polyx-panel-bg);
835
+ border: 1px solid var(--polyx-border);
836
+ border-radius: var(--polyx-radius-sm);
837
+ }
838
+
839
+ .polyx-userprofile__actions {
840
+ display: flex;
841
+ justify-content: flex-end;
842
+ gap: 0.625rem;
843
+ }
844
+
845
+ .polyx-userprofile__cancel {
846
+ font: inherit;
847
+ font-size: 0.875rem;
848
+ font-weight: 500;
849
+ height: 2.5rem;
850
+ padding: 0 0.875rem;
851
+ color: var(--polyx-fg);
852
+ background: none;
853
+ border: 1px solid var(--polyx-border);
854
+ border-radius: var(--polyx-radius-sm);
855
+ cursor: pointer;
856
+ }
857
+
858
+ .polyx-userprofile__actions .polyx-signin__submit {
859
+ width: auto;
860
+ padding: 0 1.125rem;
861
+ }
862
+
712
863
  /* Closes `@layer polyx` — opened at the top of the file. The rules above are left
713
864
  unindented deliberately: wrapping the sheet in a layer is a cascade change, not a
714
865
  reformat, and re-indenting 600 lines would bury it in the diff. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poly-x/next",
3
- "version": "0.1.1",
3
+ "version": "0.2.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.1.1",
51
- "@poly-x/react": "0.1.1"
50
+ "@poly-x/core": "0.2.0",
51
+ "@poly-x/react": "0.2.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "next": ">=14",
@@ -1,254 +0,0 @@
1
- let _poly_x_core = require("@poly-x/core");
2
- //#region src/cookie-codec.ts
3
- /**
4
- * AES-GCM sealing of the session cookie (F007 / ADR-0004). The key is derived
5
- * (SHA-256) from the consumer's `POLYX_SESSION_SECRET`; AES-GCM gives
6
- * confidentiality + tamper-detection together, so any corruption, truncation,
7
- * or wrong-secret input decrypts to `null` rather than a partial session.
8
- * Web Crypto is universal (Node ≥20 + edge runtimes), so this runs anywhere a
9
- * Next route handler runs.
10
- */
11
- const IV_BYTES = 12;
12
- /**
13
- * Browsers cap a single cookie at roughly 4096 bytes for the whole `Set-Cookie` pair —
14
- * name, value and attributes together — and enforce it by DISCARDING the cookie without
15
- * an error. For a session cookie that failure is invisible and badly misleading: the
16
- * platform authenticates, the handler answers 200, and the user is bounced back to
17
- * sign-in on the next request because the cookie was never stored.
18
- *
19
- * poly-x-sdk/ADR-0006 measured a live Odin session at 2628 bytes and recorded the missing
20
- * guard as an open follow-up; a per-tenant flag that inflates the JWT is enough to cross
21
- * the line. We fail loudly here instead.
22
- */
23
- const SESSION_COOKIE_MAX_BYTES = 4096;
24
- /** Attributes `buildSessionCookieOptions` always sets: `Path=/; HttpOnly; Secure; SameSite=Lax`. */
25
- const COOKIE_ATTRIBUTE_BYTES = 45;
26
- /** Bytes the whole `Set-Cookie` pair will occupy for this value under this cookie name. */
27
- function sealedCookieByteLength(value, cookieName = "polyx_session") {
28
- return new TextEncoder().encode(cookieName).length + 1 + value.length + COOKIE_ATTRIBUTE_BYTES;
29
- }
30
- /**
31
- * Thrown when a sealed session would exceed what a browser will store. Never includes the
32
- * session itself — the claims that caused the overflow are exactly the data we must not log.
33
- */
34
- var SessionCookieTooLargeError = class extends _poly_x_core.PolyXError {
35
- bytes;
36
- limit;
37
- constructor(bytes, limit, cookieName) {
38
- super(`PolyX session cookie is ${bytes} bytes, over the ~${limit}-byte browser limit for "${cookieName}". The browser would discard it silently and the user would appear signed out. Reduce what the platform puts in the session — the usual cause is a tenant configured to embed full permissions in the JWT (includeFullPermissionInJwt).`, { code: "SESSION_COOKIE_TOO_LARGE" });
39
- this.bytes = bytes;
40
- this.limit = limit;
41
- }
42
- };
43
- function base64UrlEncode(bytes) {
44
- let binary = "";
45
- for (const byte of bytes) binary += String.fromCharCode(byte);
46
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
47
- }
48
- function base64UrlDecode(text) {
49
- const base64 = text.replace(/-/g, "+").replace(/_/g, "/");
50
- const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
51
- const binary = atob(padded);
52
- const bytes = new Uint8Array(binary.length);
53
- for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
54
- return bytes;
55
- }
56
- async function deriveKey(secret) {
57
- const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
58
- return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["encrypt", "decrypt"]);
59
- }
60
- async function sealSession(session, secret, options = {}) {
61
- const key = await deriveKey(secret);
62
- const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
63
- const plaintext = new TextEncoder().encode(JSON.stringify(session));
64
- const ciphertext = new Uint8Array(await crypto.subtle.encrypt({
65
- name: "AES-GCM",
66
- iv
67
- }, key, plaintext));
68
- const packed = new Uint8Array(iv.length + ciphertext.length);
69
- packed.set(iv);
70
- packed.set(ciphertext, iv.length);
71
- const sealed = base64UrlEncode(packed);
72
- const cookieName = options.cookieName ?? "polyx_session";
73
- const bytes = sealedCookieByteLength(sealed, cookieName);
74
- if (bytes > 4096) throw new SessionCookieTooLargeError(bytes, SESSION_COOKIE_MAX_BYTES, cookieName);
75
- return sealed;
76
- }
77
- async function openSession(token, secret) {
78
- try {
79
- const packed = base64UrlDecode(token);
80
- if (packed.length <= IV_BYTES) return null;
81
- const iv = packed.slice(0, IV_BYTES);
82
- const ciphertext = packed.slice(IV_BYTES);
83
- const key = await deriveKey(secret);
84
- const plaintext = await crypto.subtle.decrypt({
85
- name: "AES-GCM",
86
- iv
87
- }, key, ciphertext);
88
- return JSON.parse(new TextDecoder().decode(plaintext));
89
- } catch {
90
- return null;
91
- }
92
- }
93
- //#endregion
94
- //#region src/cookie-adapter.ts
95
- /**
96
- * Attributes for the session cookie (ADR-0004 secure custody). `secure` is decided at
97
- * write time from the actual transport: a `Secure` cookie is silently dropped by the
98
- * browser over plain http, so http/localhost dev must set `secure:false` while https
99
- * always sets `secure:true`. This is not a downgrade — `Secure` carries no meaning on
100
- * http; setting it there just breaks the session. httpOnly/sameSite/path are unchanged.
101
- */
102
- function buildSessionCookieOptions(secure) {
103
- return {
104
- httpOnly: true,
105
- secure,
106
- sameSite: "lax",
107
- path: "/"
108
- };
109
- }
110
- //#endregion
111
- //#region src/server-session.ts
112
- /**
113
- * The framework-agnostic BFF session controller (F007): tokens sealed in an
114
- * httpOnly cookie on the consumer's domain, exchanged/refreshed server-side.
115
- * Composes core's AuthClient with a CookieAdapter seam; fully unit-testable.
116
- */
117
- const DEFAULT_SESSION_COOKIE = "polyx_session";
118
- var ServerSession = class {
119
- authClient;
120
- secret;
121
- cookies;
122
- cookieName;
123
- secure;
124
- constructor(options) {
125
- this.authClient = options.authClient;
126
- this.secret = options.secret;
127
- this.cookies = options.cookies;
128
- this.cookieName = options.cookieName ?? "polyx_session";
129
- this.secure = options.secure ?? true;
130
- }
131
- async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
132
- const stored = await this.authClient.exchangeCode({
133
- code,
134
- codeVerifier,
135
- redirectUri,
136
- forwardedHeaders
137
- });
138
- await this.write(stored);
139
- return stored;
140
- }
141
- async read() {
142
- const raw = this.cookies.get(this.cookieName);
143
- if (!raw) return null;
144
- return openSession(raw, this.secret);
145
- }
146
- /** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
147
- async refresh() {
148
- const current = await this.read();
149
- if (!current) return null;
150
- try {
151
- const next = await this.authClient.createRefresher()(current);
152
- await this.write(next);
153
- return next;
154
- } catch (error) {
155
- if (error instanceof _poly_x_core.SessionRevokedError || error instanceof _poly_x_core.SessionExpiredError) {
156
- this.clear();
157
- return null;
158
- }
159
- if (error instanceof SessionCookieTooLargeError) {
160
- console.error(`[polyx] ${error.message}`);
161
- this.clear();
162
- return null;
163
- }
164
- throw error;
165
- }
166
- }
167
- /**
168
- * End the session upstream, then locally. `device` revokes this session; `everywhere`
169
- * revokes every session for the user (FR-SSO-5).
170
- *
171
- * Order is load-bearing: revocation is authenticated with the access token sealed in the
172
- * cookie, so the cookie must be read before it is dropped. The upstream call is
173
- * best-effort — an unreachable platform must not strand the user half-signed-in — but the
174
- * call itself is real: the ecosystem logout the SDK used before resolves its session from
175
- * a browser cookie, which a server-to-server fetch never carries, so it silently revoked
176
- * nothing and answered 200.
177
- */
178
- async revoke(scope = "device") {
179
- const current = await this.read();
180
- if (current) try {
181
- await this.authClient.revokeSession(current.tokens.accessToken, scope);
182
- } catch {}
183
- this.clear();
184
- }
185
- clear() {
186
- this.cookies.delete(this.cookieName);
187
- }
188
- async write(session) {
189
- const sealed = await sealSession(session, this.secret, { cookieName: this.cookieName });
190
- this.cookies.set(this.cookieName, sealed, buildSessionCookieOptions(this.secure));
191
- }
192
- };
193
- function toAuthContext(session) {
194
- if (!session) return {
195
- isSignedIn: false,
196
- userId: null,
197
- claims: null
198
- };
199
- return {
200
- isSignedIn: true,
201
- userId: session.session.claims.userId,
202
- organizationId: session.session.claims.organizationId,
203
- claims: session.session.claims
204
- };
205
- }
206
- //#endregion
207
- Object.defineProperty(exports, "DEFAULT_SESSION_COOKIE", {
208
- enumerable: true,
209
- get: function() {
210
- return DEFAULT_SESSION_COOKIE;
211
- }
212
- });
213
- Object.defineProperty(exports, "SESSION_COOKIE_MAX_BYTES", {
214
- enumerable: true,
215
- get: function() {
216
- return SESSION_COOKIE_MAX_BYTES;
217
- }
218
- });
219
- Object.defineProperty(exports, "ServerSession", {
220
- enumerable: true,
221
- get: function() {
222
- return ServerSession;
223
- }
224
- });
225
- Object.defineProperty(exports, "SessionCookieTooLargeError", {
226
- enumerable: true,
227
- get: function() {
228
- return SessionCookieTooLargeError;
229
- }
230
- });
231
- Object.defineProperty(exports, "openSession", {
232
- enumerable: true,
233
- get: function() {
234
- return openSession;
235
- }
236
- });
237
- Object.defineProperty(exports, "sealSession", {
238
- enumerable: true,
239
- get: function() {
240
- return sealSession;
241
- }
242
- });
243
- Object.defineProperty(exports, "sealedCookieByteLength", {
244
- enumerable: true,
245
- get: function() {
246
- return sealedCookieByteLength;
247
- }
248
- });
249
- Object.defineProperty(exports, "toAuthContext", {
250
- enumerable: true,
251
- get: function() {
252
- return toAuthContext;
253
- }
254
- });