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