@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,182 @@
1
+ import type { AccountKind, DeviceContextRelationship, DeviceDirectory, DeviceDirectoryProfile } from '@oxyhq/contracts';
2
+ /**
3
+ * Pure projections over the device directory (`GET /session/device/directory`,
4
+ * ADR 0002). No I/O — the caller holds the directory `SessionClient` fetched.
5
+ *
6
+ * These exist to say the one thing the flat `DeviceSessionState` projection
7
+ * structurally cannot: WHO an account is being reached through.
8
+ * `DeviceSessionState.accounts[]` has carried `operatedByUserId` on the wire all
9
+ * along and nothing in this SDK ever read it, so "signed in as The Oxy
10
+ * Collective" and "Nate operating The Oxy Collective" render identically today —
11
+ * two different audit actors, two different revocation paths, one row. The
12
+ * directory keeps the two facts apart, and so does everything below.
13
+ */
14
+ /**
15
+ * The human whose authentication backs a context — the audit actor.
16
+ *
17
+ * Always a person (ADR 0001: a principal is never an organization, project,
18
+ * channel or bot) and the owner of the `authuser` slot. An organization in the
19
+ * switcher consumes no slot of its own; it is a SUBJECT under some principal.
20
+ */
21
+ export interface DeviceContextActor {
22
+ /** Identifies the principal row — the id `POST /session/device/signout { principalId }` takes. */
23
+ principalId: string;
24
+ /** The person's own account id. */
25
+ userId: string;
26
+ /** Google-style signed-in-human slot, allocated per person. */
27
+ authuser: number;
28
+ profile: DeviceDirectoryProfile;
29
+ }
30
+ /** The account a context acts AS — what a profile header renders. */
31
+ export interface DeviceContextSubject {
32
+ accountId: string;
33
+ kind: AccountKind;
34
+ relationship: DeviceContextRelationship;
35
+ profile: DeviceDirectoryProfile;
36
+ /**
37
+ * `false` for a context the principal may act as but has never activated here.
38
+ *
39
+ * NOT a synonym for activatable, in either direction — see
40
+ * {@link canActivateContext}.
41
+ */
42
+ onDevice: boolean;
43
+ /**
44
+ * Whether this context can be activated right now — the server's whole verdict,
45
+ * returned as a row rather than omitted so the UI can explain a row going away.
46
+ *
47
+ * It is STRICTER than the old `/switch` gate, and stricter than the name
48
+ * suggests: it is the live `account:act_as` check AND the PRINCIPAL's own
49
+ * personal session being live. Activation has no proof of who is acting once
50
+ * the human's own session is gone, so a dead principal makes every one of
51
+ * their contexts unavailable — the delegated ones whose own sessions are
52
+ * perfectly alive included.
53
+ */
54
+ available: boolean;
55
+ lastUsedAt: number | null;
56
+ }
57
+ /**
58
+ * One resolved `principal acting as account` pair — the globally switchable
59
+ * unit, with its two halves named.
60
+ */
61
+ export interface DeviceContext {
62
+ /**
63
+ * The identifier `POST /session/device/activate` takes. Names the PAIR, never
64
+ * the account.
65
+ *
66
+ * NOT STABLE ACROSS A REMOVAL, and therefore never something to persist or to
67
+ * hold across a read. Removing a delegated context is not permanent while the
68
+ * membership lives: the server rematerializes the pair on the next directory
69
+ * read, as `onDevice: false` under a NEW id — and it does so WITHOUT bumping
70
+ * `revision`, so "the device has not changed" is not evidence the id has not.
71
+ * Re-resolve from the directory in hand every time, and read an id that no
72
+ * longer resolves as gone rather than as an error.
73
+ */
74
+ contextId: string;
75
+ actor: DeviceContextActor;
76
+ subject: DeviceContextSubject;
77
+ /**
78
+ * Whether the actor and the subject are different accounts — "Nate operating
79
+ * The Oxy Collective" rather than "Nate". Compared by id rather than read off
80
+ * `relationship`, so it stays true if the vocabulary ever grows a fourth term.
81
+ */
82
+ isDelegated: boolean;
83
+ }
84
+ /**
85
+ * Resolve one context by its id.
86
+ *
87
+ * The search is over `(principal, context)` PAIRS, not over accounts: the same
88
+ * `accountId` legitimately appears under two principals on a shared device, and
89
+ * matching on the account would hand back whichever person happened to be
90
+ * enumerated first.
91
+ */
92
+ export declare function resolveDeviceContext(directory: DeviceDirectory | null, contextId: string): DeviceContext | null;
93
+ /**
94
+ * One person on the device, with every account they can act as beneath them.
95
+ *
96
+ * This is the switcher's shape, and it is grouped rather than flat because the
97
+ * flat one cannot state the fact the whole model exists for: the same
98
+ * organization reachable through two people is TWO rows, under two different
99
+ * humans, and a list keyed by account can only ever show one of them.
100
+ */
101
+ export interface DevicePrincipalGroup {
102
+ /** The id `POST /session/device/signout { principalId }` takes. */
103
+ principalId: string;
104
+ /** The person's own account id. */
105
+ userId: string;
106
+ /** Google-style signed-in-human slot. An organization consumes none. */
107
+ authuser: number;
108
+ profile: DeviceDirectoryProfile;
109
+ /**
110
+ * Every context this person can reach, in the server's order (their personal
111
+ * account first, then the accounts they act as, by account id). Not re-sorted
112
+ * here: the server's order is already total and revision-stable, and a second
113
+ * ordering rule on the client would be a second thing to keep in agreement.
114
+ */
115
+ contexts: DeviceContext[];
116
+ /** Whether the device's ACTIVE context belongs to this person. */
117
+ isActive: boolean;
118
+ }
119
+ /**
120
+ * The directory as the switcher renders it: people, each with what they may
121
+ * become.
122
+ *
123
+ * A principal with no contexts is kept rather than dropped. It is a real state
124
+ * — a person whose every context was removed while they remain on the device —
125
+ * and rendering them with nothing under them is how "sign out of this person"
126
+ * stays reachable. Silently omitting them would strand the row.
127
+ */
128
+ export declare function projectDevicePrincipals(directory: DeviceDirectory | null): DevicePrincipalGroup[];
129
+ /**
130
+ * The device's active context, or `null`.
131
+ *
132
+ * `null` is a real state, not an error: a device with every context removed, or
133
+ * one whose active context was healed away, has none. It is also the answer when
134
+ * `activeContextId` names a row no principal holds — a directory that
135
+ * disagreed with itself, which resolves to "nothing is active" rather than to a
136
+ * guess.
137
+ */
138
+ export declare function resolveActiveContext(directory: DeviceDirectory | null): DeviceContext | null;
139
+ /**
140
+ * The name a directory row renders: the API's `displayName` when it has one,
141
+ * otherwise the normalized handle, otherwise the localized unnamed sentinel.
142
+ *
143
+ * The identity contract's `displayName ?? handle`, and deliberately not
144
+ * `getAccountDisplayName`'s multi-field chain — that one is for LOCAL account
145
+ * surfaces, and the directory profile is an API DTO whose `name.displayName`
146
+ * the server already composed or deliberately omitted. `getAccountDisplayName`
147
+ * appears here only for its `null` case, which is the sentinel.
148
+ */
149
+ export declare function directoryDisplayName(profile: DeviceDirectoryProfile, locale?: string): string;
150
+ /**
151
+ * A directory row's `@handle`, or `null` when the profile carries no usable
152
+ * username.
153
+ *
154
+ * The directory profile has no email — by design, it is the minimum that
155
+ * renders a row — so the handle is the secondary line, never a synthesized
156
+ * `username@oxy.so` address.
157
+ */
158
+ export declare function directoryHandle(profile: DeviceDirectoryProfile): string | null;
159
+ /**
160
+ * Whether a switcher may offer this row — the one question it asks, answered
161
+ * here so no surface has to re-derive it.
162
+ *
163
+ * It is deliberately a single field. `available` is the server's complete
164
+ * verdict (see {@link DeviceContextSubject.available}), and switchability is an
165
+ * authorization question the client must READ, never recompute; this exists to
166
+ * name the field that answers it, not to combine several.
167
+ *
168
+ * `onDevice` is NOT part of the question and composing the two is the mistake
169
+ * this function exists to prevent, in both directions. `onDevice: false` is an
170
+ * ordinary reachable context whose session is minted on first activation, so
171
+ * requiring it hides every organization the person has not used here yet.
172
+ * `onDevice: true` does not imply activatable either: when a principal's own
173
+ * personal session dies, their delegated contexts keep live sessions of their
174
+ * own and still cannot be activated, so `available || onDevice` would render a
175
+ * row the server answers with 403 and then heals away.
176
+ *
177
+ * Takes a structural subset so a caller holding a raw `DeviceAccountContext`
178
+ * from the wire can ask it without resolving the pair first.
179
+ */
180
+ export declare function canActivateContext(context: {
181
+ available: boolean;
182
+ }): boolean;
@@ -0,0 +1,92 @@
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 { type DevicePrincipalGroup } from './deviceDirectory';
28
+ /** One `principal acting as account` row. */
29
+ export interface SwitcherContextRow {
30
+ /**
31
+ * The id `activateContext` / `signOutContext` take. Names the PAIR.
32
+ *
33
+ * Not stable across a removal, so it is read out of the directory in hand on
34
+ * every render and never held across one.
35
+ */
36
+ contextId: string;
37
+ accountId: string;
38
+ displayName: string;
39
+ /** The `@handle` secondary line, or `null` when the profile has no username. */
40
+ handle: string | null;
41
+ avatarUrl: string | undefined;
42
+ /**
43
+ * The SUBJECT account's own accent — a named Bloom preset, or `null` when it
44
+ * has none and the renderer should use the ambient theme accent.
45
+ *
46
+ * Forwarded verbatim, never resolved to a colour here: mapping a preset name
47
+ * to a hex is Bloom's job, and `@oxyhq/core` cannot import a UI package.
48
+ */
49
+ color: string | null;
50
+ /** Whether this pair is the device's active context. */
51
+ isActive: boolean;
52
+ /** Whether the actor and the subject are different accounts. */
53
+ isDelegated: boolean;
54
+ /**
55
+ * Whether the row may be pressed — the server's `available`, read and never
56
+ * recomputed. Composing it with `onDevice` is wrong in both directions (see
57
+ * `canActivateContext`), so this is one field forwarded, not a derivation.
58
+ */
59
+ canActivate: boolean;
60
+ }
61
+ /** One person on this device, with the accounts they can act as beneath them. */
62
+ export interface SwitcherPrincipalRow {
63
+ /** The id `signOutPrincipal` takes. */
64
+ principalId: string;
65
+ displayName: string;
66
+ handle: string | null;
67
+ avatarUrl: string | undefined;
68
+ /** The PERSON's own accent, on the same terms as a context row's. */
69
+ color: string | null;
70
+ /** Whether the device's ACTIVE context belongs to this person. */
71
+ isActive: boolean;
72
+ contexts: SwitcherContextRow[];
73
+ }
74
+ /** Resolves an avatar file id to a thumbnail URL. Bind to `getFileDownloadUrl`. */
75
+ export type ResolveAvatarUrl = (avatar: string | null | undefined) => string | undefined;
76
+ /** The directory's people and their contexts, ready to render. */
77
+ export declare function buildSwitcherRows(groups: DevicePrincipalGroup[], activeContextId: string | null, resolveAvatarUrl: ResolveAvatarUrl, locale?: string): SwitcherPrincipalRow[];
78
+ /**
79
+ * Whether the switcher should name the person above each block.
80
+ *
81
+ * The question a header answers is "who is operating this account", and that is
82
+ * only a question worth printing once somebody holds MORE THAN ONE account
83
+ * here. Two people with one personal account each is the flat list again — every
84
+ * row already IS a person, and a header would just print each name twice.
85
+ *
86
+ * The moment any one of them can act as a second account, every group gets a
87
+ * header, including the single-context ones: an inconsistent list is harder to
88
+ * read than a slightly redundant one, and it is exactly then that "The Oxy
89
+ * Collective, under Nate" and "The Oxy Collective, under Alice" become two
90
+ * different rows that must be told apart.
91
+ */
92
+ export declare function showsPrincipalHeaders(rows: SwitcherPrincipalRow[]): boolean;
@@ -1,6 +1,35 @@
1
1
  import type { DeviceSessionState } from '@oxyhq/contracts';
