@oxyhq/core 20.1.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/es-ES.json +19 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
- package/dist/cjs/index.js +50 -16
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/es-ES.json +19 -2
- package/dist/esm/i18n/locales/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
- package/dist/esm/index.js +32 -10
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +8 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +19 -2
- package/src/i18n/locales/es-ES.json +19 -2
- package/src/index.ts +75 -18
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/models/session.ts +11 -0
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- 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
|
|
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
|
+
}
|