@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
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The device switcher's RENDER model: the directory's people and contexts with
3
+ * their names, handles and avatar URLs already resolved.
4
+ *
5
+ * Pure. It takes the projection {@link projectDevicePrincipals} produces from
6
+ * the directory and answers the five questions a row asks —
7
+ * what is it called, what is its handle, where is its avatar, what accent is it
8
+ * drawn in, may it be activated — so the views stay presentational and neither
9
+ * of them re-derives a display rule.
10
+ *
11
+ * One thing the flat account rows this replaced carried is deliberately absent,
12
+ * because the directory does not carry it and inventing it would mean going back
13
+ * to fetching another person's profiles: **email**. The directory profile is the
14
+ * minimum that renders a row, so the secondary line is the `@handle` — never a
15
+ * synthesized `username@oxy.so`.
16
+ *
17
+ * The accent is NOT in that category. It was, briefly, and it was wrong: an
18
+ * accent is part of drawing the row rather than profile data about its owner,
19
+ * and without it every non-active row falls back to the ambient theme accent, so
20
+ * a device holding two people draws them identically (issue #961). The server
21
+ * carries it on the directory profile, so no client has to guess or fetch.
22
+ *
23
+ * It lives here rather than in a UI package because there is more than one
24
+ * switcher — the SDK's account dialog and the `auth.oxy.so` chooser — and the
25
+ * whole point of ADR 0002 is that they render ONE list, built once.
26
+ */
27
+ import { canActivateContext, directoryDisplayName, directoryHandle, } from './deviceDirectory.js';
28
+ function toContextRow(context, activeContextId, resolveAvatarUrl, locale) {
29
+ return {
30
+ contextId: context.contextId,
31
+ accountId: context.subject.accountId,
32
+ displayName: directoryDisplayName(context.subject.profile, locale),
33
+ handle: directoryHandle(context.subject.profile),
34
+ avatarUrl: resolveAvatarUrl(context.subject.profile.avatar),
35
+ color: context.subject.profile.color ?? null,
36
+ // Compared on the CONTEXT id, never the account id: on a device holding two
37
+ // people the same account is active through exactly one of them, and an
38
+ // account comparison would light up both rows.
39
+ isActive: context.contextId === activeContextId,
40
+ isDelegated: context.isDelegated,
41
+ canActivate: canActivateContext(context.subject),
42
+ };
43
+ }
44
+ /** The directory's people and their contexts, ready to render. */
45
+ export function buildSwitcherRows(groups, activeContextId, resolveAvatarUrl, locale) {
46
+ return groups.map((group) => ({
47
+ principalId: group.principalId,
48
+ displayName: directoryDisplayName(group.profile, locale),
49
+ handle: directoryHandle(group.profile),
50
+ avatarUrl: resolveAvatarUrl(group.profile.avatar),
51
+ color: group.profile.color ?? null,
52
+ isActive: group.isActive,
53
+ contexts: group.contexts.map((context) => toContextRow(context, activeContextId, resolveAvatarUrl, locale)),
54
+ }));
55
+ }
56
+ /**
57
+ * Whether the switcher should name the person above each block.
58
+ *
59
+ * The question a header answers is "who is operating this account", and that is
60
+ * only a question worth printing once somebody holds MORE THAN ONE account
61
+ * here. Two people with one personal account each is the flat list again — every
62
+ * row already IS a person, and a header would just print each name twice.
63
+ *
64
+ * The moment any one of them can act as a second account, every group gets a
65
+ * header, including the single-context ones: an inconsistent list is harder to
66
+ * read than a slightly redundant one, and it is exactly then that "The Oxy
67
+ * Collective, under Nate" and "The Oxy Collective, under Alice" become two
68
+ * different rows that must be told apart.
69
+ */
70
+ export function showsPrincipalHeaders(rows) {
71
+ return rows.some((row) => row.contexts.length > 1);
72
+ }
@@ -20,12 +20,17 @@
20
20
  * The account a projection should resolve: the pin when one is supplied and
21
21
  * non-empty, else the device's active account. An empty-string pin is treated as
22
22
  * "not pinned" rather than as an account that can never match.
23
+ *
24
+ * Exported so no other projection over the same state answers it a second,
25
+ * subtly different way. A null state with
26
+ * a pin still resolves to the pin: the pinned identity is bound by a local key,
27
+ * not by device membership.
23
28
  */
24
- function boundAccountIdOf(state, pinnedAccountId) {
29
+ export function boundAccountIdOf(state, pinnedAccountId) {
25
30
  if (typeof pinnedAccountId === 'string' && pinnedAccountId.length > 0) {
26
31
  return pinnedAccountId;
27
32
  }
28
- return state.activeAccountId;
33
+ return state?.activeAccountId ?? null;
29
34
  }
30
35
  /**
31
36
  * Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
@@ -55,6 +60,7 @@ export function deviceStateToClientSessions(state, usersById, pinnedAccountId) {
55
60
  userId: account.accountId,
56
61
  isCurrent: account.accountId === boundAccountId,
57
62
  authuser: account.authuser,
63
+ operatedByUserId: account.operatedByUserId,
58
64
  }));
59
65
  }
60
66
  /**
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The shared native DeviceSession credential — one device, one session, many apps.
3
+ *
4
+ * ## What this is, and what it deliberately is NOT
5
+ *
6
+ * Two different secrets can make a native Oxy app boot signed in, and conflating
7
+ * them is the bug this module exists to end:
8
+ *
9
+ * Commons private identity key → identity and signed approval ONLY. It is
10
+ * self-custody, irreplaceable, and must never
11
+ * become the general app session transport.
12
+ * Shared DeviceSession credential → THIS module. An ordinary `deviceId` +
13
+ * `deviceSecret` pair that restores ordinary
14
+ * official apps, follows the device's active
15
+ * context, and is individually rotatable and
16
+ * revocable server-side.
17
+ *
18
+ * An ordinary app needs the second one. Handing it the first — which is what the
19
+ * `shared-key-signin` lane does today — gives every sibling app the ability to
20
+ * sign as the user's cryptographic identity to obtain something as mundane as a
21
+ * session. That lane stays as a recovery/compatibility path; this one supersedes
22
+ * it for the ordinary case.
23
+ *
24
+ * ## Why sharing one credential is safe against the server
25
+ *
26
+ * `POST /session/device/token` does NOT rotate: the response echoes the presented
27
+ * secret back as `nextDeviceSecret` precisely so several first-party apps sharing
28
+ * one `DeviceSession` can refresh concurrently without invalidating one another.
29
+ * So N apps holding one credential is a supported server state, not a race — and
30
+ * because they then share ONE `DeviceSession`, they automatically share its
31
+ * `activeContextId` and its token-free `session_state` broadcasts.
32
+ *
33
+ * ## The one safety rule everything here is built around
34
+ *
35
+ * A read that FAILED and a slot that is EMPTY must never be the same value. The
36
+ * empty answer authorises writes (seed the slot); the failed answer must
37
+ * authorise nothing. {@link SharedDeviceCredentialRead} keeps them apart at the
38
+ * type level, and every decision below fails closed on anything that is not a
39
+ * positive `absent`/`present`.
40
+ *
41
+ * Platform-agnostic: the actual keychain / keystore access is injected as a
42
+ * {@link SharedDeviceCredentialStore} by `@oxyhq/services`. ESM-safe, no
43
+ * `require()`, no react/react-native/expo imports.
44
+ */
45
+ import { logger } from '../logger/index.js';
46
+ /** The usable `{deviceId, deviceSecret}` pair in a persisted state, or null. */
47
+ export function readLocalDeviceCredential(state) {
48
+ if (!state?.deviceId || !state.deviceSecret) {
49
+ return null;
50
+ }
51
+ return { deviceId: state.deviceId, deviceSecret: state.deviceSecret };
52
+ }
53
+ /**
54
+ * Narrow an UNTRUSTED bridge payload into a {@link SharedDeviceCredentialRead}.
55
+ *
56
+ * Anything unrecognised resolves to `unavailable`, never `absent`. A native
57
+ * module returning a shape this build does not understand (an older app in the
58
+ * signing group, a partially-applied upgrade) means we do not KNOW whether the
59
+ * device has a shared session — and "do not know" must never authorise a write
60
+ * that would overwrite one.
61
+ */
62
+ export function normalizeSharedDeviceSessionRead(raw) {
63
+ if (!raw || typeof raw !== 'object') {
64
+ return { state: 'unavailable', cause: new Error('shared device session bridge returned a non-object') };
65
+ }
66
+ const payload = raw;
67
+ if (payload.status === 'absent') {
68
+ return { state: 'absent' };
69
+ }
70
+ if (payload.status === 'present') {
71
+ const deviceId = payload.deviceId;
72
+ const deviceSecret = payload.deviceSecret;
73
+ if (typeof deviceId === 'string' &&
74
+ deviceId.length > 0 &&
75
+ typeof deviceSecret === 'string' &&
76
+ deviceSecret.length > 0) {
77
+ return { state: 'present', credential: { deviceId, deviceSecret } };
78
+ }
79
+ // A `present` verdict whose payload is incomplete is a broken slot, not an
80
+ // empty one. Reporting `absent` here would let the next successful sign-in
81
+ // overwrite whatever is really in there.
82
+ return {
83
+ state: 'unavailable',
84
+ cause: new Error('shared device session bridge reported `present` with an incomplete credential'),
85
+ };
86
+ }
87
+ if (payload.status === 'unavailable') {
88
+ const reason = typeof payload.reason === 'string' ? payload.reason : 'unknown';
89
+ return { state: 'unavailable', cause: new Error(`shared device session slot unavailable: ${reason}`) };
90
+ }
91
+ return {
92
+ state: 'unavailable',
93
+ cause: new Error(`shared device session bridge returned an unrecognised status: ${String(payload.status)}`),
94
+ };
95
+ }
96
+ /**
97
+ * Should this app adopt the shared credential? Pure.
98
+ *
99
+ * Adoption happens in exactly ONE case: the shared slot holds a credential and
100
+ * this app has none of its own. That single rule delivers the product
101
+ * requirement — a newly installed official app joins the device's existing
102
+ * session without another QR — while making the two failure modes that matter
103
+ * unreachable:
104
+ *
105
+ * - An app that is already signed in is NEVER moved onto another credential, so
106
+ * "no user is signed out merely because one app updates first" holds by
107
+ * construction, in both upgrade directions.
108
+ * - A failed read never looks like an empty slot, so a locked keychain resolves
109
+ * to "keep what I have" rather than "this is a fresh device".
110
+ *
111
+ * The cost is stated plainly: two apps that each already own a DIFFERENT device
112
+ * session stay on their own until one of them loses its credential. Converging
113
+ * them would mean signing one of them out or a server-side device merge, and
114
+ * neither is something a boot path may do silently.
115
+ */
116
+ export function decideSharedDeviceJoin(local, shared) {
117
+ if (readLocalDeviceCredential(local) !== null) {
118
+ return { action: 'skip', reason: 'local-credential-present' };
119
+ }
120
+ switch (shared.state) {
121
+ case 'present':
122
+ return { action: 'adopt', credential: shared.credential };
123
+ case 'absent':
124
+ return { action: 'skip', reason: 'shared-empty' };
125
+ case 'unavailable':
126
+ return { action: 'skip', reason: 'shared-unreadable' };
127
+ case 'unsupported':
128
+ return { action: 'skip', reason: 'shared-unsupported' };
129
+ }
130
+ }
131
+ /**
132
+ * Should this app write the credential it just proved into the shared slot? Pure.
133
+ *
134
+ * `proven` means the server accepted it moments ago — a successful sign-in or a
135
+ * successful mint. Only a proven credential is ever published, so the slot can
136
+ * never be seeded with something no app could use.
137
+ *
138
+ * A slot already held by a DIFFERENT `deviceId` is left alone. Overwriting it
139
+ * would silently migrate every other app on this device onto our session at their
140
+ * next cold boot — a real, user-visible change of who they are signed in as, and
141
+ * not something a background persist may decide.
142
+ */
143
+ export function decideSharedDevicePublish(proven, shared) {
144
+ switch (shared.state) {
145
+ case 'unsupported':
146
+ return { action: 'skip', reason: 'shared-unsupported' };
147
+ case 'unavailable':
148
+ return { action: 'skip', reason: 'shared-unreadable' };
149
+ case 'absent':
150
+ return { action: 'publish' };
151
+ case 'present': {
152
+ if (shared.credential.deviceId !== proven.deviceId) {
153
+ return { action: 'skip', reason: 'owned-by-another-device' };
154
+ }
155
+ if (shared.credential.deviceSecret === proven.deviceSecret) {
156
+ return { action: 'skip', reason: 'already-current' };
157
+ }
158
+ // Same device, newer secret. Sign-in rotates the secret (the mint does
159
+ // not), so the just-proven one is the credential a fresh install should
160
+ // join with.
161
+ return { action: 'publish' };
162
+ }
163
+ }
164
+ }
165
+ /**
166
+ * Read the shared slot, apply {@link decideSharedDevicePublish}, and write when
167
+ * it says so. Best-effort by contract: the caller's own durable credential is
168
+ * already persisted, so a failure here only means a future install will have to
169
+ * sign in interactively.
170
+ */
171
+ export async function publishProvenDeviceCredential(deps) {
172
+ const read = await deps.shared.read();
173
+ const decision = decideSharedDevicePublish(deps.credential, read);
174
+ if (decision.action === 'skip') {
175
+ if (decision.reason === 'shared-unreadable') {
176
+ logger.debug('shared device credential slot unreadable — not publishing (an unreadable slot is never an empty one)', { component: 'sharedDeviceCredential', method: 'publishProvenDeviceCredential' });
177
+ }
178
+ return { status: 'skipped', reason: decision.reason };
179
+ }
180
+ const published = await deps.shared.publish(deps.credential);
181
+ return published ? { status: 'published' } : { status: 'publish-failed' };
182
+ }
183
+ /**
184
+ * Wrap a platform {@link AuthStateStore} so that every durable credential it
185
+ * persists is ALSO mirrored into the shared slot.
186
+ *
187
+ * Writes mirror automatically; reads do NOT adopt. That split is deliberate:
188
+ *
189
+ * - Mirroring on write is the right place because `save()` is where a proven
190
+ * credential lands, on every lane there is — interactive sign-in, the cold
191
+ * boot mint, the refresh scheduler, the 401 re-mint, shared-key recovery. One
192
+ * seam, no lane left out, and no new call site to forget.
193
+ * - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
194
+ * a storage primitive, and would run on every `load()`. Adoption is an
195
+ * explicit, once-per-boot cold-boot step instead (`shared-device-adopt`).
196
+ *
197
+ * `clear()` deliberately does NOT clear the shared slot. This app signing out is
198
+ * not authority over the device-wide join point: other apps may still be signed
199
+ * in on that same credential, and once the server session is really gone the
200
+ * credential mints `no_active_session` for everyone anyway.
201
+ */
202
+ export function createSharedMirroringAuthStateStore(deps) {
203
+ const { local, shared } = deps;
204
+ // Process-local memo of the credential we last observed the shared slot to
205
+ // hold. Purely an optimization to keep the refresh scheduler from re-reading
206
+ // the keychain every mint; a slot wiped out from under us is re-seeded on the
207
+ // next launch rather than mid-process.
208
+ let mirrored = null;
209
+ return {
210
+ load: () => local.load(),
211
+ clear: () => local.clear(),
212
+ save: async (state) => {
213
+ // The durable local write is the contract this store owes its caller —
214
+ // run it first and report ITS result, unchanged. The mirror is additive.
215
+ const durablePersisted = await local.save(state);
216
+ const credential = readLocalDeviceCredential(state);
217
+ if (!credential) {
218
+ return durablePersisted;
219
+ }
220
+ if (mirrored !== null &&
221
+ mirrored.deviceId === credential.deviceId &&
222
+ mirrored.deviceSecret === credential.deviceSecret) {
223
+ return durablePersisted;
224
+ }
225
+ try {
226
+ const outcome = await publishProvenDeviceCredential({ shared, credential });
227
+ if (outcome.status === 'published' || (outcome.status === 'skipped' && outcome.reason === 'already-current')) {
228
+ mirrored = credential;
229
+ }
230
+ }
231
+ catch (error) {
232
+ // A store implementation is contractually non-throwing, but a mirror
233
+ // failure must never take down the durable write that already succeeded.
234
+ logger.debug('mirroring the device credential into the shared slot threw — the local credential is unaffected', { component: 'sharedDeviceCredential', method: 'save' }, error);
235
+ }
236
+ return durablePersisted;
237
+ },
238
+ };
239
+ }