@ekanos/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +326 -0
  3. package/dist/auth/credential-store.d.ts +96 -0
  4. package/dist/auth/credential-store.js +274 -0
  5. package/dist/auth/credential-store.js.map +1 -0
  6. package/dist/auth/device-flow.d.ts +57 -0
  7. package/dist/auth/device-flow.js +201 -0
  8. package/dist/auth/device-flow.js.map +1 -0
  9. package/dist/auth/fusion-api.d.ts +48 -0
  10. package/dist/auth/fusion-api.js +310 -0
  11. package/dist/auth/fusion-api.js.map +1 -0
  12. package/dist/auth/session.d.ts +57 -0
  13. package/dist/auth/session.js +114 -0
  14. package/dist/auth/session.js.map +1 -0
  15. package/dist/bin.d.ts +2 -0
  16. package/dist/bin.js +20 -0
  17. package/dist/bin.js.map +1 -0
  18. package/dist/commands/dev.d.ts +36 -0
  19. package/dist/commands/dev.js +207 -0
  20. package/dist/commands/dev.js.map +1 -0
  21. package/dist/commands/init.d.ts +30 -0
  22. package/dist/commands/init.js +178 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/login.d.ts +20 -0
  25. package/dist/commands/login.js +92 -0
  26. package/dist/commands/login.js.map +1 -0
  27. package/dist/commands/logout.d.ts +20 -0
  28. package/dist/commands/logout.js +75 -0
  29. package/dist/commands/logout.js.map +1 -0
  30. package/dist/commands/test.d.ts +16 -0
  31. package/dist/commands/test.js +54 -0
  32. package/dist/commands/test.js.map +1 -0
  33. package/dist/commands/validate.d.ts +14 -0
  34. package/dist/commands/validate.js +98 -0
  35. package/dist/commands/validate.js.map +1 -0
  36. package/dist/commands/whoami.d.ts +18 -0
  37. package/dist/commands/whoami.js +29 -0
  38. package/dist/commands/whoami.js.map +1 -0
  39. package/dist/compatibility.d.ts +81 -0
  40. package/dist/compatibility.js +135 -0
  41. package/dist/compatibility.js.map +1 -0
  42. package/dist/context.d.ts +81 -0
  43. package/dist/context.js +125 -0
  44. package/dist/context.js.map +1 -0
  45. package/dist/errors.d.ts +41 -0
  46. package/dist/errors.js +111 -0
  47. package/dist/errors.js.map +1 -0
  48. package/dist/exit-codes.d.ts +52 -0
  49. package/dist/exit-codes.js +51 -0
  50. package/dist/exit-codes.js.map +1 -0
  51. package/dist/harness-scaffold.d.ts +212 -0
  52. package/dist/harness-scaffold.js +434 -0
  53. package/dist/harness-scaffold.js.map +1 -0
  54. package/dist/index.d.ts +17 -0
  55. package/dist/index.js +262 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/load-definition.d.ts +20 -0
  58. package/dist/load-definition.js +126 -0
  59. package/dist/load-definition.js.map +1 -0
  60. package/dist/merge-manifest.d.ts +44 -0
  61. package/dist/merge-manifest.js +124 -0
  62. package/dist/merge-manifest.js.map +1 -0
  63. package/dist/package-manager.d.ts +25 -0
  64. package/dist/package-manager.js +57 -0
  65. package/dist/package-manager.js.map +1 -0
  66. package/dist/parse-argv.d.ts +27 -0
  67. package/dist/parse-argv.js +74 -0
  68. package/dist/parse-argv.js.map +1 -0
  69. package/dist/project-checks.d.ts +18 -0
  70. package/dist/project-checks.js +77 -0
  71. package/dist/project-checks.js.map +1 -0
  72. package/dist/project.d.ts +140 -0
  73. package/dist/project.js +228 -0
  74. package/dist/project.js.map +1 -0
  75. package/dist/safe-fs.d.ts +55 -0
  76. package/dist/safe-fs.js +209 -0
  77. package/dist/safe-fs.js.map +1 -0
  78. package/dist/templates.d.ts +23 -0
  79. package/dist/templates.js +49 -0
  80. package/dist/templates.js.map +1 -0
  81. package/package.json +62 -0
  82. package/templates/ekanos.json.tmpl +4 -0
  83. package/templates/gitignore.tmpl +12 -0
  84. package/templates/harness/app/[slug]/activation/page.tsx.tmpl +1 -0
  85. package/templates/harness/app/[slug]/layout.tsx.tmpl +1 -0
  86. package/templates/harness/app/[slug]/tile/page.tsx.tmpl +1 -0
  87. package/templates/harness/app/[slug]/triggers/page.tsx.tmpl +1 -0
  88. package/templates/harness/app/[slug]/widgets/[widgetId]/page.tsx.tmpl +1 -0
  89. package/templates/harness/app/[slug]/widgets/page.tsx.tmpl +1 -0
  90. package/templates/harness/app/harness-shell.tsx.tmpl +9 -0
  91. package/templates/harness/app/layout.tsx.tmpl +4 -0
  92. package/templates/harness/app/page.tsx.tmpl +7 -0
  93. package/templates/harness/next.config.mjs.tmpl +46 -0
  94. package/templates/harness/package.json.tmpl +11 -0
  95. package/templates/harness/postcss.config.mjs.tmpl +6 -0
  96. package/templates/harness/styles/globals.css.tmpl +33 -0
  97. package/templates/harness/tsconfig.json.tmpl +31 -0
  98. package/templates/harness/types/team-accounts.d.ts.tmpl +16 -0
  99. package/templates/harness.config.entry.ts.tmpl +65 -0
  100. package/templates/harness.config.ts.tmpl +58 -0
  101. package/templates/package.json.tmpl +28 -0
  102. package/templates/src/integration.test.ts.tmpl +24 -0
  103. package/templates/src/integration.ts.tmpl +83 -0
  104. package/templates/tsconfig.json.tmpl +19 -0
  105. package/templates/vitest.config.ts.tmpl +32 -0
