@oxyhq/core 5.4.3 → 6.0.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.
Files changed (186) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +6 -3
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +47 -43
  12. package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
  15. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  16. package/dist/cjs/mixins/index.js +13 -17
  17. package/dist/cjs/session/SessionClient.js +44 -2
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/createSessionClient.js +8 -2
  20. package/dist/cjs/session/refresh.js +264 -0
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +6 -15
  23. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/ssoBounce.js +15 -340
  26. package/dist/cjs/utils/validationUtils.js +57 -0
  27. package/dist/esm/.tsbuildinfo +1 -1
  28. package/dist/esm/HttpService.js +6 -3
  29. package/dist/esm/OxyServices.base.js +7 -102
  30. package/dist/esm/boot/coldBootV2.js +344 -0
  31. package/dist/esm/boot/deviceBootReturn.js +146 -0
  32. package/dist/esm/crypto/keyManager.js +95 -0
  33. package/dist/esm/i18n/locales/en-US.json +13 -1
  34. package/dist/esm/i18n/locales/es-ES.json +13 -1
  35. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  36. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  37. package/dist/esm/index.js +28 -18
  38. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  39. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  40. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +13 -17
  43. package/dist/esm/session/SessionClient.js +44 -2
  44. package/dist/esm/session/authStateStore.js +278 -0
  45. package/dist/esm/session/createSessionClient.js +8 -2
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/utils/accountUtils.js +0 -53
  48. package/dist/esm/utils/authWebUrl.js +6 -14
  49. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  50. package/dist/esm/utils/platform.js +18 -0
  51. package/dist/esm/utils/ssoBounce.js +14 -324
  52. package/dist/esm/utils/validationUtils.js +56 -0
  53. package/dist/types/.tsbuildinfo +1 -1
  54. package/dist/types/HttpService.d.ts +13 -0
  55. package/dist/types/OxyServices.base.d.ts +0 -52
  56. package/dist/types/OxyServices.d.ts +0 -25
  57. package/dist/types/boot/coldBootV2.d.ts +76 -0
  58. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  59. package/dist/types/crypto/keyManager.d.ts +21 -0
  60. package/dist/types/index.d.ts +16 -21
  61. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  64. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  66. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  67. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  71. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  85. package/dist/types/mixins/index.d.ts +6 -9
  86. package/dist/types/models/interfaces.d.ts +0 -67
  87. package/dist/types/session/SessionClient.d.ts +38 -1
  88. package/dist/types/session/authStateStore.d.ts +119 -0
  89. package/dist/types/session/createSessionClient.d.ts +8 -1
  90. package/dist/types/session/refresh.d.ts +93 -0
  91. package/dist/types/utils/accountUtils.d.ts +0 -14
  92. package/dist/types/utils/authWebUrl.d.ts +6 -12
  93. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  94. package/dist/types/utils/platform.d.ts +14 -0
  95. package/dist/types/utils/ssoBounce.d.ts +14 -262
  96. package/dist/types/utils/validationUtils.d.ts +15 -0
  97. package/package.json +2 -2
  98. package/src/HttpService.ts +19 -3
  99. package/src/OxyServices.base.ts +7 -112
  100. package/src/OxyServices.ts +0 -38
  101. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  102. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  103. package/src/boot/coldBootV2.ts +426 -0
  104. package/src/boot/deviceBootReturn.ts +195 -0
  105. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  106. package/src/crypto/keyManager.ts +101 -0
  107. package/src/i18n/locales/en-US.json +13 -1
  108. package/src/i18n/locales/es-ES.json +13 -1
  109. package/src/index.ts +78 -64
  110. package/src/mixins/OxyServices.accounts.ts +6 -13
  111. package/src/mixins/OxyServices.auth.ts +78 -253
  112. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  113. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  114. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  115. package/src/mixins/__tests__/accounts.test.ts +17 -44
  116. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  117. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  118. package/src/mixins/index.ts +16 -22
  119. package/src/models/interfaces.ts +0 -79
  120. package/src/session/SessionClient.ts +69 -3
  121. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  122. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  123. package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
  124. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  125. package/src/session/__tests__/authStateStore.test.ts +209 -0
  126. package/src/session/__tests__/refresh.test.ts +256 -0
  127. package/src/session/authStateStore.ts +335 -0
  128. package/src/session/createSessionClient.ts +9 -1
  129. package/src/session/refresh.ts +334 -0
  130. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  131. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  132. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  133. package/src/utils/accountUtils.ts +0 -62
  134. package/src/utils/authWebUrl.ts +6 -15
  135. package/src/utils/fapiAutoDetect.ts +16 -60
  136. package/src/utils/platform.ts +21 -0
  137. package/src/utils/ssoBounce.ts +14 -371
  138. package/src/utils/validationUtils.ts +62 -0
  139. package/dist/cjs/AuthManager.js +0 -1110
  140. package/dist/cjs/AuthManagerTypes.js +0 -13
  141. package/dist/cjs/CrossDomainAuth.js +0 -206
  142. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  143. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  144. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  145. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  146. package/dist/cjs/utils/ssoEstablish.js +0 -110
  147. package/dist/cjs/utils/ssoReturn.js +0 -267
  148. package/dist/esm/AuthManager.js +0 -1105
  149. package/dist/esm/AuthManagerTypes.js +0 -12
  150. package/dist/esm/CrossDomainAuth.js +0 -201
  151. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  152. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  153. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  154. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  155. package/dist/esm/utils/ssoEstablish.js +0 -107
  156. package/dist/esm/utils/ssoReturn.js +0 -263
  157. package/dist/types/AuthManager.d.ts +0 -380
  158. package/dist/types/AuthManagerTypes.d.ts +0 -81
  159. package/dist/types/CrossDomainAuth.d.ts +0 -164
  160. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  161. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  162. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  163. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  164. package/dist/types/utils/ssoReturn.d.ts +0 -146
  165. package/src/AuthManager.ts +0 -1269
  166. package/src/AuthManagerTypes.ts +0 -86
  167. package/src/CrossDomainAuth.ts +0 -243
  168. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  169. package/src/__tests__/authManager.security.test.ts +0 -377
  170. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  171. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  172. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  173. package/src/mixins/OxyServices.redirect.ts +0 -122
  174. package/src/mixins/OxyServices.silent.ts +0 -272
  175. package/src/mixins/OxyServices.sso.ts +0 -261
  176. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  177. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  178. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  179. package/src/mixins/__tests__/silent.test.ts +0 -102
  180. package/src/mixins/__tests__/sso.test.ts +0 -228
  181. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  182. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  183. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  184. package/src/utils/__tests__/ssoReturn.test.ts +0 -251
  185. package/src/utils/ssoEstablish.ts +0 -174
  186. package/src/utils/ssoReturn.ts +0 -372
