@oxyhq/core 16.0.0 → 17.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 (47) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +28 -6
  3. package/dist/cjs/i18n/locales/en-US.json +5 -3
  4. package/dist/cjs/i18n/locales/es-ES.json +5 -3
  5. package/dist/cjs/i18n/locales/locales/en-US.json +5 -3
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +5 -3
  7. package/dist/cjs/index.js +6 -3
  8. package/dist/cjs/mixins/OxyServices.auth.js +57 -19
  9. package/dist/cjs/server/index.js +7 -1
  10. package/dist/cjs/server/userInvalidation.js +172 -0
  11. package/dist/cjs/utils/displayNamePolicyRanges.generated.js +28 -3
  12. package/dist/cjs/utils/validationUtils.js +112 -21
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/HttpService.js +28 -6
  15. package/dist/esm/i18n/locales/en-US.json +5 -3
  16. package/dist/esm/i18n/locales/es-ES.json +5 -3
  17. package/dist/esm/i18n/locales/locales/en-US.json +5 -3
  18. package/dist/esm/i18n/locales/locales/es-ES.json +5 -3
  19. package/dist/esm/index.js +1 -1
  20. package/dist/esm/mixins/OxyServices.auth.js +57 -19
  21. package/dist/esm/server/index.js +3 -0
  22. package/dist/esm/server/userInvalidation.js +167 -0
  23. package/dist/esm/utils/displayNamePolicyRanges.generated.js +27 -2
  24. package/dist/esm/utils/validationUtils.js +112 -21
  25. package/dist/types/.tsbuildinfo +1 -1
  26. package/dist/types/HttpService.d.ts +13 -4
  27. package/dist/types/index.d.ts +2 -2
  28. package/dist/types/mixins/OxyServices.auth.d.ts +19 -0
  29. package/dist/types/server/index.d.ts +2 -0
  30. package/dist/types/server/userInvalidation.d.ts +133 -0
  31. package/dist/types/utils/displayNamePolicyRanges.generated.d.ts +27 -2
  32. package/dist/types/utils/validationUtils.d.ts +106 -20
  33. package/package.json +1 -1
  34. package/src/HttpService.ts +32 -7
  35. package/src/__tests__/httpServiceFormEncoded.test.ts +142 -0
  36. package/src/i18n/locales/en-US.json +5 -3
  37. package/src/i18n/locales/es-ES.json +5 -3
  38. package/src/index.ts +4 -1
  39. package/src/mixins/OxyServices.auth.ts +76 -20
  40. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +72 -11
  41. package/src/server/__tests__/userInvalidation.test.ts +208 -0
  42. package/src/server/index.ts +13 -0
  43. package/src/server/userInvalidation.ts +221 -0
  44. package/src/utils/__tests__/coldBoot.test.ts +9 -4
  45. package/src/utils/__tests__/validationUtils.test.ts +292 -1
  46. package/src/utils/displayNamePolicyRanges.generated.ts +31 -2
  47. package/src/utils/validationUtils.ts +117 -20
@@ -203,7 +203,7 @@
203
203
  "firstNamePlaceholder": "Introduce tu nombre",
204
204
  "lastName": "Apellido",
205
205
  "lastNamePlaceholder": "Introduce tu apellido (opcional)",
206
- "invalidChars": "Usa solo letras y espacios"
206
+ "invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
207
207
  },