@@ -0,0 +1,310 @@
1
+ import { authRequiredError, networkError, preconditionError } from '../errors.js';
2
+ /**
3
+ * The CLI's client for the four Fusion auth routes.
4
+ *
5
+ * ---------------------------------------------------------------------------
6
+ * WIRE CONTRACT — the seam between this package and the Fusion web app.
7
+ *
8
+ * The server side lives in `apps/web/app/api/auth/cli-<verb>/route.ts` and ships on
9
+ * a DIFFERENT BRANCH from this file. This block is the single place the shapes
10
+ * are written down on the client side; if you change one side, change the
11
+ * other and update this block. Nothing here imports from `apps/web` — the two
12
+ * halves are coupled only by HTTP, and the tests stub `fetch`, never the route.
13
+ *
14
+ * GET /api/auth/cli-config [no auth]
15
+ * → 200 { auth0_domain, client_id, scope,
16
+ * device_authorization_endpoint, token_endpoint } all strings
17
+ * → 503 { error, code: "CLI_NOT_CONFIGURED" } deployment not provisioned
18
+ *
19
+ * POST /api/auth/cli-session [no auth]
20
+ * ← { auth0_access_token: string }
21
+ * → 200 { access_token, expires_at, user: { id, email } }
22
+ * → 400 { error, code: "INVALID_REQUEST" } malformed / wrong shape
23
+ * → 401 { error, code: "SESSION_EXPIRED" } Auth0 said no, OR no
24
+ * Fusion account for it
25
+ * → 503 { error, code: "CLI_NOT_CONFIGURED" } deployment not provisioned
26
+ * → 500 { error, code: "INTERNAL_ERROR" }
27
+ *
28
+ * POST /api/auth/cli-refresh [no auth]
29
+ * ← { auth0_refresh_token: string }
30
+ * → 200 { access_token, expires_at, user: { id, email },
31
+ * auth0_refresh_token } ← ROTATED; the presented one is dead
32
+ * → 400 { error, code: "INVALID_REQUEST" }
33
+ * → 401 { error, code: "SESSION_EXPIRED" } expired or revoked
34
+ * → 404 { error, code: "ACCOUNT_NOT_FOUND",
35
+ * auth0_refresh_token } no Fusion account
36
+ * → 502 { error, code: "TOKEN_EXCHANGE_FAILED" } Auth0 returned no
37
+ * rotated token
38
+ * → 503 { error, code: "CLI_NOT_CONFIGURED" } deployment not provisioned
39
+ * → 503 { error, code: "REFRESH_INCOMPLETE",
40
+ * auth0_refresh_token } rotated, but /userinfo
41
+ * did not answer; RETRYABLE
42
+ * → 500 { error, code: "INTERNAL_ERROR",
43
+ * auth0_refresh_token? } present iff rotation
44
+ * completed before the throw
45
+ *
46
+ * !! cli-refresh MAY RETURN `auth0_refresh_token` ON ERROR RESPONSES, and a
47
+ * client MUST persist it before surfacing the error. Once the server's
48
+ * exchange with Auth0 succeeds the presented token is dead and the value
49
+ * in that body is the ONLY copy in existence; discarding it on a failure
50
+ * path destroys the session permanently. Note 503 has TWO codes here —
51
+ * branch on `code`, not on the status alone.
52
+ *
53
+ * All three routes above read the Fusion CLI Auth0 application config, so all
54
+ * three can answer 503 CLI_NOT_CONFIGURED. cli-whoami cannot — it reads no
55
+ * Auth0 config, only the caller's bearer token.
56
+ *
57
+ * GET /api/auth/cli-whoami [Authorization: Bearer <access_token>]
58
+ * → 200 { user: { id, email, name } }
59
+ * → 401 { error, code: "AUTH_REQUIRED" } token absent or invalid
60
+ * → 403 { error, code: "MFA_REQUIRED" }
61
+ * → 404 { error, code: "ACCOUNT_NOT_FOUND" }
62
+ * → 3xx treated as 401, see below
63
+ *
64
+ * `access_token` is a Fusion HS256 JWT. Every authenticated Fusion route
65
+ * accepts it as `Authorization: Bearer` — see `resolveRequestSupabaseJwt` in
66
+ * `@kit/supabase`, which both `requireUser` and the RLS server client consume.
67
+ * ---------------------------------------------------------------------------
68
+ *
69
+ * Two rules hold everywhere in this file:
70
+ *
71
+ * - `redirect: 'manual'`. Fusion answers an unauthenticated page request with
72
+ * a 307 to `/auth/sign-in`; Node's fetch follows redirects by default, so
73
+ * without this a stale token would come back as a 200 full of HTML and be
74
+ * read as success. A 3xx here is treated as "not authenticated", full stop.
75
+ *
76
+ * - No token is ever interpolated into an error message. The server's error
77
+ * bodies are `{ error, code }` documents that do not echo credentials, and
78
+ * the request bodies that DO carry credentials are never included in a
79
+ * thrown message.
80
+ */
81
+ const REQUEST_TIMEOUT_MS = 15000;
82
+ /** Fetch the device-flow parameters for a host. */
83
+ export async function fetchCliAuthConfig(host) {
84
+ const response = await request(`${host}/api/auth/cli-config`, {
85
+ method: 'GET',
86
+ headers: { accept: 'application/json' },
87
+ });
88
+ throwIfNotConfigured(response, host);
89
+ if (!response.ok) {
90
+ throw unexpectedStatus(host, '/api/auth/cli-config', response.status);
91
+ }
92
+ const body = await readJson(response, `${host}/api/auth/cli-config`);
93
+ const config = {
94
+ auth0Domain: str(body.auth0_domain),
95
+ clientId: str(body.client_id),
96
+ scope: str(body.scope),
97
+ deviceAuthorizationEndpoint: str(body.device_authorization_endpoint),
98
+ tokenEndpoint: str(body.token_endpoint),
99
+ };
100
+ if (Object.values(config).some((value) => value === null)) {
101
+ throw preconditionError(`${host} returned an incomplete CLI login configuration.`, `Confirm the deployment is running a Fusion build that includes the ` +
102
+ `/api/auth/cli-config route.`);
103
+ }
104
+ // The endpoints come from the server, so they are attacker-controlled if the
105
+ // host is. Pin them to the Auth0 domain the same response declares, so a
106
+ // compromised or misconfigured Fusion cannot redirect the device flow — and
107
+ // the resulting credentials — to an arbitrary origin.
108
+ assertOnDomain(config.deviceAuthorizationEndpoint, config.auth0Domain);
109
+ assertOnDomain(config.tokenEndpoint, config.auth0Domain);
110
+ return config;
111
+ }
112
+ /** Exchange a verified Auth0 access token for a Fusion JWT. */
113
+ export async function createFusionSession(host, auth0AccessToken) {
114
+ const response = await request(`${host}/api/auth/cli-session`, {
115
+ method: 'POST',
116
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
117
+ body: JSON.stringify({ auth0_access_token: auth0AccessToken }),
118
+ });
119
+ throwIfNotConfigured(response, host);
120
+ if (response.status === 401) {
121
+ throw authRequiredError(`${host} rejected the Auth0 identity.`, `Run "ekanos login" again. If it keeps failing, confirm your account ` +
122
+ `exists on ${host} — the browser login can succeed for an identity ` +
123
+ `that has no Fusion account.`);
124
+ }
125
+ if (!response.ok) {
126
+ throw unexpectedStatus(host, '/api/auth/cli-session', response.status);
127
+ }
128
+ return toSession(await readJson(response, `${host}/api/auth/cli-session`), host);
129
+ }
130
+ /** Re-mint a Fusion JWT from the stored Auth0 refresh token. */
131
+ export async function refreshFusionSession(host, auth0RefreshToken, options = {}) {
132
+ var _a;
133
+ const response = await request(`${host}/api/auth/cli-refresh`, {
134
+ method: 'POST',
135
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
136
+ body: JSON.stringify({ auth0_refresh_token: auth0RefreshToken }),
137
+ });
138
+ // Read the body FIRST, whatever the status: the rotated token can arrive on
139
+ // a 503, a 404 or a 500 just as readily as on a 200, and it must be captured
140
+ // before any branch below throws.
141
+ const body = await readOptionalJson(response);
142
+ const rotated = str(body.auth0_refresh_token);
143
+ if (rotated) {
144
+ (_a = options.onRotatedToken) === null || _a === void 0 ? void 0 : _a.call(options, rotated);
145
+ }
146
+ throwIfNotConfigured(response, host, body);
147
+ if (response.status === 401) {
148
+ throw authRequiredError(`The stored session for ${host} is no longer valid.`, `Run "ekanos login" to sign in again. This is expected after the ` +
149
+ `refresh token expires, after it is revoked in Auth0, or if two ` +
150
+ `machines used the same credentials file.`);
151
+ }
152
+ if (response.status === 503 && body.code === 'REFRESH_INCOMPLETE') {
153
+ // NOT an auth failure. The credential was renewed — and, thanks to the
154
+ // callback above, already saved. Auth0's /userinfo is simply not answering.
155
+ throw networkError(`${host} renewed the session but could not verify the identity with Auth0.`, `Retry in a moment — your credentials are still valid and have been ` +
156
+ `updated. You do NOT need to run "ekanos login" again.`);
157
+ }
158
+ if (response.status === 404) {
159
+ throw authRequiredError(`${host} has no Fusion account for the signed-in identity.`, `Sign in to ${host} in a browser once to have the account created, ` +
160
+ `then retry.`);
161
+ }
162
+ if (!response.ok) {
163
+ throw unexpectedStatus(host, '/api/auth/cli-refresh', response.status);
164
+ }
165
+ if (!rotated) {
166
+ throw networkError(`${host} refreshed the session without returning a rotated refresh token.`, `Run "ekanos login" to start a fresh session, and report this — the ` +
167
+ `deployment's Auth0 CLI application may not have rotation enabled.`);
168
+ }
169
+ return Object.assign(Object.assign({}, toSession(body, host)), { auth0RefreshToken: rotated });
170
+ }
171
+ /**
172
+ * Prove the Fusion JWT authenticates. Returns null on 401 so the caller can
173
+ * decide whether to refresh and retry — an expired access token is an ordinary
174
+ * event, not an error.
175
+ */
176
+ export async function fetchIdentity(host, accessToken) {
177
+ var _a;
178
+ const response = await request(`${host}/api/auth/cli-whoami`, {
179
+ method: 'GET',
180
+ headers: {
181
+ accept: 'application/json',
182
+ authorization: `Bearer ${accessToken}`,
183
+ },
184
+ });
185
+ // 401, or the 307 that an auth-gated Fusion route answers with. Both mean
186
+ // "this token did not authenticate".
187
+ if (response.status === 401 || isRedirect(response.status)) {
188
+ return null;
189
+ }
190
+ if (response.status === 403) {
191
+ throw authRequiredError(`${host} requires multi-factor authentication for this account.`, `Complete MFA in the browser, then run "ekanos login" again.`);
192
+ }
193
+ if (response.status === 404) {
194
+ throw authRequiredError(`${host} has no account for the signed-in identity.`, `Sign in to ${host} in a browser once to have the account created, ` +
195
+ `then run "ekanos login" again.`);
196
+ }
197
+ if (!response.ok) {
198
+ throw unexpectedStatus(host, '/api/auth/cli-whoami', response.status);
199
+ }
200
+ const body = await readJson(response, `${host}/api/auth/cli-whoami`);
201
+ const user = ((_a = body.user) !== null && _a !== void 0 ? _a : {});
202
+ const id = str(user.id);
203
+ if (!id) {
204
+ throw networkError(`${host} returned an identity without a user id.`, `Confirm the deployment is running a Fusion build that includes the ` +
205
+ `/api/auth/cli-whoami route.`);
206
+ }
207
+ return { id, email: str(user.email), name: str(user.name) };
208
+ }
209
+ function toSession(body, host) {
210
+ var _a;
211
+ const user = ((_a = body.user) !== null && _a !== void 0 ? _a : {});
212
+ const accessToken = str(body.access_token);
213
+ const expiresAt = str(body.expires_at);
214
+ const userId = str(user.id);
215
+ if (!accessToken || !expiresAt || !userId) {
216
+ throw networkError(`${host} returned an incomplete session.`, `Confirm the deployment is running a Fusion build that includes the ` +
217
+ `CLI auth routes, then retry.`);
218
+ }
219
+ return { accessToken, expiresAt, userId, email: str(user.email) };
220
+ }
221
+ export async function request(url, init) {
222
+ try {
223
+ return await fetch(url, Object.assign(Object.assign({}, init), { redirect: 'manual', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }));
224
+ }
225
+ catch (error) {
226
+ const reason = error instanceof Error ? error.message : String(error);
227
+ throw networkError(`Could not reach ${new URL(url).origin}: ${reason}`, `Check the host is correct and reachable from this machine ` +
228
+ `(proxy, VPN, DNS), then retry.`);
229
+ }
230
+ }
231
+ /**
232
+ * Best-effort body read for a response whose status may be an error. Returns
233
+ * an empty object rather than throwing, because the caller needs to inspect
234
+ * the body BEFORE deciding what the status means — and a 500 from a proxy may
235
+ * carry no JSON at all.
236
+ */
237
+ async function readOptionalJson(response) {
238
+ try {
239
+ const parsed = await response.json();
240
+ return typeof parsed === 'object' && parsed !== null
241
+ ? parsed
242
+ : {};
243
+ }
244
+ catch (_a) {
245
+ return {};
246
+ }
247
+ }
248
+ async function readJson(response, what) {
249
+ let parsed;
250
+ try {
251
+ parsed = await response.json();
252
+ }
253
+ catch (_a) {
254
+ throw networkError(`${what} did not return JSON.`, `Confirm the host is a Fusion deployment and not a proxy or captive ` +
255
+ `portal sitting in front of one.`);
256
+ }
257
+ if (typeof parsed !== 'object' || parsed === null) {
258
+ throw networkError(`${what} returned an unexpected payload.`, `Confirm the host is a Fusion deployment, then retry.`);
259
+ }
260
+ return parsed;
261
+ }
262
+ /**
263
+ * 503 means the deployment is not provisioned for CLI login — an operator
264
+ * action that has not happened, not a fault in the request.
265
+ *
266
+ * Every route that reads the Fusion CLI Auth0 config can answer this, so every
267
+ * caller has to check for it. Falling through to `unexpectedStatus` would tell
268
+ * a partner "confirm the host is a Fusion deployment" when the host is a
269
+ * perfectly good Fusion deployment that is simply missing one env var — and
270
+ * would exit 8 (network, retryable) instead of 9 (precondition, not).
271
+ */
272
+ function throwIfNotConfigured(response, host, body) {
273
+ if (response.status !== 503)
274
+ return;
275
+ // 503 carries two meanings on these routes. Only CLI_NOT_CONFIGURED is a
276
+ // precondition; REFRESH_INCOMPLETE is transient and handled by its caller.
277
+ if (body && body.code !== 'CLI_NOT_CONFIGURED')
278
+ return;
279
+ throw preconditionError(`${host} does not have CLI login configured.`, `Ask the Fusion operator to provision the "Fusion CLI" Auth0 ` +
280
+ `application and set AUTH0_CLI_CLIENT_ID on that deployment.`);
281
+ }
282
+ function unexpectedStatus(host, route, status) {
283
+ return networkError(`${host}${route} responded ${status}.`, status >= 500
284
+ ? `The Fusion deployment returned a server error. Retry shortly; if it ` +
285
+ `persists, report it with the status code.`
286
+ : `Confirm the host is a Fusion deployment running a build that ` +
287
+ `includes ${route}.`);
288
+ }
289
+ function assertOnDomain(endpoint, domain) {
290
+ let url;
291
+ try {
292
+ url = new URL(endpoint);
293
+ }
294
+ catch (_a) {
295
+ throw preconditionError(`The login configuration contains a malformed endpoint.`, `Report this to the operator of the Fusion deployment.`);
296
+ }
297
+ if (url.protocol !== 'https:' ||
298
+ url.host.toLowerCase() !== domain.toLowerCase()) {
299
+ throw preconditionError(`The login configuration points at an endpoint outside its own Auth0 ` +
300
+ `domain (${url.host}).`, `Do not continue. Report this to the operator of the Fusion ` +
301
+ `deployment — it may be misconfigured or compromised.`);
302
+ }
303
+ }
304
+ function isRedirect(status) {
305
+ return status >= 300 && status < 400;
306
+ }
307
+ function str(value) {
308
+ return typeof value === 'string' && value.length > 0 ? value : null;
309
+ }
310
+ //# sourceMappingURL=fusion-api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fusion-api.js","sourceRoot":"","sources":["../../src/auth/fusion-api.ts"],"names":[],"mappings":"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","sourcesContent":["import { authRequiredError, networkError, preconditionError } from '../errors';\n\n/**\n * The CLI's client for the four Fusion auth routes.\n *\n * ---------------------------------------------------------------------------\n * WIRE CONTRACT — the seam between this package and the Fusion web app.\n *\n * The server side lives in `apps/web/app/api/auth/cli-<verb>/route.ts` and ships on\n * a DIFFERENT BRANCH from this file. This block is the single place the shapes\n * are written down on the client side; if you change one side, change the\n * other and update this block. Nothing here imports from `apps/web` — the two\n * halves are coupled only by HTTP, and the tests stub `fetch`, never the route.\n *\n * GET /api/auth/cli-config [no auth]\n * → 200 { auth0_domain, client_id, scope,\n * device_authorization_endpoint, token_endpoint } all strings\n * → 503 { error, code: \"CLI_NOT_CONFIGURED\" } deployment not provisioned\n *\n * POST /api/auth/cli-session [no auth]\n * ← { auth0_access_token: string }\n * → 200 { access_token, expires_at, user: { id, email } }\n * → 400 { error, code: \"INVALID_REQUEST\" } malformed / wrong shape\n * → 401 { error, code: \"SESSION_EXPIRED\" } Auth0 said no, OR no\n * Fusion account for it\n * → 503 { error, code: \"CLI_NOT_CONFIGURED\" } deployment not provisioned\n * → 500 { error, code: \"INTERNAL_ERROR\" }\n *\n * POST /api/auth/cli-refresh [no auth]\n * ← { auth0_refresh_token: string }\n * → 200 { access_token, expires_at, user: { id, email },\n * auth0_refresh_token } ← ROTATED; the presented one is dead\n * → 400 { error, code: \"INVALID_REQUEST\" }\n * → 401 { error, code: \"SESSION_EXPIRED\" } expired or revoked\n * → 404 { error, code: \"ACCOUNT_NOT_FOUND\",\n * auth0_refresh_token } no Fusion account\n * → 502 { error, code: \"TOKEN_EXCHANGE_FAILED\" } Auth0 returned no\n * rotated token\n * → 503 { error, code: \"CLI_NOT_CONFIGURED\" } deployment not provisioned\n * → 503 { error, code: \"REFRESH_INCOMPLETE\",\n * auth0_refresh_token } rotated, but /userinfo\n * did not answer; RETRYABLE\n * → 500 { error, code: \"INTERNAL_ERROR\",\n * auth0_refresh_token? } present iff rotation\n * completed before the throw\n *\n * !! cli-refresh MAY RETURN `auth0_refresh_token` ON ERROR RESPONSES, and a\n * client MUST persist it before surfacing the error. Once the server's\n * exchange with Auth0 succeeds the presented token is dead and the value\n * in that body is the ONLY copy in existence; discarding it on a failure\n * path destroys the session permanently. Note 503 has TWO codes here —\n * branch on `code`, not on the status alone.\n *\n * All three routes above read the Fusion CLI Auth0 application config, so all\n * three can answer 503 CLI_NOT_CONFIGURED. cli-whoami cannot — it reads no\n * Auth0 config, only the caller's bearer token.\n *\n * GET /api/auth/cli-whoami [Authorization: Bearer <access_token>]\n * → 200 { user: { id, email, name } }\n * → 401 { error, code: \"AUTH_REQUIRED\" } token absent or invalid\n * → 403 { error, code: \"MFA_REQUIRED\" }\n * → 404 { error, code: \"ACCOUNT_NOT_FOUND\" }\n * → 3xx treated as 401, see below\n *\n * `access_token` is a Fusion HS256 JWT. Every authenticated Fusion route\n * accepts it as `Authorization: Bearer` — see `resolveRequestSupabaseJwt` in\n * `@kit/supabase`, which both `requireUser` and the RLS server client consume.\n * ---------------------------------------------------------------------------\n *\n * Two rules hold everywhere in this file:\n *\n * - `redirect: 'manual'`. Fusion answers an unauthenticated page request with\n * a 307 to `/auth/sign-in`; Node's fetch follows redirects by default, so\n * without this a stale token would come back as a 200 full of HTML and be\n * read as success. A 3xx here is treated as \"not authenticated\", full stop.\n *\n * - No token is ever interpolated into an error message. The server's error\n * bodies are `{ error, code }` documents that do not echo credentials, and\n * the request bodies that DO carry credentials are never included in a\n * thrown message.\n */\n\nconst REQUEST_TIMEOUT_MS = 15_000;\n\nexport interface CliAuthConfig {\n auth0Domain: string;\n clientId: string;\n scope: string;\n deviceAuthorizationEndpoint: string;\n tokenEndpoint: string;\n}\n\nexport interface FusionSession {\n accessToken: string;\n expiresAt: string;\n userId: string;\n email: string | null;\n}\n\nexport interface FusionIdentity {\n id: string;\n email: string | null;\n name: string | null;\n}\n\n/** Fetch the device-flow parameters for a host. */\nexport async function fetchCliAuthConfig(host: string): Promise<CliAuthConfig> {\n const response = await request(`${host}/api/auth/cli-config`, {\n method: 'GET',\n headers: { accept: 'application/json' },\n });\n\n throwIfNotConfigured(response, host);\n\n if (!response.ok) {\n throw unexpectedStatus(host, '/api/auth/cli-config', response.status);\n }\n\n const body = await readJson(response, `${host}/api/auth/cli-config`);\n\n const config = {\n auth0Domain: str(body.auth0_domain),\n clientId: str(body.client_id),\n scope: str(body.scope),\n deviceAuthorizationEndpoint: str(body.device_authorization_endpoint),\n tokenEndpoint: str(body.token_endpoint),\n };\n\n if (Object.values(config).some((value) => value === null)) {\n throw preconditionError(\n `${host} returned an incomplete CLI login configuration.`,\n `Confirm the deployment is running a Fusion build that includes the ` +\n `/api/auth/cli-config route.`,\n );\n }\n\n // The endpoints come from the server, so they are attacker-controlled if the\n // host is. Pin them to the Auth0 domain the same response declares, so a\n // compromised or misconfigured Fusion cannot redirect the device flow — and\n // the resulting credentials — to an arbitrary origin.\n assertOnDomain(config.deviceAuthorizationEndpoint!, config.auth0Domain!);\n assertOnDomain(config.tokenEndpoint!, config.auth0Domain!);\n\n return config as CliAuthConfig;\n}\n\n/** Exchange a verified Auth0 access token for a Fusion JWT. */\nexport async function createFusionSession(\n host: string,\n auth0AccessToken: string,\n): Promise<FusionSession> {\n const response = await request(`${host}/api/auth/cli-session`, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify({ auth0_access_token: auth0AccessToken }),\n });\n\n throwIfNotConfigured(response, host);\n\n if (response.status === 401) {\n throw authRequiredError(\n `${host} rejected the Auth0 identity.`,\n `Run \"ekanos login\" again. If it keeps failing, confirm your account ` +\n `exists on ${host} — the browser login can succeed for an identity ` +\n `that has no Fusion account.`,\n );\n }\n\n if (!response.ok) {\n throw unexpectedStatus(host, '/api/auth/cli-session', response.status);\n }\n\n return toSession(\n await readJson(response, `${host}/api/auth/cli-session`),\n host,\n );\n}\n\nexport interface RefreshedSession extends FusionSession {\n /** The ROTATED refresh token. The presented one is already invalid. */\n auth0RefreshToken: string;\n}\n\nexport interface RefreshOptions {\n /**\n * Called the INSTANT a rotated refresh token is seen in the response — on\n * success and on failure alike, before anything is thrown.\n *\n * Persist-on-learn, not persist-on-success. Once the server's exchange with\n * Auth0 succeeds the presented token is dead and the replacement in that\n * body is the only copy in existence, so a client that only saves it on the\n * happy path destroys the partner's credential the first time /userinfo\n * blips. The callback runs before any throw so no error path can skip it.\n */\n onRotatedToken?: (rotated: string) => void;\n}\n\n/** Re-mint a Fusion JWT from the stored Auth0 refresh token. */\nexport async function refreshFusionSession(\n host: string,\n auth0RefreshToken: string,\n options: RefreshOptions = {},\n): Promise<RefreshedSession> {\n const response = await request(`${host}/api/auth/cli-refresh`, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify({ auth0_refresh_token: auth0RefreshToken }),\n });\n\n // Read the body FIRST, whatever the status: the rotated token can arrive on\n // a 503, a 404 or a 500 just as readily as on a 200, and it must be captured\n // before any branch below throws.\n const body = await readOptionalJson(response);\n const rotated = str(body.auth0_refresh_token);\n\n if (rotated) {\n options.onRotatedToken?.(rotated);\n }\n\n throwIfNotConfigured(response, host, body);\n\n if (response.status === 401) {\n throw authRequiredError(\n `The stored session for ${host} is no longer valid.`,\n `Run \"ekanos login\" to sign in again. This is expected after the ` +\n `refresh token expires, after it is revoked in Auth0, or if two ` +\n `machines used the same credentials file.`,\n );\n }\n\n if (response.status === 503 && body.code === 'REFRESH_INCOMPLETE') {\n // NOT an auth failure. The credential was renewed — and, thanks to the\n // callback above, already saved. Auth0's /userinfo is simply not answering.\n throw networkError(\n `${host} renewed the session but could not verify the identity with Auth0.`,\n `Retry in a moment — your credentials are still valid and have been ` +\n `updated. You do NOT need to run \"ekanos login\" again.`,\n );\n }\n\n if (response.status === 404) {\n throw authRequiredError(\n `${host} has no Fusion account for the signed-in identity.`,\n `Sign in to ${host} in a browser once to have the account created, ` +\n `then retry.`,\n );\n }\n\n if (!response.ok) {\n throw unexpectedStatus(host, '/api/auth/cli-refresh', response.status);\n }\n\n if (!rotated) {\n throw networkError(\n `${host} refreshed the session without returning a rotated refresh token.`,\n `Run \"ekanos login\" to start a fresh session, and report this — the ` +\n `deployment's Auth0 CLI application may not have rotation enabled.`,\n );\n }\n\n return { ...toSession(body, host), auth0RefreshToken: rotated };\n}\n\n/**\n * Prove the Fusion JWT authenticates. Returns null on 401 so the caller can\n * decide whether to refresh and retry — an expired access token is an ordinary\n * event, not an error.\n */\nexport async function fetchIdentity(\n host: string,\n accessToken: string,\n): Promise<FusionIdentity | null> {\n const response = await request(`${host}/api/auth/cli-whoami`, {\n method: 'GET',\n headers: {\n accept: 'application/json',\n authorization: `Bearer ${accessToken}`,\n },\n });\n\n // 401, or the 307 that an auth-gated Fusion route answers with. Both mean\n // \"this token did not authenticate\".\n if (response.status === 401 || isRedirect(response.status)) {\n return null;\n }\n\n if (response.status === 403) {\n throw authRequiredError(\n `${host} requires multi-factor authentication for this account.`,\n `Complete MFA in the browser, then run \"ekanos login\" again.`,\n );\n }\n\n if (response.status === 404) {\n throw authRequiredError(\n `${host} has no account for the signed-in identity.`,\n `Sign in to ${host} in a browser once to have the account created, ` +\n `then run \"ekanos login\" again.`,\n );\n }\n\n if (!response.ok) {\n throw unexpectedStatus(host, '/api/auth/cli-whoami', response.status);\n }\n\n const body = await readJson(response, `${host}/api/auth/cli-whoami`);\n const user = (body.user ?? {}) as Record<string, unknown>;\n const id = str(user.id);\n\n if (!id) {\n throw networkError(\n `${host} returned an identity without a user id.`,\n `Confirm the deployment is running a Fusion build that includes the ` +\n `/api/auth/cli-whoami route.`,\n );\n }\n\n return { id, email: str(user.email), name: str(user.name) };\n}\n\nfunction toSession(body: Record<string, unknown>, host: string): FusionSession {\n const user = (body.user ?? {}) as Record<string, unknown>;\n\n const accessToken = str(body.access_token);\n const expiresAt = str(body.expires_at);\n const userId = str(user.id);\n\n if (!accessToken || !expiresAt || !userId) {\n throw networkError(\n `${host} returned an incomplete session.`,\n `Confirm the deployment is running a Fusion build that includes the ` +\n `CLI auth routes, then retry.`,\n );\n }\n\n return { accessToken, expiresAt, userId, email: str(user.email) };\n}\n\nexport async function request(\n url: string,\n init: RequestInit,\n): Promise<Response> {\n try {\n return await fetch(url, {\n ...init,\n redirect: 'manual',\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n });\n } catch (error) {\n const reason = error instanceof Error ? error.message : String(error);\n\n throw networkError(\n `Could not reach ${new URL(url).origin}: ${reason}`,\n `Check the host is correct and reachable from this machine ` +\n `(proxy, VPN, DNS), then retry.`,\n );\n }\n}\n\n/**\n * Best-effort body read for a response whose status may be an error. Returns\n * an empty object rather than throwing, because the caller needs to inspect\n * the body BEFORE deciding what the status means — and a 500 from a proxy may\n * carry no JSON at all.\n */\nasync function readOptionalJson(\n response: Response,\n): Promise<Record<string, unknown>> {\n try {\n const parsed: unknown = await response.json();\n\n return typeof parsed === 'object' && parsed !== null\n ? (parsed as Record<string, unknown>)\n : {};\n } catch {\n return {};\n }\n}\n\nasync function readJson(\n response: Response,\n what: string,\n): Promise<Record<string, unknown>> {\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch {\n throw networkError(\n `${what} did not return JSON.`,\n `Confirm the host is a Fusion deployment and not a proxy or captive ` +\n `portal sitting in front of one.`,\n );\n }\n\n if (typeof parsed !== 'object' || parsed === null) {\n throw networkError(\n `${what} returned an unexpected payload.`,\n `Confirm the host is a Fusion deployment, then retry.`,\n );\n }\n\n return parsed as Record<string, unknown>;\n}\n\n/**\n * 503 means the deployment is not provisioned for CLI login — an operator\n * action that has not happened, not a fault in the request.\n *\n * Every route that reads the Fusion CLI Auth0 config can answer this, so every\n * caller has to check for it. Falling through to `unexpectedStatus` would tell\n * a partner \"confirm the host is a Fusion deployment\" when the host is a\n * perfectly good Fusion deployment that is simply missing one env var — and\n * would exit 8 (network, retryable) instead of 9 (precondition, not).\n */\nfunction throwIfNotConfigured(\n response: Response,\n host: string,\n body?: Record<string, unknown>,\n): void {\n if (response.status !== 503) return;\n\n // 503 carries two meanings on these routes. Only CLI_NOT_CONFIGURED is a\n // precondition; REFRESH_INCOMPLETE is transient and handled by its caller.\n if (body && body.code !== 'CLI_NOT_CONFIGURED') return;\n\n throw preconditionError(\n `${host} does not have CLI login configured.`,\n `Ask the Fusion operator to provision the \"Fusion CLI\" Auth0 ` +\n `application and set AUTH0_CLI_CLIENT_ID on that deployment.`,\n );\n}\n\nfunction unexpectedStatus(host: string, route: string, status: number) {\n return networkError(\n `${host}${route} responded ${status}.`,\n status >= 500\n ? `The Fusion deployment returned a server error. Retry shortly; if it ` +\n `persists, report it with the status code.`\n : `Confirm the host is a Fusion deployment running a build that ` +\n `includes ${route}.`,\n );\n}\n\nfunction assertOnDomain(endpoint: string, domain: string): void {\n let url: URL;\n\n try {\n url = new URL(endpoint);\n } catch {\n throw preconditionError(\n `The login configuration contains a malformed endpoint.`,\n `Report this to the operator of the Fusion deployment.`,\n );\n }\n\n if (\n url.protocol !== 'https:' ||\n url.host.toLowerCase() !== domain.toLowerCase()\n ) {\n throw preconditionError(\n `The login configuration points at an endpoint outside its own Auth0 ` +\n `domain (${url.host}).`,\n `Do not continue. Report this to the operator of the Fusion ` +\n `deployment — it may be misconfigured or compromised.`,\n );\n }\n}\n\nfunction isRedirect(status: number): boolean {\n return status >= 300 && status < 400;\n}\n\nfunction str(value: unknown): string | null {\n return typeof value === 'string' && value.length > 0 ? value : null;\n}\n"]}
@@ -0,0 +1,57 @@
1
+ import type { CliContext } from '../context.js';
2
+ import { CredentialStore, type StoredSession } from './credential-store.js';
3
+ import { type FusionIdentity } from './fusion-api.js';
4
+ /**
5
+ * Session plumbing shared by `login`, `logout` and `whoami`: where the Fusion
6
+ * host comes from, where credentials live, and how a stale access token is
7
+ * silently replaced.
8
+ */
9
+ export interface AuthEnvironment {
10
+ store: CredentialStore;
11
+ host: string;
12
+ }
13
+ /**
14
+ * Resolve the Fusion host from `--host`, else `EKANOS_HOST`.
15
+ *
16
+ * Deliberately no default. A baked-in default would be wrong for every partner
17
+ * running against their own sandbox, and — worse — a CLI that silently picks a
18
+ * host is a CLI that can send a developer's credentials somewhere they did not
19
+ * intend.
20
+ */
21
+ export declare function resolveHost(flag: string | undefined, env: Record<string, string | undefined>): string;
22
+ export declare function resolveAuthEnvironment(ctx: CliContext, hostFlag: string | undefined, env: Record<string, string | undefined>): AuthEnvironment;
23
+ /** A store with no host attached — for `logout --all`. */
24
+ export declare function newStore(ctx: CliContext, env: Record<string, string | undefined>): CredentialStore;
25
+ export declare function requireSession(store: CredentialStore, host: string): StoredSession;
26
+ /**
27
+ * Resolve the caller's identity, refreshing the access token once if the
28
+ * stored one no longer authenticates.
29
+ *
30
+ * Validity is decided by the SERVER, not by comparing `expiresAt` to the local
31
+ * clock. A container with a skewed clock — the exact environment this flow
32
+ * exists to support — would otherwise either refuse a perfectly good token or
33
+ * keep presenting a dead one. A 401 is the authoritative answer; one refresh
34
+ * and one retry is the whole recovery.
35
+ */
36
+ export declare function resolveIdentity(env: AuthEnvironment, session: StoredSession): Promise<{
37
+ identity: FusionIdentity;
38
+ session: StoredSession;
39
+ }>;
40
+ /**
41
+ * Refresh and persist.
42
+ *
43
+ * The rotated refresh token is written the MOMENT the server reports it, not
44
+ * when the refresh finishes. Auth0 rotates as soon as the server's exchange
45
+ * succeeds, so from that instant the presented token is dead and the
46
+ * replacement is the only copy in existence — and `cli-refresh` can still fail
47
+ * afterwards (a `/userinfo` blip, a missing account, an unexpected throw) and
48
+ * return that replacement on an ERROR response. Saving only on success would
49
+ * discard it and lock the partner out for good over a transient upstream
50
+ * hiccup: one process, no concurrency needed.
51
+ *
52
+ * So `onRotatedToken` writes it immediately and `refreshFusionSession` invokes
53
+ * that before it throws. The write keeps the OLD access token, which is
54
+ * correct: that token is merely expired, while the refresh token is current
55
+ * and is what the next attempt needs.
56
+ */
57
+ export declare function refreshStoredSession(env: AuthEnvironment, session: StoredSession): Promise<StoredSession>;
@@ -0,0 +1,114 @@
1
+ import { authRequiredError, usageError } from '../errors.js';
2
+ import { CredentialStore, normalizeHost, resolveEkanosHome, } from './credential-store.js';
3
+ import { fetchIdentity, refreshFusionSession, } from './fusion-api.js';
4
+ /**
5
+ * Resolve the Fusion host from `--host`, else `EKANOS_HOST`.
6
+ *
7
+ * Deliberately no default. A baked-in default would be wrong for every partner
8
+ * running against their own sandbox, and — worse — a CLI that silently picks a
9
+ * host is a CLI that can send a developer's credentials somewhere they did not
10
+ * intend.
11
+ */
12
+ export function resolveHost(flag, env) {
13
+ const raw = flag !== null && flag !== void 0 ? flag : env.EKANOS_HOST;
14
+ if (!raw || raw.trim().length === 0) {
15
+ throw usageError('No Fusion host given.', 'Pass "--host https://<your-fusion-host>" or set EKANOS_HOST.');
16
+ }
17
+ return normalizeHost(raw.trim());
18
+ }
19
+ export function resolveAuthEnvironment(ctx, hostFlag, env) {
20
+ const host = resolveHost(hostFlag, env);
21
+ warnOnPlaintextHost(ctx, host);
22
+ return {
23
+ host,
24
+ store: newStore(ctx, env),
25
+ };
26
+ }
27
+ /** A store with no host attached — for `logout --all`. */
28
+ export function newStore(ctx, env) {
29
+ return new CredentialStore({
30
+ home: resolveEkanosHome(env),
31
+ // Permission warnings are diagnostics, not results: stderr, always.
32
+ warn: (message) => ctx.warn(message),
33
+ });
34
+ }
35
+ /**
36
+ * `http://` is allowed because local development needs it, but sending a
37
+ * bearer token to a plaintext REMOTE host puts it on the wire in the clear.
38
+ * Warn rather than refuse: refusing would break `--host http://localhost:3000`
39
+ * for anyone whose loopback resolves to something other than the names below.
40
+ */
41
+ function warnOnPlaintextHost(ctx, host) {
42
+ const url = new URL(host);
43
+ if (url.protocol !== 'http:')
44
+ return;
45
+ const loopback = ['localhost', '127.0.0.1', '[::1]', '0.0.0.0'];
46
+ if (loopback.includes(url.hostname) || url.hostname.endsWith('.localhost')) {
47
+ return;
48
+ }
49
+ ctx.warn(`ekanos: ${host} is plaintext HTTP. Your credentials will cross the ` +
50
+ `network unencrypted. Use https:// unless this is a local host.`);
51
+ }
52
+ export function requireSession(store, host) {
53
+ const session = store.read(host);
54
+ if (!session) {
55
+ throw authRequiredError(`Not logged in to ${host}.`, `Run "ekanos login --host ${host}".`);
56
+ }
57
+ return session;
58
+ }
59
+ /**
60
+ * Resolve the caller's identity, refreshing the access token once if the
61
+ * stored one no longer authenticates.
62
+ *
63
+ * Validity is decided by the SERVER, not by comparing `expiresAt` to the local
64
+ * clock. A container with a skewed clock — the exact environment this flow
65
+ * exists to support — would otherwise either refuse a perfectly good token or
66
+ * keep presenting a dead one. A 401 is the authoritative answer; one refresh
67
+ * and one retry is the whole recovery.
68
+ */
69
+ export async function resolveIdentity(env, session) {
70
+ const identity = await fetchIdentity(env.host, session.accessToken);
71
+ if (identity) {
72
+ return { identity, session };
73
+ }
74
+ const refreshed = await refreshStoredSession(env, session);
75
+ const retried = await fetchIdentity(env.host, refreshed.accessToken);
76
+ if (!retried) {
77
+ throw authRequiredError(`The session for ${env.host} could not be renewed.`, `Run "ekanos logout --host ${env.host}" then "ekanos login --host ${env.host}".`);
78
+ }
79
+ return { identity: retried, session: refreshed };
80
+ }
81
+ /**
82
+ * Refresh and persist.
83
+ *
84
+ * The rotated refresh token is written the MOMENT the server reports it, not
85
+ * when the refresh finishes. Auth0 rotates as soon as the server's exchange
86
+ * succeeds, so from that instant the presented token is dead and the
87
+ * replacement is the only copy in existence — and `cli-refresh` can still fail
88
+ * afterwards (a `/userinfo` blip, a missing account, an unexpected throw) and
89
+ * return that replacement on an ERROR response. Saving only on success would
90
+ * discard it and lock the partner out for good over a transient upstream
91
+ * hiccup: one process, no concurrency needed.
92
+ *
93
+ * So `onRotatedToken` writes it immediately and `refreshFusionSession` invokes
94
+ * that before it throws. The write keeps the OLD access token, which is
95
+ * correct: that token is merely expired, while the refresh token is current
96
+ * and is what the next attempt needs.
97
+ */
98
+ export async function refreshStoredSession(env, session) {
99
+ const persistRotated = (rotated) => {
100
+ env.store.write(env.host, Object.assign(Object.assign({}, session), { auth0RefreshToken: rotated, updatedAt: new Date().toISOString() }));
101
+ };
102
+ const refreshed = await refreshFusionSession(env.host, session.auth0RefreshToken, { onRotatedToken: persistRotated });
103
+ const next = {
104
+ auth0RefreshToken: refreshed.auth0RefreshToken,
105
+ accessToken: refreshed.accessToken,
106
+ expiresAt: refreshed.expiresAt,
107
+ userId: refreshed.userId,
108
+ email: refreshed.email,
109
+ updatedAt: new Date().toISOString(),
110
+ };
111
+ env.store.write(env.host, next);
112
+ return next;
113
+ }
114
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/auth/session.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CliContext } from '../context';\nimport { authRequiredError, usageError } from '../errors';\nimport {\n CredentialStore,\n type StoredSession,\n normalizeHost,\n resolveEkanosHome,\n} from './credential-store';\nimport {\n type FusionIdentity,\n fetchIdentity,\n refreshFusionSession,\n} from './fusion-api';\n\n/**\n * Session plumbing shared by `login`, `logout` and `whoami`: where the Fusion\n * host comes from, where credentials live, and how a stale access token is\n * silently replaced.\n */\n\nexport interface AuthEnvironment {\n store: CredentialStore;\n host: string;\n}\n\n/**\n * Resolve the Fusion host from `--host`, else `EKANOS_HOST`.\n *\n * Deliberately no default. A baked-in default would be wrong for every partner\n * running against their own sandbox, and — worse — a CLI that silently picks a\n * host is a CLI that can send a developer's credentials somewhere they did not\n * intend.\n */\nexport function resolveHost(\n flag: string | undefined,\n env: Record<string, string | undefined>,\n): string {\n const raw = flag ?? env.EKANOS_HOST;\n\n if (!raw || raw.trim().length === 0) {\n throw usageError(\n 'No Fusion host given.',\n 'Pass \"--host https://<your-fusion-host>\" or set EKANOS_HOST.',\n );\n }\n\n return normalizeHost(raw.trim());\n}\n\nexport function resolveAuthEnvironment(\n ctx: CliContext,\n hostFlag: string | undefined,\n env: Record<string, string | undefined>,\n): AuthEnvironment {\n const host = resolveHost(hostFlag, env);\n\n warnOnPlaintextHost(ctx, host);\n\n return {\n host,\n store: newStore(ctx, env),\n };\n}\n\n/** A store with no host attached — for `logout --all`. */\nexport function newStore(\n ctx: CliContext,\n env: Record<string, string | undefined>,\n): CredentialStore {\n return new CredentialStore({\n home: resolveEkanosHome(env),\n // Permission warnings are diagnostics, not results: stderr, always.\n warn: (message) => ctx.warn(message),\n });\n}\n\n/**\n * `http://` is allowed because local development needs it, but sending a\n * bearer token to a plaintext REMOTE host puts it on the wire in the clear.\n * Warn rather than refuse: refusing would break `--host http://localhost:3000`\n * for anyone whose loopback resolves to something other than the names below.\n */\nfunction warnOnPlaintextHost(ctx: CliContext, host: string): void {\n const url = new URL(host);\n\n if (url.protocol !== 'http:') return;\n\n const loopback = ['localhost', '127.0.0.1', '[::1]', '0.0.0.0'];\n\n if (loopback.includes(url.hostname) || url.hostname.endsWith('.localhost')) {\n return;\n }\n\n ctx.warn(\n `ekanos: ${host} is plaintext HTTP. Your credentials will cross the ` +\n `network unencrypted. Use https:// unless this is a local host.`,\n );\n}\n\nexport function requireSession(\n store: CredentialStore,\n host: string,\n): StoredSession {\n const session = store.read(host);\n\n if (!session) {\n throw authRequiredError(\n `Not logged in to ${host}.`,\n `Run \"ekanos login --host ${host}\".`,\n );\n }\n\n return session;\n}\n\n/**\n * Resolve the caller's identity, refreshing the access token once if the\n * stored one no longer authenticates.\n *\n * Validity is decided by the SERVER, not by comparing `expiresAt` to the local\n * clock. A container with a skewed clock — the exact environment this flow\n * exists to support — would otherwise either refuse a perfectly good token or\n * keep presenting a dead one. A 401 is the authoritative answer; one refresh\n * and one retry is the whole recovery.\n */\nexport async function resolveIdentity(\n env: AuthEnvironment,\n session: StoredSession,\n): Promise<{ identity: FusionIdentity; session: StoredSession }> {\n const identity = await fetchIdentity(env.host, session.accessToken);\n\n if (identity) {\n return { identity, session };\n }\n\n const refreshed = await refreshStoredSession(env, session);\n const retried = await fetchIdentity(env.host, refreshed.accessToken);\n\n if (!retried) {\n throw authRequiredError(\n `The session for ${env.host} could not be renewed.`,\n `Run \"ekanos logout --host ${env.host}\" then \"ekanos login --host ${env.host}\".`,\n );\n }\n\n return { identity: retried, session: refreshed };\n}\n\n/**\n * Refresh and persist.\n *\n * The rotated refresh token is written the MOMENT the server reports it, not\n * when the refresh finishes. Auth0 rotates as soon as the server's exchange\n * succeeds, so from that instant the presented token is dead and the\n * replacement is the only copy in existence — and `cli-refresh` can still fail\n * afterwards (a `/userinfo` blip, a missing account, an unexpected throw) and\n * return that replacement on an ERROR response. Saving only on success would\n * discard it and lock the partner out for good over a transient upstream\n * hiccup: one process, no concurrency needed.\n *\n * So `onRotatedToken` writes it immediately and `refreshFusionSession` invokes\n * that before it throws. The write keeps the OLD access token, which is\n * correct: that token is merely expired, while the refresh token is current\n * and is what the next attempt needs.\n */\nexport async function refreshStoredSession(\n env: AuthEnvironment,\n session: StoredSession,\n): Promise<StoredSession> {\n const persistRotated = (rotated: string) => {\n env.store.write(env.host, {\n ...session,\n auth0RefreshToken: rotated,\n updatedAt: new Date().toISOString(),\n });\n };\n\n const refreshed = await refreshFusionSession(\n env.host,\n session.auth0RefreshToken,\n { onRotatedToken: persistRotated },\n );\n\n const next: StoredSession = {\n auth0RefreshToken: refreshed.auth0RefreshToken,\n accessToken: refreshed.accessToken,\n expiresAt: refreshed.expiresAt,\n userId: refreshed.userId,\n email: refreshed.email,\n updatedAt: new Date().toISOString(),\n };\n\n env.store.write(env.host, next);\n\n return next;\n}\n"]}
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './index.js';
3
+ /**
4
+ * The bin entry — the ONLY place that calls process.exit, so `run()` stays a
5
+ * pure, testable function that returns an exit code. Argv is sliced to drop
6
+ * the node executable and this script path.
7
+ */
8
+ run(process.argv.slice(2))
9
+ .then((code) => {
10
+ process.exitCode = code;
11
+ })
12
+ .catch((error) => {
13
+ var _a;
14
+ // run() catches its own errors and emits the envelope; reaching here means
15
+ // the envelope machinery itself threw. Fail loudly on stderr (never
16
+ // stdout) with the internal exit code.
17
+ process.stderr.write(`ekanos: fatal internal error: ${error instanceof Error ? ((_a = error.stack) !== null && _a !== void 0 ? _a : error.message) : String(error)}\n`);
18
+ process.exitCode = 1;
19
+ });
20
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.js","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,GAAG,EAAE,MAAM,SAAS,CAAC;AAE9B;;;;GAIG;AACH,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;KACvB,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE;IACb,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;AAC1B,CAAC,CAAC;KACD,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;;IACxB,2EAA2E;IAC3E,oEAAoE;IACpE,uCAAuC;IACvC,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,iCACE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,MAAA,KAAK,CAAC,KAAK,mCAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CACxE,IAAI,CACL,CAAC;IACF,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;AACvB,CAAC,CAAC,CAAC","sourcesContent":["#!/usr/bin/env node\nimport { run } from './index';\n\n/**\n * The bin entry — the ONLY place that calls process.exit, so `run()` stays a\n * pure, testable function that returns an exit code. Argv is sliced to drop\n * the node executable and this script path.\n */\nrun(process.argv.slice(2))\n .then((code) => {\n process.exitCode = code;\n })\n .catch((error: unknown) => {\n // run() catches its own errors and emits the envelope; reaching here means\n // the envelope machinery itself threw. Fail loudly on stderr (never\n // stdout) with the internal exit code.\n process.stderr.write(\n `ekanos: fatal internal error: ${\n error instanceof Error ? (error.stack ?? error.message) : String(error)\n }\\n`,\n );\n process.exitCode = 1;\n });\n"]}
@@ -0,0 +1,36 @@
1
+ import type { CliContext } from '../context.js';
2
+ export interface DevArgs {
3
+ /** Project directory (contains ekanos.json). Defaults to cwd. */
4
+ dir: string;
5
+ /** Port for the dev server. */
6
+ port?: string;
7
+ /**
8
+ * Interface to bind. Defaults to loopback — see `resolveHost`. Anything
9
+ * else is an explicit, warned-about decision to expose the harness.
10
+ */
11
+ host?: string;
12
+ /** Rewrite every generated file even when its bytes already match. */
13
+ force: boolean;
14
+ /**
15
+ * Explicit `--start` / `--no-start`. Undefined means "decide from the
16
+ * output mode": a JSON caller is an agent or a pipe and must not be handed
17
+ * a process that never exits.
18
+ */
19
+ start?: boolean;
20
+ }
21
+ /**
22
+ * `dev` — scaffold the local harness shell into the partner's project, then
23
+ * run it with their own Next.
24
+ *
25
+ * The shape of this verb follows from one rule: we must be able to ship a
26
+ * harness bugfix as an `@ekanos/harness` version bump that a partner picks up
27
+ * with `pnpm up`, with zero edits to any file they own. So everything under
28
+ * `.ekanos/` is generated, gitignored and rewritten freely, `harness.config.ts`
29
+ * at the project root is written once and never touched again, and all actual
30
+ * harness behaviour lives in the installed package.
31
+ *
32
+ * Two idempotency properties matter and are both tested: a second run produces
33
+ * byte-identical files and rewrites nothing (so Next's watcher stays quiet),
34
+ * and `.gitignore` never grows a duplicate line.
35
+ */
36
+ export declare function runDev(ctx: CliContext, args: DevArgs): Promise<number>;