2
2
  import type { ClientSession } from '../models/session';
3
3
  import type { User } from '../models/interfaces';
4
+ /**
5
+ * Pure projection helpers: `DeviceSessionState` (the device-scoped
6
+ * multi-account session-sync state produced by `SessionClient`) -> the
7
+ * shapes `@oxyhq/services` consumers render today
8
+ * (`ClientSession[]`, an active session id, an active `User`).
9
+ *
10
+ * No I/O. The caller fetches profiles via
11
+ * `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
12
+ * from the result before calling `deviceStateToClientSessions` /
13
+ * `activeUserOf`.
14
+ *
15
+ * Each projection takes an OPTIONAL `pinnedAccountId`. Omit it (or pass `null`)
16
+ * and the projection resolves the device's `activeAccountId` exactly as before.
17
+ * Pass it — an IDENTITY-BOUND client, whose user is fixed by the local identity
18
+ * key — and the projection resolves THAT account instead, so an account switch
19
+ * made by another app on the same device changes `state` but never the user this
20
+ * client renders.
21
+ */
22
+ /**
23
+ * The account a projection should resolve: the pin when one is supplied and
24
+ * non-empty, else the device's active account. An empty-string pin is treated as
25
+ * "not pinned" rather than as an account that can never match.
26
+ *
27
+ * Exported so no other projection over the same state answers it a second,
28
+ * subtly different way. A null state with
29
+ * a pin still resolves to the pin: the pinned identity is bound by a local key,
30
+ * not by device membership.
31
+ */
32
+ export declare function boundAccountIdOf(state: DeviceSessionState | null, pinnedAccountId?: string | null): string | null;
4
33
  /**
5
34
  * Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
6
35
  *
@@ -0,0 +1,202 @@
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 type { AuthStateStore, PersistedAuthState } from './authStateStore';
46
+ /**
47
+ * The zero-cookie device credential, as shared between apps. Exactly the pair
48
+ * `POST /session/device/token` takes — nothing else travels through the shared
49
+ * slot: no access token, no account id, no user id, no identity key.
50
+ */
51
+ export interface SharedDeviceCredential {
52
+ deviceId: string;
53
+ deviceSecret: string;
54
+ }
55
+ /**
56
+ * The outcome of reading the shared slot. FOUR states, and the distinction
57
+ * between the last two is load-bearing:
58
+ *
59
+ * - `present` — a well-formed credential was read.
60
+ * - `absent` — the read SUCCEEDED and the slot is empty. The only state that
61
+ * may authorise seeding the slot.
62
+ * - `unavailable` — the read failed (keychain locked, keystore unreadable, the
63
+ * bridge returned something unrecognisable). Authorises
64
+ * nothing: not adoption, and above all not a write.
65
+ * - `unsupported` — this build has no shared slot at all (web, or a native app
66
+ * without the module linked). Not an error; simply means the
67
+ * app keeps its own per-app credential.
68
+ */
69
+ export type SharedDeviceCredentialRead = {
70
+ state: 'present';
71
+ credential: SharedDeviceCredential;
72
+ } | {
73
+ state: 'absent';
74
+ } | {
75
+ state: 'unavailable';
76
+ cause: unknown;
77
+ } | {
78
+ state: 'unsupported';
79
+ };
80
+ /**
81
+ * The platform seam. `@oxyhq/services` implements this over the iOS Keychain
82
+ * Access Group (a dedicated `keychainService`) or the Android signature-protected
83
+ * `OxyDeviceSession` broker.
84
+ */
85
+ export interface SharedDeviceCredentialStore {
86
+ /** Never throws — a failure is reported as `unavailable`, never as `absent`. */
87
+ read(): Promise<SharedDeviceCredentialRead>;
88
+ /**
89
+ * Publish the credential. Resolves `true` only when a read-back confirmed the
90
+ * exact bytes landed; `false` on any failure. Never throws.
91
+ */
92
+ publish(credential: SharedDeviceCredential): Promise<boolean>;
93
+ /** Drop this app's copy of the shared credential. Never throws. */
94
+ clear(): Promise<void>;
95
+ }
96
+ /** Why {@link decideSharedDeviceJoin} declined to adopt the shared credential. */
97
+ export type SharedDeviceJoinSkipReason = 'shared-unsupported' | 'shared-unreadable' | 'shared-empty' | 'local-credential-present';
98
+ /** What a booting app should do with the shared slot it just read. */
99
+ export type SharedDeviceJoinDecision = {
100
+ action: 'adopt';
101
+ credential: SharedDeviceCredential;
102
+ } | {
103
+ action: 'skip';
104
+ reason: SharedDeviceJoinSkipReason;
105
+ };
106
+ /** Why {@link decideSharedDevicePublish} declined to write the shared slot. */
107
+ export type SharedDevicePublishSkipReason = 'shared-unsupported' | 'shared-unreadable' | 'already-current' | 'owned-by-another-device';
108
+ /** What an app that just PROVED a credential should do with the shared slot. */
109
+ export type SharedDevicePublishDecision = {
110
+ action: 'publish';
111
+ } | {
112
+ action: 'skip';
113
+ reason: SharedDevicePublishSkipReason;
114
+ };
115
+ /** The usable `{deviceId, deviceSecret}` pair in a persisted state, or null. */
116
+ export declare function readLocalDeviceCredential(state: PersistedAuthState | null): SharedDeviceCredential | null;
117
+ /**
118
+ * Narrow an UNTRUSTED bridge payload into a {@link SharedDeviceCredentialRead}.
119
+ *
120
+ * Anything unrecognised resolves to `unavailable`, never `absent`. A native
121
+ * module returning a shape this build does not understand (an older app in the
122
+ * signing group, a partially-applied upgrade) means we do not KNOW whether the
123
+ * device has a shared session — and "do not know" must never authorise a write
124
+ * that would overwrite one.
125
+ */
126
+ export declare function normalizeSharedDeviceSessionRead(raw: unknown): SharedDeviceCredentialRead;
127
+ /**
128
+ * Should this app adopt the shared credential? Pure.
129
+ *
130
+ * Adoption happens in exactly ONE case: the shared slot holds a credential and
131
+ * this app has none of its own. That single rule delivers the product
132
+ * requirement — a newly installed official app joins the device's existing
133
+ * session without another QR — while making the two failure modes that matter
134
+ * unreachable:
135
+ *
136
+ * - An app that is already signed in is NEVER moved onto another credential, so
137
+ * "no user is signed out merely because one app updates first" holds by
138
+ * construction, in both upgrade directions.
139
+ * - A failed read never looks like an empty slot, so a locked keychain resolves
140
+ * to "keep what I have" rather than "this is a fresh device".
141
+ *
142
+ * The cost is stated plainly: two apps that each already own a DIFFERENT device
143
+ * session stay on their own until one of them loses its credential. Converging
144
+ * them would mean signing one of them out or a server-side device merge, and
145
+ * neither is something a boot path may do silently.
146
+ */
147
+ export declare function decideSharedDeviceJoin(local: PersistedAuthState | null, shared: SharedDeviceCredentialRead): SharedDeviceJoinDecision;
148
+ /**
149
+ * Should this app write the credential it just proved into the shared slot? Pure.
150
+ *
151
+ * `proven` means the server accepted it moments ago — a successful sign-in or a
152
+ * successful mint. Only a proven credential is ever published, so the slot can
153
+ * never be seeded with something no app could use.
154
+ *
155
+ * A slot already held by a DIFFERENT `deviceId` is left alone. Overwriting it
156
+ * would silently migrate every other app on this device onto our session at their
157
+ * next cold boot — a real, user-visible change of who they are signed in as, and
158
+ * not something a background persist may decide.
159
+ */
160
+ export declare function decideSharedDevicePublish(proven: SharedDeviceCredential, shared: SharedDeviceCredentialRead): SharedDevicePublishDecision;
161
+ /** The result of {@link publishProvenDeviceCredential}, for logs and tests. */
162
+ export type SharedDevicePublishOutcome = {
163
+ status: 'published';
164
+ } | {
165
+ status: 'publish-failed';
166
+ } | {
167
+ status: 'skipped';
168
+ reason: SharedDevicePublishSkipReason;
169
+ };
170
+ /**
171
+ * Read the shared slot, apply {@link decideSharedDevicePublish}, and write when
172
+ * it says so. Best-effort by contract: the caller's own durable credential is
173
+ * already persisted, so a failure here only means a future install will have to
174
+ * sign in interactively.
175
+ */
176
+ export declare function publishProvenDeviceCredential(deps: {
177
+ shared: SharedDeviceCredentialStore;
178
+ credential: SharedDeviceCredential;
179
+ }): Promise<SharedDevicePublishOutcome>;
180
+ /**
181
+ * Wrap a platform {@link AuthStateStore} so that every durable credential it
182
+ * persists is ALSO mirrored into the shared slot.
183
+ *
184
+ * Writes mirror automatically; reads do NOT adopt. That split is deliberate:
185
+ *
186
+ * - Mirroring on write is the right place because `save()` is where a proven
187
+ * credential lands, on every lane there is — interactive sign-in, the cold
188
+ * boot mint, the refresh scheduler, the 401 re-mint, shared-key recovery. One
189
+ * seam, no lane left out, and no new call site to forget.
190
+ * - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
191
+ * a storage primitive, and would run on every `load()`. Adoption is an
192
+ * explicit, once-per-boot cold-boot step instead (`shared-device-adopt`).
193
+ *
194
+ * `clear()` deliberately does NOT clear the shared slot. This app signing out is
195
+ * not authority over the device-wide join point: other apps may still be signed
196
+ * in on that same credential, and once the server session is really gone the
197
+ * credential mints `no_active_session` for everyone anyway.
198
+ */
199
+ export declare function createSharedMirroringAuthStateStore(deps: {
200
+ local: AuthStateStore;
201
+ shared: SharedDeviceCredentialStore;
202
+ }): AuthStateStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "20.0.0",
3
+ "version": "21.0.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -116,7 +116,7 @@
116
116
  "dependencies": {
117
117
  "@noble/ciphers": "^1.3.0",
118
118
  "@noble/hashes": "^1.8.0",
119
- "@oxyhq/contracts": "^0.25.0",
119
+ "@oxyhq/contracts": "^0.26.0",
120
120
  "@oxyhq/protocol": "^0.2.0",
121
121
  "@scure/bip39": "^1.6.0",
122
122
  "@types/elliptic": "^6.4.18",
@@ -127,7 +127,7 @@
127
127
  "invariant": "^2.2.4",
128
128
  "jwt-decode": "^4.0.0",
129
129
  "socket.io-client": "^4.8.1",
130
- "tldts": "^7.4.8",
130
+ "tldts": "^7.4.10",
131
131
  "zod": "^3.25.64"
132
132
  },
133
133
  "peerDependencies": {