@oxy.so/core 4.1.0 → 4.2.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/HttpService.js +142 -33
- package/dist/cjs/api/accounts.js +49 -10
- package/dist/cjs/api/apps.js +2 -2
- package/dist/cjs/api/auth.js +24 -3
- package/dist/cjs/api/billing.js +35 -0
- package/dist/cjs/boot/sessionColdBoot.js +28 -57
- package/dist/cjs/inference/OxyInferenceClient.js +23 -15
- package/dist/cjs/server/OxyServer.js +107 -51
- package/dist/cjs/server/agentAccount.js +19 -0
- package/dist/cjs/server/auth.js +19 -0
- package/dist/cjs/server/capabilityTicket.js +3 -41
- package/dist/cjs/server/index.js +10 -2
- package/dist/cjs/server/liveCapabilityTicket.js +48 -0
- package/dist/cjs/server/middleware.js +60 -18
- package/dist/cjs/server/namespaces.js +57 -0
- package/dist/cjs/server/serviceTokenLifetime.js +13 -0
- package/dist/cjs/session/SessionClient.js +90 -12
- package/dist/cjs/session/authStateStore.js +130 -39
- package/dist/cjs/session/refresh.js +152 -30
- package/dist/cjs/session/sharedDeviceCredential.js +17 -1
- package/dist/cjs/utils/officialOrigins.js +7 -18
- package/dist/esm/HttpService.js +143 -34
- package/dist/esm/api/accounts.js +38 -0
- package/dist/esm/api/apps.js +2 -2
- package/dist/esm/api/auth.js +23 -3
- package/dist/esm/api/billing.js +35 -0
- package/dist/esm/boot/sessionColdBoot.js +29 -58
- package/dist/esm/inference/OxyInferenceClient.js +21 -13
- package/dist/esm/server/OxyServer.js +107 -51
- package/dist/esm/server/agentAccount.js +16 -0
- package/dist/esm/server/auth.js +18 -0
- package/dist/esm/server/capabilityTicket.js +1 -39
- package/dist/esm/server/index.js +4 -1
- package/dist/esm/server/liveCapabilityTicket.js +45 -0
- package/dist/esm/server/middleware.js +60 -18
- package/dist/esm/server/namespaces.js +57 -0
- package/dist/esm/server/serviceTokenLifetime.js +9 -0
- package/dist/esm/session/SessionClient.js +90 -12
- package/dist/esm/session/authStateStore.js +128 -38
- package/dist/esm/session/refresh.js +151 -30
- package/dist/esm/session/sharedDeviceCredential.js +17 -1
- package/dist/esm/utils/officialOrigins.js +2 -13
- package/dist/types/HttpService.d.ts +26 -12
- package/dist/types/api/accounts.d.ts +38 -0
- package/dist/types/api/agency.d.ts +3 -1
- package/dist/types/api/apps.d.ts +3 -1
- package/dist/types/api/auth.d.ts +12 -1
- package/dist/types/api/billing.d.ts +12 -0
- package/dist/types/api/session.d.ts +3 -1
- package/dist/types/boot/sessionColdBoot.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/inference/OxyInferenceClient.d.ts +9 -0
- package/dist/types/inference/index.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +16 -9
- package/dist/types/server/agentAccount.d.ts +14 -0
- package/dist/types/server/auth.d.ts +18 -0
- package/dist/types/server/capabilityTicket.d.ts +2 -4
- package/dist/types/server/index.d.ts +7 -1
- package/dist/types/server/liveCapabilityTicket.d.ts +15 -0
- package/dist/types/server/middleware.d.ts +15 -6
- package/dist/types/server/namespaces.d.ts +78 -2
- package/dist/types/server/serviceTokenLifetime.d.ts +7 -0
- package/dist/types/session/SessionClient.d.ts +8 -0
- package/dist/types/session/authStateStore.d.ts +15 -0
- package/dist/types/session/index.d.ts +1 -1
- package/dist/types/session/refresh.d.ts +32 -7
- package/dist/types/session/sharedDeviceCredential.d.ts +3 -1
- package/dist/types/utils/officialOrigins.d.ts +1 -5
- package/package.json +6 -6
- package/src/HttpService.ts +138 -50
- package/src/api/accounts.ts +22 -0
- package/src/api/agency.ts +3 -1
- package/src/api/apps.ts +2 -2
- package/src/api/auth.ts +31 -2
- package/src/api/billing.ts +71 -9
- package/src/api/session.ts +3 -1
- package/src/boot/sessionColdBoot.ts +27 -72
- package/src/index.ts +1 -1
- package/src/inference/OxyInferenceClient.ts +19 -5
- package/src/inference/index.ts +1 -0
- package/src/server/OxyServer.ts +114 -53
- package/src/server/agentAccount.ts +28 -0
- package/src/server/auth.ts +25 -0
- package/src/server/capabilityTicket.ts +2 -42
- package/src/server/index.ts +13 -0
- package/src/server/liveCapabilityTicket.ts +47 -0
- package/src/server/middleware.ts +72 -25
- package/src/server/namespaces.ts +99 -2
- package/src/server/serviceTokenLifetime.ts +13 -0
- package/src/session/SessionClient.ts +85 -12
- package/src/session/authStateStore.ts +130 -46
- package/src/session/index.ts +1 -0
- package/src/session/refresh.ts +144 -28
- package/src/session/sharedDeviceCredential.ts +14 -2
- package/src/utils/officialOrigins.ts +2 -13
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.REMINT_AFTER_EXPIRY_MS = exports.TOKEN_REFRESH_LEAD_MS = void 0;
|
|
4
4
|
exports.refreshDeviceSecretArm = refreshDeviceSecretArm;
|
|
5
|
+
exports.refreshSharedDeviceArm = refreshSharedDeviceArm;
|
|
5
6
|
exports.refreshPersistedSession = refreshPersistedSession;
|
|
6
7
|
exports.createAuthRefreshHandler = createAuthRefreshHandler;
|
|
7
8
|
exports.installAuthRefreshHandler = installAuthRefreshHandler;
|
|
8
9
|
exports.startTokenRefreshScheduler = startTokenRefreshScheduler;
|
|
10
|
+
const authStateStore_1 = require("./authStateStore");
|
|
9
11
|
const identitySession_1 = require("./identitySession");
|
|
10
12
|
const platform_1 = require("../utils/platform");
|
|
11
13
|
const errorUtils_1 = require("../utils/errorUtils");
|
|
@@ -65,9 +67,13 @@ exports.REMINT_AFTER_EXPIRY_MS = 2000;
|
|
|
65
67
|
*
|
|
66
68
|
* On success it persists `nextDeviceSecret` (read-back-verified) BEFORE planting
|
|
67
69
|
* the access token; a failed durable persist yields `persist-failed` WITHOUT
|
|
68
|
-
* planting.
|
|
70
|
+
* planting. Apart from the account-mode logout marker below, failure mutations
|
|
71
|
+
* belong to the caller, which
|
|
69
72
|
* applies the drop/clear policy (which differs web vs native) from the returned
|
|
70
|
-
* status.
|
|
73
|
+
* status. A native account store with a suppression marker records a current
|
|
74
|
+
* `no_active_session`, or an invalid secret with prior authenticated session
|
|
75
|
+
* history, records durable ACCOUNT logout intent; it never deletes that holder.
|
|
76
|
+
* Error classification uses the existing 401 message markers.
|
|
71
77
|
*
|
|
72
78
|
* `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
|
|
73
79
|
* `accountId` (so the server mints that account's token without touching
|
|
@@ -78,7 +84,10 @@ exports.REMINT_AFTER_EXPIRY_MS = 2000;
|
|
|
78
84
|
async function refreshDeviceSecretArm(deps) {
|
|
79
85
|
const { oxy, store } = deps;
|
|
80
86
|
const pin = deps.pin ?? null;
|
|
87
|
+
const isCurrent = deps.isCurrent ?? (() => true);
|
|
81
88
|
return oxy.http.runSingleFlightDeviceSecretMint(async () => {
|
|
89
|
+
if (!isCurrent())
|
|
90
|
+
return { status: 'session-ended' };
|
|
82
91
|
const epoch = oxy.http.getSessionEpoch();
|
|
83
92
|
const persisted = await store.load();
|
|
84
93
|
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
@@ -120,10 +129,29 @@ async function refreshDeviceSecretArm(deps) {
|
|
|
120
129
|
// transient and KEEP the credential so a later attempt self-heals. Wiping the
|
|
121
130
|
// credential on an ambiguous 401 is what logged users out on every deploy,
|
|
122
131
|
// ecosystem-wide.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
132
|
+
const invalidSecret = body.includes('invalid_device_secret');
|
|
133
|
+
const noSession = body.includes('no_active_session');
|
|
134
|
+
if (invalidSecret || noSession) {
|
|
135
|
+
// A stopped ACCOUNT sibling cannot receive the other app's logout
|
|
136
|
+
// push. Its exact live holder's authoritative empty-device rejection
|
|
137
|
+
// must suppress automatic key recovery on this and future boots.
|
|
138
|
+
// A late rejection for a replaced session/local epoch is not current.
|
|
139
|
+
if (pin === null && store.setAutomaticIdentitySignInSuppressed && (!invalidSecret || (persisted.sessionId && persisted.userId))) {
|
|
140
|
+
const current = await store.load();
|
|
141
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch ||
|
|
142
|
+
current?.deviceId !== persisted.deviceId ||
|
|
143
|
+
current.deviceSecret !== persisted.deviceSecret ||
|
|
144
|
+
current.sessionId !== persisted.sessionId ||
|
|
145
|
+
current.userId !== persisted.userId ||
|
|
146
|
+
current.accessToken !== persisted.accessToken)
|
|
147
|
+
return { status: 'session-ended' };
|
|
148
|
+
if (!(await store.setAutomaticIdentitySignInSuppressed(true, {
|
|
149
|
+
expectedState: persisted, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch,
|
|
150
|
+
})))
|
|
151
|
+
return { status: 'session-ended' };
|
|
152
|
+
}
|
|
153
|
+
return { status: invalidSecret ? 'invalid-secret' : 'no-session' };
|
|
154
|
+
}
|
|
127
155
|
// A pinned mint whose account left the device set. The secret is intact —
|
|
128
156
|
// never classify this as a bad secret, or the caller would drop a healthy
|
|
129
157
|
// credential over a stale identity binding.
|
|
@@ -145,24 +173,33 @@ async function refreshDeviceSecretArm(deps) {
|
|
|
145
173
|
expiresAt: mint.expiresAt,
|
|
146
174
|
...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
|
|
147
175
|
};
|
|
176
|
+
if (!isCurrent())
|
|
177
|
+
return { status: 'session-ended' };
|
|
148
178
|
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
179
|
+
// A logout or account/session switch invalidated this mint. Preserve
|
|
180
|
+
// only credential rotation for an otherwise unchanged retained context;
|
|
181
|
+
// never transplant the old mint's account, session or bearer. Compare
|
|
182
|
+
// the entire snapshot inside the store queue: same holder bytes alone
|
|
183
|
+
// cannot distinguish an account switch, nor a queued save of that switch.
|
|
184
|
+
const retainedEpoch = oxy.http.getSessionEpoch();
|
|
153
185
|
const current = await store.load();
|
|
154
|
-
if (
|
|
155
|
-
await store.
|
|
186
|
+
if (store.saveIfCurrent && current && (0, authStateStore_1.matchesAuthState)(current, persisted)) {
|
|
187
|
+
await store.saveIfCurrent({ ...current, deviceSecret: mint.nextDeviceSecret }, {
|
|
188
|
+
expectedState: current,
|
|
189
|
+
isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === retainedEpoch,
|
|
190
|
+
});
|
|
156
191
|
}
|
|
157
192
|
return { status: 'session-ended' };
|
|
158
193
|
}
|
|
159
194
|
// Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
|
|
160
195
|
// A failed durable persist must NOT plant.
|
|
161
|
-
const persistedOk =
|
|
196
|
+
const persistedOk = store.saveIfCurrent
|
|
197
|
+
? await store.saveIfCurrent(next, { expectedState: persisted, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch })
|
|
198
|
+
: await store.save(next);
|
|
162
199
|
if (!persistedOk) {
|
|
163
200
|
return { status: 'persist-failed' };
|
|
164
201
|
}
|
|
165
|
-
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
202
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch) {
|
|
166
203
|
return { status: 'session-ended' };
|
|
167
204
|
}
|
|
168
205
|
oxy.session.setAccessToken(mint.accessToken);
|
|
@@ -175,15 +212,76 @@ async function refreshDeviceSecretArm(deps) {
|
|
|
175
212
|
};
|
|
176
213
|
});
|
|
177
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Prove a shared native ACCOUNT holder before replacing local bytes. Callers
|
|
217
|
+
* may replace a local credential only after its explicit mint rejection. The
|
|
218
|
+
* optional guarded storage seam makes custom stores fail closed on adoption.
|
|
219
|
+
* This lane never clears explicit sign-out intent or uses an identity key.
|
|
220
|
+
*/
|
|
221
|
+
async function refreshSharedDeviceArm(deps) {
|
|
222
|
+
const { oxy, store, shared } = deps;
|
|
223
|
+
const isCurrent = deps.isCurrent ?? (() => true);
|
|
224
|
+
return oxy.http.runSingleFlightDeviceSecretMint(async () => {
|
|
225
|
+
const epoch = oxy.http.getSessionEpoch();
|
|
226
|
+
if (!isCurrent())
|
|
227
|
+
return { status: 'session-ended' };
|
|
228
|
+
const before = await store.load();
|
|
229
|
+
if (before?.deviceId && before.deviceSecret && !deps.rejectedLocalHolder)
|
|
230
|
+
return { status: 'no-secret' };
|
|
231
|
+
const read = await shared.read();
|
|
232
|
+
if (read.state !== 'present' || !store.saveIfCurrent)
|
|
233
|
+
return { status: 'no-secret' };
|
|
234
|
+
const credential = read.credential;
|
|
235
|
+
if (credential.deviceId === before?.deviceId && credential.deviceSecret === before.deviceSecret)
|
|
236
|
+
return { status: 'no-secret' };
|
|
237
|
+
let mint;
|
|
238
|
+
try {
|
|
239
|
+
mint = await oxy.devices.mintToken(credential.deviceId, credential.deviceSecret);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
if ((0, errorUtils_1.extractErrorStatus)(error) === 429)
|
|
243
|
+
oxy.http.noteRefreshRateLimited();
|
|
244
|
+
if ((0, errorUtils_1.extractErrorStatus)(error) === 401) {
|
|
245
|
+
const message = error?.message;
|
|
246
|
+
if (typeof message === 'string' && message.includes('invalid_device_secret')) {
|
|
247
|
+
// There is no cross-app CAS clear in the bridge contract. An old
|
|
248
|
+
// rejection cannot delete a slot another app has since published.
|
|
249
|
+
return { status: 'invalid-secret' };
|
|
250
|
+
}
|
|
251
|
+
if (typeof message === 'string' && message.includes('no_active_session'))
|
|
252
|
+
return { status: 'no-session' };
|
|
253
|
+
}
|
|
254
|
+
return { status: 'transient' };
|
|
255
|
+
}
|
|
256
|
+
const bound = mint.state.accounts.find(account => account.accountId === mint.state.activeAccountId);
|
|
257
|
+
if (!bound || mint.state.deviceId !== credential.deviceId || !mint.accessToken || !mint.nextDeviceSecret)
|
|
258
|
+
return { status: 'transient' };
|
|
259
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch || !(0, authStateStore_1.matchesAuthState)(await store.load(), before))
|
|
260
|
+
return { status: 'session-ended' };
|
|
261
|
+
const next = {
|
|
262
|
+
deviceId: mint.state.deviceId, deviceSecret: mint.nextDeviceSecret,
|
|
263
|
+
sessionId: bound.sessionId, userId: bound.accountId,
|
|
264
|
+
accessToken: mint.accessToken, expiresAt: mint.expiresAt,
|
|
265
|
+
};
|
|
266
|
+
if (!(await store.saveIfCurrent(next, { expectedState: before, isCurrent: () => isCurrent() && oxy.http.getSessionEpoch() === epoch })))
|
|
267
|
+
return { status: 'persist-failed' };
|
|
268
|
+
if (!isCurrent() || oxy.http.getSessionEpoch() !== epoch)
|
|
269
|
+
return { status: 'session-ended' };
|
|
270
|
+
oxy.session.setAccessToken(mint.accessToken);
|
|
271
|
+
return { status: 'ok', token: mint.accessToken, sessionId: bound.sessionId, userId: bound.accountId, state: mint.state };
|
|
272
|
+
});
|
|
273
|
+
}
|
|
178
274
|
/**
|
|
179
275
|
* Re-mint the persisted session and return the fresh access token, or `null`
|
|
180
276
|
* when no arm could produce one.
|
|
181
277
|
*
|
|
182
278
|
* Arm 1 (`POST /session/device/token`, via {@link refreshDeviceSecretArm}): mint
|
|
183
|
-
* from the persisted `deviceId` + `deviceSecret`.
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
279
|
+
* from the persisted `deviceId` + `deviceSecret`. An authenticated native
|
|
280
|
+
* holder's invalid-secret or `no_active_session` verdict suppresses key recovery
|
|
281
|
+
* while permitting a separately proved new shared holder. The latter verdict
|
|
282
|
+
* retains that holder and ends ACCOUNT recovery without a new key challenge.
|
|
283
|
+
* Classification still uses the existing 401 message markers, not a new parser.
|
|
284
|
+
* A transient error — or a durable-persist failure — leaves the store and
|
|
187
285
|
* returns `null` WITHOUT falling to arm 2 (those are not bad-secret signals).
|
|
188
286
|
*
|
|
189
287
|
* Arm 2 (native Commons identity): when the secret is absent or was just
|
|
@@ -200,9 +298,11 @@ async function refreshDeviceSecretArm(deps) {
|
|
|
200
298
|
*/
|
|
201
299
|
async function refreshPersistedSession(deps) {
|
|
202
300
|
const { oxy, store } = deps;
|
|
301
|
+
if (deps.isCurrent && !deps.isCurrent())
|
|
302
|
+
return null;
|
|
203
303
|
const identity = deps.identity ?? null;
|
|
204
304
|
// The shared identity is never an identity-bound client's recovery path.
|
|
205
|
-
const allowCommonsIdentityFallback = identity ? false : (deps.allowCommonsIdentityFallback ?? (0, platform_1.isNative)());
|
|
305
|
+
const allowCommonsIdentityFallback = identity ? false : (deps.allowCommonsIdentityFallback ?? (0, platform_1.isNative)()) && !(await store.isAutomaticIdentitySignInSuppressed?.());
|
|
206
306
|
const epoch = oxy.http.getSessionEpoch();
|
|
207
307
|
// Resolved per call: a re-established identity session can move the pin, and a
|
|
208
308
|
// replaced/removed local key clears it (in which case arm 1 must NOT mint —
|
|
@@ -211,7 +311,7 @@ async function refreshPersistedSession(deps) {
|
|
|
211
311
|
if (identity && !pin) {
|
|
212
312
|
return recoverIdentitySession(oxy, store, identity);
|
|
213
313
|
}
|
|
214
|
-
const arm1 = await refreshDeviceSecretArm({ oxy, store, pin });
|
|
314
|
+
const arm1 = await refreshDeviceSecretArm({ oxy, store, pin, isCurrent: deps.isCurrent });
|
|
215
315
|
switch (arm1.status) {
|
|
216
316
|
case 'ok':
|
|
217
317
|
return arm1.token;
|
|
@@ -229,6 +329,28 @@ async function refreshPersistedSession(deps) {
|
|
|
229
329
|
return null;
|
|
230
330
|
case 'invalid-secret':
|
|
231
331
|
case 'no-session': {
|
|
332
|
+
if (!identity && deps.sharedDeviceCredential) {
|
|
333
|
+
const joined = await refreshSharedDeviceArm({ oxy, store, shared: deps.sharedDeviceCredential, rejectedLocalHolder: true, isCurrent: deps.isCurrent });
|
|
334
|
+
if (joined.status === 'ok')
|
|
335
|
+
return joined.token;
|
|
336
|
+
if (joined.status === 'session-ended' || joined.status === 'persist-failed')
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
if (!identity && arm1.status === 'invalid-secret' && await store.isAutomaticIdentitySignInSuppressed?.())
|
|
340
|
+
return null;
|
|
341
|
+
if (arm1.status === 'no-session' && !identity) {
|
|
342
|
+
if (store.setAutomaticIdentitySignInSuppressed)
|
|
343
|
+
return null;
|
|
344
|
+
// The holder is still valid, but its account session ended. Retain only
|
|
345
|
+
// the holder for a later deliberate/shared sign-in; never use the key
|
|
346
|
+
// to turn this signed-out verdict into a new account session.
|
|
347
|
+
const persisted = await store.load();
|
|
348
|
+
if (persisted?.deviceId && persisted.deviceSecret) {
|
|
349
|
+
await store.save({ sessionId: '', userId: '', deviceId: persisted.deviceId, deviceSecret: persisted.deviceSecret });
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
// Identity mode retains its separate pinned-key recovery contract.
|
|
232
354
|
// 401: secret diverged or no live session. When a key-based arm 2 can still
|
|
233
355
|
// recover (the native Commons identity, or an identity-bound client's own
|
|
234
356
|
// primary key) drop ONLY the secret and keep the deviceId; otherwise (web) the
|
|
@@ -239,13 +361,6 @@ async function refreshPersistedSession(deps) {
|
|
|
239
361
|
await store.save({ ...persisted, deviceSecret: undefined });
|
|
240
362
|
}
|
|
241
363
|
}
|
|
242
|
-
else if (arm1.status === 'no-session' && persisted?.deviceId && persisted.deviceSecret) {
|
|
243
|
-
// Web, a credential the server still recognises: this origin stays a
|
|
244
|
-
// holder of the browser's device (ADR 0029 D2) — the next sign-in in any
|
|
245
|
-
// app lands on it, and this app follows without opening the bridge
|
|
246
|
-
// again. Only the session fields go.
|
|
247
|
-
await store.save({ sessionId: '', userId: '', deviceId: persisted.deviceId, deviceSecret: persisted.deviceSecret });
|
|
248
|
-
}
|
|
249
364
|
else {
|
|
250
365
|
await store.clear();
|
|
251
366
|
}
|
|
@@ -257,6 +372,13 @@ async function refreshPersistedSession(deps) {
|
|
|
257
372
|
logger_1.logger.debug('Pinned device-secret mint rejected: the pinned account is no longer on this device — re-establishing from the identity key', { component: 'refresh', method: 'refreshPersistedSession' });
|
|
258
373
|
break;
|
|
259
374
|
case 'no-secret':
|
|
375
|
+
if (!identity && deps.sharedDeviceCredential) {
|
|
376
|
+
const joined = await refreshSharedDeviceArm({ oxy, store, shared: deps.sharedDeviceCredential, isCurrent: deps.isCurrent });
|
|
377
|
+
if (joined.status === 'ok')
|
|
378
|
+
return joined.token;
|
|
379
|
+
if (joined.status === 'session-ended' || joined.status === 'persist-failed' || joined.status === 'transient')
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
260
382
|
break;
|
|
261
383
|
}
|
|
262
384
|
if (identity) {
|
|
@@ -264,14 +386,14 @@ async function refreshPersistedSession(deps) {
|
|
|
264
386
|
}
|
|
265
387
|
// Never after a sign-out: the shared identity is a KEY, not a session, and
|
|
266
388
|
// proving it here would sign the user straight back in.
|
|
267
|
-
if (allowCommonsIdentityFallback && !oxy.http.hasSessionEnded()) {
|
|
389
|
+
if (allowCommonsIdentityFallback && (deps.isCurrent?.() ?? true) && !oxy.http.hasSessionEnded() && !(await store.isAutomaticIdentitySignInSuppressed?.())) {
|
|
268
390
|
try {
|
|
269
391
|
// Planted here, not by the sign-in: a sign-out that lands while the
|
|
270
392
|
// challenge round-trips must win, or the identity proof signs the user
|
|
271
393
|
// straight back in.
|
|
272
394
|
const session = await oxy.auth.signInWithCommonsIdentity({ plantTokens: false });
|
|
273
395
|
if (session?.accessToken) {
|
|
274
|
-
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
396
|
+
if (!(deps.isCurrent?.() ?? true) || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
|
|
275
397
|
return null;
|
|
276
398
|
}
|
|
277
399
|
// Repopulate the fast device-secret lane from the identity re-mint.
|
|
@@ -285,7 +407,7 @@ async function refreshPersistedSession(deps) {
|
|
|
285
407
|
expiresAt: session.expiresAt,
|
|
286
408
|
});
|
|
287
409
|
}
|
|
288
|
-
if (oxy.http.getSessionEpoch() !== epoch) {
|
|
410
|
+
if (!(deps.isCurrent?.() ?? true) || oxy.http.getSessionEpoch() !== epoch || await store.isAutomaticIdentitySignInSuppressed?.()) {
|
|
289
411
|
return null;
|
|
290
412
|
}
|
|
291
413
|
oxy.session.setAccessToken(session.accessToken);
|
|
@@ -201,7 +201,9 @@ async function publishProvenDeviceCredential(deps) {
|
|
|
201
201
|
* seam, no lane left out, and no new call site to forget.
|
|
202
202
|
* - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
|
|
203
203
|
* a storage primitive, and would run on every `load()`. Adoption is an
|
|
204
|
-
* explicit
|
|
204
|
+
* explicit cold-boot/resume recovery instead; an existing local holder must
|
|
205
|
+
* first be rejected by the server, and a new shared holder must mint before
|
|
206
|
+
* conditional persistence.
|
|
205
207
|
*
|
|
206
208
|
* `clear()` deliberately does NOT clear the shared slot. This app signing out is
|
|
207
209
|
* not authority over the device-wide join point: other apps may still be signed
|
|
@@ -216,8 +218,22 @@ function createSharedMirroringAuthStateStore(deps) {
|
|
|
216
218
|
// next launch rather than mid-process.
|
|
217
219
|
let mirrored = null;
|
|
218
220
|
return {
|
|
221
|
+
...local,
|
|
219
222
|
load: () => local.load(),
|
|
220
223
|
clear: () => local.clear(),
|
|
224
|
+
...(local.saveIfCurrent ? { saveIfCurrent: async (state, guard) => {
|
|
225
|
+
const committed = await local.saveIfCurrent?.(state, guard);
|
|
226
|
+
if (!committed)
|
|
227
|
+
return false;
|
|
228
|
+
const credential = readLocalDeviceCredential(state);
|
|
229
|
+
if (credential && guard.isCurrent()) {
|
|
230
|
+
try {
|
|
231
|
+
await publishProvenDeviceCredential({ shared, credential });
|
|
232
|
+
}
|
|
233
|
+
catch { /* Local durability already verified. */ }
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
} } : {}),
|
|
221
237
|
save: async (state) => {
|
|
222
238
|
// The durable local write is the contract this store owes its caller —
|
|
223
239
|
// run it first and report ITS result, unchanged. The mirror is additive.
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isAllowedDeviceJoinOrigin = exports.isLoopbackOrigin = void 0;
|
|
4
|
+
exports.isOfficialWebOrigin = isOfficialWebOrigin;
|
|
5
|
+
const contracts_1 = require("@oxy.so/contracts");
|
|
6
|
+
var contracts_2 = require("@oxy.so/contracts");
|
|
7
|
+
Object.defineProperty(exports, "isLoopbackOrigin", { enumerable: true, get: function () { return contracts_2.isLoopbackOrigin; } });
|
|
2
8
|
/**
|
|
3
9
|
* Official first-party web origin allowlist — shared by OAuth redirect
|
|
4
10
|
* validation and the server-side trusted-origin checks.
|
|
5
11
|
*/
|
|
6
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.isAllowedDeviceJoinOrigin = void 0;
|
|
8
|
-
exports.isLoopbackOrigin = isLoopbackOrigin;
|
|
9
|
-
exports.isOfficialWebOrigin = isOfficialWebOrigin;
|
|
10
12
|
const authWebUrl_1 = require("./authWebUrl");
|
|
11
13
|
const registrableApex_1 = require("./registrableApex");
|
|
12
14
|
/** Official first-party registrable apexes (mirrors API trusted origins). */
|
|
@@ -26,22 +28,9 @@ const OFFICIAL_APEXES = new Set([
|
|
|
26
28
|
* Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
|
|
27
29
|
* or `[::1]` on any port, http or https).
|
|
28
30
|
*/
|
|
29
|
-
function isLoopbackOrigin(origin) {
|
|
30
|
-
try {
|
|
31
|
-
const parsed = new URL(origin);
|
|
32
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
const host = parsed.hostname.toLowerCase();
|
|
36
|
-
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
31
|
/** Whether an origin belongs to the official Oxy web ecosystem. */
|
|
43
32
|
function isOfficialWebOrigin(origin) {
|
|
44
|
-
if (isLoopbackOrigin(origin)) {
|
|
33
|
+
if ((0, contracts_1.isLoopbackOrigin)(origin)) {
|
|
45
34
|
return true;
|
|
46
35
|
}
|
|
47
36
|
try {
|
package/dist/esm/HttpService.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { TTLCache, registerCacheForCleanup, unregisterCacheFromCleanup } from './utils/cache.js';
|
|
16
16
|
import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/requestUtils.js';
|
|
17
17
|
import { retryAsync } from './utils/asyncUtils.js';
|
|
18
|
-
import { createCancelledError, ErrorCodes, handleHttpError, parseHttpErrorBody } from './utils/errorUtils.js';
|
|
18
|
+
import { createCancelledError, ErrorCodes, handleHttpError, isCancelledError, parseHttpErrorBody } from './utils/errorUtils.js';
|
|
19
19
|
import { decodeTokenClaims } from './utils/tokenClaims.js';
|
|
20
20
|
import { isReactNative } from '@oxy.so/protocol/random';
|
|
21
21
|
import { computeIdentityTag, fnv1a32 } from './utils/cacheKey.js';
|
|
@@ -24,6 +24,35 @@ import { OxyAuthenticationError } from './OxyServices.errors.js';
|
|
|
24
24
|
import { peekBrowserEdgeRegionHeader } from './utils/edgeRegion.js';
|
|
25
25
|
import { getBrowserActivityIdHeader } from './utils/activityId.js';
|
|
26
26
|
import { trimLeadingSlashes, trimTrailingSlashes } from './utils/slashes.js';
|
|
27
|
+
/** Comparison only fences local async work; it never validates token authority. */
|
|
28
|
+
function sameTokenSession(left, right) {
|
|
29
|
+
if (left === right)
|
|
30
|
+
return true;
|
|
31
|
+
if (!left || !right)
|
|
32
|
+
return false;
|
|
33
|
+
const a = decodeTokenClaims(left);
|
|
34
|
+
const b = decodeTokenClaims(right);
|
|
35
|
+
if (!a || !b || !(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub))
|
|
36
|
+
return false;
|
|
37
|
+
// A refresh may rotate exp/iat/jti while preserving its account and session.
|
|
38
|
+
// Opaque/malformed context fields cannot establish that equivalence.
|
|
39
|
+
return ['userId', 'id', 'sub', 'sessionId', 'operatorId', 'clientId', 'appId', 'authMethodId'].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
|
|
40
|
+
}
|
|
41
|
+
/** Cancel this wait only; the shared mint continues for other callers. */
|
|
42
|
+
function awaitRequestSignal(pending, signal) {
|
|
43
|
+
if (!signal)
|
|
44
|
+
return pending;
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const onAbort = () => { reject(createCancelledError()); };
|
|
47
|
+
if (signal.aborted)
|
|
48
|
+
onAbort();
|
|
49
|
+
else
|
|
50
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
51
|
+
// Both handlers remain attached even after cancellation, so a late shared
|
|
52
|
+
// failure is observed without reviving this request or becoming unhandled.
|
|
53
|
+
pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
27
56
|
/** Methods whose calls concurrent callers may share (read-only). */
|
|
28
57
|
const SAFE_METHODS = new Set(['GET']);
|
|
29
58
|
/** Methods safe to re-send after a transient failure (RFC 9110 §9.2.2). */
|
|
@@ -302,8 +331,7 @@ export class HttpService {
|
|
|
302
331
|
const currentToken = this.tokenStore.getAccessToken();
|
|
303
332
|
if (providedToken) {
|
|
304
333
|
if (providedToken !== currentToken) {
|
|
305
|
-
this.
|
|
306
|
-
this.notifyTokenChange();
|
|
334
|
+
this.setTokens(providedToken);
|
|
307
335
|
}
|
|
308
336
|
return providedToken;
|
|
309
337
|
}
|
|
@@ -1011,8 +1039,12 @@ export class HttpService {
|
|
|
1011
1039
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1012
1040
|
.then((newToken) => {
|
|
1013
1041
|
if (epoch !== this.sessionEpoch) {
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1042
|
+
// A guarded refresh handler can itself adopt a new active context.
|
|
1043
|
+
// Acknowledge only an already-installed, exact result; never plant
|
|
1044
|
+
// a stale return value over a concurrent switch/logout.
|
|
1045
|
+
if (newToken && !this.sessionEnded && this.tokenStore.getAccessToken() === newToken) {
|
|
1046
|
+
return newToken;
|
|
1047
|
+
}
|
|
1016
1048
|
this.logger.debug('Discarded a token refresh that outlived its session');
|
|
1017
1049
|
return null;
|
|
1018
1050
|
}
|
|
@@ -1023,8 +1055,7 @@ export class HttpService {
|
|
|
1023
1055
|
// A token is planted again, so there is a session again.
|
|
1024
1056
|
this.sessionEnded = false;
|
|
1025
1057
|
if (this.tokenStore.getAccessToken() !== newToken) {
|
|
1026
|
-
this.
|
|
1027
|
-
this.notifyTokenChange();
|
|
1058
|
+
this.setTokens(newToken);
|
|
1028
1059
|
}
|
|
1029
1060
|
this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
|
|
1030
1061
|
// A success clears the failure timestamp so the next refresh is never
|
|
@@ -1180,23 +1211,61 @@ export class HttpService {
|
|
|
1180
1211
|
* abort signal defines the lifetime of a healthy stream.
|
|
1181
1212
|
*/
|
|
1182
1213
|
async requestAuthenticatedResponse(config) {
|
|
1183
|
-
|
|
1184
|
-
|
|
1214
|
+
return this.requestRawResponse(config, true);
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Raw response for public or authenticated app endpoints. The SDK supplies
|
|
1218
|
+
* its current bearer when available; caller headers never supply authority.
|
|
1219
|
+
* No caching, envelope transformation or transport-error retry is performed.
|
|
1220
|
+
*/
|
|
1221
|
+
async requestResponse(config) {
|
|
1222
|
+
return this.requestRawResponse(config, false);
|
|
1223
|
+
}
|
|
1224
|
+
async requestRawResponse(config, requireAuth) {
|
|
1225
|
+
// Validate before obtaining a bearer: an unrelated origin must not trigger
|
|
1226
|
+
// an auth mint, even when the trusted transport accepts absolute URLs.
|
|
1227
|
+
const target = new URL(this.buildURL(config.url));
|
|
1228
|
+
const base = new URL(this.baseURL);
|
|
1229
|
+
if (target.origin !== base.origin || !['http:', 'https:'].includes(target.protocol)
|
|
1230
|
+
|| target.username || target.password) {
|
|
1231
|
+
throw new Error('Raw response requests must target the configured API origin');
|
|
1232
|
+
}
|
|
1233
|
+
if (config.signal?.aborted)
|
|
1234
|
+
throw createCancelledError();
|
|
1235
|
+
// Snapshot caller-owned options once, including headers, before any await.
|
|
1236
|
+
const request = { ...config, headers: new Headers(config.headers) };
|
|
1237
|
+
const contextToken = this.syncAccessTokenFromProvider();
|
|
1238
|
+
const beforeAuthEpoch = this.sessionEpoch;
|
|
1239
|
+
const authHeader = await awaitRequestSignal(this.getAuthHeader(), config.signal);
|
|
1240
|
+
if (beforeAuthEpoch !== this.sessionEpoch || !sameTokenSession(contextToken, this.tokenStore.getAccessToken())) {
|
|
1241
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
1242
|
+
}
|
|
1243
|
+
if (config.signal?.aborted)
|
|
1244
|
+
throw createCancelledError();
|
|
1245
|
+
if (requireAuth && !authHeader) {
|
|
1185
1246
|
throw new OxyAuthenticationError('An active Oxy session is required for this request', 'AUTH_REQUIRED');
|
|
1186
1247
|
}
|
|
1187
|
-
return this.
|
|
1248
|
+
return this.requestResponseAttempt(request, target.href, authHeader, false, this.sessionEpoch, contextToken);
|
|
1188
1249
|
}
|
|
1189
|
-
async
|
|
1250
|
+
async requestResponseAttempt(config, fullUrl, authHeader, isAuthRetry, epoch, contextToken) {
|
|
1251
|
+
const assertCurrent = () => {
|
|
1252
|
+
this.syncAccessTokenFromProvider();
|
|
1253
|
+
if (epoch !== this.sessionEpoch || !sameTokenSession(contextToken, this.tokenStore.getAccessToken())) {
|
|
1254
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1190
1257
|
const startTime = Date.now();
|
|
1258
|
+
if (config.signal?.aborted)
|
|
1259
|
+
throw createCancelledError();
|
|
1260
|
+
assertCurrent();
|
|
1191
1261
|
const headers = new Headers(config.headers);
|
|
1192
|
-
if (!headers.has('Accept'))
|
|
1262
|
+
if (!headers.has('Accept'))
|
|
1193
1263
|
headers.set('Accept', 'application/json');
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
// to a second in front of requests.
|
|
1264
|
+
// Never accept ambient credentials or another authority from a caller.
|
|
1265
|
+
for (const name of ['Authorization', 'Cookie', 'Cookie2', 'Host', 'Proxy-Authorization'])
|
|
1266
|
+
headers.delete(name);
|
|
1267
|
+
if (authHeader)
|
|
1268
|
+
headers.set('Authorization', authHeader);
|
|
1200
1269
|
const edgeRegionHeader = peekBrowserEdgeRegionHeader();
|
|
1201
1270
|
for (const [name, value] of Object.entries(edgeRegionHeader))
|
|
1202
1271
|
headers.set(name, value);
|
|
@@ -1204,22 +1273,51 @@ export class HttpService {
|
|
|
1204
1273
|
for (const [name, value] of Object.entries(activityIdHeader))
|
|
1205
1274
|
headers.set(name, value);
|
|
1206
1275
|
try {
|
|
1207
|
-
const
|
|
1208
|
-
const
|
|
1276
|
+
const body = config.method === 'GET' ? undefined : config.body;
|
|
1277
|
+
const init = {
|
|
1209
1278
|
method: config.method,
|
|
1210
1279
|
headers,
|
|
1211
|
-
body
|
|
1280
|
+
body,
|
|
1212
1281
|
signal: config.signal,
|
|
1213
|
-
credentials:
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1282
|
+
credentials: 'omit',
|
|
1283
|
+
redirect: 'error',
|
|
1284
|
+
};
|
|
1285
|
+
// Node fetch requires this flag for streaming uploads; ordinary and
|
|
1286
|
+
// multipart bodies keep the platform's existing body serialization.
|
|
1287
|
+
if (body && typeof body === 'object' && 'getReader' in body)
|
|
1288
|
+
init.duplex = 'half';
|
|
1289
|
+
const response = await (config.fetch ?? globalThis.fetch)(fullUrl, init);
|
|
1290
|
+
try {
|
|
1291
|
+
assertCurrent();
|
|
1292
|
+
}
|
|
1293
|
+
catch (error) {
|
|
1294
|
+
await response.body?.cancel();
|
|
1295
|
+
throw error;
|
|
1296
|
+
}
|
|
1297
|
+
const replayable = body === undefined || typeof body === 'string';
|
|
1298
|
+
if (response.status === 401 && !isAuthRetry && authHeader && replayable && !config.signal?.aborted) {
|
|
1299
|
+
let refreshed;
|
|
1300
|
+
try {
|
|
1301
|
+
refreshed = await awaitRequestSignal(this.refreshAccessToken('response-401'), config.signal);
|
|
1302
|
+
}
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
void response.body?.cancel().catch(() => undefined);
|
|
1305
|
+
throw error;
|
|
1306
|
+
}
|
|
1307
|
+
try {
|
|
1308
|
+
assertCurrent();
|
|
1309
|
+
}
|
|
1310
|
+
catch (error) {
|
|
1311
|
+
await response.body?.cancel();
|
|
1312
|
+
throw error;
|
|
1313
|
+
}
|
|
1217
1314
|
if (refreshed) {
|
|
1218
1315
|
await response.body?.cancel();
|
|
1219
|
-
|
|
1316
|
+
if (config.signal?.aborted)
|
|
1317
|
+
throw createCancelledError();
|
|
1318
|
+
return this.requestResponseAttempt(config, fullUrl, `Bearer ${refreshed}`, true, epoch, contextToken);
|
|
1220
1319
|
}
|
|
1221
|
-
this.
|
|
1222
|
-
this.notifyTokenChange();
|
|
1320
|
+
this.clearTokens();
|
|
1223
1321
|
}
|
|
1224
1322
|
const duration = Date.now() - startTime;
|
|
1225
1323
|
this.updateMetrics(response.ok, duration);
|
|
@@ -1230,8 +1328,12 @@ export class HttpService {
|
|
|
1230
1328
|
const duration = Date.now() - startTime;
|
|
1231
1329
|
this.updateMetrics(false, duration);
|
|
1232
1330
|
this.config.onRequestEnd?.(config.url, config.method, duration, false);
|
|
1233
|
-
const normalizedError =
|
|
1331
|
+
const normalizedError = config.signal?.aborted || isCancelledError(error)
|
|
1332
|
+
? createCancelledError()
|
|
1333
|
+
: error instanceof Error ? error : new Error(String(error));
|
|
1234
1334
|
this.config.onRequestError?.(config.url, config.method, normalizedError);
|
|
1335
|
+
if (isCancelledError(normalizedError) || normalizedError instanceof OxyAuthenticationError)
|
|
1336
|
+
throw normalizedError;
|
|
1235
1337
|
throw handleHttpError(normalizedError);
|
|
1236
1338
|
}
|
|
1237
1339
|
}
|
|
@@ -1253,6 +1355,9 @@ export class HttpService {
|
|
|
1253
1355
|
}
|
|
1254
1356
|
// Token management
|
|
1255
1357
|
setTokens(accessToken) {
|
|
1358
|
+
const current = this.tokenStore.getAccessToken();
|
|
1359
|
+
if (current && !sameTokenSession(current, accessToken))
|
|
1360
|
+
this.sessionEpoch += 1;
|
|
1256
1361
|
this.sessionEnded = false;
|
|
1257
1362
|
this.tokenStore.setTokens(accessToken);
|
|
1258
1363
|
this.notifyTokenChange();
|
|
@@ -1261,17 +1366,19 @@ export class HttpService {
|
|
|
1261
1366
|
* End the local session: clear the bearer and abandon every re-mint already
|
|
1262
1367
|
* in flight, so none of them can plant a token after the sign-out.
|
|
1263
1368
|
*
|
|
1264
|
-
* {@link clearTokens}
|
|
1265
|
-
*
|
|
1266
|
-
* refresh started a moment earlier would finish and plant a new one.
|
|
1369
|
+
* Unlike {@link clearTokens}, this also marks the session as ended and
|
|
1370
|
+
* invalidates pending re-mints even when no bearer is currently present.
|
|
1267
1371
|
*/
|
|
1268
1372
|
endSession() {
|
|
1269
|
-
this.
|
|
1373
|
+
if (!this.tokenStore.getAccessToken())
|
|
1374
|
+
this.sessionEpoch += 1;
|
|
1270
1375
|
this.sessionEnded = true;
|
|
1271
1376
|
this.clearTokens();
|
|
1272
1377
|
}
|
|
1273
1378
|
/**
|
|
1274
|
-
* The current
|
|
1379
|
+
* The current context epoch: account/session switches and bearer removal
|
|
1380
|
+
* invalidate pending work; rotation within the same context preserves it.
|
|
1381
|
+
* A re-mint lane that
|
|
1275
1382
|
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
1276
1383
|
*/
|
|
1277
1384
|
getSessionEpoch() {
|
|
@@ -1298,6 +1405,8 @@ export class HttpService {
|
|
|
1298
1405
|
this.anonymousAuthProvider = provider;
|
|
1299
1406
|
}
|
|
1300
1407
|
clearTokens() {
|
|
1408
|
+
if (this.tokenStore.getAccessToken())
|
|
1409
|
+
this.sessionEpoch += 1;
|
|
1301
1410
|
this.tokenStore.clearTokens();
|
|
1302
1411
|
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
1303
1412
|
// different user could never read these entries, but a logged-out client
|