@oxyhq/core 20.0.0 → 21.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/NOTICE +10 -9
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  4. package/dist/cjs/i18n/locales/en-US.json +19 -2
  5. package/dist/cjs/i18n/locales/es-ES.json +19 -2
  6. package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
  7. package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
  8. package/dist/cjs/index.js +50 -16
  9. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  10. package/dist/cjs/mixins/OxyServices.chains.js +73 -0
  11. package/dist/cjs/mixins/OxyServices.store.js +266 -0
  12. package/dist/cjs/mixins/OxyServices.utility.js +159 -104
  13. package/dist/cjs/mixins/index.js +7 -0
  14. package/dist/cjs/server/rateLimit.js +15 -6
  15. package/dist/cjs/session/SessionClient.js +361 -1
  16. package/dist/cjs/session/accountDialogController.js +121 -147
  17. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  18. package/dist/cjs/session/deviceDirectory.js +143 -0
  19. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  20. package/dist/cjs/session/projectSessionState.js +8 -1
  21. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  22. package/dist/esm/.tsbuildinfo +1 -1
  23. package/dist/esm/boot/sessionColdBoot.js +107 -8
  24. package/dist/esm/i18n/locales/en-US.json +19 -2
  25. package/dist/esm/i18n/locales/es-ES.json +19 -2
  26. package/dist/esm/i18n/locales/locales/en-US.json +19 -2
  27. package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
  28. package/dist/esm/index.js +32 -10
  29. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  30. package/dist/esm/mixins/OxyServices.chains.js +70 -0
  31. package/dist/esm/mixins/OxyServices.store.js +263 -0
  32. package/dist/esm/mixins/OxyServices.utility.js +159 -104
  33. package/dist/esm/mixins/index.js +7 -0
  34. package/dist/esm/server/rateLimit.js +15 -6
  35. package/dist/esm/session/SessionClient.js +362 -2
  36. package/dist/esm/session/accountDialogController.js +121 -147
  37. package/dist/esm/session/accountSwitchTargets.js +71 -0
  38. package/dist/esm/session/deviceDirectory.js +135 -0
  39. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  40. package/dist/esm/session/projectSessionState.js +8 -2
  41. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  42. package/dist/types/.tsbuildinfo +1 -1
  43. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  44. package/dist/types/index.d.ts +15 -3
  45. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  46. package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
  47. package/dist/types/mixins/OxyServices.store.d.ts +334 -0
  48. package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
  49. package/dist/types/mixins/index.d.ts +3 -1
  50. package/dist/types/models/session.d.ts +11 -0
  51. package/dist/types/session/SessionClient.d.ts +202 -1
  52. package/dist/types/session/accountDialogController.d.ts +76 -64
  53. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  54. package/dist/types/session/deviceDirectory.d.ts +182 -0
  55. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  56. package/dist/types/session/projectSessionState.d.ts +29 -0
  57. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  58. package/package.json +3 -3
  59. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  60. package/src/boot/sessionColdBoot.ts +133 -9
  61. package/src/i18n/locales/en-US.json +19 -2
  62. package/src/i18n/locales/es-ES.json +19 -2
  63. package/src/index.ts +105 -18
  64. package/src/mixins/OxyServices.auth.ts +67 -5
  65. package/src/mixins/OxyServices.chains.ts +134 -0
  66. package/src/mixins/OxyServices.store.ts +585 -0
  67. package/src/mixins/OxyServices.utility.ts +161 -108
  68. package/src/mixins/__tests__/chains.test.ts +113 -0
  69. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  70. package/src/mixins/__tests__/store.test.ts +304 -0
  71. package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
  72. package/src/mixins/index.ts +9 -0
  73. package/src/models/session.ts +11 -0
  74. package/src/server/__tests__/rateLimit.test.ts +47 -0
  75. package/src/server/rateLimit.ts +18 -8
  76. package/src/session/SessionClient.ts +386 -1
  77. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  78. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  79. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  80. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  81. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  82. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  83. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  84. package/src/session/accountDialogController.ts +141 -179
  85. package/src/session/accountSwitchTargets.ts +87 -0
  86. package/src/session/deviceDirectory.ts +269 -0
  87. package/src/session/deviceSwitcherRows.ts +145 -0
  88. package/src/session/projectSessionState.ts +9 -3
  89. package/src/session/sharedDeviceCredential.ts +349 -0
  90. package/dist/cjs/session/accountProjection.js +0 -213
  91. package/dist/esm/session/accountProjection.js +0 -207
  92. package/dist/types/session/accountProjection.d.ts +0 -198
  93. package/src/session/__tests__/accountProjection.test.ts +0 -447
  94. package/src/session/accountProjection.ts +0 -354
@@ -18,10 +18,17 @@ exports.runSessionColdBoot = runSessionColdBoot;
18
18
  * origin persisted a `deviceId` + `deviceSecret`, mint a short access token
19
19
  * with a single bearer-less POST to `/session/device/token` (no cookie, no
20
20
  * navigation) and rotate the secret in-use.
