@oxy.so/core 1.9.1 → 1.11.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.
Files changed (71) hide show
  1. package/README.md +4 -4
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +56 -163
  4. package/dist/cjs/OxyServices.base.js +4 -2
  5. package/dist/cjs/boot/sessionColdBoot.js +3 -0
  6. package/dist/cjs/crypto/deviceBackup.js +75 -0
  7. package/dist/cjs/crypto/keyManager.js +285 -12
  8. package/dist/cjs/mixins/OxyServices.linkedAccounts.js +79 -0
  9. package/dist/cjs/mixins/OxyServices.user.js +7 -6
  10. package/dist/cjs/mixins/index.js +4 -0
  11. package/dist/cjs/server/cors.js +0 -1
  12. package/dist/cjs/server/securityHeaders.js +3 -0
  13. package/dist/cjs/session/refresh.js +32 -2
  14. package/dist/cjs/utils/errorUtils.js +1 -1
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/HttpService.js +56 -163
  17. package/dist/esm/OxyServices.base.js +4 -2
  18. package/dist/esm/boot/sessionColdBoot.js +3 -0
  19. package/dist/esm/crypto/deviceBackup.js +70 -0
  20. package/dist/esm/crypto/keyManager.js +252 -12
  21. package/dist/esm/mixins/OxyServices.linkedAccounts.js +76 -0
  22. package/dist/esm/mixins/OxyServices.user.js +7 -6
  23. package/dist/esm/mixins/index.js +4 -0
  24. package/dist/esm/server/cors.js +0 -1
  25. package/dist/esm/server/securityHeaders.js +3 -0
  26. package/dist/esm/session/refresh.js +32 -2
  27. package/dist/esm/utils/errorUtils.js +1 -1
  28. package/dist/types/.tsbuildinfo +1 -1
  29. package/dist/types/HttpService.d.ts +31 -7
  30. package/dist/types/OxyServices.base.d.ts +3 -1
  31. package/dist/types/crypto/deviceBackup.d.ts +60 -0
  32. package/dist/types/crypto/keyManager.d.ts +79 -12
  33. package/dist/types/index.d.ts +1 -0
  34. package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
  35. package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
  36. package/dist/types/mixins/OxyServices.user.d.ts +8 -6
  37. package/dist/types/mixins/index.d.ts +2 -1
  38. package/dist/types/models/interfaces.d.ts +30 -0
  39. package/dist/types/server/securityHeaders.d.ts +5 -0
  40. package/dist/types/session/refresh.d.ts +7 -0
  41. package/dist/types/utils/errorUtils.d.ts +1 -1
  42. package/package.json +2 -2
  43. package/src/HttpService.ts +59 -187
  44. package/src/OxyServices.base.ts +4 -2
  45. package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
  46. package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
  47. package/src/__tests__/inSessionRefresh.test.ts +53 -0
  48. package/src/__tests__/linkedClient.test.ts +0 -7
  49. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
  50. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
  51. package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
  52. package/src/boot/sessionColdBoot.ts +3 -0
  53. package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +310 -0
  54. package/src/crypto/deviceBackup.ts +108 -0
  55. package/src/crypto/keyManager.ts +278 -13
  56. package/src/index.ts +1 -0
  57. package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
  58. package/src/mixins/OxyServices.user.ts +9 -7
  59. package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
  60. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
  61. package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
  62. package/src/mixins/__tests__/topics.test.ts +2 -3
  63. package/src/mixins/index.ts +6 -0
  64. package/src/models/interfaces.ts +30 -1
  65. package/src/server/__tests__/securityHeaders.test.ts +7 -0
  66. package/src/server/cors.ts +0 -1
  67. package/src/server/securityHeaders.ts +8 -0
  68. package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
  69. package/src/session/__tests__/refresh.test.ts +115 -2
  70. package/src/session/refresh.ts +38 -2
  71. package/src/utils/errorUtils.ts +1 -1
