@oxyhq/core 16.1.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 (41) 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 +4 -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/validationUtils.js +3 -1
  12. package/dist/esm/.tsbuildinfo +1 -1
  13. package/dist/esm/HttpService.js +28 -6
  14. package/dist/esm/i18n/locales/en-US.json +5 -3
  15. package/dist/esm/i18n/locales/es-ES.json +5 -3
  16. package/dist/esm/i18n/locales/locales/en-US.json +5 -3
  17. package/dist/esm/i18n/locales/locales/es-ES.json +5 -3
  18. package/dist/esm/index.js +1 -1
  19. package/dist/esm/mixins/OxyServices.auth.js +57 -19
  20. package/dist/esm/server/index.js +3 -0
  21. package/dist/esm/server/userInvalidation.js +167 -0
  22. package/dist/esm/utils/validationUtils.js +2 -0
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/HttpService.d.ts +13 -4
  25. package/dist/types/index.d.ts +2 -2
  26. package/dist/types/mixins/OxyServices.auth.d.ts +19 -0
  27. package/dist/types/server/index.d.ts +2 -0
  28. package/dist/types/server/userInvalidation.d.ts +133 -0
  29. package/dist/types/utils/validationUtils.d.ts +2 -0
  30. package/package.json +1 -1
  31. package/src/HttpService.ts +32 -7
  32. package/src/__tests__/httpServiceFormEncoded.test.ts +142 -0
  33. package/src/i18n/locales/en-US.json +5 -3
  34. package/src/i18n/locales/es-ES.json +5 -3
  35. package/src/index.ts +2 -1
  36. package/src/mixins/OxyServices.auth.ts +76 -20
  37. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +72 -11
  38. package/src/server/__tests__/userInvalidation.test.ts +208 -0
  39. package/src/server/index.ts +13 -0
  40. package/src/server/userInvalidation.ts +221 -0
  41. package/src/utils/validationUtils.ts +4 -0
