@oxyhq/core 20.0.0 → 20.1.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.
@@ -17,6 +17,7 @@ import { OxyServicesReputationMixin } from './OxyServices.reputation';
17
17
  import { OxyServicesAssetsMixin } from './OxyServices.assets';
18
18
  import { OxyServicesAccountsMixin } from './OxyServices.accounts';
19
19
  import { OxyServicesConnectedAppsMixin } from './OxyServices.connectedApps';
20
+ import { OxyServicesStoreMixin } from './OxyServices.store';
20
21
  import { OxyServicesLocationMixin } from './OxyServices.location';
21
22
  import { OxyServicesAnalyticsMixin } from './OxyServices.analytics';
22
23
  import { OxyServicesDevicesMixin } from './OxyServices.devices';
@@ -28,6 +29,7 @@ import { OxyServicesContactsMixin } from './OxyServices.contacts';
28
29
  import { OxyServicesNotificationsMixin } from './OxyServices.notifications';
29
30
  import { OxyServicesAppDataMixin } from './OxyServices.appData';
30
31
  import { OxyServicesCivicMixin } from './OxyServices.civic';
32
+ import { OxyServicesChainsMixin } from './OxyServices.chains';
31
33
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
32
34
  import { OxyServicesLinksMixin } from './OxyServices.links';
33
35
  import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
@@ -55,6 +57,7 @@ type AllMixinInstances =
55
57
  & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>>
56
58
  & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>>
57
59
  & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>>
60
+ & InstanceType<ReturnType<typeof OxyServicesStoreMixin<typeof OxyServicesBase>>>
58
61
  & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>>
59
62
  & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>>
60
63
  & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>>
@@ -65,6 +68,7 @@ type AllMixinInstances =
65
68
  & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>>
66
69
  & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
67
70
  & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
71
+ & InstanceType<ReturnType<typeof OxyServicesChainsMixin<typeof OxyServicesBase>>>
68
72
  & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
69
73
  & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
70
74
  & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>>
@@ -123,6 +127,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
123
127
  // OAuth-consent surface (public app identity + connected-app grants). Kept
124
128
  // separate from account ownership.
125
129
  OxyServicesConnectedAppsMixin,
130
+ // The app store: the public storefront, the reviews on it, and the listing a
131
+ // publisher edits. A module OVER the platform — turn it off and OAuth still
132
+ // works — so it is its own surface rather than more of `accounts`.
133
+ OxyServicesStoreMixin,
126
134
  OxyServicesLocationMixin,
127
135
  OxyServicesAnalyticsMixin,
128
136
  OxyServicesDevicesMixin,