@@ -1,379 +1,22 @@
1
1
  /**
2
- * Central cross-domain SSO bounce per-origin sessionStorage keys, the bounce
3
- * URL builder, and the small pure predicates shared by every consumer's
4
- * cold-boot `sso-return` / `sso-bounce` steps and bfcache `pageshow`
5
- * re-evaluation.
2
+ * SSO callback path constant.
6
3
  *
7
- * This is the single source of truth for the SSO bounce wire/storage contract.
8
- * `@oxyhq/auth` (`WebOxyProvider`) and `@oxyhq/services` (`OxyContext`) both
9
- * consume these helpers so the two providers behave identically.
4
+ * The client SSO-bounce machinery (per-origin sessionStorage keys, the bounce
5
+ * URL builder, the `guardActive` / `allowSsoBounce` / `getSsoCallbackBootstrapScript`
6
+ * predicates, …) was removed in the device-first cutover RP apps no longer
7
+ * bounce through `auth.oxy.so`. The one survivor is this path constant, still
8
+ * referenced by the api SSO controller and the IdP (both lista B, gated on the
9
+ * ecosystem bump). `@oxyhq/core/server` re-exports it for the api.
10
10
  *
11
- * TRUE central SSO (Google/Meta/Clerk style) works like this for a Relying
12
- * Party (mention.earth, homiio.com, alia.onl, …) with no local session:
13
- *
14
- * 1. `sso-bounce` (terminal, once): a TOP-LEVEL navigation to
15
- * `auth.oxy.so/sso?prompt=none&client_id=<origin>&return_to=<origin>{@link SSO_CALLBACK_PATH}&state=<s>`.
16
- * Before navigating it records, in this origin's `sessionStorage`, the
17
- * CSRF `state` ({@link ssoStateKey}), a guard timestamp ({@link ssoGuardKey},
18
- * the loop breaker), and the real destination URL ({@link ssoDestKey}) to
19
- * restore after the callback.
20
- * 2. The central IdP worker reads its first-party `fedcm_session`, mints a
21
- * session, stores it under an opaque single-use `code`, and 303-redirects
22
- * back to `<origin>{@link SSO_CALLBACK_PATH}#oxy_sso=ok&code=<code>&state=<s>`
23
- * (or `#oxy_sso=none` / `#oxy_sso=error`).
24
- * 3. `sso-return` parses the fragment (`parseSsoReturnFragment`), validates
25
- * `state`, exchanges the `code` via `oxyServices.exchangeSsoCode`, commits
26
- * the session, then restores the original destination.
27
- *
28
- * Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
29
- * guard/state/dest + the outcome-independent attempted-flag
30
- * ({@link ssoAttemptedKey}) and navigates; the IdP (no central session) returns
31
- * `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
32
- * NO_SESSION flag ({@link ssoNoSessionKey}), and `sso-bounce` is then disabled.
33
- * Exactly ONE bounce, no loop. An interrupted bounce (user hit back
34
- * mid-redirect) self-heals once the {@link SSO_GUARD_TTL_MS} guard TTL lapses.
35
- * The attempted-flag is the definitive, outcome-INDEPENDENT loop breaker: it is
36
- * set pre-bounce so even if the return-side NO_SESSION write never lands, the
37
- * bounce can never re-fire this tab after the self-heal TTL lapses.
38
- *
39
- * All state lives in `sessionStorage` (per tab, cleared on tab close) and is
40
- * keyed per-origin so two RPs hosted in the same browser never collide. The
41
- * key strings and the 30s TTL are a wire/storage contract — they MUST match
42
- * the values the IdP and every consumer expect and must not change lightly.
11
+ * LEGACY(old-sdk): `SSO_CALLBACK_PATH` survives ONLY for the lista-B api/IdP SSO
12
+ * surface. Deletable once Homiio/Allo/Alia/Syra are bumped off the old SDK AND
13
+ * CloudWatch `/oxy/ecs` shows the `/sso*` + `/fedcm/*` routes quiet — the
14
+ * F-final sweep should remove this file then.
43
15
  */