208
208
  "username": {
209
209
  "title": "Usuario",
@@ -1752,7 +1752,8 @@
1752
1752
  "title": "Crear cuenta",
1753
1753
  "subtitle": "Crea una cuenta que tú controlas. Tendrá su propio perfil, miembros y aplicaciones.",
1754
1754
  "displayName": {
1755
- "label": "Nombre visible"
1755
+ "label": "Nombre visible",
1756
+ "invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
1756
1757
  },
1757
1758
  "bio": {
1758
1759
  "label": "Biografía (opcional)"
@@ -1829,7 +1830,8 @@
1829
1830
  "subtitle": "Gestiona el perfil, los miembros y el acceso de esta cuenta.",
1830
1831
  "save": "Guardar cambios",
1831
1832
  "displayName": {
1832
- "label": "Nombre visible"
1833
+ "label": "Nombre visible",
1834
+ "invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
1833
1835
  },
1834
1836
  "bio": {
1835
1837
  "label": "Biografía (opcional)"
package/src/index.ts CHANGED
@@ -49,7 +49,7 @@ export type {
49
49
  CommonsDeliveryPlatform,
50
50
  CommonsDeliveryRoute,
51
51
  } from './utils/commonsDelivery';
52
- export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
52
+ export type { ServiceTokenResponse, OAuthUserInfoResponse } from './mixins/OxyServices.auth';
53
53
  // "Sign in with Oxy" — handoff (Workstream C)
54
54
  export type {
55
55
  CommonsSignInHandle,
@@ -479,6 +479,8 @@ export {
479
479
  EMAIL_REGEX,
480
480
  USERNAME_REGEX,
481
481
  PASSWORD_REGEX,
482
+ MAX_DISPLAY_NAME_LENGTH,
483
+ DISPLAY_NAME_INVALID_MESSAGE,
482
484
  isValidEmail,
483
485
  isValidUsername,
484
486
  isValidPassword,
@@ -486,6 +488,7 @@ export {
486
488
  DISPLAY_NAME_ALLOWED_SCRIPTS,
487
489
  DISPLAY_NAME_DISALLOWED_SOURCE,
488
490
  DISPLAY_NAME_ORPHANED_MARK_SOURCE,
491
+ DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE,
489
492
  isRequiredString,
490
493
  isRequiredNumber,
491
494
  isRequiredBoolean,
@@ -32,6 +32,13 @@ import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/use
32
32
  */
33
33
  const COMMONS_SIGN_IN_EXPIRY_MS = 5 * 60 * 1000;
34
34
 
35
+ /**
36
+ * Fallback access-token lifetime used only if the token endpoint ever omits the
37
+ * RFC 6749 `expires_in` member. Matches the server's current 15-minute access
38
+ * token; the server's value always wins when present.
39
+ */
40
+ const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
41
+
35
42
  export interface ChallengeResponse {
36
43
  challenge: string;
37
44
  expiresAt: string;
@@ -59,6 +66,14 @@ export interface PublicKeyCheckResponse {
59
66
  message: string;
60
67
  }
61
68
 
69
+ /** OpenID Connect userinfo claims returned by `GET /auth/oauth/userinfo`. */
70
+ export interface OAuthUserInfoResponse {
71
+ sub: string;
72
+ preferred_username?: string;
73
+ name?: string;
74
+ picture?: string;
75
+ }
76
+
62
77
  // ===========================================================================
63
78
  // "Sign in with Oxy" — cross-device QR / app-to-app handoff (Workstream C)
64
79
  // ===========================================================================
@@ -1677,6 +1692,13 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1677
1692
  * after sign-in at auth.oxy.so) for a device-first session.
1678
1693
  * Public first-party clients use PKCE (`codeVerifier`); the access token is
1679
1694
  * planted immediately on success.
1695
+ *
1696
+ * Speaks the standard RFC 6749 §4.1.3 token request — a form-urlencoded
1697
+ * body with snake_case parameters and `grant_type=authorization_code` — and
1698
+ * reads the flat §5.1 response. The camelCase JSON request and `{ data }`
1699
+ * response this method used before were an Oxy invention no OAuth library
1700
+ * could interoperate with; the endpoint no longer accepts them. The method's
1701
+ * OWN signature is unchanged, so callers are unaffected.
1680
1702
  */
1681
1703
  async exchangeOAuthCode(params: {
1682
1704
  code: string;
@@ -1685,38 +1707,39 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1685
1707
  codeVerifier: string;
1686
1708
  }): Promise<LoginSessionResult> {
1687
1709
  try {
1688
- const res = await this.makeRequest<unknown>('POST', '/auth/oauth/token', {
1710
+ const form = new URLSearchParams({
1711
+ grant_type: 'authorization_code',
1689
1712
  code: params.code,
1690
- clientId: params.clientId,
1691
- redirectUri: params.redirectUri,
1692
- codeVerifier: params.codeVerifier,
1693
- }, { cache: false, skipAuth: true });
1694
- const payload =
1695
- (res as { data?: Record<string, unknown> }).data ??
1696
- (res as Record<string, unknown>);
1697
- if (!payload || typeof payload !== 'object') {
1713
+ redirect_uri: params.redirectUri,
1714
+ client_id: params.clientId,
1715
+ code_verifier: params.codeVerifier,
1716
+ });
1717
+ const res = await this.makeRequest<unknown>(
1718
+ 'POST',
1719
+ '/auth/oauth/token',
1720
+ form,
1721
+ { cache: false, skipAuth: true },
1722
+ );
1723
+ if (!res || typeof res !== 'object') {
1698
1724
  throw new Error('auth/oauth/token returned an unexpected response shape');
1699
1725
  }
1700
- const record = payload as Record<string, unknown>;
1701
- const accessToken = (record.access_token ?? record.accessToken) as string | undefined;
1702
- const sessionId = (record.session_id ?? record.sessionId) as string | undefined;
1703
- const deviceId = (record.deviceId ?? record.device_id) as string | undefined;
1704
- const deviceSecret = (record.deviceSecret ?? record.device_secret) as string | undefined;
1726
+ // RFC 6749 §5.1: every member sits at the TOP LEVEL of the document.
1727
+ const record = res as Record<string, unknown>;
1728
+ const accessToken = typeof record.access_token === 'string' ? record.access_token : undefined;
1729
+ const sessionId = typeof record.session_id === 'string' ? record.session_id : undefined;
1730
+ const deviceId = typeof record.deviceId === 'string' ? record.deviceId : undefined;
1731
+ const deviceSecret = typeof record.deviceSecret === 'string' ? record.deviceSecret : undefined;
1705
1732
  const userRaw = record.user;
1706
1733
  if (!sessionId || !deviceId || !deviceSecret || !userRaw || typeof userRaw !== 'object') {
1707
1734
  throw new Error('auth/oauth/token returned an incomplete session payload');
1708
1735
  }
1709
1736
  const userObj = userRaw as Record<string, unknown>;
1710
- const userId = userObj.id as string | undefined;
1737
+ const userId = typeof userObj.id === 'string' ? userObj.id : undefined;
1711
1738
  if (!userId) {
1712
1739
  throw new Error('auth/oauth/token returned a session without user.id');
1713
1740
  }
1714
1741
  const expiresInSec =
1715
- typeof record.expires_in === 'number'
1716
- ? record.expires_in
1717
- : typeof record.expiresIn === 'number'
1718
- ? record.expiresIn
1719
- : 15 * 60;
1742
+ typeof record.expires_in === 'number' ? record.expires_in : DEFAULT_ACCESS_TOKEN_TTL_SECONDS;
1720
1743
  const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
1721
1744
  if (accessToken) {
1722
1745
  this.setTokens(accessToken);
@@ -1737,5 +1760,38 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1737
1760
  throw this.handleError(error);
1738
1761
  }
1739
1762
  }
1763
+
1764
+ /**
1765
+ * Fetch OpenID Connect userinfo for the current bearer (`GET /auth/oauth/userinfo`).
1766
+ * The response is a flat JSON document — no `{ data }` wrapper.
1767
+ */
1768
+ async getOAuthUserInfo(): Promise<OAuthUserInfoResponse> {
1769
+ try {
1770
+ const res = await this.makeRequest<unknown>(
1771
+ 'GET',
1772
+ '/auth/oauth/userinfo',
1773
+ undefined,
1774
+ { cache: false },
1775
+ );
1776
+ if (!res || typeof res !== 'object') {
1777
+ throw new Error('auth/oauth/userinfo returned an unexpected response shape');
1778
+ }
1779
+ const record = res as Record<string, unknown>;
1780
+ const sub = typeof record.sub === 'string' ? record.sub : undefined;
1781
+ if (!sub) {
1782
+ throw new Error('auth/oauth/userinfo returned a response without sub');
1783
+ }
1784
+ return {
1785
+ sub,
1786
+ ...(typeof record.preferred_username === 'string'
1787
+ ? { preferred_username: record.preferred_username }
1788
+ : {}),
1789
+ ...(typeof record.name === 'string' ? { name: record.name } : {}),
1790
+ ...(typeof record.picture === 'string' ? { picture: record.picture } : {}),
1791
+ };
1792
+ } catch (error) {
1793
+ throw this.handleError(error);
1794
+ }
1795
+ }
1740
1796
  };
1741
1797
  }
@@ -124,12 +124,14 @@ describe('pre-session public endpoints use skipAuth', () => {
124
124
  );
125
125
  });
126
126
 
127
- it('exchangeOAuthCode skips auth preflight', async () => {
127
+ it('exchangeOAuthCode skips auth preflight and sends an RFC 6749 form request', async () => {
128
128
  makeRequest.mockResolvedValueOnce({
129
- sessionId: 's1',
129
+ access_token: 'tok',
130
+ token_type: 'Bearer',
131
+ expires_in: 900,
132
+ session_id: 's1',
130
133
  deviceId: 'd1',
131
134
  deviceSecret: 'ds_secret',
132
- accessToken: 'tok',
133
135
  user: { id: 'u1' },
134
136
  });
135
137
  await oxy.exchangeOAuthCode({
@@ -141,21 +143,28 @@ describe('pre-session public endpoints use skipAuth', () => {
141
143
  expect(makeRequest).toHaveBeenCalledWith(
142
144
  'POST',
143
145
  '/auth/oauth/token',
144
- {
145
- code: 'code-1',
146
- clientId: 'oxy_dk_test',
147
- redirectUri: 'https://app.example/callback',
148
- codeVerifier: 'verifier',
149
- },
146
+ expect.any(URLSearchParams),
150
147
  expect.objectContaining({ skipAuth: true }),
151
148
  );
149
+ // The body is the standard §4.1.3 request: snake_case names, an explicit
150
+ // grant type, and nothing camelCase that only Oxy's old endpoint understood.
151
+ const body = makeRequest.mock.calls[0][2] as URLSearchParams;
152
+ expect(Object.fromEntries(body.entries())).toEqual({
153
+ grant_type: 'authorization_code',
154
+ code: 'code-1',
155
+ redirect_uri: 'https://app.example/callback',
156
+ client_id: 'oxy_dk_test',
157
+ code_verifier: 'verifier',
158
+ });
152
159
  });
153
160
 
154
161
  it('exchangeOAuthCode rejects a response without deviceSecret', async () => {
155
162
  makeRequest.mockResolvedValueOnce({
156
- sessionId: 's1',
163
+ access_token: 'tok',
164
+ token_type: 'Bearer',
165
+ expires_in: 900,
166
+ session_id: 's1',
157
167
  deviceId: 'd1',
158
- accessToken: 'tok',
159
168
  user: { id: 'u1' },
160
169
  });
161
170
  await expect(
@@ -167,4 +176,56 @@ describe('pre-session public endpoints use skipAuth', () => {
167
176
  }),
168
177
  ).rejects.toThrow(/incomplete session payload/i);
169
178
  });
179
+
180
+ it('exchangeOAuthCode reads the FLAT RFC 6749 §5.1 response', async () => {
181
+ makeRequest.mockResolvedValueOnce({
182
+ access_token: 'tok',
183
+ token_type: 'Bearer',
184
+ expires_in: 900,
185
+ scope: 'profile:read',
186
+ session_id: 's1',
187
+ deviceId: 'd1',
188
+ deviceSecret: 'ds_secret',
189
+ user: { id: 'u1', username: 'alice', avatar: 'file-1' },
190
+ });
191
+
192
+ const result = await oxy.exchangeOAuthCode({
193
+ code: 'code-1',
194
+ clientId: 'oxy_dk_test',
195
+ redirectUri: 'https://app.example/callback',
196
+ codeVerifier: 'verifier',
197
+ });
198
+
199
+ expect(result).toMatchObject({
200
+ sessionId: 's1',
201
+ deviceId: 'd1',
202
+ deviceSecret: 'ds_secret',
203
+ accessToken: 'tok',
204
+ user: { id: 'u1', username: 'alice', avatar: 'file-1' },
205
+ });
206
+ });
207
+
208
+ it('getOAuthUserInfo reads the flat OIDC userinfo response', async () => {
209
+ makeRequest.mockResolvedValueOnce({
210
+ sub: '507f1f77bcf86cd799439011',
211
+ preferred_username: 'alice',
212
+ name: 'Alice Example',
213
+ picture: 'https://api.oxy.so/assets/file-1/stream',
214
+ });
215
+
216
+ const result = await oxy.getOAuthUserInfo();
217
+
218
+ expect(makeRequest).toHaveBeenCalledWith(
219
+ 'GET',
220
+ '/auth/oauth/userinfo',
221
+ undefined,
222
+ expect.objectContaining({ cache: false }),
223
+ );
224
+ expect(result).toEqual({
225
+ sub: '507f1f77bcf86cd799439011',
226
+ preferred_username: 'alice',
227
+ name: 'Alice Example',
228
+ picture: 'https://api.oxy.so/assets/file-1/stream',
229
+ });
230
+ });
170
231
  });
@@ -0,0 +1,208 @@
1
+ import { OXY_USER_INVALIDATION_CHANNEL } from '@oxyhq/contracts';
2
+
3
+ import {
4
+ createOxyUserInvalidationHandler,
5
+ evictOxyIdentityCache,
6
+ publishOxyUserInvalidation,
7
+ type OxyIdentityCacheEvictor,
8
+ } from '../userInvalidation';
9
+
10
+ function makePublisher() {
11
+ const calls: Array<{ channel: string; message: string }> = [];
12
+ return {
13
+ calls,
14
+ publish(channel: string, message: string) {
15
+ calls.push({ channel, message });
16
+ return Promise.resolve(1);
17
+ },
18
+ };
19
+ }
20
+
21
+ function makeEvictor() {
22
+ const entries: string[] = [];
23
+ const prefixes: string[] = [];
24
+ const evictor: OxyIdentityCacheEvictor = {
25
+ clearCacheEntry: (key) => {
26
+ entries.push(key);
27
+ },
28
+ clearCacheByPrefix: (prefix) => {
29
+ prefixes.push(prefix);
30
+ return 0;
31
+ },
32
+ };
33
+ return { evictor, entries, prefixes };
34
+ }
35
+
36
+ describe('@oxyhq/core/server publishOxyUserInvalidation', () => {
37
+ it('publishes a profile change on the contract channel', () => {
38
+ const publisher = makePublisher();
39
+ const published = publishOxyUserInvalidation(publisher, 'user-1', 'profile');
40
+
41
+ expect(published).toBe(true);
42
+ expect(publisher.calls).toHaveLength(1);
43
+ expect(publisher.calls[0].channel).toBe(OXY_USER_INVALIDATION_CHANNEL);
44
+
45
+ const payload = JSON.parse(publisher.calls[0].message);
46
+ expect(payload.userId).toBe('user-1');
47
+ expect(payload.reason).toBe('profile');
48
+ expect(typeof payload.at).toBe('number');
49
+ });
50
+
51
+ it('puts NOTHING on the wire for graph churn', () => {
52
+ // The whole point of the suppression: not a message subscribers discard,
53
+ // but no message at all. Bulk follow moves up to 200 edges per call.
54
+ const publisher = makePublisher();
55
+ const published = publishOxyUserInvalidation(publisher, 'user-1', 'graph');
56
+
57
+ expect(published).toBe(false);
58
+ expect(publisher.calls).toHaveLength(0);
59
+ });
60
+
61
+ it('publishes nothing for an empty user id', () => {
62
+ const publisher = makePublisher();
63
+ expect(publishOxyUserInvalidation(publisher, '', 'profile')).toBe(false);
64
+ expect(publisher.calls).toHaveLength(0);
65
+ });
66
+
67
+ it('never throws when the client throws synchronously', () => {
68
+ const boom = new Error('redis down');
69
+ const publisher = {
70
+ publish() {
71
+ throw boom;
72
+ },
73
+ };
74
+ const onError = jest.fn();
75
+
76
+ expect(() => publishOxyUserInvalidation(publisher, 'user-1', 'profile', onError)).not.toThrow();
77
+ expect(publishOxyUserInvalidation(publisher, 'user-1', 'profile', onError)).toBe(false);
78
+ expect(onError).toHaveBeenCalledWith(boom);
79
+ });
80
+
81
+ it('never surfaces a rejected publish as an unhandled rejection', async () => {
82
+ const boom = new Error('publish rejected');
83
+ const publisher = {
84
+ publish: () => Promise.reject(boom),
85
+ };
86
+ const onError = jest.fn();
87
+
88
+ // Returns true — the message was handed to the client; the failure is async.
89
+ expect(publishOxyUserInvalidation(publisher, 'user-1', 'profile', onError)).toBe(true);
90
+ await Promise.resolve();
91
+ expect(onError).toHaveBeenCalledWith(boom);
92
+ });
93
+
94
+ it('tolerates a client whose publish returns a non-promise', () => {
95
+ const calls: string[] = [];
96
+ const publisher = {
97
+ publish: (_channel: string, message: string) => {
98
+ calls.push(message);
99
+ return 1;
100
+ },
101
+ };
102
+ expect(publishOxyUserInvalidation(publisher, 'user-1', 'profile')).toBe(true);
103
+ expect(calls).toHaveLength(1);
104
+ });
105
+ });
106
+
107
+ describe('@oxyhq/core/server createOxyUserInvalidationHandler', () => {
108
+ const validMessage = JSON.stringify({ userId: 'user-1', reason: 'profile', at: 1 });
109
+
110
+ it('sweeps the SDK identity cache for the invalidated user', () => {
111
+ const { evictor, entries, prefixes } = makeEvictor();
112
+ createOxyUserInvalidationHandler({ oxy: evictor })(validMessage);
113
+
114
+ expect(entries).toEqual(['GET:/users/user-1']);
115
+ expect(prefixes).toEqual(['GET:/profiles/username/', 'GET:/profiles/resolve']);
116
+ });
117
+
118
+ it('hands the parsed event to app-specific eviction', () => {
119
+ const onInvalidate = jest.fn();
120
+ createOxyUserInvalidationHandler({ onInvalidate })(validMessage);
121
+
122
+ expect(onInvalidate).toHaveBeenCalledWith({ userId: 'user-1', reason: 'profile', at: 1 });
123
+ });
124
+
125
+ it('drops an unparseable message without throwing', () => {
126
+ const onInvalidate = jest.fn();
127
+ const onError = jest.fn();
128
+ const handle = createOxyUserInvalidationHandler({ onInvalidate, onError });
129
+
130
+ expect(() => handle('not json at all')).not.toThrow();
131
+ expect(onInvalidate).not.toHaveBeenCalled();
132
+ expect(onError).toHaveBeenCalled();
133
+ });
134
+
135
+ it('drops a message that fails the contract schema', () => {
136
+ const onInvalidate = jest.fn();
137
+ const onError = jest.fn();
138
+ const handle = createOxyUserInvalidationHandler({ onInvalidate, onError });
139
+
140
+ handle(JSON.stringify({ userId: 'user-1', reason: 'graph', at: 1 }));
141
+ handle(JSON.stringify({ reason: 'profile', at: 1 }));
142
+
143
+ expect(onInvalidate).not.toHaveBeenCalled();
144
+ expect(onError).toHaveBeenCalledTimes(2);
145
+ });
146
+
147
+ it('never throws when app eviction throws', () => {
148
+ // The handler runs inside the Redis client's message dispatch. An escape
149
+ // there kills the subscription — silently and permanently — which is far
150
+ // worse than losing the one message that caused it.
151
+ const boom = new Error('app eviction failed');
152
+ const onError = jest.fn();
153
+ const handle = createOxyUserInvalidationHandler({
154
+ onInvalidate: () => {
155
+ throw boom;
156
+ },
157
+ onError,
158
+ });
159
+
160
+ expect(() => handle(validMessage)).not.toThrow();
161
+ expect(onError).toHaveBeenCalledWith(boom, validMessage);
162
+ });
163
+
164
+ it('never throws when app eviction rejects', async () => {
165
+ const boom = new Error('async eviction failed');
166
+ const onError = jest.fn();
167
+ const handle = createOxyUserInvalidationHandler({
168
+ onInvalidate: () => Promise.reject(boom),
169
+ onError,
170
+ });
171
+
172
+ expect(() => handle(validMessage)).not.toThrow();
173
+ await Promise.resolve();
174
+ expect(onError).toHaveBeenCalledWith(boom, validMessage);
175
+ });
176
+
177
+ it('still runs app eviction when the SDK sweep throws', () => {
178
+ const onInvalidate = jest.fn();
179
+ const onError = jest.fn();
180
+ const evictor: OxyIdentityCacheEvictor = {
181
+ clearCacheEntry: () => {
182
+ throw new Error('sweep failed');
183
+ },
184
+ clearCacheByPrefix: () => 0,
185
+ };
186
+
187
+ createOxyUserInvalidationHandler({ oxy: evictor, onInvalidate, onError })(validMessage);
188
+
189
+ expect(onInvalidate).toHaveBeenCalled();
190
+ expect(onError).toHaveBeenCalled();
191
+ });
192
+
193
+ it('is a no-op with no options configured', () => {
194
+ expect(() => createOxyUserInvalidationHandler()(validMessage)).not.toThrow();
195
+ });
196
+ });
197
+
198
+ describe('@oxyhq/core/server evictOxyIdentityCache', () => {
199
+ it('clears the exact by-id entry and the handle-keyed prefixes', () => {
200
+ // The by-id key is exact. The handle-keyed ones cannot be derived from an
201
+ // id without the lookup being invalidated, so they are swept by prefix.
202
+ const { evictor, entries, prefixes } = makeEvictor();
203
+ evictOxyIdentityCache(evictor, 'abc123');
204
+
205
+ expect(entries).toEqual(['GET:/users/abc123']);
206
+ expect(prefixes).toEqual(['GET:/profiles/username/', 'GET:/profiles/resolve']);
207
+ });
208
+ });
@@ -82,6 +82,19 @@ export type {
82
82
  // Constant-time secret comparison.
83
83
  export { verifySecret } from './verifySecret';
84
84
 
85
+ // Cross-service user-invalidation signal: oxy-api publishes when identity
86
+ // changes, every consuming backend sweeps its caches instead of waiting out a TTL.
87
+ export {
88
+ createOxyUserInvalidationHandler,
89
+ evictOxyIdentityCache,
90
+ publishOxyUserInvalidation,
91
+ } from './userInvalidation';
92
+ export type {
93
+ OxyIdentityCacheEvictor,
94
+ OxyInvalidationPublisher,
95
+ OxyUserInvalidationHandlerOptions,
96
+ } from './userInvalidation';
97
+
85
98
  // Registrable-apex (eTLD+1) derivation via the Public Suffix List — the SINGLE
86
99
  // SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
87
100
  // Pure host handling (no browser deps), so it is safe on the server subpath and