21
- * 3. `shared-key-signin` (native, ACCOUNT mode) — re-mint from the
22
- * shared-keychain identity OR `identity-key-signin` (IDENTITY mode)
23
- * re-mint from THIS device's primary identity key.
24
- * 4. Signed out.
21
+ * 3. `shared-device-adopt` (native, ACCOUNT mode) — this app has no credential
22
+ * of its own but a sibling official app already put one in the shared native
23
+ * slot: adopt it and mint. This is how a newly installed official app joins
24
+ * the device's existing session WITHOUT another QR and without ever touching
25
+ * the Commons private key.
26
+ * 4. `shared-key-signin` (native, ACCOUNT mode) — the legacy lane: re-mint by
27
+ * signing with the shared-keychain IDENTITY key. Retained as a recovery /
28
+ * compatibility path for devices whose apps have not yet published a shared
29
+ * device credential — OR `identity-key-signin` (IDENTITY mode) — re-mint
30
+ * from THIS device's primary identity key.
31
+ * 5. Signed out.
25
32
  *
26
33
  * Two session modes (see {@link RunSessionColdBootOptions.sessionMode}):
27
34
  * - `account` (default) — the device's ACTIVE account owns the session. Every
@@ -40,6 +47,7 @@ const logger_1 = require("../logger");
40
47
  const cacheKey_1 = require("../utils/cacheKey");
41
48
  const refresh_1 = require("../session/refresh");
42
49
  const identitySession_1 = require("../session/identitySession");