@@ -131,12 +131,21 @@ export declare class HttpService {
131
131
  *
132
132
  * Why we explicitly reject `URLSearchParams`:
133
133
  * - `URLSearchParams` ALSO exposes `append` / `get` / `has`, so the
134
- * duck-type fallback below would have misidentified it as FormData.
135
- * - We want urlencoded payloads to take the JSON-stringify path so the
136
- * server receives them as `application/x-www-form-urlencoded` instead
137
- * of an empty multipart body.
134
+ * duck-type fallback below would have misidentified it as FormData and
135
+ * sent an empty multipart body.
136
+ * - It has its own encoding path instead — see {@link isUrlSearchParams}.
138
137
  */
139
138
  private isFormData;
139
+ /**
140
+ * True for an `application/x-www-form-urlencoded` payload.
141
+ *
142
+ * Needed because a handful of endpoints are defined by a standard that fixes
143
+ * their request encoding rather than by our own JSON conventions — today
144
+ * `POST /auth/oauth/token`, whose encoding RFC 6749 §4.1.3 mandates. Passing
145
+ * a `URLSearchParams` as `data` selects that encoding; everything else is
146
+ * still JSON.
147
+ */
148
+ private isUrlSearchParams;
140
149
  /**
141
150
  * Main request method - handles everything in one place
142
151
  */
@@ -25,7 +25,7 @@ export { ServiceCredentialMismatchError, } from './mixins/OxyServices.auth';
25
25
  export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from './utils/commonsApproval';
26
26
  export { selectCommonsDelivery, pushTargetsFromDelivery, commonsDeliveryPlatform } from './utils/commonsDelivery';
27
27
  export type { CommonsDeliveryFacts, CommonsDeliveryPlatform, CommonsDeliveryRoute, } from './utils/commonsDelivery';
28
- export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
28
+ export type { ServiceTokenResponse, OAuthUserInfoResponse } from './mixins/OxyServices.auth';
29
29
  export type { CommonsSignInHandle, CommonsSignInStatus, CommonsSignInPurpose, CommonsOAuthContext, CommonsApprovalInfo, CommonsApprovalSubjectAccount, CommonsSignInActionResult, CommonsOAuthFinalizeResult, CommonsDeliveryResult, } from './mixins/OxyServices.auth';
30
30
  export type { PushTokenPlatform, RegisterPushTokenInput, } from './mixins/OxyServices.notifications';
31
31
  export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
@@ -83,7 +83,7 @@ export { buildQueryParams, buildSearchParams, buildUrl, buildPaginationParams, s
83
83
  export type { PaginationParams, FollowGraphParams, FollowGraphSort, ApiResponse, ErrorResponse, } from './utils/apiUtils';
84
84
  export { ErrorCodes, createApiError, handleHttpError, validateRequiredFields, } from './utils/errorUtils';
85
85
  export { retryAsync } from './utils/asyncUtils';
86
- export { EMAIL_REGEX, USERNAME_REGEX, PASSWORD_REGEX, MAX_DISPLAY_NAME_LENGTH, isValidEmail, isValidUsername, isValidPassword, isValidDisplayName, DISPLAY_NAME_ALLOWED_SCRIPTS, DISPLAY_NAME_DISALLOWED_SOURCE, DISPLAY_NAME_ORPHANED_MARK_SOURCE, DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE, isRequiredString, isRequiredNumber, isRequiredBoolean, isValidArray, isValidObject, isValidUUID, isValidURL, isValidDate, isValidFileSize, isValidFileType, sanitizeString, sanitizeHTML, isValidObjectId, validateAndSanitizeUserInput, } from './utils/validationUtils';
86
+ export { EMAIL_REGEX, USERNAME_REGEX, PASSWORD_REGEX, MAX_DISPLAY_NAME_LENGTH, DISPLAY_NAME_INVALID_MESSAGE, isValidEmail, isValidUsername, isValidPassword, isValidDisplayName, DISPLAY_NAME_ALLOWED_SCRIPTS, DISPLAY_NAME_DISALLOWED_SOURCE, DISPLAY_NAME_ORPHANED_MARK_SOURCE, DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE, isRequiredString, isRequiredNumber, isRequiredBoolean, isValidArray, isValidObject, isValidUUID, isValidURL, isValidDate, isValidFileSize, isValidFileType, sanitizeString, sanitizeHTML, isValidObjectId, validateAndSanitizeUserInput, } from './utils/validationUtils';
87
87
  export { normalizeInlineText, normalizeMultilineText, } from './utils/textNormalization';
88
88
  export { logger, createLogger, configureLogger, getLoggerConfig, resetLoggerConfig, consoleSink, isDev, } from './logger';
89
89
  export type { Logger, LogLevel, EmittableLogLevel, LogContext, LogEntry, LogSink, LoggerConfig, } from './logger';
@@ -32,6 +32,13 @@ export interface PublicKeyCheckResponse {
32
32
  registered: boolean;
33
33
  message: string;
34
34
  }
35
+ /** OpenID Connect userinfo claims returned by `GET /auth/oauth/userinfo`. */
36
+ export interface OAuthUserInfoResponse {
37
+ sub: string;
38
+ preferred_username?: string;
39
+ name?: string;
40
+ picture?: string;
41
+ }
35
42
  /**
36
43
  * How a "Sign in with Oxy" request finalizes once the approver authorizes it.
37
44
  *
@@ -779,6 +786,13 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
779
786
  * after sign-in at auth.oxy.so) for a device-first session.
780
787
  * Public first-party clients use PKCE (`codeVerifier`); the access token is
781
788
  * planted immediately on success.
789
+ *
790
+ * Speaks the standard RFC 6749 §4.1.3 token request — a form-urlencoded
791
+ * body with snake_case parameters and `grant_type=authorization_code` — and
792
+ * reads the flat §5.1 response. The camelCase JSON request and `{ data }`
793
+ * response this method used before were an Oxy invention no OAuth library
794
+ * could interoperate with; the endpoint no longer accepts them. The method's
795
+ * OWN signature is unchanged, so callers are unaffected.
782
796
  */
783
797
  exchangeOAuthCode(params: {
784
798
  code: string;
@@ -786,6 +800,11 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
786
800
  redirectUri: string;
787
801
  codeVerifier: string;
788
802
  }): Promise<LoginSessionResult>;
803
+ /**
804
+ * Fetch OpenID Connect userinfo for the current bearer (`GET /auth/oauth/userinfo`).
805
+ * The response is a flat JSON document — no `{ data }` wrapper.
806
+ */
807
+ getOAuthUserInfo(): Promise<OAuthUserInfoResponse>;
789
808
  httpService: import("../HttpService").HttpService;
790
809
  cloudURL: string;
791
810
  config: import("../OxyServices.base").OxyConfig;
@@ -25,5 +25,7 @@ export type { OxyCorsOptions } from './cors';
25
25
  export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, formatOxyCspPolicy, OXY_CSP_BASELINE, } from './securityHeaders';
26
26
  export type { OxyCspDirective, OxyCspExtensions, OxyPagesHeadersOptions, OxySecurityHeadersOptions, } from './securityHeaders';
27
27
  export { verifySecret } from './verifySecret';
28
+ export { createOxyUserInvalidationHandler, evictOxyIdentityCache, publishOxyUserInvalidation, } from './userInvalidation';
29
+ export type { OxyIdentityCacheEvictor, OxyInvalidationPublisher, OxyUserInvalidationHandlerOptions, } from './userInvalidation';
28
30
  export { registrableApex } from '../utils/registrableApex';
29
31
  export { isOfficialWebOrigin } from '../utils/officialOrigins';
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Oxy user-invalidation publish/consume helpers for Oxy backends.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * Every Oxy backend caches Oxy identity, and none of them find out when it
7
+ * changes. The `OxyServices` GET response cache holds `GET /users/:id` and
8
+ * `GET /profiles/username/:name` for five minutes; it is swept when THIS process
9
+ * writes the profile (see the `clearCacheEntry` calls in the user mixin) and
10
+ * never when somebody else does — which is the normal case, since profiles are
11
+ * edited in Oxy's own apps. So an avatar or display-name change is invisible to
12
+ * every consuming backend for up to five minutes, per process.
13
+ *
14
+ * oxy-api broadcasts {@link OXY_USER_INVALIDATION_CHANNEL} on the shared Valkey
15
+ * when a user's identity changes. This module is the consumer half: it parses
16
+ * and validates the event, sweeps the SDK's own cache, and hands the event to
17
+ * app-specific eviction. Wiring it is two lines and every backend that does so
18
+ * stops serving stale identity.
19
+ *
20
+ * WHY THE TRANSPORT IS THE CALLER'S JOB
21
+ * -------------------------------------
22
+ * This module deliberately does NOT take a Redis client. ioredis and node-redis
23
+ * disagree about how to subscribe — node-redis passes the listener to
24
+ * `subscribe(channel, listener)`, ioredis takes `subscribe(channel)` and then
25
+ * emits `'message'` on the client — and a helper that accepted "a client" would
26
+ * have to sniff which library it was handed. That kind of detection is exactly
27
+ * what breaks silently when a consumer upgrades a client library.
28
+ *
29
+ * So the split is: this module owns parsing, validation, dispatch and the
30
+ * never-throw guarantee (the parts that are easy to get wrong and identical
31
+ * everywhere), and the caller owns its own client's two-line subscribe idiom
32
+ * (trivial, but library-specific).
33
+ *
34
+ * // node-redis
35
+ * await subscriber.subscribe(
36
+ * OXY_USER_INVALIDATION_CHANNEL,
37
+ * createOxyUserInvalidationHandler({ oxy: oxyClient }),
38
+ * );
39
+ *
40
+ * // ioredis
41
+ * const handle = createOxyUserInvalidationHandler({ oxy: oxyClient });
42
+ * await subscriber.subscribe(OXY_USER_INVALIDATION_CHANNEL);
43
+ * subscriber.on('message', (_channel, raw) => handle(raw));
44
+ *
45
+ * Subscribe on EVERY task, not just an elected leader. The SDK cache this sweeps
46
+ * is per-process in-memory, so a leader-only subscriber would leave every other
47
+ * task stale — and leader-gating would add a failure mode (leader down means no
48
+ * invalidation anywhere) to a signal whose whole point is that losing it is
49
+ * merely slow, never wrong.
50
+ *
51
+ * Node-only; exported solely from `@oxyhq/core/server`.
52
+ */
53
+ import { type OxyUserChangeReason, type OxyUserInvalidationEvent } from '@oxyhq/contracts';
54
+ /**
55
+ * The publish surface of a Redis client. Both `ioredis` and `node-redis`
56
+ * satisfy this structurally, so neither library is a dependency here.
57
+ */
58
+ export interface OxyInvalidationPublisher {
59
+ publish(channel: string, message: string): unknown;
60
+ }
61
+ /**
62
+ * The cache-eviction surface of an {@link OxyServices} instance. Declared
63
+ * structurally so this Node-only module does not pull in the client.
64
+ */
65
+ export interface OxyIdentityCacheEvictor {
66
+ clearCacheEntry(key: string): void;
67
+ clearCacheByPrefix(prefix: string): number;
68
+ }
69
+ /**
70
+ * Broadcast that an Oxy user's record changed.
71
+ *
72
+ * Returns `true` when a message was put on the wire and `false` when the reason
73
+ * is not a broadcast one ({@link isPublishedOxyUserChangeReason}) — the latter is
74
+ * a deliberate no-op, not a failure. Suppressing at the publisher rather than
75
+ * letting every subscriber discard matters at bulk-follow scale, where a single
76
+ * call moves up to 200 edges.
77
+ *
78
+ * NEVER THROWS AND NEVER RETURNS A REJECTED PROMISE. This is called from inside
79
+ * cache invalidation, which itself runs after a successful database write on the
80
+ * request path: a publish failure must not turn a completed profile update into
81
+ * a 500. A dropped message costs a consumer its TTL and nothing more.
82
+ *
83
+ * @param publisher - A connected Redis client. Must NOT be a client currently in
84
+ * subscriber mode — Redis forbids `PUBLISH` on a subscribed
85
+ * connection, so pass the publisher half of a pub/sub pair.
86
+ * @param userId - The Oxy user whose record changed.
87
+ * @param reason - How the record changed. See {@link OxyUserChangeReason}.
88
+ * @param onError - Optional diagnostic sink for a failed publish.
89
+ */
90
+ export declare function publishOxyUserInvalidation(publisher: OxyInvalidationPublisher, userId: string, reason: OxyUserChangeReason, onError?: (error: unknown) => void): boolean;
91
+ /** Options for {@link createOxyUserInvalidationHandler}. */
92
+ export interface OxyUserInvalidationHandlerOptions {
93
+ /**
94
+ * The backend's `OxyServices` instance. When supplied, its GET response cache
95
+ * is swept for the invalidated user — this is the whole reason a backend that
96
+ * has no cache of its own still benefits from subscribing.
97
+ */
98
+ oxy?: OxyIdentityCacheEvictor;
99
+ /**
100
+ * App-specific eviction (e.g. a Redis identity cache the app maintains itself).
101
+ * May be async; a rejection is routed to `onError` and never escapes.
102
+ */
103
+ onInvalidate?: (event: OxyUserInvalidationEvent) => void | Promise<void>;
104
+ /** Diagnostic sink for an unparseable message or a failing `onInvalidate`. */
105
+ onError?: (error: unknown, raw: string) => void;
106
+ }
107
+ /**
108
+ * Build the message handler for {@link OXY_USER_INVALIDATION_CHANNEL}.
109
+ *
110
+ * The returned function NEVER THROWS and never returns a rejected promise. It
111
+ * runs inside the Redis client's message dispatch, where an exception either
112
+ * takes down the subscriber connection or surfaces as an unhandled rejection —
113
+ * and losing the subscription is strictly worse than losing one message, because
114
+ * it is silent and permanent.
115
+ *
116
+ * A message that fails schema validation is dropped, not retried: the payload is
117
+ * produced by a contract both sides compile against, so a malformed one means a
118
+ * version skew or an unrelated publisher on the channel, neither of which a retry
119
+ * fixes.
120
+ */
121
+ export declare function createOxyUserInvalidationHandler(options?: OxyUserInvalidationHandlerOptions): (raw: string) => void;
122
+ /**
123
+ * Sweep an `OxyServices` GET response cache of everything that could carry the
124
+ * given user's identity.
125
+ *
126
+ * The by-id entry is exact. The by-username and resolve entries are keyed by
127
+ * HANDLE, which cannot be derived from an id without the very lookup we are
128
+ * invalidating, so those are swept by prefix — the same imprecision the SDK
129
+ * already accepts when it sweeps its own cache after a local profile write, and
130
+ * bounded by the fact that over-eviction costs a refetch and can never serve
131
+ * wrong data.
132
+ */
133
+ export declare function evictOxyIdentityCache(oxy: OxyIdentityCacheEvictor, userId: string): void;
@@ -6,6 +6,8 @@
6
6
  * Shared by the API write path and client input surfaces.
7
7
  */
8
8
  export declare const MAX_DISPLAY_NAME_LENGTH = 80;
9
+ /** Shared 400 / inline-validation copy for native display-name policy rejections. */
10
+ export declare const DISPLAY_NAME_INVALID_MESSAGE = "Name may only contain letters, spaces, apostrophes, and name separators (\u00B7, \u05BE, \u0F0B, \u30FB).";
9
11
  /**
10
12
  * Email validation regex
11
13
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "16.1.0",
3
+ "version": "17.0.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -373,10 +373,9 @@ export class HttpService {
373
373
  *
374
374
  * Why we explicitly reject `URLSearchParams`:
375
375
  * - `URLSearchParams` ALSO exposes `append` / `get` / `has`, so the
376
- * duck-type fallback below would have misidentified it as FormData.
377
- * - We want urlencoded payloads to take the JSON-stringify path so the
378
- * server receives them as `application/x-www-form-urlencoded` instead
379
- * of an empty multipart body.
376
+ * duck-type fallback below would have misidentified it as FormData and
377
+ * sent an empty multipart body.
378
+ * - It has its own encoding path instead — see {@link isUrlSearchParams}.
380
379
  */
381
380
  private isFormData(data: unknown): data is FormDataLike {
382
381
  if (!data || typeof data !== 'object') {
@@ -417,6 +416,19 @@ export class HttpService {
417
416
  );
418
417
  }
419
418
 
419
+ /**
420
+ * True for an `application/x-www-form-urlencoded` payload.
421
+ *
422
+ * Needed because a handful of endpoints are defined by a standard that fixes
423
+ * their request encoding rather than by our own JSON conventions — today
424
+ * `POST /auth/oauth/token`, whose encoding RFC 6749 §4.1.3 mandates. Passing
425
+ * a `URLSearchParams` as `data` selects that encoding; everything else is
426
+ * still JSON.
427
+ */
428
+ private isUrlSearchParams(data: unknown): data is URLSearchParams {
429
+ return typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams;
430
+ }
431
+
420
432
  /**
421
433
  * Main request method - handles everything in one place
422
434
  */
@@ -482,6 +494,7 @@ export class HttpService {
482
494
 
483
495
  // Determine if data is FormData using robust detection
484
496
  const isFormData = this.isFormData(data);
497
+ const isUrlEncoded = this.isUrlSearchParams(data);
485
498
 
486
499
  // Make fetch request
487
500
  const controller = new AbortController();
@@ -497,7 +510,9 @@ export class HttpService {
497
510
  };
498
511
 
499
512
  // Only set Content-Type for non-FormData requests (FormData sets it automatically with boundary)
500
- if (!isFormData) {
513
+ if (isUrlEncoded) {
514
+ headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
515
+ } else if (!isFormData) {
501
516
  headers['Content-Type'] = 'application/json';
502
517
  }
503
518
 
@@ -545,8 +560,11 @@ export class HttpService {
545
560
  });
546
561
  }
547
562
 
563
+ // `URLSearchParams` is serialised explicitly rather than handed to
564
+ // `fetch` as-is: RN's fetch does not consistently encode it, and doing
565
+ // it here keeps the body identical across every platform.
548
566
  const bodyValue = method !== 'GET' && data
549
- ? (isFormData ? data : JSON.stringify(data))
567
+ ? (isFormData ? data : isUrlEncoded ? data.toString() : JSON.stringify(data))
550
568
  : undefined;
551
569
 
552
570
  // React Native FormData workaround:
@@ -619,10 +637,17 @@ export class HttpService {
619
637
  const contentType = response.headers.get('content-type');
620
638
  if (contentType && contentType.includes('application/json')) {
621
639
  try {
622
- const errorData = await response.json() as { message?: string; error?: string } | null;
640
+ const errorData = await response.json() as {
641
+ message?: string;
642
+ error?: string;
643
+ error_description?: string;
644
+ } | null;
623
645
  // Accept either structured error field from API responses.
624
646
  if (errorData?.message) {
625
647
  errorMessage = errorData.message;
648
+ } else if (errorData?.error_description) {
649
+ // RFC 6749 §5.2 / RFC 6750 §3 — OAuth endpoints surface human text here.
650
+ errorMessage = errorData.error_description;
626
651
  } else if (errorData?.error) {
627
652
  errorMessage = errorData.error;
628
653
  }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `application/x-www-form-urlencoded` request bodies.
3
+ *
4
+ * Needed because a few endpoints are defined by a standard that fixes their
5
+ * request encoding instead of by our JSON conventions — `POST /auth/oauth/token`
6
+ * (RFC 6749 §4.1.3) is the first. Before this path existed, a `URLSearchParams`
7
+ * payload fell through to `JSON.stringify`, which serialises it to `"{}"`: the
8
+ * request left with an empty body and a JSON content type, and the server saw
9
+ * no parameters at all. That silent failure is what these tests prevent.
10
+ */
11
+
12
+ import { HttpService } from '../HttpService';
13
+
14
+ interface FetchCall {
15
+ url: string;
16
+ init: RequestInit | undefined;
17
+ }
18
+
19
+ function jsonResponse(data: unknown): Response {
20
+ return new Response(JSON.stringify(data), {
21
+ status: 200,
22
+ headers: { 'content-type': 'application/json' },
23
+ });
24
+ }
25
+
26
+ function readHeaders(init: RequestInit | undefined): Record<string, string> {
27
+ const headers = init?.headers;
28
+ if (!headers) {
29
+ return {};
30
+ }
31
+ if (headers instanceof Headers) {
32
+ return Object.fromEntries(headers.entries());
33
+ }
34
+ if (Array.isArray(headers)) {
35
+ return Object.fromEntries(headers);
36
+ }
37
+ return headers as Record<string, string>;
38
+ }
39
+
40
+ describe('HttpService form-urlencoded bodies', () => {
41
+ const originalFetch = globalThis.fetch;
42
+
43
+ afterEach(() => {
44
+ globalThis.fetch = originalFetch;
45
+ jest.restoreAllMocks();
46
+ });
47
+
48
+ /**
49
+ * POST a form body and return the token-endpoint call. Selected by URL
50
+ * because an unauthenticated write is preceded by a `GET /csrf-token`.
51
+ */
52
+ async function postForm(body: URLSearchParams): Promise<FetchCall> {
53
+ const calls: FetchCall[] = [];
54
+ globalThis.fetch = async (input, init) => {
55
+ calls.push({ url: String(input), init });
56
+ return jsonResponse({ ok: true });
57
+ };
58
+
59
+ const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
60
+ await http.post('/auth/oauth/token', body, { skipAuth: true, cache: false });
61
+
62
+ const tokenCalls = calls.filter((call) => call.url.endsWith('/auth/oauth/token'));
63
+ expect(tokenCalls).toHaveLength(1);
64
+ return tokenCalls[0];
65
+ }
66
+
67
+ it('serialises URLSearchParams into the request body', async () => {
68
+ const call = await postForm(
69
+ new URLSearchParams({
70
+ grant_type: 'authorization_code',
71
+ code: 'code-1',
72
+ redirect_uri: 'https://app.example/callback',
73
+ }),
74
+ );
75
+
76
+ // Not `"{}"` — the actual encoded parameters.
77
+ expect(call.init?.body).toBe(
78
+ 'grant_type=authorization_code&code=code-1&redirect_uri=https%3A%2F%2Fapp.example%2Fcallback',
79
+ );
80
+ });
81
+
82
+ it('declares the form content type instead of application/json', async () => {
83
+ const call = await postForm(new URLSearchParams({ grant_type: 'authorization_code' }));
84
+
85
+ expect(readHeaders(call.init)['Content-Type']).toBe(
86
+ 'application/x-www-form-urlencoded;charset=UTF-8',
87
+ );
88
+ });
89
+
90
+ it('percent-encodes values that would otherwise break the encoding', async () => {
91
+ const call = await postForm(new URLSearchParams({ code: 'a+b c&d=e' }));
92
+
93
+ expect(call.init?.body).toBe('code=a%2Bb+c%26d%3De');
94
+ });
95
+
96
+ it('still sends plain objects as JSON', async () => {
97
+ const calls: FetchCall[] = [];
98
+ globalThis.fetch = async (input, init) => {
99
+ calls.push({ url: String(input), init });
100
+ return jsonResponse({ ok: true });
101
+ };
102
+
103
+ const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
104
+ await http.post('/auth/session/claim', { sessionToken: 'abc' }, { skipAuth: true });
105
+
106
+ const claimCall = calls.find((call) => call.url.endsWith('/auth/session/claim'));
107
+ expect(claimCall?.init?.body).toBe(JSON.stringify({ sessionToken: 'abc' }));
108
+ expect(readHeaders(claimCall?.init)['Content-Type']).toBe('application/json');
109
+ });
110
+
111
+ it('prefers OAuth error_description over the bare error code', async () => {
112
+ globalThis.fetch = async (input) => {
113
+ const url = String(input);
114
+ if (url.endsWith('/csrf-token')) {
115
+ return jsonResponse({ csrfToken: 'csrf-test' });
116
+ }
117
+ return new Response(
118
+ JSON.stringify({
119
+ error: 'invalid_grant',
120
+ error_description: 'The authorization code has expired.',
121
+ }),
122
+ { status: 400, headers: { 'content-type': 'application/json' } },
123
+ );
124
+ };
125
+
126
+ const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
127
+ let caught: unknown;
128
+ try {
129
+ await http.post(
130
+ '/auth/oauth/token',
131
+ new URLSearchParams({ grant_type: 'authorization_code', code: 'expired' }),
132
+ { skipAuth: true, cache: false, deduplicate: false, retry: false },
133
+ );
134
+ } catch (error) {
135
+ caught = error;
136
+ }
137
+ expect(caught).toBeDefined();
138
+ expect(String((caught as { message?: string })?.message ?? caught)).toContain(
139
+ 'The authorization code has expired.',
140
+ );
141
+ });
142
+ });
@@ -678,7 +678,7 @@
678
678
  "firstNamePlaceholder": "Enter first name",
679
679
  "lastName": "Last Name",
680
680
  "lastNamePlaceholder": "Enter last name (optional)",
681
- "invalidChars": "Use letters and spaces only"
681
+ "invalidChars": "Use letters, spaces, apostrophes, and name separators only"
682
682
  },
683
683
  "username": {
684
684
  "title": "Username",
@@ -1752,7 +1752,8 @@
1752
1752
  "title": "Create account",
1753
1753
  "subtitle": "Create an account you control. It will have its own profile, members, and apps.",
1754
1754
  "displayName": {
1755
- "label": "Display name"
1755
+ "label": "Display name",
1756
+ "invalidChars": "Use letters, spaces, apostrophes, and name separators only"
1756
1757
  },
1757
1758
  "bio": {
1758
1759
  "label": "Bio (optional)"
@@ -1829,7 +1830,8 @@
1829
1830
  "subtitle": "Manage this account’s profile, members, and access.",
1830
1831
  "save": "Save changes",
1831
1832
  "displayName": {
1832
- "label": "Display name"
1833
+ "label": "Display name",
1834
+ "invalidChars": "Use letters, spaces, apostrophes, and name separators only"
1833
1835
  },
1834
1836
  "bio": {
1835
1837
  "label": "Bio (optional)"
@@ -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,
@@ -480,6 +480,7 @@ export {
480
480
  USERNAME_REGEX,
481
481
  PASSWORD_REGEX,
482
482
  MAX_DISPLAY_NAME_LENGTH,
483
+ DISPLAY_NAME_INVALID_MESSAGE,
483
484
  isValidEmail,
484
485
  isValidUsername,
485
486
  isValidPassword,