@elixpo/lixblogs-cli 1.3.3 → 1.4.2
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/README.md +9 -7
- package/dist/lixblogs.mjs +102 -0
- package/package.json +9 -10
- package/API.md +0 -104
- package/CHANGELOG.md +0 -10
- package/RELEASE.md +0 -30
- package/THREAT_MODEL.md +0 -91
- package/bin/lixblogs.mjs +0 -802
- package/src/api/AnalyticsClient.js +0 -40
- package/src/api/BlogClient.js +0 -140
- package/src/api/CollaborationClient.js +0 -73
- package/src/api/OrgClient.js +0 -158
- package/src/auth/AuthProvider.js +0 -90
- package/src/auth/AuthenticatedClient.js +0 -131
- package/src/auth/ElixpoAuthProvider.js +0 -281
- package/src/auth/MockAuthProvider.js +0 -170
- package/src/auth/productionGate.js +0 -44
- package/src/cli/contract.js +0 -46
- package/src/cli/ui.js +0 -54
- package/src/commands/analytics/index.js +0 -57
- package/src/commands/auth/login.js +0 -117
- package/src/commands/auth/logout.js +0 -21
- package/src/commands/auth/profileAlias.js +0 -29
- package/src/commands/auth/profiles.js +0 -27
- package/src/commands/auth/revoke.js +0 -45
- package/src/commands/auth/status.js +0 -33
- package/src/commands/blog/index.js +0 -85
- package/src/commands/blog/input.js +0 -59
- package/src/commands/collab/index.js +0 -53
- package/src/commands/org/index.js +0 -22
- package/src/commands/skill/index.js +0 -83
- package/src/config/CredentialStore.js +0 -142
- package/src/config/KeychainCredentialStore.js +0 -180
- package/src/config/ProfileRegistry.js +0 -105
- package/src/config/config.js +0 -60
- package/src/config/credentialStoreFactory.js +0 -63
- package/src/config/providerFactory.js +0 -42
- package/src/config/redact.js +0 -74
- package/src/content/markdown.js +0 -68
- package/src/content/validate.js +0 -45
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
import { AuthProvider } from "./AuthProvider.js";
|
|
2
|
-
|
|
3
|
-
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
4
|
-
const SUPPORTED_CONTRACT_MAJOR = 1;
|
|
5
|
-
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
6
|
-
|
|
7
|
-
const SAFE_ERROR_MESSAGES = {
|
|
8
|
-
access_denied: "Login was denied.",
|
|
9
|
-
authorization_pending: "Login is awaiting approval.",
|
|
10
|
-
expired_token: "The device authorization expired. Start login again.",
|
|
11
|
-
invalid_client: "The LixBlogs CLI client is not registered for this environment.",
|
|
12
|
-
invalid_grant: "This session is no longer valid. Log in again.",
|
|
13
|
-
invalid_request: "Accounts rejected the authentication request.",
|
|
14
|
-
invalid_scope: "The requested LixBlogs permissions are not available for this client.",
|
|
15
|
-
server_error: "Accounts could not complete authentication. Try again later.",
|
|
16
|
-
slow_down: "Accounts requested slower polling.",
|
|
17
|
-
temporarily_unavailable: "Accounts is temporarily unavailable. Try again later.",
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export class AuthProviderError extends Error {
|
|
21
|
-
constructor(code, { status = 0, requiresLogin = false } = {}) {
|
|
22
|
-
super(SAFE_ERROR_MESSAGES[code] || "Authentication failed.");
|
|
23
|
-
this.name = "AuthProviderError";
|
|
24
|
-
this.code = code || "authentication_failed";
|
|
25
|
-
this.status = status;
|
|
26
|
-
this.requiresLogin = requiresLogin;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export class CompatibilityError extends Error {
|
|
31
|
-
constructor(message) {
|
|
32
|
-
super(message);
|
|
33
|
-
this.name = "CompatibilityError";
|
|
34
|
-
this.code = "incompatible_accounts_contract";
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function versionParts(value) {
|
|
39
|
-
return String(value || "0.0.0")
|
|
40
|
-
.split(".")
|
|
41
|
-
.slice(0, 3)
|
|
42
|
-
.map((part) => Number.parseInt(part, 10) || 0);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function versionAtLeast(current, minimum) {
|
|
46
|
-
const left = versionParts(current);
|
|
47
|
-
const right = versionParts(minimum);
|
|
48
|
-
for (let index = 0; index < 3; index += 1) {
|
|
49
|
-
if (left[index] !== right[index]) return left[index] > right[index];
|
|
50
|
-
}
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function normalizeBaseUrl(value) {
|
|
55
|
-
const url = new URL(value);
|
|
56
|
-
url.pathname = url.pathname.replace(/\/$/, "");
|
|
57
|
-
url.search = "";
|
|
58
|
-
url.hash = "";
|
|
59
|
-
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
60
|
-
throw new CompatibilityError("Accounts must use HTTPS outside local development.");
|
|
61
|
-
}
|
|
62
|
-
return url.toString().replace(/\/$/, "");
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function responseJson(response) {
|
|
66
|
-
try {
|
|
67
|
-
return await response.json();
|
|
68
|
-
} catch {
|
|
69
|
-
throw new AuthProviderError("server_error", { status: response.status });
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function oauthError(payload, response) {
|
|
74
|
-
const code = typeof payload?.error === "string" ? payload.error : "server_error";
|
|
75
|
-
return new AuthProviderError(code, {
|
|
76
|
-
status: response.status,
|
|
77
|
-
requiresLogin: code === "invalid_grant" || code === "access_denied" || code === "expired_token",
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function tokenResponse(payload, response) {
|
|
82
|
-
if (!response.ok) throw oauthError(payload, response);
|
|
83
|
-
if (
|
|
84
|
-
typeof payload?.access_token !== "string" ||
|
|
85
|
-
typeof payload?.refresh_token !== "string" ||
|
|
86
|
-
!Number.isFinite(Number(payload?.expires_in))
|
|
87
|
-
) {
|
|
88
|
-
throw new AuthProviderError("server_error", { status: response.status });
|
|
89
|
-
}
|
|
90
|
-
return {
|
|
91
|
-
accessToken: payload.access_token,
|
|
92
|
-
refreshToken: payload.refresh_token,
|
|
93
|
-
expiresInSeconds: Number(payload.expires_in),
|
|
94
|
-
scopes: typeof payload.scope === "string" ? payload.scope.split(/\s+/).filter(Boolean) : [],
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export class ElixpoAuthProvider extends AuthProvider {
|
|
99
|
-
constructor({
|
|
100
|
-
accountsBaseUrl = "https://accounts.elixpo.com",
|
|
101
|
-
clientId = "lixblogs-cli-prod",
|
|
102
|
-
audience = "blogs.elixpo.com",
|
|
103
|
-
cliVersion = "1.2.0",
|
|
104
|
-
fetchImpl = globalThis.fetch,
|
|
105
|
-
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
106
|
-
} = {}) {
|
|
107
|
-
super();
|
|
108
|
-
if (typeof fetchImpl !== "function") throw new TypeError("A fetch implementation is required.");
|
|
109
|
-
this.accountsBaseUrl = normalizeBaseUrl(accountsBaseUrl);
|
|
110
|
-
this.clientId = clientId;
|
|
111
|
-
this.audience = audience;
|
|
112
|
-
this.cliVersion = cliVersion;
|
|
113
|
-
this.fetchImpl = fetchImpl;
|
|
114
|
-
this.timeoutMs = timeoutMs;
|
|
115
|
-
this._metadata = null;
|
|
116
|
-
this._discoveryPromise = null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
get providerId() {
|
|
120
|
-
return "elixpo";
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async _fetch(url, options = {}) {
|
|
124
|
-
const controller = new AbortController();
|
|
125
|
-
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
126
|
-
try {
|
|
127
|
-
return await this.fetchImpl(url, {
|
|
128
|
-
...options,
|
|
129
|
-
signal: options.signal || controller.signal,
|
|
130
|
-
headers: { accept: "application/json", ...options.headers },
|
|
131
|
-
});
|
|
132
|
-
} catch {
|
|
133
|
-
throw new AuthProviderError("temporarily_unavailable");
|
|
134
|
-
} finally {
|
|
135
|
-
clearTimeout(timeout);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async discover({ scopes = [] } = {}) {
|
|
140
|
-
if (!this._metadata) {
|
|
141
|
-
if (!this._discoveryPromise) this._discoveryPromise = this._loadDiscovery();
|
|
142
|
-
try {
|
|
143
|
-
this._metadata = await this._discoveryPromise;
|
|
144
|
-
} finally {
|
|
145
|
-
this._discoveryPromise = null;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const unsupported = scopes.filter((scope) => !this._metadata.scopes_supported.includes(scope));
|
|
150
|
-
if (unsupported.length) throw new AuthProviderError("invalid_scope");
|
|
151
|
-
return this._metadata;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async _loadDiscovery() {
|
|
155
|
-
const response = await this._fetch(`${this.accountsBaseUrl}/.well-known/oauth-authorization-server`);
|
|
156
|
-
const metadata = await responseJson(response);
|
|
157
|
-
if (!response.ok) throw new AuthProviderError("temporarily_unavailable", { status: response.status });
|
|
158
|
-
|
|
159
|
-
const contractMajor = versionParts(metadata.elixpo_contract_version)[0];
|
|
160
|
-
if (contractMajor !== SUPPORTED_CONTRACT_MAJOR) {
|
|
161
|
-
throw new CompatibilityError("Accounts uses an unsupported device-flow contract version.");
|
|
162
|
-
}
|
|
163
|
-
if (!versionAtLeast(this.cliVersion, metadata.elixpo_min_compatible_cli_version)) {
|
|
164
|
-
throw new CompatibilityError(
|
|
165
|
-
`This CLI is too old for Accounts. Upgrade to version ${metadata.elixpo_min_compatible_cli_version} or newer.`,
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
if (!Array.isArray(metadata.grant_types_supported) || !metadata.grant_types_supported.includes(DEVICE_GRANT)) {
|
|
169
|
-
throw new CompatibilityError("Accounts does not advertise OAuth device authorization.");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const requiredEndpoints = [
|
|
173
|
-
"device_authorization_endpoint",
|
|
174
|
-
"token_endpoint",
|
|
175
|
-
"revocation_endpoint",
|
|
176
|
-
];
|
|
177
|
-
for (const field of requiredEndpoints) {
|
|
178
|
-
if (typeof metadata[field] !== "string") {
|
|
179
|
-
throw new CompatibilityError(`Accounts discovery is missing ${field}.`);
|
|
180
|
-
}
|
|
181
|
-
const endpoint = new URL(metadata[field]);
|
|
182
|
-
const accounts = new URL(this.accountsBaseUrl);
|
|
183
|
-
if (endpoint.origin !== accounts.origin) {
|
|
184
|
-
throw new CompatibilityError(`Accounts discovery returned an untrusted ${field}.`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return {
|
|
189
|
-
...metadata,
|
|
190
|
-
scopes_supported: Array.isArray(metadata.scopes_supported) ? metadata.scopes_supported : [],
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async requestDeviceCode({ scopes }) {
|
|
195
|
-
const metadata = await this.discover({ scopes });
|
|
196
|
-
const response = await this._fetch(metadata.device_authorization_endpoint, {
|
|
197
|
-
method: "POST",
|
|
198
|
-
headers: { "content-type": "application/json" },
|
|
199
|
-
body: JSON.stringify({
|
|
200
|
-
client_id: this.clientId,
|
|
201
|
-
scope: scopes.join(" "),
|
|
202
|
-
audience: this.audience,
|
|
203
|
-
}),
|
|
204
|
-
});
|
|
205
|
-
const payload = await responseJson(response);
|
|
206
|
-
if (!response.ok) throw oauthError(payload, response);
|
|
207
|
-
if (
|
|
208
|
-
typeof payload.device_code !== "string" ||
|
|
209
|
-
typeof payload.user_code !== "string" ||
|
|
210
|
-
typeof payload.verification_uri !== "string"
|
|
211
|
-
) {
|
|
212
|
-
throw new AuthProviderError("server_error", { status: response.status });
|
|
213
|
-
}
|
|
214
|
-
return {
|
|
215
|
-
deviceCode: payload.device_code,
|
|
216
|
-
userCode: payload.user_code,
|
|
217
|
-
verificationUri: payload.verification_uri,
|
|
218
|
-
verificationUriComplete: payload.verification_uri_complete || payload.verification_uri,
|
|
219
|
-
expiresInSeconds: Number(payload.expires_in) || 600,
|
|
220
|
-
pollIntervalSeconds: Number(payload.interval) || 5,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async pollDeviceCode({ deviceCode }) {
|
|
225
|
-
const metadata = await this.discover();
|
|
226
|
-
const body = new URLSearchParams({
|
|
227
|
-
grant_type: DEVICE_GRANT,
|
|
228
|
-
device_code: deviceCode,
|
|
229
|
-
client_id: this.clientId,
|
|
230
|
-
});
|
|
231
|
-
const response = await this._fetch(metadata.token_endpoint, {
|
|
232
|
-
method: "POST",
|
|
233
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
234
|
-
body,
|
|
235
|
-
});
|
|
236
|
-
const payload = await responseJson(response);
|
|
237
|
-
if (response.ok) return { status: "approved", token: tokenResponse(payload, response) };
|
|
238
|
-
if (payload?.error === "authorization_pending") return { status: "pending" };
|
|
239
|
-
if (payload?.error === "slow_down") {
|
|
240
|
-
const polling = metadata.elixpo_device_flow_polling || {};
|
|
241
|
-
const increase = Math.max(
|
|
242
|
-
5,
|
|
243
|
-
Number(polling.slow_down_interval_seconds || 10) - Number(polling.interval_seconds || 5),
|
|
244
|
-
);
|
|
245
|
-
return { status: "slow_down", pollIntervalIncreaseSeconds: increase };
|
|
246
|
-
}
|
|
247
|
-
if (payload?.error === "access_denied") return { status: "denied" };
|
|
248
|
-
if (payload?.error === "expired_token") return { status: "expired" };
|
|
249
|
-
throw oauthError(payload, response);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
async refresh({ refreshToken, scopes }) {
|
|
253
|
-
const metadata = await this.discover({ scopes: scopes || [] });
|
|
254
|
-
const body = new URLSearchParams({
|
|
255
|
-
grant_type: "refresh_token",
|
|
256
|
-
refresh_token: refreshToken,
|
|
257
|
-
client_id: this.clientId,
|
|
258
|
-
});
|
|
259
|
-
if (scopes?.length) body.set("scope", scopes.join(" "));
|
|
260
|
-
const response = await this._fetch(metadata.token_endpoint, {
|
|
261
|
-
method: "POST",
|
|
262
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
263
|
-
body,
|
|
264
|
-
});
|
|
265
|
-
const payload = await responseJson(response);
|
|
266
|
-
return tokenResponse(payload, response);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async revoke({ token }) {
|
|
270
|
-
const metadata = await this.discover();
|
|
271
|
-
const response = await this._fetch(metadata.revocation_endpoint, {
|
|
272
|
-
method: "POST",
|
|
273
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
274
|
-
body: new URLSearchParams({ token, client_id: this.clientId }),
|
|
275
|
-
});
|
|
276
|
-
if (!response.ok) {
|
|
277
|
-
const payload = await responseJson(response);
|
|
278
|
-
throw oauthError(payload, response);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { AuthProvider } from "./AuthProvider.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* MockAuthProvider — deterministic, in-memory device-flow simulation for
|
|
5
|
-
* development and tests. Never talks to a real server.
|
|
6
|
-
*
|
|
7
|
-
* Per maintainer direction: this exists so CLI/UI work isn't blocked while
|
|
8
|
-
* accounts.elixpo.com's device-flow support has no confirmed ETA. This must
|
|
9
|
-
* never be reachable in a production configuration — see
|
|
10
|
-
* assertNotProduction() in productionGate.js, which every provider
|
|
11
|
-
* constructor call should be routed through at the call site.
|
|
12
|
-
*
|
|
13
|
-
* States simulated (mirroring "Device login, refresh, logout, revocation,
|
|
14
|
-
* expiry, denial, and polling errors are tested" from #135's acceptance
|
|
15
|
-
* criteria):
|
|
16
|
-
* - approved (successful login)
|
|
17
|
-
* - pending (still polling)
|
|
18
|
-
* - denied
|
|
19
|
-
* - expired
|
|
20
|
-
* - invalid/unknown device code
|
|
21
|
-
* - slow_down (RFC 8628 §3.5 style rate-limit signal)
|
|
22
|
-
* - refresh success / refresh failure
|
|
23
|
-
* - revoke
|
|
24
|
-
*
|
|
25
|
-
* Scenario selection is deterministic and driven by the deviceCode's prefix,
|
|
26
|
-
* not randomness — so tests are reproducible. See SCENARIO_PREFIX below.
|
|
27
|
-
*
|
|
28
|
-
* --- Open questions from elixpo/blogs.elixpo#137, resolved by implementer ---
|
|
29
|
-
* - Mock states: slow_down added (see above), matching RFC 8628 rather than
|
|
30
|
-
* inventing a bespoke rate-limit shape, so the real ElixpoAuthProvider can
|
|
31
|
-
* follow the same contract later.
|
|
32
|
-
* - Polling/backoff: on slow_down, caller must increase its poll interval by
|
|
33
|
-
* SLOW_DOWN_INTERVAL_INCREASE_SECONDS before polling again. No other
|
|
34
|
-
* backoff logic in the mock itself — backoff is the CLI's responsibility,
|
|
35
|
-
* not the provider's.
|
|
36
|
-
* Flagged for the maintainer to override if a different behavior is wanted.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
const SCENARIO_PREFIX = {
|
|
40
|
-
APPROVE_IMMEDIATELY: "mock-approve-",
|
|
41
|
-
PENDING_THEN_APPROVE: "mock-pending-then-approve-",
|
|
42
|
-
DENY: "mock-deny-",
|
|
43
|
-
EXPIRE: "mock-expire-",
|
|
44
|
-
SLOW_DOWN_THEN_APPROVE: "mock-slow-down-then-approve-",
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
// Mirrors RFC 8628 §3.5: on slow_down, the client must increase its polling
|
|
48
|
-
// interval by this many seconds. Real ElixpoAuthProvider should follow the
|
|
49
|
-
// same contract so CLI polling logic doesn't need a provider-specific branch.
|
|
50
|
-
const SLOW_DOWN_INTERVAL_INCREASE_SECONDS = 5;
|
|
51
|
-
|
|
52
|
-
let counter = 0;
|
|
53
|
-
function nextId(prefix) {
|
|
54
|
-
counter += 1;
|
|
55
|
-
return `${prefix}${counter}`;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export class MockAuthProvider extends AuthProvider {
|
|
59
|
-
constructor() {
|
|
60
|
-
super();
|
|
61
|
-
/** @type {Map<string, { scenario: string, pollCount: number, createdAt: number, scopes: string[] }>} */
|
|
62
|
-
this._devicesCodes = new Map();
|
|
63
|
-
/** @type {Set<string>} revoked tokens */
|
|
64
|
-
this._revoked = new Set();
|
|
65
|
-
/** @type {Set<string>} tokens that will fail on next refresh (for testing refresh failure) */
|
|
66
|
-
this._refreshWillFail = new Set();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
get providerId() {
|
|
70
|
-
return "mock";
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* @param {{ scopes: string[], scenario?: keyof typeof SCENARIO_PREFIX }} params
|
|
75
|
-
* `scenario` lets tests/dev deterministically choose which path this
|
|
76
|
-
* device code will take. Defaults to APPROVE_IMMEDIATELY.
|
|
77
|
-
*/
|
|
78
|
-
async requestDeviceCode({ scopes, scenario = "APPROVE_IMMEDIATELY" }) {
|
|
79
|
-
const prefix = SCENARIO_PREFIX[scenario] ?? SCENARIO_PREFIX.APPROVE_IMMEDIATELY;
|
|
80
|
-
const deviceCode = nextId(prefix);
|
|
81
|
-
const userCode = deviceCode.slice(-6).toUpperCase();
|
|
82
|
-
|
|
83
|
-
this._devicesCodes.set(deviceCode, {
|
|
84
|
-
scenario,
|
|
85
|
-
pollCount: 0,
|
|
86
|
-
createdAt: Date.now(),
|
|
87
|
-
scopes: [...scopes],
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
deviceCode,
|
|
92
|
-
userCode,
|
|
93
|
-
verificationUri: "https://mock.lixblogs.local/device",
|
|
94
|
-
verificationUriComplete: `https://mock.lixblogs.local/device?user_code=${encodeURIComponent(userCode)}`,
|
|
95
|
-
expiresInSeconds: scenario === "EXPIRE" ? 1 : 600,
|
|
96
|
-
pollIntervalSeconds: 1,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async pollDeviceCode({ deviceCode }) {
|
|
101
|
-
const record = this._devicesCodes.get(deviceCode);
|
|
102
|
-
|
|
103
|
-
if (!record) {
|
|
104
|
-
// Unknown/invalid code — distinct from "expired": this code never existed.
|
|
105
|
-
return { status: "denied" };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (record.scenario === "EXPIRE") {
|
|
109
|
-
return { status: "expired" };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (record.scenario === "DENY") {
|
|
113
|
-
return { status: "denied" };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (record.scenario === "PENDING_THEN_APPROVE") {
|
|
117
|
-
record.pollCount += 1;
|
|
118
|
-
if (record.pollCount < 2) {
|
|
119
|
-
return { status: "pending" };
|
|
120
|
-
}
|
|
121
|
-
// fall through to approve on the 2nd+ poll
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (record.scenario === "SLOW_DOWN_THEN_APPROVE") {
|
|
125
|
-
record.pollCount += 1;
|
|
126
|
-
if (record.pollCount < 2) {
|
|
127
|
-
return {
|
|
128
|
-
status: "slow_down",
|
|
129
|
-
pollIntervalIncreaseSeconds: SLOW_DOWN_INTERVAL_INCREASE_SECONDS,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
// fall through to approve on the 2nd+ poll
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
status: "approved",
|
|
137
|
-
token: {
|
|
138
|
-
accessToken: `mock-access-${deviceCode}`,
|
|
139
|
-
refreshToken: `mock-refresh-${deviceCode}`,
|
|
140
|
-
expiresInSeconds: 3600,
|
|
141
|
-
scopes: record.scopes,
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async refresh({ refreshToken, scopes = [] }) {
|
|
147
|
-
if (this._revoked.has(refreshToken)) {
|
|
148
|
-
throw new Error("refresh token has been revoked");
|
|
149
|
-
}
|
|
150
|
-
if (this._refreshWillFail.has(refreshToken)) {
|
|
151
|
-
throw new Error("mock refresh failure (test-injected)");
|
|
152
|
-
}
|
|
153
|
-
return {
|
|
154
|
-
accessToken: `mock-access-refreshed-${refreshToken}`,
|
|
155
|
-
refreshToken,
|
|
156
|
-
expiresInSeconds: 3600,
|
|
157
|
-
scopes,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
async revoke({ token }) {
|
|
162
|
-
// Must not throw if already revoked — revocation is idempotent.
|
|
163
|
-
this._revoked.add(token);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Test helper: force the next refresh() call for this token to fail. */
|
|
167
|
-
_simulateRefreshFailureFor(refreshToken) {
|
|
168
|
-
this._refreshWillFail.add(refreshToken);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Production safety gate.
|
|
3
|
-
*
|
|
4
|
-
* Maintainer's explicit requirement (verbatim):
|
|
5
|
-
* "Production login must remain explicitly unavailable until the real
|
|
6
|
-
* issuer, polling, refresh, scope, and revocation contract is approved —
|
|
7
|
-
* no copied cookies or fallback credentials."
|
|
8
|
-
*
|
|
9
|
-
* This must fail loudly and immediately — not silently degrade, not warn
|
|
10
|
-
* and continue. Every call site that constructs an AuthProvider must route
|
|
11
|
-
* through assertProviderAllowed() first.
|
|
12
|
-
*
|
|
13
|
-
* Accounts now publishes the approved RFC 8628 contract, so production is
|
|
14
|
-
* enabled only for ElixpoAuthProvider. The deterministic mock remains usable
|
|
15
|
-
* in explicit development/test environments and can never cross this gate.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
const APPROVED_PRODUCTION_PROVIDER_ID = "elixpo";
|
|
19
|
-
|
|
20
|
-
export class ProductionAuthGateError extends Error {
|
|
21
|
-
constructor(message) {
|
|
22
|
-
super(message);
|
|
23
|
-
this.name = "ProductionAuthGateError";
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* @param {{ providerId: string, environment: string }} params
|
|
29
|
-
* `environment` should come from explicit config, not inferred/guessed.
|
|
30
|
-
*/
|
|
31
|
-
export function assertProviderAllowed({ providerId, environment }) {
|
|
32
|
-
const isProduction = environment === "production";
|
|
33
|
-
|
|
34
|
-
if (!isProduction) {
|
|
35
|
-
return; // any provider (including mock) is fine outside production
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (providerId !== APPROVED_PRODUCTION_PROVIDER_ID) {
|
|
39
|
-
throw new ProductionAuthGateError(
|
|
40
|
-
`Provider "${providerId}" is not approved for production. ` +
|
|
41
|
-
`Only "${APPROVED_PRODUCTION_PROVIDER_ID}" may be used in production.`
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
}
|
package/src/cli/contract.js
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
export const EXIT_CODES = Object.freeze({
|
|
2
|
-
OK: 0,
|
|
3
|
-
ERROR: 1,
|
|
4
|
-
USAGE: 2,
|
|
5
|
-
CONFLICT: 3,
|
|
6
|
-
AUTH: 4,
|
|
7
|
-
CONFIRMATION: 5,
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
const TOP_LEVEL_ALIASES = Object.freeze({
|
|
11
|
-
login: ['auth', 'login'],
|
|
12
|
-
logout: ['auth', 'logout'],
|
|
13
|
-
whoami: ['auth', 'whoami'],
|
|
14
|
-
profiles: ['auth', 'profiles'],
|
|
15
|
-
use: ['auth', 'use'],
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
export function normalizeCommand(positionals) {
|
|
19
|
-
const [command, ...rest] = positionals;
|
|
20
|
-
const alias = TOP_LEVEL_ALIASES[command];
|
|
21
|
-
return alias ? [...alias, ...rest] : positionals;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function errorEnvelope(error, fallbackCode = 'cli_error') {
|
|
25
|
-
if (error && typeof error === 'object' && error.error && !Array.isArray(error.error)) return error;
|
|
26
|
-
const value = error && typeof error === 'object' ? error : { message: String(error || 'Command failed.') };
|
|
27
|
-
return {
|
|
28
|
-
ok: false,
|
|
29
|
-
error: {
|
|
30
|
-
code: value.code || fallbackCode,
|
|
31
|
-
message: value.message || 'Command failed.',
|
|
32
|
-
hint: value.hint || null,
|
|
33
|
-
requestId: value.requestId || null,
|
|
34
|
-
...(value.details ? { details: value.details } : {}),
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function requireConfirmation(options, action) {
|
|
40
|
-
if (options.yes) return;
|
|
41
|
-
const error = new Error(`${action} requires --yes in non-interactive operation.`);
|
|
42
|
-
error.code = 'confirmation_required';
|
|
43
|
-
error.hint = `Review the operation, then run it again with --yes.`;
|
|
44
|
-
error.exitCode = EXIT_CODES.CONFIRMATION;
|
|
45
|
-
throw error;
|
|
46
|
-
}
|
package/src/cli/ui.js
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
const ANSI = Object.freeze({
|
|
2
|
-
reset: "\u001b[0m",
|
|
3
|
-
bold: "\u001b[1m",
|
|
4
|
-
dim: "\u001b[2m",
|
|
5
|
-
violet: "\u001b[38;5;141m",
|
|
6
|
-
green: "\u001b[38;5;42m",
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
export function colorEnabled(stream = process.stdout, env = process.env) {
|
|
10
|
-
return Boolean(stream.isTTY) && env.NO_COLOR === undefined && env.TERM !== "dumb";
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function paint(value, code, enabled) {
|
|
14
|
-
return enabled ? `${code}${value}${ANSI.reset}` : value;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function loginChallenge({ url, code, expiresInSeconds, profile, interactive, color = false }) {
|
|
18
|
-
const title = `${paint("◆", ANSI.violet, color)} ${paint("LixBlogs", ANSI.bold, color)}`;
|
|
19
|
-
const instruction = interactive
|
|
20
|
-
? "Press Enter to open here, or use the URL on another device."
|
|
21
|
-
: "Open the URL in any browser and approve this device.";
|
|
22
|
-
return [
|
|
23
|
-
"",
|
|
24
|
-
` ${title}`,
|
|
25
|
-
` ${paint("Device login", ANSI.dim, color)}`,
|
|
26
|
-
" ─────────────────────────────────────────",
|
|
27
|
-
` URL ${url}`,
|
|
28
|
-
` Code ${paint(code, ANSI.bold, color)}`,
|
|
29
|
-
` Expires ${Math.ceil(expiresInSeconds / 60)} min`,
|
|
30
|
-
profile
|
|
31
|
-
? ` Profile ${profile} ${paint("(local credential slot)", ANSI.dim, color)}`
|
|
32
|
-
: ` Profile ${paint("your Accounts username after approval", ANSI.dim, color)}`,
|
|
33
|
-
"",
|
|
34
|
-
` ${instruction}`,
|
|
35
|
-
" No localhost callback or exposed port is required.",
|
|
36
|
-
"",
|
|
37
|
-
].join("\n");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function successLine(message, color = false) {
|
|
41
|
-
return ` ${paint("✓", ANSI.green, color)} ${message}`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function listenForEnter({ input = process.stdin, open, url }) {
|
|
45
|
-
if (!input.isTTY || typeof open !== "function") return () => {};
|
|
46
|
-
const onData = () => { Promise.resolve(open(url)).catch(() => {}); };
|
|
47
|
-
input.setEncoding?.("utf8");
|
|
48
|
-
input.once("data", onData);
|
|
49
|
-
input.resume?.();
|
|
50
|
-
return () => {
|
|
51
|
-
input.off?.("data", onData);
|
|
52
|
-
input.pause?.();
|
|
53
|
-
};
|
|
54
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { writeFile } from 'node:fs/promises';
|
|
2
|
-
|
|
3
|
-
const DIMENSIONS = new Set(['overview', 'timeline', 'posts', 'sources', 'devices', 'countries']);
|
|
4
|
-
const RANGES = new Set(['7d', '30d', '90d', '12m', 'custom']);
|
|
5
|
-
|
|
6
|
-
function normalizedOptions(options = {}) {
|
|
7
|
-
const dimension = options.dimension || 'overview';
|
|
8
|
-
const range = options.range || (options.from || options.to ? 'custom' : '30d');
|
|
9
|
-
if (!DIMENSIONS.has(dimension)) throw new Error(`Unsupported analytics dimension: ${dimension}.`);
|
|
10
|
-
if (!RANGES.has(range)) throw new Error(`Unsupported analytics range: ${range}.`);
|
|
11
|
-
if (range === 'custom' && (!options.from || !options.to)) throw new Error('Custom analytics ranges require --from and --to.');
|
|
12
|
-
return {
|
|
13
|
-
scope: options.scope?.[0] || options.publication || 'personal',
|
|
14
|
-
range,
|
|
15
|
-
from: options.from,
|
|
16
|
-
to: options.to,
|
|
17
|
-
dimension,
|
|
18
|
-
limit: options.limit,
|
|
19
|
-
cursor: options.cursor,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function analyticsQuery({ client, options }) {
|
|
24
|
-
return client.query(normalizedOptions(options));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function csvCell(value) {
|
|
28
|
-
const text = value === null || value === undefined ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
29
|
-
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function rowsFromPayload(payload) {
|
|
33
|
-
const values = payload?.data?.values;
|
|
34
|
-
if (Array.isArray(values)) return values;
|
|
35
|
-
if (values?.labels && Array.isArray(values.labels)) {
|
|
36
|
-
return values.labels.map((label, index) => ({ label, views: values.views?.[index] || 0, reads: values.reads?.[index] || 0 }));
|
|
37
|
-
}
|
|
38
|
-
if (values?.totals) return Object.entries(values.totals).map(([metric, value]) => ({ metric, value, previous: values.previous?.[metric], change: values.changes?.[metric] }));
|
|
39
|
-
return [];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function analyticsExport({ client, options }) {
|
|
43
|
-
if (!options.output) throw new Error('Analytics export requires --output <file>.');
|
|
44
|
-
const format = options.format || 'json';
|
|
45
|
-
if (!['json', 'csv'].includes(format)) throw new Error('Analytics export format must be json or csv.');
|
|
46
|
-
const payload = await client.query(normalizedOptions(options));
|
|
47
|
-
let content;
|
|
48
|
-
if (format === 'json') {
|
|
49
|
-
content = `${JSON.stringify(payload, null, 2)}\n`;
|
|
50
|
-
} else {
|
|
51
|
-
const rows = rowsFromPayload(payload);
|
|
52
|
-
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
53
|
-
content = `${columns.map(csvCell).join(',')}\n${rows.map((row) => columns.map((column) => csvCell(row[column])).join(',')).join('\n')}\n`;
|
|
54
|
-
}
|
|
55
|
-
await writeFile(options.output, content, { encoding: 'utf8', flag: 'wx' });
|
|
56
|
-
return { output: options.output, format, rows: rowsFromPayload(payload).length };
|
|
57
|
-
}
|