50
+ const sharedDeviceCredential_1 = require("../session/sharedDeviceCredential");
43
51
  /**
44
52
  * Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
45
53
  * a side effect, invokes `onSession` (winning session, token already planted) or
@@ -239,10 +247,101 @@ async function runSessionColdBoot(opts) {
239
247
  },
240
248
  });
241
249
  }
242
- else {
243
- // 3. shared-key-signin (native) — re-mint from the shared identity. Native
244
- // AND online: it is a network step (challenge + verify round-trips), so it
245
- // is gated by the same offline hint as the mint lane. `{ retry: false }`
250
+ else if (opts.sharedDeviceCredential) {
251
+ // 3. shared-device-adopt (native, ACCOUNT mode) — join the device's existing
252
+ // session through the shared native credential slot.
253
+ //
254
+ // This is the lane that separates identity from session transport. What it
255
+ // reads is an ordinary, individually revocable `deviceId` + `deviceSecret`
256
+ // put there by a sibling official app — never the Commons private key. It
257
+ // is what lets a freshly installed official app land signed in with no QR,
258
+ // and it is why an ordinary app never needs identity-key access at all.
259
+ //
260
+ // `decideSharedDeviceJoin` gates it: an app that already holds its own
261
+ // credential is never moved, and an UNREADABLE slot is never mistaken for
262
+ // an empty one. The lane therefore cannot sign anyone out, in either
263
+ // upgrade order.
264
+ const sharedSlot = opts.sharedDeviceCredential;
265
+ steps.push({
266
+ id: 'shared-device-adopt',
267
+ // The adoption itself is local, but it is only worth committing alongside
268
+ // a mint that proves the credential — so the whole lane is online-gated
269
+ // like every other network step.
270
+ enabled: () => isNative && !isOffline(),
271
+ run: async () => {
272
+ const before = await store.load();
273
+ const decision = (0, sharedDeviceCredential_1.decideSharedDeviceJoin)(before, await sharedSlot.read());
274
+ if (decision.action === 'skip') {
275
+ logger_1.logger.debug(`shared device credential not adopted (${decision.reason})`, { component: 'sessionColdBoot', method: 'shared-device-adopt' });
276
+ return { kind: 'skip' };
277
+ }
278
+ // Restore the store to exactly what it held before this lane touched it.
279
+ // A credential we adopted and could not prove must not be left behind for
280
+ // the next boot's mint lane to keep retrying.
281
+ const revert = async () => {
282
+ if (before) {
283
+ await store.save(before);
284
+ }
285
+ else {
286
+ await store.clear();
287
+ }
288
+ };
289
+ const adopted = {
290
+ // The mint fills both in from the device's live state; carrying the
291
+ // previous session's ids into a different device session would be a
292
+ // lie for however long the mint takes.
293
+ sessionId: '',
294
+ userId: '',
295
+ deviceId: decision.credential.deviceId,
296
+ deviceSecret: decision.credential.deviceSecret,
297
+ };
298
+ if (!(await store.save(adopted))) {
299
+ logger_1.logger.error('adopted the shared device credential but it could not be durably persisted — reverting', undefined, { component: 'sessionColdBoot', method: 'shared-device-adopt' });
300
+ await revert();
301
+ return { kind: 'skip' };
302
+ }
303
+ const result = await (0, refresh_1.refreshDeviceSecretArm)({ oxy, store, pin: null });
304
+ if (result.status === 'ok') {
305
+ return {
306
+ kind: 'session',
307
+ session: {
308
+ sessionId: result.sessionId,
309
+ userId: result.userId,
310
+ accessToken: result.token,
311
+ },
312
+ };
313
+ }
314
+ if (result.status === 'invalid-secret') {
315
+ // The one place we hold POSITIVE proof that the exact bytes in the
316
+ // shared slot are dead — the server rejected them by name. Clearing it
317
+ // signs nobody out (a credential the server does not recognise cannot
318
+ // be minting for anyone) and it is what stops a dead credential from
319
+ // blocking every future install: a stale slot owned by a different
320
+ // `deviceId` is otherwise never overwritten, by design.
321
+ await sharedSlot.clear();
322
+ }
323
+ else if (result.status === 'no-session') {
324
+ signedOutReason = 'no_session';
325
+ }
326
+ await revert();
327
+ return { kind: 'skip' };
328
+ },
329
+ });
330
+ }
331
+ if (identityBinding === null) {
332
+ // 4. shared-key-signin (native) — the RECOVERY / COMPATIBILITY lane: sign a
333
+ // challenge with the shared-keychain IDENTITY key to re-mint a session.
334
+ //
335
+ // It runs LAST on purpose. Using the self-custody key to obtain an ordinary
336
+ // session is the over-sharing #937 sets out to end, so it is now reachable
337
+ // only on a device where no sibling app has published a shared device
338
+ // credential yet — an install that predates this lane, or one where the
339
+ // shared slot is unreadable. Its own `store.save` below feeds the shared
340
+ // slot through the mirroring store, so the FIRST boot that takes this lane
341
+ // is also the last one that needs to: every later app joins by credential.
342
+ //
343
+ // Native AND online: it is a network step (challenge + verify round-trips),
344
+ // so it is gated by the same offline hint as the mint lane. `{ retry: false }`
246
345
  // keeps the two round-trips as single attempts — the refresh scheduler /
247
346
  // 401 lane own later retries — so this step cannot multiply boot latency.
248
347
  steps.push({
@@ -396,13 +396,22 @@
396
396
  "remoteSignOutFailed": "There was a problem signing out from the device. Please try again.",
397
397
  "noOtherDeviceSessions": "No other device sessions found to sign out from.",
398
398
  "signOutOthersSuccess": "Signed out from all other devices successfully!",
399
- "signOutOthersFailed": "There was a problem signing out from other devices. Please try again."
399
+ "signOutOthersFailed": "There was a problem signing out from other devices. Please try again.",
400
+ "contextRemoved": "Removed {{account}}",
401
+ "contextRemoveFailed": "There was a problem removing that account. Please try again.",
402
+ "principalRemoved": "Signed {{name}} out of this device",
403
+ "principalRemoveFailed": "There was a problem signing that person out. Please try again.",
404
+ "activateFailed": "There was a problem switching accounts. Please try again."
400
405
  },
401
406
  "confirms": {
402
407
  "remove": "Are you sure you want to remove {{displayName}} from this device? You'll need to sign in again to access this account.",
403
408
  "logoutAll": "Are you sure you want to sign out of all accounts? This will remove all saved accounts from this device.",
404
409
  "remoteLogout": "Are you sure you want to sign out from \"{{deviceName}}\"? This will end the session on that device.",
405
- "logoutOthers": "Are you sure you want to sign out from all {{count}} other device(s)? This will end sessions on all other devices except this one."
410
+ "logoutOthers": "Are you sure you want to sign out from all {{count}} other device(s)? This will end sessions on all other devices except this one.",
411
+ "removeContextTitle": "Remove this account?",
412
+ "removeContext": "Stop {{person}} acting as {{account}} on this device? Anyone else who reaches {{account}} here keeps their access.",
413
+ "removePrincipalTitle": "Sign out of this device?",
414
+ "removePrincipal": "Sign {{name}} out of this device? Every account they reach here is removed. Nobody else is affected."
406
415
  },
407
416
  "device": {
408
417
  "loadingTitle": "Loading device sessions...",
@@ -439,6 +448,14 @@
439
448
  "openedInCommons": "Opened in Commons",
440
449
  "confirming": "Confirming identity",
441
450
  "confirmed": "Identity confirmed"
451
+ },
452
+ "context": {
453
+ "unavailable": "Unavailable right now",
454
+ "operatedBy": "Operated by {{name}}",
455
+ "remove": "Remove {{account}} from {{person}}"
456
+ },
457
+ "principal": {
458
+ "signOut": "Sign {{name}} out of this device"
442
459
  }
443
460
  },
444
461
  "reputation": {
@@ -636,13 +636,22 @@
636
636
  "remoteSignOutFailed": "Hubo un problema al cerrar sesión en el dispositivo. Inténtalo de nuevo.",
637
637
  "noOtherDeviceSessions": "No se encontraron otras sesiones de dispositivo para cerrar.",
638
638
  "signOutOthersSuccess": "¡Sesiones cerradas correctamente en los demás dispositivos!",
639
- "signOutOthersFailed": "Hubo un problema al cerrar sesión en otros dispositivos. Inténtalo de nuevo."
639
+ "signOutOthersFailed": "Hubo un problema al cerrar sesión en otros dispositivos. Inténtalo de nuevo.",
640
+ "contextRemoved": "Se quitó {{account}}",
641
+ "contextRemoveFailed": "Hubo un problema al quitar esa cuenta. Inténtalo de nuevo.",
642
+ "principalRemoved": "Se cerró la sesión de {{name}} en este dispositivo",
643
+ "principalRemoveFailed": "Hubo un problema al cerrar esa sesión. Inténtalo de nuevo.",
644
+ "activateFailed": "Hubo un problema al cambiar de cuenta. Inténtalo de nuevo."
640
645
  },
641
646
  "confirms": {
642
647
  "remove": "¿Seguro que quieres eliminar a {{displayName}} de este dispositivo? Tendrás que iniciar sesión de nuevo para acceder a esta cuenta.",
643
648
  "logoutAll": "¿Seguro que quieres cerrar sesión en todas las cuentas? Esto eliminará todas las cuentas guardadas de este dispositivo.",
644
649
  "remoteLogout": "¿Seguro que quieres cerrar sesión en \"{{deviceName}}\"? Esto finalizará la sesión en ese dispositivo.",
645
- "logoutOthers": "¿Seguro que quieres cerrar sesión en los otros {{count}} dispositivo(s)? Esto finalizará las sesiones en todos los demás dispositivos excepto en este."
650
+ "logoutOthers": "¿Seguro que quieres cerrar sesión en los otros {{count}} dispositivo(s)? Esto finalizará las sesiones en todos los demás dispositivos excepto en este.",
651
+ "removeContextTitle": "¿Quitar esta cuenta?",
652
+ "removeContext": "¿Dejar de usar {{account}} como {{person}} en este dispositivo? Quien más acceda a {{account}} aquí lo conserva.",
653
+ "removePrincipalTitle": "¿Cerrar sesión en este dispositivo?",
654
+ "removePrincipal": "¿Cerrar la sesión de {{name}} en este dispositivo? Se quitan todas las cuentas a las que llega desde aquí. No afecta a nadie más."
646
655
  },
647
656
  "device": {
648
657
  "loadingTitle": "Cargando sesiones de dispositivo...",
@@ -679,6 +688,14 @@
679
688
  "openedInCommons": "Abierto en Commons",
680
689
  "confirming": "Confirmando identidad",
681
690
  "confirmed": "Identidad confirmada"
691
+ },
692
+ "context": {
693
+ "unavailable": "No disponible ahora mismo",
694
+ "operatedBy": "Gestionada por {{name}}",
695
+ "remove": "Quitar {{account}} de {{person}}"
696
+ },
697
+ "principal": {
698
+ "signOut": "Cerrar la sesión de {{name}} en este dispositivo"
682
699
  }
683
700
  },
684
701
  "feedback": {
@@ -396,13 +396,22 @@
396
396
  "remoteSignOutFailed": "There was a problem signing out from the device. Please try again.",
397
397
  "noOtherDeviceSessions": "No other device sessions found to sign out from.",
398
398
  "signOutOthersSuccess": "Signed out from all other devices successfully!",
399
- "signOutOthersFailed": "There was a problem signing out from other devices. Please try again."
399
+ "signOutOthersFailed": "There was a problem signing out from other devices. Please try again.",
400
+ "contextRemoved": "Removed {{account}}",
401
+ "contextRemoveFailed": "There was a problem removing that account. Please try again.",
402
+ "principalRemoved": "Signed {{name}} out of this device",
403
+ "principalRemoveFailed": "There was a problem signing that person out. Please try again.",
404
+ "activateFailed": "There was a problem switching accounts. Please try again."
400
405
  },
401
406
  "confirms": {
402
407
  "remove": "Are you sure you want to remove {{displayName}} from this device? You'll need to sign in again to access this account.",
403
408
  "logoutAll": "Are you sure you want to sign out of all accounts? This will remove all saved accounts from this device.",
404
409
  "remoteLogout": "Are you sure you want to sign out from \"{{deviceName}}\"? This will end the session on that device.",
405
- "logoutOthers": "Are you sure you want to sign out from all {{count}} other device(s)? This will end sessions on all other devices except this one."
410
+ "logoutOthers": "Are you sure you want to sign out from all {{count}} other device(s)? This will end sessions on all other devices except this one.",
411
+ "removeContextTitle": "Remove this account?",
412
+ "removeContext": "Stop {{person}} acting as {{account}} on this device? Anyone else who reaches {{account}} here keeps their access.",
413
+ "removePrincipalTitle": "Sign out of this device?",
414
+ "removePrincipal": "Sign {{name}} out of this device? Every account they reach here is removed. Nobody else is affected."
406
415
  },
407
416
  "device": {
408
417
  "loadingTitle": "Loading device sessions...",
@@ -439,6 +448,14 @@
439
448
  "openedInCommons": "Opened in Commons",
440
449
  "confirming": "Confirming identity",
441
450
  "confirmed": "Identity confirmed"
451
+ },
452
+ "context": {
453
+ "unavailable": "Unavailable right now",
454
+ "operatedBy": "Operated by {{name}}",
455
+ "remove": "Remove {{account}} from {{person}}"
456
+ },
457
+ "principal": {
458
+ "signOut": "Sign {{name}} out of this device"
442
459
  }
443
460
  },
444
461
  "reputation": {
@@ -636,13 +636,22 @@
636
636
  "remoteSignOutFailed": "Hubo un problema al cerrar sesión en el dispositivo. Inténtalo de nuevo.",
637
637
  "noOtherDeviceSessions": "No se encontraron otras sesiones de dispositivo para cerrar.",
638
638
  "signOutOthersSuccess": "¡Sesiones cerradas correctamente en los demás dispositivos!",
639
- "signOutOthersFailed": "Hubo un problema al cerrar sesión en otros dispositivos. Inténtalo de nuevo."
639
+ "signOutOthersFailed": "Hubo un problema al cerrar sesión en otros dispositivos. Inténtalo de nuevo.",
640
+ "contextRemoved": "Se quitó {{account}}",
641
+ "contextRemoveFailed": "Hubo un problema al quitar esa cuenta. Inténtalo de nuevo.",
642
+ "principalRemoved": "Se cerró la sesión de {{name}} en este dispositivo",
643
+ "principalRemoveFailed": "Hubo un problema al cerrar esa sesión. Inténtalo de nuevo.",
644
+ "activateFailed": "Hubo un problema al cambiar de cuenta. Inténtalo de nuevo."
640
645
  },
641
646
  "confirms": {
642
647
  "remove": "¿Seguro que quieres eliminar a {{displayName}} de este dispositivo? Tendrás que iniciar sesión de nuevo para acceder a esta cuenta.",
643
648
  "logoutAll": "¿Seguro que quieres cerrar sesión en todas las cuentas? Esto eliminará todas las cuentas guardadas de este dispositivo.",
644
649
  "remoteLogout": "¿Seguro que quieres cerrar sesión en \"{{deviceName}}\"? Esto finalizará la sesión en ese dispositivo.",
645
- "logoutOthers": "¿Seguro que quieres cerrar sesión en los otros {{count}} dispositivo(s)? Esto finalizará las sesiones en todos los demás dispositivos excepto en este."
650
+ "logoutOthers": "¿Seguro que quieres cerrar sesión en los otros {{count}} dispositivo(s)? Esto finalizará las sesiones en todos los demás dispositivos excepto en este.",
651
+ "removeContextTitle": "¿Quitar esta cuenta?",
652
+ "removeContext": "¿Dejar de usar {{account}} como {{person}} en este dispositivo? Quien más acceda a {{account}} aquí lo conserva.",
653
+ "removePrincipalTitle": "¿Cerrar sesión en este dispositivo?",
654
+ "removePrincipal": "¿Cerrar la sesión de {{name}} en este dispositivo? Se quitan todas las cuentas a las que llega desde aquí. No afecta a nadie más."
646
655
  },
647
656
  "device": {
648
657
  "loadingTitle": "Cargando sesiones de dispositivo...",
@@ -679,6 +688,14 @@
679
688
  "openedInCommons": "Abierto en Commons",
680
689
  "confirming": "Confirmando identidad",
681
690
  "confirmed": "Identidad confirmada"
691
+ },
692
+ "context": {
693
+ "unavailable": "No disponible ahora mismo",
694
+ "operatedBy": "Gestionada por {{name}}",
695
+ "remove": "Quitar {{account}} de {{person}}"
696
+ },
697
+ "principal": {
698
+ "signOut": "Cerrar la sesión de {{name}} en este dispositivo"
682
699
  }
683
700
  },
684
701
  "feedback": {
package/dist/cjs/index.js CHANGED
@@ -21,8 +21,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.AEAD_KEY_LENGTH = exports.decryptAead = exports.encryptAead = exports.hkdfSha256 = exports.RecoveryPhraseService = exports.SignatureService = exports.updateIdentityMarker = exports.readIdentityMarker = exports.IdentityUnavailableError = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.kindAcceptsAccountCategories = exports.isSelectableAccountCategoryId = exports.SELECTABLE_ACCOUNT_CATEGORY_IDS = exports.MAX_ACCOUNT_CATEGORIES = exports.ACCOUNT_CATEGORY_IDS = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.commonsDeliveryPlatform = exports.pushTargetsFromDelivery = exports.selectCommonsDelivery = exports.parseCommonsApprovalExpiresAt = exports.getCommonsApprovalBlockingReason = exports.ServiceCredentialMismatchError = exports.oxyClient = exports.OXY_CLOUD_URL = exports.ServiceAssetMetadataError = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.AssetUrlResolutionError = exports.OxyServices = void 0;
22
22
  exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.deriveSharedSecret = exports.AEAD_NONCE_LENGTH = void 0;
23
23
  exports.normalizeInlineText = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.isValidDisplayName = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.DISPLAY_NAME_INVALID_MESSAGE = exports.MAX_DISPLAY_NAME_LENGTH = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.parseHttpErrorBody = exports.isHttpRequestError = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.buildQueryParams = exports.trustTierLabel = exports.reputationCategoryLabel = exports.accountRoleLabel = exports.accountCategoryLabel = exports.translate = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = void 0;
24
- exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.canSwitchIntoAccount = exports.isSwitchTargetAccount = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.runColdBoot = exports.isOxyRpOrigin = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = void 0;
25
- exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.AccountNotOnDeviceError = exports.establishIdentitySession = exports.resolveIdentityPin = exports.IDENTITY_PIN_STORAGE_KEY = exports.identityPinMatches = exports.createMemoryIdentityPinStore = exports.createNativeIdentityPinStore = exports.createWebIdentityPinStore = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = void 0;
24
+ exports.projectDevicePrincipals = exports.directoryHandle = exports.directoryDisplayName = exports.canActivateContext = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.runColdBoot = exports.isOxyRpOrigin = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = void 0;
25
+ exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.AccountNotOnDeviceError = exports.establishIdentitySession = exports.resolveIdentityPin = exports.IDENTITY_PIN_STORAGE_KEY = exports.identityPinMatches = exports.createMemoryIdentityPinStore = exports.createNativeIdentityPinStore = exports.createWebIdentityPinStore = exports.readLocalDeviceCredential = exports.publishProvenDeviceCredential = exports.normalizeSharedDeviceSessionRead = exports.decideSharedDevicePublish = exports.decideSharedDeviceJoin = exports.createSharedMirroringAuthStateStore = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.canSwitchIntoAccount = exports.isSwitchTargetAccount = exports.showsPrincipalHeaders = exports.buildSwitcherRows = exports.resolveDeviceContext = exports.resolveActiveContext = void 0;
26
26
  // Ensure crypto polyfills are loaded before anything else
27
27
  require("./crypto/polyfill");
28
28
  // ---------------------------------------------------------------------------
@@ -376,20 +376,42 @@ Object.defineProperty(exports, "deviceStateToClientSessions", { enumerable: true
376
376
  Object.defineProperty(exports, "activeSessionIdOf", { enumerable: true, get: function () { return projectSessionState_1.activeSessionIdOf; } });
377
377
  Object.defineProperty(exports, "activeUserOf", { enumerable: true, get: function () { return projectSessionState_1.activeUserOf; } });
378
378
  Object.defineProperty(exports, "accountIdsOf", { enumerable: true, get: function () { return projectSessionState_1.accountIdsOf; } });
379
- // Unified account-list projection (THE single source of truth for the account
380
- // chooser: device sign-ins account graph, deduped by accountId). Pure +
381
- // I/O-free the caller hydrates profiles via `getUsersByIds`. Shared by
382
- // `@oxyhq/services` and auth.oxy.so so the list can't diverge.
383
- // `isSwitchTargetAccount` is the structural half ("is this kind switchable at
384
- // all?"); `canSwitchIntoAccount` adds the caller's `account:act_as` permission.
385
- // Both are exported so surfaces that render `AccountNode`s rather than the
386
- // projection the Console workspace switcher, managed-accounts rows ask the
387
- // SAME questions instead of testing a kind literal.
388
- var accountProjection_1 = require("./session/accountProjection");
389
- Object.defineProperty(exports, "isSwitchTargetAccount", { enumerable: true, get: function () { return accountProjection_1.isSwitchTargetAccount; } });
390
- Object.defineProperty(exports, "canSwitchIntoAccount", { enumerable: true, get: function () { return accountProjection_1.canSwitchIntoAccount; } });
391
- Object.defineProperty(exports, "projectSwitchableAccounts", { enumerable: true, get: function () { return accountProjection_1.projectSwitchableAccounts; } });
392
- Object.defineProperty(exports, "switchableAccountIds", { enumerable: true, get: function () { return accountProjection_1.switchableAccountIds; } });
379
+ // Pure projections over the device DIRECTORY (`GET /session/device/directory`,
380
+ // ADR 0002) the read model that keeps the actor (the human who authenticated)
381
+ // and the subject (the account being acted as) apart. The flat
382
+ // `DeviceSessionState` collapses them into one row, so it can neither tell
383
+ // "signed in as an org" from "a person operating that org" nor hold two people
384
+ // reaching the same org on one device.
385
+ // `canActivateContext` is the switchability question `available` alone, never
386
+ // composed with `onDevice`, which is a different fact in both directions.
387
+ // `projectDevicePrincipals` is the switcher's shape: people, each with what
388
+ // they may become. Grouped rather than flat because the same organization
389
+ // reached through two people is TWO rows under two humans, which a list keyed
390
+ // by account cannot say.
391
+ var deviceDirectory_1 = require("./session/deviceDirectory");
392
+ Object.defineProperty(exports, "canActivateContext", { enumerable: true, get: function () { return deviceDirectory_1.canActivateContext; } });
393
+ Object.defineProperty(exports, "directoryDisplayName", { enumerable: true, get: function () { return deviceDirectory_1.directoryDisplayName; } });
394
+ Object.defineProperty(exports, "directoryHandle", { enumerable: true, get: function () { return deviceDirectory_1.directoryHandle; } });
395
+ Object.defineProperty(exports, "projectDevicePrincipals", { enumerable: true, get: function () { return deviceDirectory_1.projectDevicePrincipals; } });
396
+ Object.defineProperty(exports, "resolveActiveContext", { enumerable: true, get: function () { return deviceDirectory_1.resolveActiveContext; } });
397
+ Object.defineProperty(exports, "resolveDeviceContext", { enumerable: true, get: function () { return deviceDirectory_1.resolveDeviceContext; } });
398
+ // The switcher's RENDER model over that projection — names, handles and avatar
399
+ // URLs resolved once. Shared by `@oxyhq/services`' account dialog and the
400
+ // auth.oxy.so chooser so the two cannot drift, the same reason the flat
401
+ // projection lived here before it.
402
+ var deviceSwitcherRows_1 = require("./session/deviceSwitcherRows");
403
+ Object.defineProperty(exports, "buildSwitcherRows", { enumerable: true, get: function () { return deviceSwitcherRows_1.buildSwitcherRows; } });
404
+ Object.defineProperty(exports, "showsPrincipalHeaders", { enumerable: true, get: function () { return deviceSwitcherRows_1.showsPrincipalHeaders; } });
405
+ // The switch-target predicates over the account GRAPH — a list of accounts to
406
+ // manage, not the device's list of identities to become (that is the directory
407
+ // above). `isSwitchTargetAccount` is the structural half ("is this kind
408
+ // switchable at all?"); `canSwitchIntoAccount` adds the caller's
409
+ // `account:act_as` permission. Exported so the surfaces that render
410
+ // `AccountNode`s — the Console workspace switcher, managed-accounts rows — ask
411
+ // the SAME questions instead of testing a kind literal.
412
+ var accountSwitchTargets_1 = require("./session/accountSwitchTargets");
413
+ Object.defineProperty(exports, "isSwitchTargetAccount", { enumerable: true, get: function () { return accountSwitchTargets_1.isSwitchTargetAccount; } });
414
+ Object.defineProperty(exports, "canSwitchIntoAccount", { enumerable: true, get: function () { return accountSwitchTargets_1.canSwitchIntoAccount; } });
393
415
  // Headless controller for the unified account dialog. Framework-agnostic
394
416
  // state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
395
417
  // — sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff;
@@ -411,6 +433,18 @@ Object.defineProperty(exports, "createWebAuthStateStore", { enumerable: true, ge
411
433
  Object.defineProperty(exports, "createNativeAuthStateStore", { enumerable: true, get: function () { return authStateStore_1.createNativeAuthStateStore; } });
412
434
  Object.defineProperty(exports, "createMemoryAuthStateStore", { enumerable: true, get: function () { return authStateStore_1.createMemoryAuthStateStore; } });
413
435
  Object.defineProperty(exports, "AUTH_STATE_STORAGE_KEY", { enumerable: true, get: function () { return authStateStore_1.AUTH_STATE_STORAGE_KEY; } });
436
+ // The shared NATIVE DeviceSession credential — how several official apps on one
437
+ // device end up on ONE `DeviceSession` and therefore one active context. It is an
438
+ // ordinary rotatable/revocable `deviceId` + `deviceSecret`, deliberately NOT the
439
+ // Commons private identity key: an app that only needs a session must never be
440
+ // handed the key that signs identity approvals.
441
+ var sharedDeviceCredential_1 = require("./session/sharedDeviceCredential");
442
+ Object.defineProperty(exports, "createSharedMirroringAuthStateStore", { enumerable: true, get: function () { return sharedDeviceCredential_1.createSharedMirroringAuthStateStore; } });
443
+ Object.defineProperty(exports, "decideSharedDeviceJoin", { enumerable: true, get: function () { return sharedDeviceCredential_1.decideSharedDeviceJoin; } });
444
+ Object.defineProperty(exports, "decideSharedDevicePublish", { enumerable: true, get: function () { return sharedDeviceCredential_1.decideSharedDevicePublish; } });
445
+ Object.defineProperty(exports, "normalizeSharedDeviceSessionRead", { enumerable: true, get: function () { return sharedDeviceCredential_1.normalizeSharedDeviceSessionRead; } });
446
+ Object.defineProperty(exports, "publishProvenDeviceCredential", { enumerable: true, get: function () { return sharedDeviceCredential_1.publishProvenDeviceCredential; } });
447
+ Object.defineProperty(exports, "readLocalDeviceCredential", { enumerable: true, get: function () { return sharedDeviceCredential_1.readLocalDeviceCredential; } });
414
448
  // Identity-bound sessions (the identity vault). The pin is the durable
415
449
  // `{publicKey, accountId}` binding between this device's PRIMARY identity key
416
450
  // and the account it authenticates as; it is what keeps such a client from
@@ -1147,6 +1147,23 @@ function OxyServicesAuthMixin(Base) {
1147
1147
  * response this method used before were an Oxy invention no OAuth library
1148
1148
  * could interoperate with; the endpoint no longer accepts them. The method's
1149
1149
  * OWN signature is unchanged, so callers are unaffected.
1150
+ *
1151
+ * `deviceId` + `deviceSecret` are OPTIONAL and their absence is a valid
1152
+ * outcome, not an error. A third-party grant is meant to be isolated from the
1153
+ * browser's shared DeviceSession, so the token endpoint must be free to return
1154
+ * no device credential at all — the guard that used to require the pair made
1155
+ * that omission unshippable, since it turned every third-party sign-in through
1156
+ * the SDK into a silent `exchange-failed`.
1157
+ *
1158
+ * The cost is real and deliberate: a DEVICE-LESS session cannot use the
1159
+ * zero-cookie mint lane (`POST /session/device/token`), because that lane's
1160
+ * whole proof is possession of a `deviceSecret`. Its lifetime is therefore the
1161
+ * access token itself — nothing persists a restore credential, the cold boot's
1162
+ * `device-secret-mint` step reports `no-secret` and skips, and the refresh
1163
+ * scheduler has nothing to re-mint from. When the token expires the session
1164
+ * ends LOUDLY: the 401 lane clears the tokens and the provider resolves signed
1165
+ * out, so the app can run the OAuth flow again. It never degrades into a
1166
+ * session that looks alive and cannot refresh.
1150
1167
  */
