@cleocode/core 2026.9.25 → 2026.9.26

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 (208) hide show
  1. package/dist/admin/import-tasks.d.ts.map +1 -1
  2. package/dist/admin/import-tasks.js +5 -2
  3. package/dist/admin/import-tasks.js.map +1 -1
  4. package/dist/admin/import.d.ts.map +1 -1
  5. package/dist/admin/import.js +7 -2
  6. package/dist/admin/import.js.map +1 -1
  7. package/dist/build-provenance.json +2 -2
  8. package/dist/cloud/index.d.ts +1 -0
  9. package/dist/cloud/index.d.ts.map +1 -1
  10. package/dist/cloud/index.js +1 -0
  11. package/dist/cloud/index.js.map +1 -1
  12. package/dist/cloud/nexus-auth.d.ts +26 -1
  13. package/dist/cloud/nexus-auth.d.ts.map +1 -1
  14. package/dist/cloud/nexus-auth.js +61 -32
  15. package/dist/cloud/nexus-auth.js.map +1 -1
  16. package/dist/cloud/nexus-credentials.js +2 -2
  17. package/dist/cloud/nexus-credentials.js.map +1 -1
  18. package/dist/cloud/nexus-device.d.ts +809 -0
  19. package/dist/cloud/nexus-device.d.ts.map +1 -0
  20. package/dist/cloud/nexus-device.js +1680 -0
  21. package/dist/cloud/nexus-device.js.map +1 -0
  22. package/dist/cloud/nexus-enrol.d.ts +210 -0
  23. package/dist/cloud/nexus-enrol.d.ts.map +1 -0
  24. package/dist/cloud/nexus-enrol.js +1023 -0
  25. package/dist/cloud/nexus-enrol.js.map +1 -0
  26. package/dist/cloud/nexus-link.d.ts +3 -0
  27. package/dist/cloud/nexus-link.d.ts.map +1 -1
  28. package/dist/cloud/nexus-link.js +38 -4
  29. package/dist/cloud/nexus-link.js.map +1 -1
  30. package/dist/cloud/signing.d.ts +20 -0
  31. package/dist/cloud/signing.d.ts.map +1 -1
  32. package/dist/cloud/signing.js +14 -0
  33. package/dist/cloud/signing.js.map +1 -1
  34. package/dist/crypto/credentials.d.ts +61 -0
  35. package/dist/crypto/credentials.d.ts.map +1 -1
  36. package/dist/crypto/credentials.js +154 -58
  37. package/dist/crypto/credentials.js.map +1 -1
  38. package/dist/docs/export-document.js +830 -190
  39. package/dist/docs/export-document.js.map +3 -3
  40. package/dist/doctor/dependency-cycles.d.ts +41 -0
  41. package/dist/doctor/dependency-cycles.d.ts.map +1 -0
  42. package/dist/doctor/dependency-cycles.js +39 -0
  43. package/dist/doctor/dependency-cycles.js.map +1 -0
  44. package/dist/doctor/index.d.ts +2 -0
  45. package/dist/doctor/index.d.ts.map +1 -1
  46. package/dist/doctor/index.js +1 -0
  47. package/dist/doctor/index.js.map +1 -1
  48. package/dist/doctor/row-identity.d.ts +11 -0
  49. package/dist/doctor/row-identity.d.ts.map +1 -1
  50. package/dist/doctor/row-identity.js +67 -9
  51. package/dist/doctor/row-identity.js.map +1 -1
  52. package/dist/error-catalog.d.ts.map +1 -1
  53. package/dist/error-catalog.js +4 -0
  54. package/dist/error-catalog.js.map +1 -1
  55. package/dist/error-registry.d.ts.map +1 -1
  56. package/dist/error-registry.js +8 -0
  57. package/dist/error-registry.js.map +1 -1
  58. package/dist/llm/plugin-facade.js +857 -217
  59. package/dist/llm/plugin-facade.js.map +3 -3
  60. package/dist/nexus/workspace.d.ts.map +1 -1
  61. package/dist/nexus/workspace.js +54 -4
  62. package/dist/nexus/workspace.js.map +1 -1
  63. package/dist/orchestrate/lifecycle-ops.js +1 -1
  64. package/dist/orchestrate/lifecycle-ops.js.map +1 -1
  65. package/dist/orchestrate/pivot.d.ts.map +1 -1
  66. package/dist/orchestrate/pivot.js +4 -2
  67. package/dist/orchestrate/pivot.js.map +1 -1
  68. package/dist/orchestrate/query-ops.d.ts.map +1 -1
  69. package/dist/orchestrate/query-ops.js +3 -3
  70. package/dist/orchestrate/query-ops.js.map +1 -1
  71. package/dist/orchestrate/spawn-ops.js +1 -1
  72. package/dist/orchestrate/spawn-ops.js.map +1 -1
  73. package/dist/orchestration/index.js +1 -1
  74. package/dist/orchestration/index.js.map +1 -1
  75. package/dist/orchestration/lead-rollup.d.ts.map +1 -1
  76. package/dist/orchestration/lead-rollup.js +3 -3
  77. package/dist/orchestration/lead-rollup.js.map +1 -1
  78. package/dist/orchestration/parallel.js +1 -1
  79. package/dist/orchestration/parallel.js.map +1 -1
  80. package/dist/orchestration/wave-topic.d.ts +4 -2
  81. package/dist/orchestration/wave-topic.d.ts.map +1 -1
  82. package/dist/orchestration/wave-topic.js +6 -4
  83. package/dist/orchestration/wave-topic.js.map +1 -1
  84. package/dist/orchestration/waves.d.ts +2 -1
  85. package/dist/orchestration/waves.d.ts.map +1 -1
  86. package/dist/orchestration/waves.js +4 -3
  87. package/dist/orchestration/waves.js.map +1 -1
  88. package/dist/phases/index.d.ts.map +1 -1
  89. package/dist/phases/index.js +4 -2
  90. package/dist/phases/index.js.map +1 -1
  91. package/dist/selfimprove/fix-gen.js +856 -216
  92. package/dist/selfimprove/fix-gen.js.map +3 -3
  93. package/dist/session/engine-ops.d.ts.map +1 -1
  94. package/dist/session/engine-ops.js +77 -49
  95. package/dist/session/engine-ops.js.map +1 -1
  96. package/dist/sessions/focus-state-store.d.ts +69 -23
  97. package/dist/sessions/focus-state-store.d.ts.map +1 -1
  98. package/dist/sessions/focus-state-store.js +113 -27
  99. package/dist/sessions/focus-state-store.js.map +1 -1
  100. package/dist/sessions/handoff.js +1 -1
  101. package/dist/sessions/handoff.js.map +1 -1
  102. package/dist/sessions/index.d.ts.map +1 -1
  103. package/dist/sessions/index.js +24 -13
  104. package/dist/sessions/index.js.map +1 -1
  105. package/dist/sessions/terminal-identity.d.ts +165 -17
  106. package/dist/sessions/terminal-identity.d.ts.map +1 -1
  107. package/dist/sessions/terminal-identity.js +341 -32
  108. package/dist/sessions/terminal-identity.js.map +1 -1
  109. package/dist/skills/orchestrator/startup.js +2 -2
  110. package/dist/skills/orchestrator/startup.js.map +1 -1
  111. package/dist/snapshot/index.d.ts +4 -0
  112. package/dist/snapshot/index.d.ts.map +1 -1
  113. package/dist/snapshot/index.js +50 -3
  114. package/dist/snapshot/index.js.map +1 -1
  115. package/dist/store/db-helpers.d.ts +15 -2
  116. package/dist/store/db-helpers.d.ts.map +1 -1
  117. package/dist/store/db-helpers.js +84 -3
  118. package/dist/store/db-helpers.js.map +1 -1
  119. package/dist/store/dependency-cycles.d.ts +164 -0
  120. package/dist/store/dependency-cycles.d.ts.map +1 -0
  121. package/dist/store/dependency-cycles.js +360 -0
  122. package/dist/store/dependency-cycles.js.map +1 -0
  123. package/dist/store/display-id-alias.d.ts +93 -37
  124. package/dist/store/display-id-alias.d.ts.map +1 -1
  125. package/dist/store/display-id-alias.js +590 -115
  126. package/dist/store/display-id-alias.js.map +1 -1
  127. package/dist/store/dual-scope-db.d.ts.map +1 -1
  128. package/dist/store/dual-scope-db.js +34 -7
  129. package/dist/store/dual-scope-db.js.map +1 -1
  130. package/dist/store/exodus/migrate.d.ts.map +1 -1
  131. package/dist/store/exodus/migrate.js +8 -1
  132. package/dist/store/exodus/migrate.js.map +1 -1
  133. package/dist/store/global-salt.d.ts +35 -0
  134. package/dist/store/global-salt.d.ts.map +1 -1
  135. package/dist/store/global-salt.js +136 -16
  136. package/dist/store/global-salt.js.map +1 -1
  137. package/dist/store/migration-sqlite.d.ts.map +1 -1
  138. package/dist/store/migration-sqlite.js +19 -11
  139. package/dist/store/migration-sqlite.js.map +1 -1
  140. package/dist/store/portable-bundle-scan.d.ts.map +1 -1
  141. package/dist/store/portable-bundle-scan.js +6 -0
  142. package/dist/store/portable-bundle-scan.js.map +1 -1
  143. package/dist/store/row-identity-registry.d.ts.map +1 -1
  144. package/dist/store/row-identity-registry.js +11 -3
  145. package/dist/store/row-identity-registry.js.map +1 -1
  146. package/dist/store/row-identity.d.ts +91 -1
  147. package/dist/store/row-identity.d.ts.map +1 -1
  148. package/dist/store/row-identity.js +276 -7
  149. package/dist/store/row-identity.js.map +1 -1
  150. package/dist/store/safety-data-accessor.d.ts +3 -2
  151. package/dist/store/safety-data-accessor.d.ts.map +1 -1
  152. package/dist/store/safety-data-accessor.js +5 -2
  153. package/dist/store/safety-data-accessor.js.map +1 -1
  154. package/dist/store/session-store.d.ts +63 -3
  155. package/dist/store/session-store.d.ts.map +1 -1
  156. package/dist/store/session-store.js +121 -8
  157. package/dist/store/session-store.js.map +1 -1
  158. package/dist/store/split-brain-import.d.ts.map +1 -1
  159. package/dist/store/split-brain-import.js +36 -0
  160. package/dist/store/split-brain-import.js.map +1 -1
  161. package/dist/store/sqlite-data-accessor.d.ts +50 -3
  162. package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
  163. package/dist/store/sqlite-data-accessor.js +246 -13
  164. package/dist/store/sqlite-data-accessor.js.map +1 -1
  165. package/dist/store/tasks-sqlite.d.ts.map +1 -1
  166. package/dist/store/tasks-sqlite.js +14 -2
  167. package/dist/store/tasks-sqlite.js.map +1 -1
  168. package/dist/store/umbrella-data-accessor.d.ts +3 -2
  169. package/dist/store/umbrella-data-accessor.d.ts.map +1 -1
  170. package/dist/store/umbrella-data-accessor.js +5 -2
  171. package/dist/store/umbrella-data-accessor.js.map +1 -1
  172. package/dist/system/health.d.ts +2 -0
  173. package/dist/system/health.d.ts.map +1 -1
  174. package/dist/system/health.js +54 -0
  175. package/dist/system/health.js.map +1 -1
  176. package/dist/task-work/index.d.ts.map +1 -1
  177. package/dist/task-work/index.js +9 -6
  178. package/dist/task-work/index.js.map +1 -1
  179. package/dist/tasks/analyze.d.ts.map +1 -1
  180. package/dist/tasks/analyze.js +3 -2
  181. package/dist/tasks/analyze.js.map +1 -1
  182. package/dist/tasks/evidence.d.ts +19 -1
  183. package/dist/tasks/evidence.d.ts.map +1 -1
  184. package/dist/tasks/evidence.js +79 -18
  185. package/dist/tasks/evidence.js.map +1 -1
  186. package/dist/tasks/infer-add-params.js +1 -1
  187. package/dist/tasks/infer-add-params.js.map +1 -1
  188. package/dist/tasks/task-context.d.ts.map +1 -1
  189. package/dist/tasks/task-context.js +3 -1
  190. package/dist/tasks/task-context.js.map +1 -1
  191. package/dist/tasks/task-import.d.ts.map +1 -1
  192. package/dist/tasks/task-import.js +11 -3
  193. package/dist/tasks/task-import.js.map +1 -1
  194. package/dist/tasks/task-next.d.ts +5 -1
  195. package/dist/tasks/task-next.d.ts.map +1 -1
  196. package/dist/tasks/task-next.js +7 -2
  197. package/dist/tasks/task-next.js.map +1 -1
  198. package/dist/validation/engine-ops.d.ts.map +1 -1
  199. package/dist/validation/engine-ops.js +3 -2
  200. package/dist/validation/engine-ops.js.map +1 -1
  201. package/dist/workgraph/scaffold-apply.d.ts.map +1 -1
  202. package/dist/workgraph/scaffold-apply.js +14 -5
  203. package/dist/workgraph/scaffold-apply.js.map +1 -1
  204. package/migrations/drizzle-cleo-project/20260930160000_t12886-dependency-cycle-guard/migration.sql +76 -0
  205. package/migrations/drizzle-cleo-project/20260930160000_t12886-dependency-cycle-guard/revert.sql +7 -0
  206. package/package.json +13 -13
  207. package/templates/CLEO-INJECTION.md +2 -2
  208. package/templates/CLEO-REFERENCE.md +1 -1