44
16
 
45
- import { CENTRAL_AUTH_URL, resolveCentralAuthUrl } from './authWebUrl';
46
-
47
17
  /**
48
- * The RP callback path the central IdP redirects back to after a bounce. The
49
- * SSO result is delivered in the fragment of this URL; the `sso-return` step
50
- * consumes it and then restores the user's real destination (stored under
51
- * {@link ssoDestKey}), so the user never lingers on this internal path.
18
+ * The RP callback path the central IdP redirects back to after a legacy SSO
19
+ * bounce. Kept as the single source of truth so the api/IdP never hardcode the
20
+ * literal.
52
21
  */
53
22
  export const SSO_CALLBACK_PATH = '/__oxy/sso-callback';
54
-
55
- /**
56
- * Self-healing TTL (ms) for the bounce guard. An in-flight bounce sets a
57
- * timestamp guard; if the bounce is interrupted before the callback lands
58
- * (e.g. the user navigates back mid-redirect), the guard would otherwise pin
59
- * the RP signed-out forever. After this window the guard is treated as stale
60
- * and a fresh single bounce is permitted. 30s comfortably exceeds a real
61
- * redirect round-trip while keeping a crash short-lived.
62
- */
63
- export const SSO_GUARD_TTL_MS = 30_000;
64
-
65
- const STATE_KEY_PREFIX = 'oxy_sso_state:';
66
- const GUARD_KEY_PREFIX = 'oxy_sso_guard:';
67
- const DEST_KEY_PREFIX = 'oxy_sso_dest:';
68
- const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
69
- const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
70
- const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
71
- const PRIOR_SESSION_KEY_PREFIX = 'oxy_sso_prior_session:';
72
- const SIGNED_OUT_KEY_PREFIX = 'oxy_signed_out:';
73
-
74
- /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
75
- export function ssoStateKey(origin: string): string {
76
- return `${STATE_KEY_PREFIX}${origin}`;
77
- }
78
-
79
- /** Per-origin bounce guard key (a timestamp; loop breaker + self-heal TTL). */
80
- export function ssoGuardKey(origin: string): string {
81
- return `${GUARD_KEY_PREFIX}${origin}`;
82
- }
83
-
84
- /** Per-origin destination key (the real URL to restore after the callback). */
85
- export function ssoDestKey(origin: string): string {
86
- return `${DEST_KEY_PREFIX}${origin}`;
87
- }
88
-
89
- /**
90
- * Per-origin "the central IdP has no session for me" key. Set after a
91
- * `none`/`error` return (or a failed/forged exchange) so `sso-bounce` does not
92
- * fire again this tab — the definitive loop breaker.
93
- */
94
- export function ssoNoSessionKey(origin: string): string {
95
- return `${NO_SESSION_KEY_PREFIX}${origin}`;
96
- }
97
-
98
- /**
99
- * Per-origin, OUTCOME-INDEPENDENT once-guard. Set in `sessionStorage` BEFORE
100
- * the terminal SSO bounce navigates. Gates the bounce so the silent
101
- * cross-domain probe fires AT MOST ONCE per tab session — independent of
102
- * whether the return-side NO_SESSION flag ever lands. The definitive loop
103
- * breaker; survives the 30s self-heal `ssoGuardKey` TTL. Cleared only on an
104
- * explicit sign-out/clear so a later cold boot (after the user signs in
105
- * centrally) can probe again.
106
- */
107
- export function ssoAttemptedKey(origin: string): string {
108
- return `${ATTEMPTED_KEY_PREFIX}${origin}`;
109
- }
110
-
111
- /**
112
- * Per-origin DURABLE "this device/origin has had a signed-in Oxy session
113
- * before" hint.
114
- *
115
- * Unlike every other key in this module — which lives in per-tab
116
- * `sessionStorage` — this hint is written to DURABLE storage (web
117
- * `localStorage`; the services provider uses its own `storageKeyPrefix`-scoped
118
- * key in `@oxyhq/services`). It is set whenever a session is established or
119
- * restored and survives a session expiring; it is cleared ONLY on an explicit
120
- * full sign-out. It exists purely to drive {@link allowSsoBounce}: a returning
121
- * visitor (hint present) whose local session has lapsed still gets ONE terminal
122
- * `/sso` establish bounce to recover a session that lives only at the central
123
- * IdP, while a truly first-time anonymous visitor is never force-bounced.
124
- */
125
- export function ssoPriorSessionKey(origin: string): string {
126
- return `${PRIOR_SESSION_KEY_PREFIX}${origin}`;
127
- }
128
-
129
- /**
130
- * Per-origin DURABLE "the user DELIBERATELY signed out on this device/origin"
131
- * flag.
132
- *
133
- * Like {@link ssoPriorSessionKey} this lives in DURABLE storage (web
134
- * `localStorage`; services uses its own `storageKeyPrefix`-scoped key), NOT the
135
- * per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
136
- *
137
- * It exists purely to suppress AUTOMATIC silent restore after a deliberate
138
- * sign-out: a still-live IdP session (the central `fedcm_session`) would
139
- * otherwise let the per-apex `/auth/silent` iframe re-mint a session on the
140
- * very next cold boot, so a user who pressed "Sign out" gets silently signed
141
- * back in on reload. With this flag set, that silent cold-boot step is
142
- * skipped while the Gmail-style returning-account fast-path is otherwise
143
- * preserved.
144
- *
145
- * Lifecycle (mirrors the existing gate machinery — set on a definitive event,
146
- * cleared on its inverse):
147
- * - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
148
- * and the SSO bounce state).
149
- * - CLEARED on ANY deliberate sign-in (password, account switch, device
150
- * claim) so a real sign-in fully re-enables silent restore — there is no
151
- * "stuck signed out" state.
152
- *
153
- * NOTE: this gates only AUTOMATIC/silent restore. An INTERACTIVE sign-in always
154
- * clears it first, so the user can always sign back in.
155
- */
156
- export function ssoSignedOutKey(origin: string): string {
157
- return `${SIGNED_OUT_KEY_PREFIX}${origin}`;
158
- }
159
-
160
- /**
161
- * Per-origin marker written by the pre-hydration callback bootstrap.
162
- *
163
- * Static Expo exports render unknown paths as `+not-found`; on
164
- * `/__oxy/sso-callback` that can fail hydration before the React provider has a
165
- * chance to run `consumeSsoReturn`. The bootstrap runs in the HTML head, moves
166
- * the URL to a hydratable route while preserving the SSO fragment, and writes
167
- * this marker so `consumeSsoReturn` still restores the original destination as
168
- * if the page were physically on the callback path.
169
- */
170
- export function ssoCallbackBootstrapKey(origin: string): string {
171
- return `${CALLBACK_BOOTSTRAP_KEY_PREFIX}${origin}`;
172
- }
173
-
174
- /**
175
- * Inline script for Expo/static web apps.
176
- *
177
- * Must run before the app bundle hydrates. It is intentionally tiny and
178
- * dependency-free: if the browser lands on the internal callback route with an
179
- * Oxy SSO fragment, it marks the handoff and rewrites the path to `/` while
180
- * preserving `#oxy_sso=...`. The normal SDK cold-boot `sso-return` step then
181
- * consumes the fragment from a route that can hydrate. If the internal route is
182
- * reached without a valid SSO fragment, it leaves the route via a hard root
183
- * navigation because there is no session material to preserve.
184
- */
185
- export function getSsoCallbackBootstrapScript(): string {
186
- const callbackPath = JSON.stringify(SSO_CALLBACK_PATH);
187
- const bootstrapPrefix = JSON.stringify(CALLBACK_BOOTSTRAP_KEY_PREFIX);
188
-
189
- return `(function(){var p=${callbackPath};if(window.location.pathname!==p)return;var h=window.location.hash||"";if(!/(?:^#|&)oxy_sso=(?:ok|none|error)(?:&|$)/.test(h)){window.location.replace("/");return;}try{window.sessionStorage.setItem(${bootstrapPrefix}+window.location.origin,"1");}catch(e){window.__oxySsoCallbackBootstrapError=e instanceof Error?e.message:String(e);}try{window.history.replaceState(null,"","/"+h);}catch(e){window.__oxySsoCallbackBootstrapError=e instanceof Error?e.message:String(e);window.location.replace("/"+h);}})();`;
190
- }
191
-
192
- /**
193
- * Perform the terminal top-level SSO bounce navigation.
194
- *
195
- * A thin wrapper over `window.location.assign(url)` so the single navigation
196
- * seam lives in one place (and stays mockable in tests, where jsdom's
197
- * `Location.assign` is a non-configurable native method). In production this is
198
- * exactly `window.location.assign` — the document is torn down and replaced by
199
- * the central IdP page. Off-browser (SSR / native) it is a no-op: native never
200
- * bounces.
201
- */
202
- export function ssoNavigate(url: string): void {
203
- if (typeof window === 'undefined' || typeof window.location === 'undefined') {
204
- return;
205
- }
206
- window.location.assign(url);
207
- }
208
-
209
- /**
210
- * Build the central IdP `/sso` bounce URL for an RP.
211
- *
212
- * Pure (no DOM access) so it is unit-testable and shared by every consumer's
213
- * terminal `sso-bounce` step. The IdP reads `client_id` (the RP origin) and
214
- * `return_to` to mint an origin-bound opaque code and 303-redirect back.
215
- *
216
- * The IdP base is resolved via {@link resolveCentralAuthUrl} so an explicit
217
- * `authWebUrl` override (e.g. a staging IdP) drives the SSO bounce exactly the
218
- * way it drives FedCM. When omitted, the central default {@link CENTRAL_AUTH_URL}
219
- * is used.
220
- *
221
- * @param origin - The RP origin (`window.location.origin`).
222
- * @param state - The CSRF state minted for this bounce.
223
- * @param authWebUrl - Optional explicit IdP base URL override. Falls back to
224
- * the central default when `undefined`/empty.
225
- * @returns The absolute `<idp-origin>/sso?...` URL string.
226
- */
227
- export function buildSsoBounceUrl(
228
- origin: string,
229
- state: string,
230
- authWebUrl?: string,
231
- ): string {
232
- const url = new URL('/sso', resolveCentralAuthUrl(authWebUrl));
233
- url.searchParams.set('prompt', 'none');
234
- url.searchParams.set('client_id', origin);
235
- url.searchParams.set('return_to', origin + SSO_CALLBACK_PATH);
236
- url.searchParams.set('state', state);
237
- return url.toString();
238
- }
239
-
240
- /**
241
- * Whether `origin` IS the central IdP origin. The RP must NEVER bounce while
242
- * sitting on `auth.oxy.so` itself — doing so would loop the IdP against itself.
243
- *
244
- * Both sides are normalised via `new URL(...).origin` so a trailing-slash or
245
- * path difference never defeats the guard. Returns `false` on any parse
246
- * failure (an unparseable candidate is, by definition, not the central IdP).
247
- */
248
- export function isCentralIdPOrigin(origin: string): boolean {
249
- let centralOrigin: string;
250
- try {
251
- centralOrigin = new URL(CENTRAL_AUTH_URL).origin;
252
- } catch {
253
- return false;
254
- }
255
- let candidateOrigin: string;
256
- try {
257
- candidateOrigin = new URL(origin).origin;
258
- } catch {
259
- return false;
260
- }
261
- return candidateOrigin === centralOrigin;
262
- }
263
-
264
- /**
265
- * Read the bounce guard and decide whether it is still ACTIVE.
266
- *
267
- * Active means: a guard value is present AND it parses to a finite timestamp
268
- * AND less than {@link SSO_GUARD_TTL_MS} has elapsed since it was set. An active
269
- * guard disables `sso-bounce` (a bounce is already in flight this tab). A
270
- * missing, malformed, or expired guard is NOT active, so a fresh bounce may
271
- * proceed (this is the 30s self-heal for an interrupted bounce).
272
- *
273
- * Defensive: a `getItem` that throws (e.g. a locked/disabled storage) is
274
- * treated as "not active" so the guard never wedges the flow.
275
- *
276
- * @param storage - The session storage to read (injected for testability).
277
- * @param origin - The page origin whose guard to evaluate.
278
- * @param now - Current epoch ms (injected for deterministic tests). Defaults to
279
- * `Date.now()`.
280
- */
281
- export function guardActive(
282
- storage: Pick<Storage, 'getItem'>,
283
- origin: string,
284
- now: number = Date.now(),
285
- ): boolean {
286
- let raw: string | null;
287
- try {
288
- raw = storage.getItem(ssoGuardKey(origin));
289
- } catch {
290
- return false;
291
- }
292
- if (raw === null || raw.length === 0) {
293
- return false;
294
- }
295
- const ts = Number(raw);
296
- if (!Number.isFinite(ts)) {
297
- return false;
298
- }
299
- return now - ts < SSO_GUARD_TTL_MS;
300
- }
301
-
302
- /**
303
- * Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
304
- * user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
305
- *
306
- * When `true`, the silent cold-boot step that can re-mint a session from a
307
- * still-live IdP session WITHOUT user intent — the per-apex
308
- * `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
309
- * not silently signed back in on the next reload. Interactive sign-in clears the
310
- * flag, so this never blocks a deliberate re-sign-in.
311
- *
312
- * Defensive: a `getItem` that throws (locked/disabled storage) is treated as NOT
313
- * suppressed, so the gate fails toward the normal restore behaviour rather than
314
- * wedging the user out.
315
- *
316
- * @param storage - The DURABLE storage to read (web `localStorage`; injected for
317
- * testability).
318
- * @param origin - The page origin whose signed-out flag to evaluate.
319
- */
320
- export function silentRestoreSuppressed(
321
- storage: Pick<Storage, 'getItem'>,
322
- origin: string,
323
- ): boolean {
324
- try {
325
- return storage.getItem(ssoSignedOutKey(origin)) === '1';
326
- } catch {
327
- return false;
328
- }
329
- }
330
-
331
- /**
332
- * Inputs to the smart {@link allowSsoBounce} gate.
333
- */
334
- export interface SsoBounceGate {
335
- /**
336
- * Whether this device/origin has had a signed-in Oxy session before (the
337
- * durable {@link ssoPriorSessionKey} hint). Set whenever a session is
338
- * established or restored; survives session expiry; cleared only on explicit
339
- * full sign-out. `true` ⇒ a returning visitor.
340
- */
341
- readonly hasPriorSession: boolean;
342
- /**
343
- * Whether a local/stored session was recovered earlier this cold boot. At the
344
- * terminal bounce gate this is effectively always `false` (an earlier step
345
- * would have won and short-circuited), but it is part of the contract — "no
346
- * prior hint AND no local session" — so it is passed explicitly for fidelity
347
- * and robustness.
348
- */
349
- readonly hasLocalSession: boolean;
350
- }
351
-
352
- /**
353
- * Decide whether the terminal `/sso` establish-bounce is ALLOWED for this
354
- * visitor (the smart `enabled` gate for the `sso-bounce` cold-boot step).
355
- *
356
- * The terminal bounce is the ONLY cold-boot step that can recover a session
357
- * that lives SOLELY at the central IdP — the cross-apex Relying-Party case
358
- * (e.g. `mention.earth`, a different apex from `oxy.so`) whose device-local
359
- * session has expired and whose `Domain=oxy.so` refresh cookie never reaches
360
- * `api.<apex>`. It is also what plants the first-party per-apex `fedcm_session`
361
- * cookie that the EARLIER `silent-iframe` step later relies on. So it must fire
362
- * for a RETURNING user, yet it must NOT force a truly first-time anonymous
363
- * visitor off to the IdP.
364
- *
365
- * - ALLOW when there is a prior-signed-in hint OR a local session was
366
- * recovered this boot (a returning user) — so a central-only cross-domain
367
- * session recovers via ONE bounce, after which the per-apex cookie is
368
- * planted and subsequent loads restore silently with no bounce.
369
- * - else (no hint, no local session) SUPPRESS — a first-time anonymous
370
- * visitor browses without a forced redirect.
371
- *
372
- * This is the smart DEFAULT and the ONLY behaviour: apps never configure it.
373
- * It is also the GATE DECISION ONLY — callers still apply the per-tab loop
374
- * guards (`ssoAttemptedKey`, `ssoNoSessionKey`, {@link guardActive}) so an
375
- * allowed bounce still fires at most once per cold boot.
376
- */
377
- export function allowSsoBounce(gate: SsoBounceGate): boolean {
378
- return gate.hasPriorSession || gate.hasLocalSession;
379
- }
@@ -39,6 +39,68 @@ export function isValidPassword(password: string): boolean {
39
39
  return PASSWORD_REGEX.test(password);
40
40
  }