1151
1168
  async exchangeOAuthCode(params) {
1152
1169
  try {
@@ -1168,7 +1185,9 @@ function OxyServicesAuthMixin(Base) {
1168
1185
  const deviceId = typeof record.deviceId === 'string' ? record.deviceId : undefined;
1169
1186
  const deviceSecret = typeof record.deviceSecret === 'string' ? record.deviceSecret : undefined;
1170
1187
  const userRaw = record.user;
1171
- if (!sessionId || !deviceId || !deviceSecret || !userRaw || typeof userRaw !== 'object') {
1188
+ // The device pair is NOT part of this guard see the note above. What is
1189
+ // still mandatory is what identifies the session at all.
1190
+ if (!sessionId || !userRaw || typeof userRaw !== 'object') {
1172
1191
  throw new Error('auth/oauth/token returned an incomplete session payload');
1173
1192
  }
1174
1193
  const userObj = userRaw;
@@ -1181,12 +1200,17 @@ function OxyServicesAuthMixin(Base) {
1181
1200
  if (accessToken) {
1182
1201
  this.setTokens(accessToken);
1183
1202
  }
1203
+ if (!deviceId || !deviceSecret) {
1204
+ logger_1.logger.debug('auth/oauth/token returned no device credential — this session lives only as long as its access token', { component: 'oxy.auth', method: 'exchangeOAuthCode' });
1205
+ }
1184
1206
  return {
1185
1207
  sessionId,
1186
- deviceId,
1187
1208
  expiresAt,
1188
1209
  accessToken,
1189
- deviceSecret,
1210
+ // Omitted rather than set to `undefined` when the server sent no device
1211
+ // credential, so a device-less grant serializes as the absence it is.
1212
+ ...(deviceId ? { deviceId } : {}),
1213
+ ...(deviceSecret ? { deviceSecret } : {}),
1190
1214
  user: {
1191
1215
  id: userId,
1192
1216
  username: typeof userObj.username === 'string' ? userObj.username : undefined,
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ /**
3
+ * Chains — the shared record log every Oxy app reads and writes.
4
+ *
5
+ * A person has ONE chain. An app appends its own records to it and projects its
6
+ * feeds from what it reads back, instead of keeping a private copy of the same
7
+ * person's activity. This mixin is the client half of `/chains` in oxy-api, and
8
+ * it exists so that adopting the chain costs an app no HTTP of its own — the
9
+ * whole point of the shared substrate is that the second app writes less code
10
+ * than the first, not the same amount in a different file.
11
+ *
12
+ * ## Both calls are SERVICE-authenticated
13
+ *
14
+ * They go through `makeServiceRequest`, so they only work on a backend that has
15
+ * called `configureServiceAuth()`. That is not an accident of implementation: an
16
+ * append writes to someone else's chain and a read spans many subjects, so
17
+ * neither belongs in a browser holding a user session. A frontend that needs
18
+ * this asks its own backend.
19
+ *
20
+ * The authority is checked server-side and cannot be talked out of from here:
21
+ * `chains:write` plus the application's own `chainNamespaces` for an append,
22
+ * `chains:read` plus the public-collection policy for a read. A call that
23
+ * violates either gets a 403 or an empty page — this client adds no
24
+ * pre-validation that could drift from the server's answer.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.OxyServicesChainsMixin = OxyServicesChainsMixin;
28
+ function OxyServicesChainsMixin(Base) {
29
+ return class extends Base {
30
+ constructor(...args) {
31
+ super(...args);
32
+ }
33
+ /**
34
+ * Append a record to `oxyUserId`'s chain under `collection`/`rkey`.
35
+ *
36
+ * Oxy issues and signs it; the calling app never holds a chain signing key.
37
+ * `rkey` is the app's own id for the thing — reusing it later supersedes the
38
+ * earlier record for that key, which is how an edit works.
39
+ *
40
+ * Requires the `chains:write` scope AND `collection` falling under one of
41
+ * this application's granted `chainNamespaces`. Both are enforced by the
42
+ * server; a violation throws with a 403.
43
+ */
44
+ async appendChainRecord(params) {
45
+ return this.makeServiceRequest('POST', '/chains/records', params);
46
+ }
47
+ /**
48
+ * Records published by any of `oxyUserIds` under any of `collections`,
49
+ * oldest first — the read a cross-app feed is projected from.
50
+ *
51
+ * Only collections Oxy declares PUBLIC come back, whatever is asked for; a
52
+ * private one yields nothing rather than an error.
53
+ *
54
+ * **Re-poll from slightly BEFORE your last cursor and dedupe by
55
+ * `recordId`.** The chain's pagination axis is a transaction-start
56
+ * timestamp, so a record can commit behind a cursor that already passed it.
57
+ * Re-delivering one costs bytes; skipping one costs a record that never
58
+ * appears. Projections are expected to be idempotent for exactly this
59
+ * reason.
60
+ */
61
+ async readChainRecords(params) {
62
+ const query = new URLSearchParams({
63
+ authors: params.oxyUserIds.join(','),
64
+ collections: params.collections.join(','),
65
+ });
66
+ if (params.since)
67
+ query.set('since', params.since);
68
+ if (params.limit !== undefined)
69
+ query.set('limit', String(params.limit));
70
+ return this.makeServiceRequest('GET', `/chains/records?${query.toString()}`);
71
+ }
72
+ };
73
+ }