@@ -10,6 +10,7 @@ import { deriveSecp256k1PublicKey, generateSecp256k1KeyPair, isValidSecp256k1Pri
10
10
  import { isDev, logger } from '../logger/index.js';
11
11
  import { hkdfSha256 } from './kdf.js';
12
12
  import { clearIdentityMarker, readIdentityMarker, updateIdentityMarker, writeIdentityMarker, } from './identityMarker.js';
13
+ import { DEVICE_BACKUP_VERSION, parseDeviceBackup, serializeDeviceBackup, } from './deviceBackup.js';
13
14
  /**
14
15
  * Thrown when an identity-mutating operation (createIdentity / importKeyPair)
15
16
  * is invoked while a valid identity already exists on the device.
@@ -236,6 +237,19 @@ async function getSecureRandomBytes(length) {
236
237
  }
237
238
  }
238
239
  export class KeyManager {
240
+ /**
241
+ * Register (or, with `null`, unregister) where the identity's device backup is
242
+ * kept. Call once at startup, before the first identity read, ONLY from the
243
+ * app that holds the self-custody identity: the backup is a full copy of the
244
+ * private key, and it belongs to exactly one app.
245
+ *
246
+ * From then on every create, import, rotation and restore refreshes the
247
+ * backup, a forced delete clears it, and {@link attemptIdentityRecovery}
248
+ * restores from it when the keystore copies are gone.
249
+ */
250
+ static setDeviceBackupStore(store) {
251
+ KeyManager.deviceBackupStore = store;
252
+ }
239
253
  /**
240
254
  * Invalidate cached identity state
241
255
  * Called internally when identity is created/deleted/imported
@@ -1224,6 +1238,138 @@ export class KeyManager {
1224
1238
  // persist (a subsequent healthy read re-backfills it). Rollback paths above
1225
1239
  // return before reaching here, so they never touch the marker.
1226
1240
  await KeyManager._syncMarkerAfterPersist(canonicalPublic, origin);
1241
+ // Mirror the durable identity into the keystore-independent device backup,
1242
+ // so a wipe of this UID's keystore can be undone without the phrase. Every
1243
+ // identity change (create, import, rotation, restore) flows through here.
1244
+ // Best-effort like the marker: `ensureDeviceBackup` retries on next launch.
1245
+ await KeyManager._writeDeviceBackup(canonicalPrivate, canonicalPublic);
1246
+ }
1247
+ /**
1248
+ * Write the device backup for `(privateKey, publicKey)`, keeping the phrase an
1249
+ * existing record already carries for the SAME identity. `mnemonic` replaces
1250
+ * it, and must already be verified against `publicKey` by the caller.
1251
+ * Best-effort: never throws, returns whether the store now holds this pair.
1252
+ *
1253
+ * @internal
1254
+ */
1255
+ static async _writeDeviceBackup(privateKey, publicKey, mnemonic) {
1256
+ const store = KeyManager.deviceBackupStore;
1257
+ if (!store) {
1258
+ return false;
1259
+ }
1260
+ try {
1261
+ let existing = null;
1262
+ try {
1263
+ existing = parseDeviceBackup(await store.read());
1264
+ }
1265
+ catch {
1266
+ existing = null;
1267
+ }
1268
+ const sameIdentity = existing?.publicKey === publicKey;
1269
+ const keptMnemonic = mnemonic ?? (sameIdentity ? existing?.mnemonic : undefined);
1270
+ if (sameIdentity && existing?.privateKey === privateKey && existing?.mnemonic === keptMnemonic) {
1271
+ return true; // Already current; do not churn the store.
1272
+ }
1273
+ await store.write(serializeDeviceBackup({
1274
+ version: DEVICE_BACKUP_VERSION,
1275
+ privateKey,
1276
+ publicKey,
1277
+ mnemonic: keptMnemonic,
1278
+ updatedAt: new Date().toISOString(),
1279
+ }));
1280
+ return true;
1281
+ }
1282
+ catch (error) {
1283
+ logger.warn(`Failed to write the identity device backup (${store.name}); retried on next launch`, { component: 'KeyManager' }, error);
1284
+ return false;
1285
+ }
1286
+ }
1287
+ /** Remove the device backup. Best-effort: never throws. @internal */
1288
+ static async _clearDeviceBackup() {
1289
+ const store = KeyManager.deviceBackupStore;
1290
+ if (!store) {
1291
+ return;
1292
+ }
1293
+ try {
1294
+ await store.clear();
1295
+ }
1296
+ catch (error) {
1297
+ logger.warn(`Failed to clear the identity device backup (${store.name})`, { component: 'KeyManager' }, error);
1298
+ }
1299
+ }
1300
+ /** True when `phrase` is a valid BIP-39 phrase that derives `publicKey`. @internal */
1301
+ static async _phraseDerivesPublicKey(phrase, publicKey) {
1302
+ try {
1303
+ // Imported lazily: `./recoveryPhrase` imports this module, and the phrase
1304
+ // derivation is frozen there, so it is reused rather than copied.
1305
+ const { RecoveryPhraseService } = await import('./recoveryPhrase.js');
1306
+ const derived = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
1307
+ return derived.toLowerCase() === publicKey.toLowerCase();
1308
+ }
1309
+ catch {
1310
+ return false;
1311
+ }
1312
+ }
1313
+ /** The primary pair when it reads back healthy, else `null`. Never throws. @internal */
1314
+ static async _readHealthyPrimary() {
1315
+ try {
1316
+ const direct = await KeyManager._readPrimaryDirect();
1317
+ if (KeyManager._isHealthyPair(direct.privateKey, direct.publicKey) && direct.privateKey && direct.publicKey) {
1318
+ return {
1319
+ privateKey: KeyManager.canonicalPrivateKey(direct.privateKey),
1320
+ publicKey: direct.publicKey.toLowerCase(),
1321
+ };
1322
+ }
1323
+ return null;
1324
+ }
1325
+ catch {
1326
+ return null;
1327
+ }
1328
+ }
1329
+ /**
1330
+ * Make sure the device backup holds the identity on this device, writing it
1331
+ * when it is missing or stale. Commons calls this once per launch once the
1332
+ * identity reads back healthy: it backfills identities created before the
1333
+ * device backup existed, and repairs a backup whose last write failed (a
1334
+ * failed write after a key rotation would otherwise leave the OLD key there).
1335
+ *
1336
+ * Carries the stored recovery phrase along only when it provably derives the
1337
+ * current key. Resolves `true` when the backup is current, `false` when there
1338
+ * is no store, no healthy identity, or the write failed. Never throws.
1339
+ */
1340
+ static async ensureDeviceBackup() {
1341
+ if (isWebPlatform() || !KeyManager.deviceBackupStore) {
1342
+ return false;
1343
+ }
1344
+ const primary = await KeyManager._readHealthyPrimary();
1345
+ if (!primary) {
1346
+ return false;
1347
+ }
1348
+ let existing = null;
1349
+ try {
1350
+ existing = parseDeviceBackup(await KeyManager.deviceBackupStore.read());
1351
+ }
1352
+ catch {
1353
+ existing = null;
1354
+ }
1355
+ const pairCurrent = existing?.publicKey === primary.publicKey && existing?.privateKey === primary.privateKey;
1356
+ if (pairCurrent && existing?.mnemonic) {
1357
+ return true; // The common launch: one read, no write, no phrase derivation.
1358
+ }
1359
+ let mnemonic;
1360
+ try {
1361
+ const stored = await KeyManager.getRecoveryMnemonic();
1362
+ if (stored && (await KeyManager._phraseDerivesPublicKey(stored, primary.publicKey))) {
1363
+ mnemonic = stored;
1364
+ }
1365
+ }
1366
+ catch {
1367
+ mnemonic = undefined;
1368
+ }
1369
+ if (pairCurrent && !mnemonic) {
1370
+ return true;
1371
+ }
1372
+ return KeyManager._writeDeviceBackup(primary.privateKey, primary.publicKey, mnemonic);
1227
1373
  }
1228
1374
  /**
1229
1375
  * Write/refresh the identity marker after a successful persist. A same-identity
@@ -1542,6 +1688,15 @@ export class KeyManager {
1542
1688
  }
1543
1689
  throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
1544
1690
  }
1691
+ // The phrase slot above dies with the keystore too, so the device backup
1692
+ // carries the phrase as well — but only one that derives the identity on
1693
+ // this device. Best-effort: never fails the store.
1694
+ if (KeyManager.deviceBackupStore) {
1695
+ const primary = await KeyManager._readHealthyPrimary();
1696
+ if (primary && (await KeyManager._phraseDerivesPublicKey(mnemonic, primary.publicKey))) {
1697
+ await KeyManager._writeDeviceBackup(primary.privateKey, primary.publicKey, mnemonic.trim().toLowerCase());
1698
+ }
1699
+ }
1545
1700
  }
1546
1701
  /**
1547
1702
  * Read the stored recovery mnemonic for re-reveal in Settings.
@@ -1804,6 +1959,10 @@ export class KeyManager {
1804
1959
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
1805
1960
  await KeyManager._clearSharedSlot(store);
1806
1961
  }
1962
+ // The device backup goes on EVERY delete, forced or not: it is restored
1963
+ // silently, so leaving it would bring a deleted identity back on the next
1964
+ // launch. Best-effort, like the other recovery sources.
1965
+ await KeyManager._clearDeviceBackup();
1807
1966
  // Clear the marker AFTER key deletion succeeds — a marker must never outlive
1808
1967
  // its identity (a leftover marker would route a truly-absent device to
1809
1968
  // `recovery` instead of `welcome`).
@@ -2014,19 +2173,29 @@ export class KeyManager {
2014
2173
  }
2015
2174
  }
2016
2175
  /**
2017
- * Recovery ladder — restore a `lost` identity from an independent,
2018
- * `key_v1`-surviving source WITHOUT the user re-entering their recovery phrase.
2019
- *
2020
- * Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
2021
- * - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
2022
- * - `unavailable` → `unavailable` (keychain locked; retry later)
2176
+ * Recovery ladder — restore an identity whose keys are gone from an
2177
+ * independent source WITHOUT the user re-entering their recovery phrase.
2023
2178
  *
2024
- * Rungs, tried in order, each fully validated (well-formed + derive-match +
2025
- * `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
2026
- * SKIPPED, never restored):
2027
- * 1. the v2 backup slot (independent keychain key from the primary), then
2179
+ * For a `lost` verdict (marker present, keys empty) the rungs are tried in
2180
+ * order, each fully validated (well-formed + derive-match + `publicKey ===
2181
+ * marker.publicKey`, so a source holding a DIFFERENT account is SKIPPED, never
2182
+ * restored):
2183
+ * 1. the v2 backup slot (independent keychain key from the primary),
2028
2184
  * 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
2029
- * group) — the copy that survives a primary+backup `key_v1` death.
2185
+ * group) — the copy that survives a primary+backup `key_v1` death,
2186
+ * 3. the device backup ({@link setDeviceBackupStore}; Android Block Store in
2187
+ * Commons) — the only copy that survives a wipe of the whole shared-UID
2188
+ * Android Keystore, which takes rungs 1 and 2 with it (oxy#1388).
2189
+ *
2190
+ * For an `absent` verdict (no keys AND no marker: this app's own data was
2191
+ * cleared, or it was reinstalled) only rung 3 applies: a device backup there
2192
+ * means an identity lived on this device and was never deleted (every delete
2193
+ * clears the backup), so it is restored instead of letting onboarding create a
2194
+ * new identity over it. Without a registered store the result stays
2195
+ * `not-lost`, exactly as before.
2196
+ *
2197
+ * `present` → `not-lost`; `unavailable` (keychain locked) → `unavailable`.
2198
+ * Everything here is on-device: no network, so it works offline.
2030
2199
  *
2031
2200
  * On success it re-persists via {@link _persistIdentityAtomic} (origin
2032
2201
  * `'restore'`) and invalidates the cache so routing re-reads `present`. When no
@@ -2037,12 +2206,24 @@ export class KeyManager {
2037
2206
  return { recovered: false, reason: 'not-lost' };
2038
2207
  }
2039
2208
  const status = await KeyManager.getIdentityStatus({ bypassCache: true });
2040
- if (status.state === 'present' || status.state === 'absent') {
2209
+ if (status.state === 'present') {
2041
2210
  return { recovered: false, reason: 'not-lost' };
2042
2211
  }
2043
2212
  if (status.state === 'unavailable') {
2044
2213
  return { recovered: false, reason: 'unavailable' };
2045
2214
  }
2215
+ if (status.state === 'absent') {
2216
+ if (!KeyManager.deviceBackupStore) {
2217
+ return { recovered: false, reason: 'not-lost' };
2218
+ }
2219
+ const candidate = await KeyManager._readDeviceBackupCandidate();
2220
+ if (!candidate) {
2221
+ return { recovered: false, reason: 'not-lost' };
2222
+ }
2223
+ return (await KeyManager._commitDeviceBackupRecovery(candidate))
2224
+ ? { recovered: true, source: 'device-backup', publicKey: candidate.publicKey }
2225
+ : { recovered: false, reason: 'no-sources' };
2226
+ }
2046
2227
  // status.state === 'lost'
2047
2228
  const expectedPublic = status.marker.publicKey.toLowerCase();
2048
2229
  let sawMismatch = false;
@@ -2070,10 +2251,63 @@ export class KeyManager {
2070
2251
  sawMismatch = true;
2071
2252
  }
2072
2253
  }
2254
+ // Rung 3: device backup, outside this UID's keystore.
2255
+ const deviceCandidate = await KeyManager._readDeviceBackupCandidate();
2256
+ if (deviceCandidate) {
2257
+ if (deviceCandidate.publicKey === expectedPublic) {
2258
+ if (await KeyManager._commitDeviceBackupRecovery(deviceCandidate)) {
2259
+ return { recovered: true, source: 'device-backup', publicKey: deviceCandidate.publicKey };
2260
+ }
2261
+ }
2262
+ else {
2263
+ sawMismatch = true;
2264
+ }
2265
+ }
2073
2266
  // A source existed but identified a DIFFERENT account — never silently
2074
2267
  // switched. Report `mismatch` so the UI can require explicit confirmation.
2075
2268
  return { recovered: false, reason: sawMismatch ? 'mismatch' : 'no-sources' };
2076
2269
  }
2270
+ /** Read the device backup as a healthy, canonical candidate, or null. Never throws. @internal */
2271
+ static async _readDeviceBackupCandidate() {
2272
+ const store = KeyManager.deviceBackupStore;
2273
+ if (!store) {
2274
+ return null;
2275
+ }
2276
+ try {
2277
+ const record = parseDeviceBackup(await store.read());
2278
+ if (!record || !KeyManager._isHealthyPair(record.privateKey, record.publicKey)) {
2279
+ return null;
2280
+ }
2281
+ return {
2282
+ ...record,
2283
+ privateKey: KeyManager.canonicalPrivateKey(record.privateKey),
2284
+ publicKey: record.publicKey.toLowerCase(),
2285
+ };
2286
+ }
2287
+ catch (error) {
2288
+ logger.warn(`Recovery: failed to read the device backup (${store.name})`, { component: 'KeyManager' }, error);
2289
+ return null;
2290
+ }
2291
+ }
2292
+ /**
2293
+ * Persist a device-backup candidate, then put its phrase back in the
2294
+ * re-reveal slot (which died with the keystore) when it derives the key.
2295
+ * @internal
2296
+ */
2297
+ static async _commitDeviceBackupRecovery(record) {
2298
+ if (!(await KeyManager._commitRecovery(record.privateKey, record.publicKey))) {
2299
+ return false;
2300
+ }
2301
+ if (record.mnemonic && (await KeyManager._phraseDerivesPublicKey(record.mnemonic, record.publicKey))) {
2302
+ try {
2303
+ await KeyManager.storeRecoveryMnemonic(record.mnemonic);
2304
+ }
2305
+ catch (error) {
2306
+ logger.warn('Recovery: restored the identity but not its phrase slot', { component: 'KeyManager' }, error);
2307
+ }
2308
+ }
2309
+ return true;
2310
+ }
2077
2311
  /** Read the active-layout backup slot as a healthy candidate, or null. @internal */
2078
2312
  static async _readBackupCandidate() {
2079
2313
  try {
@@ -2265,4 +2499,10 @@ KeyManager.identityChangeListeners = new Set();
2265
2499
  */
2266
2500
  KeyManager.slotMigrationPromise = null;
2267
2501
  KeyManager.slotMigrationResult = null;
2502
+ /**
2503
+ * The keystore-independent device backup, registered by the app that owns the
2504
+ * identity (Commons). `null` in every other app, which makes every device
2505
+ * backup path a no-op there. See `./deviceBackup`.
2506
+ */
2507
+ KeyManager.deviceBackupStore = null;
2268
2508
  export default KeyManager;
@@ -0,0 +1,76 @@
1
+ export function OxyServicesLinkedAccountsMixin(Base) {
2
+ return class extends Base {
3
+ /**
4
+ * Begin linking an external account for the current user.
5
+ *
6
+ * @param network - `'activitypub'` (any Mastodon-API server) or `'atproto'` (Bluesky).
7
+ * @param options - `instance` for ActivityPub (`mastodon.social`, or
8
+ * `@user@mastodon.social`), `handle` for atproto; `clientId` + `returnTo`
9
+ * to be sent back to your app afterwards.
10
+ * @returns The URL to open, and when the attempt expires (ten minutes).
11
+ */
12
+ async startLinkedAccount(network, options) {
13
+ try {
14
+ return await this.makeRequest('POST', `/linked-accounts/${encodeURIComponent(network)}/start`, options, { cache: false });
15
+ }
16
+ catch (error) {
17
+ throw this.handleError(error);
18
+ }
19
+ }
20
+ /**
21
+ * Finish a link with the `link_code` the callback appended to `returnTo`.
22
+ * Must be the user who started the flow: for anyone else the API answers
23
+ * 403 and burns the code. 409 when the account is already someone else's
24
+ * link. Repeating it within the code's five minutes returns the same link.
25
+ */
26
+ async completeLinkedAccount(code) {
27
+ try {
28
+ const response = await this.makeRequest('POST', '/linked-accounts/complete', { code }, { cache: false });
29
+ return response.linkedAccount;
30
+ }
31
+ catch (error) {
32
+ throw this.handleError(error);
33
+ }
34
+ }
35
+ /** The current user's live linked accounts, oldest first. */
36
+ async listLinkedAccounts() {
37
+ try {
38
+ const response = await this.makeRequest('GET', '/linked-accounts', undefined, {
39
+ cache: false,
40
+ });
41
+ return response.linkedAccounts;
42
+ }
43
+ catch (error) {
44
+ throw this.handleError(error);
45
+ }
46
+ }
47
+ /**
48
+ * Unlink one of the current user's accounts. An ActivityPub link stops being
49
+ * published as an `alsoKnownAs` alias, and the external account can then be
50
+ * linked to another Oxy account.
51
+ */
52
+ async revokeLinkedAccount(linkedAccountId) {
53
+ try {
54
+ await this.makeRequest('DELETE', `/linked-accounts/${encodeURIComponent(linkedAccountId)}`, undefined, {
55
+ cache: false,
56
+ });
57
+ }
58
+ catch (error) {
59
+ throw this.handleError(error);
60
+ }
61
+ }
62
+ /**
63
+ * SERVICE: a user's live linked accounts, each with the federated shadow
64
+ * user Oxy already holds for it (`federatedUserId`). Requires a service
65
+ * credential whose application holds the privileged `linked-accounts:read`.
66
+ */
67
+ async getLinkedAccountsForUser(userId) {
68
+ try {
69
+ return await this.makeServiceRequest('GET', `/linked-accounts/by-user/${encodeURIComponent(userId)}`);
70
+ }
71
+ catch (error) {
72
+ throw this.handleError(error);
73
+ }
74
+ }
75
+ };
76
+ }
@@ -285,10 +285,7 @@ export function OxyServicesUserMixin(Base) {
285
285
  * `PostHydrationService`) and is unchanged.
286
286
  * - **Plain client (browser / React Native with a user session):** when no
287
287
  * service credentials are configured, the chunk is fetched via
288
- * `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
289
- * middleware skips bearer-authenticated writes, and `makeRequest` only
290
- * fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
291
- * so the user-bearer POST is sent without CSRF and succeeds. Previously
288
+ * `makeRequest`, which attaches the configured user bearer. Previously
292
289
  * this method always used the service path, so every client-side caller
293
290
  * silently received `[]` because `getServiceToken()` had no credentials.
294
291
  *
@@ -315,7 +312,7 @@ export function OxyServicesUserMixin(Base) {
315
312
  }
316
313
  // A backend that called configureServiceAuth() uses the bearer-service
317
314
  // path; any other caller (browser / RN with a user session) uses the
318
- // user-bearer path. See the method doc for why the user path is CSRF-safe.
315
+ // user-bearer path.
319
316
  const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
320
317
  // Run chunks concurrently; a single chunk failure must not sink the rest.
321
318
  const settled = await Promise.all(chunks.map(async (chunk) => {
@@ -859,7 +856,11 @@ export function OxyServicesUserMixin(Base) {
859
856
  }
860
857
  }
861
858
  /**
862
- * Create notification
859
+ * Create a notification (`POST /notifications`; the API requires a service
860
+ * token whose application holds `notifications:write`). `type` is one of
861
+ * `OXY_NOTIFICATION_TYPES` from `@oxy.so/contracts` — `system` for a message
862
+ * from an Oxy service about the recipient's own account, with the recipient
863
+ * as actor and their profile as the entity.
863
864
  */
864
865
  async createNotification(data) {
865
866
  try {
@@ -34,6 +34,7 @@ import { OxyServicesNodesMixin } from './OxyServices.nodes.js';
34
34
  import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph.js';
35
35
  import { OxyServicesInferenceMixin } from './OxyServices.inference.js';
36
36
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot.js';
37
+ import { OxyServicesLinkedAccountsMixin } from './OxyServices.linkedAccounts.js';
37
38
  /**
38
39
  * Mixin pipeline - applied in order from first to last.
39
40
  *
@@ -103,6 +104,9 @@ const MIXIN_PIPELINE = [
103
104
  // Device-first token mint: the client half of the zero-cookie transport
104
105
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
105
106
  OxyServicesDeviceBootMixin,
107
+ // Linked external accounts (Mastodon-API, Bluesky) proven by OAuth; the
108
+ // service read uses the auth mixin's `makeServiceRequest`.
109
+ OxyServicesLinkedAccountsMixin,
106
110
  // Device-to-device identity transfer ("add a device"): E2E-encrypted key
107
111
  // clone over a short-lived relay (b3 Feature 2).
108
112
  // Utility (last, can use all above)
@@ -35,7 +35,6 @@ const DEFAULT_ALLOWED_HEADERS = [
35
35
  'X-Requested-With',
36
36
  'X-Oxy-User-Id',
37
37
  'X-Oxy-Internal',
38
- 'X-CSRF-Token',
39
38
  'X-Oxy-Edge-Region',
40
39
  'X-Oxy-Activity-Id',
41
40
  ];
@@ -340,6 +340,9 @@ export function buildOxyPagesHeaders(options = {}) {
340
340
  if (options.hsts !== false) {
341
341
  lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
342
342
  }
343
+ if (options.sensitive) {
344
+ lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
345
+ }
343
346
  lines.push('');
344
347
  return lines.join('\n');
345
348
  }
@@ -71,6 +71,7 @@ export async function refreshDeviceSecretArm(deps) {
71
71
  const { oxy, store } = deps;
72
72
  const pin = deps.pin ?? null;
73
73
  return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
74
+ const epoch = oxy.httpService.getSessionEpoch();
74
75
  const persisted = await store.load();
75
76
  if (!persisted?.deviceId || !persisted?.deviceSecret) {
76
77
  return { status: 'no-secret' };
@@ -136,12 +137,26 @@ export async function refreshDeviceSecretArm(deps) {
136
137
  expiresAt: mint.expiresAt,
137
138
  ...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
138
139
  };
140
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
141
+ // Signed out while minting. The server has rotated the secret, so a store
142
+ // that still holds the presented one must learn the rotation (the
143
+ // token-null lane keeps the store so a reload can restore); a store the
144
+ // sign-out cleared must NOT be refilled. Either way, plant nothing.
145
+ const current = await store.load();
146
+ if (current?.deviceId === persisted.deviceId && current.deviceSecret === persisted.deviceSecret) {
147
+ await store.save(next);
148
+ }
149
+ return { status: 'session-ended' };
150
+ }
139
151
  // Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
140
152
  // A failed durable persist must NOT plant.
141
153
  const persistedOk = await store.save(next);
142
154
  if (!persistedOk) {
143
155
  return { status: 'persist-failed' };
144
156
  }
157
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
158
+ return { status: 'session-ended' };
159
+ }
145
160
  oxy.setTokens(mint.accessToken);
146
161
  return {
147
162
  status: 'ok',
@@ -179,6 +194,7 @@ export async function refreshPersistedSession(deps) {
179
194
  const identity = deps.identity ?? null;
180
195
  // The shared keychain is never an identity-bound client's recovery path.
181
196
  const allowSharedKeyFallback = identity ? false : (deps.allowSharedKeyFallback ?? isNative());
197
+ const epoch = oxy.httpService.getSessionEpoch();
182
198
  // Resolved per call: a re-established identity session can move the pin, and a
183
199
  // replaced/removed local key clears it (in which case arm 1 must NOT mint —
184
200
  // an unpinned mint would adopt whatever account the device switched to).
@@ -190,6 +206,8 @@ export async function refreshPersistedSession(deps) {
190
206
  switch (arm1.status) {
191
207
  case 'ok':
192
208
  return arm1.token;
209
+ case 'session-ended':
210
+ return null;
193
211
  case 'transient':
194
212
  logger.debug('Persisted deviceSecret mint failed (transient) — keeping store', { component: 'refresh', method: 'refreshPersistedSession' });
195
213
  return null;
@@ -228,10 +246,18 @@ export async function refreshPersistedSession(deps) {
228
246
  if (identity) {
229
247
  return recoverIdentitySession(oxy, store, identity);
230
248
  }
231
- if (allowSharedKeyFallback) {
249
+ // Never after a sign-out: the shared keychain holds an identity KEY, not a
250
+ // session, and using it here would sign the user straight back in.
251
+ if (allowSharedKeyFallback && !oxy.httpService.hasSessionEnded()) {
232
252
  try {
233
- const session = await oxy.signInWithSharedIdentity();
253
+ // Planted here, not by the sign-in: a sign-out that lands while the
254
+ // challenge round-trips must win, or the shared keychain signs the user
255
+ // straight back in.
256
+ const session = await oxy.signInWithSharedIdentity({ plantTokens: false });
234
257
  if (session?.accessToken) {
258
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
259
+ return null;
260
+ }
235
261
  // Repopulate the fast device-secret lane from the shared-key re-mint.
236
262
  if (session.deviceId && session.deviceSecret) {
237
263
  await store.save({
@@ -243,6 +269,10 @@ export async function refreshPersistedSession(deps) {
243
269
  expiresAt: session.expiresAt,
244
270
  });
245
271
  }
272
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
273
+ return null;
274
+ }
275
+ oxy.setTokens(session.accessToken);
246
276
  return session.accessToken;
247
277
  }
248
278
  }
@@ -69,7 +69,7 @@ const nonEmptyString = (value) => typeof value === 'string' && value.trim().leng
69
69
  * - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
70
70
  * OAuth token and userinfo endpoints. `error_description` is the human text
71
71
  * and `error` is the machine code, so both survive.
72
- * - `{ message, code }` — e.g. the API's CSRF rejections.
72
+ * - `{ message, code }` — e.g. the API's middleware rejections.
73
73
  * - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
74
74
  * `message`/`error_description` the string is the message, not a code: a bare
75
75
  * `error` string is not machine-readable enough to promote to `code`.