@oxy.so/core 1.10.0 → 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 (35) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/deviceBackup.js +75 -0
  3. package/dist/cjs/crypto/keyManager.js +285 -12
  4. package/dist/cjs/mixins/OxyServices.linkedAccounts.js +79 -0
  5. package/dist/cjs/mixins/OxyServices.user.js +5 -1
  6. package/dist/cjs/mixins/index.js +4 -0
  7. package/dist/cjs/server/securityHeaders.js +3 -0
  8. package/dist/esm/.tsbuildinfo +1 -1
  9. package/dist/esm/crypto/deviceBackup.js +70 -0
  10. package/dist/esm/crypto/keyManager.js +252 -12
  11. package/dist/esm/mixins/OxyServices.linkedAccounts.js +76 -0
  12. package/dist/esm/mixins/OxyServices.user.js +5 -1
  13. package/dist/esm/mixins/index.js +4 -0
  14. package/dist/esm/server/securityHeaders.js +3 -0
  15. package/dist/types/.tsbuildinfo +1 -1
  16. package/dist/types/crypto/deviceBackup.d.ts +60 -0
  17. package/dist/types/crypto/keyManager.d.ts +79 -12
  18. package/dist/types/index.d.ts +1 -0
  19. package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
  20. package/dist/types/mixins/OxyServices.user.d.ts +7 -2
  21. package/dist/types/mixins/index.d.ts +2 -1
  22. package/dist/types/models/interfaces.d.ts +30 -0
  23. package/dist/types/server/securityHeaders.d.ts +5 -0
  24. package/package.json +2 -2
  25. package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +310 -0
  26. package/src/crypto/deviceBackup.ts +108 -0
  27. package/src/crypto/keyManager.ts +278 -13
  28. package/src/index.ts +1 -0
  29. package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
  30. package/src/mixins/OxyServices.user.ts +7 -2
  31. package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
  32. package/src/mixins/index.ts +6 -0
  33. package/src/models/interfaces.ts +30 -1
  34. package/src/server/__tests__/securityHeaders.test.ts +7 -0
  35. package/src/server/securityHeaders.ts +8 -0
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ /**
3
+ * The device backup of the self-custody identity: a copy that lives OUTSIDE this
4
+ * app's own keystore, so a keystore wipe cannot take it along with the keys.
5
+ *
6
+ * Why it exists (OxyHQ/oxy#1388): on Android every Oxy app shares the Linux UID
7
+ * `so.oxy.shared`, and the Android Keystore is per UID. Clearing the storage of
8
+ * ANY sibling app (`pm clear`, or Settings › Apps › Storage › Clear storage)
9
+ * wipes the Keystore of the whole UID. Every copy the SDK keeps in
10
+ * expo-secure-store (primary, backup slot, phrase slot) and in the androidx
11
+ * encrypted prefs (the cross-app shared slot) is wrapped by a key in that
12
+ * Keystore, so they all die together, and until now the only way back was the
13
+ * recovery phrase.
14
+ *
15
+ * `@oxy.so/core` never imports a native module, so the store is injected by the
16
+ * app that owns the identity ({@link KeyManager.setDeviceBackupStore}). Commons
17
+ * registers Android Block Store; every other app registers nothing, which keeps
18
+ * every code path below a no-op for them. The design and the threat model are in
19
+ * `docs/identity/device-backup.md`.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.DEVICE_BACKUP_VERSION = void 0;
23
+ exports.serializeDeviceBackup = serializeDeviceBackup;
24
+ exports.parseDeviceBackup = parseDeviceBackup;
25
+ /** The record format. Bump {@link DEVICE_BACKUP_VERSION} on a breaking change. */
26
+ exports.DEVICE_BACKUP_VERSION = 1;
27
+ function serializeDeviceBackup(record) {
28
+ const out = {
29
+ version: exports.DEVICE_BACKUP_VERSION,
30
+ privateKey: record.privateKey,
31
+ publicKey: record.publicKey,
32
+ updatedAt: record.updatedAt,
33
+ };
34
+ if (record.mnemonic) {
35
+ out.mnemonic = record.mnemonic;
36
+ }
37
+ return JSON.stringify(out);
38
+ }
39
+ /**
40
+ * Parse a stored record, or `null` for anything that is not a well-formed
41
+ * version-1 record. Checks SHAPE only; the caller still proves the key pair is
42
+ * healthy (well-formed, public derives from private) before trusting it.
43
+ */
44
+ function parseDeviceBackup(raw) {
45
+ if (!raw) {
46
+ return null;
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(raw);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ if (!parsed || typeof parsed !== 'object') {
56
+ return null;
57
+ }
58
+ const candidate = parsed;
59
+ if (candidate.version !== exports.DEVICE_BACKUP_VERSION ||
60
+ typeof candidate.privateKey !== 'string' ||
61
+ typeof candidate.publicKey !== 'string' ||
62
+ typeof candidate.updatedAt !== 'string') {
63
+ return null;
64
+ }
65
+ const record = {
66
+ version: exports.DEVICE_BACKUP_VERSION,
67
+ privateKey: candidate.privateKey,
68
+ publicKey: candidate.publicKey,
69
+ updatedAt: candidate.updatedAt,
70
+ };
71
+ if (typeof candidate.mnemonic === 'string' && candidate.mnemonic.length > 0) {
72
+ record.mnemonic = candidate.mnemonic;
73
+ }
74
+ return record;
75
+ }
@@ -5,6 +5,39 @@
5
5
  * Handles secure generation, storage, and retrieval of cryptographic keys.
6
6
  * Private keys are stored securely using expo-secure-store and never leave the device.
7
7
  */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
8
41
  Object.defineProperty(exports, "__esModule", { value: true });
9
42
  exports.KeyManager = exports.IdentityUnavailableError = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = void 0;
10
43
  const platform_1 = require("../utils/platform");
@@ -13,6 +46,7 @@ const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
13
46
  const logger_1 = require("../logger");
14
47
  const kdf_1 = require("./kdf");
15
48
  const identityMarker_1 = require("./identityMarker");
49
+ const deviceBackup_1 = require("./deviceBackup");
16
50
  /**
17
51
  * Thrown when an identity-mutating operation (createIdentity / importKeyPair)
18
52
  * is invoked while a valid identity already exists on the device.
@@ -242,6 +276,19 @@ async function getSecureRandomBytes(length) {
242
276
  }
243
277
  }
244
278
  class KeyManager {
279
+ /**
280
+ * Register (or, with `null`, unregister) where the identity's device backup is
281
+ * kept. Call once at startup, before the first identity read, ONLY from the
282
+ * app that holds the self-custody identity: the backup is a full copy of the
283
+ * private key, and it belongs to exactly one app.
284
+ *
285
+ * From then on every create, import, rotation and restore refreshes the
286
+ * backup, a forced delete clears it, and {@link attemptIdentityRecovery}
287
+ * restores from it when the keystore copies are gone.
288
+ */
289
+ static setDeviceBackupStore(store) {
290
+ KeyManager.deviceBackupStore = store;
291
+ }
245
292
  /**
246
293
  * Invalidate cached identity state
247
294
  * Called internally when identity is created/deleted/imported
@@ -1230,6 +1277,138 @@ class KeyManager {
1230
1277
  // persist (a subsequent healthy read re-backfills it). Rollback paths above
1231
1278
  // return before reaching here, so they never touch the marker.
1232
1279
  await KeyManager._syncMarkerAfterPersist(canonicalPublic, origin);
1280
+ // Mirror the durable identity into the keystore-independent device backup,
1281
+ // so a wipe of this UID's keystore can be undone without the phrase. Every
1282
+ // identity change (create, import, rotation, restore) flows through here.
1283
+ // Best-effort like the marker: `ensureDeviceBackup` retries on next launch.
1284
+ await KeyManager._writeDeviceBackup(canonicalPrivate, canonicalPublic);
1285
+ }
1286
+ /**
1287
+ * Write the device backup for `(privateKey, publicKey)`, keeping the phrase an
1288
+ * existing record already carries for the SAME identity. `mnemonic` replaces
1289
+ * it, and must already be verified against `publicKey` by the caller.
1290
+ * Best-effort: never throws, returns whether the store now holds this pair.
1291
+ *
1292
+ * @internal
1293
+ */
1294
+ static async _writeDeviceBackup(privateKey, publicKey, mnemonic) {
1295
+ const store = KeyManager.deviceBackupStore;
1296
+ if (!store) {
1297
+ return false;
1298
+ }
1299
+ try {
1300
+ let existing = null;
1301
+ try {
1302
+ existing = (0, deviceBackup_1.parseDeviceBackup)(await store.read());
1303
+ }
1304
+ catch {
1305
+ existing = null;
1306
+ }
1307
+ const sameIdentity = existing?.publicKey === publicKey;
1308
+ const keptMnemonic = mnemonic ?? (sameIdentity ? existing?.mnemonic : undefined);
1309
+ if (sameIdentity && existing?.privateKey === privateKey && existing?.mnemonic === keptMnemonic) {
1310
+ return true; // Already current; do not churn the store.
1311
+ }
1312
+ await store.write((0, deviceBackup_1.serializeDeviceBackup)({
1313
+ version: deviceBackup_1.DEVICE_BACKUP_VERSION,
1314
+ privateKey,
1315
+ publicKey,
1316
+ mnemonic: keptMnemonic,
1317
+ updatedAt: new Date().toISOString(),
1318
+ }));
1319
+ return true;
1320
+ }
1321
+ catch (error) {
1322
+ logger_1.logger.warn(`Failed to write the identity device backup (${store.name}); retried on next launch`, { component: 'KeyManager' }, error);
1323
+ return false;
1324
+ }
1325
+ }
1326
+ /** Remove the device backup. Best-effort: never throws. @internal */
1327
+ static async _clearDeviceBackup() {
1328
+ const store = KeyManager.deviceBackupStore;
1329
+ if (!store) {
1330
+ return;
1331
+ }
1332
+ try {
1333
+ await store.clear();
1334
+ }
1335
+ catch (error) {
1336
+ logger_1.logger.warn(`Failed to clear the identity device backup (${store.name})`, { component: 'KeyManager' }, error);
1337
+ }
1338
+ }
1339
+ /** True when `phrase` is a valid BIP-39 phrase that derives `publicKey`. @internal */
1340
+ static async _phraseDerivesPublicKey(phrase, publicKey) {
1341
+ try {
1342
+ // Imported lazily: `./recoveryPhrase` imports this module, and the phrase
1343
+ // derivation is frozen there, so it is reused rather than copied.
1344
+ const { RecoveryPhraseService } = await Promise.resolve().then(() => __importStar(require('./recoveryPhrase')));
1345
+ const derived = await RecoveryPhraseService.derivePublicKeyFromPhrase(phrase);
1346
+ return derived.toLowerCase() === publicKey.toLowerCase();
1347
+ }
1348
+ catch {
1349
+ return false;
1350
+ }
1351
+ }
1352
+ /** The primary pair when it reads back healthy, else `null`. Never throws. @internal */
1353
+ static async _readHealthyPrimary() {
1354
+ try {
1355
+ const direct = await KeyManager._readPrimaryDirect();
1356
+ if (KeyManager._isHealthyPair(direct.privateKey, direct.publicKey) && direct.privateKey && direct.publicKey) {
1357
+ return {
1358
+ privateKey: KeyManager.canonicalPrivateKey(direct.privateKey),
1359
+ publicKey: direct.publicKey.toLowerCase(),
1360
+ };
1361
+ }
1362
+ return null;
1363
+ }
1364
+ catch {
1365
+ return null;
1366
+ }
1367
+ }
1368
+ /**
1369
+ * Make sure the device backup holds the identity on this device, writing it
1370
+ * when it is missing or stale. Commons calls this once per launch once the
1371
+ * identity reads back healthy: it backfills identities created before the
1372
+ * device backup existed, and repairs a backup whose last write failed (a
1373
+ * failed write after a key rotation would otherwise leave the OLD key there).
1374
+ *
1375
+ * Carries the stored recovery phrase along only when it provably derives the
1376
+ * current key. Resolves `true` when the backup is current, `false` when there
1377
+ * is no store, no healthy identity, or the write failed. Never throws.
1378
+ */
1379
+ static async ensureDeviceBackup() {
1380
+ if (isWebPlatform() || !KeyManager.deviceBackupStore) {
1381
+ return false;
1382
+ }
1383
+ const primary = await KeyManager._readHealthyPrimary();
1384
+ if (!primary) {
1385
+ return false;
1386
+ }
1387
+ let existing = null;
1388
+ try {
1389
+ existing = (0, deviceBackup_1.parseDeviceBackup)(await KeyManager.deviceBackupStore.read());
1390
+ }
1391
+ catch {
1392
+ existing = null;
1393
+ }
1394
+ const pairCurrent = existing?.publicKey === primary.publicKey && existing?.privateKey === primary.privateKey;
1395
+ if (pairCurrent && existing?.mnemonic) {
1396
+ return true; // The common launch: one read, no write, no phrase derivation.
1397
+ }
1398
+ let mnemonic;
1399
+ try {
1400
+ const stored = await KeyManager.getRecoveryMnemonic();
1401
+ if (stored && (await KeyManager._phraseDerivesPublicKey(stored, primary.publicKey))) {
1402
+ mnemonic = stored;
1403
+ }
1404
+ }
1405
+ catch {
1406
+ mnemonic = undefined;
1407
+ }
1408
+ if (pairCurrent && !mnemonic) {
1409
+ return true;
1410
+ }
1411
+ return KeyManager._writeDeviceBackup(primary.privateKey, primary.publicKey, mnemonic);
1233
1412
  }
1234
1413
  /**
1235
1414
  * Write/refresh the identity marker after a successful persist. A same-identity
@@ -1548,6 +1727,15 @@ class KeyManager {
1548
1727
  }
1549
1728
  throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
1550
1729
  }
1730
+ // The phrase slot above dies with the keystore too, so the device backup
1731
+ // carries the phrase as well — but only one that derives the identity on
1732
+ // this device. Best-effort: never fails the store.
1733
+ if (KeyManager.deviceBackupStore) {
1734
+ const primary = await KeyManager._readHealthyPrimary();
1735
+ if (primary && (await KeyManager._phraseDerivesPublicKey(mnemonic, primary.publicKey))) {
1736
+ await KeyManager._writeDeviceBackup(primary.privateKey, primary.publicKey, mnemonic.trim().toLowerCase());
1737
+ }
1738
+ }
1551
1739
  }
1552
1740
  /**
1553
1741
  * Read the stored recovery mnemonic for re-reveal in Settings.
@@ -1810,6 +1998,10 @@ class KeyManager {
1810
1998
  await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
1811
1999
  await KeyManager._clearSharedSlot(store);
1812
2000
  }
2001
+ // The device backup goes on EVERY delete, forced or not: it is restored
2002
+ // silently, so leaving it would bring a deleted identity back on the next
2003
+ // launch. Best-effort, like the other recovery sources.
2004
+ await KeyManager._clearDeviceBackup();
1813
2005
  // Clear the marker AFTER key deletion succeeds — a marker must never outlive
1814
2006
  // its identity (a leftover marker would route a truly-absent device to
1815
2007
  // `recovery` instead of `welcome`).
@@ -2020,19 +2212,29 @@ class KeyManager {
2020
2212
  }
2021
2213
  }
2022
2214
  /**
2023
- * Recovery ladder — restore a `lost` identity from an independent,
2024
- * `key_v1`-surviving source WITHOUT the user re-entering their recovery phrase.
2215
+ * Recovery ladder — restore an identity whose keys are gone from an
2216
+ * independent source WITHOUT the user re-entering their recovery phrase.
2025
2217
  *
2026
- * Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
2027
- * - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
2028
- * - `unavailable` → `unavailable` (keychain locked; retry later)
2029
- *
2030
- * Rungs, tried in order, each fully validated (well-formed + derive-match +
2031
- * `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
2032
- * SKIPPED, never restored):
2033
- * 1. the v2 backup slot (independent keychain key from the primary), then
2218
+ * For a `lost` verdict (marker present, keys empty) the rungs are tried in
2219
+ * order, each fully validated (well-formed + derive-match + `publicKey ===
2220
+ * marker.publicKey`, so a source holding a DIFFERENT account is SKIPPED, never
2221
+ * restored):
2222
+ * 1. the v2 backup slot (independent keychain key from the primary),
2034
2223
  * 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
2035
- * group) — the copy that survives a primary+backup `key_v1` death.
2224
+ * group) — the copy that survives a primary+backup `key_v1` death,
2225
+ * 3. the device backup ({@link setDeviceBackupStore}; Android Block Store in
2226
+ * Commons) — the only copy that survives a wipe of the whole shared-UID
2227
+ * Android Keystore, which takes rungs 1 and 2 with it (oxy#1388).
2228
+ *
2229
+ * For an `absent` verdict (no keys AND no marker: this app's own data was
2230
+ * cleared, or it was reinstalled) only rung 3 applies: a device backup there
2231
+ * means an identity lived on this device and was never deleted (every delete
2232
+ * clears the backup), so it is restored instead of letting onboarding create a
2233
+ * new identity over it. Without a registered store the result stays
2234
+ * `not-lost`, exactly as before.
2235
+ *
2236
+ * `present` → `not-lost`; `unavailable` (keychain locked) → `unavailable`.
2237
+ * Everything here is on-device: no network, so it works offline.
2036
2238
  *
2037
2239
  * On success it re-persists via {@link _persistIdentityAtomic} (origin
2038
2240
  * `'restore'`) and invalidates the cache so routing re-reads `present`. When no
@@ -2043,12 +2245,24 @@ class KeyManager {
2043
2245
  return { recovered: false, reason: 'not-lost' };
2044
2246
  }
2045
2247
  const status = await KeyManager.getIdentityStatus({ bypassCache: true });
2046
- if (status.state === 'present' || status.state === 'absent') {
2248
+ if (status.state === 'present') {
2047
2249
  return { recovered: false, reason: 'not-lost' };
2048
2250
  }
2049
2251
  if (status.state === 'unavailable') {
2050
2252
  return { recovered: false, reason: 'unavailable' };
2051
2253
  }
2254
+ if (status.state === 'absent') {
2255
+ if (!KeyManager.deviceBackupStore) {
2256
+ return { recovered: false, reason: 'not-lost' };
2257
+ }
2258
+ const candidate = await KeyManager._readDeviceBackupCandidate();
2259
+ if (!candidate) {
2260
+ return { recovered: false, reason: 'not-lost' };
2261
+ }
2262
+ return (await KeyManager._commitDeviceBackupRecovery(candidate))
2263
+ ? { recovered: true, source: 'device-backup', publicKey: candidate.publicKey }
2264
+ : { recovered: false, reason: 'no-sources' };
2265
+ }
2052
2266
  // status.state === 'lost'
2053
2267
  const expectedPublic = status.marker.publicKey.toLowerCase();
2054
2268
  let sawMismatch = false;
@@ -2076,10 +2290,63 @@ class KeyManager {
2076
2290
  sawMismatch = true;
2077
2291
  }
2078
2292
  }
2293
+ // Rung 3: device backup, outside this UID's keystore.
2294
+ const deviceCandidate = await KeyManager._readDeviceBackupCandidate();
2295
+ if (deviceCandidate) {
2296
+ if (deviceCandidate.publicKey === expectedPublic) {
2297
+ if (await KeyManager._commitDeviceBackupRecovery(deviceCandidate)) {
2298
+ return { recovered: true, source: 'device-backup', publicKey: deviceCandidate.publicKey };
2299
+ }
2300
+ }
2301
+ else {
2302
+ sawMismatch = true;
2303
+ }
2304
+ }
2079
2305
  // A source existed but identified a DIFFERENT account — never silently
2080
2306
  // switched. Report `mismatch` so the UI can require explicit confirmation.
2081
2307
  return { recovered: false, reason: sawMismatch ? 'mismatch' : 'no-sources' };
2082
2308
  }
2309
+ /** Read the device backup as a healthy, canonical candidate, or null. Never throws. @internal */
2310
+ static async _readDeviceBackupCandidate() {
2311
+ const store = KeyManager.deviceBackupStore;
2312
+ if (!store) {
2313
+ return null;
2314
+ }
2315
+ try {
2316
+ const record = (0, deviceBackup_1.parseDeviceBackup)(await store.read());
2317
+ if (!record || !KeyManager._isHealthyPair(record.privateKey, record.publicKey)) {
2318
+ return null;
2319
+ }
2320
+ return {
2321
+ ...record,
2322
+ privateKey: KeyManager.canonicalPrivateKey(record.privateKey),
2323
+ publicKey: record.publicKey.toLowerCase(),
2324
+ };
2325
+ }
2326
+ catch (error) {
2327
+ logger_1.logger.warn(`Recovery: failed to read the device backup (${store.name})`, { component: 'KeyManager' }, error);
2328
+ return null;
2329
+ }
2330
+ }
2331
+ /**
2332
+ * Persist a device-backup candidate, then put its phrase back in the
2333
+ * re-reveal slot (which died with the keystore) when it derives the key.
2334
+ * @internal
2335
+ */
2336
+ static async _commitDeviceBackupRecovery(record) {
2337
+ if (!(await KeyManager._commitRecovery(record.privateKey, record.publicKey))) {
2338
+ return false;
2339
+ }
2340
+ if (record.mnemonic && (await KeyManager._phraseDerivesPublicKey(record.mnemonic, record.publicKey))) {
2341
+ try {
2342
+ await KeyManager.storeRecoveryMnemonic(record.mnemonic);
2343
+ }
2344
+ catch (error) {
2345
+ logger_1.logger.warn('Recovery: restored the identity but not its phrase slot', { component: 'KeyManager' }, error);
2346
+ }
2347
+ }
2348
+ return true;
2349
+ }
2083
2350
  /** Read the active-layout backup slot as a healthy candidate, or null. @internal */
2084
2351
  static async _readBackupCandidate() {
2085
2352
  try {
@@ -2272,4 +2539,10 @@ KeyManager.identityChangeListeners = new Set();
2272
2539
  */
2273
2540
  KeyManager.slotMigrationPromise = null;
2274
2541
  KeyManager.slotMigrationResult = null;
2542
+ /**
2543
+ * The keystore-independent device backup, registered by the app that owns the
2544
+ * identity (Commons). `null` in every other app, which makes every device
2545
+ * backup path a no-op there. See `./deviceBackup`.
2546
+ */
2547
+ KeyManager.deviceBackupStore = null;
2275
2548
  exports.default = KeyManager;
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OxyServicesLinkedAccountsMixin = OxyServicesLinkedAccountsMixin;
4
+ function OxyServicesLinkedAccountsMixin(Base) {
5
+ return class extends Base {
6
+ /**
7
+ * Begin linking an external account for the current user.
8
+ *
9
+ * @param network - `'activitypub'` (any Mastodon-API server) or `'atproto'` (Bluesky).
10
+ * @param options - `instance` for ActivityPub (`mastodon.social`, or
11
+ * `@user@mastodon.social`), `handle` for atproto; `clientId` + `returnTo`
12
+ * to be sent back to your app afterwards.
13
+ * @returns The URL to open, and when the attempt expires (ten minutes).
14
+ */
15
+ async startLinkedAccount(network, options) {
16
+ try {
17
+ return await this.makeRequest('POST', `/linked-accounts/${encodeURIComponent(network)}/start`, options, { cache: false });
18
+ }
19
+ catch (error) {
20
+ throw this.handleError(error);
21
+ }
22
+ }
23
+ /**
24
+ * Finish a link with the `link_code` the callback appended to `returnTo`.
25
+ * Must be the user who started the flow: for anyone else the API answers
26
+ * 403 and burns the code. 409 when the account is already someone else's
27
+ * link. Repeating it within the code's five minutes returns the same link.
28
+ */
29
+ async completeLinkedAccount(code) {
30
+ try {
31
+ const response = await this.makeRequest('POST', '/linked-accounts/complete', { code }, { cache: false });
32
+ return response.linkedAccount;
33
+ }
34
+ catch (error) {
35
+ throw this.handleError(error);
36
+ }
37
+ }
38
+ /** The current user's live linked accounts, oldest first. */
39
+ async listLinkedAccounts() {
40
+ try {
41
+ const response = await this.makeRequest('GET', '/linked-accounts', undefined, {
42
+ cache: false,
43
+ });
44
+ return response.linkedAccounts;
45
+ }
46
+ catch (error) {
47
+ throw this.handleError(error);
48
+ }
49
+ }
50
+ /**
51
+ * Unlink one of the current user's accounts. An ActivityPub link stops being
52
+ * published as an `alsoKnownAs` alias, and the external account can then be
53
+ * linked to another Oxy account.
54
+ */
55
+ async revokeLinkedAccount(linkedAccountId) {
56
+ try {
57
+ await this.makeRequest('DELETE', `/linked-accounts/${encodeURIComponent(linkedAccountId)}`, undefined, {
58
+ cache: false,
59
+ });
60
+ }
61
+ catch (error) {
62
+ throw this.handleError(error);
63
+ }
64
+ }
65
+ /**
66
+ * SERVICE: a user's live linked accounts, each with the federated shadow
67
+ * user Oxy already holds for it (`federatedUserId`). Requires a service
68
+ * credential whose application holds the privileged `linked-accounts:read`.
69
+ */
70
+ async getLinkedAccountsForUser(userId) {
71
+ try {
72
+ return await this.makeServiceRequest('GET', `/linked-accounts/by-user/${encodeURIComponent(userId)}`);
73
+ }
74
+ catch (error) {
75
+ throw this.handleError(error);
76
+ }
77
+ }
78
+ };
79
+ }
@@ -859,7 +859,11 @@ function OxyServicesUserMixin(Base) {
859
859
  }
860
860
  }
861
861
  /**
862
- * Create notification
862
+ * Create a notification (`POST /notifications`; the API requires a service
863
+ * token whose application holds `notifications:write`). `type` is one of
864
+ * `OXY_NOTIFICATION_TYPES` from `@oxy.so/contracts` — `system` for a message
865
+ * from an Oxy service about the recipient's own account, with the recipient
866
+ * as actor and their profile as the entity.
863
867
  */
864
868
  async createNotification(data) {
865
869
  try {
@@ -38,6 +38,7 @@ const OxyServices_nodes_1 = require("./OxyServices.nodes");
38
38
  const OxyServices_followGraph_1 = require("./OxyServices.followGraph");
39
39
  const OxyServices_inference_1 = require("./OxyServices.inference");
40
40
  const OxyServices_deviceBoot_1 = require("./OxyServices.deviceBoot");
41
+ const OxyServices_linkedAccounts_1 = require("./OxyServices.linkedAccounts");
41
42
  /**
42
43
  * Mixin pipeline - applied in order from first to last.
43
44
  *
@@ -107,6 +108,9 @@ const MIXIN_PIPELINE = [
107
108
  // Device-first token mint: the client half of the zero-cookie transport
108
109
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
109
110
  OxyServices_deviceBoot_1.OxyServicesDeviceBootMixin,
111
+ // Linked external accounts (Mastodon-API, Bluesky) proven by OAuth; the
112
+ // service read uses the auth mixin's `makeServiceRequest`.
113
+ OxyServices_linkedAccounts_1.OxyServicesLinkedAccountsMixin,
110
114
  // Device-to-device identity transfer ("add a device"): E2E-encrypted key
111
115
  // clone over a short-lived relay (b3 Feature 2).
112
116
  // Utility (last, can use all above)
@@ -353,6 +353,9 @@ function buildOxyPagesHeaders(options = {}) {
353
353
  if (options.hsts !== false) {
354
354
  lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
355
355
  }
356
+ if (options.sensitive) {
357
+ lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
358
+ }
356
359
  lines.push('');
357
360
  return lines.join('\n');
358
361
  }