@oxyhq/core 17.1.0 → 19.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/boot/sessionColdBoot.js +4 -5
- package/dist/cjs/i18n/locales/en-US.json +48 -5
- package/dist/cjs/i18n/locales/es-ES.json +48 -5
- package/dist/cjs/i18n/locales/locales/en-US.json +48 -5
- package/dist/cjs/i18n/locales/locales/es-ES.json +48 -5
- package/dist/cjs/index.js +10 -6
- package/dist/cjs/mixins/OxyServices.accounts.js +27 -2
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +3 -2
- package/dist/cjs/mixins/OxyServices.user.js +14 -20
- package/dist/cjs/server/index.js +8 -2
- package/dist/cjs/server/userInvalidation.js +6 -28
- package/dist/cjs/session/refresh.js +9 -14
- package/dist/cjs/utils/identityCacheSweep.js +97 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +4 -5
- package/dist/esm/i18n/locales/en-US.json +48 -5
- package/dist/esm/i18n/locales/es-ES.json +48 -5
- package/dist/esm/i18n/locales/locales/en-US.json +48 -5
- package/dist/esm/i18n/locales/locales/es-ES.json +48 -5
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.accounts.js +22 -1
- package/dist/esm/mixins/OxyServices.deviceBoot.js +3 -2
- package/dist/esm/mixins/OxyServices.user.js +14 -20
- package/dist/esm/server/index.js +5 -1
- package/dist/esm/server/userInvalidation.js +5 -26
- package/dist/esm/session/refresh.js +9 -14
- package/dist/esm/utils/identityCacheSweep.js +92 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.accounts.d.ts +54 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +3 -2
- package/dist/types/mixins/OxyServices.user.d.ts +9 -7
- 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/refresh.d.ts +13 -18
- package/dist/types/utils/identityCacheSweep.d.ts +80 -0
- package/package.json +2 -2
- package/src/boot/sessionColdBoot.ts +4 -5
- package/src/i18n/locales/en-US.json +48 -5
- package/src/i18n/locales/es-ES.json +48 -5
- package/src/index.ts +8 -3
- package/src/mixins/OxyServices.accounts.ts +73 -12
- package/src/mixins/OxyServices.deviceBoot.ts +3 -2
- package/src/mixins/OxyServices.user.ts +14 -20
- package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +370 -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/refresh.ts +15 -20
- package/src/utils/__tests__/identityCacheSweep.test.ts +151 -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
|
-
}
|
package/src/session/refresh.ts
CHANGED
|
@@ -88,10 +88,10 @@ export interface RefreshDeps {
|
|
|
88
88
|
* The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
|
|
89
89
|
* the re-mint handler and the cold boot can react per the transport contract
|
|
90
90
|
* without re-classifying the raw error:
|
|
91
|
-
* - `ok` — minted, persisted
|
|
91
|
+
* - `ok` — minted, persisted `nextDeviceSecret`, planted the token.
|
|
92
92
|
* - `no-secret` — the store holds no `deviceId` + `deviceSecret` to mint from.
|
|
93
93
|
* - `invalid-secret` — 401 `invalid_device_secret`: the presented secret
|
|
94
|
-
*
|
|
94
|
+
* no longer matches the server's stored hash.
|
|
95
95
|
* - `no-session` — 401 `no_active_session`: the device is known but has no live
|
|
96
96
|
* session (authoritative signed-out).
|
|
97
97
|
* - `account-not-on-device` — 401 `account_not_on_device` for a PINNED mint: the
|
|
@@ -99,10 +99,10 @@ export interface RefreshDeps {
|
|
|
99
99
|
* The device secret is FINE — it is the identity binding that went stale, so
|
|
100
100
|
* the caller must re-establish from the local key, never drop the credential.
|
|
101
101
|
* - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
|
|
102
|
-
* - `persist-failed` — the mint succeeded
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
102
|
+
* - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
|
|
103
|
+
* durably persisted. The token is deliberately NOT planted: advertising a
|
|
104
|
+
* healthy session on a secret that will not survive a reload is exactly the
|
|
105
|
+
* divergence that logs users out.
|
|
106
106
|
*/
|
|
107
107
|
export type DeviceSecretMintOutcome =
|
|
108
108
|
| { status: 'ok'; token: string; sessionId: string; userId: string }
|
|
@@ -114,19 +114,14 @@ export type DeviceSecretMintOutcome =
|
|
|
114
114
|
| { status: 'persist-failed' };
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
* Arm 1 — the
|
|
118
|
-
*
|
|
117
|
+
* Arm 1 — the device-secret mint, run under the owning client's PROCESS-WIDE
|
|
118
|
+
* single-flight (`httpService.runSingleFlightDeviceSecretMint`).
|
|
119
119
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* SUPERSEDED secret — after the grace window the next cold boot mint 401s and the
|
|
126
|
-
* user is signed out. Routing EVERY lane through this one single-flight makes
|
|
127
|
-
* concurrent callers await the SAME in-flight mint and all receive its result, so
|
|
128
|
-
* there is exactly one rotation and the store always converges on the true
|
|
129
|
-
* `current` secret.
|
|
120
|
+
* Concurrent lanes (cold boot, the proactive scheduler, a request-time preflight,
|
|
121
|
+
* a 401 retry, the socket token transport, or a tab-focus reconcile) must not
|
|
122
|
+
* each persist a different view of the mint response. Routing EVERY lane through
|
|
123
|
+
* this one single-flight makes concurrent callers await the SAME in-flight mint
|
|
124
|
+
* and all receive its result, so the durable store converges on one credential.
|
|
130
125
|
*
|
|
131
126
|
* On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
|
|
132
127
|
* the access token; a failed durable persist yields `persist-failed` WITHOUT
|
|
@@ -200,8 +195,8 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
200
195
|
expiresAt: mint.expiresAt,
|
|
201
196
|
...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
|
|
202
197
|
};
|
|
203
|
-
//
|
|
204
|
-
//
|
|
198
|
+
// Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
|
|
199
|
+
// A failed durable persist must NOT plant.
|
|
205
200
|
const persistedOk = await store.save(next);
|
|
206
201
|
if (!persistedOk) {
|
|
207
202
|
return { status: 'persist-failed' };
|
|
@@ -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,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
|
+
}
|