@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
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* Every Oxy backend caches Oxy identity, and none of them find out when it
|
|
7
7
|
* changes. The `OxyServices` GET response cache holds `GET /users/:id` and
|
|
8
8
|
* `GET /profiles/username/:name` for five minutes; it is swept when THIS process
|
|
9
|
-
* writes the profile (
|
|
10
|
-
* never when somebody else does — which is the normal case, since
|
|
11
|
-
* edited in Oxy's own apps. So an avatar or display-name change is
|
|
12
|
-
* every consuming backend for up to five minutes, per process.
|
|
9
|
+
* writes the profile (the `evictOxyIdentityCache` calls in the user and accounts
|
|
10
|
+
* mixins) and never when somebody else does — which is the normal case, since
|
|
11
|
+
* profiles are edited in Oxy's own apps. So an avatar or display-name change is
|
|
12
|
+
* invisible to every consuming backend for up to five minutes, per process.
|
|
13
13
|
*
|
|
14
14
|
* oxy-api broadcasts {@link OXY_USER_INVALIDATION_CHANNEL} on the shared Valkey
|
|
15
15
|
* when a user's identity changes. This module is the consumer half: it parses
|
|
@@ -58,6 +58,10 @@ import {
|
|
|
58
58
|
type OxyUserChangeReason,
|
|
59
59
|
type OxyUserInvalidationEvent,
|
|
60
60
|
} from '@oxyhq/contracts';
|
|
61
|
+
import {
|
|
62
|
+
evictOxyIdentityCache,
|
|
63
|
+
type OxyIdentityCacheEvictor,
|
|
64
|
+
} from '../utils/identityCacheSweep';
|
|
61
65
|
|
|
62
66
|
/**
|
|
63
67
|
* The publish surface of a Redis client. Both `ioredis` and `node-redis`
|
|
@@ -67,15 +71,6 @@ export interface OxyInvalidationPublisher {
|
|
|
67
71
|
publish(channel: string, message: string): unknown;
|
|
68
72
|
}
|
|
69
73
|
|
|
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
74
|
/**
|
|
80
75
|
* Broadcast that an Oxy user's record changed.
|
|
81
76
|
*
|
|
@@ -202,26 +197,3 @@ export function createOxyUserInvalidationHandler(
|
|
|
202
197
|
}
|
|
203
198
|
};
|
|
204
199
|
}
|
|
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
|
-
// Match the sweep the user mixin runs after a local profile write — session-
|
|
219
|
-
// bound and /users/me entries are keyed without the user id, so they must be
|
|
220
|
-
// prefix-swept on cross-service invalidation too.
|
|
221
|
-
oxy.clearCacheByPrefix('GET:/session/user/');
|
|
222
|
-
oxy.clearCacheByPrefix('GET:/users/me');
|
|
223
|
-
oxy.clearCacheByPrefix('GET:/auth/lookup/');
|
|
224
|
-
oxy.clearCacheEntry(`GET:/users/${userId}`);
|
|
225
|
-
oxy.clearCacheByPrefix('GET:/profiles/username/');
|
|
226
|
-
oxy.clearCacheByPrefix('GET:/profiles/resolve');
|
|
227
|
-
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import { ACCOUNT_KINDS } from '@oxyhq/contracts';
|
|
2
3
|
import type { User } from '../../models/interfaces';
|
|
3
4
|
import type { AccountNode } from '../../mixins/OxyServices.accounts';
|
|
4
5
|
import {
|
|
6
|
+
isSwitchTargetAccount,
|
|
5
7
|
projectSwitchableAccounts,
|
|
6
8
|
switchableAccountIds,
|
|
7
9
|
} from '../accountProjection';
|
|
@@ -49,6 +51,57 @@ const mapOf = (...users: User[]): Map<string, User> => {
|
|
|
49
51
|
|
|
50
52
|
const noAvatar = (): undefined => undefined;
|
|
51
53
|
|
|
54
|
+
describe('isSwitchTargetAccount', () => {
|
|
55
|
+
/**
|
|
56
|
+
* EXHAUSTIVE over `ACCOUNT_KINDS`, as one object equality rather than a
|
|
57
|
+
* per-kind assertion, for two reasons that a `channel → false` spot-check
|
|
58
|
+
* cannot give:
|
|
59
|
+
*
|
|
60
|
+
* - It distinguishes "excludes channels" from "excludes everything". Three
|
|
61
|
+
* kinds must come back `true` here, so a predicate that answered `false`
|
|
62
|
+
* unconditionally — the shape that empties a switcher instead of filtering
|
|
63
|
+
* it — fails on those three, not on the channel.
|
|
64
|
+
* - Adding a sixth kind fails this test with a missing key, forcing the
|
|
65
|
+
* decision to be made HERE rather than inherited silently from whichever
|
|
66
|
+
* literal comparison happened to be written first.
|
|
67
|
+
*/
|
|
68
|
+
it('answers by kind for an account the caller merely owns or is a member of', () => {
|
|
69
|
+
expect(
|
|
70
|
+
Object.fromEntries(
|
|
71
|
+
ACCOUNT_KINDS.map((kind) => [kind, isSwitchTargetAccount({ kind, relationship: 'owner' })]),
|
|
72
|
+
),
|
|
73
|
+
).toEqual({
|
|
74
|
+
personal: false,
|
|
75
|
+
organization: true,
|
|
76
|
+
project: true,
|
|
77
|
+
bot: true,
|
|
78
|
+
channel: false,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The `self` ground, and the reason this predicate is not `isActAsEligibleKind`.
|
|
84
|
+
*
|
|
85
|
+
* `personal` is act-as INELIGIBLE — assuming somebody's human login would be
|
|
86
|
+
* impersonation — so a switcher gated on that predicate alone would drop the
|
|
87
|
+
* caller's OWN account and render an empty list. `GET /accounts` resolves its
|
|
88
|
+
* caller through `resolveOperatorId`, so a `self` node is always the human
|
|
89
|
+
* operator's own personal account, even while they operate an org.
|
|
90
|
+
*/
|
|
91
|
+
it('admits the caller’s own personal account, which is act-as ineligible', () => {
|
|
92
|
+
expect(isSwitchTargetAccount({ kind: 'personal', relationship: 'self' })).toBe(true);
|
|
93
|
+
// Same kind, not the caller's own → refused. The `relationship` is doing the
|
|
94
|
+
// work, so neither half of the predicate can be deleted without a failure.
|
|
95
|
+
expect(isSwitchTargetAccount({ kind: 'personal', relationship: 'member' })).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('refuses an account with no kind information rather than assuming', () => {
|
|
99
|
+
expect(isSwitchTargetAccount({})).toBe(false);
|
|
100
|
+
expect(isSwitchTargetAccount({ kind: null })).toBe(false);
|
|
101
|
+
expect(isSwitchTargetAccount({ kind: undefined, relationship: 'owner' })).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
52
105
|
describe('projectSwitchableAccounts', () => {
|
|
53
106
|
it('returns [] for null state and empty graph', () => {
|
|
54
107
|
expect(
|
|
@@ -138,6 +191,50 @@ describe('projectSwitchableAccounts', () => {
|
|
|
138
191
|
expect(rows.some((r) => r.kind === 'channel')).toBe(false);
|
|
139
192
|
});
|
|
140
193
|
|
|
194
|
+
/**
|
|
195
|
+
* The same rule as the `isSwitchTargetAccount` matrix above, asserted through
|
|
196
|
+
* the projection so the WIRING is covered and not just the predicate.
|
|
197
|
+
*
|
|
198
|
+
* The fixture deliberately carries one graph-only node of every kind, and
|
|
199
|
+
* FOUR of the five must survive: a fixture list of channels alone could not
|
|
200
|
+
* tell "omits channels" from "omits every graph-only row", which is the
|
|
201
|
+
* failure mode that would silently empty an operator's switcher of the orgs
|
|
202
|
+
* they actually work in. `a1` is a device row and is asserted separately, so
|
|
203
|
+
* the graph lane's output is never confused with the device lane's.
|
|
204
|
+
*/
|
|
205
|
+
it('keeps every switchable kind while dropping the channel (graph lane)', () => {
|
|
206
|
+
const rows = projectSwitchableAccounts({
|
|
207
|
+
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
208
|
+
graph: [
|
|
209
|
+
graphNode('self1', { kind: 'personal', relationship: 'self' }),
|
|
210
|
+
graphNode('org1', { kind: 'organization' }),
|
|
211
|
+
graphNode('proj1', { kind: 'project' }),
|
|
212
|
+
graphNode('bot1', { kind: 'bot' }),
|
|
213
|
+
graphNode('chan1', { kind: 'channel' }),
|
|
214
|
+
],
|
|
215
|
+
profilesById: mapOf(
|
|
216
|
+
user('a1'),
|
|
217
|
+
user('self1'),
|
|
218
|
+
user('org1'),
|
|
219
|
+
user('proj1'),
|
|
220
|
+
user('bot1'),
|
|
221
|
+
user('chan1'),
|
|
222
|
+
),
|
|
223
|
+
resolveAvatarUrl: noAvatar,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(rows.map((r) => r.accountId)).toEqual(['a1', 'self1', 'org1', 'proj1', 'bot1']);
|
|
227
|
+
// Stated the other way round too, so a fixture that stopped reaching the
|
|
228
|
+
// graph lane at all could not pass this as a vacuous "no channels found".
|
|
229
|
+
expect(rows.map((r) => r.kind)).toEqual([
|
|
230
|
+
undefined,
|
|
231
|
+
'personal',
|
|
232
|
+
'organization',
|
|
233
|
+
'project',
|
|
234
|
+
'bot',
|
|
235
|
+
]);
|
|
236
|
+
});
|
|
237
|
+
|
|
141
238
|
it('dedups an account present as BOTH device session and graph node into ONE enriched row', () => {
|
|
142
239
|
const rows = projectSwitchableAccounts({
|
|
143
240
|
state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }], 'a1'),
|
|
@@ -232,11 +329,21 @@ describe('switchableAccountIds', () => {
|
|
|
232
329
|
expect(switchableAccountIds(null, [])).toEqual([]);
|
|
233
330
|
});
|
|
234
331
|
|
|
235
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Must stay in lockstep with the projection's own filter in BOTH directions:
|
|
334
|
+
* an id fetched for a dropped row is wasted work, but an id NOT fetched for a
|
|
335
|
+
* row the projection keeps is worse — that row has no profile, so the
|
|
336
|
+
* projection's device lane skips it and it silently never renders.
|
|
337
|
+
*/
|
|
338
|
+
it('applies the same switch-target filter as the projection', () => {
|
|
236
339
|
const ids = switchableAccountIds(null, [
|
|
340
|
+
graphNode('self1', { kind: 'personal', relationship: 'self' }),
|
|
237
341
|
graphNode('org1', { kind: 'organization' }),
|
|
342
|
+
graphNode('proj1', { kind: 'project' }),
|
|
343
|
+
graphNode('bot1', { kind: 'bot' }),
|
|
238
344
|
graphNode('chan1', { kind: 'channel' }),
|
|
345
|
+
graphNode('other1', { kind: 'personal', relationship: 'member' }),
|
|
239
346
|
]);
|
|
240
|
-
expect(ids).toEqual(['org1']);
|
|
347
|
+
expect(ids).toEqual(['bot1', 'org1', 'proj1', 'self1']);
|
|
241
348
|
});
|
|
242
349
|
});
|
|
@@ -103,6 +103,38 @@ export interface SwitchableAccount {
|
|
|
103
103
|
user: SwitchableAccountUser;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Whether the caller can BECOME this account — the one question every account
|
|
108
|
+
* switcher asks, answered here so no surface has to re-derive it.
|
|
109
|
+
*
|
|
110
|
+
* Two independent grounds, either of which suffices:
|
|
111
|
+
*
|
|
112
|
+
* - **It is already the caller's own identity** (`relationship: 'self'`).
|
|
113
|
+
* `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
|
|
114
|
+
* is the HUMAN operator's personal account even while they are operating an
|
|
115
|
+
* org — never the operated account. Kind is irrelevant on this ground: the
|
|
116
|
+
* caller IS that account, so returning to it asks the server for nothing.
|
|
117
|
+
* - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
|
|
118
|
+
* the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
|
|
119
|
+
* on this ground is never a dead button.
|
|
120
|
+
*
|
|
121
|
+
* `isActAsEligibleKind` ALONE is not this question, and reaching for it
|
|
122
|
+
* directly is the mistake this function exists to prevent: it is false for
|
|
123
|
+
* `personal` as well as `channel`, so a switcher gated on it alone renders an
|
|
124
|
+
* empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
|
|
125
|
+
* this question either — it silently admits every kind invented after it was
|
|
126
|
+
* written, which is the same trap `isActAsEligibleKind` was introduced to close
|
|
127
|
+
* on the server.
|
|
128
|
+
*
|
|
129
|
+
* Takes a structural subset rather than a whole {@link AccountNode} so a caller
|
|
130
|
+
* holding a projected {@link SwitchableAccount} can ask it too.
|
|
131
|
+
*/
|
|
132
|
+
export function isSwitchTargetAccount(
|
|
133
|
+
node: { kind?: AccountKind | null; relationship?: AccountRelationship },
|
|
134
|
+
): boolean {
|
|
135
|
+
return node.relationship === 'self' || isActAsEligibleKind(node.kind);
|
|
136
|
+
}
|
|
137
|
+
|
|
106
138
|
/** Input to {@link projectSwitchableAccounts}. */
|
|
107
139
|
export interface ProjectSwitchableAccountsInput {
|
|
108
140
|
/**
|
|
@@ -144,8 +176,9 @@ export interface ProjectSwitchableAccountsInput {
|
|
|
144
176
|
* and a graph node is deduped into ONE device row enriched with the graph
|
|
145
177
|
* metadata (relationship / kind / parent / membership).
|
|
146
178
|
*
|
|
147
|
-
* Graph nodes
|
|
148
|
-
*
|
|
179
|
+
* Graph nodes that are not switch targets — a `channel`, which nobody may act
|
|
180
|
+
* as — are omitted. {@link isSwitchTargetAccount} is the rule; see the filter
|
|
181
|
+
* below.
|
|
149
182
|
*/
|
|
150
183
|
export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[] {
|
|
151
184
|
const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
|
|
@@ -236,10 +269,12 @@ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput)
|
|
|
236
269
|
// construction": the graph contributes accounts that have no device session
|
|
237
270
|
// and no credentials at all, which is exactly how an org first becomes
|
|
238
271
|
// switchable. So a kind that must never be switched into has to be filtered
|
|
239
|
-
// HERE
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
272
|
+
// HERE — offering a row the server would 403 is a dead button.
|
|
273
|
+
//
|
|
274
|
+
// An account already on the device skipped this check via the branch above,
|
|
275
|
+
// and correctly: whatever its kind, the caller is signed into it, so
|
|
276
|
+
// switching is a local activation that asks the server for nothing.
|
|
277
|
+
if (!isSwitchTargetAccount(node)) {
|
|
243
278
|
continue;
|
|
244
279
|
}
|
|
245
280
|
remember(toRow(node.account, {
|
|
@@ -263,8 +298,11 @@ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput)
|
|
|
263
298
|
* document, but including their ids lets the caller pass one id set and lets the
|
|
264
299
|
* projection prefer freshly-fetched profiles uniformly.
|
|
265
300
|
*
|
|
266
|
-
* Applies the SAME
|
|
267
|
-
* nodes, so this never fetches a
|
|
301
|
+
* Applies the SAME {@link isSwitchTargetAccount} filter as
|
|
302
|
+
* {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
|
|
303
|
+
* profile for a row the projection will drop — and, just as importantly, never
|
|
304
|
+
* SKIPS one the projection will keep, which would leave that row unrendered
|
|
305
|
+
* until some later fetch happened to resolve it.
|
|
268
306
|
*/
|
|
269
307
|
export function switchableAccountIds(
|
|
270
308
|
state: DeviceSessionState | null,
|
|
@@ -277,7 +315,7 @@ export function switchableAccountIds(
|
|
|
277
315
|
}
|
|
278
316
|
}
|
|
279
317
|
for (const node of graph) {
|
|
280
|
-
if (node.accountId &&
|
|
318
|
+
if (node.accountId && isSwitchTargetAccount(node)) {
|
|
281
319
|
ids.add(node.accountId);
|
|
282
320
|
}
|
|
283
321
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity cache-key enumeration, checked against the keys REAL reads
|
|
3
|
+
* produce.
|
|
4
|
+
*
|
|
5
|
+
* A list-equality assertion on `OXY_IDENTITY_CACHE_PREFIXES` alone would be
|
|
6
|
+
* satisfied forever by a typo (`GET:/profile/username/`) — it pins the list's
|
|
7
|
+
* shape, not its correctness. So the load-bearing test here drives each prefix
|
|
8
|
+
* from the SDK method that actually reads under it, over the real
|
|
9
|
+
* `HttpService` cache, and asserts the sweep evicts every one. A prefix that
|
|
10
|
+
* stops matching its read fails here rather than in production.
|
|
11
|
+
*
|
|
12
|
+
* The list is shared by every profile writer (`updateProfile`,
|
|
13
|
+
* `updatePrivacySettings`, `updateAccount`) and by the Node-only
|
|
14
|
+
* `oxy:user:invalidate` subscriber in `@oxyhq/core/server`, precisely because
|
|
15
|
+
* two hand-written copies of it had already drifted apart.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { OxyServices } from '../../OxyServices';
|
|
19
|
+
import {
|
|
20
|
+
OXY_IDENTITY_CACHE_PREFIXES,
|
|
21
|
+
evictOxyIdentityCache,
|
|
22
|
+
oxyUserByIdCacheKey,
|
|
23
|
+
type OxyIdentityCacheEvictor,
|
|
24
|
+
} from '../identityCacheSweep';
|
|
25
|
+
|
|
26
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
27
|
+
const b64url = (obj: Record<string, unknown>): string =>
|
|
28
|
+
Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
29
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url({
|
|
30
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
31
|
+
...payload,
|
|
32
|
+
})}.sig`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function jsonResponse(data: unknown): Response {
|
|
36
|
+
return new Response(JSON.stringify({ data }), {
|
|
37
|
+
status: 200,
|
|
38
|
+
headers: { 'content-type': 'application/json' },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeRecordingEvictor() {
|
|
43
|
+
const entries: string[] = [];
|
|
44
|
+
const prefixes: string[] = [];
|
|
45
|
+
const evictor: OxyIdentityCacheEvictor = {
|
|
46
|
+
clearCacheEntry: (key) => {
|
|
47
|
+
entries.push(key);
|
|
48
|
+
},
|
|
49
|
+
clearCacheByPrefix: (prefix) => {
|
|
50
|
+
prefixes.push(prefix);
|
|
51
|
+
return 0;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
return { evictor, entries, prefixes };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('evictOxyIdentityCache — the key list', () => {
|
|
58
|
+
it('sweeps every identity prefix and the exact by-id entry', () => {
|
|
59
|
+
const { evictor, entries, prefixes } = makeRecordingEvictor();
|
|
60
|
+
evictOxyIdentityCache(evictor, 'abc123');
|
|
61
|
+
|
|
62
|
+
expect(prefixes).toEqual([
|
|
63
|
+
'GET:/session/user/',
|
|
64
|
+
'GET:/users/me',
|
|
65
|
+
'GET:/auth/lookup/',
|
|
66
|
+
'GET:/profiles/username/',
|
|
67
|
+
'GET:/profiles/resolve',
|
|
68
|
+
]);
|
|
69
|
+
expect(prefixes).toEqual([...OXY_IDENTITY_CACHE_PREFIXES]);
|
|
70
|
+
expect(entries).toEqual([oxyUserByIdCacheKey('abc123')]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('sweeps the prefixes but writes no by-id entry when the id is unknown', () => {
|
|
74
|
+
const { evictor, entries, prefixes } = makeRecordingEvictor();
|
|
75
|
+
evictOxyIdentityCache(evictor);
|
|
76
|
+
|
|
77
|
+
expect(prefixes).toEqual([...OXY_IDENTITY_CACHE_PREFIXES]);
|
|
78
|
+
expect(entries).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('treats an empty-string id as unknown rather than building `GET:/users/`', () => {
|
|
82
|
+
// `GET:/users/` would be a prefix-shaped key handed to an EXACT-match
|
|
83
|
+
// deleter, so it evicts nothing while looking like it evicted something.
|
|
84
|
+
const { evictor, entries } = makeRecordingEvictor();
|
|
85
|
+
evictOxyIdentityCache(evictor, '');
|
|
86
|
+
expect(entries).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('evictOxyIdentityCache — every prefix matches a real read', () => {
|
|
91
|
+
let originalFetch: typeof globalThis.fetch;
|
|
92
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
93
|
+
let oxy: OxyServices;
|
|
94
|
+
|
|
95
|
+
const USER_ID = 'user-77';
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
originalFetch = globalThis.fetch;
|
|
99
|
+
fetchMock = jest.fn();
|
|
100
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
101
|
+
oxy = new OxyServices({
|
|
102
|
+
baseURL: 'http://test.invalid',
|
|
103
|
+
enableRetry: false,
|
|
104
|
+
requestTimeout: 1000,
|
|
105
|
+
});
|
|
106
|
+
oxy.httpService.setTokens(makeJwt({ userId: USER_ID }));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
globalThis.fetch = originalFetch;
|
|
111
|
+
jest.clearAllMocks();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** One real read per swept key, each warmed into the real response cache. */
|
|
115
|
+
const reads: ReadonlyArray<{
|
|
116
|
+
key: string;
|
|
117
|
+
warm: (client: OxyServices) => Promise<unknown>;
|
|
118
|
+
}> = [
|
|
119
|
+
{ key: 'GET:/session/user/', warm: (c) => c.getUserBySession('sess-1') },
|
|
120
|
+
{ key: 'GET:/users/me', warm: (c) => c.getCurrentUser() },
|
|
121
|
+
{ key: 'GET:/auth/lookup/', warm: (c) => c.lookupUsername('alice') },
|
|
122
|
+
{ key: 'GET:/profiles/username/', warm: (c) => c.getProfileByUsername('alice') },
|
|
123
|
+
{ key: 'GET:/profiles/resolve', warm: (c) => c.resolveProfile('@alice@test.invalid') },
|
|
124
|
+
{ key: 'GET:/users/<id>', warm: (c) => c.getUserById(USER_ID) },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
it('covers every prefix in the list with a read (no prefix goes unexercised)', () => {
|
|
128
|
+
// Vacuity floor: adding a prefix to the list without adding the read that
|
|
129
|
+
// exercises it fails HERE, rather than silently shrinking the test below.
|
|
130
|
+
expect(reads).toHaveLength(OXY_IDENTITY_CACHE_PREFIXES.length + 1);
|
|
131
|
+
for (const prefix of OXY_IDENTITY_CACHE_PREFIXES) {
|
|
132
|
+
expect(reads.some((read) => read.key === prefix)).toBe(true);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it.each(reads)('evicts the entry warmed by $key', async ({ warm }) => {
|
|
137
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: USER_ID, username: 'alice' }));
|
|
138
|
+
await warm(oxy);
|
|
139
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
140
|
+
|
|
141
|
+
// Control: the entry really is warm (a miss would call the un-queued mock).
|
|
142
|
+
await warm(oxy);
|
|
143
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
144
|
+
|
|
145
|
+
evictOxyIdentityCache(oxy, USER_ID);
|
|
146
|
+
|
|
147
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: USER_ID, username: 'alice-2' }));
|
|
148
|
+
await warm(oxy);
|
|
149
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE enumeration of `OxyServices` GET-cache keys that serve the ACCOUNT FOREST
|
|
3
|
+
* — the caller's accessible accounts, as lists and as individual detail rows —
|
|
4
|
+
* and the one sweep that clears them.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS IS NOT A METHOD ON THE ACCOUNTS MIXIN
|
|
7
|
+
* ---------------------------------------------
|
|
8
|
+
* `AccountNode.account` is a whole `User`, so a forest read embeds the very
|
|
9
|
+
* profile the identity reads serve. That makes an IDENTITY write a writer of
|
|
10
|
+
* these keys too: `updateProfile` edits the caller's own personal account,
|
|
11
|
+
* which is a row in `GET /accounts` and is its own `GET /accounts/<id>`. Leave
|
|
12
|
+
* those cached and the account switcher keeps drawing the pre-edit name and
|
|
13
|
+
* picture for the full TTL, against a perfectly healthy server.
|
|
14
|
+
*
|
|
15
|
+
* The mixins compose into one class at runtime but are typed one at a time, so
|
|
16
|
+
* the user mixin cannot call a method the accounts mixin owns. The key list
|
|
17
|
+
* therefore lives here, once, and every writer calls {@link
|
|
18
|
+
* evictOxyAccountForestCache} — exactly like the identity key list in
|
|
19
|
+
* `identityCacheSweep`, which the accounts mixin already calls for the
|
|
20
|
+
* mirror-image case (an account write staling the identity reads). The
|
|
21
|
+
* alternative — a second hand-written copy of these keys in the other mixin —
|
|
22
|
+
* is the drift that shipped the two stale-profile bugs `identityCacheSweep`
|
|
23
|
+
* documents.
|
|
24
|
+
*
|
|
25
|
+
* WHY THE LIST NEEDS A PREFIX AND THE DETAIL DOES NOT
|
|
26
|
+
* --------------------------------------------------
|
|
27
|
+
* `listAccounts({tree?})` keys the flat list as `GET:/accounts` and every
|
|
28
|
+
* option variant as `GET:/accounts?<query>` (the query string is part of the
|
|
29
|
+
* URL, hence of the key), and a writer cannot enumerate which variants a caller
|
|
30
|
+
* has read. The detail key, by contrast, is derivable from the account id the
|
|
31
|
+
* writer already holds.
|
|
32
|
+
*
|
|
33
|
+
* The `GET:/accounts?` prefix matches ONLY the query-string list variants —
|
|
34
|
+
* never `GET:/accounts/<id>` or its `…/members`, `…/credentials`, `…/children`
|
|
35
|
+
* sub-resources, which are the accounts mixin's own business and stay there.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { OxyIdentityCacheEvictor } from './identityCacheSweep';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The cache-eviction surface of an `OxyServices` instance. Reused from
|
|
42
|
+
* `identityCacheSweep` rather than re-declared: it is the SDK's one published
|
|
43
|
+
* name for these two methods, and a second identical interface would be one
|
|
44
|
+
* more shape to keep in step.
|
|
45
|
+
*/
|
|
46
|
+
export type OxyAccountCacheEvictor = OxyIdentityCacheEvictor;
|
|
47
|
+
|
|
48
|
+
/** The cache key `listAccounts()` reads under with no options. */
|
|
49
|
+
export const OXY_ACCOUNT_LIST_CACHE_KEY = 'GET:/accounts';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The prefix covering every option-carrying `listAccounts(opts)` variant
|
|
53
|
+
* (`?tree=true`, …), none of which a writer can enumerate.
|
|
54
|
+
*/
|
|
55
|
+
export const OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX = 'GET:/accounts?';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Prefix covering every per-account sub-resource cache key
|
|
59
|
+
* (`GET:/accounts/<id>`, `…/members`, `…/credentials`, `…/children`). A
|
|
60
|
+
* membership mutation on an ancestor must sweep ALL of these, not only the
|
|
61
|
+
* account named in the path: descendant member rosters embed inherited rows
|
|
62
|
+
* resolved from that ancestor, and the writer cannot enumerate which descendant
|
|
63
|
+
* ids a caller has already read. The trailing slash deliberately excludes the
|
|
64
|
+
* forest list keys (`GET:/accounts`, `GET:/accounts?…`) documented above.
|
|
65
|
+
*/
|
|
66
|
+
export const OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX = 'GET:/accounts/';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build the exact cache key `getAccount(accountId)` reads under.
|
|
70
|
+
*/
|
|
71
|
+
export function oxyAccountDetailCacheKey(accountId: string): string {
|
|
72
|
+
return `GET:/accounts/${encodeURIComponent(accountId)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Sweep an `OxyServices` GET response cache of the account forest.
|
|
77
|
+
*
|
|
78
|
+
* @param oxy - Anything exposing the SDK's two eviction methods.
|
|
79
|
+
* @param accountId - The account whose detail row to drop as well. Optional: a
|
|
80
|
+
* writer that changed the SHAPE of the forest rather than one
|
|
81
|
+
* account in it (create, archive, ownership transfer) has no
|
|
82
|
+
* detail row to name, and clears only the lists.
|
|
83
|
+
*/
|
|
84
|
+
export function evictOxyAccountForestCache(
|
|
85
|
+
oxy: OxyAccountCacheEvictor,
|
|
86
|
+
accountId?: string,
|
|
87
|
+
): void {
|
|
88
|
+
oxy.clearCacheEntry(OXY_ACCOUNT_LIST_CACHE_KEY);
|
|
89
|
+
oxy.clearCacheByPrefix(OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX);
|
|
90
|
+
if (accountId) {
|
|
91
|
+
oxy.clearCacheEntry(oxyAccountDetailCacheKey(accountId));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE enumeration of `OxyServices` GET-cache keys that can carry a single
|
|
3
|
+
* account's identity, and the one sweep that clears them.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS ONE LIST
|
|
6
|
+
* --------------------
|
|
7
|
+
* An Oxy account is readable under SEVERAL cache keys, and a write that only
|
|
8
|
+
* busts the key it happens to know about leaves every other one serving the
|
|
9
|
+
* pre-write snapshot for up to its TTL — from the caller's OWN in-memory cache,
|
|
10
|
+
* with a perfectly healthy server. That failure has already shipped twice with
|
|
11
|
+
* two different sets of keys:
|
|
12
|
+
*
|
|
13
|
+
* - `updateAccount` busted `GET:/accounts/<id>` and the account lists, but a
|
|
14
|
+
* profile screen reads `GET:/profiles/username/<name>` and
|
|
15
|
+
* `GET:/users/<id>`, so a channel's new picture stayed invisible for the
|
|
16
|
+
* full 5-minute profile TTL.
|
|
17
|
+
* - `updateProfile` busted four of the six keys below, missing
|
|
18
|
+
* `GET:/auth/lookup/` (the login-flow avatar/display-name lookup) and
|
|
19
|
+
* `GET:/profiles/resolve` (handle resolution) — two independently-drifted
|
|
20
|
+
* copies of a list that has to agree.
|
|
21
|
+
*
|
|
22
|
+
* So the list lives here, once, and every writer calls
|
|
23
|
+
* {@link evictOxyIdentityCache}. Adding a new identity read means adding its key
|
|
24
|
+
* HERE and every writer inherits it.
|
|
25
|
+
*
|
|
26
|
+
* WHERE THE LINE IS DRAWN
|
|
27
|
+
* -----------------------
|
|
28
|
+
* These are the SINGLE-PROFILE reads — the account is the subject of the
|
|
29
|
+
* response and is addressable by id, handle, or session. Reads that merely
|
|
30
|
+
* CONTAIN an account among many (`GET:/profiles/search`,
|
|
31
|
+
* `GET:/users/<other>/followers`, `GET:/profiles/<other>/similar`) are
|
|
32
|
+
* deliberately NOT swept: an account cannot be located in them without the very
|
|
33
|
+
* lookup being invalidated, so sweeping them means sweeping the whole namespace
|
|
34
|
+
* on every identity change — a real cost on a backend consuming the
|
|
35
|
+
* cross-service invalidation signal, for a surface where a stale thumbnail
|
|
36
|
+
* expires on its own in ~2 minutes.
|
|
37
|
+
*
|
|
38
|
+
* WHY PREFIXES RATHER THAN EXACT KEYS
|
|
39
|
+
* -----------------------------------
|
|
40
|
+
* Only the by-id key can be built from a user id. The handle-keyed and
|
|
41
|
+
* session-keyed entries cannot — deriving a handle from an id needs the lookup
|
|
42
|
+
* we are invalidating, and the SDK never tracks active session ids centrally.
|
|
43
|
+
* Prefix sweeping is also what makes a USERNAME CHANGE correct: the entry under
|
|
44
|
+
* the OLD handle is unreachable by construction (nothing in the write response
|
|
45
|
+
* carries it), and a sweep targeted at the new handle alone would leave the old
|
|
46
|
+
* one serving the pre-rename profile until its TTL. Over-eviction costs a
|
|
47
|
+
* refetch; under-eviction serves wrong data.
|
|
48
|
+
*
|
|
49
|
+
* Platform-neutral by construction (no imports, no `OxyServices` reference) so
|
|
50
|
+
* the client mixins and the Node-only `@oxyhq/core/server` invalidation
|
|
51
|
+
* subscriber can share it without either pulling in the other.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The cache-eviction surface of an `OxyServices` instance. Declared
|
|
56
|
+
* structurally so this module stays free of any client import.
|
|
57
|
+
*/
|
|
58
|
+
export interface OxyIdentityCacheEvictor {
|
|
59
|
+
clearCacheEntry(key: string): void;
|
|
60
|
+
clearCacheByPrefix(prefix: string): number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Cache-key PREFIXES under which an account's identity can be served, for the
|
|
65
|
+
* reads whose key cannot be derived from a user id. Swept wholesale.
|
|
66
|
+
*/
|
|
67
|
+
export const OXY_IDENTITY_CACHE_PREFIXES: readonly string[] = [
|
|
68
|
+
// `getUserBySession` — keyed by session id, which the SDK never enumerates.
|
|
69
|
+
'GET:/session/user/',
|
|
70
|
+
// `getCurrentUser` (and `GET:/users/me/graph`, harmlessly included).
|
|
71
|
+
'GET:/users/me',
|
|
72
|
+
// `lookupUsername` — the pre-session login lookup; carries avatar + display name.
|
|
73
|
+
'GET:/auth/lookup/',
|
|
74
|
+
// `getProfileByUsername` — keyed by handle, including the pre-rename handle.
|
|
75
|
+
'GET:/profiles/username/',
|
|
76
|
+
// `resolveProfile` — keyed by fediverse handle in the query payload.
|
|
77
|
+
'GET:/profiles/resolve',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the exact cache key `getUserById` reads under. The only identity key
|
|
82
|
+
* derivable from a user id, so the only one that does not need a prefix sweep.
|
|
83
|
+
*/
|
|
84
|
+
export function oxyUserByIdCacheKey(userId: string): string {
|
|
85
|
+
return `GET:/users/${userId}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sweep an `OxyServices` GET response cache of everything that could carry the
|
|
90
|
+
* given account's identity.
|
|
91
|
+
*
|
|
92
|
+
* @param oxy - Anything exposing the SDK's two eviction methods.
|
|
93
|
+
* @param userId - The account whose by-id entry to drop. Optional: a caller
|
|
94
|
+
* that does not know the id still clears every handle-, session-
|
|
95
|
+
* and self-keyed entry, which is the majority of the surface.
|
|
96
|
+
*/
|
|
97
|
+
export function evictOxyIdentityCache(oxy: OxyIdentityCacheEvictor, userId?: string): void {
|
|
98
|
+
for (const prefix of OXY_IDENTITY_CACHE_PREFIXES) {
|
|
99
|
+
oxy.clearCacheByPrefix(prefix);
|
|
100
|
+
}
|
|
101
|
+
if (userId) {
|
|
102
|
+
oxy.clearCacheEntry(oxyUserByIdCacheKey(userId));
|
|
103
|
+
}
|
|
104
|
+
}
|