41
41
 
42
+ /**
43
+ * Display-name character policy.
44
+ *
45
+ * A clean display name is composed ONLY of:
46
+ * - letters of any script (`\p{L}`),
47
+ * - combining marks / accents (`\p{M}`, e.g. the acute accent in a decomposed
48
+ * "é"),
49
+ * - Unicode space separators (`\p{Zs}`: the ASCII space, NBSP, ideographic
50
+ * space, …) — but NOT control whitespace such as tab, newline, or carriage
51
+ * return, which would break layout or enable multi-line spoofing,
52
+ * - the straight apostrophe (`'`, e.g. "O'Brien").
53
+ *
54
+ * Everything else is rejected: emoji (🐧), symbols (⁂ ⏚), `:emoji:` shortcodes,
55
+ * digits, hyphens, dots, control whitespace (tab/newline/CR), and any other
56
+ * punctuation. The allowed set `\p{L}\p{M}\p{Zs}'` explicitly EXCLUDES `<`, `>`,
57
+ * `&`, and `"`, so a value that passes this predicate can never contain an
58
+ * HTML/XSS vector.
59
+ *
60
+ * This is the SINGLE definition of the policy, shared between the API 400-gate
61
+ * (`@oxyhq/api` `displayNameSanitize.ts`) and client-side inline validation
62
+ * (the RN profile editor) so the two can never drift. It is platform-agnostic
63
+ * (no react/react-native/expo).
64
+ */
65
+
66
+ /**
67
+ * Single test for the presence of a disallowed character (non-global). The
68
+ * whitespace class is `\p{Zs}` (space separators only), NOT `\s` — the latter
69
+ * would admit tab/newline/carriage return, which break layout and enable
70
+ * multi-line spoofing.
71
+ */
72
+ const DISALLOWED_PROBE = /[^\p{L}\p{M}\p{Zs}']/u;
73
+
74
+ /**
75
+ * Single test for the presence of an orphaned combining mark (non-global) — a
76
+ * `\p{M}` not attached to a base letter (string start, whitespace, the
77
+ * apostrophe, or a position vacated by a stripped character). A mark preceded by
78
+ * `\p{L}` (a base letter, e.g. the decomposed accent in "Renée") or by another
79
+ * `\p{M}` (a multi-mark cluster) is NOT matched because the negative lookbehind
80
+ * fails at its position.
81
+ */
82
+ const ORPHANED_MARK_PROBE = /(?<![\p{L}\p{M}])\p{M}/u;
83
+
84
+ /**
85
+ * Whether `raw` already satisfies the display-name policy, i.e. it contains no
86
+ * disallowed characters AND no orphaned combining marks. Used to REJECT native
87
+ * (signup / profile edit) names with a 400 rather than silently stripping them,
88
+ * and to validate inline in the client editor.
89
+ *
90
+ * The orphaned-mark probe runs on the NFC-normalized form so a legitimate
91
+ * decomposed accent (`e`+◌́) — which normalization recomposes into `é` — is NOT
92
+ * rejected, while a lone, base-less mark (e.g. `"༘"`) IS.
93
+ *
94
+ * The function only checks the character set; an empty or whitespace-only string
95
+ * is considered valid (`true`). Call sites that require a non-empty name enforce
96
+ * that separately.
97
+ */
98
+ export function isValidDisplayName(raw: string): boolean {
99
+ return (
100
+ !DISALLOWED_PROBE.test(raw) && !ORPHANED_MARK_PROBE.test(raw.normalize('NFC'))
101
+ );
102
+ }
103
+
42
104
  /**
43
105
  * Validate required string
44
106
  */