@@ -0,0 +1,1023 @@
1
+ /**
2
+ * Cleo Nexus device enrolment: the device half of `cleo login nexus` and the
3
+ * one-time upgrade of a 9.24 bearer session (cleo-nexus device contract v2.9,
4
+ * §3.3 steps 4 to 10, §3.4). Everything here runs only behind
5
+ * `CLEO_NEXUS_DEVICE=1` ({@link isNexusDeviceEnabled}); with the switch off
6
+ * the CLI keeps the 9.24 session flow in `nexus-auth.ts` untouched.
7
+ *
8
+ * Lock discipline (v2.9). `nexus-device.json`'s lock is held only around local
9
+ * read-modify-writes, never across a network call:
10
+ *
11
+ * 1. under the lock: re-read, check, persist the identity and the E1 intent;
12
+ * 2. release the lock, send the request with a plain timeout;
13
+ * 3. re-take the lock and re-check with a compare-and-swap before storing.
14
+ *
15
+ * Racing logins on one home (step 7). When the file already holds a different
16
+ * credential for the same (origin, user), E2 (`/v1/whoami`) is asked about
17
+ * each credential and the one that answers 200 is kept; the other is dropped
18
+ * with no E9 and no `pendingSignOut` (the server already refused it). Both
19
+ * 200: the later `createdAt` wins and a `W_NEXUS_LOGIN_RACE_BOTH_LIVE`
20
+ * warning is added. Both 401: neither is kept and a login is required. A
21
+ * network error or 5xx leaves the file as it is.
22
+ *
23
+ * No function here logs, no result or error carries a token or key, and no
24
+ * guarded entry is ever spread: every entry change goes through the store's
25
+ * `apply*` transitions.
26
+ *
27
+ * @task T12868
28
+ * @epic T12323
29
+ */
30
+ import { randomBytes } from 'node:crypto';
31
+ import { hostname as osHostname } from 'node:os';
32
+ import { nexusAccountOrganizationSchema, } from '@cleocode/contracts';
33
+ import { z } from 'zod';
34
+ import { generateEd25519, generateX25519, signEd25519 } from './crypto.js';
35
+ import { Http, NexusError } from './http.js';
36
+ import { NEXUS_REVOKE_TIMEOUT_MS, NexusAccountError, resolveNexusApiUrl, runNexusDeviceCode, signOutNexusSessionToken, } from './nexus-auth.js';
37
+ import { FileNexusTokenStore, nexusOriginKey, } from './nexus-credentials.js';
38
+ import { applyClearEnrolIntent, applyDropCurrent, applyDropRaceCandidate, applyEnrolIntent, applyEnrolment, applyRefreshEnrolIntent, applySetRaceCandidate, assertEnrolmentAllowed, guardNexusDeviceSecrets, NEXUS_DEVICE_CREDENTIAL_PATTERN, NEXUS_DEVICE_PROFILES, NEXUS_DEVICE_SCOPES, NexusDeviceEnrolment, NexusDeviceStore, NexusDeviceStoreError, UnreadableNexusDevice, } from './nexus-device.js';
39
+ import { deviceEnrollmentMessage } from './signing.js';
40
+ import { uuidv7 } from './uuidv7.js';
41
+ /**
42
+ * E1's request timeout. An upgrade intent older than this is stale: the
43
+ * process that wrote it has given up or died (§3.4 step 1).
44
+ */
45
+ export const NEXUS_ENROL_TIMEOUT_MS = 30_000;
46
+ /** Timeout of one E2 (`/v1/whoami`) call. */
47
+ export const NEXUS_WHOAMI_TIMEOUT_MS = 15_000;
48
+ /** How long an upgrade waits for another process's live upgrade intent (the ~60 s lock budget). */
49
+ export const NEXUS_UPGRADE_WAIT_MS = 60_000;
50
+ /** Interval between locked re-reads while waiting for another process's upgrade. */
51
+ export const NEXUS_UPGRADE_POLL_MS = 250;
52
+ /** Slack added to the stale age of an E1 intent, over its owner's E2 and E1 timeouts. */
53
+ export const NEXUS_INTENT_MARGIN_MS = 15_000;
54
+ /**
55
+ * Age after which another process's E1 intent is stale (§3.4 step 1). A live
56
+ * owner can be E2 plus E1 past `startedAt` (it also refreshes `startedAt`
57
+ * right before E1), so the threshold is derived from both timeouts plus a
58
+ * margin, never E1's timeout alone (security review M1).
59
+ */
60
+ export const NEXUS_INTENT_STALE_MS = NEXUS_WHOAMI_TIMEOUT_MS + NEXUS_ENROL_TIMEOUT_MS + NEXUS_INTENT_MARGIN_MS;
61
+ /** Warning code: two racing logins both held a live credential (§3.3 step 7, ruling A). */
62
+ export const W_NEXUS_LOGIN_RACE_BOTH_LIVE = 'W_NEXUS_LOGIN_RACE_BOTH_LIVE';
63
+ /** Warning code: the chosen device name is this machine's hostname (L8). */
64
+ export const W_NEXUS_DEVICE_NAME_IS_HOSTNAME = 'W_NEXUS_DEVICE_NAME_IS_HOSTNAME';
65
+ /** The remedy for any failure that needs a fresh, human, browser login. */
66
+ const BROWSER_LOGIN_FIX = 'run `cleo login nexus` (needs a browser: on a headless machine a human must complete the login)';
67
+ const uuid = z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
68
+ const profileSchema = z.enum(NEXUS_DEVICE_PROFILES);
69
+ /** The subset of `DeviceView` (§4.1) the CLI reads. */
70
+ const deviceViewSchema = z.object({
71
+ deviceId: uuid,
72
+ name: z.string(),
73
+ state: z.string(),
74
+ profile: profileSchema.nullable().optional(),
75
+ });
76
+ /** The subset of `Whoami` (E2, §4.1) the CLI reads. Unknown fields are ignored. */
77
+ const whoamiSchema = z.object({
78
+ serverTime: z.string().optional(),
79
+ user: z.object({
80
+ id: z.string().min(1),
81
+ email: z.string(),
82
+ name: z.string().nullable().optional(),
83
+ }),
84
+ organizations: z.array(nexusAccountOrganizationSchema).default([]),
85
+ credential: z
86
+ .object({
87
+ kind: z.enum(['device', 'session']),
88
+ credentialId: z.string().nullable().optional(),
89
+ profile: profileSchema.nullable().optional(),
90
+ scopes: z.array(z.string()).default([]),
91
+ })
92
+ .optional(),
93
+ device: deviceViewSchema.nullable().optional(),
94
+ });
95
+ /** `EnrollDeviceResult` (E1, §4.1), as far as the CLI reads it. */
96
+ const enrollResultSchema = z.object({
97
+ device: deviceViewSchema,
98
+ credential: z.object({
99
+ credentialId: uuid,
100
+ token: z.string().regex(NEXUS_DEVICE_CREDENTIAL_PATTERN),
101
+ profile: profileSchema,
102
+ scopes: z.array(z.string()),
103
+ createdAt: z.iso.datetime(),
104
+ }),
105
+ created: z.boolean(),
106
+ });
107
+ function context(opts) {
108
+ const base = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
109
+ return {
110
+ apiUrl: resolveNexusApiUrl(opts.apiUrl),
111
+ fetch: base,
112
+ devices: opts.deviceStore ?? new NexusDeviceStore(),
113
+ sessions: opts.store ?? new FileNexusTokenStore(),
114
+ now: opts.now ?? (() => new Date()),
115
+ sleep: opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
116
+ enrolTimeoutMs: opts.enrolTimeoutMs ?? NEXUS_ENROL_TIMEOUT_MS,
117
+ intentStaleMs: (opts.whoamiTimeoutMs ?? NEXUS_WHOAMI_TIMEOUT_MS) +
118
+ (opts.enrolTimeoutMs ?? NEXUS_ENROL_TIMEOUT_MS) +
119
+ (opts.intentMarginMs ?? NEXUS_INTENT_MARGIN_MS),
120
+ whoamiTimeoutMs: opts.whoamiTimeoutMs ?? NEXUS_WHOAMI_TIMEOUT_MS,
121
+ upgradeWaitMs: opts.upgradeWaitMs ?? NEXUS_UPGRADE_WAIT_MS,
122
+ upgradePollMs: opts.upgradePollMs ?? NEXUS_UPGRADE_POLL_MS,
123
+ cliVersion: async () => {
124
+ if (opts.cliVersion !== undefined)
125
+ return opts.cliVersion;
126
+ const { getCleoVersion } = await import('../scaffold/ensure-config.js');
127
+ return getCleoVersion();
128
+ },
129
+ };
130
+ }
131
+ /** One HTTP client for one bearer: one attempt, a plain timeout (no lock is held). */
132
+ function client(ctx, bearer, timeoutMs) {
133
+ const timed = (input, init) => ctx.fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) });
134
+ return new Http({ baseUrl: ctx.apiUrl, token: bearer, fetch: timed, maxAttempts: 1 });
135
+ }
136
+ /** A fresh 32-hex intent owner id. */
137
+ function newOwner() {
138
+ return randomBytes(16).toString('hex');
139
+ }
140
+ // ---------- error mapping (§4.0.4) ----------
141
+ /** The `details.reason` of an API error, when it is a string. */
142
+ function reasonOf(err) {
143
+ const reason = err.details?.['reason'];
144
+ return typeof reason === 'string' ? reason : undefined;
145
+ }
146
+ /** True when the request got no usable answer (network, timeout) or a 5xx. */
147
+ function isUnanswered(err) {
148
+ return err instanceof NexusError && (err.code === 'E_NETWORK' || err.status >= 500);
149
+ }
150
+ /**
151
+ * Map an API failure to the CLI's error codes (device contract §4.0.4). The
152
+ * reasons handled internally by the enrolment and rotation flows
153
+ * (`device-revoked` and `device-other-account` on E1, `device-id-taken`,
154
+ * `device-keys-changed`, `project-id-taken`) are mapped by their callers
155
+ * first; anything reaching this function is reported.
156
+ *
157
+ * @param err - The thrown error.
158
+ * @returns A {@link NexusAccountError} with a stable code, or `err` itself
159
+ * when it is not an API failure.
160
+ */
161
+ export function nexusApiErrorToAccountError(err) {
162
+ if (err instanceof NexusAccountError || !(err instanceof NexusError)) {
163
+ return err instanceof Error ? err : new Error(String(err));
164
+ }
165
+ const reason = reasonOf(err);
166
+ const remedy = typeof err.details?.['remedy'] === 'string' ? err.details['remedy'] : undefined;
167
+ if (err.code === 'E_NETWORK') {
168
+ return new NexusAccountError('E_NEXUS_UNREACHABLE', `Cleo Nexus did not answer: ${err.message}`, 'check your network and retry');
169
+ }
170
+ if (err.status === 401) {
171
+ switch (reason) {
172
+ case 'credential-revoked':
173
+ if (err.details?.['revokedReason'] === 'reenrolled') {
174
+ // Replaced by a newer login of this device, not stolen: a login
175
+ // fixes it, and the device id stays (security review M2b).
176
+ return new NexusAccountError('E_NEXUS_NOT_SIGNED_IN', "this device's credential was replaced by a newer login of the same device", 'run `cleo login nexus`');
177
+ }
178
+ return new NexusAccountError('E_NEXUS_CREDENTIAL_COMPROMISED', "this device's credential was replaced or revoked elsewhere, possibly used from another machine", 'revoke this device on cleocode.dev and sign in again with `cleo login nexus`');
179
+ case 'credential-expired':
180
+ return new NexusAccountError('E_NEXUS_SESSION_EXPIRED', 'the device credential expired after 90 days without use', 'run `cleo login nexus`');
181
+ case 'device-signed-out':
182
+ return new NexusAccountError('E_NEXUS_DEVICE_SIGNED_OUT', 'this device was signed out', 'run `cleo login nexus`');
183
+ case 'device-revoked':
184
+ return new NexusAccountError('E_NEXUS_DEVICE_REVOKED', 'this device was revoked', 'run `cleo login nexus` to enrol this machine as a new device');
185
+ case 'session-bearer-retired':
186
+ return new NexusAccountError('E_NEXUS_SESSION_EXPIRED', 'bearer sessions are no longer accepted', 'run `cleo login nexus` with the current CLI');
187
+ default:
188
+ return new NexusAccountError('E_NEXUS_NOT_SIGNED_IN', 'not signed in to Cleo Nexus (the credential is missing or invalid)', 'run `cleo login nexus`');
189
+ }
190
+ }
191
+ if (err.status === 403) {
192
+ if (reason === 'insufficient-scope') {
193
+ return new NexusAccountError('E_NEXUS_INSUFFICIENT_SCOPE', `this device's credential lacks the scope for that request${typeof err.details?.['requiredScope'] === 'string' ? ` (${err.details['requiredScope']})` : ''}`, 'run `cleo login nexus` without --read-only to enrol with the full device profile');
194
+ }
195
+ if (reason === 'bearer-session-required' || reason === 'session-not-fresh') {
196
+ return new NexusAccountError('E_NEXUS_SESSION_EXPIRED', 'the session is too old to enrol this device', BROWSER_LOGIN_FIX);
197
+ }
198
+ if (reason === 'device-limit') {
199
+ return new NexusAccountError('E_NEXUS_REQUEST_FAILED', 'the account has reached its device limit', remedy ?? 'revoke unused devices on cleocode.dev, then retry');
200
+ }
201
+ }
202
+ if (err.status === 409 && reason === 'replica-copied') {
203
+ return new NexusAccountError('E_NEXUS_REPLICA_COPIED', 'this project store is attached from another device (a copied store)', remedy);
204
+ }
205
+ const retry = err.status === 429 || err.status >= 500 ? ' (retryable)' : '';
206
+ return new NexusAccountError('E_NEXUS_REQUEST_FAILED', `${err.message}${retry}`, remedy);
207
+ }
208
+ /** Map a device-store failure to a CLI error, keeping its message (never a secret). */
209
+ function storeErrorToAccountError(err) {
210
+ if (!(err instanceof NexusDeviceStoreError))
211
+ return err instanceof Error ? err : new Error(String(err));
212
+ if (err.code === 'E_NEXUS_DEVICE_REVOKE_PENDING') {
213
+ return new NexusAccountError('E_NEXUS_REVOKE_PENDING', 'a revoke of this device is not yet confirmed by the server, so logging in would undo it', 'finish the revoke (`cleo logout nexus --revoke` retries it) or cancel it, then log in');
214
+ }
215
+ if (err.code === 'E_NEXUS_DEVICE_BUSY' || err.code === 'E_NEXUS_DEVICE_LOCK_COMPROMISED') {
216
+ return new NexusAccountError('E_NEXUS_BUSY', err.message, 'retry the command');
217
+ }
218
+ return new NexusAccountError('E_NEXUS_REQUEST_FAILED', err.message);
219
+ }
220
+ // ---------- device identity ----------
221
+ /** Fresh X25519 and Ed25519 key pairs (node:crypto), base64 raw 32 bytes each. */
222
+ function freshKeys() {
223
+ const enc = generateX25519();
224
+ const sig = generateEd25519();
225
+ return {
226
+ encryption: {
227
+ publicKey: enc.publicKey.toString('base64'),
228
+ privateKey: enc.privateKey.toString('base64'),
229
+ },
230
+ signing: {
231
+ publicKey: sig.publicKey.toString('base64'),
232
+ privateKey: sig.privateKey.toString('base64'),
233
+ },
234
+ };
235
+ }
236
+ /** `process.platform` mapped to the contract's `Platform`. */
237
+ function platformOf(p) {
238
+ return p === 'darwin' || p === 'linux' || p === 'win32' ? p : 'other';
239
+ }
240
+ /** `process.arch` mapped to the contract's `Arch`. */
241
+ function archOf(a) {
242
+ return a === 'x64' || a === 'arm64' ? a : 'other';
243
+ }
244
+ /**
245
+ * The default device name (§3.2): `<OS label> <arch> · <first 4 hex of the
246
+ * device id>`. The hostname is never used.
247
+ *
248
+ * @param deviceId - The Nexus device id.
249
+ * @param platform - `process.platform`.
250
+ * @param arch - `process.arch`.
251
+ * @returns For example `macOS arm64 · 01f3`.
252
+ */
253
+ export function defaultNexusDeviceName(deviceId, platform = process.platform, arch = process.arch) {
254
+ const labels = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
255
+ return `${labels[platform] ?? 'Other'} ${archOf(arch)} · ${deviceId.replace(/-/g, '').slice(0, 4)}`;
256
+ }
257
+ /** The scopes of an E1 answer this CLI knows (unknown ones are dropped, never stored). */
258
+ function knownScopes(scopes) {
259
+ const known = NEXUS_DEVICE_SCOPES;
260
+ return scopes.filter((s) => known.includes(s));
261
+ }
262
+ /**
263
+ * Discard an entry this machine's key cannot open, so a login or upgrade can
264
+ * enrol afresh (§2.2 "a lost machine key"). The unreadable entry's device id
265
+ * is read from the lock-free listing; the discard is a CAS on it.
266
+ */
267
+ async function unreadableDeviceId(ctx, userId) {
268
+ const origin = nexusOriginKey(ctx.apiUrl);
269
+ const listed = await ctx.devices.list();
270
+ const hit = listed.find((d) => d instanceof UnreadableNexusDevice && d.origin === origin && d.userId === userId);
271
+ return hit?.deviceId ?? null;
272
+ }
273
+ /** Inside a transaction: discard the unreadable entry, if it is still the one listed. */
274
+ function discardIfUnreadable(tx, ctx, userId, unreadable) {
275
+ if (unreadable === null)
276
+ return;
277
+ tx.discardUnreadable(ctx.apiUrl, userId, { deviceId: unreadable });
278
+ }
279
+ /**
280
+ * Step 5 inside a transaction: refuse while a revoke is pending, then load or
281
+ * mint the identity and persist it with the E1 intent. `replaceIdentity`
282
+ * mints a new device (after a 409 that burns the current id).
283
+ */
284
+ function prepareInTx(tx, ctx, input, replaceIdentity) {
285
+ const entry = tx.get(ctx.apiUrl, input.userId);
286
+ if (entry !== null)
287
+ assertEnrolmentAllowed(entry, entry.deviceId);
288
+ const now = ctx.now();
289
+ const reuse = entry !== null && !replaceIdentity;
290
+ const deviceId = reuse ? entry.deviceId : uuidv7(now.getTime());
291
+ const keys = reuse && entry.keys !== null ? entry.keys : freshKeys();
292
+ const intent = {
293
+ deviceId,
294
+ kind: input.kind,
295
+ owner: input.owner,
296
+ startedAt: now.toISOString(),
297
+ };
298
+ tx.set(ctx.apiUrl, input.userId, applyEnrolIntent(entry, { deviceId, keys, intent }, now));
299
+ // Guarded: the private keys never print (security review L7).
300
+ return guardNexusDeviceSecrets({
301
+ deviceId,
302
+ keys: structuredClone(keys),
303
+ regeneratedKeys: reuse && entry.keys === null,
304
+ baseline: reuse ? (entry.current?.credentialId ?? null) : null,
305
+ });
306
+ }
307
+ /** Step 5: take the lock, discard an unreadable entry, persist identity and intent, release. */
308
+ async function prepare(ctx, input, replaceIdentity, warnings) {
309
+ const unreadable = await unreadableDeviceId(ctx, input.userId);
310
+ return ctx.devices.update((tx) => {
311
+ discardIfUnreadable(tx, ctx, input.userId, unreadable);
312
+ const prepared = prepareInTx(tx, ctx, input, replaceIdentity);
313
+ warnings.push(...tx.warnings);
314
+ return prepared;
315
+ });
316
+ }
317
+ /** Refresh this process's E1 intent under the lock; `false` when it is no longer ours. */
318
+ async function refreshIntent(ctx, input, prepared) {
319
+ return ctx.devices.update((tx) => {
320
+ const entry = tx.get(ctx.apiUrl, input.userId);
321
+ if (entry === null || entry.deviceId !== prepared.deviceId)
322
+ return false;
323
+ const next = applyRefreshEnrolIntent(entry, input.owner, ctx.now());
324
+ if (next === null)
325
+ return false;
326
+ tx.set(ctx.apiUrl, input.userId, next);
327
+ return true;
328
+ });
329
+ }
330
+ /** Step 6: E1 with the session, proof signed with the device's Ed25519 key. */
331
+ async function sendEnrol(ctx, input, prepared) {
332
+ const encPub = Buffer.from(prepared.keys.encryption.publicKey, 'base64');
333
+ const sigPub = Buffer.from(prepared.keys.signing.publicKey, 'base64');
334
+ const message = deviceEnrollmentMessage({
335
+ userId: input.userId,
336
+ deviceId: prepared.deviceId,
337
+ encryptionPublicKeyHex: encPub.toString('hex'),
338
+ signingPublicKeyHex: sigPub.toString('hex'),
339
+ profile: input.profile,
340
+ });
341
+ const proof = signEd25519({ publicKey: sigPub, privateKey: Buffer.from(prepared.keys.signing.privateKey, 'base64') }, message);
342
+ const body = {
343
+ deviceId: prepared.deviceId,
344
+ name: input.name ?? defaultNexusDeviceName(prepared.deviceId),
345
+ platform: platformOf(process.platform),
346
+ arch: archOf(process.arch),
347
+ cliVersion: (await ctx.cliVersion()).slice(0, 40),
348
+ encryptionPublicKey: prepared.keys.encryption.publicKey,
349
+ signingPublicKey: prepared.keys.signing.publicKey,
350
+ profile: input.profile,
351
+ proof: proof.toString('base64'),
352
+ };
353
+ const { data, status } = await client(ctx, input.session, ctx.enrolTimeoutMs).requestWithStatus('POST', '/v1/devices/enroll', enrollResultSchema, body);
354
+ return { result: data, status };
355
+ }
356
+ /** E2 with a bearer: `ok` (200), `refused` (401) or `unknown` (anything else). */
357
+ async function probe(ctx, bearer) {
358
+ try {
359
+ const whoami = await client(ctx, bearer, ctx.whoamiTimeoutMs).request('GET', '/v1/whoami', whoamiSchema);
360
+ return { state: 'ok', whoami };
361
+ }
362
+ catch (err) {
363
+ if (err instanceof NexusError && err.status === 401)
364
+ return { state: 'refused', whoami: null };
365
+ return { state: 'unknown', whoami: null };
366
+ }
367
+ }
368
+ /** Clear this process's intent, best effort (a failure leaves only a stale intent). */
369
+ async function clearIntent(ctx, userId, owner) {
370
+ try {
371
+ await ctx.devices.update((tx) => {
372
+ const entry = tx.get(ctx.apiUrl, userId);
373
+ if (entry?.enrolIntent?.owner === owner) {
374
+ tx.set(ctx.apiUrl, userId, applyClearEnrolIntent(entry, owner));
375
+ }
376
+ });
377
+ }
378
+ catch {
379
+ /* a stale intent is harmless: it is taken over or ignored */
380
+ }
381
+ }
382
+ /**
383
+ * Step 7: re-take the lock and store E1's credential with a CAS, applying the
384
+ * racing-login rule when the file already holds a different credential.
385
+ */
386
+ async function store(ctx, input, prepared, issued, warnings) {
387
+ const enrolment = new NexusDeviceEnrolment({
388
+ deviceId: prepared.deviceId,
389
+ keys: prepared.keys,
390
+ credential: {
391
+ credentialId: issued.credential.credentialId,
392
+ token: issued.credential.token,
393
+ profile: issued.credential.profile,
394
+ scopes: knownScopes(issued.credential.scopes),
395
+ createdAt: issued.credential.createdAt,
396
+ },
397
+ });
398
+ const ours = enrolment.credential();
399
+ for (let round = 0; round < 3; round++) {
400
+ const step = await ctx.devices.update((tx) => {
401
+ const entry = tx.get(ctx.apiUrl, input.userId);
402
+ if (entry !== null && entry.deviceId !== prepared.deviceId) {
403
+ // Another process replaced this machine's identity meanwhile: never
404
+ // retire that device's live credential for ours (security review L3).
405
+ tx.set(ctx.apiUrl, input.userId, applyClearEnrolIntent(entry, input.owner));
406
+ return { kind: 'identity-changed' };
407
+ }
408
+ if (entry !== null && entry.pendingRevoke !== null) {
409
+ tx.set(ctx.apiUrl, input.userId, applyClearEnrolIntent(entry, input.owner));
410
+ return { kind: 'revoke-pending' };
411
+ }
412
+ const held = entry?.current ?? null;
413
+ if (entry === null || held === null || held.credentialId === prepared.baseline) {
414
+ tx.set(ctx.apiUrl, input.userId, applyClearEnrolIntent(applyEnrolment(entry, enrolment, ctx.now()), input.owner));
415
+ return { kind: 'stored' };
416
+ }
417
+ if (held.credentialId === ours.credentialId)
418
+ return { kind: 'stored' };
419
+ // Racing logins: park ours, sealed, beside the held one BEFORE probing,
420
+ // so the only live credential is never lost (security review M2).
421
+ tx.set(ctx.apiUrl, input.userId, applyClearEnrolIntent(applySetRaceCandidate(entry, {
422
+ credentialId: ours.credentialId,
423
+ token: ours.token,
424
+ profile: ours.profile,
425
+ scopes: ours.scopes,
426
+ createdAt: ours.createdAt,
427
+ }), input.owner));
428
+ return { kind: 'race' };
429
+ });
430
+ if (step.kind === 'identity-changed') {
431
+ throw new NexusAccountError('E_NEXUS_BUSY', `another cleo process replaced this machine's Nexus device while device ${prepared.deviceId} was being enrolled; nothing was stored`, `retry \`cleo login nexus\`; if device ${prepared.deviceId} is listed on cleocode.dev, revoke it there`);
432
+ }
433
+ if (step.kind === 'revoke-pending') {
434
+ throw storeErrorToAccountError(new NexusDeviceStoreError('E_NEXUS_DEVICE_REVOKE_PENDING', 'revoke pending'));
435
+ }
436
+ if (step.kind === 'stored')
437
+ return requireStored(ctx, input.userId);
438
+ const outcome = await settleRaceCandidate(ctx, input.userId, warnings);
439
+ if (outcome === 'unknown') {
440
+ throw new NexusAccountError('E_NEXUS_UNREACHABLE', 'another login stored a device credential at the same time, and the server could not confirm which one is live; both are kept, sealed, and the next cloud command settles them', 'run `cleo login nexus` again when Cleo Nexus is reachable: it re-enrols the same device id, which revokes any credential left orphaned on the server');
441
+ }
442
+ if (outcome === 'both-refused') {
443
+ throw new NexusAccountError('E_NEXUS_NOT_SIGNED_IN', 'two logins raced on this CLEO home and the server refused both credentials', 'run `cleo login nexus`');
444
+ }
445
+ if (outcome !== 'changed')
446
+ return requireStored(ctx, input.userId);
447
+ // The file changed again while probing: re-run the check.
448
+ }
449
+ await clearIntent(ctx, input.userId, input.owner);
450
+ throw new NexusAccountError('E_NEXUS_BUSY', 'other logins kept replacing the device credential on this CLEO home', 'retry the command');
451
+ }
452
+ /**
453
+ * Settle a parked race candidate (§3.3 step 7, ruling A): ask E2 about it and
454
+ * about `current`, then, under the lock with a CAS on both, keep the one that
455
+ * answers 200 (both: the later `createdAt`, with
456
+ * {@link W_NEXUS_LOGIN_RACE_BOTH_LIVE}), or drop both when both are refused.
457
+ * The loser is dropped with no E9 and no `pendingSignOut`: the server already
458
+ * refused it. With no answer, nothing changes.
459
+ */
460
+ async function settleRaceCandidate(ctx, userId, warnings) {
461
+ const snap = (await ctx.devices.get(ctx.apiUrl, userId))?.unseal() ?? null;
462
+ const candidate = snap?.raceCandidate ?? null;
463
+ if (snap === null || candidate === null)
464
+ return 'none';
465
+ const current = snap.current;
466
+ let keep;
467
+ if (current === null) {
468
+ // No other credential: the candidate is still probed, never promoted
469
+ // blind. 200 promotes, 401 drops, no answer keeps it (re-check P5).
470
+ const mine = await probe(ctx, candidate.token);
471
+ if (mine.state === 'unknown')
472
+ return 'unknown';
473
+ keep = mine.state === 'ok' ? 'candidate' : 'neither';
474
+ }
475
+ else {
476
+ const [mine, other] = await Promise.all([
477
+ probe(ctx, candidate.token),
478
+ probe(ctx, current.token),
479
+ ]);
480
+ if (mine.state === 'unknown' || other.state === 'unknown')
481
+ return 'unknown';
482
+ if (mine.state === 'ok' && other.state === 'ok') {
483
+ keep =
484
+ Date.parse(candidate.createdAt) > Date.parse(current.createdAt) ? 'candidate' : 'current';
485
+ warnings.push(`${W_NEXUS_LOGIN_RACE_BOTH_LIVE}: two logins on this CLEO home both hold a live credential for device ${snap.deviceId}; kept the newer one`);
486
+ }
487
+ else if (mine.state === 'ok') {
488
+ keep = 'candidate';
489
+ }
490
+ else if (other.state === 'ok') {
491
+ keep = 'current';
492
+ }
493
+ else {
494
+ keep = 'neither';
495
+ }
496
+ }
497
+ const currentId = current?.credentialId ?? null;
498
+ const applied = await ctx.devices.update((tx) => {
499
+ const entry = tx.get(ctx.apiUrl, userId);
500
+ if (entry === null ||
501
+ entry.deviceId !== snap.deviceId ||
502
+ entry.raceCandidate?.credentialId !== candidate.credentialId ||
503
+ (entry.current?.credentialId ?? null) !== currentId) {
504
+ return false;
505
+ }
506
+ if (keep === 'candidate') {
507
+ const promoted = new NexusDeviceEnrolment({
508
+ deviceId: entry.deviceId,
509
+ keys: null,
510
+ credential: {
511
+ credentialId: candidate.credentialId,
512
+ token: candidate.token,
513
+ profile: candidate.profile,
514
+ scopes: candidate.scopes,
515
+ createdAt: candidate.createdAt,
516
+ },
517
+ });
518
+ tx.set(ctx.apiUrl, userId, applyEnrolment(entry, promoted, ctx.now()));
519
+ }
520
+ else if (keep === 'current') {
521
+ tx.set(ctx.apiUrl, userId, applyDropRaceCandidate(entry, candidate.credentialId));
522
+ }
523
+ else {
524
+ const dropped = applyDropRaceCandidate(entry, candidate.credentialId);
525
+ tx.set(ctx.apiUrl, userId, currentId === null ? dropped : applyDropCurrent(dropped, currentId));
526
+ }
527
+ return true;
528
+ });
529
+ if (!applied)
530
+ return 'changed';
531
+ return keep === 'candidate'
532
+ ? 'kept-candidate'
533
+ : keep === 'current'
534
+ ? 'kept-current'
535
+ : 'both-refused';
536
+ }
537
+ /** The stored entry after step 7. */
538
+ async function requireStored(ctx, userId) {
539
+ const device = await ctx.devices.get(ctx.apiUrl, userId);
540
+ if (device === null || device.currentBearer() === null) {
541
+ throw new NexusAccountError('E_NEXUS_NOT_SIGNED_IN', 'the device credential was removed by another cleo process', 'run `cleo login nexus`');
542
+ }
543
+ return device;
544
+ }
545
+ /** An E1 409 that §3.3 step 6 handles by minting a new identity. */
546
+ function conflictReason(err) {
547
+ return err instanceof NexusError && err.status === 409 ? (reasonOf(err) ?? null) : null;
548
+ }
549
+ /**
550
+ * Steps 5 to 7 with a session token. On the exempt upgrade path
551
+ * (`input.kind === 'upgrade'`) a lost E1 answer is not retried: the server
552
+ * may have consumed the session (N2).
553
+ */
554
+ async function enrol(ctx, input, alreadyPrepared, warnings) {
555
+ let prepared;
556
+ try {
557
+ prepared = alreadyPrepared ?? (await prepare(ctx, input, false, warnings));
558
+ }
559
+ catch (err) {
560
+ throw storeErrorToAccountError(err);
561
+ }
562
+ let issued = null;
563
+ let adopted = null;
564
+ for (let attempt = 0; attempt < 2 && issued === null && adopted === null; attempt++) {
565
+ // Refresh the intent (owner CAS) right before E1, so a live intent never
566
+ // looks stale to another upgrader (security review M1). On the one-shot
567
+ // upgrade path, an intent that is no longer ours means another process
568
+ // took over: send nothing.
569
+ let ownIntent;
570
+ try {
571
+ ownIntent = await refreshIntent(ctx, input, prepared);
572
+ }
573
+ catch (err) {
574
+ throw storeErrorToAccountError(err);
575
+ }
576
+ if (!ownIntent && input.kind === 'upgrade') {
577
+ throw new NexusAccountError('E_NEXUS_BUSY', 'another cleo process took over this upgrade; the stored session was not sent', 'retry the command');
578
+ }
579
+ try {
580
+ issued = await sendEnrol(ctx, input, prepared);
581
+ }
582
+ catch (err) {
583
+ const reason = conflictReason(err);
584
+ const retryable = attempt === 0 &&
585
+ (reason === 'device-revoked' ||
586
+ reason === 'device-other-account' ||
587
+ reason === 'device-id-taken' ||
588
+ (reason === 'device-keys-changed' && prepared.regeneratedKeys));
589
+ if (reason === 'device-id-taken' && (await enrolledMeanwhile(ctx, input, prepared))) {
590
+ // Another process on this home enrolled this identity: use its result.
591
+ adopted = await requireStored(ctx, input.userId);
592
+ break;
593
+ }
594
+ if (retryable) {
595
+ try {
596
+ prepared = await prepare(ctx, input, true, warnings);
597
+ }
598
+ catch (storeErr) {
599
+ throw storeErrorToAccountError(storeErr);
600
+ }
601
+ continue;
602
+ }
603
+ await clearIntent(ctx, input.userId, input.owner);
604
+ throw enrolError(err, err instanceof NexusError ? (reasonOf(err) ?? null) : null, input);
605
+ }
606
+ }
607
+ if (adopted !== null) {
608
+ return {
609
+ device: adopted,
610
+ view: { deviceId: adopted.deviceId, name: '', state: 'active', profile: null },
611
+ created: null,
612
+ warnings,
613
+ };
614
+ }
615
+ if (issued === null) {
616
+ await clearIntent(ctx, input.userId, input.owner);
617
+ throw new NexusAccountError('E_NEXUS_REQUEST_FAILED', 'the device enrolment did not complete');
618
+ }
619
+ const device = await store(ctx, input, prepared, issued.result, warnings);
620
+ const stored = device.unseal().current;
621
+ const ours = stored?.credentialId === issued.result.credential.credentialId;
622
+ return {
623
+ device,
624
+ view: ours
625
+ ? {
626
+ deviceId: issued.result.device.deviceId,
627
+ name: issued.result.device.name,
628
+ state: issued.result.device.state,
629
+ profile: issued.result.credential.profile,
630
+ }
631
+ : { deviceId: device.deviceId, name: '', state: 'active', profile: stored?.profile ?? null },
632
+ created: ours ? issued.result.created : null,
633
+ warnings,
634
+ };
635
+ }
636
+ /**
637
+ * §3.3 step 6, 409 `device-id-taken`: under the lock, re-read the file. When
638
+ * this identity now holds a credential other than the one step 5 saw, another
639
+ * process on this home enrolled it; the intent is cleared and that result is used.
640
+ */
641
+ async function enrolledMeanwhile(ctx, input, prepared) {
642
+ try {
643
+ return await ctx.devices.update((tx) => {
644
+ const entry = tx.get(ctx.apiUrl, input.userId);
645
+ const held = entry?.current ?? null;
646
+ if (entry === null || entry.deviceId !== prepared.deviceId || held === null)
647
+ return false;
648
+ if (held.credentialId === prepared.baseline)
649
+ return false;
650
+ tx.set(ctx.apiUrl, input.userId, applyClearEnrolIntent(entry, input.owner));
651
+ return true;
652
+ });
653
+ }
654
+ catch {
655
+ return false;
656
+ }
657
+ }
658
+ /** The error for a failed E1 (after the internal 409 handling). */
659
+ function enrolError(err, reason, input) {
660
+ if (input.kind === 'upgrade') {
661
+ if (isUnanswered(err)) {
662
+ return new NexusAccountError('E_NEXUS_SESSION_EXPIRED', 'the automatic upgrade of the stored session lost its answer from Cleo Nexus; that session can enrol only once, so it cannot be retried', BROWSER_LOGIN_FIX);
663
+ }
664
+ const refusedSession = err instanceof NexusError &&
665
+ (err.status === 401 ||
666
+ (err.status === 403 &&
667
+ (reason === 'session-not-fresh' || reason === 'bearer-session-required')));
668
+ if (refusedSession) {
669
+ return new NexusAccountError('E_NEXUS_SESSION_EXPIRED', 'the stored session can no longer be upgraded (expired, or created after the server deploy and older than 15 minutes)', BROWSER_LOGIN_FIX);
670
+ }
671
+ }
672
+ if (isUnanswered(err)) {
673
+ return new NexusAccountError('E_NEXUS_UNREACHABLE', `Cleo Nexus did not answer the device enrolment: ${err instanceof Error ? err.message : String(err)}`, 'run `cleo login nexus` again when Cleo Nexus is reachable: it re-enrols the same device id, which revokes any credential left orphaned on the server');
674
+ }
675
+ const conflict = conflictReason(err);
676
+ if (conflict === 'device-keys-changed') {
677
+ return new NexusAccountError('E_NEXUS_REQUEST_FAILED', 'the server holds different keys for this device than the ones stored on this machine', 'revoke this device on cleocode.dev, then run `cleo login nexus`');
678
+ }
679
+ if (conflict === 'device-id-taken' ||
680
+ conflict === 'device-revoked' ||
681
+ conflict === 'device-other-account') {
682
+ return new NexusAccountError('E_NEXUS_REQUEST_FAILED', `the device enrolment was refused twice (${conflict})`, 'retry `cleo login nexus`');
683
+ }
684
+ return nexusApiErrorToAccountError(err);
685
+ }
686
+ /** Steps 8 and 9 for a login: sign the session out, then confirm the credential with E2. */
687
+ async function finish(ctx, session, device, signOutExpectedToFail, warnings) {
688
+ try {
689
+ await signOutNexusSessionToken(ctx.apiUrl, session, NEXUS_REVOKE_TIMEOUT_MS, ctx.fetch);
690
+ }
691
+ catch (err) {
692
+ if (!signOutExpectedToFail) {
693
+ warnings.push(`the browser session could not be signed out (${err instanceof Error ? err.message : String(err)}); it expires on its own`);
694
+ }
695
+ }
696
+ const bearer = device.currentBearer();
697
+ if (bearer === null)
698
+ return null;
699
+ const confirmed = await probe(ctx, bearer);
700
+ if (confirmed.state !== 'ok') {
701
+ warnings.push('the device credential was stored, but Cleo Nexus could not confirm it yet');
702
+ }
703
+ return confirmed.whoami;
704
+ }
705
+ /** The personal organization, else the first. */
706
+ function primaryOrganization(orgs) {
707
+ return orgs.find((o) => o.personal) ?? orgs[0] ?? null;
708
+ }
709
+ /**
710
+ * `cleo login nexus` with device credentials (contract §3.3): the device-code
711
+ * login gives a session held in memory only; the session enrols this machine
712
+ * as a device (E1), is signed out, and only the scoped device credential is
713
+ * stored, sealed, in `nexus-device.json`. `nexus-credentials.json` is never
714
+ * written.
715
+ *
716
+ * @param opts - API URL, profile, name, UI hooks and test overrides.
717
+ * @returns The secret-free login result, with the device and its scopes.
718
+ * @throws {NexusAccountError} `E_NEXUS_REVOKE_PENDING` while a revoke of this
719
+ * device is unconfirmed; `E_NEXUS_UNREACHABLE`; the §4.0.4 mappings.
720
+ */
721
+ export async function loginToNexusDevice(opts = {}) {
722
+ const ctx = context(opts);
723
+ const profile = opts.readOnly === true ? 'read-only' : 'device';
724
+ const loginOpts = {
725
+ ...(opts.fetch ? { fetch: opts.fetch } : {}),
726
+ ...(opts.onCode ? { onCode: opts.onCode } : {}),
727
+ ...(opts.onPending ? { onPending: opts.onPending } : {}),
728
+ ...(opts.signal ? { signal: opts.signal } : {}),
729
+ ...(opts.pollSleep ? { sleep: opts.pollSleep } : {}),
730
+ };
731
+ const token = await runNexusDeviceCode(ctx.apiUrl, loginOpts, profile === 'read-only' ? 'cleo:read-only' : 'cleo:device');
732
+ const session = token.accessToken;
733
+ const warnings = [];
734
+ // Step 4: who is signing in.
735
+ let me;
736
+ try {
737
+ me = await client(ctx, session, ctx.whoamiTimeoutMs).request('GET', '/v1/whoami', whoamiSchema);
738
+ }
739
+ catch (err) {
740
+ throw nexusApiErrorToAccountError(err);
741
+ }
742
+ const name = opts.name?.trim();
743
+ if (name !== undefined && name !== '' && name === osHostname()) {
744
+ warnings.push(`${W_NEXUS_DEVICE_NAME_IS_HOSTNAME}: device names are visible to members of your organizations`);
745
+ }
746
+ const enrolled = await enrol(ctx, {
747
+ userId: me.user.id,
748
+ session,
749
+ profile,
750
+ kind: 'login',
751
+ owner: newOwner(),
752
+ ...(name !== undefined && name !== '' ? { name } : {}),
753
+ }, null, warnings);
754
+ const confirmed = await finish(ctx, session, enrolled.device, false, warnings);
755
+ return loginResult(ctx, me, confirmed, enrolled, warnings);
756
+ }
757
+ /** Build the step-10 result. */
758
+ function loginResult(ctx, me, confirmed, enrolled, warnings) {
759
+ const who = confirmed ?? me;
760
+ const user = {
761
+ id: who.user.id,
762
+ email: who.user.email,
763
+ ...(who.user.name !== undefined ? { name: who.user.name } : {}),
764
+ };
765
+ const stored = enrolled.device.unseal().current;
766
+ return {
767
+ apiUrl: ctx.apiUrl,
768
+ user,
769
+ organization: primaryOrganization(who.organizations),
770
+ expiresAt: null,
771
+ credentialsPath: ctx.devices.location,
772
+ warnings,
773
+ device: {
774
+ deviceId: enrolled.view.deviceId,
775
+ name: confirmed?.device?.name ?? enrolled.view.name,
776
+ state: confirmed?.device?.state ?? enrolled.view.state,
777
+ profile: stored?.profile ?? enrolled.view.profile,
778
+ created: enrolled.created,
779
+ },
780
+ scopes: confirmed?.credential?.scopes ?? stored?.scopes ?? [],
781
+ };
782
+ }
783
+ /**
784
+ * Upgrade a 9.24 session to a device credential, once (contract §3.4).
785
+ *
786
+ * Under `nexus-device.json`'s lock, then `nexus-credentials.json`'s (always in
787
+ * that order), both files are re-read:
788
+ *
789
+ * - the device file already holds a credential for (origin, user): another
790
+ * process upgraded; the leftover session is removed and E1 is not called;
791
+ * - the session is gone from the v1 file: another process consumed it;
792
+ * - another process's upgrade intent is live: the locks are released and
793
+ * this process waits for it (re-reading under the lock), never calling E1;
794
+ * - an intent older than E1's timeout is stale, and is taken over only when
795
+ * this locked re-read finds no credential and that same intent.
796
+ *
797
+ * Otherwise the identity and the upgrade intent are persisted, the locks are
798
+ * released, and steps 4 to 9 run with the stored session. E1 accepts it once
799
+ * (the auto-upgrade exemption) and deletes it, so a lost answer is reported
800
+ * as needing a browser login, and the session is removed so no later command
801
+ * retries it.
802
+ *
803
+ * @param opts - API URL, stores and test overrides.
804
+ * @returns What happened; never a secret.
805
+ * @throws {NexusAccountError} `E_NEXUS_SESSION_EXPIRED` (browser login
806
+ * needed), `E_NEXUS_BUSY`, `E_NEXUS_REVOKE_PENDING`, or a §4.0.4 mapping.
807
+ */
808
+ export async function upgradeNexusSession(opts = {}) {
809
+ const ctx = context(opts);
810
+ const session = await ctx.sessions.get(ctx.apiUrl);
811
+ if (session === null)
812
+ return { outcome: 'no-session', device: null, warnings: [] };
813
+ const warnings = [];
814
+ // The user id keys the device entry. A session stored without one needs E2
815
+ // first; that call does not consume the session.
816
+ let userId = session.user?.id ?? null;
817
+ if (userId === null) {
818
+ const me = await probe(ctx, session.bearer());
819
+ if (me.whoami === null) {
820
+ const again = await ctx.sessions.get(ctx.apiUrl);
821
+ if (again === null)
822
+ return { outcome: 'no-session', device: null, warnings };
823
+ throw me.state === 'refused'
824
+ ? new NexusAccountError('E_NEXUS_SESSION_EXPIRED', 'the stored Nexus session expired', BROWSER_LOGIN_FIX)
825
+ : new NexusAccountError('E_NEXUS_UNREACHABLE', 'Cleo Nexus did not answer', 'retry');
826
+ }
827
+ userId = me.whoami.user.id;
828
+ }
829
+ const owner = newOwner();
830
+ const input = {
831
+ userId,
832
+ session: session.bearer(),
833
+ profile: 'device',
834
+ kind: 'upgrade',
835
+ owner,
836
+ };
837
+ const deadline = ctx.now().getTime() + ctx.upgradeWaitMs;
838
+ let staleOwner = null;
839
+ let prepared = null;
840
+ while (prepared === null) {
841
+ let step;
842
+ try {
843
+ step = await upgradeCheck(ctx, session, input, staleOwner, warnings);
844
+ }
845
+ catch (err) {
846
+ throw storeErrorToAccountError(err);
847
+ }
848
+ if (step.kind === 'enrolled') {
849
+ await retireLeftoverSession(ctx, session, warnings);
850
+ const device = await ctx.devices.get(ctx.apiUrl, userId);
851
+ return { outcome: 'already-enrolled', device, warnings };
852
+ }
853
+ if (step.kind === 'consumed')
854
+ return { outcome: 'no-session', device: null, warnings };
855
+ if (step.kind === 'go') {
856
+ prepared = step.prepared;
857
+ break;
858
+ }
859
+ if (step.kind === 'stale') {
860
+ // Seen stale once; take it over only if the next locked re-read finds
861
+ // no credential and this same intent.
862
+ staleOwner = step.owner;
863
+ continue;
864
+ }
865
+ staleOwner = null;
866
+ if (ctx.now().getTime() >= deadline) {
867
+ throw new NexusAccountError('E_NEXUS_BUSY', 'another cleo process is upgrading the stored Nexus session and has not finished', 'retry the command');
868
+ }
869
+ await ctx.sleep(ctx.upgradePollMs);
870
+ }
871
+ // Step 4 with the stored session, after the intent is persisted: it must
872
+ // still name the same user.
873
+ const me = await probe(ctx, session.bearer());
874
+ if (me.whoami === null || me.whoami.user.id !== userId) {
875
+ await clearIntent(ctx, userId, owner);
876
+ if (me.state === 'unknown') {
877
+ throw new NexusAccountError('E_NEXUS_UNREACHABLE', 'Cleo Nexus did not answer', 'retry');
878
+ }
879
+ await ctx.sessions.delete(ctx.apiUrl, session).catch(() => false);
880
+ throw new NexusAccountError('E_NEXUS_SESSION_EXPIRED', me.state === 'refused'
881
+ ? 'the stored Nexus session expired'
882
+ : 'the stored Nexus session belongs to another user than the one recorded', BROWSER_LOGIN_FIX);
883
+ }
884
+ let enrolled;
885
+ try {
886
+ enrolled = await enrol(ctx, input, prepared, warnings);
887
+ }
888
+ catch (err) {
889
+ if (err instanceof NexusAccountError && err.code === 'E_NEXUS_SESSION_EXPIRED') {
890
+ // Never retry an exempt session: remove it (CAS on the token).
891
+ await ctx.sessions.delete(ctx.apiUrl, session).catch(() => false);
892
+ }
893
+ throw err;
894
+ }
895
+ // Step 3: the session is consumed; remove it from the v1 file (CAS).
896
+ await ctx.sessions.delete(ctx.apiUrl, session).catch(() => false);
897
+ // Step 4 of §3.4: E1 deleted the exempt session, so a failed sign-out is expected.
898
+ await finish(ctx, session.bearer(), enrolled.device, true, warnings);
899
+ return { outcome: 'upgraded', device: enrolled.device, warnings };
900
+ }
901
+ /**
902
+ * A 9.24 session left in the v1 file next to a device credential (a login ran,
903
+ * or another process upgraded): sign it out server-side first, best effort,
904
+ * as a login signs out its own session, then remove it from the v1 file with
905
+ * a CAS on its token. No lock is held during the sign-out.
906
+ */
907
+ async function retireLeftoverSession(ctx, session, warnings) {
908
+ try {
909
+ await signOutNexusSessionToken(ctx.apiUrl, session.bearer(), NEXUS_REVOKE_TIMEOUT_MS, ctx.fetch);
910
+ }
911
+ catch (err) {
912
+ warnings.push(`the leftover Cleo Nexus session could not be signed out (${err instanceof Error ? err.message : String(err)}); it was removed locally and expires on its own`);
913
+ }
914
+ try {
915
+ await ctx.sessions.delete(ctx.apiUrl, session);
916
+ }
917
+ catch (err) {
918
+ throw sessionStoreError(err);
919
+ }
920
+ }
921
+ /**
922
+ * Map a `nexus-credentials.json` failure to a CLI error: a held lock
923
+ * (`ELOCKED`) is {@link NexusAccountError} `E_NEXUS_BUSY` (security review L4).
924
+ */
925
+ function sessionStoreError(err) {
926
+ if (err instanceof Error && 'code' in err && err.code === 'ELOCKED') {
927
+ return new NexusAccountError('E_NEXUS_BUSY', 'another cleo process holds the Nexus session file', 'retry the command');
928
+ }
929
+ return err instanceof Error ? err : new Error(String(err));
930
+ }
931
+ /**
932
+ * One locked re-check of §3.4 step 1, device file first, then the v1 file. On
933
+ * `go` it has persisted the identity and the upgrade intent in the same
934
+ * transaction, so no second process can pass the check meanwhile.
935
+ */
936
+ async function upgradeCheck(ctx, session, input, staleOwner, warnings) {
937
+ const unreadable = await unreadableDeviceId(ctx, input.userId);
938
+ return ctx.devices.update(async (tx) => {
939
+ discardIfUnreadable(tx, ctx, input.userId, unreadable);
940
+ warnings.push(...tx.warnings.filter((w) => !warnings.includes(w)));
941
+ const entry = tx.get(ctx.apiUrl, input.userId);
942
+ // A leftover session is signed out and removed after this transaction:
943
+ // no network call is made with the lock held.
944
+ if (entry?.current)
945
+ return { kind: 'enrolled' };
946
+ const stored = await ctx.sessions.get(ctx.apiUrl);
947
+ if (stored === null || stored.bearer() !== session.bearer())
948
+ return { kind: 'consumed' };
949
+ const intent = entry?.enrolIntent ?? null;
950
+ // Any live intent (an upgrade or a login) means another process is about
951
+ // to enrol this identity: wait for it (security review M1, LOW).
952
+ if (intent !== null && intent.owner !== input.owner) {
953
+ const age = ctx.now().getTime() - Date.parse(intent.startedAt);
954
+ if (age < ctx.intentStaleMs)
955
+ return { kind: 'wait' };
956
+ if (intent.owner !== staleOwner)
957
+ return { kind: 'stale', owner: intent.owner };
958
+ // Second locked read: no credential and the same stale intent. Take over.
959
+ }
960
+ return { kind: 'go', prepared: prepareInTx(tx, ctx, input, false) };
961
+ });
962
+ }
963
+ /**
964
+ * The device credential for commands that need one (behind `CLEO_NEXUS_DEVICE=1`).
965
+ *
966
+ * - A 9.24 session still in `nexus-credentials.json` is upgraded first, once
967
+ * (§3.4); a leftover session next to an existing credential is signed out
968
+ * and removed.
969
+ * - The account is `opts.userId`, else the upgraded one, else the only one
970
+ * on the origin; with several, it refuses and lists them.
971
+ * - A racing login's parked credential is settled with E2 first (§3.3 step
972
+ * 7); without an answer the current credential is used and a warning added.
973
+ *
974
+ * @param opts - API URL, account, stores and test overrides.
975
+ * @returns The sealed device entry and whether an upgrade ran.
976
+ * @throws {NexusAccountError} `E_NEXUS_NOT_SIGNED_IN` with no usable
977
+ * credential, `E_NEXUS_ACCOUNT_AMBIGUOUS`, or the upgrade's errors.
978
+ */
979
+ export async function ensureNexusDeviceCredential(opts = {}) {
980
+ const ctx = context(opts);
981
+ const upgrade = await upgradeNexusSession({
982
+ ...opts,
983
+ apiUrl: ctx.apiUrl,
984
+ deviceStore: ctx.devices,
985
+ store: ctx.sessions,
986
+ });
987
+ const warnings = [...upgrade.warnings];
988
+ const notSignedIn = () => new NexusAccountError('E_NEXUS_NOT_SIGNED_IN', `not signed in to Cleo Nexus at ${ctx.apiUrl}`, 'run `cleo login nexus`');
989
+ let userId = opts.userId ?? upgrade.device?.userId ?? null;
990
+ if (userId === null) {
991
+ const origin = nexusOriginKey(ctx.apiUrl);
992
+ const holders = new Set();
993
+ for (const d of await ctx.devices.list()) {
994
+ if (d instanceof UnreadableNexusDevice || d.origin !== origin)
995
+ continue;
996
+ if (d.currentBearer() !== null || d.unseal().raceCandidate)
997
+ holders.add(d.userId);
998
+ }
999
+ if (holders.size === 0)
1000
+ throw notSignedIn();
1001
+ if (holders.size > 1) {
1002
+ throw new NexusAccountError('E_NEXUS_ACCOUNT_AMBIGUOUS', `several Cleo Nexus accounts are signed in at ${ctx.apiUrl} on this CLEO home (users ${[...holders].sort().join(', ')}); refusing to pick one`, 'sign out the accounts you do not use with `cleo logout nexus`, or name the account explicitly');
1003
+ }
1004
+ userId = [...holders][0] ?? null;
1005
+ if (userId === null)
1006
+ throw notSignedIn();
1007
+ }
1008
+ const settled = await settleRaceCandidate(ctx, userId, warnings);
1009
+ if (settled === 'both-refused')
1010
+ throw notSignedIn();
1011
+ if (settled === 'unknown' || settled === 'changed') {
1012
+ warnings.push('a racing login left two credentials for this device and the server could not say which is live yet; using the current one');
1013
+ }
1014
+ const device = await ctx.devices.get(ctx.apiUrl, userId);
1015
+ if (device === null || device.currentBearer() === null) {
1016
+ if (settled === 'unknown') {
1017
+ throw new NexusAccountError('E_NEXUS_UNREACHABLE', 'the only device credential on this CLEO home is unconfirmed and Cleo Nexus did not answer; it was kept', 'retry when Cleo Nexus is reachable');
1018
+ }
1019
+ throw notSignedIn();
1020
+ }
1021
+ return { device, upgraded: upgrade.outcome === 'upgraded', warnings };
1022
+ }
1023
+ //# sourceMappingURL=nexus-enrol.js.map