@oxyhq/core 18.0.0 → 19.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/i18n/locales/en-US.json +49 -5
- package/dist/cjs/i18n/locales/es-ES.json +49 -5
- package/dist/cjs/i18n/locales/locales/en-US.json +49 -5
- package/dist/cjs/i18n/locales/locales/es-ES.json +49 -5
- package/dist/cjs/index.js +17 -6
- package/dist/cjs/mixins/OxyServices.accounts.js +69 -31
- package/dist/cjs/mixins/OxyServices.followGraph.js +204 -0
- package/dist/cjs/mixins/OxyServices.user.js +17 -20
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/server/index.js +8 -2
- package/dist/cjs/server/userInvalidation.js +6 -28
- package/dist/cjs/session/accountProjection.js +45 -9
- package/dist/cjs/utils/accountCacheSweep.js +80 -0
- package/dist/cjs/utils/identityCacheSweep.js +97 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/i18n/locales/en-US.json +49 -5
- package/dist/esm/i18n/locales/es-ES.json +49 -5
- package/dist/esm/i18n/locales/locales/en-US.json +49 -5
- package/dist/esm/i18n/locales/locales/es-ES.json +49 -5
- package/dist/esm/index.js +8 -2
- package/dist/esm/mixins/OxyServices.accounts.js +64 -30
- package/dist/esm/mixins/OxyServices.followGraph.js +201 -0
- package/dist/esm/mixins/OxyServices.user.js +17 -20
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/server/index.js +5 -1
- package/dist/esm/server/userInvalidation.js +5 -26
- package/dist/esm/session/accountProjection.js +44 -9
- package/dist/esm/utils/accountCacheSweep.js +75 -0
- package/dist/esm/utils/identityCacheSweep.js +92 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/mixins/OxyServices.accounts.d.ts +91 -34
- package/dist/types/mixins/OxyServices.followGraph.d.ts +211 -0
- package/dist/types/mixins/OxyServices.user.d.ts +10 -7
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +11 -3
- package/dist/types/server/index.d.ts +4 -2
- package/dist/types/server/userInvalidation.d.ts +5 -24
- package/dist/types/session/accountProjection.d.ts +38 -4
- package/dist/types/utils/accountCacheSweep.d.ts +75 -0
- package/dist/types/utils/identityCacheSweep.d.ts +80 -0
- package/package.json +2 -2
- package/src/i18n/locales/en-US.json +49 -5
- package/src/i18n/locales/es-ES.json +49 -5
- package/src/index.ts +15 -2
- package/src/mixins/OxyServices.accounts.ts +123 -45
- package/src/mixins/OxyServices.followGraph.ts +266 -0
- package/src/mixins/OxyServices.user.ts +17 -20
- package/src/mixins/__tests__/accounts.test.ts +5 -0
- package/src/mixins/__tests__/followGraph.test.ts +128 -0
- package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +407 -0
- package/src/mixins/index.ts +5 -0
- package/src/models/interfaces.ts +11 -3
- package/src/server/__tests__/userInvalidation.test.ts +3 -20
- package/src/server/index.ts +5 -2
- package/src/server/userInvalidation.ts +8 -36
- package/src/session/__tests__/accountProjection.test.ts +109 -2
- package/src/session/accountProjection.ts +47 -9
- package/src/utils/__tests__/identityCacheSweep.test.ts +151 -0
- package/src/utils/accountCacheSweep.ts +93 -0
- package/src/utils/identityCacheSweep.ts +104 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Follow Graph Mixin (`/v2/follows`)
|
|
4
|
+
*
|
|
5
|
+
* The user-owned follow graph: one relationship per user and target, shared by
|
|
6
|
+
* every application, with per-application context on top. This is the SDK half
|
|
7
|
+
* of #809 and the replacement for the per-app follow endpoints each application
|
|
8
|
+
* grew for itself.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this is not `followUser` with more parameters
|
|
11
|
+
*
|
|
12
|
+
* `followUser` answers "does A follow B" and nothing else. This answers "what
|
|
13
|
+
* does this user follow, anywhere, and which applications act on it" — a
|
|
14
|
+
* different question with a different owner. The legacy methods stay for the
|
|
15
|
+
* Mongo-backed social graph they were written for; new kinds (topics, stores,
|
|
16
|
+
* artists, channels) come here, and users will migrate behind an adapter rather
|
|
17
|
+
* than through a flag day.
|
|
18
|
+
*
|
|
19
|
+
* ## Caching
|
|
20
|
+
*
|
|
21
|
+
* Every method is `cache: false`. A follow status is exactly the shape that
|
|
22
|
+
* must never be served stale: the SDK's GET cache is identity-scoped but
|
|
23
|
+
* time-based, and a status cached across a write is the "follow reverts after
|
|
24
|
+
* navigating away and back" bug — which the legacy `followUser` had to fix with
|
|
25
|
+
* explicit invalidation. Not caching at this layer means an app's own store
|
|
26
|
+
* (React Query, Zustand) is the single cache authority, which is the rule the
|
|
27
|
+
* ecosystem already follows for anything written and read in the same session.
|
|
28
|
+
*/
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.OxyServicesFollowGraphMixin = OxyServicesFollowGraphMixin;
|
|
31
|
+
const apiUtils_1 = require("../utils/apiUtils");
|
|
32
|
+
function OxyServicesFollowGraphMixin(Base) {
|
|
33
|
+
return class extends Base {
|
|
34
|
+
constructor(...args) {
|
|
35
|
+
super(...args);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Follow a target. Idempotent — following something already followed
|
|
39
|
+
* returns the same relationship with `created: false`.
|
|
40
|
+
*
|
|
41
|
+
* The follower and the acting application are BOTH derived server-side from
|
|
42
|
+
* the session. There is deliberately no parameter for either: a client that
|
|
43
|
+
* could name them could forge a follow on another user's behalf, or record
|
|
44
|
+
* one as coming from an application it is not.
|
|
45
|
+
*
|
|
46
|
+
* @param targetId - The registered target's id, not its URI. Registration is
|
|
47
|
+
* a separate operation precisely so following cannot silently create
|
|
48
|
+
* targets — a typo would otherwise become a permanent row nobody follows.
|
|
49
|
+
* @param options.expiresIn - Seconds until the follow lapses on its own. For
|
|
50
|
+
* an event, a trial, a topic followed for a week. The server bounds it.
|
|
51
|
+
*/
|
|
52
|
+
async followTarget(targetId, options) {
|
|
53
|
+
try {
|
|
54
|
+
return await this.makeRequest('PUT', `/v2/follows/${encodeURIComponent(targetId)}`, options?.expiresIn !== undefined ? { expiresIn: options.expiresIn } : {}, { cache: false });
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw this.handleError(error);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Unfollow everywhere.
|
|
62
|
+
*
|
|
63
|
+
* There is no "unfollow here" — that is `setFollowApplicationMode(...,
|
|
64
|
+
* 'disabled')`, and keeping the two distinct is the point of the design. An
|
|
65
|
+
* application that quietly turned a global unfollow into a local one would
|
|
66
|
+
* leave the user believing they had stopped following something they still
|
|
67
|
+
* follow everywhere else.
|
|
68
|
+
*
|
|
69
|
+
* Idempotent: `removed: false` when it was already gone, because the state
|
|
70
|
+
* the caller asked for is the state that holds.
|
|
71
|
+
*/
|
|
72
|
+
async unfollowTarget(relationshipId) {
|
|
73
|
+
try {
|
|
74
|
+
return await this.makeRequest('DELETE', `/v2/follows/${encodeURIComponent(relationshipId)}`, undefined, { cache: false });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw this.handleError(error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The three-part status: globally, in this application, and in effect.
|
|
82
|
+
*
|
|
83
|
+
* Render `effectiveState` on the button and keep the other two for the
|
|
84
|
+
* explanation. A UI that collapses them cannot tell the user why a follow
|
|
85
|
+
* they can see in their list is not showing up in this app's feed.
|
|
86
|
+
*/
|
|
87
|
+
async getFollowTargetStatus(targetId) {
|
|
88
|
+
try {
|
|
89
|
+
return await this.makeRequest('GET', `/v2/follows/${encodeURIComponent(targetId)}/status`, undefined, { cache: false });
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
throw this.handleError(error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Turn a relationship off, or back on, in ONE application.
|
|
97
|
+
*
|
|
98
|
+
* Omit `applicationId` and it applies to the calling application, which is
|
|
99
|
+
* the only form an ordinary app should ever need. Naming a DIFFERENT
|
|
100
|
+
* application requires `follows:manage` server-side — acting on another
|
|
101
|
+
* app's behalf is exactly the cross-application authority this design
|
|
102
|
+
* otherwise refuses, so it is a distinct permission and not a parameter an
|
|
103
|
+
* app happens to fill in.
|
|
104
|
+
*/
|
|
105
|
+
async setFollowApplicationMode(relationshipId, mode, applicationId) {
|
|
106
|
+
try {
|
|
107
|
+
return await this.makeRequest('PUT', `/v2/follows/${encodeURIComponent(relationshipId)}/context`, { mode, ...(applicationId ? { applicationId } : {}) }, { cache: false });
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw this.handleError(error);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Drop the override so this application follows the global relationship
|
|
115
|
+
* again. Distinct from setting `enabled`: inheriting means a later global
|
|
116
|
+
* change takes effect here, and an explicit `enabled` means it does not.
|
|
117
|
+
*/
|
|
118
|
+
async restoreFollowInheritance(relationshipId, applicationId) {
|
|
119
|
+
try {
|
|
120
|
+
const path = (0, apiUtils_1.buildUrl)(`/v2/follows/${encodeURIComponent(relationshipId)}/context`, applicationId ? { applicationId } : {});
|
|
121
|
+
return await this.makeRequest('DELETE', path, undefined, { cache: false });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw this.handleError(error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Resolve a target by canonical URI, registering it the first time anyone
|
|
129
|
+
* asks. The call an application makes on the way into a screen, before it
|
|
130
|
+
* can render a button.
|
|
131
|
+
*
|
|
132
|
+
* Idempotent on the URI, which is what makes two applications describing
|
|
133
|
+
* the same thing — the same fediverse actor, the same topic — arrive at ONE
|
|
134
|
+
* row, and therefore at one relationship per user rather than one per app.
|
|
135
|
+
*
|
|
136
|
+
* `metadata` is a display snapshot (name, handle, icon) and is refreshed
|
|
137
|
+
* only for the application that provides the target: a second application
|
|
138
|
+
* passing its own idea of the name would make the display flip depending on
|
|
139
|
+
* which app last looked.
|
|
140
|
+
*/
|
|
141
|
+
async ensureFollowTarget(input) {
|
|
142
|
+
try {
|
|
143
|
+
return await this.makeRequest('POST', '/v2/follow-targets', input, { cache: false });
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
throw this.handleError(error);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Claim a namespace for the calling application. First come, and idempotent
|
|
151
|
+
* for the holder — an application that registers on every boot must not
|
|
152
|
+
* fail the second time.
|
|
153
|
+
*/
|
|
154
|
+
async claimFollowNamespace(namespace) {
|
|
155
|
+
try {
|
|
156
|
+
return await this.makeRequest('POST', '/v2/follow-targets/namespaces', { namespace }, { cache: false });
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
throw this.handleError(error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Declare what following a kind of thing MEANS: the verb clients render,
|
|
164
|
+
* whether reverse lookups are public, whether it federates.
|
|
165
|
+
*
|
|
166
|
+
* Declared once by the application that owns the concept, rather than
|
|
167
|
+
* passed per call site — otherwise two screens of one app can disagree
|
|
168
|
+
* about whether a store is followed or subscribed to.
|
|
169
|
+
*/
|
|
170
|
+
async registerFollowKind(input) {
|
|
171
|
+
try {
|
|
172
|
+
return await this.makeRequest('POST', '/v2/follow-targets/kinds', input, {
|
|
173
|
+
cache: false,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
throw this.handleError(error);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Everything the signed-in user follows, newest first.
|
|
182
|
+
*
|
|
183
|
+
* Owner-only by construction server-side — there is no parameter naming a
|
|
184
|
+
* user, so this cannot be pointed at somebody else's graph.
|
|
185
|
+
*
|
|
186
|
+
* Paginate by passing back `nextCursor`, never an offset: the list changes
|
|
187
|
+
* while it is being read, and an offset silently skips or repeats rows
|
|
188
|
+
* exactly when it does.
|
|
189
|
+
*/
|
|
190
|
+
async listFollows(params) {
|
|
191
|
+
try {
|
|
192
|
+
const path = (0, apiUtils_1.buildUrl)('/v2/me/follows', {
|
|
193
|
+
...(params?.kind ? { kind: params.kind } : {}),
|
|
194
|
+
...(params?.cursor ? { cursor: params.cursor } : {}),
|
|
195
|
+
...(params?.limit ? { limit: params.limit } : {}),
|
|
196
|
+
});
|
|
197
|
+
return await this.makeRequest('GET', path, undefined, { cache: false });
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
throw this.handleError(error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -6,6 +6,8 @@ const apiUtils_1 = require("../utils/apiUtils");
|
|
|
6
6
|
const keyManager_1 = require("../crypto/keyManager");
|
|
7
7
|
const signatureService_1 = require("../crypto/signatureService");
|
|
8
8
|
const userIdentity_1 = require("../utils/userIdentity");
|
|
9
|
+
const identityCacheSweep_1 = require("../utils/identityCacheSweep");
|
|
10
|
+
const accountCacheSweep_1 = require("../utils/accountCacheSweep");
|
|
9
11
|
const logger_1 = require("../logger");
|
|
10
12
|
const errorUtils_1 = require("../utils/errorUtils");
|
|
11
13
|
/**
|
|
@@ -347,28 +349,24 @@ function OxyServicesUserMixin(Base) {
|
|
|
347
349
|
/**
|
|
348
350
|
* Update user profile.
|
|
349
351
|
*
|
|
350
|
-
* Invalidates the SDK-side response cache for every endpoint that
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
352
|
+
* Invalidates the SDK-side response cache for every endpoint that can
|
|
353
|
+
* return this user — the list is owned by {@link evictOxyIdentityCache}, so
|
|
354
|
+
* a new identity read is added in one place instead of to each writer
|
|
355
|
+
* separately (this method's own hand-written copy had already drifted from
|
|
356
|
+
* the server-side one, missing `GET /auth/lookup/*` and
|
|
357
|
+
* `GET /profiles/resolve`). The account forest (`GET /accounts` and the
|
|
358
|
+
* caller's own detail row) is swept too — a personal account IS this user,
|
|
359
|
+
* and `AccountNode.account` embeds the whole profile — from the list that
|
|
360
|
+
* {@link evictOxyAccountForestCache} owns, for the same reason: the accounts
|
|
361
|
+
* mixin writes those keys as well, and two hand-written copies drift.
|
|
357
362
|
*
|
|
358
363
|
* TanStack Query handles offline queuing automatically.
|
|
359
364
|
*/
|
|
360
365
|
async updateProfile(updates) {
|
|
361
366
|
try {
|
|
362
367
|
const result = (0, userIdentity_1.normalizeUserIdentity)(await this.makeRequest('PUT', '/users/me', updates, { cache: false }));
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// tracks the set of active session IDs centrally.
|
|
366
|
-
this.clearCacheByPrefix('GET:/session/user/');
|
|
367
|
-
this.clearCacheByPrefix('GET:/users/me');
|
|
368
|
-
this.clearCacheByPrefix('GET:/profiles/username/');
|
|
369
|
-
if (result?.id) {
|
|
370
|
-
this.clearCacheEntry(`GET:/users/${result.id}`);
|
|
371
|
-
}
|
|
368
|
+
(0, identityCacheSweep_1.evictOxyIdentityCache)(this, result?.id);
|
|
369
|
+
(0, accountCacheSweep_1.evictOxyAccountForestCache)(this, result?.id);
|
|
372
370
|
return result;
|
|
373
371
|
}
|
|
374
372
|
catch (error) {
|
|
@@ -421,10 +419,9 @@ function OxyServicesUserMixin(Base) {
|
|
|
421
419
|
const result = await this.makeRequest('PATCH', `/privacy/${id}/privacy`, settings, {
|
|
422
420
|
cache: false,
|
|
423
421
|
});
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
this.clearCacheEntry(`GET:/users/${id}`);
|
|
422
|
+
// Privacy settings ride the user DTO, so every identity read goes stale
|
|
423
|
+
// too — same key list as any other profile write.
|
|
424
|
+
(0, identityCacheSweep_1.evictOxyIdentityCache)(this, id);
|
|
428
425
|
this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
|
|
429
426
|
return result;
|
|
430
427
|
}
|
package/dist/cjs/mixins/index.js
CHANGED
|
@@ -33,6 +33,7 @@ const OxyServices_appData_1 = require("./OxyServices.appData");
|
|
|
33
33
|
const OxyServices_civic_1 = require("./OxyServices.civic");
|
|
34
34
|
const OxyServices_nodes_1 = require("./OxyServices.nodes");
|
|
35
35
|
const OxyServices_links_1 = require("./OxyServices.links");
|
|
36
|
+
const OxyServices_followGraph_1 = require("./OxyServices.followGraph");
|
|
36
37
|
const OxyServices_deviceBoot_1 = require("./OxyServices.deviceBoot");
|
|
37
38
|
const OxyServices_deviceTransfer_1 = require("./OxyServices.deviceTransfer");
|
|
38
39
|
/**
|
|
@@ -89,6 +90,9 @@ const MIXIN_PIPELINE = [
|
|
|
89
90
|
// Link previews / unfurls: SDK-owned link-metadata resolution via oxy-api,
|
|
90
91
|
// so apps stop scraping link metadata locally.
|
|
91
92
|
OxyServices_links_1.OxyServicesLinksMixin,
|
|
93
|
+
// The user-owned follow graph (#809). One relationship per user and target,
|
|
94
|
+
// shared across applications, with per-application context on top.
|
|
95
|
+
OxyServices_followGraph_1.OxyServicesFollowGraphMixin,
|
|
92
96
|
// Device-first token mint: the client half of the zero-cookie transport
|
|
93
97
|
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
94
98
|
OxyServices_deviceBoot_1.OxyServicesDeviceBootMixin,
|
package/dist/cjs/server/index.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
-
exports.isOfficialWebOrigin = exports.registrableApex = exports.
|
|
19
|
+
exports.isOfficialWebOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.verifySecret = exports.OXY_CSP_BASELINE = exports.formatOxyCspPolicy = exports.createOxySecurityHeaders = exports.buildOxyPagesHeaders = exports.buildOxyCspDirectives = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.OXY_SERVICE_ENVIRONMENTS = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getOxyUserId = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
|
|
20
20
|
var auth_1 = require("./auth");
|
|
21
21
|
Object.defineProperty(exports, "createOptionalOxyAuth", { enumerable: true, get: function () { return auth_1.createOptionalOxyAuth; } });
|
|
22
22
|
Object.defineProperty(exports, "createOxyAuthMiddleware", { enumerable: true, get: function () { return auth_1.createOxyAuthMiddleware; } });
|
|
@@ -59,8 +59,14 @@ Object.defineProperty(exports, "verifySecret", { enumerable: true, get: function
|
|
|
59
59
|
// changes, every consuming backend sweeps its caches instead of waiting out a TTL.
|
|
60
60
|
var userInvalidation_1 = require("./userInvalidation");
|
|
61
61
|
Object.defineProperty(exports, "createOxyUserInvalidationHandler", { enumerable: true, get: function () { return userInvalidation_1.createOxyUserInvalidationHandler; } });
|
|
62
|
-
Object.defineProperty(exports, "evictOxyIdentityCache", { enumerable: true, get: function () { return userInvalidation_1.evictOxyIdentityCache; } });
|
|
63
62
|
Object.defineProperty(exports, "publishOxyUserInvalidation", { enumerable: true, get: function () { return userInvalidation_1.publishOxyUserInvalidation; } });
|
|
63
|
+
// The identity-key enumeration itself is platform-neutral (`src/utils/`) so the
|
|
64
|
+
// client mixins and this Node-only subscriber sweep the SAME list — a second
|
|
65
|
+
// copy is what let `updateAccount` and `updateProfile` drift apart.
|
|
66
|
+
var identityCacheSweep_1 = require("../utils/identityCacheSweep");
|
|
67
|
+
Object.defineProperty(exports, "evictOxyIdentityCache", { enumerable: true, get: function () { return identityCacheSweep_1.evictOxyIdentityCache; } });
|
|
68
|
+
Object.defineProperty(exports, "oxyUserByIdCacheKey", { enumerable: true, get: function () { return identityCacheSweep_1.oxyUserByIdCacheKey; } });
|
|
69
|
+
Object.defineProperty(exports, "OXY_IDENTITY_CACHE_PREFIXES", { enumerable: true, get: function () { return identityCacheSweep_1.OXY_IDENTITY_CACHE_PREFIXES; } });
|
|
64
70
|
// Registrable-apex (eTLD+1) derivation via the Public Suffix List — the SINGLE
|
|
65
71
|
// SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
|
|
66
72
|
// Pure host handling (no browser deps), so it is safe on the server subpath and
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Every Oxy backend caches Oxy identity, and none of them find out when it
|
|
8
8
|
* changes. The `OxyServices` GET response cache holds `GET /users/:id` and
|
|
9
9
|
* `GET /profiles/username/:name` for five minutes; it is swept when THIS process
|
|
10
|
-
* writes the profile (
|
|
11
|
-
* never when somebody else does — which is the normal case, since
|
|
12
|
-
* edited in Oxy's own apps. So an avatar or display-name change is
|
|
13
|
-
* every consuming backend for up to five minutes, per process.
|
|
10
|
+
* writes the profile (the `evictOxyIdentityCache` calls in the user and accounts
|
|
11
|
+
* mixins) and never when somebody else does — which is the normal case, since
|
|
12
|
+
* profiles are edited in Oxy's own apps. So an avatar or display-name change is
|
|
13
|
+
* invisible to every consuming backend for up to five minutes, per process.
|
|
14
14
|
*
|
|
15
15
|
* oxy-api broadcasts {@link OXY_USER_INVALIDATION_CHANNEL} on the shared Valkey
|
|
16
16
|
* when a user's identity changes. This module is the consumer half: it parses
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
55
|
exports.publishOxyUserInvalidation = publishOxyUserInvalidation;
|
|
56
56
|
exports.createOxyUserInvalidationHandler = createOxyUserInvalidationHandler;
|
|
57
|
-
exports.evictOxyIdentityCache = evictOxyIdentityCache;
|
|
58
57
|
const contracts_1 = require("@oxyhq/contracts");
|
|
58
|
+
const identityCacheSweep_1 = require("../utils/identityCacheSweep");
|
|
59
59
|
/**
|
|
60
60
|
* Broadcast that an Oxy user's record changed.
|
|
61
61
|
*
|
|
@@ -134,7 +134,7 @@ function createOxyUserInvalidationHandler(options = {}) {
|
|
|
134
134
|
}
|
|
135
135
|
if (oxy) {
|
|
136
136
|
try {
|
|
137
|
-
evictOxyIdentityCache(oxy, event.userId);
|
|
137
|
+
(0, identityCacheSweep_1.evictOxyIdentityCache)(oxy, event.userId);
|
|
138
138
|
}
|
|
139
139
|
catch (error) {
|
|
140
140
|
// A cache sweep must never cost us the app-specific eviction below.
|
|
@@ -154,25 +154,3 @@ function createOxyUserInvalidationHandler(options = {}) {
|
|
|
154
154
|
}
|
|
155
155
|
};
|
|
156
156
|
}
|
|
157
|
-
/**
|
|
158
|
-
* Sweep an `OxyServices` GET response cache of everything that could carry the
|
|
159
|
-
* given user's identity.
|
|
160
|
-
*
|
|
161
|
-
* The by-id entry is exact. The by-username and resolve entries are keyed by
|
|
162
|
-
* HANDLE, which cannot be derived from an id without the very lookup we are
|
|
163
|
-
* invalidating, so those are swept by prefix — the same imprecision the SDK
|
|
164
|
-
* already accepts when it sweeps its own cache after a local profile write, and
|
|
165
|
-
* bounded by the fact that over-eviction costs a refetch and can never serve
|
|
166
|
-
* wrong data.
|
|
167
|
-
*/
|
|
168
|
-
function evictOxyIdentityCache(oxy, userId) {
|
|
169
|
-
// Match the sweep the user mixin runs after a local profile write — session-
|
|
170
|
-
// bound and /users/me entries are keyed without the user id, so they must be
|
|
171
|
-
// prefix-swept on cross-service invalidation too.
|
|
172
|
-
oxy.clearCacheByPrefix('GET:/session/user/');
|
|
173
|
-
oxy.clearCacheByPrefix('GET:/users/me');
|
|
174
|
-
oxy.clearCacheByPrefix('GET:/auth/lookup/');
|
|
175
|
-
oxy.clearCacheEntry(`GET:/users/${userId}`);
|
|
176
|
-
oxy.clearCacheByPrefix('GET:/profiles/username/');
|
|
177
|
-
oxy.clearCacheByPrefix('GET:/profiles/resolve');
|
|
178
|
-
}
|
|
@@ -18,11 +18,41 @@
|
|
|
18
18
|
* atomic, so no cross-call current-row reconciliation is needed).
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.isSwitchTargetAccount = isSwitchTargetAccount;
|
|
21
22
|
exports.projectSwitchableAccounts = projectSwitchableAccounts;
|
|
22
23
|
exports.switchableAccountIds = switchableAccountIds;
|
|
23
24
|
const contracts_1 = require("@oxyhq/contracts");
|
|
24
25
|
const accountUtils_1 = require("../utils/accountUtils");
|
|
25
26
|
const userHandle_1 = require("../utils/userHandle");
|
|
27
|
+
/**
|
|
28
|
+
* Whether the caller can BECOME this account — the one question every account
|
|
29
|
+
* switcher asks, answered here so no surface has to re-derive it.
|
|
30
|
+
*
|
|
31
|
+
* Two independent grounds, either of which suffices:
|
|
32
|
+
*
|
|
33
|
+
* - **It is already the caller's own identity** (`relationship: 'self'`).
|
|
34
|
+
* `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
|
|
35
|
+
* is the HUMAN operator's personal account even while they are operating an
|
|
36
|
+
* org — never the operated account. Kind is irrelevant on this ground: the
|
|
37
|
+
* caller IS that account, so returning to it asks the server for nothing.
|
|
38
|
+
* - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
|
|
39
|
+
* the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
|
|
40
|
+
* on this ground is never a dead button.
|
|
41
|
+
*
|
|
42
|
+
* `isActAsEligibleKind` ALONE is not this question, and reaching for it
|
|
43
|
+
* directly is the mistake this function exists to prevent: it is false for
|
|
44
|
+
* `personal` as well as `channel`, so a switcher gated on it alone renders an
|
|
45
|
+
* empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
|
|
46
|
+
* this question either — it silently admits every kind invented after it was
|
|
47
|
+
* written, which is the same trap `isActAsEligibleKind` was introduced to close
|
|
48
|
+
* on the server.
|
|
49
|
+
*
|
|
50
|
+
* Takes a structural subset rather than a whole {@link AccountNode} so a caller
|
|
51
|
+
* holding a projected {@link SwitchableAccount} can ask it too.
|
|
52
|
+
*/
|
|
53
|
+
function isSwitchTargetAccount(node) {
|
|
54
|
+
return node.relationship === 'self' || (0, contracts_1.isActAsEligibleKind)(node.kind);
|
|
55
|
+
}
|
|
26
56
|
/**
|
|
27
57
|
* Pure union of device sign-ins and account-graph nodes into the flat
|
|
28
58
|
* {@link SwitchableAccount}[] every switcher renders.
|
|
@@ -32,8 +62,9 @@ const userHandle_1 = require("../utils/userHandle");
|
|
|
32
62
|
* and a graph node is deduped into ONE device row enriched with the graph
|
|
33
63
|
* metadata (relationship / kind / parent / membership).
|
|
34
64
|
*
|
|
35
|
-
* Graph nodes
|
|
36
|
-
*
|
|
65
|
+
* Graph nodes that are not switch targets — a `channel`, which nobody may act
|
|
66
|
+
* as — are omitted. {@link isSwitchTargetAccount} is the rule; see the filter
|
|
67
|
+
* below.
|
|
37
68
|
*/
|
|
38
69
|
function projectSwitchableAccounts(input) {
|
|
39
70
|
const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
|
|
@@ -108,10 +139,12 @@ function projectSwitchableAccounts(input) {
|
|
|
108
139
|
// construction": the graph contributes accounts that have no device session
|
|
109
140
|
// and no credentials at all, which is exactly how an org first becomes
|
|
110
141
|
// switchable. So a kind that must never be switched into has to be filtered
|
|
111
|
-
// HERE
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
142
|
+
// HERE — offering a row the server would 403 is a dead button.
|
|
143
|
+
//
|
|
144
|
+
// An account already on the device skipped this check via the branch above,
|
|
145
|
+
// and correctly: whatever its kind, the caller is signed into it, so
|
|
146
|
+
// switching is a local activation that asks the server for nothing.
|
|
147
|
+
if (!isSwitchTargetAccount(node)) {
|
|
115
148
|
continue;
|
|
116
149
|
}
|
|
117
150
|
remember(toRow(node.account, {
|
|
@@ -133,8 +166,11 @@ function projectSwitchableAccounts(input) {
|
|
|
133
166
|
* document, but including their ids lets the caller pass one id set and lets the
|
|
134
167
|
* projection prefer freshly-fetched profiles uniformly.
|
|
135
168
|
*
|
|
136
|
-
* Applies the SAME
|
|
137
|
-
* nodes, so this never fetches a
|
|
169
|
+
* Applies the SAME {@link isSwitchTargetAccount} filter as
|
|
170
|
+
* {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
|
|
171
|
+
* profile for a row the projection will drop — and, just as importantly, never
|
|
172
|
+
* SKIPS one the projection will keep, which would leave that row unrendered
|
|
173
|
+
* until some later fetch happened to resolve it.
|
|
138
174
|
*/
|
|
139
175
|
function switchableAccountIds(state, graph) {
|
|
140
176
|
const ids = new Set();
|
|
@@ -144,7 +180,7 @@ function switchableAccountIds(state, graph) {
|
|
|
144
180
|
}
|
|
145
181
|
}
|
|
146
182
|
for (const node of graph) {
|
|
147
|
-
if (node.accountId && (
|
|
183
|
+
if (node.accountId && isSwitchTargetAccount(node)) {
|
|
148
184
|
ids.add(node.accountId);
|
|
149
185
|
}
|
|
150
186
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* THE enumeration of `OxyServices` GET-cache keys that serve the ACCOUNT FOREST
|
|
4
|
+
* — the caller's accessible accounts, as lists and as individual detail rows —
|
|
5
|
+
* and the one sweep that clears them.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS IS NOT A METHOD ON THE ACCOUNTS MIXIN
|
|
8
|
+
* ---------------------------------------------
|
|
9
|
+
* `AccountNode.account` is a whole `User`, so a forest read embeds the very
|
|
10
|
+
* profile the identity reads serve. That makes an IDENTITY write a writer of
|
|
11
|
+
* these keys too: `updateProfile` edits the caller's own personal account,
|
|
12
|
+
* which is a row in `GET /accounts` and is its own `GET /accounts/<id>`. Leave
|
|
13
|
+
* those cached and the account switcher keeps drawing the pre-edit name and
|
|
14
|
+
* picture for the full TTL, against a perfectly healthy server.
|
|
15
|
+
*
|
|
16
|
+
* The mixins compose into one class at runtime but are typed one at a time, so
|
|
17
|
+
* the user mixin cannot call a method the accounts mixin owns. The key list
|
|
18
|
+
* therefore lives here, once, and every writer calls {@link
|
|
19
|
+
* evictOxyAccountForestCache} — exactly like the identity key list in
|
|
20
|
+
* `identityCacheSweep`, which the accounts mixin already calls for the
|
|
21
|
+
* mirror-image case (an account write staling the identity reads). The
|
|
22
|
+
* alternative — a second hand-written copy of these keys in the other mixin —
|
|
23
|
+
* is the drift that shipped the two stale-profile bugs `identityCacheSweep`
|
|
24
|
+
* documents.
|
|
25
|
+
*
|
|
26
|
+
* WHY THE LIST NEEDS A PREFIX AND THE DETAIL DOES NOT
|
|
27
|
+
* --------------------------------------------------
|
|
28
|
+
* `listAccounts({tree?})` keys the flat list as `GET:/accounts` and every
|
|
29
|
+
* option variant as `GET:/accounts?<query>` (the query string is part of the
|
|
30
|
+
* URL, hence of the key), and a writer cannot enumerate which variants a caller
|
|
31
|
+
* has read. The detail key, by contrast, is derivable from the account id the
|
|
32
|
+
* writer already holds.
|
|
33
|
+
*
|
|
34
|
+
* The `GET:/accounts?` prefix matches ONLY the query-string list variants —
|
|
35
|
+
* never `GET:/accounts/<id>` or its `…/members`, `…/credentials`, `…/children`
|
|
36
|
+
* sub-resources, which are the accounts mixin's own business and stay there.
|
|
37
|
+
*/
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX = exports.OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX = exports.OXY_ACCOUNT_LIST_CACHE_KEY = void 0;
|
|
40
|
+
exports.oxyAccountDetailCacheKey = oxyAccountDetailCacheKey;
|
|
41
|
+
exports.evictOxyAccountForestCache = evictOxyAccountForestCache;
|
|
42
|
+
/** The cache key `listAccounts()` reads under with no options. */
|
|
43
|
+
exports.OXY_ACCOUNT_LIST_CACHE_KEY = 'GET:/accounts';
|
|
44
|
+
/**
|
|
45
|
+
* The prefix covering every option-carrying `listAccounts(opts)` variant
|
|
46
|
+
* (`?tree=true`, …), none of which a writer can enumerate.
|
|
47
|
+
*/
|
|
48
|
+
exports.OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX = 'GET:/accounts?';
|
|
49
|
+
/**
|
|
50
|
+
* Prefix covering every per-account sub-resource cache key
|
|
51
|
+
* (`GET:/accounts/<id>`, `…/members`, `…/credentials`, `…/children`). A
|
|
52
|
+
* membership mutation on an ancestor must sweep ALL of these, not only the
|
|
53
|
+
* account named in the path: descendant member rosters embed inherited rows
|
|
54
|
+
* resolved from that ancestor, and the writer cannot enumerate which descendant
|
|
55
|
+
* ids a caller has already read. The trailing slash deliberately excludes the
|
|
56
|
+
* forest list keys (`GET:/accounts`, `GET:/accounts?…`) documented above.
|
|
57
|
+
*/
|
|
58
|
+
exports.OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX = 'GET:/accounts/';
|
|
59
|
+
/**
|
|
60
|
+
* Build the exact cache key `getAccount(accountId)` reads under.
|
|
61
|
+
*/
|
|
62
|
+
function oxyAccountDetailCacheKey(accountId) {
|
|
63
|
+
return `GET:/accounts/${encodeURIComponent(accountId)}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Sweep an `OxyServices` GET response cache of the account forest.
|
|
67
|
+
*
|
|
68
|
+
* @param oxy - Anything exposing the SDK's two eviction methods.
|
|
69
|
+
* @param accountId - The account whose detail row to drop as well. Optional: a
|
|
70
|
+
* writer that changed the SHAPE of the forest rather than one
|
|
71
|
+
* account in it (create, archive, ownership transfer) has no
|
|
72
|
+
* detail row to name, and clears only the lists.
|
|
73
|
+
*/
|
|
74
|
+
function evictOxyAccountForestCache(oxy, accountId) {
|
|
75
|
+
oxy.clearCacheEntry(exports.OXY_ACCOUNT_LIST_CACHE_KEY);
|
|
76
|
+
oxy.clearCacheByPrefix(exports.OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX);
|
|
77
|
+
if (accountId) {
|
|
78
|
+
oxy.clearCacheEntry(oxyAccountDetailCacheKey(accountId));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* THE enumeration of `OxyServices` GET-cache keys that can carry a single
|
|
4
|
+
* account's identity, and the one sweep that clears them.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS IS ONE LIST
|
|
7
|
+
* --------------------
|
|
8
|
+
* An Oxy account is readable under SEVERAL cache keys, and a write that only
|
|
9
|
+
* busts the key it happens to know about leaves every other one serving the
|
|
10
|
+
* pre-write snapshot for up to its TTL — from the caller's OWN in-memory cache,
|
|
11
|
+
* with a perfectly healthy server. That failure has already shipped twice with
|
|
12
|
+
* two different sets of keys:
|
|
13
|
+
*
|
|
14
|
+
* - `updateAccount` busted `GET:/accounts/<id>` and the account lists, but a
|
|
15
|
+
* profile screen reads `GET:/profiles/username/<name>` and
|
|
16
|
+
* `GET:/users/<id>`, so a channel's new picture stayed invisible for the
|
|
17
|
+
* full 5-minute profile TTL.
|
|
18
|
+
* - `updateProfile` busted four of the six keys below, missing
|
|
19
|
+
* `GET:/auth/lookup/` (the login-flow avatar/display-name lookup) and
|
|
20
|
+
* `GET:/profiles/resolve` (handle resolution) — two independently-drifted
|
|
21
|
+
* copies of a list that has to agree.
|
|
22
|
+
*
|
|
23
|
+
* So the list lives here, once, and every writer calls
|
|
24
|
+
* {@link evictOxyIdentityCache}. Adding a new identity read means adding its key
|
|
25
|
+
* HERE and every writer inherits it.
|
|
26
|
+
*
|
|
27
|
+
* WHERE THE LINE IS DRAWN
|
|
28
|
+
* -----------------------
|
|
29
|
+
* These are the SINGLE-PROFILE reads — the account is the subject of the
|
|
30
|
+
* response and is addressable by id, handle, or session. Reads that merely
|
|
31
|
+
* CONTAIN an account among many (`GET:/profiles/search`,
|
|
32
|
+
* `GET:/users/<other>/followers`, `GET:/profiles/<other>/similar`) are
|
|
33
|
+
* deliberately NOT swept: an account cannot be located in them without the very
|
|
34
|
+
* lookup being invalidated, so sweeping them means sweeping the whole namespace
|
|
35
|
+
* on every identity change — a real cost on a backend consuming the
|
|
36
|
+
* cross-service invalidation signal, for a surface where a stale thumbnail
|
|
37
|
+
* expires on its own in ~2 minutes.
|
|
38
|
+
*
|
|
39
|
+
* WHY PREFIXES RATHER THAN EXACT KEYS
|
|
40
|
+
* -----------------------------------
|
|
41
|
+
* Only the by-id key can be built from a user id. The handle-keyed and
|
|
42
|
+
* session-keyed entries cannot — deriving a handle from an id needs the lookup
|
|
43
|
+
* we are invalidating, and the SDK never tracks active session ids centrally.
|
|
44
|
+
* Prefix sweeping is also what makes a USERNAME CHANGE correct: the entry under
|
|
45
|
+
* the OLD handle is unreachable by construction (nothing in the write response
|
|
46
|
+
* carries it), and a sweep targeted at the new handle alone would leave the old
|
|
47
|
+
* one serving the pre-rename profile until its TTL. Over-eviction costs a
|
|
48
|
+
* refetch; under-eviction serves wrong data.
|
|
49
|
+
*
|
|
50
|
+
* Platform-neutral by construction (no imports, no `OxyServices` reference) so
|
|
51
|
+
* the client mixins and the Node-only `@oxyhq/core/server` invalidation
|
|
52
|
+
* subscriber can share it without either pulling in the other.
|
|
53
|
+
*/
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.OXY_IDENTITY_CACHE_PREFIXES = void 0;
|
|
56
|
+
exports.oxyUserByIdCacheKey = oxyUserByIdCacheKey;
|
|
57
|
+
exports.evictOxyIdentityCache = evictOxyIdentityCache;
|
|
58
|
+
/**
|
|
59
|
+
* Cache-key PREFIXES under which an account's identity can be served, for the
|
|
60
|
+
* reads whose key cannot be derived from a user id. Swept wholesale.
|
|
61
|
+
*/
|
|
62
|
+
exports.OXY_IDENTITY_CACHE_PREFIXES = [
|
|
63
|
+
// `getUserBySession` — keyed by session id, which the SDK never enumerates.
|
|
64
|
+
'GET:/session/user/',
|
|
65
|
+
// `getCurrentUser` (and `GET:/users/me/graph`, harmlessly included).
|
|
66
|
+
'GET:/users/me',
|
|
67
|
+
// `lookupUsername` — the pre-session login lookup; carries avatar + display name.
|
|
68
|
+
'GET:/auth/lookup/',
|
|
69
|
+
// `getProfileByUsername` — keyed by handle, including the pre-rename handle.
|
|
70
|
+
'GET:/profiles/username/',
|
|
71
|
+
// `resolveProfile` — keyed by fediverse handle in the query payload.
|
|
72
|
+
'GET:/profiles/resolve',
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* Build the exact cache key `getUserById` reads under. The only identity key
|
|
76
|
+
* derivable from a user id, so the only one that does not need a prefix sweep.
|
|
77
|
+
*/
|
|
78
|
+
function oxyUserByIdCacheKey(userId) {
|
|
79
|
+
return `GET:/users/${userId}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Sweep an `OxyServices` GET response cache of everything that could carry the
|
|
83
|
+
* given account's identity.
|
|
84
|
+
*
|
|
85
|
+
* @param oxy - Anything exposing the SDK's two eviction methods.
|
|
86
|
+
* @param userId - The account whose by-id entry to drop. Optional: a caller
|
|
87
|
+
* that does not know the id still clears every handle-, session-
|
|
88
|
+
* and self-keyed entry, which is the majority of the surface.
|
|
89
|
+
*/
|
|
90
|
+
function evictOxyIdentityCache(oxy, userId) {
|
|
91
|
+
for (const prefix of exports.OXY_IDENTITY_CACHE_PREFIXES) {
|
|
92
|
+
oxy.clearCacheByPrefix(prefix);
|
|
93
|
+
}
|
|
94
|
+
if (userId) {
|
|
95
|
+
oxy.clearCacheEntry(oxyUserByIdCacheKey(userId));
|
|
96
|
+
}
|
|
97
|
+
}
|