@@ -137,6 +145,7 @@ const MIXIN_PIPELINE: MixinFunction[] = [
137
145
  OxyServicesAppDataMixin,
138
146
  // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
139
147
  OxyServicesCivicMixin,
148
+ OxyServicesChainsMixin,
140
149
  // User nodes / decentralization (Fase 5): register/read/revoke/manage the
141
150
  // caller's personal data node + ingest hint.
142
151
  OxyServicesNodesMixin,
@@ -125,6 +125,53 @@ describe('@oxyhq/core/server rate limiter', () => {
125
125
  expect(req.observedKey).toBe('user:validated-user');
126
126
  });
127
127
 
128
+ it('does not clobber an identity a preceding middleware already resolved', () => {
129
+ // The limiter mutates the SHARED `req`, so the unconditional
130
+ // `req.userId = null` that `oxy.auth({ optional: true })` writes for every
131
+ // request it cannot authenticate is not merely a bucketing detail — it
132
+ // erases the identity for every handler downstream of the limiter too.
133
+ // The resolver must therefore skip entirely when a user is already present.
134
+ // This handler stands in for that erasure.
135
+ const clobberingAuth = jest.fn(
136
+ (req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
137
+ req.userId = null;
138
+ req.user = null;
139
+ req.sessionId = null;
140
+ next();
141
+ },
142
+ );
143
+ const oxy = makeOxy(clobberingAuth as unknown as RequestHandler);
144
+ const req = makeRequest({
145
+ userId: 'resolved-by-the-app',
146
+ user: { id: 'resolved-by-the-app' },
147
+ sessionId: 'app-session',
148
+ });
149
+
150
+ createOxyRateLimit(oxy)(req, {} as Response, jest.fn());
151
+
152
+ expect(req.userId).toBe('resolved-by-the-app');
153
+ expect(req.user).toEqual({ id: 'resolved-by-the-app' });
154
+ expect(req.sessionId).toBe('app-session');
155
+ expect(req.observedKey).toBe('user:resolved-by-the-app');
156
+ expect(clobberingAuth).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('still resolves the session when no identity is present yet', () => {
160
+ const authHandler = jest.fn((req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
161
+ req.userId = 'resolved-by-oxy';
162
+ req.user = { id: 'resolved-by-oxy' };
163
+ req.sessionId = 'oxy-session';
164
+ next();
165
+ });
166
+ const oxy = makeOxy(authHandler as unknown as RequestHandler);
167
+ const req = makeRequest();
168
+
169
+ createOxyRateLimit(oxy)(req, {} as Response, jest.fn());
170
+
171
+ expect(authHandler).toHaveBeenCalledTimes(1);
172
+ expect(req.observedKey).toBe('user:resolved-by-oxy');
173
+ });
174
+
128
175
  it('continues through the anonymous limiter if optional auth returns an error', () => {
129
176
  const oxy = makeOxy((_req: Request, _res: Response, next: NextFunction) => {
130
177
  next(new Error('token rejected'));
@@ -3,6 +3,7 @@ import { isIPv4, isIPv6 } from 'node:net';
3
3
  import type { Request, RequestHandler } from 'express';
4
4
  import rateLimit, { type Store } from 'express-rate-limit';
5
5
  import type { OxyServices } from '../OxyServices';
6
+ import { createOptionalOxyAuth } from './auth';
6
7
 
7
8
  /**
8
9
  * Server-only rate limiting for Oxy backends.
@@ -25,8 +26,9 @@ import type { OxyServices } from '../OxyServices';
25
26
  * WHAT IT PROVIDES
26
27
  * ----------------
27
28
  * `createOxyRateLimit(oxy, options)` returns a SINGLE composed middleware that:
28
- * 1. Resolves the user via `oxy.auth({ optional: true })` (idempotent — it
29
- * skips re-verification if a prior middleware already set `req.user`).
29
+ * 1. Resolves the user via `createOptionalOxyAuth` (idempotent — it skips
30
+ * resolution entirely if a prior middleware already resolved a user, so
31
+ * the limiter can never erase an identity it did not create).
30
32
  * 2. Applies an `express-rate-limit` limiter keyed PER USER when
31
33
  * authenticated, falling back to the (IPv6-safe) IP otherwise, with
32
34
  * generous, media-app-realistic defaults and sensible exemptions.
@@ -203,11 +205,12 @@ function hashAnonymousIp(ip: string): string {
203
205
  /**
204
206
  * Resolve the trusted authenticated rate-limit key.
205
207
  *
206
- * `oxy.auth({ optional: true })` preserves legacy non-session user tokens by
207
- * decoding their JWT claims locally. Those claims are not cryptographically
208
- * verified and therefore MUST NOT influence abuse-control buckets. Only use
209
- * identities that came from a server-validated session or a verified service
210
- * token/delegation.
208
+ * Only identities that came from a server-validated session or a verified
209
+ * service token/delegation may pick a bucket. `req.sessionId` is the marker
210
+ * for the former: `oxy.auth()` sets it only after `validateSession()` came
211
+ * back valid, so requiring it here means an identity written by some OTHER
212
+ * middleware — which this package cannot vouch for — shares the anonymous
213
+ * per-IP bucket rather than getting the authenticated quota.
211
214
  */
212
215
  function resolveTrustedAuthenticatedKey(req: OxyAuthedRequest): string | null {
213
216
  const userId = req.userId ?? req.user?.id ?? req.user?._id;
@@ -260,7 +263,14 @@ export function createOxyRateLimit(
260
263
 
261
264
  // Idempotent optional-auth resolver. Reuses the SAME session resolution as
262
265
  // every protected route, so the limiter keys by the real user identity.
263
- const resolveSession = oxy.auth({ ...auth, optional: true });
266
+ //
267
+ // `createOptionalOxyAuth` — NOT the raw `oxy.auth({ optional: true })` —
268
+ // because only the former skips resolution when a preceding middleware has
269
+ // already resolved a user. The raw middleware writes `req.userId = null` on
270
+ // every request it cannot authenticate, and because it mutates the shared
271
+ // `req` that erasure is visible to every handler downstream of the limiter,
272
+ // not just to the bucket calculation.
273
+ const resolveSession = createOptionalOxyAuth(oxy, { auth });
264
274
 
265
275
  const skip = (req: Request): boolean =>
266
276
  isBuiltInExempt(req) || (exempt ? exempt(req) : false);