@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +28 -6
- package/dist/cjs/i18n/locales/en-US.json +5 -3
- package/dist/cjs/i18n/locales/es-ES.json +5 -3
- package/dist/cjs/i18n/locales/locales/en-US.json +5 -3
- package/dist/cjs/i18n/locales/locales/es-ES.json +5 -3
- package/dist/cjs/index.js +4 -3
- package/dist/cjs/mixins/OxyServices.auth.js +57 -19
- package/dist/cjs/server/index.js +7 -1
- package/dist/cjs/server/userInvalidation.js +172 -0
- package/dist/cjs/utils/validationUtils.js +3 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +28 -6
- package/dist/esm/i18n/locales/en-US.json +5 -3
- package/dist/esm/i18n/locales/es-ES.json +5 -3
- package/dist/esm/i18n/locales/locales/en-US.json +5 -3
- package/dist/esm/i18n/locales/locales/es-ES.json +5 -3
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.auth.js +57 -19
- package/dist/esm/server/index.js +3 -0
- package/dist/esm/server/userInvalidation.js +167 -0
- package/dist/esm/utils/validationUtils.js +2 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +13 -4
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +19 -0
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/server/userInvalidation.d.ts +133 -0
- package/dist/types/utils/validationUtils.d.ts +2 -0
- package/package.json +1 -1
- package/src/HttpService.ts +32 -7
- package/src/__tests__/httpServiceFormEncoded.test.ts +142 -0
- package/src/i18n/locales/en-US.json +5 -3
- package/src/i18n/locales/es-ES.json +5 -3
- package/src/index.ts +2 -1
- package/src/mixins/OxyServices.auth.ts +76 -20
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +72 -11
- package/src/server/__tests__/userInvalidation.test.ts +208 -0
- package/src/server/index.ts +13 -0
- package/src/server/userInvalidation.ts +221 -0
- package/src/utils/validationUtils.ts +4 -0
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
|
|
54
|
+
import {
|
|
55
|
+
OXY_USER_INVALIDATION_CHANNEL,
|
|
56
|
+
isPublishedOxyUserChangeReason,
|
|
57
|
+
oxyUserInvalidationEventSchema,
|
|
58
|
+
type OxyUserChangeReason,
|
|
59
|
+
type OxyUserInvalidationEvent,
|
|
60
|
+
} from '@oxyhq/contracts';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The publish surface of a Redis client. Both `ioredis` and `node-redis`
|
|
64
|
+
* satisfy this structurally, so neither library is a dependency here.
|
|
65
|
+
*/
|
|
66
|
+
export interface OxyInvalidationPublisher {
|
|
67
|
+
publish(channel: string, message: string): unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The cache-eviction surface of an {@link OxyServices} instance. Declared
|
|
72
|
+
* structurally so this Node-only module does not pull in the client.
|
|
73
|
+
*/
|
|
74
|
+
export interface OxyIdentityCacheEvictor {
|
|
75
|
+
clearCacheEntry(key: string): void;
|
|
76
|
+
clearCacheByPrefix(prefix: string): number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Broadcast that an Oxy user's record changed.
|
|
81
|
+
*
|
|
82
|
+
* Returns `true` when a message was put on the wire and `false` when the reason
|
|
83
|
+
* is not a broadcast one ({@link isPublishedOxyUserChangeReason}) — the latter is
|
|
84
|
+
* a deliberate no-op, not a failure. Suppressing at the publisher rather than
|
|
85
|
+
* letting every subscriber discard matters at bulk-follow scale, where a single
|
|
86
|
+
* call moves up to 200 edges.
|
|
87
|
+
*
|
|
88
|
+
* NEVER THROWS AND NEVER RETURNS A REJECTED PROMISE. This is called from inside
|
|
89
|
+
* cache invalidation, which itself runs after a successful database write on the
|
|
90
|
+
* request path: a publish failure must not turn a completed profile update into
|
|
91
|
+
* a 500. A dropped message costs a consumer its TTL and nothing more.
|
|
92
|
+
*
|
|
93
|
+
* @param publisher - A connected Redis client. Must NOT be a client currently in
|
|
94
|
+
* subscriber mode — Redis forbids `PUBLISH` on a subscribed
|
|
95
|
+
* connection, so pass the publisher half of a pub/sub pair.
|
|
96
|
+
* @param userId - The Oxy user whose record changed.
|
|
97
|
+
* @param reason - How the record changed. See {@link OxyUserChangeReason}.
|
|
98
|
+
* @param onError - Optional diagnostic sink for a failed publish.
|
|
99
|
+
*/
|
|
100
|
+
export function publishOxyUserInvalidation(
|
|
101
|
+
publisher: OxyInvalidationPublisher,
|
|
102
|
+
userId: string,
|
|
103
|
+
reason: OxyUserChangeReason,
|
|
104
|
+
onError?: (error: unknown) => void,
|
|
105
|
+
): boolean {
|
|
106
|
+
if (!userId || !isPublishedOxyUserChangeReason(reason)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const event: OxyUserInvalidationEvent = { userId, reason, at: Date.now() };
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const result = publisher.publish(OXY_USER_INVALIDATION_CHANNEL, JSON.stringify(event));
|
|
114
|
+
// node-redis returns a promise; ioredis returns a promise too, but a mocked
|
|
115
|
+
// or synchronous client may return anything. Only attach a rejection handler
|
|
116
|
+
// when there is actually something thenable to reject.
|
|
117
|
+
if (isPromiseLike(result)) {
|
|
118
|
+
Promise.resolve(result).catch((error: unknown) => onError?.(error));
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
onError?.(error);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
128
|
+
return (
|
|
129
|
+
typeof value === 'object' &&
|
|
130
|
+
value !== null &&
|
|
131
|
+
typeof (value as { then?: unknown }).then === 'function'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Options for {@link createOxyUserInvalidationHandler}. */
|
|
136
|
+
export interface OxyUserInvalidationHandlerOptions {
|
|
137
|
+
/**
|
|
138
|
+
* The backend's `OxyServices` instance. When supplied, its GET response cache
|
|
139
|
+
* is swept for the invalidated user — this is the whole reason a backend that
|
|
140
|
+
* has no cache of its own still benefits from subscribing.
|
|
141
|
+
*/
|
|
142
|
+
oxy?: OxyIdentityCacheEvictor;
|
|
143
|
+
/**
|
|
144
|
+
* App-specific eviction (e.g. a Redis identity cache the app maintains itself).
|
|
145
|
+
* May be async; a rejection is routed to `onError` and never escapes.
|
|
146
|
+
*/
|
|
147
|
+
onInvalidate?: (event: OxyUserInvalidationEvent) => void | Promise<void>;
|
|
148
|
+
/** Diagnostic sink for an unparseable message or a failing `onInvalidate`. */
|
|
149
|
+
onError?: (error: unknown, raw: string) => void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build the message handler for {@link OXY_USER_INVALIDATION_CHANNEL}.
|
|
154
|
+
*
|
|
155
|
+
* The returned function NEVER THROWS and never returns a rejected promise. It
|
|
156
|
+
* runs inside the Redis client's message dispatch, where an exception either
|
|
157
|
+
* takes down the subscriber connection or surfaces as an unhandled rejection —
|
|
158
|
+
* and losing the subscription is strictly worse than losing one message, because
|
|
159
|
+
* it is silent and permanent.
|
|
160
|
+
*
|
|
161
|
+
* A message that fails schema validation is dropped, not retried: the payload is
|
|
162
|
+
* produced by a contract both sides compile against, so a malformed one means a
|
|
163
|
+
* version skew or an unrelated publisher on the channel, neither of which a retry
|
|
164
|
+
* fixes.
|
|
165
|
+
*/
|
|
166
|
+
export function createOxyUserInvalidationHandler(
|
|
167
|
+
options: OxyUserInvalidationHandlerOptions = {},
|
|
168
|
+
): (raw: string) => void {
|
|
169
|
+
const { oxy, onInvalidate, onError } = options;
|
|
170
|
+
|
|
171
|
+
return (raw: string): void => {
|
|
172
|
+
let event: OxyUserInvalidationEvent;
|
|
173
|
+
try {
|
|
174
|
+
const parsed = oxyUserInvalidationEventSchema.safeParse(JSON.parse(raw));
|
|
175
|
+
if (!parsed.success) {
|
|
176
|
+
onError?.(parsed.error, raw);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
event = parsed.data;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
onError?.(error, raw);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (oxy) {
|
|
186
|
+
try {
|
|
187
|
+
evictOxyIdentityCache(oxy, event.userId);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// A cache sweep must never cost us the app-specific eviction below.
|
|
190
|
+
onError?.(error, raw);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!onInvalidate) return;
|
|
195
|
+
try {
|
|
196
|
+
const result = onInvalidate(event);
|
|
197
|
+
if (isPromiseLike(result)) {
|
|
198
|
+
Promise.resolve(result).catch((error: unknown) => onError?.(error, raw));
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
onError?.(error, raw);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Sweep an `OxyServices` GET response cache of everything that could carry the
|
|
208
|
+
* given user's identity.
|
|
209
|
+
*
|
|
210
|
+
* The by-id entry is exact. The by-username and resolve entries are keyed by
|
|
211
|
+
* HANDLE, which cannot be derived from an id without the very lookup we are
|
|
212
|
+
* invalidating, so those are swept by prefix — the same imprecision the SDK
|
|
213
|
+
* already accepts when it sweeps its own cache after a local profile write, and
|
|
214
|
+
* bounded by the fact that over-eviction costs a refetch and can never serve
|
|
215
|
+
* wrong data.
|
|
216
|
+
*/
|
|
217
|
+
export function evictOxyIdentityCache(oxy: OxyIdentityCacheEvictor, userId: string): void {
|
|
218
|
+
oxy.clearCacheEntry(`GET:/users/${userId}`);
|
|
219
|
+
oxy.clearCacheByPrefix('GET:/profiles/username/');
|
|
220
|
+
oxy.clearCacheByPrefix('GET:/profiles/resolve');
|
|
221
|
+
}
|
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
*/
|
|
17
17
|
export const MAX_DISPLAY_NAME_LENGTH = 80;
|
|
18
18
|
|
|
19
|
+
/** Shared 400 / inline-validation copy for native display-name policy rejections. */
|
|
20
|
+
export const DISPLAY_NAME_INVALID_MESSAGE =
|
|
21
|
+
'Name may only contain letters, spaces, apostrophes, and name separators (·, ־, ་, ・).';
|
|
22
|
+
|
|
19
23
|
/**
|
|
20
24
|
* Email validation regex
|
|
21
25
|
*/
|